☰ GPU 编程基础

6. 主机侧:拷贝、流、事件与多卡

kernel 写得再好,数据和它之间还隔着一条 PCIe。这一节讲主机侧的账:一次拷贝有多贵、怎么让拷贝和计算重叠、怎么正确地计时。很多“GPU 没提速”的案例,问题根本不在 kernel。

6.1 分离的内存

显存和主机内存是两套物理内存。设备指针在主机上解引用会崩,主机指针也不能直接在 kernel 里用(除非用了统一内存或 zero-copy)。完整骨架长这样:

host.cu

#include <cstdio>
#include <cstdlib>
#include <cuda_runtime.h>

#define CK(x) do { cudaError_t e_ = (x); if (e_ != cudaSuccess) { \
    fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, cudaGetErrorString(e_)); \
    std::exit(1); } } while (0)

int main() {
    const size_t bytes = 64ull << 20;              // 64 MiB
    float *h = nullptr, *d = nullptr;

    CK(cudaHostAlloc((void**)&h, bytes, cudaHostAllocDefault));  // pinned
    CK(cudaMalloc((void**)&d, bytes));

    CK(cudaMemcpy(d, h, bytes, cudaMemcpyHostToDevice));
    kernel<<<(bytes / 4 + 255) / 256, 256>>>(d, bytes / 4);
    CK(cudaGetLastError());
    CK(cudaMemcpy(h, d, bytes, cudaMemcpyDeviceToHost));

    CK(cudaFree(d));
    CK(cudaFreeHost(h));                            // pinned 用 cudaFreeHost
}

cudaMalloc 是同步的,而且可能很慢:驱动要建立页表映射,几 MB 的分配经常是几百微秒。不要在热路径里反复分配;用一次分配、重复使用,或者用 cudaMallocAsync 的池(CUDA 11.2+)。

UVA(统一虚拟地址)让主机指针和设备指针落在同一个地址空间,可以用 cudaPointerGetAttributes 判断一个指针属于谁。它不改变物理上的分离,只是省掉了“这个指针是哪边的”这类手工记账。

6.2 为什么必须用 pinned memory

普通 malloc 的页可能被换出,DMA 引擎不能直接拿它的物理地址。驱动只能先把数据拷到一块临时的锁定页,再发起传输——于是你的“异步”拷贝退化成同步,带宽也打折。用 cudaHostAlloc 或 cudaHostRegister 锁住页之后,DMA 可以直接读写:

// 错误:pageable 内存 + Async,实际是同步的
cudaMemcpyAsync(d, h_pageable, bytes, cudaMemcpyHostToDevice, s);

// 正确:pinned 内存,DMA 真正异步
cudaHostAlloc((void**)&h, bytes, cudaHostAllocDefault);
cudaMemcpyAsync(d, h, bytes, cudaMemcpyHostToDevice, s);

代价是锁住的页不能被换出、也不能被 OS 随意挪动,锁太多会拖累整个系统。通常只锁“正在流式传输”的那几块缓冲区。PCIe 4.0 x16 的单向带宽约 32 GB/s,5.0 x16 约 64 GB/s;H2D 和 D2H 可以同时进行,总线和双向计数时要注意别重复算。

6.3 流:把拷贝和计算叠起来

stream 是一个有序队列:同一个流里的操作按顺序执行,不同流之间可以并发。把数据切成几块,每块用独立的流做“拷贝进 → 计算 → 拷贝出”,就能让 DMA 和 SM 同时工作:

streams.cu

const int nStreams = 4;
cudaStream_t s[nStreams];
for (int i = 0; i < nStreams; ++i) CK(cudaStreamCreate(&s[i]));

size_t n = bytes / sizeof(float);
size_t chunk = n / nStreams;
for (int i = 0; i < nStreams; ++i) {
    size_t off = i * chunk;
    size_t cnt = (i == nStreams - 1) ? (n - off) : chunk;   // 最后一块收尾
    CK(cudaMemcpyAsync(d + off, h + off, cnt * sizeof(float),
                       cudaMemcpyHostToDevice, s[i]));
    kernel<<<(cnt + 255) / 256, 256, 0, s[i]>>>(d + off, cnt);
    CK(cudaMemcpyAsync(h + off, d + off, cnt * sizeof(float),
                       cudaMemcpyDeviceToHost, s[i]));
}
for (int i = 0; i < nStreams; ++i) CK(cudaStreamSynchronize(s[i]));
for (int i = 0; i < nStreams; ++i) CK(cudaStreamDestroy(s[i]));

