Skip to content

数据传输优化专题 ​

LLM 推理中的 CPU–GPU 传输不只有模型权重和 KV Cache,也包括每轮调度产生的 token、序列长度、请求索引,以及采样后的结果。一次传输即使只有几个字节,也可能让 CPU 等待前面整段 GPU 工作,打断调度与计算的流水线。

本文沿两个方向展开:H2D 关注如何异步提交数据,D2H 关注何时必须把 GPU 结果交给 CPU。前者涉及 non_blocking、Pinned Memory 和 Stream;后者涉及 .tolist()、.cpu()、.item(),以及依赖 CUDA Tensor 的 Python 条件判断。

text
CPU–GPU 数据传输
├── H2D:CPU → GPU
│   ├── non_blocking:减少 CPU 提交后的等待
│   ├── Pinned Memory:让锁页内存直接参与 DMA
│   └── 独立 Stream:让传输与无依赖的计算重叠
└── D2H:GPU → CPU
    ├── .tolist():转换为 Python 列表
    ├── .cpu():取得 CPU Tensor
    ├── .item():取得 Python 标量
    └── Python 条件判断:CPU 必须知道判断结果

下文以 PyTorch eager 模式和 CUDA 为例。接口名是 non_blocking、pin_memory 和 .tolist()。

1. 先区分提交、完成与同步 ​

执行 y = model(x) 时,CPU 通常只是向 CUDA Stream 提交计算,函数返回并不意味着 GPU 已经算完。Stream 中的操作按顺序执行,CPU 可以继续准备或提交后续任务。

text
CPU:提交 A → 提交 B → 提交 C → 继续执行
GPU:         执行 A → 执行 B → 执行 C

当 CPU 必须取得 GPU 上的真实数据时,就需要等到相关结果可用。假设 A、B、C 已经排队,一次回读 C 的操作即使只传 8 字节,也需要先等 A、B、C 执行完成。

因此,在时间线上看到某次 .item() 或拷贝调用耗时 2 ms,不能直接认为“这几个字节传了 2 ms”。调用耗时可能包含此前未完成工作的等待。

概念含义常见误解
CPU 提交耗时Python/CUDA 调用到返回的时间返回就代表数据已经传完
完整完成耗时从开始到相关 GPU 工作完成可以用异步调用返回时间代替
CPU–GPU 重叠CPU 工作与 GPU 工作同时进行必须使用两个 CUDA Stream
GPU 传输–计算重叠DMA 拷贝与独立 Kernel 同时进行加一个 non_blocking 就自动实现

CPU–GPU 重叠与 GPU 上的传输–计算重叠是两件事。单个 Stream 也可以让 CPU 提前执行;但同一个 Stream 中的 H2D 和后续 Kernel 仍然按序执行。PyTorch CUDA 执行语义

2. H2D:异步拷贝 ​

2.1 从 Python 列表构造 CUDA Tensor ​

在调度代码里,常见的写法是:

python
input_ids = torch.tensor(last_tokens, dtype=torch.int64, device="cuda")

Python 列表位于 CPU,指定 device="cuda" 并不会让 Python 元素直接在 GPU 上解析。常规构造路径先将列表转换成 CPU 上的连续 Tensor,再搬到目标设备;内部设备转换使用阻塞模式。PyTorch Tensor 构造源码

可以显式拆开:

python
input_ids = torch.tensor(
    last_tokens, dtype=torch.int64, device="cpu"
).to("cuda", non_blocking=True)

两种写法都需要执行:

text
Python 列表 → CPU 上的 int64 数据 → H2D → GPU Tensor

改变的是提交后的等待行为。显式写 device="cpu" 也能避免默认设备被修改后,前半段意外创建 CUDA Tensor。

2.2 non_blocking:让 CPU 提前继续 ​

在常见的 PyTorch CPU–CUDA 拷贝路径中,底层提交 cudaMemcpyAsync;默认阻塞路径随后执行 Stream 同步。设置 non_blocking=True 后,PyTorch 省掉这次显式同步等待。PyTorch 拷贝实现说明

text
blocking:
CPU:提交 H2D ── 等待完成 ── 后续 CPU 工作
GPU:             H2D

