GPU 编号进阶:CUDA\_VISIBLE\_DEVICES、多进程与容器化陷阱

GPU 编号进阶:CUDA_VISIBLE_DEVICES、多进程与容器化陷阱

本篇是系列第二篇。第一篇介绍了 CUDA_DEVICE_ORDER 的基本概念与修复方法。本篇在此基础上,深入讲解 CUDA_VISIBLE_DEVICESCUDA_DEVICE_ORDER 的叠加效应,分析多进程训练框架(torch.distributed、DeepSpeed)中的潜在陷阱,并介绍 Docker 和 Kubernetes 环境下的特殊处理方式,最后给出混合 GPU 环境下的正确架构设计思路。

作者:吴佳浩

撰稿时间:2026-3-19

最后更新:2026-3-22

测试版本:pytorch 2.8.0

一、CUDA 设备枚举机制的完整流程

在讲进阶内容之前,先把 CUDA Runtime 枚举 GPU 设备的完整流程梳理清楚。这个流程涉及两个环境变量,它们的生效顺序是固定的,必须理解清楚才能正确使用。

1.1 枚举流程详解

flowchart TD A["程序首次调用任何 CUDA API,如 import torch"] --> B["CUDA Runtime 初始化(进程生命周期内仅执行一次)"] B --> C{"检查 CUDA_DEVICE_ORDER 环境变量"} C -- "未设置 或 值为 FASTEST_FIRST" --> D["按计算能力降序排序\nsm_89 > sm_86 > sm_80 > sm_75 ..."] C -- "值为 PCI_BUS_ID" --> E["按 PCI 总线地址升序排序\n结果与 nvidia-smi 保持一致"] D --> F["生成完整的设备编号映射(初始映射)"] E --> F F --> G{"检查 CUDA_VISIBLE_DEVICES 环境变量"} G -- "未设置" --> H["所有 GPU 可见,编号使用初始映射,不变"] G -- "已设置,如 '0,2'" --> I["过滤掉不在列表中的 GPU\n剩余 GPU 重新从 0 开始连续编号"] H --> J["最终设备映射固定,不可再改变"] I --> J

1.2 两个环境变量的职责分工

这两个环境变量虽然都影响 CUDA 设备编号,但职责完全不同:

  • CUDA_DEVICE_ORDER:决定排序规则。在所有 GPU 可见的前提下,按什么顺序给它们编号。这是"排序"操作。
  • CUDA_VISIBLE_DEVICES:决定可见性。哪些 GPU 对这个进程可见,不在列表中的 GPU 就像不存在一样。这是"过滤"操作。

两者是串行生效的:先由 CUDA_DEVICE_ORDER 确定所有 GPU 的排序,然后由 CUDA_VISIBLE_DEVICES 从这个排序结果中选取指定的 GPU,并对选出的 GPU 重新从 0 开始编号。

1.3 CUDA_VISIBLE_DEVICES 的语法

CUDA_VISIBLE_DEVICES 的值是一个逗号分隔的索引列表,这些索引对应的是 CUDA_DEVICE_ORDER 决定的排序结果中的位置:

bash 复制代码
# 只让 cuda:0 和 cuda:2 可见(基于当前 CUDA_DEVICE_ORDER 的排序)
export CUDA_VISIBLE_DEVICES=0,2

# 完全禁用 GPU,让程序跑在 CPU 上
export CUDA_VISIBLE_DEVICES=""

# 也可以用逗号分隔的列表,顺序决定最终的 cuda 编号
# 例如 CUDA_VISIBLE_DEVICES=2,0 会让原来的 cuda:2 变成 cuda:0,cuda:0 变成 cuda:1
export CUDA_VISIBLE_DEVICES=2,0

这里有一个很容易被忽视的细节:CUDA_VISIBLE_DEVICES 中的数字,指的是当前 CUDA_DEVICE_ORDER 策略下的 cuda 编号,而不是 nvidia-smi 的物理编号(除非你已经设置了 CUDA_DEVICE_ORDER=PCI_BUS_ID)。


二、CUDA_VISIBLE_DEVICES 与 CUDA_DEVICE_ORDER 的叠加效应

这是最容易出现混乱的地方。两个环境变量叠加使用时,最终的设备编号可能与直觉完全不同。

2.1 场景一:仅设置 CUDA_VISIBLE_DEVICES,不设置 CUDA_DEVICE_ORDER

此时 CUDA_DEVICE_ORDER 使用默认的 FASTEST_FIRST,在本文的服务器上,PyTorch 的初始映射是:

ini 复制代码
cuda:0 = 4090(sm_89,最强)
cuda:1 = T4(Bus 5E)
cuda:2 = T4(Bus AF)
cuda:3 = T4(Bus D8)

现在设置 CUDA_VISIBLE_DEVICES=1,2,意思是"只让初始映射中 cuda:1 和 cuda:2 对应的 GPU 可见":

