张量都 drop 了,显存不降
主线位置:算子主线的最底层。
Fusion<CubeBackend>执行内核时向 cubecl 的Client要缓冲区,缓冲区从每条流的内存池里切出来;张量被丢掉时切片回到池里,页不还。内存池的结构见运行时与 Client。
flowchart TB alloc["4 个 64 MiB 张量"] --> pool["池向驱动要页"] pool --> dropt["drop 张量"] dropt --> keep["切片空了 页还在"] keep --> reuse["下次分配直接复用"] keep --> clean["memory_cleanup"] clean --> back["页还给驱动"]
实验 crate pc-gpu,Metal,release 构建,M2 Max、macOS 27、rustc 1.99.0。burn 0.22.1,cubecl 0.11.3。
现象
先建 4 个 [4096, 4096] 的 f32 张量(每个 64 MiB),丢掉;再建 4 个小的,丢掉。每一步先 device.sync(),再读 burn 的 memory_pool_usage() 和进程的物理内存占用(proc_pid_rusage 的 ri_phys_footprint;Apple 芯片上 GPU 缓冲区在统一内存里,下面的数字显示它们算在进程头上):
let show = |label: &str| {
device.sync().unwrap();
let u = device.memory_pool_usage().unwrap();
println!(
"{label:<26} in_use={:>8.1} MiB reserved={:>8.1} MiB footprint={:>8.1} MiB",
u.bytes_in_use as f64 / MIB,
u.bytes_reserved as f64 / MIB,
footprint_mib()
);
};
warmup(&device);
show("start");
let big: Vec<Tensor<2>> = (0..4)
.map(|_| Tensor::random([4096, 4096], Distribution::Default, &device))
.collect();
show("4 x [4096,4096] f32");
drop(big);
show("dropped");
// ... 4 x [256, 256], drop, then memory_cleanup
start in_use= 0.0 MiB reserved= 8.0 MiB footprint= 20.5 MiB
4 x [4096,4096] f32 in_use= 256.0 MiB reserved= 268.1 MiB footprint= 284.4 MiB
dropped in_use= 0.0 MiB reserved= 268.1 MiB footprint= 284.4 MiB
4 x [256,256] f32 in_use= 1.0 MiB reserved= 268.1 MiB footprint= 284.4 MiB
dropped in_use= 0.0 MiB reserved= 268.1 MiB footprint= 284.4 MiB
in_use 跟着张量走,reserved 和进程占用停在 256 MiB 以上不动。后面 4 个小张量直接从已有的页里切,没有新分配。
出处
cubecl 每条流有一个 MemoryManagement,向驱动要大块的页,再切成切片给句柄;句柄被丢掉,切片标成空闲,页留着。还页只有两种时机(Cleanup):分配路上顺带的 Periodic 清理,按各池自己的策略还掉闲置太久的页;显式调用时的 Explicit 清理,没人持有的页全部还掉。一直不再分配的程序,Periodic 根本不会发生。好处在训练循环里:每个 step 分配的大小差不多,复用页省掉向驱动申请和释放的开销。细节和各个池的分工在运行时与 Client的「内存池」一节。
burn 这一层只是转发:Device::memory_cleanup 经 Dispatch、Fusion 到 CubeBackend::memory_cleanup,最后是 cubecl 的 Client::memory_cleanup,对设备上每一条流都清一遍(不只是调用线程的那条)。memory_pool_usage 读的也是整个设备。
修法
一个阶段结束、下一阶段用不到这些页时(比如训练完转推理,或者处理完一批特别大的输入),调一次 memory_cleanup:
device.memory_cleanup();
show("memory_cleanup");
std::thread::sleep(Duration::from_millis(500));
show("+500 ms");
let _ = (Tensor::<1>::from_floats([1.0], &device) + 1.0).into_data();
show("+1 small op");
device.memory_cleanup();
show("memory_cleanup again");
std::thread::sleep(Duration::from_millis(2000));
show("+2 s");
memory_cleanup in_use= 0.0 MiB reserved= 0.0 MiB footprint= 284.4 MiB
+500 ms in_use= 0.0 MiB reserved= 0.0 MiB footprint= 16.3 MiB
+1 small op in_use= 0.0 MiB reserved= 8.0 MiB footprint= 24.6 MiB
memory_cleanup again in_use= 0.0 MiB reserved= 0.0 MiB footprint= 24.6 MiB
+2 s in_use= 0.0 MiB reserved= 0.0 MiB footprint= 15.3 MiB
reserved 立刻归零,进程占用却在 memory_cleanup 返回时还没变,半秒后才降下来;第二次也一样。池把页交还给了 wgpu,真正释放 Metal 缓冲区发生在之后,本页没有追到是哪一层延后的,只能说:调完马上量进程内存,可能还看不到效果。
一个小运算之后 reserved 又回到 8 MiB:那是放内核元数据的小分配池的一页,每次启动内核都要用到。
不要在训练循环的每一步都调它:清掉的页下一步又要重新向驱动申请,正是内存池要省掉的开销。
怎么确认没中招
- 看
device.memory_pool_usage():bytes_in_use是活着的张量,bytes_reserved是池手里的总量,两者之差是可以memory_cleanup还回去的部分。device.memory_pool_report()能看到各个池的页数。 in_use本身一直涨,才是张量真的没被释放(比如还被某个结构体持有,或者在 autodiff 设备上只做前向,计算图一直留着)。- 量进程内存时,在
memory_cleanup之后等一会儿再读。
小结
- 丢掉 GPU 张量只把切片还给池,页留着复用,
reserved和进程占用不降,这是设计。 device.memory_cleanup()把整个设备上没人用的页还给驱动;进程占用在实测中要过几百毫秒才降。- 区分
bytes_in_use和bytes_reserved:前者涨才是泄漏。