non_blocking:
CPU:提交 H2D → 后续 CPU 工作
GPU:             H2D

non_blocking=True 表示尽可能相对 CPU 异步执行。普通 CPU 内存传输可能触发驱动暂存或内部同步,所以不能保证整个调用完全不阻塞。Tensor.to 文档

同一 Stream 中,紧接着使用返回的 CUDA Tensor 是安全的:

python
x = cpu_tensor.to("cuda", non_blocking=True)
y = x + 1  # 同一 Stream:先完成 H2D,再执行加法

不需要为了这次 GPU 消费立刻调用 torch.cuda.synchronize();那会重新让 CPU 等待。换到其他 Stream 消费时,则需要建立对应的数据依赖。

2.3 Pinned Memory:减少中间暂存 ​

普通 CPU Tensor 默认使用 Pageable Memory,即可分页内存。CUDA 传输时可能需要先将数据暂存到驱动管理的锁页缓冲,再交给 DMA 引擎:

text
Pageable CPU 内存 → Pinned 暂存内存 → GPU 显存
                    CPU 数据移动      DMA

Pinned Memory,也叫 Page-locked Memory,保证相关内存页驻留,并提供稳定的 DMA 映射。数据已经在这类内存里时,可以直接作为 DMA 传输来源:

text
Pinned CPU 内存 ───── DMA ─────→ GPU 显存

锁页不要求整块物理内存连续,也不意味着普通内存每次传输都会发生磁盘换页。关键是驱动能否直接、稳定地使用这些内存页。CUDA API 同步与暂存语义

配置传输路径CPU 等待行为
Pageable + blocking可能需要中间暂存PyTorch 等待拷贝完成
Pageable + non_blocking仍可能需要中间暂存省掉显式等待,驱动仍可能阻塞
Pinned + blocking可直接作为 DMA 源仍然等待完成
Pinned + non_blocking可直接作为 DMA 源支持异步提交后继续 CPU 工作

Pinned Memory 不能替代 non_blocking,non_blocking 也不会自动把普通内存变成可复用的 pinned buffer。

2.4 为什么每次临时 pin 可能更慢 ​

下面的写法会在调用线程上把普通 CPU Tensor 复制到 pinned 存储:

python
x = torch.tensor(last_tokens, dtype=torch.int64, device="cpu")
x_gpu = x.pin_memory().to("cuda", non_blocking=True)

这增加了 CPU 拷贝、Tensor 管理和 pinned allocator 的操作。即使分配器复用了缓存,复制数据的成本仍然存在。对于很小的 token 列表,这些开销可能超过异步传输的收益。

高频传输更适合提前申请、反复填充:

python
# 初始化阶段
host_buf = torch.empty(
    capacity, dtype=torch.int64, device="cpu", pin_memory=True
)
copy_done = None

# 调度循环示意,GPU 消费与 H2D 在同一 Stream
for last_tokens in batches:
    if copy_done is not None:
        copy_done.synchronize()  # 等上次 DMA 读完,才可覆盖源 buffer

    n = len(last_tokens)
    assert n <= capacity
    host_buf[:n].copy_(
        torch.tensor(last_tokens, dtype=torch.int64, device="cpu")
    )
    input_ids = host_buf[:n].to("cuda", non_blocking=True)
    copy_done = torch.cuda.current_stream().record_event()
    consume_on_gpu(input_ids)

这段代码省掉了每轮申请 pinned buffer,但仍包含“列表 → 普通 CPU Tensor → pinned buffer”。如果上游能直接写入 pinned buffer,还可以减少中间对象和拷贝。例如,在初始化时取得 host_buf.numpy() 零拷贝视图,再通过视图填入列表;列表到 int64 的转换仍然有成本。

复用需要区分两个生命周期:CPU 源 buffer 在 H2D 完成后才能覆盖;GPU 目标 buffer 在上一次消费者完成后才能覆盖。 双缓冲可以把下一批填充与上一批传输重叠,但每个槽位都需要管理完成事件。

2.5 独立 Stream:让 DMA 与计算重叠 ​