bash 复制代码
export CUDA_VISIBLE_DEVICES=1,2
python -c "
import torch
for i in range(torch.cuda.device_count()):
    print(f'cuda:{i} -> {torch.cuda.get_device_name(i)}')
"

输出:

makefile 复制代码
cuda:0 -> Tesla T4   (原 cuda:1,即 Bus 5E 的 T4,重新编号为 0)
cuda:1 -> Tesla T4   (原 cuda:2,即 Bus AF 的 T4,重新编号为 1)

此时 4090 被完全隐藏,进程只能看到两张 T4,cuda:0 是 Bus 5E 的那张,cuda:1 是 Bus AF 的那张。

2.2 场景二:同时设置 PCI_BUS_ID 和 CUDA_VISIBLE_DEVICES

此时 CUDA_DEVICE_ORDER=PCI_BUS_ID,PyTorch 的初始映射与 nvidia-smi 一致:

ini 复制代码
cuda:0 = T4(Bus 5E)
cuda:1 = 4090(Bus 86)
cuda:2 = T4(Bus AF)
cuda:3 = T4(Bus D8)

同样设置 CUDA_VISIBLE_DEVICES=1,2

bash 复制代码
export CUDA_DEVICE_ORDER=PCI_BUS_ID
export CUDA_VISIBLE_DEVICES=1,2
python -c "
import torch
for i in range(torch.cuda.device_count()):
    print(f'cuda:{i} -> {torch.cuda.get_device_name(i)}')
"

输出:

makefile 复制代码
cuda:0 -> NVIDIA GeForce RTX 4090   (原 cuda:1,即 4090,重新编号为 0)
cuda:1 -> Tesla T4                   (原 cuda:2,即 Bus AF 的 T4,重新编号为 1)

同样是 CUDA_VISIBLE_DEVICES=1,2,但因为 CUDA_DEVICE_ORDER 不同,两个场景的最终结果完全不同!场景一选出了两张 T4,场景二选出了一张 4090 和一张 T4。

2.3 叠加效应的完整流程图

flowchart TD A["物理 GPU 列表:T4(5E), 4090(86), T4(AF), T4(D8)"] --> B{"CUDA_DEVICE_ORDER?"} B -- "FASTEST_FIRST(默认)" --> C["初始映射:\ncuda:0=4090, cuda:1=T4(5E)\ncuda:2=T4(AF), cuda:3=T4(D8)"] B -- "PCI_BUS_ID" --> D["初始映射(与 nvidia-smi 一致):\ncuda:0=T4(5E), cuda:1=4090\ncuda:2=T4(AF), cuda:3=T4(D8)"] C --> E{"CUDA_VISIBLE_DEVICES?"} D --> F{"CUDA_VISIBLE_DEVICES?"} E -- "未设置" --> G["最终:cuda:0=4090, cuda:1=T4(5E)\ncuda:2=T4(AF), cuda:3=T4(D8)"] E -- "设为 1,2" --> H["过滤后重新编号:\ncuda:0=T4(5E), cuda:1=T4(AF)"] E -- "设为 0" --> I["过滤后重新编号:\ncuda:0=4090(仅此一张)"] F -- "未设置" --> J["最终:cuda:0=T4(5E), cuda:1=4090\ncuda:2=T4(AF), cuda:3=T4(D8)"] F -- "设为 1,2" --> K["过滤后重新编号:\ncuda:0=4090, cuda:1=T4(AF)"] F -- "设为 0" --> L["过滤后重新编号:\ncuda:0=T4(5E)(仅此一张)"]

核心规则:CUDA_VISIBLE_DEVICES 中的索引,指向的是 CUDA_DEVICE_ORDER 先确定的初始排序结果中的位置。两者的顺序不能颠倒,必须先理解 CUDA_DEVICE_ORDER 的结果,才能正确使用 CUDA_VISIBLE_DEVICES

2.4 实际工程中的最佳实践

为了避免叠加效应带来的混乱,生产环境中建议遵循以下原则:

第一,始终先设置 CUDA_DEVICE_ORDER=PCI_BUS_ID ,让两套编号统一。这样 CUDA_VISIBLE_DEVICES 中的索引就与 nvidia-smi 里的 GPU 编号一一对应,直观可靠。

第二,CUDA_VISIBLE_DEVICES 做进程隔离,而不是用它来改变编号顺序。每个服务进程只看到自己需要的那张 GPU,避免进程之间误用彼此的 GPU:

bash 复制代码
export CUDA_DEVICE_ORDER=PCI_BUS_ID

# modeA 服务:只看到 GPU 0(T4)
CUDA_VISIBLE_DEVICES=0 python xxxxxxx.py &

# modelB 服务:只看到 GPU 1(4090)
CUDA_VISIBLE_DEVICES=1 python xxxxxxx.py &

