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

作者:吴佳浩日期:2026/3/22

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

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

nvidia_cuda.jpg

作者:吴佳浩

撰稿时间: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=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。

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

GPU 编号进阶:CUDA_VISIBLE_DEVICES、多进程与容器化陷阱》 是转载文章,点击查看原文


相关推荐


PostgreSQL 入门学习教程,从入门到精通,PostgreSQL 16 服务器配置与数据库监控终极指南 —语法、案例与实战(18)
知识分享小能手2026/3/14

PostgreSQL 16 服务器配置与数据库监控终极指南 —语法、案例与实战 ✅ 一、服务器配置概述 PostgreSQL 16 的服务器配置主要通过 配置文件 和 SQL 命令 控制,涵盖连接、资源、日志、查询优化、统计收集等核心模块。 📁 主要配置文件: postgresql.conf:主配置文件(全局参数)pg_hba.conf:客户端认证配置(Host-Based Authentication)pg_ident.conf:用户映射配置(可选) ⚙️ 配置方式优先级: 会话级设


分享被迫变直播:AI·Spring养虾记就这样上线了
飞哥数智谈2026/3/6

今天在我的个人公众号上做了自己社群 AI·Spring 的第一次线上分享直播——养虾记第一期。 对,你没看错,社群的第一次直播是在个人号上。 本来想着第一次分享,直接走内部分享的,但昨天晚上忽然想到腾讯会议、飞书会议都有人数、时间限额,大晚上的把我搞清醒了,我又加班尝试的视频号直播。 可社群视频号还没下来,只能临时使用个人视频号直播了,于是,就这样赶鸭上架了。 腾讯会议2人不限时,超过2人40分钟,飞书会议个人版最多25人,最多45分钟,其实可以和嘉宾用腾讯会议的2人模式的,当时有点懵 AI


当 AI Agent 接管手机:移动端如何进行观测
阿里云云原生2026/2/26

作者:高玉龙(元泊) 背景介绍 最近,基于 AI Agent 的各种手机助手在社交媒体上爆火,它能够通过 AI 自动操作手机完成下单、比价、搜索等复杂任务。用户只需说一句“帮我找最便宜的 iPhone”,AI 就能自动打开购物 App、搜索商品、对比价格并完成下单。这种“AI 接管手机”的场景,让很多人看到了未来人机交互的新形态。 然而,当 AI 开始大规模操作手机时,传统的用户行为分析将会面临严重的数据污染问题,如: 转换率虚高:AI 自动下单会对转换率数据造成干扰,导致业务决策误判 用户路


宝塔安装-Redis
吃不胖爹2026/2/17

一、安装 Redis 步骤:宝塔面板 ——> 应用搜索 ——> redis ——> 安装即可 二、配置 Redis 1.宝塔配置 IP 以及密码 方法1 方法2 配置修改,这个就是Redis的配置文件了,可以根据自己的业务需求,进行更改 配置文件 bind 127.0.0.1 改成 bind 0.0.0.0 再追加 requirepass yourPassword(密码) 保存 重启redis 2.放行 Redis 对应的端口 切记:宝塔面板 与 服务器控制台 6379 端口都要放开,


mcp学习笔记(一)-mcp核心概念梳理
Shawn_Shawn2026/2/9

Model Context Protocol (MCP) ,即模型上下文协议,是一个开放标准和开源框架,旨在为大型语言模型(LLMs)应用提供一个标准化的接口,使其能够无缝集成和交互外部数据源、工具和系统。 其主要作用为: 提供标准化接口,让LLMs(或基于LLMs构建的AI代理)能够连接到各种外部资源,如数据库,文件系统,Service Api,爬虫等资源,获取到数据。 LLMs可以实时与mcp双向交互,及时更新LLM中的上下文信息并能够即时执行LLM发出的指令,完成任务。 解决碎片化:统一


Vue-Data 属性避坑指南
发现一只大呆瓜2026/1/31

前言 在 Vue 开发中,我们经常会遇到“明明修改了数据,视图却不更新”的尴尬场景。这通常与 Vue 的初始化顺序及响应式实现原理有关。本文将从 Data 属性的本质出发,解析响应式“丢失”的根本原因及解决方案。 一、 组件中的 Data 为什么必须是函数? 在 Vue 2 中,根实例的 data 可以是对象,但组件中的 data 必须是函数。 核心原因:数据隔离 对象形式:JavaScript 中的对象是引用类型。如果 data 是对象,所有组件实例将共享同一个内存地址。修改实例 A 的数据


一文读懂强化学习
不惑_2026/1/21

从一个小故事说起 你还记得小时候学骑自行车吗? 没有人一上来就会骑。刚开始的时候,你歪歪扭扭地扶着车把,脚踩上踏板,车子晃了两下——砰,摔了。膝盖破了皮,疼得龇牙咧嘴。 但你爬起来,又试了一次。这回你发现,身体稍微往左倾的时候,车把往右打一点,好像能稳住。于是你又骑了几米远,然后——又摔了。 就这样摔了无数次之后,突然有一天,你发现自己居然能骑着车满院子跑了。那种感觉特别神奇,你也说不清楚具体是怎么学会的,但就是会了。 这个过程,其实就藏着强化学习最核心的秘密。 那到底啥是强化学习? 咱们先别


华为eNSP模拟器综合实验之- HRP(华为冗余协议)双机热备
以太浮标2026/1/13

核心高可用技术汇总 实现网络高可用性,主要依赖于以下几项技术在不同网络层级的协同工作: 技术领域 关键技术 主要作用 解决的核心问题 网关冗余​ VRRP(虚拟路由冗余协议) 为终端提供虚拟网关,实现网关设备的主备切换。 单一网关设备故障导致网络中断。 链路冗余与防环​ MSTP(多生成树协议) 在存在物理环路的二层网络中,通过逻辑阻塞端口,构建


Rust:用 dyn trait 需要注意 object safety 哦
Pomelo_刘金2026/1/5

1)Rust 为什么会有 object safety 1.1 dyn Trait 到底是什么 dyn Trait 是类型擦除后的动态派发:编译期不关心具体类型是谁,运行时靠 vtable(虚表) 找到对应实现。 一个 &dyn Trait / Box<dyn Trait> 本质上是“胖指针”: data pointer:指向真实对象数据 vtable pointer:指向虚表(里面是一堆函数指针 + 一些元信息) 关键点:vtable 里的每个函数入口,必须是“确定的、统一的签名”。因为不管


基于深度学习的河道垃圾检测系统设计(YOLOv8)
我是杰尼2025/12/27

基于深度学习的河道垃圾检测系统设计(YOLOv8) 一、研究背景:AI 如何参与河道环境治理? 随着城市化进程加快,河道、湖泊、水库等水体中的塑料垃圾问题日益严峻。其中,塑料瓶因体积明显、数量庞大、难以自然降解,已成为水环境污染治理中的重点对象。 传统河道垃圾监测方式主要存在以下痛点: ❌ 人工巡查成本高、效率低 ❌ 监测结果主观性强,难以量化 ❌ 无法实现实时、连续监控 ❌ 难以形成数据闭环支撑决策 在此背景下,基于深度学习的目标检测技术为河道垃圾自动识别提供了新的解决方案。 本项目以

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 XYZ博客