异构计算——CPU 与 GPU 如何协同工作 / Heterogeneous Computing with CPUs and GPUs
📅 创建时间:2026-06-02 🏷️ 标签:#异构计算 #CPU #GPU #流水线 #异步执行 #PinnedMemory 📚 前置知识:[[04-cuda-programming-model]](CUDA 编程) [[08-mpi-cluster-hpc]](集群通信) 📚 相关知识:[[10-dl-training-optimization]](训练优化实战)
先抓住直觉
CPU 与 GPU 不是竞争关系,而是一条流水线的不同工位。最差的情况是 CPU 准备数据时 GPU 闲着,GPU 计算时传输通道又闲着;协同优化就是让准备、搬运和计算尽量重叠。
- 必须理解:异步执行、流水线、Pinned Memory 为何能帮助传输。
- 用到再查:Stream API、Unified Memory 提示函数和双缓冲模板。
- 读完能回答:为什么 GPU 利用率低不一定是 GPU Kernel 的问题?
场景:CPU 和 GPU 谁才是瓶颈?
┌─────────────────────────────────────────────────────────────┐
│ │
│ 训练一个 epoch,耗时分析: │
│ │
│ GPU Forward/Backward: 10 秒(90%) │
│ CPU 数据加载: 1 秒(9%) │
│ CPU→GPU 数据传输: 0.1 秒(1%) │
│ │
│ 看起来 GPU 占据了大部分时间。 │
│ 但为什么 GPU 利用率只有 60%? │
│ │
│ 原因:CPU 数据加载太慢,GPU 经常在等待数据! │
│ │
│ 本章学习如何让 CPU 和 GPU 协同工作,最大化效率。 │
│ │
└─────────────────────────────────────────────────────────────┘第1节:异构系统的基本架构
CPU-GPU 数据流
┌─────────────────────────────────────────────────────────────┐
│ CPU-GPU 异构系统 │
├─────────────────────────────────────────────────────────────┤
│ │
│ CPU │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 数据加载 数据预处理 调度控制 日志记录 │ │
│ │ (SSD) (OpenMP) (CUDA API) │ │
│ │ │ │
│ └────────────────────────────┬────────────────────────┘ │
│ │ PCIe │
│ ↓ │
│ ┌────────────────────────────┐ │
│ │ GPU 显存 (HBM) │ │
│ │ ┌──────────────────────┐ │ │
│ │ │ Forward | Backward │ │ ← GPU 计算 │
│ │ │ Kernel | Kernel │ │ │
│ │ └──────────────────────┘ │ │
│ └────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
关键问题:
1. 数据从 SSD → CPU → GPU,链路长
2. 如果 CPU 太慢,GPU 等待
3. 需要 CPU 和 GPU 并行工作异步执行原理
cpp
// ❌ 同步执行:CPU 等 GPU,浪费时间
cudaMemcpy(d_data, h_data, size, cudaMemcpyHostToDevice);
kernel<<<...>>>(d_data); // GPU 执行时 CPU 空闲
cudaMemcpy(h_result, d_result, size, cudaMemcpyDeviceToHost);
// ================================================================
// ✅ 异步执行:CPU 和 GPU 并行工作
// ================================================================
cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
// CPU 做数据加载(stream1)
cudaMemcpyAsync(d_data, h_data, size, cudaMemcpyHostToDevice, stream1);
// GPU 执行 kernel(stream1)
kernel<<<blocks, threads, 0, stream1>>>(d_data);
// CPU 同时做其他事情(不阻塞!)
preprocess_next_batch_cpu(); // CPU 预处理下一批
log_to_disk(); // CPU 写日志
// 最后才等待 GPU 完成
cudaStreamSynchronize(stream1);第2节:Pinned Memory——加速 CPU-GPU 传输
Pageable vs Pinned Memory
┌─────────────────────────────────────────────────────────────┐
│ Pinned Memory(页锁定内存) │
├─────────────────────────────────────────────────────────────┤
│ │
│ Pageable Memory(普通内存): │
│ - 操作系统可以把这块内存换页到磁盘(swap) │
│ - CPU 访问可能触发缺页中断 │
│ - GPU 传输前需要先拷贝到临时缓冲区 │
│ → 传输慢 │
│ │
│ Pinned Memory(页锁定内存): │
│ - 永远保持在物理内存,不会被换页 │
│ - GPU 可以直接传输,不需要临时拷贝 │
│ → 传输快 2-3 倍!但会占用系统内存 │
│ │
└─────────────────────────────────────────────────────────────┘Pinned Memory 代码
cpp
// 普通方式(pageable memory)
float* h_data = (float*)malloc(size); // pageable
cudaMemcpy(d_data, h_data, size, cudaMemcpyHostToDevice);
// ================================================================
// Pinned Memory 方式
// ================================================================
float* h_data_pinned;
cudaMallocHost(&h_data_pinned, size); // 分配页锁定内存
// 使用方式一样,但传输更快
cudaMemcpy(d_data, h_data_pinned, size, cudaMemcpyHostToDevice);
// 释放
cudaFreeHost(h_data_pinned);异步传输 + Pinned Memory
cpp
// 最佳实践:Pinned Memory + Async + Double Buffering
cudaStream_t stream;
cudaStreamCreate(&stream);
// 两套缓冲区,轮换使用
float *d_buf[2], *h_buf[2];
for (int i = 0; i < 2; i++) {
cudaMalloc(&d_buf[i], size);
cudaMallocHost(&h_buf[i], size); // pinned memory
}
int buf_idx = 0;
bool first_iteration = true;
// 主循环
while (has_more_data()) {
// CPU 加载下一批数据到当前缓冲区
load_data(h_buf[buf_idx], size);
// CPU→GPU 异步拷贝(数据加载完成后)
cudaMemcpyAsync(d_buf[buf_idx], h_buf[buf_idx],
size, cudaMemcpyHostToDevice, stream);
// 如果不是第一次,等待上一个 kernel 完成
if (!first_iteration) {
int prev_idx = 1 - buf_idx;
cudaStreamSynchronize(stream); // 等待前一批 GPU 计算完成
// 处理前一批的结果
process_results(d_buf[prev_idx]);
}
// 启动当前批次的 kernel(与下一次 CPU 加载并行!)
kernel<<<blocks, threads, 0, stream>>>(d_buf[buf_idx], size);
buf_idx = 1 - buf_idx; // 切换缓冲区
first_iteration = false;
}第3节:CUDA Streams 实现流水线
数据加载流水线
┌─────────────────────────────────────────────────────────────┐
│ 3-Stage 流水线 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Time → │
│ │
│ Stage 1: [Load GPU0][Load GPU1][Load GPU2][Load GPU3]...│
│ Stage 2: [Preprocess][Preprocess][Preprocess]... │
│ Stage 3: [GPU Fwd][GPU Fwd][GPU Fwd]... │
│ │
│ 理想情况:3 个阶段并行,满载运转 │
│ │
│ 如果数据加载太慢(Stage 2 瓶颈): │
│ Stage 1: [Load][Load][Load][Load][Load][Load]... │
│ Stage 2: [Pre ][Pre ][Pre ][Pre ][Pre ][Pre ] │
│ Stage 3: [Fwd ][Fwd ][Fwd ][Fwd ] │
│ ─────────→ GPU 大量空闲等待 │
│ │
└─────────────────────────────────────────────────────────────┘多 Stream 实现流水线
cpp
// 3-Stream 流水线实现
cudaStream_t stream_load, stream_preprocess, stream_compute;
cudaStreamCreate(&stream_load);
cudaStreamCreate(&stream_preprocess);
cudaStreamCreate(&stream_compute);
// 使用事件控制流水线顺序
cudaEvent_t preprocess_done, compute_done;
int batch = 0;
while (true) {
// Stage 1: CPU 加载数据到 pinned memory
load_data_cpu(h_pinned[batch % 2]);
// 等待 preprocess 完成,才能开始下一次加载
if (batch > 0) {
cudaEventSynchronize(preprocess_done);
}
// 异步拷贝到 GPU
cudaMemcpyAsync(d_pinned[batch % 2], h_pinned[batch % 2],
size, cudaMemcpyHostToDevice, stream_load);
// Stage 2: GPU 预处理(resize, normalize 等)
preprocess<<<blocks, threads, 0, stream_preprocess>>>(
d_pinned[batch % 2], d_preprocessed[batch % 2]);
cudaEventRecord(preprocess_done, stream_preprocess);
// 等待 preprocess 完成,才能开始计算
cudaEventSynchronize(preprocess_done);
// Stage 3: GPU 训练
train<<<blocks, threads, 0, stream_compute>>>(
d_preprocessed[batch % 2], model_params);
cudaEventRecord(compute_done, stream_compute);
batch++;
}第4节:统一内存的异构使用
┌─────────────────────────────────────────────────────────────┐
│ Unified Memory 在异构场景的应用 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 场景:CPU 和 GPU 交替访问同一块数据 │
│ │
│ 例如: │
│ - CPU 加载数据 → GPU 处理 → CPU 读取结果 → CPU 写日志 │
│ - 循环很多次 │
│ │
│ 传统方式:每次都需要 cudaMemcpy │
│ Unified Memory:自动迁移,无需手动管理 │
│ │
└─────────────────────────────────────────────────────────────┘cpp
// Unified Memory 方式
float* data;
cudaMallocManaged(&data, size);
// CPU 处理
preprocess_cpu(data, size);
// GPU 处理(OS 自动把数据迁移到 GPU)
cudaMemcpy(data, size, cudaMemcpyHostToDevice); // 显式提示迁移
train<<<blocks, threads>>>(data, size);
cudaDeviceSynchronize();
// CPU 读取结果(OS 自动把数据迁移回 CPU)
read_results_cpu(data, size);
// 性能提示:预取到 GPU
cudaMemPrefetchAsync(data, size, 0); // deviceId=0 = GPU
train<<<blocks, threads>>>(data, size);
cudaDeviceSynchronize();
// 预取回 CPU
cudaMemPrefetchAsync(data, size, cudaCpuDeviceId);
read_results_cpu(data, size);第5节:CPU 和 GPU 的任务分配策略
┌─────────────────────────────────────────────────────────────┐
│ CPU vs GPU 任务分配 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 适合 CPU: │
│ - IO 密集型:磁盘读写、网络通信 │
│ - 分支密集型:if-else 多的逻辑 │
│ - 串行任务:数据预处理、格式转换 │
│ - 任务量小的任务:启动 GPU 开销不划算 │
│ │
│ 适合 GPU: │
│ - 计算密集型:矩阵乘法、卷积 │
│ - 数据并行:大量相同操作 │
│ - 算术强度高:计算量 >> 内存访问量 │
│ │
│ 实际训练中的分配: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ CPU: 数据加载 → Tokenize → Resize → Normalize │ │
│ │ ↓ (Pinned Memory + Async) │ │
│ │ GPU: Embedding → Transformer → Loss → Backward │ │
│ │ ↑ (CUDA Stream 并行) │ │
│ │ CPU: 日志记录 → Checkpoint → Metrics 计算 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 核心原则:CPU 和 GPU 永远不要同时闲着! │
│ │
└─────────────────────────────────────────────────────────────┘"AI 可查 vs 必须理解"清单
AI 可查:
✅ cudaStreamCreate/cudaEventCreate 的具体参数
✅ Pinned Memory 的具体使用限制
✅ Unified Memory prefetch 的具体 API
必须理解:
🔴 CPU-GPU 数据传输是瓶颈,需要异步化
🔴 Pinned Memory 比普通内存传输快 2-3 倍
🔴 Double Buffering:两套缓冲区轮换,CPU 和 GPU 并行工作
🔴 CUDA Stream 实现流水线:Load → Preprocess → Compute
🔴 任务分配:CPU 做 IO/预处理,GPU 做计算密集型任务学习状态:🟡 开始学习