# 另外两个 modeA 服务
CUDA_VISIBLE_DEVICES=2 python xxxxxxx.py &
CUDA_VISIBLE_DEVICES=3 python xxxxxxx.py &

这种写法的好处是:每个服务进程里,cuda:0 就是它自己的那张 GPU,不需要在代码里指定复杂的设备编号,也不会因为编号错乱而用错卡。


三、多进程训练框架中的陷阱

在使用 torch.distributed、DeepSpeed 等分布式训练框架时,GPU 编号问题会出现新的变体,因为这些框架会在背后自动操作 CUDA_VISIBLE_DEVICES

3.1 torch.distributed.run 的自动行为

使用 python -m torch.distributed.run --nproc_per_node=4 train.py 启动多进程训练时,框架会自动为每个 Worker 进程设置 CUDA_VISIBLE_DEVICES,让每个 Worker 只看到一张 GPU:

sequenceDiagram participant User as 用户(执行 torch.distributed.run) participant Launch as torch.distributed.run(主进程) participant W0 as Worker 进程 0 participant W1 as Worker 进程 1 participant W2 as Worker 进程 2 participant W3 as Worker 进程 3 User->>Launch: 启动,--nproc_per_node=4 Launch->>W0: fork 子进程,设置 CUDA_VISIBLE_DEVICES=0,LOCAL_RANK=0 Launch->>W1: fork 子进程,设置 CUDA_VISIBLE_DEVICES=1,LOCAL_RANK=1 Launch->>W2: fork 子进程,设置 CUDA_VISIBLE_DEVICES=2,LOCAL_RANK=2 Launch->>W3: fork 子进程,设置 CUDA_VISIBLE_DEVICES=3,LOCAL_RANK=3 Note over W0: CUDA_VISIBLE_DEVICES=0\n此时 cuda:0 对应哪张物理 GPU?\n取决于父进程的 CUDA_DEVICE_ORDER 设置 Note over W1: CUDA_VISIBLE_DEVICES=1\ncuda:0 = 第二张 GPU(按 CUDA_DEVICE_ORDER) Note over W2: CUDA_VISIBLE_DEVICES=2\ncuda:0 = 第三张 GPU Note over W3: CUDA_VISIBLE_DEVICES=3\ncuda:0 = 第四张 GPU

关键点在于:torch.distributed.run 设置的 CUDA_VISIBLE_DEVICES=0CUDA_VISIBLE_DEVICES=1 等,这里的 0123 是什么含义?它们是 CUDA_DEVICE_ORDER 策略下的排序索引。如果 CUDA_DEVICE_ORDER 是默认的 FASTEST_FIRST,那么:

  • Worker 0 的 CUDA_VISIBLE_DEVICES=0 指向的是 4090(FASTEST_FIRST 排序下的第 0 张)
  • Worker 1 的 CUDA_VISIBLE_DEVICES=1 指向的是第一张 T4(FASTEST_FIRST 排序下的第 1 张)
  • 以此类推

这与 nvidia-smi 显示的物理编号完全不同,但只要在调用 torch.distributed.run 之前设置了 export CUDA_DEVICE_ORDER=PCI_BUS_ID,这个行为就会与 nvidia-smi 保持一致,不会有意外。

3.2 DeepSpeed 训练速度异常案例

场景描述

使用 DeepSpeed ZeRO-3 在本文的 4 张混合 GPU(1 张 4090 + 3 张 T4)上做分布式训练,训练速度比单用 4 张 T4 的服务器还慢,完全出乎意料。

按照 nvidia-smi 显示,GPU 0 是 T4,GPU 1 是 4090,GPU 2、3 是 T4。但在 FASTEST_FIRST 排序下,cuda:0 对应 4090,cuda:1/2/3 对应三张 T4。

sequenceDiagram participant DS as DeepSpeed Launcher participant W0 as Worker 0(cuda:0=4090) participant W1 as Worker 1(cuda:0=T4) participant W2 as Worker 2(cuda:0=T4) participant W3 as Worker 3(cuda:0=T4) DS->>W0: CUDA_VISIBLE_DEVICES=0(FASTEST_FIRST 下是 4090) DS->>W1: CUDA_VISIBLE_DEVICES=1(T4) DS->>W2: CUDA_VISIBLE_DEVICES=2(T4) DS->>W3: CUDA_VISIBLE_DEVICES=3(T4) Note over W0,W3: ZeRO-3 每步都需要 All-Reduce 通信\n所有 Worker 必须同步梯度\n整体速度受最慢的节点限制 W0->>W0: 4090 计算 Batch 0,1.0s 完成 W1->>W1: T4 计算 Batch 1,13.0s 完成 W2->>W2: T4 计算 Batch 2,13.2s 完成 W3->>W3: T4 计算 Batch 3,13.1s 完成 Note over W0: 4090 算完后等待 12 秒\n等三张 T4 完成后才能开始 All-Reduce\n4090 GPU 利用率不足 8%

