运行时与 Client
主线位置:算子主线的最后一段。
CubeBackend选好内核、launch登记好参数之后(见 #[cube] 内核),工作交给 cubecl 的Client;本页讲Client和设备线程上的Server怎么分工、一次create→launch→read经过哪里、为什么读回是异步的,以及显存怎么分配和回收。融合流怎么在读之前 drain 见 Fusion 装饰器,wgpu 设备和编译管线见 wgpu 与 Metal。
sequenceDiagram participant M as 调用线程 participant C as Client participant S as 设备线程 Server participant G as GPU M->>C: create_from_slice C->>C: 先发一个 Handle C->>S: submit 分配并写入 M->>C: launch C->>S: submit 内核 S->>S: 按 KernelId 取管线 排进流 M->>C: read_one C->>S: submit_blocking read S->>G: 拷进暂存缓冲 并提交 S-->>C: 返回 future G-->>C: map_async 回调 C-->>M: block_on 拿到字节
本页的 cubecl 版本是 0.11.3。实验 crate cube-runtime 开 burn 的 metal、cubecl 两个 feature,一部分直接用 cubecl 的 Client,一部分走 burn 的 Device API;平台 M2 Max、macOS 27、rustc 1.99.0,每段输出注明 debug 还是 release。
Runtime、Device、Client
Runtime 在 0.11 里很薄:两个关联类型 Server 和 Device,一个默认方法 client,其余是列举设备、目标属性这类查询。
pub trait Runtime: Sized + Send + Sync + 'static + core::fmt::Debug + Clone {
type Server: ServerStorage;
type Device: Device;
fn client(device: &Self::Device) -> Client {
Client::load::<Self::Server>(device.to_id())
}
fn can_read_tensor(shape: &Shape, strides: &Strides) -> bool;
fn target_properties() -> TargetProperties;
// ...
}
client 的返回类型是不带参数的 Client,运行时在这里就被擦掉了:
pub struct Client {
device: DeviceHandle<dyn Server>,
utilities: Arc<ServerUtilities>,
stream_id: Option<StreamId>,
}
device 是到设备线程的通道,另一头是 dyn Server(wgpu 运行时是 WgpuServer<C>,C 是编译器);utilities 是设备属性、日志器、布局策略这些只读的东西,调用线程上直接用,不过通道;stream_id 为空时取当前线程的流(见下文「流」)。用户手里的 cubecl::Device 是一个枚举(Wgpu(..)、Cuda(..) …),device.client() 按变体调对应运行时的 Runtime::client。Client::load 在这个设备第一次用时启动 Server,之后同一个设备拿到的 Client 共享同一个设备线程。
burn 0.22 把后端从 Tensor 的类型参数里拿掉,改成按设备在运行期分派(见 Dispatch)。下面一层的 cubecl 在 0.11 做了同样的事:#1590(「Refactor/runtime erasure」)把 pub struct ComputeClient<R: Runtime> { device: DeviceHandle<R::Server>, .. } 改成 pub struct ComputeClient { device: DeviceHandle<dyn ComputeServer>, .. },Runtime 里的 type Compiler、fn name(client: &ComputeClient<Self>)、fn max_cube_count() 一并删掉,client 变成上面那个默认方法;示例里的 gelu::launch::<cubecl::wgpu::WgpuRuntime>(&Default::default()) 变成 gelu::launch(&cubecl::Device::Wgpu(Default::default()))。同一天的 #1603(「refactor: ComputeClient and ComputeServer are Client and Server」)再把名字缩短。两个提交都只有标题,首发 v0.11.0-pre.4。本卷其他页的链接文字还写「ComputeClient」,在 0.11.3 里它叫 Client。
设备线程:submit 与 submit_blocking
Server 的所有方法都是 &mut self,它只在设备线程上被访问。Client 的每个方法最后都把一个闭包送过通道,ChannelDeviceHandle 提供两种送法:
/// Runs `task` on the device thread, blocking until it returns.
fn submit_blocking<'a, R: Send, T: FnOnce(&mut dyn Any) -> R + Send + 'a>(
&self,
task: T,
) -> Result<R, CallError> {
let state = self.state.service.clone();
let current = StreamId::current();
self.run_scoped(move || state.act_on(|s| current.executes(|| task(s.as_mut()))))
}
/// Asynchronously dispatches a task to the device thread.
fn submit<T: FnOnce(&mut dyn Any) + Send + 'static>(&self, task: T) {
self.submit_inner::<_, SEND_NO_FLUSH>(task)
.expect("Can't have an error when submitting a task");
}
submit_blocking 带上调用线程的 StreamId,在设备线程上用 current.executes(..) 恢复它,所以设备线程替哪个流干活由调用方决定。设备线程叫 DSU-… / DSD-…,按上游、下游两个阶段各一个,一次 matmul 用 tracing 看到过。Client 的主要方法:
| 方法 | 怎么送到设备线程 | 调用线程拿到什么 |
|---|---|---|
create_from_slice、create、empty | submit,不等 | 立刻得到 Handle |
launch | submit,不等;开了性能日志时改为 submit_blocking 并计时 | () |
read_async | submit_blocking 拿回一个 future | future |
read_one、read | 同上,再 read_sync 等 future | 字节 |
sync | submit_blocking 拿回一个 future | future |
flush、check、memory_report、memory_cleanup | submit_blocking | 结果 |
设备线程上 panic 的话,submit_blocking(..).unwrap_or_resume() 把 panic 带回调用线程重新抛出。
Client 是前台,Server 是后厨,通道是递单的窗口。点菜(launch)时前台把单子从窗口递进去(submit)就去招呼下一位,不等菜做好。存东西(create)时前台先给客人一张号码牌(Handle),东西随后才送进后厨入柜。要取菜(read)时前台要隔着窗口问一句「这份什么时候好」,后厨回一张取餐提示(future),客人拿着它等(block_on)。问库存(memory_report)、清柜子(memory_cleanup)也要隔着窗口等后厨回话(submit_blocking)。后厨只有一个,所以任何时候只有一个人在动柜子(&mut self)。
Handle:先有号码,后有内存
Handle 里的 memory 是一个 ManagedMemoryHandle:一个 Arc 包着的描述符(id 加一个原子的「位置」:池、页、切片),外加一个只用来数句柄的 Arc<()>。can_mut 看这个计数是否不超过 2,burn-cubecl 的逐元素内核(kernel/binary.rs)用它判断能不能把结果直接写进输入的缓冲。
create 走 do_create_from_slices:
let stream_id = self.stream_id();
let (handle_base, layouts) =
self.utilities
.layout_policy
.apply(self.service_id(), stream_id, &descriptors);
// ... 为每个张量配好 CopyDescriptor 和数据
let (size, memory) = (handle_base.size(), handle_base.memory);
self.device.submit(move |server| {
// Cannot write on memory that wasn't initialized. The error is attached to the
// buffer and is reported at the next sync point.
if server.initialize_memory(memory, size, stream_id).is_ok() {
server.write(descriptors, stream_id);
}
});
layouts
句柄(和 strides)在调用线程上由布局策略算出来就返回了,这时内存还不存在;分配(initialize_memory)和写入在设备线程上稍后发生。描述符的注释直说:「A handle is minted before its memory exists, and the reservation that follows can fail」(is_allocated)。分配失败不会让 create 报错,错误挂在这块缓冲上,下一个读它、check 它或 sync_buffers 它的地方才报出来。这和 burn 上层「op 立刻返回、错误延后」的行为一致(见检查与报错)。
句柄的最后一个副本被丢掉时,描述符的引用计数归零,池在设备线程上把那块切片标成空闲。空闲切片还在池的页里,下一次同样大小的分配直接复用,不会还给驱动,下文的实验能看到。
一次 create → launch → read
timing 模式用 Client 直接做三件事:create_from_slice 上传 16 MiB(4M 个 f32),启动一个把每个元素乘 2 的内核,read_one 读回,各计时,重复三轮。release 构建:
for round in 0..3 {
let t = Instant::now();
let h = client.create_from_slice(f32::as_bytes(&data));
let t_create = t.elapsed();
let t = Instant::now();
let count = CubeCount::Static((n as u32).div_ceil(256), 1, 1);
// SAFETY: the handle holds exactly n f32 values.
let arg = unsafe { BufferArg::from_raw_parts(h.clone(), n) };
scale::launch::<f32>(&client, count, CubeDim::new_1d(256), arg);
let t_launch = t.elapsed();
let t = Instant::now();
let bytes = client.read_one(h).unwrap();
let t_read = t.elapsed();
// ...
}
round 0: create 4.8595ms, launch 7.053792ms, read_one 132.533458ms (x[0] = 2)
round 1: create 2.840542ms, launch 31.416µs, read_one 6.989583ms (x[0] = 2)
round 2: create 2.421208ms, launch 17.5µs, read_one 3.219125ms (x[0] = 2)
launch第二轮起是 17–31 µs:登记参数、submit,不等 GPU。第一轮的 7 ms 是首次调用里的一次性开销,本页没有拆开。- 内核的编译不在
launch里,而在设备线程上第一次处理这个KernelId时(#[cube] 内核),所以它落进了第一轮的read_one:132 ms 里包括构造 IR、编成 MSL、建 Metal 管线,本页没有再拆开。之后的read_one是 3–7 ms,包括等内核跑完和 16 MiB 的拷贝。 create每轮 2.4–4.9 ms,几乎都是调用线程上的内存拷贝:create_from_slice先slice.to_vec(),do_create_from_slices里又Bytes::from_bytes_vec(data.to_vec()),16 MiB 复制了两遍才交给设备线程。它的文档注释也让人优先用直接收Bytes的create。burn 的from_data用的是create_tensor(bytes, ..),不经过这两次复制。
数字只对这台机器有效。
读回为什么是异步的
设备线程收到读请求后,WgpuServer::read 先确认这些缓冲没有挂着错误(ensure_written)、把涉及的流里排着的任务都交出去(execute_streams),再调流的 read_resources:
let (staging, binding) = self.auxiliary.reserve_staging(aligned_len).unwrap();
self.tasks_count += 1;
self.encoder.copy_buffer_to_buffer(
&resource.buffer,
resource.offset,
&staging.buffer,
0,
aligned_len,
);
// ...
// Flush all commands to the queue, so GPU gets started on copying to the
// staging buffer. ...
if let Err(err) = self.flush(stream_id) {
return Box::pin(async move { Err(err) });
}
// ...
let (sender, receiver) = cubecl_environment::future::channel::bounded(1);
staging
.buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |v| {
// This might fail if the channel is closed (eg. the future is dropped).
// This is fine, just means results aren't needed anymore.
let _ = sender.try_send(v);
});
// ...
let poll = self.poll.start_polling();
// ...
Box::pin(async move {
for receiver in callbacks.iter().flatten() {
let mapped = receiver.recv().await;
GPU 上的显存不能直接被 CPU 读:先录一条「拷到暂存(staging)缓冲」的命令,连同之前排着的内核一起提交,再请求把暂存缓冲映射到主机(map_async)。映射什么时候完成取决于 GPU 什么时候把前面的工作做完,wgpu 用回调告诉你,cubecl 把回调接到一个单元素通道上,返回一个等这个通道的 future。设备线程把 future 交回就去处理下一个请求,不在这里干等;start_polling 让 wgpu 的设备被轮询,回调才会触发。
所以 read 的本性是一个 future。Client::read_one 只是 read_sync(self.read_async(..))。在不能阻塞的平台上,read_sync 直接 panic:「Failed to read tensor data synchronously. This can happen on platforms that don't support blocking futures like WASM. If possible, try using an async variant of this function instead.」。burn 这一层的 float_into_data 是 async fn 也是同一个原因,同步的 into_data 外面套了一层 block_on(Backend trait;future 和 waker 的契约见 tokio 卷的运行时解剖)。
问: burn 的 into_data() 返回时,数据已经在主机内存里了吗?
答: 在原生平台上不一定。burn-cubecl 的 into_data 在多线程运行时下调的是 read_lazy_async:它只在设备线程上 check 一下缓冲没有错误,就返回一个立刻就绪的 future,里面是一个「懒」的 Bytes,GPU 到主机的拷贝推迟到第一次访问这些字节时。注释给的理由是序列化大量设备张量时不必一次把它们都搬到主机内存。async 运行时(注释举的例子是远程服务端)和 wasm 上则当场读。下面在 Metal 上读一个 4096×4096 的 f32 张量(64 MiB),release,连跑两个进程:
let t: Tensor<2> = Tensor::random([4096, 4096], Distribution::Default, &device) + 1.0;
device.sync().unwrap();
let start = Instant::now();
let data = t.into_data();
let t_into = start.elapsed();
let before = format!("{:?}", data.bytes().property());
let start = Instant::now();
let values = data.to_vec::<f32>().unwrap();
let t_vec = start.elapsed();
round 0: into_data 227.25µs -> property Device, to_vec 13.625708ms (16777216 values, first true)
round 1: into_data 458.542µs -> property Device, to_vec 5.060625ms (16777216 values, first true)
round 2: into_data 108.792µs -> property Device, to_vec 4.085041ms (16777216 values, first true)
round 0: into_data 169.708µs -> property Device, to_vec 15.95175ms (16777216 values, first true)
round 1: into_data 139.375µs -> property Device, to_vec 10.149834ms (16777216 values, first true)
round 2: into_data 154.833µs -> property Device, to_vec 3.977625ms (16777216 values, first true)
into_data 返回的 TensorData 的字节属性是 Device,用了 0.1–0.5 ms;真正的读回发生在 to_vec 里,4–16 ms。所以给 GPU 读回计时,要计到第一次访问数据为止。
内存池
页、切片和几个池
每个流在设备线程上有一个 MemoryManagement:向驱动要大块的「页」(page),再从页里切出「切片」(slice)给句柄。它分三部分:
| 部分 | 放什么 |
|---|---|
persistent | 在「持久」分配窗口里建的张量(比如加载模型权重),按完全相同的大小复用 |
dedicated | 「专用」窗口里的分配,每块单独向驱动要,释放后下一次清理就还 |
pools(DynamicMemory) | 其余一切:运行中随建随丢的张量和元数据 |
动态内存在 wgpu 上默认是 Adaptive(AdaptiveMemory),里面又分三个池:零字节的分配一个池;不超过 64 KiB 的小分配(主要是内核的形状、strides、标量这些元数据)放在 8 MiB 一页的切片池里,免得把大页切碎;其余放进「arena」,它的页大小跟着目前见过的最大分配走,最小 2 MiB。
实验:分配、丢弃、复用、清理
默认模式(debug)用 Client 直接上传、丢弃,每一步之后读 memory_report(MemoryScope::Device) 的合计,有几处再列出各个池:
fn pool_demo() {
let client = cubecl::Device::default().client();
usage(&client, "start");
let data = vec![1u8; MIB];
let handles: Vec<_> = (0..8).map(|_| client.create_from_slice(&data)).collect();
usage(&client, "8 x 1 MiB created");
pools(&client);
drop(handles);
usage(&client, "dropped");
let again: Vec<_> = (0..8).map(|_| client.create_from_slice(&data)).collect();
usage(&client, "8 x 1 MiB created again");
drop(again);
let big = client.create_from_slice(&vec![0u8; 64 * MIB]);
usage(&client, "one 64 MiB created");
pools(&client);
drop(big);
usage(&client, "64 MiB dropped");
client.memory_cleanup().unwrap();
usage(&client, "memory_cleanup");
pools(&client);
}
reserved 是多少?上面程序第三行输出 dropped 时,in_use 是 0。reserved(向驱动要了、还没还的字节)是 0、1 MiB,还是 8 MiB?答案在本页末尾。
memory_report 本身也是 submit_blocking,而 WgpuServer::memory_report 先 execute_streams,所以每次读到的都是之前提交的分配和释放都处理完之后的状态。完整输出放在答案里,这里先讲后半段。分配一个 64 MiB 之后,arena 的页大小从 2 MiB 长到 68 157 440 字节(65 MiB),原来那 4 个 2 MiB 的页变成「过时」(outdated_pages: 4),挂在一个单独的 Sliced { page_size: 2097152 } 池里。它们已经空了,但要等下一次清理才还,所以 reserved 是 65 MiB + 8 MiB。丢掉 64 MiB 之后什么都没还;memory_cleanup 之后 reserved 归零。
清理什么时候发生
MemoryManagement::cleanup 有两种理由(Cleanup):
pub enum Cleanup {
/// On the way to a reservation: each pool gives back what its own policy
/// says is unused, such as a page past its deallocation period or an
/// outdated page that emptied.
Periodic,
/// Asked for: everything nothing holds goes back.
Explicit,
}
平时只有分配路上顺带的 Periodic 清理,按各池自己的策略还掉闲置太久的页或已经空了的过时页;显式调 memory_cleanup 才是 Explicit:没人持有的页全部还给驱动,而且立刻 storage.flush(),不留到下一轮。wgpu 的 memory_cleanup 在这之前还会清掉元数据缓存(info_cache.clear_unpinned()),下一节会看到它的作用。
所以「张量都丢了,显存却没降」是设计如此:池留着页等复用。真要还给系统(比如一个阶段结束、下一个阶段要用别的大小),调 memory_cleanup。
从 burn 看
burn 把这几样包成 Device 上的方法:memory_cleanup、memory_pool_usage、memory_pool_report,经 Dispatch、Fusion 到 CubeBackend::memory_cleanup(就是 client.memory_cleanup())和 CubeBackend::memory_pool_usage(memory_report(MemoryScope::Device).usage())。Fusion 读用量之前先把记下的操作 drain 掉,否则还没执行的分配不在里面。同样的实验用 Device::metal(..),每步先 device.sync()(debug):
burn start allocs=0 in_use= 0 reserved= 0
burn 8 x [512,512] f32 allocs=16 in_use= 8388992 reserved= 8515072
report Some([SlicedPoolReport { page_size: 8388608, pages: 0, pages_peak: 0, largest_alloc: 0 }, SlicedPoolReport { page_size: 2097152, pages: 4, pages_peak: 4, largest_alloc: 1048576 }])
burn dropped allocs=8 in_use= 384 reserved= 8515072
burn memory_cleanup allocs=0 in_use= 0 reserved= 0
8 个张量却有 16 个分配,多出的 8 个共 384 字节,张量丢掉之后还在,memory_cleanup 之后才没了。它们是 8 次 random 内核启动的元数据缓冲:每次启动的形状、strides、标量(random 的种子每次不同)写进一个只读的 info_st 缓冲,MetadataInfoCache 按这些字节缓存它,同样的元数据下次直接复用,省掉一次分配和一次拷贝。直接用 Client 也能看到:两次启动同一个内核、长度不同,丢掉所有句柄之后(info 模式,debug):
2 launches, handles dropped allocs=2 in_use= 16 reserved= 8421120
memory_cleanup allocs=0 in_use= 0 reserved= 0
两个 8 字节的元数据缓冲(只有长度)留着,直到 memory_cleanup。reserved 里的 8 MiB 是另一回事:那两个 4 KiB、8 KiB 的数据缓冲不超过 64 KiB,进了小分配池,它的页同样留着等复用。
流
句柄里有 stream 字段,Client 有 stream_id,池也是按流分的。流是什么由 StreamPolicy 决定,默认 PerThread:每个操作系统线程一个流,StreamId::current 第一次在某个线程上调用时给它分一个新号。同一个流里的工作按提交顺序执行;跨流读别的流写的缓冲时,服务端要先确认那边的写入完成(WgpuServer::read 里的 ensure_written 就是在做这个),用到别的流的输入时把那块内存钉住直到本流提交。
streams 模式在主线程和一个新线程上各上传 1 MiB(debug):
let _main = client.create_from_slice(&data);
let other = std::thread::spawn({
let client = client.clone();
let data = data.clone();
move || {
let handle = client.create_from_slice(&data);
let mine = client.memory_report(cubecl::MemoryScope::CurrentStream);
println!("worker thread CurrentStream: {} stream(s)", mine.streams.len());
handle
}
})
.join()
.unwrap();
worker thread CurrentStream: 1 stream(s)
StreamId { value: 0 }: allocs=1 in_use=1048576 reserved=2097152
StreamId { value: 1 }: allocs=1 in_use=1048576 reserved=2097152
两个 1 MiB 落在两个流里,各自开了一个 2 MiB 的页,合计占了 4 MiB;同一个线程上传两次只会用一个页。多线程往同一个 GPU 提交时,每个线程有自己的池,一个线程空出来的页另一个线程用不上。burn 用的是同一个 StreamId(burn-std 直接转出 cubecl_environment::stream),融合流也按它区分,见 Fusion 装饰器。MemoryScope::CurrentStream 只看当前线程的流,Device 看全部,burn 的 memory_pool_usage 用的是后者。
动脑筋答案
8 MiB。 丢掉句柄之后切片空了(in_use=0),但 4 个 2 MiB 的页还留在 arena 里,再上传 8 个 1 MiB 时直接复用,reserved 一点没变:
start allocs=0 in_use= 0 reserved= 0
8 x 1 MiB created allocs=8 in_use= 8388608 reserved= 8388608
dynamic Exclusive { max_alloc_size: 0 } pages=0 peak=0 largest=0 in_use=0
dynamic Sliced { page_size: 8388608, max_slice_size: 65536 } pages=0 peak=0 largest=0 in_use=0
dynamic Adaptive { page_size: 2097152, outdated_pages: 0 } pages=4 peak=4 largest=1048576 in_use=8388608
persistent pages=0 in_use=0
dropped allocs=0 in_use= 0 reserved= 8388608
8 x 1 MiB created again allocs=8 in_use= 8388608 reserved= 8388608
one 64 MiB created allocs=1 in_use= 67108864 reserved= 76546048
dynamic Exclusive { max_alloc_size: 0 } pages=0 peak=0 largest=0 in_use=0
dynamic Sliced { page_size: 8388608, max_slice_size: 65536 } pages=0 peak=0 largest=0 in_use=0
dynamic Adaptive { page_size: 68157440, outdated_pages: 4 } pages=1 peak=1 largest=67108864 in_use=67108864
dynamic Sliced { page_size: 2097152, max_slice_size: 2097152 } pages=4 peak=4 largest=1048576 in_use=0
persistent pages=0 in_use=0
64 MiB dropped allocs=0 in_use= 0 reserved= 76546048
memory_cleanup allocs=0 in_use= 0 reserved= 0
dynamic Exclusive { max_alloc_size: 0 } pages=0 peak=0 largest=0 in_use=0
dynamic Sliced { page_size: 8388608, max_slice_size: 65536 } pages=0 peak=0 largest=0 in_use=0
dynamic Adaptive { page_size: 68157440, outdated_pages: 0 } pages=0 peak=1 largest=67108864 in_use=0
persistent pages=0 in_use=0
- 三个
dynamic池依次是零字节池、小分配池(8 MiB 页,最大 64 KiB)和 arena。1 MiB 的分配进 arena,2 MiB 一页能切两个,8 个占 4 页。 dropped时allocs=0、in_use=0,reserved仍是 8 388 608:页没还。- 第二次上传 8 个 1 MiB,
reserved不变,用的是原来的 4 页。 - 只有
memory_cleanup让reserved回到 0,pages_peak留着历史最高值 1。
小结
Runtime只剩Server、Device两个关联类型;Client不带运行时参数(#1590、#1603,v0.11.0-pre.4),内部是到设备线程上dyn Server的通道。- 不需要结果的请求(
create、launch)用submit丢过去就返回;需要结果的(读、sync、内存报告、清理)用submit_blocking,读和sync拿回的是 future。 create在调用线程上先发Handle,内存在设备线程上稍后分配;分配或启动的错误挂在缓冲上,下一次读、check、sync_buffers时报出。- 读回 = 拷进暂存缓冲 + 提交 +
map_async回调,天然是 future;read_one只是read_sync包一层。burn 的into_data在原生平台上返回懒的Bytes,拷贝推迟到第一次访问。 - 内存池按流分,页留着复用,丢掉张量不还显存;arena 的页跟着最大分配长大,旧页变成过时页;
memory_cleanup才把空页还给驱动,并清掉内核元数据缓存。