EXPERIMENT REPORT

实验九:单机多进程模拟分布式训练 (Pseudo-DDP) 与通信故障排查

2026-01-03 10 MIN READ

1. 实验九:基础实验

  • 本实验假设环境为摩尔线程 MUSA SDK v4.0+ 或 NVIDIA CUDA 环境(用于对照)。
  • Happy Path:全流程跑通,MCCL/NCCL 初始化成功。
  • Sad Path:模拟显存锁死与通信超时。

1.1 第一阶段:环境准备与理论认知

目标:理解为何要在单卡上跑分布式代码——这不是为了加速,而是为了验证驱动的上下文切换能力

  1. 理论背景

    • DDP (DistributedDataParallel):多卡训练标准。即使只有一张卡,也可以启动两个进程(Rank 0, Rank 1)各占一半显存。
    • Loopback (回环):数据不走网卡,直接在显存内通过 Kernel Copy 搬运。如果这都跑不通,上集群必死无疑。
  2. 环境检查

    • 确认 torch_musa (国产) 或 torch (N卡) 已正确安装。
    • 确认 musa-sminvidia-smi 能看到 GPU。

1.2 第二阶段:构建实验代码 (Pseudo-DDP)

目标:编写一个能正确划分显存、避免 OOM 的单机多进程脚本。

  1. 创建主实验脚本

    hljs bash
    1
    touch pseudo_ddp_base.py
    
  2. 编写代码(逻辑修正版):

    • 修正点:不要手动切分显存地址,改用 PyTorch 的 set_per_process_memory_fraction 限制显存比例,防止两个进程把显存撑爆。
    hljs python
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    import os
    import torch
    import torch.distributed as dist
    import torch.nn as nn
    import torch.optim as optim
    
    # 适配国产 MUSA 环境,如果是 N 卡环境请注释相关行
    try:
        import torch_musa
        BACKEND = "mccl"
        DEVICE_PREFIX = "musa"
    except ImportError:
        BACKEND = "nccl"
        DEVICE_PREFIX = "cuda"
    
    def setup():
        # 1. 初始化进程组
        dist.init_process_group(backend=BACKEND)
        
        # 2. 关键:限制显存占用 (45% per process),预留 10% 给系统
        # N 卡通常无需显式限制也能跑,但国产卡驱动建议加上硬限制
        if DEVICE_PREFIX == "musa":
            torch.musa.set_per_process_memory_fraction(0.45)
        else:
            torch.cuda.set_per_process_memory_fraction(0.45, 0)
            
        local_rank = int(os.environ["LOCAL_RANK"])
        # 所有进程都指向同一张物理卡 device 0
        device = torch.device(f"{DEVICE_PREFIX}:0")
        return device, local_rank
    
    def cleanup():
        dist.destroy_process_group()
    
    def run_training():
        device, rank = setup()
        print(f"[Rank {rank}] Initialized on {device}")
        
        # 模拟小模型
        model = nn.Linear(10, 10).to(device)
        # 封装 DDP (注意 device_ids 都是 0)
        ddp_model = nn.parallel.DistributedDataParallel(model, device_ids=[0])
        
        optimizer = optim.SGD(ddp_model.parameters(), lr=0.001)
        
        # 模拟训练循环
        for i in range(20):
            optimizer.zero_grad()
            outputs = ddp_model(torch.randn(20, 10).to(device))
            loss = outputs.mean()
            loss.backward()
            optimizer.step()
            
            if rank == 0 and i % 5 == 0:
                print(f"Iter {i}: Loss synchronized via {BACKEND} Ring-Reduce")
        
        cleanup()
    
    if __name__ == "__main__":
        run_training()
    

1.3 第三阶段:执行与观测

目标:使用 torchrun 启动多进程,并观察 GPU 状态。

  1. 启动指令(模拟单节点 2 卡):

    hljs bash
    1
    2
    # nproc_per_node=2 表示启动两个进程
    torchrun --nproc_per_node=2 --nnodes=1 pseudo_ddp_base.py
    
  2. 开启监控(分屏): 在另一个终端观察进程状态。注意观察是否有两个 Python 进程同时占用 GPU 显存。

    hljs bash
    1
    2
    3
    4
    # 国产卡使用 musa-smi,N 卡使用 nvidia-smi
    watch -n 0.5 nvidia-smi 
    # 或者
    # watch -n 0.5 musa-smi
    
  3. 实验结果示例

    hljs output
    1
    2
    3
    4
    5
    [Rank 1] Initialized on musa:0
    [Rank 0] Initialized on musa:0
    Iter 0: Loss synchronized via mccl Ring-Reduce
    Iter 5: Loss synchronized via mccl Ring-Reduce
    ...
    