问题分析

这个案例揭示了混合 GPU 做数据并行训练的本质矛盾:数据并行要求所有 Worker 在每个 step 结束时同步梯度(All-Reduce 操作),因此整体速度受到最慢的那张卡的制约。4090 虽然快 13 倍,但它 92% 的时间都在空等三张 T4,实际利用率极低。

从效率角度看,这种配置下的 4 卡训练速度,大约等于 4 张 T4 的训练速度,4090 几乎没有发挥作用。

根本原因

即使编号设置正确(设置了 PCI_BUS_ID),在混合型号 GPU 上做数据并行训练也是低效的。编号问题和架构问题是两个独立的问题,解决了编号问题不等于解决了架构问题。

正确的做法是:不要在异构 GPU 上做数据并行,而是按任务类型分配 GPU(详见第五节)。

3.3 安全的多进程启动方式

无论使用什么分布式训练框架,安全的多进程启动模板如下:

bash 复制代码
#!/bin/bash

# 第一步:无论如何,先统一编号体系
# 这行必须在所有 python 调用之前
export CUDA_DEVICE_ORDER=PCI_BUS_ID

# 方式一:让 torch.distributed.run 自动分配(编号已与 nvidia-smi 统一,可信赖)
python -m torch.distributed.run \
    --nproc_per_node=4 \
    --master_addr=localhost \
    --master_port=12355 \
    train.py

# 方式二:手动隔离每个进程(更明确,生产环境推荐)
# 设置了 PCI_BUS_ID 之后,CUDA_VISIBLE_DEVICES 的编号与 nvidia-smi 一致
CUDA_VISIBLE_DEVICES=0 python xxxxxx.py &           # T4,Bus 5E
CUDA_VISIBLE_DEVICES=1 python xxxxxx.py &           # 4090,Bus 86
CUDA_VISIBLE_DEVICES=2 python xxxxxx.py &           # T4,Bus AF
CUDA_VISIBLE_DEVICES=3 python xxxxxx.py &           # T4,Bus D8

wait  # 等待所有后台进程结束

3.4 进程内验证环境变量的生效情况

在分布式训练的 Worker 进程里,可以加入启动时的验证代码,确认每个进程确实拿到了预期的 GPU:

python 复制代码
import os
import torch
import torch.distributed as dist

def worker_gpu_check():
    """在每个 Worker 进程启动时验证 GPU 分配"""
    local_rank = int(os.environ.get("LOCAL_RANK", 0))
    device_order = os.environ.get("CUDA_DEVICE_ORDER", "NOT SET")
    visible = os.environ.get("CUDA_VISIBLE_DEVICES", "NOT SET")

    # 每个 Worker 进程都只看到一张 GPU(框架设置了 CUDA_VISIBLE_DEVICES)
    # 所以 cuda:0 就是这个 Worker 的 GPU
    gpu_name = torch.cuda.get_device_name(0)

    print(f"[Worker {local_rank}] "
          f"CUDA_DEVICE_ORDER={device_order}, "
          f"CUDA_VISIBLE_DEVICES={visible}, "
          f"cuda:0 = {gpu_name}")

# 在训练脚本开头调用
worker_gpu_check()

输出示例(设置了 PCI_BUS_ID,使用方式二手动隔离):

ini 复制代码
[Worker 0] CUDA_DEVICE_ORDER=PCI_BUS_ID, CUDA_VISIBLE_DEVICES=0, cuda:0 = Tesla T4
[Worker 1] CUDA_DEVICE_ORDER=PCI_BUS_ID, CUDA_VISIBLE_DEVICES=1, cuda:0 = NVIDIA GeForce RTX 4090
[Worker 2] CUDA_DEVICE_ORDER=PCI_BUS_ID, CUDA_VISIBLE_DEVICES=2, cuda:0 = Tesla T4
[Worker 3] CUDA_DEVICE_ORDER=PCI_BUS_ID, CUDA_VISIBLE_DEVICES=3, cuda:0 = Tesla T4

四、容器化环境的特殊处理

Docker 和 Kubernetes 是 GPU 服务最常见的部署环境。在容器化环境中,GPU 编号问题有额外的复杂性。

4.1 Docker 中的 GPU 映射基础

使用 --gpus 参数启动 Docker 容器时,可以指定容器能看到哪些 GPU:

bash 复制代码
# 让容器能看到所有 GPU
docker run --gpus all my_image python train.py

# 只让容器看到宿主机的 GPU 1
docker run --gpus '"device=1"' my_image python train.py

# 让容器看到宿主机的 GPU 0 和 GPU 2
docker run --gpus '"device=0,2"' my_image python train.py

关键理解 :容器内永远只看到从 0 开始的连续编号。如果你指定 device=1,容器内只有 cuda:0,它对应的是宿主机上编号为 1 的 GPU(这个"1"的含义取决于 CUDA_DEVICE_ORDER)。