如果 H2D 和计算都放在同一个 Stream,即使 CPU 不等待,GPU 仍然按序执行:

text
同一 Stream:  [H2D][计算]

使用独立 Stream,并且计算不依赖这次传入的数据,才有机会得到:

text
Copy Stream:     [下一批 H2D]
Compute Stream:  [当前批计算]

这需要可用的 DMA/计算硬件资源、适合异步传输的主机内存,以及正确的数据依赖。不能让某一批计算在自己的输入尚未拷完时开始读取。推理调度中的相关流水线见 Overlap Schedule。

3. H2D 实测:收益来自哪里 ​

2026-09-06,在单张 NVIDIA H20 上测试了 92 组配置,共 5680 个计时样本,正确性检查全部通过。环境为 PyTorch 2.11.0+cu130、CUDA Runtime 13.0、Python 3.12.3;CPU 固定到 GPU 本地 NUMA 节点,使用单线程,不锁 GPU 时钟。

各组预热 10 次,随机交错方法顺序;列表和排队场景每方法 80 个样本,纯传输和 GEMM 重叠场景每方法 40 个样本。下面使用中位数:

  • CPU 调用返回只测调用到返回。
  • 完整完成包含最后的 torch.cuda.synchronize();每个样本开始也先同步。
  • 列表场景包含列表到 Tensor 的转换,但不包含输入列表本身的生成;预分配场景不计初次分配和准备数据。
  • 正确性回读和 Profiler 不计入正式计时。小 Tensor 的完成耗时包含末尾同步调用开销,不等同于纯 DMA 时间。

H20 实测:32 token 列表延迟、64 MiB 传输的 CPU 返回与完成耗时、排队工作重叠,以及真实 H2D 与 GEMM 时间线

图 1:A–C 为重复测量统计,D 为单独采集的实际 GPU memcpy/kernel 轨迹。点击查看大图。

3.1 小 token 列表:减少同步比临时 pin 更划算 ​

方法32 token 完成耗时8192 token 完成耗时
原始 torch.tensor(..., device="cuda")19.25 μs462.64 μs
CPU Tensor + non_blocking15.34 μs452.05 μs
每次 .pin_memory() + non_blocking20.02 μs455.66 μs
复用 pinned,仍先构造 CPU Tensor 再填入16.28 μs454.69 μs
列表直接填入复用 pinned 的 NumPy 视图 + non_blocking12.61 μs266.34 μs

32 token 下,仅改 non_blocking 得到 1.26× 加速,完成时间减少约 20%。临时 pin 反而略慢;复用 pinned、但保留中转 CPU Tensor,也没有超过单改 non_blocking。

最后一行同时优化了列表转换路径、减少中间 Tensor,不能把全部提升归因于 pin。列表增大后,CPU 转换开销越来越突出;测试到 131072 token 时,原始与单改 non_blocking 分别为 6.896 ms 和 6.887 ms,差距很小。

3.2 64 MiB:传得更快与返回更早 ​

CPU 源与 GPU 目标均提前分配,执行 dst.copy_(src, non_blocking=nb):

CPU 源non_blockingCPU 调用返回完整完成
PageableFalse4.192 ms4.208 ms
PageableTrue4.175 ms4.195 ms
PinnedFalse1.224 ms1.230 ms
PinnedTrue0.008 ms1.223 ms

提前准备好 pinned 数据后,完整传输约改善 3.44×。同为 pinned 时,non_blocking 对完整传输时间影响很小,但 CPU 从等待约 1.22 ms,变成约 8 μs 就返回。

这里的 8 μs 不是传完 64 MiB 的时间。普通内存加 non_blocking 也没有明显减少调用耗时,说明本次大块传输的驱动路径仍然显著阻塞。不能把预分配场景的提升直接套到从 Python 列表开始的完整流程。

3.3 连续小拷贝:同步成本累积 ​

预分配源和目标,连续提交 32 次 64 KiB 拷贝,最后统一等待:

配置32 次拷贝完整完成
Pageable + blocking561.71 μs
Pageable + non_blocking260.57 μs
Pinned + blocking358.14 μs
Pinned + non_blocking125.75 μs

