多 GPU 仿真与训练#
Genesis World 沿两个相互独立的维度扩展。在单张 GPU 内,一个场景以批处理 并行环境 的方式同时运行同一个世界的许多副本。跨 GPU 时,每个设备一个进程,各自运行绑定到单张 GPU 的场景。本页讨论第二个维度:如何把工作分摊到多张 GPU,以及如何与数据并行训练结合。
建议只在用满第一张 GPU 之后再加第二张。单张现代 GPU 就能并行运行数千个环境,而把它们批处理起来比跨进程边界既更简单也更快。当你需要比单张 GPU 显存能容纳的更多环境,或者数据并行训练需要每个设备一个 worker 时,才需要横向扩展。
有两个可运行示例是下面这些模式的权威来源:
examples/rigid/multi_gpu.py:每张 GPU 一个仿真进程,用multiprocessing启动。examples/rigid/ddp_multi_gpu.py:使用 PyTorch DDP 和torchrun在多张 GPU 上进行数据并行训练。
每 GPU 一个进程的模型#
Genesis World 不会把单个场景拆分到多张 GPU。每个进程初始化自己的 Genesis 运行时,构建自己的场景,并且只在一张设备上运行。可以运行多个这样的进程,让每个进程绑定到不同的 GPU。
用以下环境变量固定每个进程,并且要在 gs.init() 运行之前设置,因为它们决定了 Genesis、其编译器以及渲染器在初始化时绑定到哪张设备:
CUDA_VISIBLE_DEVICES: 限制 CUDA 运行时与 PyTorch 能看到哪些物理 GPU。将其设为单个索引,使进程只看到一张设备,之后该设备在进程内以cuda:0访问。QD_VISIBLE_DEVICE: 为 Quadrants 选择 GPU,Quadrants 是生成并运行 Genesis World 内核的编译器。EGL_DEVICE_ID: 选择用于离屏(EGL)渲染的 GPU,仅在进程需要在 GPU 上渲染图像时才有意义。
把 CUDA_VISIBLE_DEVICES 和 QD_VISIBLE_DEVICE 一起设为同一个索引,这样仿真和所有 PyTorch 张量都会落在同一张设备上。每个进程只能看到那一张 GPU,因此在进程内部始终以索引 0 引用它。
每张 GPU 运行一个进程#
多进程示例会为每张 GPU 派生一个 worker。每个 worker 设置自己的设备变量,然后运行一个普通的单 GPU Genesis 程序:
def run(gpu_id, func):
# Pin this process to one physical GPU before Genesis initializes.
os.environ["CUDA_VISIBLE_DEVICES"] = str(gpu_id)
os.environ["QD_VISIBLE_DEVICE"] = str(gpu_id)
os.environ["EGL_DEVICE_ID"] = str(gpu_id)
func()
num_gpus = 2
processes = []
for i in range(num_gpus):
p = multiprocessing.Process(target=run, args=(i, main))
processes.append(p)
p.start()
main() 的主体是一个普通仿真(gs.init(backend=gs.gpu)、构建场景、步进),其中没有任何与 GPU 索引相关的内容。隔离完全由父进程为每个子进程设置的环境变量实现。
这种模式适合高度并行的任务:彼此独立的 rollout、参数扫描或数据生成,这些进程从不需要交换梯度。当需要交换梯度时,请改用 DDP。
使用 PyTorch DDP 进行数据并行训练#
examples/rigid/ddp_multi_gpu.py 使用 PyTorch DistributedDataParallel 跨 GPU 训练策略。每个 rank 拥有一个完整的 Genesis 场景及其自己的环境批次;DDP 在每个 rank 上保留一份模型副本,并在每次反向传播时跨 rank 平均梯度。有效批大小为单 GPU 的 n_envs 乘以 GPU 数量,因此增加 GPU 会降低梯度噪声,而不会改变任何单个场景。
用 torchrun 启动它;torchrun 会为每张 GPU 启动一个进程,并设置 DDP 读取的会合变量:
torchrun --standalone --nnodes=1 --nproc_per_node=2 examples/rigid/ddp_multi_gpu.py
每个 worker 读取自己的 rank,把自身绑定到对应的 GPU,并按 rank 为 Genesis 设置种子,使各 GPU 上的环境彼此去相关,而不是完全相同:
local_rank = int(os.environ.get("LOCAL_RANK", 0))
os.environ["CUDA_VISIBLE_DEVICES"] = str(local_rank)
os.environ["QD_VISIBLE_DEVICE"] = str(local_rank)
gs.init(backend=gs.gpu, seed=local_rank) # distinct seed per rank
之后构建场景并初始化进程组。由于 CUDA_VISIBLE_DEVICES 已把该进程限制到一张 GPU,这里的设备就是 cuda:0,DDP 包装器会绑定到它:
scene.build(n_envs=args.num_envs)
gpu_id = 0
torch.cuda.set_device(gpu_id)
dist.init_process_group(backend="nccl", init_method="env://")
device = torch.device("cuda", gpu_id)
model = TinyMLP(obs_dim, act_dim).to(device)
model = DDP(model, device_ids=[gpu_id])
训练循环会步进仿真,把状态读入张量,并让 DDP 同步梯度。观测直接来自刚体求解器的广义坐标,因此没有数据离开 GPU:
for step in range(args.steps):
scene.step()
qpos = rigid.get_qpos() # shape (n_envs, n_qs), on the GPU
obs = qpos + torch.randn_like(qpos)
logits = model(obs)
target = qpos.sum(dim=1, keepdim=True)
loss = torch.nn.functional.mse_loss(logits, target)
optim.zero_grad(set_to_none=True)
loss.backward() # DDP averages gradients across ranks
optim.step()
最后要干净地关闭。这个 barrier 让每个 rank 在 NCCL 销毁之前到达同一点,从而避免某个 rank 提前退出时发生挂起:
dist.barrier()
dist.destroy_process_group()
gs.destroy()
把仿真状态读入张量#
在 GPU 后端上,实体状态已经驻留在设备上,因此可以直接送入模型,而无需往返主机。DDP 示例从刚体求解器读取广义位置:
rigid = scene.sim.rigid_solver
qpos = rigid.get_qpos() # shape ([n_envs,] n_qs)
当场景以并行环境构建时,返回的张量带有前置的 n_envs 维度;单环境场景则会去掉该维度。这遵循 Genesis World 中通用的 形状约定。在 gs.init() 之后,gs.device 与 gs.backend 会报告解析得到的 PyTorch 设备与后端,便于把自己的张量放到同一张 GPU 上。
注意事项与常见坑#
Warning
在 gs.init() 之前设置设备环境变量。它们只在初始化时读取一次;之后再修改对正在运行的进程没有影响。
Note
用 EGL_DEVICE_ID 固定渲染 GPU 并非在每台机器上都可靠。正因如此,examples/rigid/ddp_multi_gpu.py 没有设置它。无头仿真或训练并不要求在 GPU 上渲染,因此除非你确实要按 rank 渲染图像并已确认硬件支持,否则应省略该变量。
Tip
在添加第二张 GPU 之前,先把单张 GPU 能容纳的环境尽量批量化。提高 n_envs 时用 nvidia-smi 观察显存,只有当单张设备已满,或者训练配方需要每个 GPU 一个 rank 时,才转向多 GPU。
另见#
并行仿真:在单张 GPU 上批处理环境,以及
([n_envs,] ...)形状约定。Hello, Genesis World:这些模式中每个进程所运行的单 GPU 程序。
Scene API:
build、step,以及n_envs和env_spacing参数。