速度
主线位置:推理主线的最后一步,量化与部署。前面几部分已经让 logits 和生成结果对上,这一页量同一个生成任务在两边跑多快,以及差在哪。
版本:PyTorch 2.15.0 / transformers 5.22.0(CPU;MPS)、candle 0.11.0(CPU,有无 accelerate;Metal)
flowchart TB prompt["203 个 token"] --> prefill["prefill 一次前向"] prefill --> first["第一个 token"] first --> decode["decode 128 步"] decode --> cache["每步读 KV 缓存"] prefill --> big["大矩阵乘 算力"] decode --> small["小算子 调度 读权重"]
实验代码:Python 侧 bench.py、ops.py、mps_split.py,candle 侧 crate perf-bench(bin perf-bench、ops、qops、split),一个 matrix.py 依次启动每种配置。平台 M2 Max(8 个性能核、4 个能效核)、macOS 27、rustc 1.99.0,release 构建。模型是本地的 SmolLM2-135M-Instruct 权重和同目录下的两个 GGUF。
这一页单独在机器上跑,没有别的实验同时进行;但 uptime 的 1 分钟负载在开始前也有 2.4 到 4.3(桌面程序),跑 CPU 多线程配置时会被自己的线程推到 7 到 11。每个表都写了负载。
两边量的是同一件事
任务:一段 203 个 token 的英文(不套对话模板,不加特殊 token),prefill 一次,再贪心 decode 128 步。decode 不在 eos 停,每次都走满 128 步。每个配置是一个新进程:加载模型,先完整跑 1 遍(第 0 遍,单独报告),再跑 10 遍,报告这 10 遍的最小值和中位数。每遍都换一个新的 KV 缓存。
transformers 一侧手写循环而不是调 generate,这样两边每一步做的事一样。logits_to_keep=1 让 prefill 只对最后一个位置算 lm_head,和 candle 的 llama.rs 一样(不传的话要多算一个 203 × 49152 的矩阵乘):
torch.set_num_threads(threads) # 1 或 8;MPS 一侧也设成 8
model = AutoModelForCausalLM.from_pretrained(model_dir, dtype=dtype).to(dev).eval() # 默认 sdpa 注意力
def next_token(ids, cache):
out = model(ids, past_key_values=cache, use_cache=True, logits_to_keep=1)
return out.logits[0, -1].argmax().item() # .item() 读回主机,MPS 上的活这时才算做完
with torch.inference_mode():
for rep in range(reps + 1):
cache = DynamicCache()
t = time.perf_counter()
nxt = next_token(prompt, cache)
p_ms = (time.perf_counter() - t) * 1e3
t = time.perf_counter()
for _ in range(steps):
nxt = next_token(torch.tensor([[nxt]], device=dev), cache)
d_ms = (time.perf_counter() - t) * 1e3
# ... 第 0 遍单独打印,其余记下
candle 一侧,safetensors 用 candle_transformers::models::llama,GGUF 用 quantized_llama,同一个循环:
fn next_token(run: &mut Run, ids: &[u32], pos: usize, dev: &Device) -> Result<u32> {
let x = Tensor::new(ids, dev)?.unsqueeze(0)?;
// to_scalar 读回主机,返回时 GPU 上的活已经做完
Ok(run.forward(&x, pos)?.squeeze(0)?.argmax(0)?.to_scalar::<u32>()?)
}
for rep in 0..=reps {
run.reset(&dev)?; // llama.rs:新建 Cache;quantized_llama 在 index_pos 为 0 时自己丢掉旧缓存
let t = Instant::now();
let mut next = next_token(&mut run, &prompt, 0, &dev)?;
let p_ms = t.elapsed().as_secs_f64() * 1e3;
let t = Instant::now();
for k in 0..steps {
next = next_token(&mut run, &[next], prompt.len() + k, &dev)?;
}
let d_ms = t.elapsed().as_secs_f64() * 1e3;
// ...
}
每一步都把下一个 token 读回主机,计时里自然包含了等 GPU 的时间,不会出现Metal 上计时几乎为零那种只量到编码的情况。Run 是一个小枚举,把 Llama 加 Cache 和 ModelWeights 包成同一个 forward(x, pos)。Cache 为什么要每遍新建,见 KV 缓存。
所有 f32、f16、bf16 和 Q8_0 配置生成的前 16 个 token 相同,都以 \n\nWhat is the name of the river that is said to have been the 开头;Q4_K_M 从第 6 个 token 起不同,这是量化误差,见 GGUF。
结果
prefill 203 个 token、decode 128 个 token,10 遍取最小值和中位数,tokens/s 按最小值算:
| 配置 | prefill 最小 / 中位(ms) | prefill tokens/s | decode 最小 / 中位(ms/token) | decode tokens/s | 负载(前→后) |
|---|---|---|---|---|---|
| torch CPU f32,1 线程 | 85.1 / 86.2 | 2386 | 14.20 / 14.31 | 70.4 | 4.34→3.97 |
| torch CPU f32,8 线程 | 81.0 / 81.3 | 2506 | 17.04 / 17.11 | 58.7 | 3.97→3.63 |
| torch MPS f32 | 21.9 / 22.7 | 9261 | 10.28 / 10.42 | 97.3 | 3.63→3.31 |
| torch MPS f16 | 22.7 / 23.3 | 8950 | 9.81 / 9.93 | 101.9 | 3.31→3.24 |
| candle CPU f32,accelerate | 116.9 / 117.4 | 1736 | 16.03 / 16.23 | 62.4 | 3.24→4.28 |
| candle CPU f32,不开 accelerate | 237.7 / 241.2 | 854 | 18.85 / 18.88 | 53.1 | 4.28→4.76 |
| candle Metal f32 | 32.4 / 33.0 | 6264 | 4.68 / 4.69 | 213.6 | 4.76→4.62 |
| candle Metal f16 | 29.5 / 29.8 | 6886 | 4.13 / 4.16 | 242.1 | 4.62→4.41 |
| candle Metal bf16 | 31.3 / 31.6 | 6488 | 4.40 / 4.42 | 227.1 | 4.41→4.19 |
| candle CPU Q8_0 | 208.5 / 213.6 | 973 | 7.40 / 7.50 | 135.1 | 4.19→6.99 |
| candle CPU Q4_K_M | 523.7 / 544.9 | 388 | 8.77 / 9.09 | 114.1 | 6.99→9.06 |
| candle Metal Q8_0 | 27.5 / 28.6 | 7375 | 2.72 / 2.82 | 367.0 | 9.06→8.12 |
| candle Metal Q4_K_M | 29.7 / 30.4 | 6837 | 3.19 / 3.22 | 314.0 | 8.12→7.63 |
中位数和最小值相差都在 4% 以内。几条读法:
- CPU 上 f32 两边是一个量级。 decode 每个 token 14 到 17 ms,PyTorch 1 线程最快;prefill PyTorch 快约 30%。
- GPU 上分成两半。 prefill MPS 比 candle Metal 快约三分之一;decode 反过来,candle Metal 每个 token 4.7 ms,MPS 10.3 ms,差一倍多。
- GGUF 在 CPU 上只帮 decode。 Q8_0 的 decode 比 f32 快一倍,prefill 却慢了 80%;Q4_K_M 的 prefill 慢到 f32 的 4.5 倍。
- Metal 上 dtype 影响很小。 f32、bf16、f16 的 decode 只差 13%,权重字节数却差一倍。
下面逐条拆开。从头写 Llama 有一张三种实现的速度表(同一个 main,换前向的写法),那里比的是「零件怎么写」,这里比的是「两个框架、几种设备」。
第一遍和冷启动
上表不含第 0 遍。第 0 遍(同一进程里第一次 prefill 和 decode,系统的 Metal 着色器缓存是热的):
| 配置 | 第 0 遍 prefill(ms) | 第 0 遍 decode 128 个(ms) | 之后的最小值(ms) |
|---|---|---|---|
| torch MPS f32 | 337.6 | 1329.0 | 21.9 / 1315.2 |
| torch MPS f16 | 381.6 | 1267.3 | 22.7 / 1255.9 |
| candle Metal f32 | 59.3 | 602.9 | 32.4 / 599.3 |
| candle Metal f16 | 45.8 | 534.2 | 29.5 / 528.7 |
| candle Metal bf16 | 130.4 | 564.1 | 31.3 / 563.6 |
| candle Metal Q4_K_M | 47.2 | 414.3 | 29.7 / 407.7 |
| candle Metal Q8_0(编译后第一次运行) | 886.9 | 2426.8 | 27.5 / 348.7 |
| candle Metal Q8_0(下一个进程) | 55.8 | 354.6 | — |
CPU 配置的第 0 遍和之后差不到 6%。MPS 第 0 遍的 prefill 慢了 300 多毫秒,candle 只慢十几到一百毫秒。
Q8_0 那一行是这个二进制编译出来以后第一次跑 Q8_0:第 0 遍 prefill 多了约 0.86 s,decode 多了约 2.1 s;紧接着再起一个进程,就回到 55.8 ms 和 354.6 ms。这两个增量的大小,和源码卷在冷启动时量到的 quantized.metal 0.8 s、scaled_dot_product_attention.metal 2.1 s 的编译时间相当(quantized_llama 在 Metal 上 decode 走 sdpa),像是这两个库没有命中系统缓存;本页没有加编译跟踪去确认。为什么命中不命中由系统决定,见 Metal 第一次运行要好几秒。
要一次说得清的冷启动,得让系统缓存一定不命中。CANDLE_METAL_ENABLE_FAST_MATH 是编译选项,属于缓存键的一部分;在这台机器上第一次用 false 跑 f32(3 遍),紧接着再跑一次:
### CANDLE_METAL_ENABLE_FAST_MATH=false,第一次
first rep: prefill 5943.7 ms, decode 128 tokens 612.2 ms
real 12.44
### 同样的命令,第二次
first rep: prefill 48.0 ms, decode 128 tokens 572.9 ms
real 2.56
冷的那次,第一次 prefill 用了 5.9 秒,整个进程 12.44 秒;热的时候整个进程 2.56 秒。冷的那次,进程总时间比打印出来的几段之和多出约 4 秒,不在计时区间里:Cache::new 算 RoPE 表时有一次 matmul,是这个进程第一次用到 GEMM 内核库,源码卷量到这个库冷编译要 3.8 秒,这几秒多半在这里。之后的稳态 decode 两次相同(565.8 ms 对 568.7 ms),关掉 fast math 不影响这个模型的速度。服务里的做法是启动时预热,见部署。
CPU decode:线程越多越慢
decode 每一步的输入只有一行。先看两边的矩阵乘本身:把一次前向里的 211 个 Linear 按形状单独拿出来,随机权重,每种跑 60 次取最小(前 10 次不算):
### candle(accelerate),rows=1
q_proj [1, 576] x [576, 576] min 0.0065 ms x30 = 0.196 ms
...
lm_head [1, 576] x [576, 49152] min 1.8076 ms x 1 = 1.808 ms
all Linear layers of one forward: 3.77 ms (F32)
### torch,1 线程 / 8 线程,rows=1
all Linear layers of one forward: 4.09 ms (float32, 1 threads)
all Linear layers of one forward: 4.09 ms (float32, 8 threads)
两边一样,也不随 PyTorch 的线程数变:这台机器上的 PyTorch wheel 是 BLAS_INFO=accelerate(torch.__config__.show()),和 candle 开 accelerate 时调的是同一个 cblas_sgemm。这个 3.8 ms 是权重留在缓存里时的下限;真实的一步里 211 个权重各读一次,f32 一共 513 MiB,要从内存读进来,所以实际的矩阵乘时间要长得多:下面的采样里 cblas_sgemm 占 66%,按 16 ms 一步算约 10.6 ms。
再看整步。macOS 自带的 sample 对正在 decode 的 candle 进程采样 5 秒,主线程上的分布(按调用栈里包含的函数累计):
thread total 1068 samples
inclusive 'Llama7forward': 983 (92.0%)
inclusive 'cblas_sgemm': 707 (66.2%)
inclusive 'SoftmaxLastDim': 217 (20.3%)
inclusive 'Tensor3cat': 7 (0.7%)
矩阵乘占三分之二,softmax_last_dim 占五分之一。一步里 30 次 softmax,每次的输入只有 [1, 9, 1, L],算术量可以忽略;这 217 个样本里有 205 个在 rayon 的 in_worker_cold 里,栈顶是 __psynch_cvwait:主线程把 9 行 softmax 交给 rayon 的线程池,然后睡下等它们做完。SoftmaxLastDim 的 cpu_fwd 用的是 par_chunks,不管输入多小都并行。
所以 decode 的瓶颈不在算力,在「把很小的活分给多个线程」的固定开销。两边都改线程数验证(5 遍取最小,ms/token):
| 1 线程 | 2 | 4 | 8(默认) | 12 | |
|---|---|---|---|---|---|
torch CPU f32 decode(torch.set_num_threads) | 14.23 | 15.18 | 15.57 | 17.16 | — |
| torch CPU f32 prefill(ms) | 85.9 | 75.6 | 70.5 | 80.8 | — |
candle 不开 accelerate decode(RAYON_NUM_THREADS) | 14.82 | 15.14 | 16.29 | 18.71 | 18.24 |
| candle 不开 accelerate prefill(ms) | 591.1 | 358.7 | 243.4 | 238.9 | 251.3 |
两边一致:decode 1 个线程最快,线程越多越慢;prefill 有真正的矩阵乘可分,线程多才有用。PyTorch 在 4 线程时 prefill 最快,8 线程反而退回去。
candle 开 accelerate 时,矩阵乘的线程归 Accelerate 自己管;RAYON_NUM_THREADS 管的是 rayon 的全局池,softmax 的 par_chunks 默认就跑在它上面(全局池默认是逻辑核数,这台 12 个)。几个线程池各管什么,源码卷的 CPU 后端列了一张表。同一轮里(5 遍取最小):
| candle CPU f32,accelerate | prefill(ms) | decode(ms/token) | 负载 |
|---|---|---|---|
| 默认 | 116.4 | 16.03 | 4.19→4.62 |
VECLIB_MAXIMUM_THREADS=1 | 127.4 | 16.08 | 4.62→4.25 |
RAYON_NUM_THREADS=1 | 129.5 | 12.67 | 4.25→3.98 |
RAYON_NUM_THREADS=1 + PERF_CTX=1(见下) | 127.1 | 12.25 | 3.98→3.68 |
- 限制 Accelerate 的线程(
VECLIB_MAXIMUM_THREADS=1)只让 prefill 慢了 11 ms,decode 不变:一行的矩阵乘本来就没有多线程可用。 RAYON_NUM_THREADS=1让 decode 从 16.03 降到 12.67 ms,比 PyTorch 1 线程的 14.20 还快;代价是 prefill 慢 13 ms,因为 prefill 里 softmax 是[1, 9, 203, 203],并行确实有用。
PyTorch 的默认线程数(torch.get_num_threads())这台机器上是 8。
Device::with_context
PERF_CTX=1 那一行只改了一处:把每一遍的 prefill 和 decode 包进 Device::with_context:
let (p_ms, d_ms, ids) = if ctx { dev.with_context(one)? } else { one()? };
在 CPU 上,它在 candle 自己的 rayon 线程池(candle_pool)里执行闭包,闭包里的 par_chunks 就由池里的线程直接分活,不再是「池外的主线程交出去、睡下、被叫醒」。文档注释写的就是这个场景:「keeping worker threads warm across the many short parallel sections in a model forward pass」。效果(另一轮,5 遍取最小,负载 2.44 到 3.83):decode 16.05 → 13.86 ms,prefill 116.2 → 106.8 ms,两头都快;和 RAYON_NUM_THREADS=1 合用,decode 是 12.25 ms(上表)。GGUF 也一样受益(同一轮):
| 默认 decode | with_context decode | 默认 prefill | with_context prefill | |
|---|---|---|---|---|
| CPU Q8_0 | 7.48 | 6.44 | 207.5 | 205.8 |
| CPU Q4_K_M | 8.84 | 7.49 | 525.5 | 524.2 |
candle_transformers 的 llama.rs 和 quantized_llama.rs 都不调用它;0.11.0 的 candle-examples 里只有 quantized-qwen3 一个例子调用。源码卷用单个 matmul 量过它,256 的 matmul 快 25% 到 50%、1024 的看不出差别(CPU 后端);放到整个模型的 decode 上,每步 30 段很短的 softmax 并行,效果就明显了。从 PyTorch 过来没有这么一个要自己包的东西:PyTorch 的算子总在它自己的线程池里跑。这个池和 with_context 都是 0.11.0 才加的,默认线程数在 0.11.0 里改过三次,来历见源码卷 CPU 后端的冷知识。
CPU prefill:mask 和不开 accelerate
prefill 的 211 个 Linear 两边也一样:candle 34.51 ms,PyTorch 1 线程 33.61 ms、8 线程 33.44 ms(各含一次 203 行的 lm_head,实际前向只算最后一行,扣掉后约 29.7 ms)。差别在别处。对 candle 的 prefill 采样(只做 prefill,连续 80 遍):
thread total 1046 samples
inclusive 'Llama7forward': 1037 (99.1%)
inclusive 'cblas_sgemm': 432 (41.3%)
inclusive 'SoftmaxLastDim': 62 (5.9%)
inclusive 'where_cond': 289 (27.6%)
inclusive 'masked_fill': 289 (27.6%)
inclusive 'RmsNorm': 89 (8.5%)
inclusive 'rotary': 37 (3.5%)
27.6% 落在 llama.rs 的 masked_fill 里,它用 where_cond 把 [1, 9, 203, 203] 的注意力分数中上三角换成 -inf。按 116.9 ms 算约 32 ms,正好是两边差距(35.9 ms)的大部分。从头写 Llama 用另一种办法得到了同样的结论:把加法 mask 换成 where_cond,prefill 就从 74.8 ms 变成 101.2 ms。PyTorch 一侧本页没有采样,它的 prefill 时间花在哪没有拆。
不开 accelerate 时,矩阵乘走 gemm crate(CpuStorage 的 matmul 用 get_num_threads() 个 rayon 线程),prefill 从 117 ms 变成 238 ms,decode 从 16.0 变成 18.9 ms。上面的线程表里它的 prefill 从 1 线程的 591 ms 降到 4 线程的 243 ms 就不再降了。在 macOS 上没有理由关掉 accelerate;Linux 上没有 Accelerate,见部署。
CPU GGUF:decode 快,prefill 慢
decode 每一步要把全部权重读一遍:f32 是 513 MiB,Q8_0 文件里的权重 136.3 MiB。Q8_0 的 decode(7.40 ms)比 f32(16.03 ms)快一倍,方向和读的字节数一致。
prefill 反过来,要看矩阵乘本身的算力。用 QTensor::quantize 把同一个 [576, 1536] 的随机矩阵量化成各种格式,单独量一次 QMatMul(down_proj 的形状,1536 是所有块大小的倍数;35 次取最小):
F32 [203, 1536] x [1536, 576]: min 0.2054 ms
Q8_0 [203, 1536] x [1536, 576]: min 0.8959 ms
Q5_0 [203, 1536] x [1536, 576]: min 4.2747 ms
Q4K [203, 1536] x [1536, 576]: min 0.6777 ms
Q6K [203, 1536] x [1536, 576]: min 1.1029 ms
F32 [1, 1536] x [1536, 576]: min 0.0169 ms
Q8_0 [1, 1536] x [1536, 576]: min 0.0050 ms
Q5_0 [1, 1536] x [1536, 576]: min 0.0216 ms
- 203 行时,f32 走 Accelerate 的
sgemm,比 Q8_0 的块点积快 4.4 倍。这就是 Q8_0 prefill 比 f32 慢的原因。 - 1 行时,Q8_0 比 f32 快 3.4 倍(读的字节少),和 decode 的方向一致。
- Q5_0 比 Q8_0 慢 4.8 倍。 下载的 Q4_K_M 文件里,权重大半不是 Q4_K:
F32 61 tensors 0.1 MiB
Q4K 16 tensors 7.6 MiB
Q5_0 166 tensors 51.2 MiB
Q6K 14 tensors 9.7 MiB
Q8_0 15 tensors 30.3 MiB
(行长 576 不是 K-quants 块大小 256 的倍数,这些矩阵只能退回 Q5_0,见 GGUF。)而 0.11.0 的 BlockQ5_0 的 vec_dot 在 aarch64 上没有 NEON 版本,直接调 vec_dot_unopt,是逐元素的标量循环;Q4_0、Q8_0、Q4K、Q6K 都有 neon::vec_dot_*(candle-core/src/quantized/k_quants.rs)。origin/main 上的 BlockQ5_0 仍是这样。所以 Q4_K_M 文件在 CPU 上 prefill 最慢:大部分时间在一种没有向量化的格式上。
量化矩阵乘的并行不用 rayon,用 candle 自己的 barrier_pool,线程数由 CANDLE_NUM_THREADS 决定(默认同样是性能核的个数):
CANDLE_NUM_THREADS | 1 | 2 | 4 | 8(默认) | 12 |
|---|---|---|---|---|---|
| Q8_0 prefill(ms) | 585.4 | 354.3 | 254.7 | 210.3 | 464.5 |
| Q8_0 decode(ms/token) | 9.19 | 8.25 | 7.30 | 7.41 | 13.42 |
| Q4_K_M prefill(ms) | 2960.6 | 1552.9 | 863.4 | 528.3 | 1383.1 |
| Q4_K_M decode(ms/token) | 20.70 | 13.82 | 10.02 | 9.01 | 16.94 |
(负载 4.06→11.29,跑 12 线程时最高。)prefill 随线程数明显变快(Q4_K_M 从 1 到 8 线程快 5.6 倍),是算力受限;12 个线程把 4 个能效核也算进来,反而慢一倍以上。matmul 按线程数平均切列,每一段都要等所有线程到齐,多半是被能效核拖住了。RAYON_NUM_THREADS 管不到这一部分,PyTorch 的 set_num_threads 也没有对应物,要分开设。
Metal 对 MPS:decode 差在调度
decode 是 candle Metal 快一倍多,而且 MPS 的 f16 几乎不比 f32 快(10.28 对 9.81 ms),说明 MPS 的 decode 不是被读权重限住的。把一步拆成「前向调用返回」和「读回 argmax 时等待」两段(3 遍 × 128 步,取最好的一遍):
mps f32: per token: model() returns after 3.98 ms, .item() waits 5.69 ms (best of 3 reps x 128 tokens)
candle metal f32: per token: forward returns after 1.52 ms, to_scalar waits 2.95 ms (best of 3 reps x 128 tokens)
mps f16: per token: model() returns after 4.15 ms, .item() waits 4.90 ms (best of 3 reps x 128 tokens)
candle metal f16: per token: forward returns after 1.65 ms, to_scalar waits 2.28 ms (best of 3 reps x 128 tokens)
前向调用返回时,GPU 上的活只是编码好、排进了命令缓冲区(candle 一侧的机制见 Metal 上计时几乎为零)。PyTorch 光是在 Python 里走完 30 层、把算子交给 MPS 就要 4 ms,接近 candle 一整步的时间;candle 编码同样一步只要 1.5 ms。等待的那一段两边都含 GPU 执行,但也和编码交错进行,不能直接当成 GPU 时间;本页没有再用 GPU 计数器拆。
prefill 是 MPS 快(21.9 对 32.4 ms)。prefill 只有一次前向,调度开销摊到 203 个 token 上不重要,比的是内核本身;candle 的 llama.rs 在 Metal 上 prefill 也走 masked_fill 加 softmax 的非融合注意力。这一项本页没有拆到算子。
Metal 上三种 dtype 的 decode 是 4.68、4.40、4.13 ms:编码时间(1.5 到 1.7 ms)不随 dtype 变,变的只是等待那一段。Q8_0 在 Metal 上最快(2.72 ms):要读的权重最少,quantized_llama 在 decode 时还走融合的 sdpa,而 llama.rs 走 matmul、masked_fill、softmax、matmul 四步(见量化 llama 的一次前向)。
CANDLE_METAL_COMPUTE_PER_BUFFER 设成 1(每个 op 提交一次命令缓冲区),整步是变快、变慢还是不变?源码卷的 Metal 上计时几乎为零 说默认每 50 个编码器提交一次。先猜,再看文末。
怎么选
- CPU 上 decode 为主(聊天、补全): 设
RAYON_NUM_THREADS=1,或者把生成循环包进dev.with_context(..),两者合用最快;GGUF 用 Q8_0,别用 Q5_0 占大头的文件。 - CPU 上 prefill 为主(长 prompt、打分、分类): f32 加 accelerate 最快,量化反而慢;
llama.rs的where_condmask 在 203 个 token 上就占了近三成。 - Apple GPU: candle Metal 的 decode 比 MPS 快一倍以上,Q8_0 最快;prefill MPS 快约三分之一。服务启动时先预热(部署)。
- PyTorch 一侧: CPU decode 用 1 线程比默认的 8 线程快 17%;MPS 上换 f16 对 decode 几乎没有用。
动脑筋答案
### default
candle metal f32: per token: forward returns after 1.44 ms, to_scalar waits 2.92 ms (best of 3 reps x 128 tokens)
### CANDLE_METAL_COMPUTE_PER_BUFFER=1
candle metal f32: per token: forward returns after 14.79 ms, to_scalar waits 1.50 ms (best of 3 reps x 128 tokens)
### CANDLE_METAL_COMPUTE_PER_BUFFER=200
candle metal f32: per token: forward returns after 2.09 ms, to_scalar waits 2.90 ms (best of 3 reps x 128 tokens)
变慢,而且慢很多:整步从 4.36 ms 变成 16.29 ms,是原来的 3.7 倍(负载 2.98)。
- 每个 op 提交一次,「编码」那一段从 1.44 ms 涨到 14.79 ms:一步里每个 op 都要
commit一次、再新建一个命令缓冲区,这个固定开销乘上一次前向的 op 数,就是多出来的十几毫秒。 - 等待那一段反而变短(1.50 ms):工作在编码的同时就陆续提交给了 GPU,读回时剩下的少;但省下的远不够抵消提交的开销。
- 反过来调大到 200 也没有变快(2.09 + 2.90 ms)。默认的 50(
commands.rs里的DEFAULT_CANDLE_METAL_COMPUTE_PER_BUFFER)对这个模型已经合适。这和 MPS 慢在调度是同一类问题:decode 每步的算术量很小,提交和调度的开销决定了速度。
小结
- 同一个任务(203 个 token 的 prefill、128 步贪心 decode,10 遍取最小):CPU f32 两边同一量级(decode 14 到 17 ms/token,torch 1 线程最快;prefill torch 81 ms、candle 117 ms);Metal 对 MPS,decode candle 4.68 ms、MPS 10.28 ms,prefill MPS 21.9 ms、candle 32.4 ms;Q8_0 在 Metal 上 2.72 ms/token 最快。
- CPU decode 受「小算子分给线程」的开销限制:
softmax_last_dim的par_chunks让主线程在 rayon 上等,占 20% 的样本;RAYON_NUM_THREADS=1(12.67 ms)或Device::with_context(13.86 ms),合用 12.25 ms。PyTorch 同样是 1 线程最快。 - CPU prefill:两边的
Linear都调 Accelerate,时间相同;candle 多出的 30 多毫秒主要在llama.rs用where_cond做的 mask 上。不开 accelerate,prefill 慢一倍。 - CPU 上 GGUF 只让 decode 变快;prefill 时量化点积比
sgemm慢 4 倍以上,Q4_K_M 文件大半是没有 NEON 实现的 Q5_0,prefill 是 f32 的 4.5 倍。量化矩阵乘的线程数要用CANDLE_NUM_THREADS单独设,算上能效核会慢一倍。 - MPS 的 decode 慢在调度:Python 里走一遍前向要 4 ms,candle 编码一步 1.5 ms。Metal 首次运行另算:系统缓存不命中时第一次 prefill 5.9 秒,命中时几十毫秒。