组合相对本组 Pageable + blocking 基线达到 4.47×。non_blocking 减少每次拷贝后的等待,使提交更密集;pin 减少中间暂存。32 次 DMA 仍在同一 Stream 顺序执行。实验反复覆盖同一目标,仅用于隔离提交和传输成本,不代表 32 批数据的完整消费流程。

3.4 前面已有 GPU 工作:小 H2D 也可能等 2 ms ​

构造同一 Stream 中的受控场景:先排入约 2 ms GPU 工作,传入 32 个 token,再做 1 ms CPU 工作,最后由 GPU 消费 token。

配置H2D 的 Python 调用耗时整段完成
原始列表直接构造 CUDA Tensor2001.77 μs3.017 ms
CPU Tensor + non_blocking10.91 μs2.006 ms
复用 pinned + CPU copy + non_blocking12.32 μs2.006 ms

原始调用必须等待 H2D 完成,也就先等了 Stream 前面的约 2 ms 工作,CPU 的 1 ms 工作只能随后执行。non_blocking 提前返回后,这 1 ms CPU 工作与前面的 GPU 工作重叠,整段约 1.50×。

这台机器上的 256 B Pageable 源也能快速返回,因此额外 pin 没有明显收益。实验中的 GPU _sleep 与 CPU 忙等用于隔离等待关系,不是某个真实模型的吞吐测试。

3.5 H2D 与真实 GEMM 重叠 ​

64 MiB H2D 加一次独立的 4096×4096 BF16 GEMM,先提交拷贝,再提交计算。计算使用已有 GPU 数据,不消费本次 H2D 的结果:

配置整段完成
仅 pinned H2D1.224 ms
仅 GEMM1.043 ms
Pinned + blocking,独立 Stream2.276 ms
Pinned + non_blocking,同 Stream2.266 ms
Pinned + non_blocking,独立 Stream1.235 ms

固定 pinned + non_blocking,只改变 Stream 组织就得到 1.84×。Profiler 确认独立 Stream 下实际 memcpy 与 Kernel 区间重叠;同 Stream 则先传输、再计算。

blocking 配置虽然用了独立 Stream,但 CPU 要等拷贝完成才能提交后面的 GEMM,因此这个提交顺序下没有重叠。提交顺序、数据依赖和内存类型必须一起分析。

4. D2H:隐式同步 ​

H2D 可以让 CPU 在提交后继续工作;D2H 则经常发生在 CPU 需要读取结果的位置。即使传输能够异步提交,CPU 真正使用数据前仍然必须等它就绪。优化的方向通常是减少回读次数、减少回读数据量,或将消费时机后移。

以下是 API 语义和代码示例,不是上面 H20 实验测得的 D2H 加速结果。

4.1 .tolist():Python 列表需要真实数据 ​

python
next_tokens = next_token_ids.tolist()  # next_token_ids 在 CUDA 上

.tolist() 返回 Python 标量或嵌套列表;如果 Tensor 在 GPU 上,会先自动移到 CPU。它既包含 D2H 和相关等待,也包含 Python 对象构造。Tensor.tolist 文档

如果接下来还要把同一批 token 送回 GPU,会形成一次往返:

python
tokens = next_token_ids.tolist()  # GPU → CPU,等待并生成列表
input_ids = torch.tensor(tokens, dtype=torch.int64, device="cuda")

如果下一轮计算仍能使用 GPU 数据,可以保留 Tensor,必要时在 GPU 上进行 Gather、重排或 dtype 转换。CPU 的输出、停止条件检查则在需要时处理,避免为了 GPU 到 GPU 的接力先绕回 CPU。

这要求保留真实的数据依赖;例如自回归 Decode 的下一轮必须等待上一轮 token 产生,只是这个等待可以放在 GPU Stream 中,而不必由 CPU 读取 token 来串联。

4.2 .cpu():拷回 CPU 的默认完成边界 ​

python
cpu_tokens = next_token_ids.cpu()
tokens = cpu_tokens.tolist()

