# Copyright (c) Facebook, Inc. and its affiliates.
"""
This file contains primitives for multi-gpu communication.
This is useful when doing distributed training.
Modified from detectron2(https://github.com/facebookresearch/detectron2)
Copyright (c) Xiaoyang Wu (xiaoyang.wu@connect.hku.hk). All Rights Reserved.
Please cite our work if you use any part of the code.
"""
import functools
import os
import logging
import numpy as np
import torch
import torch.distributed as dist
_LOCAL_PROCESS_GROUP = None
"""
A torch process group which only includes processes that on the same machine as the current process.
This variable is set by setup_distributed() when running under torchrun or Slurm.
"""
def _parse_tasks_per_node(value: str, default_nodes: int):
"""Parse SLURM *_TASKS_PER_NODE strings into one task count per node."""
if not value:
return [1] * max(1, default_nodes)
value = value.strip()
parts = [p.strip() for p in value.split(',') if p.strip()]
result = []
for p in parts:
if '(x' in p:
# format N(xK)
try:
n_str, rep = p.split('(x')
n = int(n_str)
k = int(rep.rstrip(')'))
result.extend([n] * k)
except Exception:
continue
else:
try:
result.append(int(p))
except Exception:
continue
if not result:
result = [1] * max(1, default_nodes)
# if a single number and multiple nodes, replicate
if len(result) == 1 and default_nodes > 1:
result = [result[0]] * default_nodes
return result
def get_slurm_env():
"""Extract the SLURM fields needed to initialize torch.distributed."""
nnodes = int(os.environ.get('SLURM_NNODES', 1))
tpn_str = (
os.environ.get('SLURM_TASKS_PER_NODE')
or os.environ.get('SLURM_STEP_TASKS_PER_NODE')
or os.environ.get('SLURM_NTASKS_PER_NODE')
or ''
)
tasks_per_node_list = _parse_tasks_per_node(tpn_str, nnodes)
try:
ntasks_per_node = int(os.environ.get('SLURM_NTASKS_PER_NODE', ''))
except Exception:
ntasks_per_node = tasks_per_node_list[0] if tasks_per_node_list else 1
return {
'job_id': os.environ.get('SLURM_JOB_ID'),
'ntasks': int(os.environ.get('SLURM_NTASKS', 1)),
'ntasks_per_node': ntasks_per_node,
'tasks_per_node_list': tasks_per_node_list,
'nnodes': nnodes,
'node_id': int(os.environ.get('SLURM_NODEID', 0)),
'proc_id': int(os.environ.get('SLURM_PROCID', 0)),
'local_id': int(os.environ.get('SLURM_LOCALID', 0)),
'nodelist': os.environ.get('SLURM_NODELIST', '127.0.0.1'),
}
def _resolve_ipv4(host: str) -> str:
"""Resolve a host to a routable IPv4, returning it unchanged on failure.
A bare node hostname can resolve to a non-routable IPv6 link-local address
(fe80::...) on the node itself, which breaks the cross-node c10d rendezvous;
force IPv4. An address that is already an IP passes through unchanged.
"""
import socket
try:
return socket.getaddrinfo(host, None, socket.AF_INET)[0][4][0]
except (socket.gaierror, IndexError, OSError):
return host
def setup_distributed():
"""Initialize torch.distributed from torchrun or SLURM environment."""
logger = logging.getLogger(__name__)
if "SLURM_PROCID" not in os.environ and "RANK" not in os.environ:
logger.info("No distributed environment found")
return
# Prefer torchrun's explicit rank env, then fall back to SLURM variables.
slurm_env = get_slurm_env() if "SLURM_PROCID" in os.environ else None
if "RANK" in os.environ and "WORLD_SIZE" in os.environ:
world_size = int(os.environ.get('WORLD_SIZE', 1))
rank = int(os.environ.get('RANK', 0))
local_rank = int(os.environ.get('LOCAL_RANK', 0))
local_world_size = int(os.environ.get('LOCAL_WORLD_SIZE', world_size))
# One entry per node, not just this node: the per-node process group used
# by get_local_rank() is built by partitioning [0, world_size) with this
# list. A single-element [local_world_size] only builds node 0's group, so
# every other node falls through to the single-rank fallback and reports
# local_rank=0 for ALL its ranks -> they collide on cuda:0 ("Duplicate GPU
# detected"). Replicate across all nodes.
num_nodes = max(1, world_size // max(1, local_world_size))
tasks_per_node_list = [local_world_size] * num_nodes
node_id = int(os.environ.get('GROUP_RANK', os.environ.get('SLURM_NODEID', 0)))
elif slurm_env is not None:
world_size = slurm_env['ntasks']
rank = slurm_env['proc_id']
local_rank = slurm_env['local_id']
tasks_per_node_list = slurm_env.get('tasks_per_node_list', [world_size])
node_id = slurm_env['node_id']
else:
world_size = 1
rank = 0
local_rank = 0
tasks_per_node_list = [1]
node_id = 0
# Set CUDA device while respecting CUDA_VISIBLE_DEVICES.
num_visible = torch.cuda.device_count()
if num_visible == 0:
raise RuntimeError("no CUDA devices available")
device_index = local_rank if num_visible > 1 else 0
if device_index >= num_visible:
device_index = device_index % num_visible
torch.cuda.set_device(device_index)
# Get master address from SLURM_NODELIST when torchrun/env did not set it.
if 'MASTER_ADDR' not in os.environ:
if slurm_env is not None:
nodelist = slurm_env['nodelist']
if '[' in nodelist:
base = nodelist.split('[')[0]
indices = nodelist.split('[')[1].split(']')[0]
first_index = indices.split(',')[0].split('-')[0]
master_addr = f"{base}{first_index}"
elif ',' in nodelist:
master_addr = nodelist.split(',')[0]
else:
master_addr = nodelist
else:
master_addr = '127.0.0.1'
os.environ['MASTER_ADDR'] = master_addr
# Normalize to a routable IPv4 whether derived above or set by the launcher
# (submitit/torchrun) -- a bare hostname can resolve to an IPv6 link-local
# on-node and break cross-node rendezvous.
os.environ['MASTER_ADDR'] = _resolve_ipv4(os.environ['MASTER_ADDR'])
# Derive a stable port for SLURM jobs so all ranks rendezvous together.
if 'MASTER_PORT' not in os.environ:
if slurm_env is not None:
job_id = slurm_env['job_id']
if job_id and len(job_id) >= 4:
port = 20000 + int(job_id[-4:]) % 10000
else:
port = 29500
else:
port = 29500
os.environ['MASTER_PORT'] = str(port)
logger.info("Initializing distributed training:")
logger.info(f" - World size: {world_size}")
logger.info(f" - Rank: {rank}")
logger.info(f" - Local rank: {local_rank}")
logger.info(f" - Master: {os.environ['MASTER_ADDR']}:{os.environ['MASTER_PORT']}")
# Initialize the global NCCL process group.
dist.init_process_group(
backend='nccl',
world_size=world_size,
rank=rank,
)
# Build a same-node group for local rank and local size queries.
global _LOCAL_PROCESS_GROUP
_LOCAL_PROCESS_GROUP = None
offset = 0
for i, tpn in enumerate(tasks_per_node_list):
if offset >= world_size:
break
ranks_on_node = list(range(offset, min(offset + tpn, world_size)))
if ranks_on_node:
pg = dist.new_group(ranks_on_node)
if i == node_id:
_LOCAL_PROCESS_GROUP = pg
offset += tpn
# Fallback if logic didn't set it (e.g. strange task/node mismatch)
if _LOCAL_PROCESS_GROUP is None:
_LOCAL_PROCESS_GROUP = dist.new_group([rank])
return True
[docs]
def get_world_size() -> int:
"""Return the initialized distributed world size, or 1."""
if not dist.is_available():
return 1
if not dist.is_initialized():
return 1
return dist.get_world_size()
[docs]
def get_rank() -> int:
"""Return the initialized distributed rank, or 0."""
if not dist.is_available():
return 0
if not dist.is_initialized():
return 0
return dist.get_rank()
def get_local_rank() -> int:
"""
Returns:
The rank of the current process within the local (per-machine) process group.
"""
if not dist.is_available():
return 0
if not dist.is_initialized():
return 0
assert (
_LOCAL_PROCESS_GROUP is not None
), "Local process group is not created! Please initialize distributed state first."
return dist.get_rank(group=_LOCAL_PROCESS_GROUP)
def get_local_size() -> int:
"""
Returns:
The size of the per-machine process group,
i.e. the number of processes per machine.
"""
if not dist.is_available():
return 1
if not dist.is_initialized():
return 1
return dist.get_world_size(group=_LOCAL_PROCESS_GROUP)
[docs]
def is_main_process() -> bool:
"""Return whether this process is global rank zero."""
return get_rank() == 0
[docs]
def synchronize():
"""
Helper function to synchronize (barrier) among all processes when
using distributed training
"""
if not dist.is_available():
return
if not dist.is_initialized():
return
world_size = dist.get_world_size()
if world_size == 1:
return
if dist.get_backend() == dist.Backend.NCCL:
# This argument is needed to avoid warnings.
# It's valid only for NCCL backend.
dist.barrier(device_ids=[torch.cuda.current_device()])
else:
dist.barrier()
@functools.lru_cache()
def _get_global_gloo_group():
"""
Return a process group based on gloo backend, containing all the ranks
The result is cached.
"""
if dist.get_backend() == "nccl":
return dist.new_group(backend="gloo")
else:
return dist.group.WORLD
[docs]
def all_gather(data, group=None):
"""
Run all_gather on arbitrary picklable data (not necessarily tensors).
Args:
data: any picklable object
group: a torch process group. By default, will use a group which
contains all ranks on gloo backend.
Returns:
list[data]: list of data gathered from each rank
"""
if get_world_size() == 1:
return [data]
if group is None:
group = (
_get_global_gloo_group()
) # use CPU group by default, to reduce GPU RAM usage.
world_size = dist.get_world_size(group)
if world_size == 1:
return [data]
output = [None for _ in range(world_size)]
dist.all_gather_object(output, data, group=group)
return output
def gather(data, dst=0, group=None):
"""
Run gather on arbitrary picklable data (not necessarily tensors).
Args:
data: any picklable object
dst (int): destination rank
group: a torch process group. By default, will use a group which
contains all ranks on gloo backend.
Returns:
list[data]: on dst, a list of data gathered from each rank. Otherwise,
an empty list.
"""
if get_world_size() == 1:
return [data]
if group is None:
group = _get_global_gloo_group()
world_size = dist.get_world_size(group=group)
if world_size == 1:
return [data]
rank = dist.get_rank(group=group)
if rank == dst:
output = [None for _ in range(world_size)]
dist.gather_object(data, output, dst=dst, group=group)
return output
else:
dist.gather_object(data, None, dst=dst, group=group)
return []
def shared_random_seed():
"""
Returns:
int: a random number that is the same across all workers.
If workers need a shared RNG, they can use this shared seed to
create one.
All workers must call this function, otherwise it will deadlock.
"""
ints = np.random.randint(2**31)
all_ints = all_gather(ints)
return all_ints[0]
[docs]
def reduce_dict(input_dict, average=True):
"""
Reduce the values in the dictionary from all processes so that process with rank
0 has the reduced results.
Args:
input_dict (dict): inputs to be reduced. All the values must be scalar CUDA Tensor.
average (bool): whether to do average or sum
Returns:
a dict with the same keys as input_dict, after reduction.
"""
world_size = get_world_size()
if world_size < 2:
return input_dict
with torch.no_grad():
names = []
values = []
# sort the keys so that they are consistent across processes
for k in sorted(input_dict.keys()):
names.append(k)
values.append(input_dict[k])
values = torch.stack(values, dim=0)
dist.reduce(values, dst=0)
if dist.get_rank() == 0 and average:
# only main process gets accumulated, so only divide by
# world_size in this case
values /= world_size
reduced_dict = {k: v for k, v in zip(names, values)}
return reduced_dict
# Scalar output keys whose values are per-rank COUNTS (summed over the local
# batch). They must be reduced by SUM to report a true global total; averaging
# them makes the logged number shrink as 1/world_size when GPUs are added.
_COUNT_KEY_SUFFIXES = (
"num_pairs",
"queries_total",
"gt_instances_total",
"unmatched_queries",
"unmatched_gt",
)
def reduce_scalar_outputs_for_logging(return_dict, skip_keys=("loss",)):
"""All-reduce 0-dim scalar model outputs in place so logged metrics are
world-size invariant.
For every scalar tensor in ``return_dict`` (skipping ``skip_keys`` and any
non-scalar value):
* keys ending in a known COUNT suffix -> reduce by SUM (true global total)
* all other scalars (loss components) -> reduce by MEAN (global average)
Reduced values are detached -- these are for logging only.
``skip_keys`` defaults to ``("loss",)``: the training loss is a per-event
mean on the local batch and is backpropagated, so DDP already averages its
gradient across ranks. Reducing it here would rescale the gradient by
world_size.
"""
world_size = get_world_size()
if world_size < 2:
return return_dict
with torch.no_grad():
for key, value in return_dict.items():
if key in skip_keys:
continue
if not (isinstance(value, torch.Tensor) and value.ndim == 0):
continue
synced = value.detach().clone()
dist.all_reduce(synced, op=dist.ReduceOp.SUM)
if not key.endswith(_COUNT_KEY_SUFFIXES):
synced = synced / world_size
return_dict[key] = synced
return return_dict
def cleanup_distributed():
"""Destroy the distributed process group if initialized."""
if not torch.distributed.is_available():
return
if not torch.distributed.is_initialized():
return
if get_world_size() > 1:
try:
synchronize()
except Exception:
pass
try:
torch.distributed.destroy_process_group()
except Exception:
pass