Rust in Everything
基于 candle 0.11.0

速度

主线位置:推理主线的最后一步,量化与部署。前面几部分已经让 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/sdecode 最小 / 中位(ms/token)decode tokens/s负载(前→后)
torch CPU f32,1 线程85.1 / 86.2238614.20 / 14.3170.44.34→3.97
torch CPU f32,8 线程81.0 / 81.3250617.04 / 17.1158.73.97→3.63
torch MPS f3221.9 / 22.7926110.28 / 10.4297.33.63→3.31
torch MPS f1622.7 / 23.389509.81 / 9.93101.93.31→3.24
candle CPU f32,accelerate116.9 / 117.4173616.03 / 16.2362.43.24→4.28
candle CPU f32,不开 accelerate237.7 / 241.285418.85 / 18.8853.14.28→4.76
candle Metal f3232.4 / 33.062644.68 / 4.69213.64.76→4.62
candle Metal f1629.5 / 29.868864.13 / 4.16242.14.62→4.41
candle Metal bf1631.3 / 31.664884.40 / 4.42227.14.41→4.19
candle CPU Q8_0208.5 / 213.69737.40 / 7.50135.14.19→6.99
candle CPU Q4_K_M523.7 / 544.93888.77 / 9.09114.16.99→9.06
candle Metal Q8_027.5 / 28.673752.72 / 2.82367.09.06→8.12
candle Metal Q4_K_M29.7 / 30.468373.19 / 3.22314.08.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 f32337.61329.021.9 / 1315.2
torch MPS f16381.61267.322.7 / 1255.9
candle Metal f3259.3602.932.4 / 599.3
candle Metal f1645.8534.229.5 / 528.7
candle Metal bf16130.4564.131.3 / 563.6
candle Metal Q4_K_M47.2414.329.7 / 407.7
candle Metal Q8_0(编译后第一次运行)886.92426.827.5 / 348.7
candle Metal Q8_0(下一个进程)55.8354.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 线程248(默认)12
torch CPU f32 decode(torch.set_num_threads)14.2315.1815.5717.16—
torch CPU f32 prefill(ms)85.975.670.580.8—
candle 不开 accelerate decode(RAYON_NUM_THREADS)14.8215.1416.2918.7118.24
candle 不开 accelerate prefill(ms)591.1358.7243.4238.9251.3

两边一致:decode 1 个线程最快,线程越多越慢;prefill 有真正的矩阵乘可分,线程多才有用。PyTorch 在 4 线程时 prefill 最快,8 线程反而退回去。

candle 开 accelerate 时,矩阵乘的线程归 Accelerate 自己管;RAYON_NUM_THREADS 管的是 rayon 的全局池,softmax 的 par_chunks 默认就跑在它上面(全局池默认是逻辑核数,这台 12 个)。几个线程池各管什么,源码卷的 CPU 后端列了一张表。同一轮里(5 遍取最小):

candle CPU f32,accelerateprefill(ms)decode(ms/token)负载
默认116.416.034.19→4.62
VECLIB_MAXIMUM_THREADS=1127.416.084.62→4.25
RAYON_NUM_THREADS=1129.512.674.25→3.98
RAYON_NUM_THREADS=1 + PERF_CTX=1(见下)127.112.253.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 也一样受益(同一轮):

默认 decodewith_context decode默认 prefillwith_context prefill
CPU Q8_07.486.44207.5205.8
CPU Q4_K_M8.847.49525.5524.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_THREADS1248(默认)12
Q8_0 prefill(ms)585.4354.3254.7210.3464.5
Q8_0 decode(ms/token)9.198.257.307.4113.42
Q4_K_M prefill(ms)2960.61552.9863.4528.31383.1
Q4_K_M decode(ms/token)20.7013.8210.029.0116.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 的一次前向)。

❗IMPORTANT
动脑筋:candle Metal f32 decode 的两段是 1.52 ms 编码 + 2.95 ms 等待。如果把 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_cond mask 在 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 秒,命中时几十毫秒。

💬 关联讨论

发起讨论
加载中...