GPU 编号进阶:CUDA_VISIBLE_DEVICES、多进程与容器化陷阱
本篇是系列第二篇。第一篇介绍了
CUDA_DEVICE_ORDER的基本概念与修复方法。本篇在此基础上,深入讲解CUDA_VISIBLE_DEVICES与CUDA_DEVICE_ORDER的叠加效应,分析多进程训练框架(torch.distributed、DeepSpeed)中的潜在陷阱,并介绍 Docker 和 Kubernetes 环境下的特殊处理方式,最后给出混合 GPU 环境下的正确架构设计思路。
作者:吴佳浩
撰稿时间:2026-3-19
最后更新:2026-3-22
测试版本:pytorch 2.8.0
一、CUDA 设备枚举机制的完整流程
在讲进阶内容之前,先把 CUDA Runtime 枚举 GPU 设备的完整流程梳理清楚。这个流程涉及两个环境变量,它们的生效顺序是固定的,必须理解清楚才能正确使用。
1.1 枚举流程详解
1flowchart TD 2 A["程序首次调用任何 CUDA API,如 import torch"] --> B["CUDA Runtime 初始化(进程生命周期内仅执行一次)"] 3 B --> C{"检查 CUDA_DEVICE_ORDER 环境变量"} 4 C -- "未设置 或 值为 FASTEST_FIRST" --> D["按计算能力降序排序\nsm_89 > sm_86 > sm_80 > sm_75 ..."] 5 C -- "值为 PCI_BUS_ID" --> E["按 PCI 总线地址升序排序\n结果与 nvidia-smi 保持一致"] 6 D --> F["生成完整的设备编号映射(初始映射)"] 7 E --> F 8 F --> G{"检查 CUDA_VISIBLE_DEVICES 环境变量"} 9 G -- "未设置" --> H["所有 GPU 可见,编号使用初始映射,不变"] 10 G -- "已设置,如 '0,2'" --> I["过滤掉不在列表中的 GPU\n剩余 GPU 重新从 0 开始连续编号"] 11 H --> J["最终设备映射固定,不可再改变"] 12 I --> J 13
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 决定的排序结果中的位置:
1# 只让 cuda:0 和 cuda:2 可见(基于当前 CUDA_DEVICE_ORDER 的排序) 2export CUDA_VISIBLE_DEVICES=0,2 3 4# 完全禁用 GPU,让程序跑在 CPU 上 5export CUDA_VISIBLE_DEVICES="" 6 7# 也可以用逗号分隔的列表,顺序决定最终的 cuda 编号 8# 例如 CUDA_VISIBLE_DEVICES=2,0 会让原来的 cuda:2 变成 cuda:0,cuda:0 变成 cuda:1 9export CUDA_VISIBLE_DEVICES=2,0 10
这里有一个很容易被忽视的细节: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 的初始映射是:
1cuda:0 = 4090(sm_89,最强) 2cuda:1 = T4(Bus 5E) 3cuda:2 = T4(Bus AF) 4cuda:3 = T4(Bus D8) 5
现在设置 CUDA_VISIBLE_DEVICES=1,2,意思是"只让初始映射中 cuda:1 和 cuda:2 对应的 GPU 可见":
1export CUDA_VISIBLE_DEVICES=1,2 2python -c " 3import torch 4for i in range(torch.cuda.device_count()): 5 print(f'cuda:{i} -> {torch.cuda.get_device_name(i)}') 6" 7
输出:
1cuda:0 -> Tesla T4 (原 cuda:1,即 Bus 5E 的 T4,重新编号为 0) 2cuda:1 -> Tesla T4 (原 cuda:2,即 Bus AF 的 T4,重新编号为 1) 3
此时 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 一致:
1cuda:0 = T4(Bus 5E) 2cuda:1 = 4090(Bus 86) 3cuda:2 = T4(Bus AF) 4cuda:3 = T4(Bus D8) 5
同样设置 CUDA_VISIBLE_DEVICES=1,2:
1export CUDA_DEVICE_ORDER=PCI_BUS_ID 2export CUDA_VISIBLE_DEVICES=1,2 3python -c " 4import torch 5for i in range(torch.cuda.device_count()): 6 print(f'cuda:{i} -> {torch.cuda.get_device_name(i)}') 7" 8
输出:
1cuda:0 -> NVIDIA GeForce RTX 4090 (原 cuda:1,即 4090,重新编号为 0) 2cuda:1 -> Tesla T4 (原 cuda:2,即 Bus AF 的 T4,重新编号为 1) 3
同样是 CUDA_VISIBLE_DEVICES=1,2,但因为 CUDA_DEVICE_ORDER 不同,两个场景的最终结果完全不同!场景一选出了两张 T4,场景二选出了一张 4090 和一张 T4。
2.3 叠加效应的完整流程图
1flowchart TD 2 A["物理 GPU 列表:T4(5E), 4090(86), T4(AF), T4(D8)"] --> B{"CUDA_DEVICE_ORDER?"} 3 4 B -- "FASTEST_FIRST(默认)" --> C["初始映射:\ncuda:0=4090, cuda:1=T4(5E)\ncuda:2=T4(AF), cuda:3=T4(D8)"] 5 B -- "PCI_BUS_ID" --> D["初始映射(与 nvidia-smi 一致):\ncuda:0=T4(5E), cuda:1=4090\ncuda:2=T4(AF), cuda:3=T4(D8)"] 6 7 C --> E{"CUDA_VISIBLE_DEVICES?"} 8 D --> F{"CUDA_VISIBLE_DEVICES?"} 9 10 E -- "未设置" --> G["最终:cuda:0=4090, cuda:1=T4(5E)\ncuda:2=T4(AF), cuda:3=T4(D8)"] 11 E -- "设为 1,2" --> H["过滤后重新编号:\ncuda:0=T4(5E), cuda:1=T4(AF)"] 12 E -- "设为 0" --> I["过滤后重新编号:\ncuda:0=4090(仅此一张)"] 13 14 F -- "未设置" --> J["最终:cuda:0=T4(5E), cuda:1=4090\ncuda:2=T4(AF), cuda:3=T4(D8)"] 15 F -- "设为 1,2" --> K["过滤后重新编号:\ncuda:0=4090, cuda:1=T4(AF)"] 16 F -- "设为 0" --> L["过滤后重新编号:\ncuda:0=T4(5E)(仅此一张)"] 17
核心规则: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:
1export CUDA_DEVICE_ORDER=PCI_BUS_ID 2 3# modeA 服务:只看到 GPU 0(T4) 4CUDA_VISIBLE_DEVICES=0 python xxxxxxx.py & 5 6# modelB 服务:只看到 GPU 1(4090) 7CUDA_VISIBLE_DEVICES=1 python xxxxxxx.py & 8 9# 另外两个 modeA 服务 10CUDA_VISIBLE_DEVICES=2 python xxxxxxx.py & 11CUDA_VISIBLE_DEVICES=3 python xxxxxxx.py & 12
这种写法的好处是:每个服务进程里,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:
1sequenceDiagram 2 participant User as 用户(执行 torch.distributed.run) 3 participant Launch as torch.distributed.run(主进程) 4 participant W0 as Worker 进程 0 5 participant W1 as Worker 进程 1 6 participant W2 as Worker 进程 2 7 participant W3 as Worker 进程 3 8 9 User->>Launch: 启动,--nproc_per_node=4 10 Launch->>W0: fork 子进程,设置 CUDA_VISIBLE_DEVICES=0,LOCAL_RANK=0 11 Launch->>W1: fork 子进程,设置 CUDA_VISIBLE_DEVICES=1,LOCAL_RANK=1 12 Launch->>W2: fork 子进程,设置 CUDA_VISIBLE_DEVICES=2,LOCAL_RANK=2 13 Launch->>W3: fork 子进程,设置 CUDA_VISIBLE_DEVICES=3,LOCAL_RANK=3 14 15 Note over W0: CUDA_VISIBLE_DEVICES=0\n此时 cuda:0 对应哪张物理 GPU?\n取决于父进程的 CUDA_DEVICE_ORDER 设置 16 Note over W1: CUDA_VISIBLE_DEVICES=1\ncuda:0 = 第二张 GPU(按 CUDA_DEVICE_ORDER) 17 Note over W2: CUDA_VISIBLE_DEVICES=2\ncuda:0 = 第三张 GPU 18 Note over W3: CUDA_VISIBLE_DEVICES=3\ncuda:0 = 第四张 GPU 19
关键点在于:torch.distributed.run 设置的 CUDA_VISIBLE_DEVICES=0、CUDA_VISIBLE_DEVICES=1 等,这里的 0、1、2、3 是什么含义?它们是 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。
1sequenceDiagram 2 participant DS as DeepSpeed Launcher 3 participant W0 as Worker 0(cuda:0=4090) 4 participant W1 as Worker 1(cuda:0=T4) 5 participant W2 as Worker 2(cuda:0=T4) 6 participant W3 as Worker 3(cuda:0=T4) 7 8 DS->>W0: CUDA_VISIBLE_DEVICES=0(FASTEST_FIRST 下是 4090) 9 DS->>W1: CUDA_VISIBLE_DEVICES=1(T4) 10 DS->>W2: CUDA_VISIBLE_DEVICES=2(T4) 11 DS->>W3: CUDA_VISIBLE_DEVICES=3(T4) 12 13 Note over W0,W3: ZeRO-3 每步都需要 All-Reduce 通信\n所有 Worker 必须同步梯度\n整体速度受最慢的节点限制 14 15 W0->>W0: 4090 计算 Batch 0,1.0s 完成 16 W1->>W1: T4 计算 Batch 1,13.0s 完成 17 W2->>W2: T4 计算 Batch 2,13.2s 完成 18 W3->>W3: T4 计算 Batch 3,13.1s 完成 19 20 Note over W0: 4090 算完后等待 12 秒\n等三张 T4 完成后才能开始 All-Reduce\n4090 GPU 利用率不足 8% 21
问题分析
这个案例揭示了混合 GPU 做数据并行训练的本质矛盾:数据并行要求所有 Worker 在每个 step 结束时同步梯度(All-Reduce 操作),因此整体速度受到最慢的那张卡的制约。4090 虽然快 13 倍,但它 92% 的时间都在空等三张 T4,实际利用率极低。
从效率角度看,这种配置下的 4 卡训练速度,大约等于 4 张 T4 的训练速度,4090 几乎没有发挥作用。
根本原因
即使编号设置正确(设置了 PCI_BUS_ID),在混合型号 GPU 上做数据并行训练也是低效的。编号问题和架构问题是两个独立的问题,解决了编号问题不等于解决了架构问题。
正确的做法是:不要在异构 GPU 上做数据并行,而是按任务类型分配 GPU(详见第五节)。
3.3 安全的多进程启动方式
无论使用什么分布式训练框架,安全的多进程启动模板如下:
1#!/bin/bash 2 3# 第一步:无论如何,先统一编号体系 4# 这行必须在所有 python 调用之前 5export CUDA_DEVICE_ORDER=PCI_BUS_ID 6 7# 方式一:让 torch.distributed.run 自动分配(编号已与 nvidia-smi 统一,可信赖) 8python -m torch.distributed.run \ 9 --nproc_per_node=4 \ 10 --master_addr=localhost \ 11 --master_port=12355 \ 12 train.py 13 14# 方式二:手动隔离每个进程(更明确,生产环境推荐) 15# 设置了 PCI_BUS_ID 之后,CUDA_VISIBLE_DEVICES 的编号与 nvidia-smi 一致 16CUDA_VISIBLE_DEVICES=0 python xxxxxx.py & # T4,Bus 5E 17CUDA_VISIBLE_DEVICES=1 python xxxxxx.py & # 4090,Bus 86 18CUDA_VISIBLE_DEVICES=2 python xxxxxx.py & # T4,Bus AF 19CUDA_VISIBLE_DEVICES=3 python xxxxxx.py & # T4,Bus D8 20 21wait # 等待所有后台进程结束 22
3.4 进程内验证环境变量的生效情况
在分布式训练的 Worker 进程里,可以加入启动时的验证代码,确认每个进程确实拿到了预期的 GPU:
1import os 2import torch 3import torch.distributed as dist 4 5def worker_gpu_check(): 6 """在每个 Worker 进程启动时验证 GPU 分配""" 7 local_rank = int(os.environ.get("LOCAL_RANK", 0)) 8 device_order = os.environ.get("CUDA_DEVICE_ORDER", "NOT SET") 9 visible = os.environ.get("CUDA_VISIBLE_DEVICES", "NOT SET") 10 11 # 每个 Worker 进程都只看到一张 GPU(框架设置了 CUDA_VISIBLE_DEVICES) 12 # 所以 cuda:0 就是这个 Worker 的 GPU 13 gpu_name = torch.cuda.get_device_name(0) 14 15 print(f"[Worker {local_rank}] " 16 f"CUDA_DEVICE_ORDER={device_order}, " 17 f"CUDA_VISIBLE_DEVICES={visible}, " 18 f"cuda:0 = {gpu_name}") 19 20# 在训练脚本开头调用 21worker_gpu_check() 22
输出示例(设置了 PCI_BUS_ID,使用方式二手动隔离):
1[Worker 0] CUDA_DEVICE_ORDER=PCI_BUS_ID, CUDA_VISIBLE_DEVICES=0, cuda:0 = Tesla T4 2[Worker 1] CUDA_DEVICE_ORDER=PCI_BUS_ID, CUDA_VISIBLE_DEVICES=1, cuda:0 = NVIDIA GeForce RTX 4090 3[Worker 2] CUDA_DEVICE_ORDER=PCI_BUS_ID, CUDA_VISIBLE_DEVICES=2, cuda:0 = Tesla T4 4[Worker 3] CUDA_DEVICE_ORDER=PCI_BUS_ID, CUDA_VISIBLE_DEVICES=3, cuda:0 = Tesla T4 5
四、容器化环境的特殊处理
Docker 和 Kubernetes 是 GPU 服务最常见的部署环境。在容器化环境中,GPU 编号问题有额外的复杂性。
4.1 Docker 中的 GPU 映射基础
使用 --gpus 参数启动 Docker 容器时,可以指定容器能看到哪些 GPU:
1# 让容器能看到所有 GPU 2docker run --gpus all my_image python train.py 3 4# 只让容器看到宿主机的 GPU 1 5docker run --gpus '"device=1"' my_image python train.py 6 7# 让容器看到宿主机的 GPU 0 和 GPU 2 8docker run --gpus '"device=0,2"' my_image python train.py 9
关键理解:容器内永远只看到从 0 开始的连续编号。如果你指定 device=1,容器内只有 cuda:0,它对应的是宿主机上编号为 1 的 GPU(这个"1"的含义取决于 CUDA_DEVICE_ORDER)。
1flowchart LR 2 subgraph 宿主机_按PCI_BUS_ID排序 3 H0["cuda:0 = T4(Bus 5E)"] 4 H1["cuda:1 = 4090(Bus 86)"] 5 H2["cuda:2 = T4(Bus AF)"] 6 H3["cuda:3 = T4(Bus D8)"] 7 end 8 9 subgraph 容器内_docker_gpus_device_1 10 C0["cuda:0 = 4090(宿主机 cuda:1 / Bus 86)"] 11 end 12 13 H1 -->|映射| C0 14
在容器里,cuda:0 就是 4090,这是符合预期的。容器内的代码写 cuda:0 即可,不需要关心宿主机上的编号。
4.2 CUDA_DEVICE_ORDER 对 Docker device= 编号的影响
这里有一个重要的细节:docker run --gpus '"device=1"' 里的 1,指的是宿主机上按 CUDA_DEVICE_ORDER 排序后的 cuda 编号,而不一定是 nvidia-smi 的物理编号。
1# 情形一:宿主机未设置 CUDA_DEVICE_ORDER(FASTEST_FIRST) 2# 宿主机的编号:cuda:0=4090, cuda:1=T4(Bus 5E), cuda:2=T4(AF), cuda:3=T4(D8) 3# 所以 device=1 指向的是 T4(Bus 5E),不是 4090 4 5docker run --gpus '"device=1"' my_image nvidia-smi 6# 容器内看到:Tesla T4 7 8# 情形二:宿主机设置了 CUDA_DEVICE_ORDER=PCI_BUS_ID 9# 宿主机的编号:cuda:0=T4(5E), cuda:1=4090(86), cuda:2=T4(AF), cuda:3=T4(D8) 10# 所以 device=1 指向的是 4090,与 nvidia-smi 显示一致 11 12docker run -e CUDA_DEVICE_ORDER=PCI_BUS_ID --gpus '"device=1"' my_image nvidia-smi 13# 容器内看到:NVIDIA GeForce RTX 4090 14
同样是 device=1,两种情形下容器得到的是完全不同的 GPU。这个细节非常容易踩坑,尤其是在脚本中写死了 device= 的编号时。
4.3 最安全的 Docker GPU 指定方式
最安全的方法是使用 PCI Bus ID 直接指定 GPU,完全绕过任何排序策略:
1# 先用 nvidia-smi 查出各 GPU 的 Bus ID 2nvidia-smi --query-gpu=index,name,pci.bus_id --format=csv,noheader 3# 输出: 4# 0, Tesla T4, 00000000:5E:00.0 5# 1, NVIDIA GeForce RTX 4090, 00000000:86:00.0 6# 2, Tesla T4, 00000000:AF:00.0 7# 3, Tesla T4, 00000000:D8:00.0 8 9# 用 Bus ID 直接指定,不受任何排序策略影响 10# 无论宿主机 CUDA_DEVICE_ORDER 怎么设置,这里的 4090 永远是 4090 11docker run --gpus '"device=00000000:86:00.0"' my_image python train.py 12
这种方式的优点:完全确定性,不受 CUDA_DEVICE_ORDER 影响,无论宿主机环境怎么变化,指定的始终是那张物理 GPU。
4.4 在容器内透传环境变量
另一个常见的做法是在容器启动时传入 CUDA_DEVICE_ORDER 环境变量,让容器内的行为与宿主机一致:
1# 方法一:通过 -e 参数传入 2docker run \ 3 -e CUDA_DEVICE_ORDER=PCI_BUS_ID \ 4 --gpus all \ 5 my_image python train.py 6 7# 方法二:通过 --env-file 传入(适合有很多环境变量的场景) 8cat > /tmp/gpu_env.txt << EOF 9CUDA_DEVICE_ORDER=PCI_BUS_ID 10PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True 11EOF 12 13docker run \ 14 --env-file /tmp/gpu_env.txt \ 15 --gpus all \ 16 my_image python train.py 17 18# 方法三:在 Dockerfile 中设置(对所有使用这个镜像的容器生效) 19# Dockerfile: 20# ENV CUDA_DEVICE_ORDER=PCI_BUS_ID 21
4.5 Kubernetes 中的 GPU 调度
在 Kubernetes 环境中,GPU 编号问题的表现形式有所不同:
1flowchart TD 2 A["Pod 的 resources.limits 中请求 1 个 GPU\nnvidia.com/gpu: 1"] --> B["Kubernetes 调度器选择节点"] 3 B --> C["nvidia-device-plugin 在选定节点上分配一张空闲的 GPU"] 4 C --> D{"分配到哪张?"} 5 D --> E["由 nvidia-device-plugin 决定,用户不直接控制\n通常是当前负载最低的那张"] 6 E --> F["通过 CUDA_VISIBLE_DEVICES 将这张 GPU 暴露给 Pod"] 7 F --> G["Pod 内只看到 cuda:0\n这个 cuda:0 就是被分配的那张 GPU"] 8 G --> H["单 GPU Pod:代码直接用 cuda:0,无需关心物理编号"] 9
单 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:
1# kubernetes deployment.yaml 2apiVersion: apps/v1 3kind: Deployment 4spec: 5 template: 6 spec: 7 containers: 8 - name: training 9 env: 10 - name: CUDA_DEVICE_ORDER 11 value: "PCI_BUS_ID" 12 - name: PYTORCH_CUDA_ALLOC_CONF 13 value: "expandable_segments:True" 14 resources: 15 limits: 16 nvidia.com/gpu: 4 17
五、混合 GPU 环境的架构设计
解决了编号问题之后,还需要思考混合 GPU 环境下的任务分配架构。即使编号完全正确,不合理的架构设计仍然会导致性能问题。
5.1 错误的架构:异构 GPU 做数据并行
数据并行(Data Parallelism)是最常见的多卡训练策略,它把每个训练 batch 切分成若干 mini-batch,分别在不同 GPU 上并行计算,最后汇总梯度。
1graph TD 2 subgraph "错误:异构 GPU 做数据并行" 3 D["完整数据集"] --> B0["Mini-Batch 0\n-> 4090 计算,1s 完成"] 4 D --> B1["Mini-Batch 1\n-> T4 计算,13s 完成"] 5 D --> B2["Mini-Batch 2\n-> T4 计算,13.2s 完成"] 6 D --> B3["Mini-Batch 3\n-> T4 计算,13.1s 完成"] 7 8 B0 --> SYNC["All-Reduce 梯度同步\n必须等待所有 GPU 完成\n实际等待时间 = T4 的计算时间 ≈ 13s"] 9 B1 --> SYNC 10 B2 --> SYNC 11 B3 --> SYNC 12 13 SYNC --> LOSS["每个 step 耗时约 13s\n4090 利用率不足 8%\n整体效率约等于 4 张 T4 并行"] 14 end 15
这种架构浪费了 4090 的大部分算力。购买 4090 的钱,却只得到了 T4 水平的训练效率。
5.2 正确的架构:按任务类型分配
混合 GPU 环境的正确设计原则是:不同性能的 GPU 跑不同性质的任务,而不是让所有 GPU 做相同的事情。
1graph TD 2 subgraph "正确:按任务类型分配 GPU" 3 REQ["用户请求流量"] --> ROUTER["请求路由层\n根据任务类型分发"] 4 5 ROUTER --> modeB["modelB 推理服务\n4090(cuda:1)\n计算密集型,对延迟敏感\n需要 FP16 高算力"] 6 ROUTER --> EMB0["modeA 服务 0\nT4(cuda:0)\n轻量级向量编码\nT4 算力足够"] 7 ROUTER --> EMB1["modeA 服务 1\nT4(cuda:2)\n负载均衡的第二个实例"] 8 ROUTER --> EMB2["modeA 服务 2\nT4(cuda:3)\n负载均衡的第三个实例"] 9 10 modeB --> RESP["合并结果,返回响应"] 11 EMB0 --> RESP 12 EMB1 --> RESP 13 EMB2 --> RESP 14 end 15
在这个架构下:
- 4090 专门负责计算量最大的 modelB,能充分发挥其 330 TFLOPS 的 FP16 算力
- 三张 T4 并行处理 modeA 请求,通过横向扩展提高吞吐量
- 各服务之间独立运行,互不干扰,GPU 利用率最大化
5.3 推理服务的设备分配代码
1import os 2import sys 3import torch 4 5# 必须在最顶部设置 6os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID") 7 8def verify_gpu_setup(expected_device_id: int, expected_gpu_name_keyword: str): 9 """ 10 服务启动时验证 GPU 分配是否正确。 11 12 参数: 13 expected_device_id: 预期使用的 cuda 设备编号 14 expected_gpu_name_keyword: 预期 GPU 名称中包含的关键词 15 例如 "4090"、"T4"、"A100" 16 """ 17 if not torch.cuda.is_available(): 18 print("[FATAL] CUDA 不可用,请检查驱动安装") 19 sys.exit(1) 20 21 device_count = torch.cuda.device_count() 22 if expected_device_id >= device_count: 23 print(f"[FATAL] 请求 cuda:{expected_device_id}," 24 f"但系统只有 {device_count} 张 GPU(cuda:0 到 cuda:{device_count - 1})") 25 sys.exit(1) 26 27 device_order = os.environ.get("CUDA_DEVICE_ORDER", "NOT SET") 28 if device_order != "PCI_BUS_ID": 29 print(f"[WARNING] CUDA_DEVICE_ORDER = {device_order},不是 PCI_BUS_ID") 30 print("[WARNING] GPU 编号可能与 nvidia-smi 不一致,建议设置 PCI_BUS_ID") 31 32 actual_name = torch.cuda.get_device_name(expected_device_id) 33 props = torch.cuda.get_device_properties(expected_device_id) 34 print(f"[INFO] cuda:{expected_device_id} -> {actual_name} " 35 f"(sm_{props.major}{props.minor}, {props.total_mem / 1024**3:.1f}GB)") 36 37 if expected_gpu_name_keyword not in actual_name: 38 print(f"[FATAL] 预期 GPU 包含 '{expected_gpu_name_keyword}'," 39 f"但实际是 '{actual_name}'") 40 print("[FATAL] GPU 分配错误,服务拒绝启动") 41 sys.exit(1) 42 43 print(f"[OK] GPU 验证通过:cuda:{expected_device_id} 确实是 {actual_name}") 44 return f"cuda:{expected_device_id}" 45 46 47# modelB 服务启动时验证(确保 cuda:1 是 4090) 48modeB_DEVICE = verify_gpu_setup(1, "4090") 49 50# modeA 服务启动时验证(确保 cuda:0 是 T4) 51modeA_DEVICE = verify_gpu_setup(0, "T4") 52
1flowchart TD 2 A["服务启动"] --> B["检查 CUDA 是否可用"] 3 B -- "不可用" --> ERR1["FATAL:退出"] 4 B -- "可用" --> C["检查 CUDA_DEVICE_ORDER 是否为 PCI_BUS_ID"] 5 C -- "不是" --> WARN["WARNING:打印警告,继续运行"] 6 C -- "是" --> D["获取 cuda:N 的设备名称"] 7 WARN --> D 8 D --> E{"名称包含预期关键词?"} 9 E -- "否" --> ERR2["FATAL:GPU 分配错误,退出"] 10 E -- "是" --> F["OK:GPU 验证通过,继续加载模型"] 11
六、真实案例深度分析
6.1 案例一:LoRA 微调写错卡导致 OOM
背景
开发者在一台 4 卡服务器(1 张 4090 + 3 张 T4)上做 Qwen-8B 模型的 LoRA 微调。8B 模型的 FP16 权重约需要 16GB 显存,而 T4 只有 15GB 显存,装不下。计划是把模型放到 4090 的 49GB 显存上。
错误代码
1# 开发者认为 CUDA_VISIBLE_DEVICES=1 会让进程只看到 GPU 1(4090) 2os.environ["CUDA_VISIBLE_DEVICES"] = "1" 3 4import torch 5from transformers import AutoModelForCausalLM 6 7# device_map="auto" 会自动把模型放到可用的 GPU 上 8# 由于设置了 CUDA_VISIBLE_DEVICES=1,开发者以为只有 4090 可见 9model = AutoModelForCausalLM.from_pretrained("Qwen-8B", device_map="auto") 10
问题分析
1flowchart LR 2 A["开发者意图\nCUDA_VISIBLE_DEVICES=1\n以为选中了 4090"] --> B["FASTEST_FIRST 排序下\ncuda:1 = T4(Bus 5E,15GB)\n而非 4090"] 3 B --> C["CUDA_VISIBLE_DEVICES=1\n过滤后容器内 cuda:0 = T4(15GB)"] 4 C --> D["8B 模型需要 ~16GB FP16\nT4 只有 15GB"] 5 D --> E["OOM:显存不足崩溃"] 6
错误信息:
1torch.cuda.OutOfMemoryError: CUDA out of memory. 2Tried to allocate 2.00 GiB. 3GPU 0 has a total capacity of 14.56 GiB of which 512.00 MiB is free. 4
开发者看到 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)。
正确写法:
1import os 2# 顺序不能错:先设置 DEVICE_ORDER,再设置 VISIBLE_DEVICES,最后 import torch 3os.environ["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID" 4os.environ["CUDA_VISIBLE_DEVICES"] = "1" # 现在 1 就是 nvidia-smi 里的 GPU 1(4090) 5 6import torch 7print(torch.cuda.get_device_name(0)) # 输出:NVIDIA GeForce RTX 4090 8 9from transformers import AutoModelForCausalLM 10model = AutoModelForCausalLM.from_pretrained("Qwen-8B", device_map="auto") 11
6.2 案例二:模型服务迁移后 QPS 骤降
背景
一个在线推理服务,原来部署在单卡 4090 服务器上,QPS(每秒请求数)为 120。为了降低成本,把服务迁移到一台混合 GPU 服务器(1 张 4090 + 3 张 T4),代码完全不改,QPS 降到了 15。
1graph TD 2 subgraph "迁移前(单卡 4090 服务器)" 3 S1["单张 4090\nPyTorch 默认 cuda:0 = 4090(没有别的卡)\nQPS: 120"] 4 end 5 6 subgraph "迁移后(混合 GPU 服务器,代码不变)" 7 S2["混合卡,代码仍写 cuda:0\nFASTEST_FIRST 下 cuda:0 = 4090(巧合正确)\n但..."] 8 S3["nvidia-smi 监控:4090 空闲,T4 满载"] 9 S2 --> S3 10 S3 --> S4["QPS: 15,与 T4 性能相符"] 11 end 12 13 S1 -- "直接迁移,代码不变" --> S2 14
这个案例更加诡异:代码写的是 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 来确保编号稳定可预期。
正确的修复方案:
1export CUDA_DEVICE_ORDER=PCI_BUS_ID 2 3# 每个服务用 CUDA_VISIBLE_DEVICES 隔离,确保独占各自的 GPU 4CUDA_VISIBLE_DEVICES=0 python server_modelA_0.py & # 独占 T4(Bus 5E) 5CUDA_VISIBLE_DEVICES=1 python server_modelB.py & # 独占 4090(Bus 86) 6CUDA_VISIBLE_DEVICES=2 python server_modelA_1.py & # 独占 T4(Bus AF) 7CUDA_VISIBLE_DEVICES=3 python server_modelA_2.py & # 独占 T4(Bus D8) 8
七、总结
1graph TD 2 ROOT["进阶场景核心要点"] 3 4 ROOT --> A["CUDA_VISIBLE_DEVICES 的索引\n来自 CUDA_DEVICE_ORDER 排序后的结果\n不是 nvidia-smi 的物理编号\n(除非设置了 PCI_BUS_ID)"] 5 ROOT --> B["torch.distributed / DeepSpeed\n会自动覆盖 CUDA_VISIBLE_DEVICES\n必须在调用框架之前设置好 CUDA_DEVICE_ORDER"] 6 ROOT --> C["Docker 中 device= 的编号\n同样受 CUDA_DEVICE_ORDER 影响\n最安全的方式是用 PCI Bus ID 直接指定"] 7 ROOT --> D["Kubernetes 单 GPU Pod\n无需关心编号,容器内只有 cuda:0\n多 GPU Pod 则需要在 YAML 中设置环境变量"] 8 ROOT --> E["混合 GPU 做数据并行\n速度受最慢的卡限制,效率极低\n应按任务类型分配,强卡做重任务"] 9 ROOT --> F["服务启动时加入 GPU 名称验证\n是生产环境的基本守则\n拒绝在错误 GPU 上启动"] 10
两个环境变量的完整使用模板:
1#!/bin/bash 2# 正确的完整模板 3 4# 第一步:统一排序规则(必须在所有 python 调用之前) 5export CUDA_DEVICE_ORDER=PCI_BUS_ID 6 7# 第二步:用 CUDA_VISIBLE_DEVICES 做进程隔离 8# 此时索引号与 nvidia-smi 的 GPU 编号一一对应,直观可靠 9CUDA_VISIBLE_DEVICES=0 python server_modelA_0.py & # nvidia-smi GPU 0 10CUDA_VISIBLE_DEVICES=1 python server_modelB.py & # nvidia-smi GPU 1 11CUDA_VISIBLE_DEVICES=2 python server_modelA_1.py & # nvidia-smi GPU 2 12CUDA_VISIBLE_DEVICES=3 python server_modelA_2.py & # nvidia-smi GPU 3 13
