Metal 后端
主线位置:算子主线在 Metal 上的终点和读回。
Storage::matmul匹配到(Metal, Metal)(见 Storage 与后端 trait),MetalStorage::matmul分配输出缓冲区、取编码器、调candle-metal-kernels的call_mlx_gemm把一次 GEMM 编码进当前命令缓冲区,然后立刻返回,GPU 这时还没算。to_vec2走到MetalStorage::to_cpu时才提交并等待。本页讲这中间的每一步。
sequenceDiagram participant T as Tensor participant S as MetalStorage participant K as Kernels participant C as Commands participant G as GPU T->>S: matmul S->>S: 从缓冲池取输出 S->>C: command_encoder S->>K: load_pipeline_with_constants K-->>S: 首次要编译 .metal S->>C: dispatch 进共享编码器 S-->>T: 新 Tensor 立即返回 C->>G: 每 50 次 commit T->>S: to_vec2 S->>C: blit 到共享内存 S->>C: flush_and_wait_current C->>G: commit 并等待 G-->>T: 读回 Vec
代码分两个 crate:candle-core/src/metal_backend/(mod.rs 2378 行是 BackendStorage 的实现,device.rs 486 行是 MetalDevice 和缓冲池);candle-metal-kernels(0.83 万行 Rust 加 1.7 万行 .metal)负责命令缓冲区、编码器、内核加载和每个内核的启动参数。它直接用 objc2-metal 调 Metal,不经过 wgpu。
本页的实验 crate 是 exp-candle/backend-ops(candle-core 0.11.0,features metal accelerate;M2 Max、macOS 27、rustc 1.99.0;全部 release)。计时前都调 device.synchronize()。
MetalDevice 有什么
MetalDevice 可以 Clone,字段除了 id 和设备句柄全是 Arc,克隆出来的还是同一个设备:
#[derive(Clone)]
pub struct MetalDevice {
pub(crate) id: DeviceId,
pub(crate) device: Device,
pub(crate) commands: Arc<Commands>,
pub(crate) buffers: Arc<RwLock<BufferMap>>,
pub(crate) private_buffers: Arc<RwLock<BufferMap>>,
pub(crate) kernels: Arc<Kernels>,
pub(crate) seed: Arc<Mutex<Buffer>>,
pub(crate) seed_value: Arc<RwLock<u64>>,
pub(crate) residency_set: Arc<ResidencySet>,
}
(引用时删去了字段上的文档注释。)
| 字段 | 是什么 |
|---|---|
id | 进程内自增的编号,判断「同一设备」用它,不用 GPU 的 registryID |
device | MTLDevice 的包装 |
commands | 命令队列、当前命令缓冲区、当前编码器,见下一节 |
buffers / private_buffers | 两个缓冲池,按大小分桶:HashMap<usize, Vec<Arc<Buffer>>> |
kernels | 编译好的库和管线的缓存 |
seed / seed_value | 随机数内核用的种子,放在一个 8 字节的缓冲区里 |
residency_set | 所有分配过的缓冲区登记在这里,挂到命令队列上,让它们常驻 GPU 可见的内存 |
BackendDevice::new 按序号取 Device::all() 里的一个设备,建一个命令队列和一个空的 Kernels。实测 Device::new_metal(0) 花 8 到 9 ms;这时一个内核都没有编译。
MetalStorage 只有四个字段:buffer: Arc<Buffer>、device: MetalDevice、count、dtype。缓冲区不带类型,所以要另存 dtype。
一个 op 怎么编码
各个 op 在 metal_backend/mod.rs 里的写法一样:从缓冲池取输出缓冲区,device.command_encoder() 取编码器,按 dtype 和布局拼出内核名,调 candle_metal_kernels 里对应的 call_* 函数。以 matmul 为例:
let buffer = self
.device
.new_buffer_builder()
.with_size_for(b * m * n, self.dtype)
.with_label("matmul")
.build()?;
let encoder = self.device.command_encoder()?;
let dtype = match self.dtype {
DType::F32 => candle_metal_kernels::GemmDType::F32,
DType::F16 => candle_metal_kernels::GemmDType::F16,
DType::BF16 => candle_metal_kernels::GemmDType::BF16,
dtype => {
return Err(
MetalError::Message(format!("mlx matmul doesn't support {dtype:?}")).into(),
)
}
};
candle_metal_kernels::call_mlx_gemm(
&self.device.device,
&encoder,
&self.device.kernels,
dtype,
(b, m, n, k),
// ...
)
.map_err(MetalError::from)?;
call_mlx_gemm 是从 MLX 移植的 GEMM:m == 1 或 n == 1 时改走 gemv;否则按矩阵大小、dtype、转置方式和设备类型选一组分块参数,内核名形如 gemm_nn_f32_f32_32_32_16_2_2,再用函数常量(function constants)传「m、n、k 是否对齐到分块」这类开关。最后 set_params! 绑定缓冲区、dispatch_thread_groups 发出去,函数返回。没有任何等待。
批维的处理是这个函数的一个缺口。CPU 的 ab_skip 要求所有批维能合成一个步长,合不成就报 MatMulUnexpectedStriding(见 CPU 后端);call_mlx_gemm 不做这个检查,批步长只取倒数第三维的步长:
let a_stride = if lhs_stride.len() > 2 {
lhs_stride[lhs_stride.len() - 3] as isize
} else {
(m * k) as isize
};
b 个批次一律按这一个步长往后跳。对一个在第二维上 narrow 过的四维张量,这个假设不成立,结果就错了,而且不报错。batch_mm 程序(最小复现):
let x = Tensor::randn(0f32, 1., (2, 4, 3, 5), &cpu)?;
let y = Tensor::randn(0f32, 1., (2, 2, 5, 6), &cpu)?;
let xn = x.narrow(1, 0, 2)?; // 形状 (2, 2, 3, 5),步长 (60, 15, 5, 1)
let reference = xn.contiguous()?.matmul(&y)?;
let xm = x.to_device(&metal)?.narrow(1, 0, 2)?;
let got = xm.matmul(&y.to_device(&metal)?)?.to_device(&cpu)?;
xn dims [2, 2, 3, 5] stride [60, 15, 5, 1]
cpu strided: Err(MatMulUnexpectedStriding { lhs_l: Layout { shape: [2, 2, 3, 5], stride: [60, 15, 5, 1], start_offset: 0 }, rhs_l: Layout { shape: [2, 2, 5, 6], stride: [60, 30, 6, 1], start_offset: 0 }, bmnk: (4, 3, 6, 5), msg: "non-contiguous lhs" })
metal batch (0,0) max |diff| = 0.00000008940697
metal batch (0,1) max |diff| = 0.00000023841858
metal batch (1,0) max |diff| = 5.5542965
metal batch (1,1) max |diff| = 7.631793
metal after contiguous(): max |diff| = 0.00000023841858
四个批次按步长 15 排,第三个批次从偏移 30 读,正确的位置是 60,后两个批次读到的是 x 里被 narrow 掉的那部分。这是 0.11.0 的上游 bug;先 contiguous() 就对了。布局与视图一页从布局的角度讲了同一个问题。
encoder 是什么?Commands::command_encoder:
pub fn command_encoder(&self) -> Result<CommandsGuard<'_>, MetalKernelError> {
let mut state_guard = self.state.lock().unwrap();
let count = self.compute_count.fetch_add(1, Ordering::Relaxed);
let flush = count >= self.compute_per_buffer;
if flush {
self.commit_swap_locked(&mut state_guard, 1)?;
}
if state_guard.current_encoder.is_none() {
let fence = Arc::new(Fence::new(&self.device));
let enc = state_guard.current.compute_command_encoder(&fence);
// Wait for all prior encoder fences before the first dispatch.
// ...
state_guard.current_encoder = Some(enc);
}
Ok(CommandsGuard { guard: state_guard })
}
- 返回的
CommandsGuard持有Commands的互斥锁,整个 op 编码期间别的线程进不来;guard 在 op 函数结束时释放。 - 计算编码器是共享的:当前命令缓冲区上只有一个
current_encoder,第一次用到时创建,之后每个 op 都往同一个编码器里追加 dispatch,直到提交或者有人要 blit。 - 这个编码器是并发的:
compute_command_encoder用MTLDispatchType::Concurrent创建,GPU 可以让相邻的 dispatch 重叠执行。缓冲区又都用HazardTrackingModeUntracked分配(RESOURCE_OPTIONS),Metal 不替你跟踪读写依赖。
依赖由 candle 自己跟踪。内核绑定缓冲区时分两种:set_input_buffer 和 set_output_buffer,按缓冲区指针记进上一批 dispatch 的输入、输出集合。这次要读的缓冲区上一批写过(写后读,RAW),或者这次要写的缓冲区上一批读过、写过(WAR、WAW),就标记 needs_barrier;下一次 dispatch 前 auto_barrier 插一道 memoryBarrierWithScope(Buffers)。没有依赖的 dispatch 之间没有屏障,可以并行。跨编码器、跨命令缓冲区的依赖用 MTLFence:每个编码器结束时 updateFence,把自己写过的缓冲区登记到 prev_ce_outputs,下一个编码器开始时对表里的每个 fence waitForFence。
这套「共享并发编码器 + 自动屏障」是 PR #3511(「[Metal] Concurrent dispatching」,首次进入 0.11.0)引入的。之前每个 op 各开一个编码器,编码器之间天然串行。也因此,candle-metal-kernels 的 set_params! 里写输出要包一层 Output::new(buffer):裸的 &Buffer 会被当成输入登记,自定义 Metal 算子同样要遵守(见自定义算子)。
什么时候提交
上面的 count >= self.compute_per_buffer 决定提交时机。Commands::new 读环境变量:
const DEFAULT_CANDLE_METAL_COMPUTE_PER_BUFFER: usize = 50;
// ...
let compute_per_buffer = match std::env::var("CANDLE_METAL_COMPUTE_PER_BUFFER") {
Ok(val) => val
.parse()
.unwrap_or(DEFAULT_CANDLE_METAL_COMPUTE_PER_BUFFER),
_ => DEFAULT_CANDLE_METAL_COMPUTE_PER_BUFFER,
};
字段的文档注释说它是「每个命令缓冲区最多几个计算编码器」,但编码器已经是共享的了,compute_count 实际数的是 command_encoder() 和 blit_command_encoder() 被调用的次数,大致等于 op 的个数。数到 50,commit_swap_locked 结束当前编码器、commit 当前命令缓冲区、放进 in_flight、换一个新的。提交之后 GPU 才开始执行这一批;不等它执行完,CPU 继续编码下一批。环境变量解析失败(比如 abc)退回 50;设成 0 等于每次取编码器都提交。
metal_queue 程序做 500 次 x = x.add(&y)(x、y 是 1048576 个 f32),分三段计时:编码循环本身、synchronize、to_vec1;另一组是 1024 个元素的张量连加 5000 次。先用一次小链预热,编译好要用的内核:
let t0 = Instant::now();
for _ in 0..steps {
x = x.add(&y)?;
}
let encode = ms(t0);
let t1 = Instant::now();
if sync_first {
dev.synchronize()?;
}
let sync = ms(t1);
let t2 = Instant::now();
let v = x.to_vec1::<f32>()?;
let read = ms(t2);
CANDLE_METAL_COMPUTE_PER_BUFFER=None
len=1048576 steps=500 encode 0.78 ms synchronize 0.00 ms to_vec1 8.01 ms total 8.79 ms x[0]=500
len=1048576 steps=500 encode 0.82 ms synchronize 7.44 ms to_vec1 0.71 ms total 8.97 ms x[0]=500
len=1024 steps=5000 encode 6.90 ms synchronize 0.00 ms to_vec1 3.42 ms total 10.31 ms x[0]=5000
CANDLE_METAL_COMPUTE_PER_BUFFER=Some("1")
len=1048576 steps=500 encode 8.88 ms synchronize 0.00 ms to_vec1 1.58 ms total 10.46 ms x[0]=500
len=1048576 steps=500 encode 8.64 ms synchronize 1.12 ms to_vec1 0.71 ms total 10.47 ms x[0]=500
len=1024 steps=5000 encode 84.83 ms synchronize 0.00 ms to_vec1 1.68 ms total 86.51 ms x[0]=5000
(每种设置跑两轮,这里是第二轮;第一轮数字相近。)
| 设置 | 500 次大加法:编码 / 总计 | 5000 次小加法:总计 |
|---|---|---|
| 默认(50) | 0.8 ms / 8.8 到 9.0 ms | 10.0 到 10.3 ms |
1 | 8.6 到 8.9 ms / 10.5 ms | 85.5 到 86.5 ms |
0 | 8.6 ms / 10.0 到 10.8 ms | 82.3 到 88.3 ms |
10 | 1.2 ms / 8.7 到 8.9 ms | 13.4 到 13.7 ms |
1000 | 0.6 ms / 9.1 到 9.4 ms | 11.9 到 12.3 ms |
abc | 0.8 到 0.9 ms / 8.8 到 9.1 ms | 10.0 到 11.1 ms |
- 默认设置下,编码 500 个 op 只要 0.8 ms,平均每个 1.6 µs。GPU 的活(约 8 ms,4 MB 读两份写一份,500 次)全部落在后面那一次等待里:不先
synchronize就算在to_vec1头上,先synchronize就算在它头上,to_vec1本身(blit 4 MB 再拷进Vec)只剩 0.7 ms。 - 每个 op 提交一次(
1或0)时,提交本身的开销在每个 op 十几微秒,小张量上总时间变成 8 倍。大张量上 GPU 时间占大头,差别只有 1 到 2 ms。 - 50 和 1000 差不多,
abc就是 50。这台机器上默认值已经在平台区,往大调没什么收益。
读回:等的是谁
MetalStorage::to_cpu 是所有 to_vec*、to_scalar、to_device(&Cpu) 的终点:
pub(crate) fn to_cpu<T: Clone>(&self) -> Result<Vec<T>> {
let size = self.count * self.dtype.size_in_bytes();
let buffer = self
.device
.new_buffer_builder()
.with_size(size)
.with_label("blit_to_cpu_dst")
.build()?;
{
let mut blit = self.device.blit_command_encoder()?;
blit.set_label("blit_to_cpu");
blit.copy_from_buffer(&self.buffer, 0, &buffer, 0, size);
}
self.device.flush_and_wait_current()?;
Ok(read_to_vec(&buffer, self.count))
}
中间结果默认放在 StorageModePrivate 的缓冲区里(只有 GPU 能读,见下一节),所以读回要先用 blit 拷进一个 StorageModeShared 的缓冲区,再从 contents() 指针拷进 Vec。拷的是整块 count 个元素,和张量的 Layout 无关,按布局挑元素是之后在 CPU 上做的。
flush_and_wait_current 在持锁时提交当前命令缓冲区并记下它,放锁后等这一个缓冲区完成。命令队列按提交顺序执行,它完成了,之前提交的也都完成了。
PR #3595(「Metal: fix CPU readback race under concurrent command submission」,首次进入 0.11.0)之前,to_cpu 编码完 blit 调的是 wait_until_completed。那个函数提交当前缓冲区,然后从 in_flight 列表里取走最后一个去等。提交说明讲了问题:别的线程同时在提交、等待时,「the in-flight list can be taken by another thread's flush_and_wait between the blit encode and the wait, causing the reader to return before its blit has executed and to read stale or unwritten destination memory」。修法就是上面的 flush_and_wait_current:「commits the current command buffer while holding the state lock and waits on that specific buffer」,并把已完成的缓冲区从 in_flight 里清掉,免得读回频繁时列表无限变长。同一个 PR 也改了量化张量的两处读回;说明里写,新加的并发读回测试在旧代码上「fail on every thread within a few iterations」。单线程程序碰不到这个竞态。
device.synchronize() 是另一条路:MetalDevice::wait_until_completed → Commands::flush_and_wait,提交当前缓冲区,取走整个 in_flight 列表,只等最后一个(注释说逐个等每个要多付 1 到 2 ms 的系统通知延迟)。两条路最后都调 drop_unused_buffers。
缓冲区:按 2 的幂分桶复用
with_size_for(..) 最终调 MetalDevice::new_buffer:
pub fn new_buffer(
&self,
element_count: usize,
dtype: DType,
_name: &str,
) -> Result<Arc<Buffer>> {
let size = element_count * dtype.size_in_bytes();
let mut buffers = self.private_buffers.write().map_err(MetalError::from)?;
if let Some(b) = find_available_buffer(size, &buffers) {
return Ok(b.clone());
}
let size = buf_size(size);
let subbuffers = buffers.entry(size).or_insert(vec![]);
let new_buffer = self
.device
.new_buffer(size, PRIVATE_RESOURCE_OPTIONS)
.map_err(MetalError::from)?;
let new_buffer = Arc::new(new_buffer);
self.residency_set.insert(&new_buffer);
subbuffers.push(new_buffer.clone());
Ok(new_buffer)
}
- 池里每个缓冲区都由池自己持有一个
Arc。find_available_buffer在不小于需求的桶里找Arc::strong_count == 1的缓冲区,也就是除了池没人再用的那个,取最小的桶。 - 找不到就新分配,大小向上取到 2 的幂(
buf_size是size.next_power_of_two())。一个 1025 字节的张量占 2048 字节。 - 中间结果用
private_buffers(StorageModePrivate,只有 GPU 访问,省掉 CPU 一致性开销);需要 CPU 读写的(读回的目标、with_zeros、从 CPU 数据创建的张量)用buffers(StorageModeShared)。 - 池不会自己缩小。
drop_unused_buffers把strong_count == 1的缓冲区从池里删掉(真正释放),它只在synchronize和读回时被调用。MetalDevice字段上的注释说「Whenever we actually allocate a new buffer, we make a full sweep to clean up unused buffers」,0.11.0 的代码里分配路径并没有做这件事。
池里的 Arc 计数还解释了另一个 0.11.0 的上游 bug。MetalStorage::try_clone 是 Ok(self.clone()),只多了一个 Arc<Buffer> 引用,没有新缓冲区;而 Tensor::copy 正是靠 try_clone「复制」数据。于是在 Metal 上 copy() 出来的张量和原张量共用显存,原地写其中一个(slice_set、zero_set、自定义的 InplaceOp),另一个跟着变。backend-ops 的 storage 程序(最小复现):
let orig = Tensor::zeros(4, DType::F32, dev)?;
let copied = orig.copy()?;
let src = Tensor::ones(2, DType::F32, dev)?;
orig.slice_set(&src, 0, 0)?;
Cpu: orig=[1.0, 1.0, 0.0, 0.0] copy=[0.0, 0.0, 0.0, 0.0]
Metal { gpu_id: 4294968354 }: orig=[1.0, 1.0, 0.0, 0.0] copy=[1.0, 1.0, 0.0, 0.0]
需要真正独立的一份时,可以用一个总会分配新输出的运算绕过去,例如 t.affine(1.0, 0.0)?(注意 to_dtype 遇到同一 dtype 时直接返回 clone,不行)。Tensor 的结构一页有更多例子。
一个张量被 drop,它的缓冲区立刻就能被下一个 op 拿去当输出,而 GPU 上可能还有排着队、要读这个缓冲区的 dispatch。之所以不出错,靠的是上面的依赖跟踪:新 op 把这个缓冲区登记为输出,而它在上一批的输入集合里,于是插入屏障(WAR);跨编码器时有 fence。注释里那句「in order for this to work, we need to guarantee the order of operation」说的就是这个前提。
PR #3394(「fix(metal): buffer pool rounds 2 down by 1」,首次进入 0.10.0)之前,buf_size 是 size.saturating_sub(1).next_power_of_two()。对多数大小这和 next_power_of_two() 一样,但 2 - 1 = 1,1.next_power_of_two() == 1:一个 bf16 或 f16 的标量要 2 字节,池给了 1 字节的缓冲区。这个 PR 改成现在的写法,并加了三个单元测试,其中 test_buf_size_bf16_f16_scalar 的注释写着「A scalar tensor requests a 2-byte buffer. This must not be rounded down to 1.」。
内核:运行时编译,两级缓存
.metal 源码不在构建时编译。source.rs 用 include_str! 把 16 个文件作为字符串嵌进二进制:
pub const AFFINE: &str = include_str!("metal_src/affine.metal");
pub const BINARY: &str = include_str!("metal_src/binary.metal");
// ...
pub const MLX_GEMM: &str = include_str!("metal_src/mlx_gemm.metal");
// ...
pub const QUANTIZED: &str = include_str!("metal_src/quantized.metal");
// ...
pub const SDPA: &str = include_str!("metal_src/scaled_dot_product_attention.metal");
目录里的第 17 个文件 utils.metal 不在其中,也没有任何文件 #include 它:newLibraryWithSource 拿到的只是一个字符串,没有包含路径,所以 get_strided_index 这样的辅助函数在 affine、binary、cast、indexing、ternary、unary 六个文件里各抄了一份。CUDA 后端正好相反,candle-kernels 的 build.rs 在构建时调 nvcc 生成 PTX;本卷没有实测 CUDA。
Kernels 有两张表:libraries: HashMap<Source, Library> 每个源文件一项,pipelines: HashMap<(KernelName, Option<ConstantValues>), ComputePipeline> 每个(内核名,函数常量)一项。load_library 第一次碰到某个 Source 时把整个文件交给 newLibraryWithSource 编译:
pub fn load_library(
&self,
device: &Device,
source: Source,
) -> Result<Library, MetalKernelError> {
let mut libraries = self.libraries.write()?;
if let Some(lib) = libraries.get(&source) {
Ok(lib.clone())
} else {
let lib = {
let source_content = self.get_library_source(source);
let compile_options = get_compile_options();
device
.new_library_with_source(source_content, Some(&compile_options))
.map_err(|e| MetalKernelError::LoadLibraryError(e.to_string()))?
};
libraries.insert(source, lib.clone());
Ok(lib)
}
}
编译时拿着写锁,同一设备上别的线程要加载任何库都得等。get_compile_options 默认开快速数学(CANDLE_METAL_ENABLE_FAST_MATH,默认 true)。load_pipeline_with_constants 在库之上再按(名字,常量)建管线:同一个库里换一个内核,或者 GEMM 换一组对齐常量,还要再建一次管线,但不用重新编译整个文件。
Kernels 在 MetalDevice::new 里新建,每个设备一份。
实测:首次与之后
metal_first 按顺序对每个 op 计「第一次」(含编译)和之后 5 次的中位数,张量是 512×512 的 f32(matmul 500 用 500×500)。下面是一次缓存未命中时的运行:
report("add f32 [Binary lib]", timed(&dev, || x.add(&y))?);
report("mul f32 [same lib]", timed(&dev, || x.mul(&y))?);
report("exp f32 [Unary lib]", timed(&dev, || x.exp())?);
report("matmul 512 [Gemm lib]", timed(&dev, || x.matmul(&y))?);
report("matmul 500 [new consts]", timed(&dev, || z.matmul(&z))?);
// ...
new_metal: 8.40 ms
add f32 [Binary lib] first 579.72 ms later(median of 5) 0.551 ms
mul f32 [same lib] first 0.54 ms later(median of 5) 0.220 ms
exp f32 [Unary lib] first 123.05 ms later(median of 5) 0.203 ms
matmul 512 [Gemm lib] first 4011.89 ms later(median of 5) 0.702 ms
matmul 500 [new consts] first 97.68 ms later(median of 5) 0.712 ms
sum_keepdim [Reduce lib] first 4490.36 ms later(median of 5) 0.549 ms
softmax_last_dim [Reduce] first 0.38 ms later(median of 5) 0.207 ms
to_dtype f16 [Cast lib] first 54.92 ms later(median of 5) 0.210 ms
QMatMul Q8_0 [Quantized lib] first 819.26 ms later(median of 5) 0.532 ms
dev2: add f32 first 0.59 ms later(median of 5) 0.552 ms
dev2: matmul 512 first 1.04 ms later(median of 5) 0.389 ms
- 首次代价按库付:
binary.metal0.58 s,mlx_gemm.metal4.0 s,reduce.metal4.5 s,quantized.metal0.8 s。和文件行数不成比例(quantized.metal7741 行反而比 1483 行的mlx_gemm.metal快),起作用的是模板实例化出多少个内核。 - 同一个库里的下一个内核(
mul之于add、softmax 之于sum)不到 1 ms;GEMM 换形状换了函数常量,要 98 ms 建新管线。 - 第二个设备
dev2有自己空的Kernels,第一次add、matmul却只要 1 ms。再让dev2先跑一个dev1也没用过的排序内核:dev2上 71 ms,之后dev1第一次跑它只要 1 ms。也就是说在 candle 自己的Kernels之下,进程内还有一层由 Metal 框架维护的缓存。
同一个程序再运行一次:
add f32 [Binary lib] first 3.14 ms later(median of 5) 0.561 ms
matmul 512 [Gemm lib] first 3.00 ms later(median of 5) 0.353 ms
sum_keepdim [Reduce lib] first 1.92 ms later(median of 5) 0.209 ms
QMatMul Q8_0 [Quantized lib] first 2.42 ms later(median of 5) 0.540 ms
跨进程还有一层:系统把编译结果存在用户缓存目录下($(getconf DARWIN_USER_CACHE_DIR)com.apple.metal/,非 app 进程共用一个目录,实测里面的库数据有 214 MB)。命中时整个库 2 到 3 ms。把 CANDLE_METAL_ENABLE_FAST_MATH=false 换一组编译选项,第一次又回到 0.57 s / 3.8 s / 4.4 s,第二次又是 2 到 3 ms,可见缓存的键里包含编译选项。
这层缓存不总能命中。实验刚开始时同一个二进制连续运行两次,两次都是约 4 s;把可执行文件复制到新路径后第一次运行也是冷的,而硬链接、改名后的副本、touch 过的原文件都是热的。没能找出稳定的失效条件;这几天机器上有多个写手同时在编译、运行别的 Metal 程序,系统缓存可能被挤掉,也可能是写入延迟。实际影响是:一个 candle Metal 程序第一次用到 GEMM 或归约时,有可能卡 4 秒以上,也可能只花几毫秒,取决于系统缓存。base 的 SmolLM2 生成程序在 Metal 上记录过 9.1 s(CPU 6.0 s);在本页的副本 backend-gen 上重跑,缓存热时 Metal 87 到 91 ms,换成 CANDLE_METAL_ENABLE_FAST_MATH=false(部分库此前没以这组选项编译过)是 2.49 s,同一次运行 CPU 都在 0.11 到 0.18 s。所以计时必须把第一次单独列出来,在 Metal 上做基准时也要先跑一遍预热。
和 burn 对照:burn 的 metal feature 走 wgpu,内核由 cubecl 在运行期从 IR 生成 MSL 源码、经 wgpu 交给 Metal 建管线,同样有首次编译的代价(见 wgpu 与 Metal);candle 手写 .metal、直接调 Metal,没有中间语言,也没有自动调优,GEMM 的分块参数是按尺寸查表选的。
CANDLE_METAL_COMPUTE_PER_BUFFER 设成 1,1024 个元素的张量连加 5000 次,总时间大约是默认设置的几倍?用上面的 metal_queue,看 len=1024 steps=5000 那一行的 total。提示:GPU 上每次加法几乎不花时间。
动脑筋答案
CANDLE_METAL_COMPUTE_PER_BUFFER=None
len=1024 steps=5000 encode 7.69 ms synchronize 0.00 ms to_vec1 2.55 ms total 10.24 ms x[0]=5000
len=1024 steps=5000 encode 6.90 ms synchronize 0.00 ms to_vec1 3.42 ms total 10.31 ms x[0]=5000
CANDLE_METAL_COMPUTE_PER_BUFFER=Some("1")
len=1024 steps=5000 encode 83.98 ms synchronize 0.00 ms to_vec1 1.52 ms total 85.50 ms x[0]=5000
len=1024 steps=5000 encode 84.83 ms synchronize 0.00 ms to_vec1 1.68 ms total 86.51 ms x[0]=5000
- 约 8.4 倍:10.3 ms 对 85.5 到 86.5 ms。
- 默认设置下 5000 次只提交 100 个命令缓冲区,每次 op 的 CPU 开销约 1.4 µs(取缓冲区、取锁、绑定参数、dispatch)。设成 1 之后每个 op 都
commit一次并新建命令缓冲区,每个 op 多出约 15 µs,全落在编码阶段。 to_vec1反而变短:命令缓冲区早就陆续提交了,最后等的只剩一小段。小张量、op 多的场景(逐 token 的解码)对这个值最敏感。
小结
MetalDevice可克隆,内部是共享的Commands、两个缓冲池、Kernels缓存;「同一设备」看进程内的DeviceId。- 每个 op 取一个缓冲区、往共享的并发计算编码器里追加一次 dispatch,立即返回;读写依赖由 candle 按缓冲区指针跟踪,自动插入屏障和 fence(0.11.0 的 #3511)。
- 每 50 次取编码器提交一次命令缓冲区(
CANDLE_METAL_COMPUTE_PER_BUFFER);设成 1 时小 op 的开销变成 8 倍。 - 读回先 blit 到共享内存,再
flush_and_wait_current等这一个命令缓冲区;GPU 的活常常算在第一次读回头上。#3595 修了多线程下读回过早返回的竞态。 - 缓冲区按 2 的幂分桶,
strong_count == 1即可复用,只在等待时释放。 - 两个 0.11.0 的上游 bug:
try_clone只复制Arc,Tensor::copy在 Metal 上不复制;call_mlx_gemm只用倒数第三维的步长当批步长,批维合不成一个步长时静默算错,CPU 上同样的输入会报错。 .metal源码嵌在二进制里,第一次用到某个库时整文件编译:GEMM 4.0 s、归约 4.5 s;进程内和跨进程(系统缓存)都有缓存,命中时只要几毫秒,但跨进程缓存不保证命中。