当源在 CUDA 上时,普通 .cpu() 返回后可以读取 CPU Tensor,因此调用可能等待 GPU 生产和拷贝完成。紧接着的 .tolist() 只处理 CPU 数据,等待已经发生在前一行。若源本来就在 CPU 上,.cpu() 则不需要设备传输。Tensor.cpu 文档

.cpu() 没有 non_blocking 参数。可以使用 .to("cpu", non_blocking=True) 或向预分配 pinned CPU 目标执行 copy_,但不能在函数返回后立即假定 CPU 数据已经有效。

python
# 错误用法:异步 D2H 可能尚未完成
cpu_tokens = next_token_ids.to("cpu", non_blocking=True)
tokens = cpu_tokens.tolist()

CPU 上的 .tolist() 不会替你追踪此前 CUDA 拷贝是否完成;此时可能读到尚未写好的数据。detach() 也不能解决这个问题,它只影响 Autograd,不会拷贝数据或等待 CUDA。

4.3 .item():一个标量也能成为同步点 ​

python
token = next_token_ids[0].item()

.item() 将单元素 Tensor 转成 Python 数字。CPU 要拿到这个值,必须等 GPU 先把它算出来。多元素 Tensor 不能直接调用 .item()。Tensor.item 文档

逐元素回读会反复跨越这个边界:

python
# 多次标量回读与 Python 调用
tokens = [next_token_ids[i].item() for i in range(batch_size)]

# CPU 确实需要整批结果时,集中回读
tokens = next_token_ids.tolist()

后一种仍然需要同步,但可以减少逐元素回读和调用次数。若 CPU 只需要一个汇总值,可以先在 GPU 上归约,再回读一次标量;如果 CPU 暂时不需要值,则继续保留 GPU Tensor。

性能统计和日志也可能引入 .item()。例如每轮将 CUDA 上的统计值转成 Python 数字再打印,会把观察代码放进推理关键路径,可以考虑按需汇总、降低频率或延后读取。

4.4 条件判断:隐式读取 GPU 标量 ​

代码没有写 .cpu() 或 .item(),也可能触发相同的等待:

python
if (next_token_ids == eos_token_id).any():
    handle_finished_requests()

比较和 .any() 在 GPU 上执行,返回单元素 CUDA Tensor。但 Python 的 if 需要 CPU 上的布尔值,因此条件转换时必须等待结果。使用 bool(cuda_scalar),或让 Python while、and、or 对 CUDA Tensor 求真值,也会遇到类似边界。PyTorch 性能指南

可以将适合的数据选择保留在 GPU:

python
# 假设 x 是 CUDA Tensor,CPU 只用于决定逐元素符号处理
if (x.sum() > 0).item():
    y = x
else:
    y = -x

# 相同数值选择在 GPU 上完成
y = torch.where(x.sum() > 0, x, -x)

这并不适用于所有分支。torch.where 的两个分支值在选择前已经求值,不能把 torch.where(cond, expensive_a(), expensive_b()) 当成只执行一条路径的 Python if。终止请求、发送网络响应等 CPU 副作用,也不能简单改写成 Tensor 选择。

判断是否同步,要看条件是否依赖 GPU 中的数据,而不是只看有没有 if:

判断或访问eager 模式下是否需要读取 GPU 数据
if cuda_scalar:需要取得标量值
if mask.any():若 mask 在 CUDA 上,需要取得归约结果
if x.numel() > 0:通常只读 Tensor 元数据
if x.shape[0] > 0:通常只读形状元数据
if cpu_finished_count > 0:已有 CPU 值,不触发 D2H

直接对多元素 Tensor 求 Python 布尔值通常会报“布尔值不明确”,不会自动替你执行 any()。另外,CUDA 上的 nonzero() 等操作也可能因需要确定输出大小而发生同步,不能仅靠搜索 .cpu() 找全同步点。

4.5 异步 D2H:先提交,后消费 ​

当 CPU 有其他工作可做时,可以先把 GPU 结果异步拷回 pinned CPU buffer,在真正消费前等待事件。下面是单次传输的完整示例:

python
import torch