1.4 第四阶段:故障模拟 (Sad Path)

目标:验证驱动的资源回收机制和超时报错。

  1. 场景 A:僵尸进程锁死显存

    • 操作:在训练运行时,Ctrl+C 强行终止,或者另开终端 kill -9 <主进程PID>
    • 观测:使用 ps -ef | grep python 查看是否有子进程残留(Zombie)。
    • 现象
      • N 卡:通常驱动会自动回收。
      • 早期国产驱动:显存可能显示“被占用”,实际上进程已死,导致下次运行直接 OOM。
    • 老鸟技巧:一键清理僵尸进程:
      hljs bash
      1
      ps -ef | grep python | grep -v grep | awk '{print $2}' | xargs kill -9
      
  2. 场景 B:模拟通信超时

    • 操作:修改代码,让 Rank 0 正常运行,Rank 1 在初始化前 time.sleep(600)
    • 预期:Rank 0 卡在 dist.init_process_group 等待,触发 NCCL/MCCL 的 Watchdog Timeout(通常默认 30 分钟,可设置 TORCH_NCCL_BLOCKING_WAIT=1 加速报错)。

2. 实验九:进阶实验

单卡跑通只是第一步。真正的压力在于高频通信下的 CPU 调度与 GPU 上下文切换瓶颈。

2.1 进阶 1:高频小包轰炸 (Latency Storm)

  • 缺失点:基础实验只测了逻辑连通性,没测通信延迟带来的系统开销。
  • 操作:循环执行 10,000 次 4 字节(Byte)的 all_reduce,不进行计算。
  • 观察
    • N 厂现象:Kernel 极快,CPU 占用率上升,但 GPU 利用率极低(因为全是小包)。
    • 国产挑战:如果 MUSA 驱动的 Launch Overhead(内核启动开销)过大,这里会看到系统卡顿,甚至 System CPU % 飙升至 100%。

执行步骤

  1. 创建文件 latency_storm.py

    hljs python
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    import torch
    import torch.distributed as dist
    import os
    import time
    
    # ... (Setup 代码同上) ...
    
    def latency_test():
        device, rank = setup()
        # 4 Byte 小包
        tensor = torch.zeros(1).to(device)
        
        start = time.time()
        # 10,000 次高频通信
        for i in range(10000):
            dist.all_reduce(tensor)
        
        dist.barrier()
        if rank == 0:
            cost = time.time() - start
            print(f"10k All-Reduce Cost: {cost:.4f}s")
            print(f"Avg Latency: {(cost/10000)*1000:.4f} ms")
        
        cleanup()
        
    if __name__ == "__main__":
        latency_test()
    
  2. 运行文件:

    hljs bash
    1
    torchrun --nproc_per_node=2 latency_storm.py
    
  3. 结果解读:如果平均延迟 > 0.1ms (Loopback模式下),说明驱动层面的通信原语还有优化空间。


3. 实验总结与核心知识点

3.1【核心结论】

单卡跑 DDP 是验证分布式逻辑的最低成本方案。假如 MCCL 在单卡回环(Loopback)都跑不通或延迟爆炸,上集群必死无疑。

3.2【技术解剖:单卡多进程的本质】

  1. Context Switching (上下文切换)
    • N 厂方案:有 MPS (Multi-Process Service) 服务,能有效减少上下文切换开销。
    • 国产化现状:在无 MPS 支持下,GPU 像单核 CPU 轮询多线程,不仅慢,还容易因资源竞争导致 Kernel Panic。
  2. Loopback (回环通信)
    • 这是通信带宽的理论天花板(等于显存带宽)。如果在 Pseudo-DDP 中测出的带宽远低于显存带宽,说明从 Tensor 到通信 Buffer 的拷贝(Memcpy)路径有问题。

3.3【老鸟锐评 (Old Pro's Review)】

“在单卡上模拟分布式,就像在浴缸里学游泳。虽然浪不大,但如果你连在浴缸里换气(Context Switching)都呛水,下了海(多机多卡)遇到惊涛骇浪(网络抖动、丢包),MUSA 驱动一定会教你做人。先把这个实验跑稳了,再谈千卡集群。”