flowchart LR subgraph 宿主机_按PCI_BUS_ID排序 H0["cuda:0 = T4(Bus 5E)"] H1["cuda:1 = 4090(Bus 86)"] H2["cuda:2 = T4(Bus AF)"] H3["cuda:3 = T4(Bus D8)"] end subgraph 容器内_docker_gpus_device_1 C0["cuda:0 = 4090(宿主机 cuda:1 / Bus 86)"] end H1 -->|映射| C0

在容器里,cuda:0 就是 4090,这是符合预期的。容器内的代码写 cuda:0 即可,不需要关心宿主机上的编号。

4.2 CUDA_DEVICE_ORDER 对 Docker device= 编号的影响

这里有一个重要的细节:docker run --gpus '"device=1"' 里的 1,指的是宿主机上按 CUDA_DEVICE_ORDER 排序后的 cuda 编号,而不一定是 nvidia-smi 的物理编号。

bash 复制代码
# 情形一:宿主机未设置 CUDA_DEVICE_ORDER(FASTEST_FIRST)
# 宿主机的编号:cuda:0=4090, cuda:1=T4(Bus 5E), cuda:2=T4(AF), cuda:3=T4(D8)
# 所以 device=1 指向的是 T4(Bus 5E),不是 4090

docker run --gpus '"device=1"' my_image nvidia-smi
# 容器内看到:Tesla T4

# 情形二:宿主机设置了 CUDA_DEVICE_ORDER=PCI_BUS_ID
# 宿主机的编号:cuda:0=T4(5E), cuda:1=4090(86), cuda:2=T4(AF), cuda:3=T4(D8)
# 所以 device=1 指向的是 4090,与 nvidia-smi 显示一致

docker run -e CUDA_DEVICE_ORDER=PCI_BUS_ID --gpus '"device=1"' my_image nvidia-smi
# 容器内看到:NVIDIA GeForce RTX 4090

同样是 device=1,两种情形下容器得到的是完全不同的 GPU。这个细节非常容易踩坑,尤其是在脚本中写死了 device= 的编号时。

4.3 最安全的 Docker GPU 指定方式

最安全的方法是使用 PCI Bus ID 直接指定 GPU,完全绕过任何排序策略:

bash 复制代码
# 先用 nvidia-smi 查出各 GPU 的 Bus ID
nvidia-smi --query-gpu=index,name,pci.bus_id --format=csv,noheader
# 输出:
# 0, Tesla T4, 00000000:5E:00.0
# 1, NVIDIA GeForce RTX 4090, 00000000:86:00.0
# 2, Tesla T4, 00000000:AF:00.0
# 3, Tesla T4, 00000000:D8:00.0

# 用 Bus ID 直接指定,不受任何排序策略影响
# 无论宿主机 CUDA_DEVICE_ORDER 怎么设置,这里的 4090 永远是 4090
docker run --gpus '"device=00000000:86:00.0"' my_image python train.py

这种方式的优点:完全确定性,不受 CUDA_DEVICE_ORDER 影响,无论宿主机环境怎么变化,指定的始终是那张物理 GPU。

4.4 在容器内透传环境变量

另一个常见的做法是在容器启动时传入 CUDA_DEVICE_ORDER 环境变量,让容器内的行为与宿主机一致:

bash 复制代码
# 方法一:通过 -e 参数传入
docker run \
    -e CUDA_DEVICE_ORDER=PCI_BUS_ID \
    --gpus all \
    my_image python train.py

# 方法二:通过 --env-file 传入(适合有很多环境变量的场景)
cat > /tmp/gpu_env.txt << EOF
CUDA_DEVICE_ORDER=PCI_BUS_ID
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
EOF

docker run \
    --env-file /tmp/gpu_env.txt \
    --gpus all \
    my_image python train.py

# 方法三:在 Dockerfile 中设置(对所有使用这个镜像的容器生效)
# Dockerfile:
# ENV CUDA_DEVICE_ORDER=PCI_BUS_ID

4.5 Kubernetes 中的 GPU 调度

在 Kubernetes 环境中,GPU 编号问题的表现形式有所不同:

flowchart TD A["Pod 的 resources.limits 中请求 1 个 GPU\nnvidia.com/gpu: 1"] --> B["Kubernetes 调度器选择节点"] B --> C["nvidia-device-plugin 在选定节点上分配一张空闲的 GPU"] C --> D{"分配到哪张?"} D --> E["由 nvidia-device-plugin 决定,用户不直接控制\n通常是当前负载最低的那张"] E --> F["通过 CUDA_VISIBLE_DEVICES 将这张 GPU 暴露给 Pod"] F --> G["Pod 内只看到 cuda:0\n这个 cuda:0 就是被分配的那张 GPU"] G --> H["单 GPU Pod:代码直接用 cuda:0,无需关心物理编号"]