device = torch.device("cuda:0")
with torch.cuda.device(device):
    producer = torch.cuda.current_stream()
    copy_stream = torch.cuda.Stream()

    # 此例由 producer 生成数据
    gpu_result = torch.arange(32, dtype=torch.int64, device=device)
    host_result = torch.empty(
        gpu_result.shape,
        dtype=gpu_result.dtype,
        device="cpu",
        pin_memory=True,
    )
    copied = torch.cuda.Event()

    # GPU 依赖:等待 producer 已提交的工作,CPU 不在这里等待
    copy_stream.wait_stream(producer)
    with torch.cuda.stream(copy_stream):
        host_result.copy_(gpu_result, non_blocking=True)
        copied.record()

    # 防止源显存被 allocator 过早复用;不替代写入依赖
    gpu_result.record_stream(copy_stream)

    # 此处可以执行不依赖 host_result 的 CPU 工作

    copied.synchronize()  # CPU 读取前,明确等待本次 D2H 完成
    values = host_result.tolist()

这里有两个不同的等待:copy_stream.wait_stream(producer) 建立 GPU 生产到拷贝的依赖,CPU 可以继续执行;copied.synchronize() 则在 CPU 消费边界等待。

示例保留源和目标直到完成。生产代码若复用 buffer,还必须保证 GPU 源在 D2H 读取期间不被覆盖,CPU 目标在拷贝完成前不被读取,在 CPU 消费结束前也不被下一次传输覆盖。record_stream() 解决 allocator 生命周期问题,不会阻止其他代码改写源 Tensor。

如果提交后马上等待,收益可能很小。调度系统通常通过队列延后消费,例如提交当前轮计算后再处理上一轮结果;结束状态和输出依赖也必须随之正确处理,不能只移动一个 .cpu() 调用就认为流水线已经完成。Overlap Schedule 的结果处理

5. 定位与验证 ​

先检查每一处传输的源、目标以及真正消费位置,再判断能否消除、合并或延后。一次优化可能只是把等待从 .cpu() 移到了后面的事件同步,单看某个函数变快不够。

现象重点检查优化方向
小 token H2D 调用耗时很长是否等待 Stream 前面的工作non_blocking、避免过早同步
大块 H2D 调用仍然阻塞源是否 Pageable、是否有中间暂存提前准备 pinned buffer
加 pin 后变慢每轮分配、CPU 拷贝、列表转换复用、直接填充,或保留普通内存
每轮 .item() / .tolist()CPU 是否立刻需要结果合并回读、GPU 接力、延后消费
Python 条件造成 GPU 空隙条件是否依赖 CUDA 标量适合时使用 GPU 选择,或调整调度
异步 D2H 偶发错误CPU 是否在完成前读取在消费位置等待 Event

测量完成耗时应包含同步:

python
import time

# 预热后再测,run_case 表示待比较的完整操作
torch.cuda.synchronize()
t0 = time.perf_counter()
result = run_case()
t1 = time.perf_counter()
torch.cuda.synchronize()
t2 = time.perf_counter()

print("CPU return:", t1 - t0)
print("Completed:", t2 - t0)

这适合比较隔离操作。判断流水线收益时,还要测包含生产、传输、消费和最终排空的整段耗时;否则可能只测到了“提前返回”。Profiler 中应同时看 CPU 的 CUDA 调用、同步操作、GPU memcpy 和 Kernel 时间线,确认是否真的发生重叠。

6. 实验数据与复现 ​

本文 H20 数字来自同一次运行,可下载 基准脚本、汇总 CSV 和 原始样本 JSON。原始数据包含各组的样本、p10/p90/min、中位数和正确性状态。

脚本依赖 PyTorch 和 NumPy,从 Git 工作目录运行以记录源码版本。确认目标 GPU 空闲后显式设置 CUDA_VISIBLE_DEVICES,为 --output 传入尚不存在的新目录:

bash
python3 benchmark_h2d_pinned.py --output ./h2d-results-new --repeats 80

这些结果是单机单卡、预热后的 eager microbenchmark。它们说明不同配置如何影响传输和等待,不代表真实 SGLang 服务必然获得相同加速比。最终应在实际 batch 大小、数据准备方式和消费依赖下验证端到端延迟与吞吐。