三个前提同时满足才有重叠:内存是 pinned 的、传输和计算在不同的流、它们访问的缓冲区不冲突。切块大小也有讲究:太小(< 1 MB)被 launch 开销吃掉,太大(> 几十 MB)末尾的尾巴盖不住。经验值 1–16 MB 一块。

默认流(legacy default stream)会和所有其他流隐式同步,等于把并发关掉。要么显式使用非默认流,要么编译时加 --default-stream per-thread,把默认流变成“每线程一个”。

6.4 用事件计时

主机计时器会被调度抖动污染,而且 cudaDeviceSynchronize() 之前的代码并不代表 GPU 真的开始/结束。用 CUDA event 在流里打点:

cudaEvent_t start, stop;
CK(cudaEventCreate(&start));
CK(cudaEventCreate(&stop));

CK(cudaEventRecord(start, s));
for (int i = 0; i < iters; ++i) kernel<<<blocks, 256, 0, s>>>(d, n);
CK(cudaEventRecord(stop, s));
CK(cudaEventSynchronize(stop));

float ms = 0.0f;
CK(cudaEventElapsedTime(&ms, start, stop));
printf("%.3f ms/iter\n", ms / iters);

注意循环里不要放同步,否则测到的是“每次 launch + 同步”的和,不是吞吐。事件本身也有约 1–2 µs 的开销,测 5 µs 的 kernel 时要多跑几次取平均。

6.5 统一内存与 zero-copy

cudaMallocManaged 返回一块“主机和设备都能访问”的指针,第一次在设备上访问时按页迁移。它省掉了显式拷贝,但:

zero-copy(映射的 pinned 内存)让 GPU 直接通过 PCIe 读主机内存,适合“只读一次、用完就丢”的数据;每次访问都要过 PCIe,延迟高、带宽低。

GH200 这类用 NVLink-C2C 连接 CPU/GPU 的机器让边界变模糊,但编程模型仍然是分离的:缓存一致性解决的是正确性,不是带宽。

6.6 多卡

多卡的基本操作是“每张卡一个上下文 + 显式指定设备”:

int can = 0;
CK(cudaDeviceCanAccessPeer(&can, 1, 0));    // 1 能否直接访问 0 的显存
if (can) {
    CK(cudaSetDevice(1));
    CK(cudaDeviceEnablePeerAccess(0, 0));
}

同一台机器上,NVLink 的卡间带宽约 900 GB/s(H100),PCIe 只有 32–64 GB/s。多卡训练/推理的集合通信(all-reduce、all-gather)用 NCCL,不要手写;它会在 NVLink / PCIe / IB 之间自动选路径。

6.7 和 CPU 的对照

CPUGPU差别
malloc / freecudaMalloc / cudaFree后者同步且慢,不能超卖
memcpycudaMemcpy跨 PCIe,同步版本会阻塞主机
mmap 懒分配cudaMallocManaged按页迁移,首次访问慢
page cacheL2 + sharedGPU 的缓存小得多,要靠显式复用
swap没有显存不够就是硬失败
线程池stream + 多 kernel流是队列,不是线程

6.8 容易写错的地方

常见错误
  • 用 pageable 内存做 cudaMemcpyAsync。看起来异步,实际同步且慢;一定要配 pinned。
  • 在主机上解引用设备指针。段错误,或者更糟:读到垃圾。
  • 忘了同步就读主机缓冲区。cudaMemcpy(同步版)会等,但 cudaMemcpyAsync + 主机线程直接读不会。
  • 默认流的隐式同步。以为开了多个流就并发了,其实全被默认流串起来。
  • 在热循环里 cudaMalloc / cudaFree。分配是同步的,会毁掉流水线。
  • 忽略错误码。CUDA 的错误是异步的,而且很多是“粘性”的:第一次出错后,后续调用都返回同一个错误,看起来像别处坏了。用 CK 宏在每个调用后检查。
  • 多卡时用错设备上下文。在 device 1 上解引用 device 0 的指针,需要先开 peer access,否则非法。