单 GPU Pod 的情况 :每个 Pod 只分配一张 GPU,容器内只有 cuda:0,编号问题不存在。代码里直接写 cuda:0 就是正确的。

多 GPU Pod 的情况 :如果一个 Pod 请求多张 GPU(nvidia.com/gpu: 4),那么 Pod 内的编号规则就又回到了 CUDA_DEVICE_ORDER 控制的范畴。此时需要在 Pod 的环境变量中设置 CUDA_DEVICE_ORDER=PCI_BUS_ID

yaml 复制代码
# kubernetes deployment.yaml
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
      - name: training
        env:
        - name: CUDA_DEVICE_ORDER
          value: "PCI_BUS_ID"
        - name: PYTORCH_CUDA_ALLOC_CONF
          value: "expandable_segments:True"
        resources:
          limits:
            nvidia.com/gpu: 4

五、混合 GPU 环境的架构设计

解决了编号问题之后,还需要思考混合 GPU 环境下的任务分配架构。即使编号完全正确,不合理的架构设计仍然会导致性能问题。

5.1 错误的架构:异构 GPU 做数据并行

数据并行(Data Parallelism)是最常见的多卡训练策略,它把每个训练 batch 切分成若干 mini-batch,分别在不同 GPU 上并行计算,最后汇总梯度。

graph TD subgraph "错误:异构 GPU 做数据并行" D["完整数据集"] --> B0["Mini-Batch 0\n-> 4090 计算,1s 完成"] D --> B1["Mini-Batch 1\n-> T4 计算,13s 完成"] D --> B2["Mini-Batch 2\n-> T4 计算,13.2s 完成"] D --> B3["Mini-Batch 3\n-> T4 计算,13.1s 完成"] B0 --> SYNC["All-Reduce 梯度同步\n必须等待所有 GPU 完成\n实际等待时间 = T4 的计算时间 ≈ 13s"] B1 --> SYNC B2 --> SYNC B3 --> SYNC SYNC --> LOSS["每个 step 耗时约 13s\n4090 利用率不足 8%\n整体效率约等于 4 张 T4 并行"] end

这种架构浪费了 4090 的大部分算力。购买 4090 的钱,却只得到了 T4 水平的训练效率。

5.2 正确的架构:按任务类型分配

混合 GPU 环境的正确设计原则是:不同性能的 GPU 跑不同性质的任务,而不是让所有 GPU 做相同的事情

graph TD subgraph "正确:按任务类型分配 GPU" REQ["用户请求流量"] --> ROUTER["请求路由层\n根据任务类型分发"] ROUTER --> modeB["modelB 推理服务\n4090(cuda:1)\n计算密集型,对延迟敏感\n需要 FP16 高算力"] ROUTER --> EMB0["modeA 服务 0\nT4(cuda:0)\n轻量级向量编码\nT4 算力足够"] ROUTER --> EMB1["modeA 服务 1\nT4(cuda:2)\n负载均衡的第二个实例"] ROUTER --> EMB2["modeA 服务 2\nT4(cuda:3)\n负载均衡的第三个实例"] modeB --> RESP["合并结果,返回响应"] EMB0 --> RESP EMB1 --> RESP EMB2 --> RESP end

在这个架构下:

  • 4090 专门负责计算量最大的 modelB,能充分发挥其 330 TFLOPS 的 FP16 算力
  • 三张 T4 并行处理 modeA 请求,通过横向扩展提高吞吐量
  • 各服务之间独立运行,互不干扰,GPU 利用率最大化

5.3 推理服务的设备分配代码

python 复制代码
import os
import sys
import torch

# 必须在最顶部设置
os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID")

def verify_gpu_setup(expected_device_id: int, expected_gpu_name_keyword: str):
    """
    服务启动时验证 GPU 分配是否正确。

    参数:
        expected_device_id:          预期使用的 cuda 设备编号
        expected_gpu_name_keyword:   预期 GPU 名称中包含的关键词
                                     例如 "4090"、"T4"、"A100"
    """
    if not torch.cuda.is_available():
        print("[FATAL] CUDA 不可用,请检查驱动安装")
        sys.exit(1)

    device_count = torch.cuda.device_count()
    if expected_device_id >= device_count:
        print(f"[FATAL] 请求 cuda:{expected_device_id},"
              f"但系统只有 {device_count} 张 GPU(cuda:0 到 cuda:{device_count - 1})")
        sys.exit(1)

    device_order = os.environ.get("CUDA_DEVICE_ORDER", "NOT SET")
    if device_order != "PCI_BUS_ID":
        print(f"[WARNING] CUDA_DEVICE_ORDER = {device_order},不是 PCI_BUS_ID")
        print("[WARNING] GPU 编号可能与 nvidia-smi 不一致,建议设置 PCI_BUS_ID")

    actual_name = torch.cuda.get_device_name(expected_device_id)
    props = torch.cuda.get_device_properties(expected_device_id)
    print(f"[INFO] cuda:{expected_device_id} -> {actual_name} "
          f"(sm_{props.major}{props.minor}, {props.total_mem / 1024**3:.1f}GB)")

    if expected_gpu_name_keyword not in actual_name:
        print(f"[FATAL] 预期 GPU 包含 '{expected_gpu_name_keyword}',"
              f"但实际是 '{actual_name}'")
        print("[FATAL] GPU 分配错误,服务拒绝启动")
        sys.exit(1)

    print(f"[OK] GPU 验证通过:cuda:{expected_device_id} 确实是 {actual_name}")
    return f"cuda:{expected_device_id}"


