1. 内存拷贝基础:CPU与GPU内存模型差异、PCIe传输原理、cudaMemcpy详解
做深度学习调参这些年,我踩过最大的坑,就是内存拷贝。
你想想看,模型训练慢,很多人第一反应是调batch size、改学习率。但有时候,瓶颈根本不在计算上——数据从CPU搬到GPU的路上,就已经把时间吃光了。
这一章,咱们就把内存拷贝这件事彻底聊透。
1.1 CPU与GPU内存模型差异
先说个最基础的问题:CPU和GPU的内存,到底有什么不同?
CPU的内存,叫主机内存(Host Memory)。它容量大,DDR4/DDR5随便插个几十GB。但它的带宽有限,延迟也高。你从内存里读一个数,可能要等几十纳秒。
GPU的内存,叫设备内存(Device Memory)。它离计算单元近,带宽极高——HBM2e能到2TB/s以上。但容量小,显存就那么几GB到几十GB。
我刚开始做项目时,总觉得这两者差不多。直到有一次,我在CPU上预处理了一批数据,直接传进GPU训练。结果发现,数据传输的时间比训练本身还长。嗯,那时候我才意识到——CPU和GPU是两套独立的存储体系,它们之间没有共享内存。
核心差异总结:
- 物理隔离:CPU内存和GPU内存通过PCIe总线连接,不是共享的
- 带宽差异:GPU显存带宽是CPU内存的10-100倍
- 延迟差异:CPU内存延迟几十ns,GPU显存延迟几百ns(但吞吐量高)
- 容量差异:CPU内存轻松上百GB,GPU显存通常8-80GB
说白了,CPU内存像个大仓库,什么都能放,但取东西慢。GPU显存像个高速工作台,东西放上去就能快速处理,但地方有限。
1.2 PCIe传输原理
那CPU和GPU之间怎么传数据?靠PCIe总线。
PCIe,全称Peripheral Component Interconnect Express。你主板上那些长长的插槽,就是PCIe接口。GPU就插在上面。
PCIe的传输模式是点对点串行。什么意思?就是数据像高速公路上的车,一辆一辆地跑。但车道可以很多——x1、x4、x8、x16,数字越大,车道越多,带宽越高。
我记得有一次,我为了省钱,把GPU插在了一个x4的插槽上。结果训练速度慢得离谱。查了半天才发现,PCIe带宽成了瓶颈。后来换到x16,速度直接翻倍。
PCIe带宽速查表(以PCIe 4.0为例):
| 通道数 | 单向带宽 | 双向带宽 |
|---|---|---|
| x1 | ~2 GB/s | ~4 GB/s |
| x4 | ~8 GB/s | ~16 GB/s |
| x8 | ~16 GB/s | ~32 GB/s |
| x16 | ~32 GB/s | ~64 GB/s |
你想想看,GPU显存带宽动辄1-2TB/s,而PCIe 4.0 x16才32GB/s。差了50倍以上。所以,频繁地在CPU和GPU之间拷贝数据,就是往瓶子里倒水——瓶口就那么细。
为什么会这样?因为PCIe的设计初衷是连接外设,不是做内存扩展。它要兼顾兼容性、功耗、成本,不可能做到和显存一样的带宽。
1.3 cudaMemcpy详解
好了,理论说完了,咱们看代码。
CUDA里做内存拷贝,核心函数就是cudaMemcpy。它的原型长这样:
cudaError_t cudaMemcpy(
void* dst, // 目标地址
const void* src, // 源地址
size_t count, // 拷贝字节数
cudaMemcpyKind kind // 拷贝方向
);
cudaMemcpyKind有四种取值:
cudaMemcpyHostToDevice:从CPU拷贝到GPUcudaMemcpyDeviceToHost:从GPU拷贝到CPUcudaMemcpyDeviceToDevice:GPU内部拷贝cudaMemcpyHostToHost:CPU内部拷贝(基本不用)
我个人习惯,每次写cudaMemcpy都会检查返回值。因为一旦拷贝失败,后续的计算全白费。而且错误信息往往很模糊——"invalid argument"?哪个参数?你猜。
避坑指南:
我曾经在项目里犯过一个低级错误:用cudaMemcpy拷贝数据时,count参数传的是元素个数,而不是字节数。结果数据只拷贝了一部分,模型训练出来全是NaN。排查了整整两天。
记住:count的单位是字节,不是元素个数。如果你要拷贝N个float,count应该写N * sizeof(float)。
下面是一个完整的示例:
#include <cuda_runtime.h>
#include <stdio.h>
int main() {
// 1. 在CPU上分配内存
int N = 1024;
float* host_data = (float*)malloc(N * sizeof(float));
for (int i = 0; i < N; i++) {
host_data[i] = i * 1.0f;
}
// 2. 在GPU上分配内存
float* device_data;
cudaMalloc(&device_data, N * sizeof(float));
// 3. 从CPU拷贝到GPU
cudaError_t err = cudaMemcpy(
device_data,
host_data,
N * sizeof(float),
cudaMemcpyHostToDevice
);
if (err != cudaSuccess) {
printf("拷贝失败: %s\n", cudaGetErrorString(err));
return -1;
}
// 4. 在GPU上做计算(省略)
// 5. 从GPU拷贝回CPU
err = cudaMemcpy(
host_data,
device_data,
N * sizeof(float),
cudaMemcpyDeviceToHost
);
// 6. 清理
cudaFree(device_data);
free(host_data);
return 0;
}
这段代码看起来简单,但实际项目中,你往往不会只拷贝一次。比如每个batch都要把数据从CPU搬到GPU。这时候,拷贝的开销就会累积。
我建议你养成一个习惯:每次拷贝前后,用cudaEvent计时。这样你就能清楚地知道,时间到底花在了哪里。
计时小技巧:
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
cudaEventRecord(start);
cudaMemcpy(device_data, host_data, N * sizeof(float), cudaMemcpyHostToDevice);
cudaEventRecord(stop);
cudaEventSynchronize(stop);
float ms = 0;
cudaEventElapsedTime(&ms, start, stop);
printf("拷贝耗时: %f ms\n", ms);
嗯,这里要注意:cudaMemcpy是同步操作。也就是说,它要等数据全部传完才返回。这期间CPU啥也干不了。所以,如果你的数据量很大,CPU会白白等着。
那怎么办?后面我们会讲异步拷贝和流水线,那是解决这个问题的关键。但这一章,先把基础打牢。
1.4 知识体系总览
为了让你更直观地理解这一章的内容,我画了一张图:
这张图把CPU内存、GPU显存和PCIe总线的关系画清楚了。你盯着看一分钟,应该就能理解为什么内存拷贝会成为瓶颈。
好了,这一章就到这里。记住:内存拷贝不是免费的。每次拷贝,都在消耗你的训练时间。后面我们会讲怎么优化它,但前提是你得先理解它。
本章要点回顾:
- CPU内存和GPU显存是两套独立的存储体系
- PCIe总线是它们之间的唯一通道,带宽有限
- cudaMemcpy是同步操作,会阻塞CPU
- 拷贝的单位是字节,不是元素个数
- 建议用cudaEvent计时,量化拷贝开销
想要更多资料,联系可可