# modelB 服务启动时验证(确保 cuda:1 是 4090)
modeB_DEVICE = verify_gpu_setup(1, "4090")

# modeA 服务启动时验证(确保 cuda:0 是 T4)
modeA_DEVICE = verify_gpu_setup(0, "T4")
flowchart TD A["服务启动"] --> B["检查 CUDA 是否可用"] B -- "不可用" --> ERR1["FATAL:退出"] B -- "可用" --> C["检查 CUDA_DEVICE_ORDER 是否为 PCI_BUS_ID"] C -- "不是" --> WARN["WARNING:打印警告,继续运行"] C -- "是" --> D["获取 cuda:N 的设备名称"] WARN --> D D --> E{"名称包含预期关键词?"} E -- "否" --> ERR2["FATAL:GPU 分配错误,退出"] E -- "是" --> F["OK:GPU 验证通过,继续加载模型"]

六、真实案例深度分析

6.1 案例一:LoRA 微调写错卡导致 OOM

背景

开发者在一台 4 卡服务器(1 张 4090 + 3 张 T4)上做 Qwen-8B 模型的 LoRA 微调。8B 模型的 FP16 权重约需要 16GB 显存,而 T4 只有 15GB 显存,装不下。计划是把模型放到 4090 的 49GB 显存上。

错误代码

python 复制代码
# 开发者认为 CUDA_VISIBLE_DEVICES=1 会让进程只看到 GPU 1(4090)
os.environ["CUDA_VISIBLE_DEVICES"] = "1"

import torch
from transformers import AutoModelForCausalLM

# device_map="auto" 会自动把模型放到可用的 GPU 上
# 由于设置了 CUDA_VISIBLE_DEVICES=1,开发者以为只有 4090 可见
model = AutoModelForCausalLM.from_pretrained("Qwen-8B", device_map="auto")

问题分析

flowchart LR A["开发者意图\nCUDA_VISIBLE_DEVICES=1\n以为选中了 4090"] --> B["FASTEST_FIRST 排序下\ncuda:1 = T4(Bus 5E,15GB)\n而非 4090"] B --> C["CUDA_VISIBLE_DEVICES=1\n过滤后容器内 cuda:0 = T4(15GB)"] C --> D["8B 模型需要 ~16GB FP16\nT4 只有 15GB"] D --> E["OOM:显存不足崩溃"]

错误信息:

erlang 复制代码
torch.cuda.OutOfMemoryError: CUDA out of memory.
Tried to allocate 2.00 GiB.
GPU 0 has a total capacity of 14.56 GiB of which 512.00 MiB is free.

开发者看到 14.56 GiB 就蒙了:4090 有 49GB,怎么只有 14.56GB?14.56 GiB 正好是 T4 的实际可用显存大小(名义 15GB,实际约 14.56GiB)。

根因 :在 CUDA_VISIBLE_DEVICES=1 设置之前没有设置 CUDA_DEVICE_ORDER=PCI_BUS_ID,导致 1 指向的是 FASTEST_FIRST 排序下的第 1 号 GPU,即 T4,而不是 nvidia-smi 里的 GPU 1(4090)。

正确写法

python 复制代码
import os
# 顺序不能错:先设置 DEVICE_ORDER,再设置 VISIBLE_DEVICES,最后 import torch
os.environ["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID"
os.environ["CUDA_VISIBLE_DEVICES"] = "1"   # 现在 1 就是 nvidia-smi 里的 GPU 1(4090)

import torch
print(torch.cuda.get_device_name(0))  # 输出:NVIDIA GeForce RTX 4090

from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("Qwen-8B", device_map="auto")

6.2 案例二:模型服务迁移后 QPS 骤降

背景

一个在线推理服务,原来部署在单卡 4090 服务器上,QPS(每秒请求数)为 120。为了降低成本,把服务迁移到一台混合 GPU 服务器(1 张 4090 + 3 张 T4),代码完全不改,QPS 降到了 15。

graph TD subgraph "迁移前(单卡 4090 服务器)" S1["单张 4090\nPyTorch 默认 cuda:0 = 4090(没有别的卡)\nQPS: 120"] end subgraph "迁移后(混合 GPU 服务器,代码不变)" S2["混合卡,代码仍写 cuda:0\nFASTEST_FIRST 下 cuda:0 = 4090(巧合正确)\n但..."] S3["nvidia-smi 监控:4090 空闲,T4 满载"] S2 --> S3 S3 --> S4["QPS: 15,与 T4 性能相符"] end S1 -- "直接迁移,代码不变" --> S2

这个案例更加诡异:代码写的是 cuda:0,在 FASTEST_FIRST 排序下,cuda:0 应该是 4090(算力最强),为什么性能反而下降了?

根因 :更深入的排查发现,这台混合 GPU 服务器上还运行着另一个服务,那个服务通过 CUDA_VISIBLE_DEVICES=0 占用了 cuda:0(即 4090),导致迁移过来的服务在争用 cuda:0 时被切换到了其他可用的 GPU(T4)上。

实际上,这个案例反映了两个独立的问题:(1)没有用 CUDA_VISIBLE_DEVICES 做进程隔离,各服务之间可能抢占 GPU;(2)没有设置 CUDA_DEVICE_ORDER=PCI_BUS_ID 来确保编号稳定可预期。

正确的修复方案

bash 复制代码
export CUDA_DEVICE_ORDER=PCI_BUS_ID

# 每个服务用 CUDA_VISIBLE_DEVICES 隔离,确保独占各自的 GPU
CUDA_VISIBLE_DEVICES=0 python server_modelA_0.py &    # 独占 T4(Bus 5E)
CUDA_VISIBLE_DEVICES=1 python server_modelB.py &       # 独占 4090(Bus 86)
CUDA_VISIBLE_DEVICES=2 python server_modelA_1.py &    # 独占 T4(Bus AF)
CUDA_VISIBLE_DEVICES=3 python server_modelA_2.py &    # 独占 T4(Bus D8)

七、总结

graph TD ROOT["进阶场景核心要点"] ROOT --> A["CUDA_VISIBLE_DEVICES 的索引\n来自 CUDA_DEVICE_ORDER 排序后的结果\n不是 nvidia-smi 的物理编号\n(除非设置了 PCI_BUS_ID)"] ROOT --> B["torch.distributed / DeepSpeed\n会自动覆盖 CUDA_VISIBLE_DEVICES\n必须在调用框架之前设置好 CUDA_DEVICE_ORDER"] ROOT --> C["Docker 中 device= 的编号\n同样受 CUDA_DEVICE_ORDER 影响\n最安全的方式是用 PCI Bus ID 直接指定"] ROOT --> D["Kubernetes 单 GPU Pod\n无需关心编号,容器内只有 cuda:0\n多 GPU Pod 则需要在 YAML 中设置环境变量"] ROOT --> E["混合 GPU 做数据并行\n速度受最慢的卡限制,效率极低\n应按任务类型分配,强卡做重任务"] ROOT --> F["服务启动时加入 GPU 名称验证\n是生产环境的基本守则\n拒绝在错误 GPU 上启动"]

两个环境变量的完整使用模板

bash 复制代码
#!/bin/bash
# 正确的完整模板

# 第一步:统一排序规则(必须在所有 python 调用之前)
export CUDA_DEVICE_ORDER=PCI_BUS_ID

# 第二步:用 CUDA_VISIBLE_DEVICES 做进程隔离
# 此时索引号与 nvidia-smi 的 GPU 编号一一对应,直观可靠
CUDA_VISIBLE_DEVICES=0 python server_modelA_0.py &   # nvidia-smi GPU 0
CUDA_VISIBLE_DEVICES=1 python server_modelB.py &   # nvidia-smi GPU 1
CUDA_VISIBLE_DEVICES=2 python server_modelA_1.py &   # nvidia-smi GPU 2
CUDA_VISIBLE_DEVICES=3 python server_modelA_2.py &   # nvidia-smi GPU 3
相关推荐
吴佳浩2 小时前
GPU 编号错乱踩坑指南:PyTorch cuda 编号与 nvidia-smi 不一致
人工智能·pytorch·nvidia
小饕2 小时前
苏格拉底式提问对抗315 AI投毒:实操指南
网络·人工智能
全栈凯哥2 小时前
18.Python中的导入类完全指南
python
卧蚕土豆2 小时前
【有啥问啥】OpenClaw 安装与使用教程
人工智能·深度学习
GoCodingInMyWay2 小时前
开源好物 26/03
人工智能·开源
AI科技星2 小时前
全尺度角速度统一:基于 v ≡ c 的纯推导与验证
c语言·开发语言·人工智能·opencv·算法·机器学习·数据挖掘
zhangfeng11332 小时前
Windows 的 Git Bash 中使用 md5sum 命令非常简单 md5做文件完整性检测 WinRAR 可以计算文件的 MD5 值
人工智能·windows·git·bash
sunwenjian8862 小时前
Java进阶——IO 流
java·开发语言·python
monsion2 小时前
OpenCode 学习指南
人工智能·vscode·架构