GGML:Backend 抽象与硬件加速
「本系列第 4/17 章」
前两章把图建成了,也把权重与激活放进了 buffer。还缺一层:谁来声明「我能算这个 op」,谁来把一张图切到 CPU 与 GPU 上。GGML 0.15.3 的答案是五层 Backend vtable,加上注册表、三遍调度和按硬件手写的矩阵核。llama.cpp 的生产执行入口是 ggml_backend_sched_graph_compute_async。
五层 vtable,而不是「一个 GPU 句柄」
ggml-backend-impl.h 把设备能力拆开,避免把「内存什么样」和「图怎么跑」焊死在同一张表上:
| 层 | 职责 |
|---|---|
ggml_backend_buffer_type_i |
对齐、是否 host、alloc_size(可含 GPU padding) |
ggml_backend_buffer_i |
alloc/free、init_tensor、同步拷贝 |
ggml_backend_i |
graph_compute、synchronize、异步 tensor / event |
ggml_backend_device_i |
supports_op、supports_buft、offload_op |
ggml_backend_reg_i |
注册名、枚举 device、proc_address |
调度器问的是 device:「这个节点你能不能算、这个 buffer type 你能不能碰」。真正开火的是 backend 的 graph_compute。buffer type 决定权重该申请 host 还是 VRAM,以及 ggml_backend_buffer_get_alloc_size 要比 ggml_nbytes 多出多少对齐。把五层合成「cudaMalloc 加一个 kernel 表」,后面的切图与 pipeline 都解释不清。
Buffer 还有用途标记。WEIGHTS 告诉调度器:能在哪块 buffer 上算,就尽量让计算跟权重走,减少把 Q4 权重传来传去。COMPUTE 标记中间激活,可以安心放在 GPU。ANY 没有倾向。llama.cpp 给权重量 WEIGHTS,是混合 offload 能工作的前提。
注册顺序决定默认优先级
ggml-backend-reg.cpp 的静态顺序是:CUDA → Metal → SYCL → Vulkan → … → CPU(最后)。调度器数组里下标越小优先级越高。CPU 最后注册,不是因为慢就弃用,而是它 supports_op 对全部算子返回真,必须当兜底。某节点 GPU 不支持——非连续、白名单外的 UNARY 子类型、split buffer 上的非 MUL_MAT——就会落到 CPU,并插入跨设备 copy。
运行时仍可 GGML_DISABLE_VULKAN=1 关掉 Vulkan,或用 ggml_backend_init_by_name("CUDA", NULL) / ggml_backend_init_best() 显式选设备。动态加载(GGML_BACKEND_DL)把 libggml-cuda.so 一类 MODULE 推进同一张表,顺序规则不变。编译了 CUDA 却看见所有 MUL_MAT 在 CPU,先查注册表里有没有 CUDA device,再查权重 buffer 是不是 host。
调度器三遍切图,pipeline 四个副本
ggml_backend_sched_split_graph 对已展开的 cgraph 做三遍,而不是按层号硬切:
1 | Pass 1 已有 buffer 的 leaf/node 绑定 Backend |
切完得到 splits[]。compute_splits 对每个片段:先把依赖张量拷到目标设备,再调用该 Backend 的 graph_compute。n_copies 默认为 4(GGML_SCHED_MAX_COPIES),用多份 buffer 轮转,让 H2D 与计算重叠。这就是 async 入口必须在复用 input 之前 synchronize 的原因:四个副本用完会回到第一份,上一轮若未结束就会被下一轮 token 覆盖。
MoE 有额外收紧:MUL_MAT_ID 只拷用到的 expert slice,不把整块专家权重在设备间搬。普通 dense 层没有这条优化,offload 策略更依赖「权重一开始就放对设备」。
和三条执行路径的关系可以收成一句:ggml_graph_compute 不经过这套切分;ggml_backend_graph_compute 只有一个 Backend,不必 split;只有 ggml_backend_sched_graph_compute_async 跑满三遍加 copies。llama.cpp 选第三条,因为用户常常 -ngl 只卸一部分层,CPU 与 GPU 必须共存。
CUDA:mmvq、mmq、cublas 三条矩阵路
CUDA 后端与 HIP / MUSA 共用 ggml-cuda/ 源码树,注册名不同。MUL_MAT 在 ggml-cuda.cu 里按形状分流:
| 条件 | 走哪 |
|---|---|
| 二维且是向量情况,量化 | mmvq(ggml_cuda_mul_mat_vec_q) |
| 二维且是向量情况,浮点 | mmvf |
| 其余量化矩阵 | mmq(ggml_cuda_mul_mat_q) |
| 其余浮点批量 | cublas |
decode 一步、batch=1 时,Q 投影往往是矩阵×向量,mmvq 比 cublas 启动更便宜。prefill 长序列则 mmq / cublas 吃得更饱。GGML_CUDA_FORCE_MMQ 与 GGML_CUDA_FORCE_CUBLAS 只用于对拍,不是默认生产开关。
Flash Attention 由 GGML_CUDA_FA 控制,默认打开;GGML_CUDA_FA_ALL_QUANTS 才会为全部量化类型编 FA kernel,二进制会胀。split buffer 多卡模式只允许 MUL_MAT / MUL_MAT_ID 跨卡切行,其它 op 必须整段待在一张卡上。这是 supports_op 里写死的限制,不是调度器疏忽。
Metal 与 Vulkan:默认路径和生成路径
macOS 上 Metal 默认 ON。Apple Silicon 统一内存让 Shared buffer 成为常见选择;offload_op 仍可能拒绝过小的 batch,避免 launch 比计算还贵。MSL kernel 集中在 ggml-metal.metal,量化 matmul、RoPE、Flash Attention 都在同一套 shader 里,而不是调用 cuBLAS 的等价物。
Vulkan 面向跨平台 GPU。vulkan-shaders-gen 用 glslc 把 .comp 编成 SPIR-V,再按约 25 种量化 / 浮点类型做笛卡尔积,嵌入 ggml-vulkan-shaders.hpp。所以打开 GGML_VULKAN 时,配置阶段会先跑 shader-gen;关掉它或设 GGML_DISABLE_VULKAN=1,注册表里就不会出现 Vulkan device,调度器自然不会把节点切过去。
三家 GPU 对上一章的 Q4×Q8 约定是一致的:CPU 用 SIMD vec_dot,CUDA 用 mmvq/mmq,Metal / Vulkan 用各自的 quant kernel。改变量化类型意味着四套核都要能认 ggml_type,这也是新类型只能追加在 enum 末尾的原因。
构建开关如何变成运行时能力
常用 CMake 不要一次全开,按机器选:
| 选项 | 作用 |
|---|---|
GGML_CUDA |
编译 CUDA 后端(HIP/MUSA 另开,但共用源码) |
GGML_CPU_REPACK |
加载期重排 Q4 等 layout,默认 ON |
GGML_CUDA_FA |
CUDA Flash Attention |
GGML_METAL |
Apple GPU,macOS 默认 |
GGML_VULKAN |
跨平台 GPU + shader-gen |
GGML_SCHED_MAX_COPIES |
pipeline 副本,默认 4 |
GGML_CPU_REPACK 不影响「有没有 GPU」,只影响 CPU 权重量进 buffer 之后的排列。GGML_CUDA 没开时,注册表没有 CUDA,sched 再聪明也只能 CPU。GGML_CUDA_FA 关掉则 Flash Attention 节点更容易 supports_op 失败,被切回 CPU,表现为 prefill 突然变慢而 decode 还过得去。
一次生产调用怎么落到硬件
1 | ggml_backend_reg 选出 CUDA / Metal / CPU |
对照本栈:LLaMA-Factory 与 LlamaIndex 都不出现在这张表里。转换桥只决定 type 是 Q4_K 还是 Q8_0;Backend 决定 Q4_K 的 vec_dot 跑在 mmq 还是 AVX。你在 llama-server 设的 -ngl、设备列表,最终就是这里 backends[] 与权重 buffer_id 的组合。
排错按层缩小。全在 CPU:注册与 CMake。部分层在 CPU:该 op 的 supports_op 或非连续 layout。跨设备 copy 占满时间线:权重没标 WEIGHTS,或 INPUT 每步从 CPU 推过大张量。结果偶发损坏:先 synchronize,再确认 OUTPUT 与 n_copies 轮转。这三章构成 GGML 的最小闭环;下一章开始,轮到 llama.cpp 用这些 API 加载模型并建出整网推理图。
硬件选择怎样映射到本栈
本系列后半会把 llama-server 暴露给 LlamaIndex,但加速决策必须在 GGML 这一层做完。一台只有 NVIDIA 的 Linux 机器:打开 GGML_CUDA,确认注册表第一位是 CUDA,权重量到 GPU 的 WEIGHTS buffer,decode 走 mmvq,prefill 走 mmq。一台 Mac:不要强行上 Vulkan 当默认,Metal 已经默认编译;统一内存上 Shared buffer 减少显式 H2D,但小 batch 仍可能被 offload_op 留在 CPU。只有跨厂商 GPU、又没有 CUDA 工具链时,才把 GGML_VULKAN 和 shader-gen 当作主路径。
CPU 永远在最后当兜底,所以「能跑」不等于「在加速」。真正要检查的是切分后有多少 MUL_MAT 落在 CPU split 里。若几乎全部矩阵乘都在 CPU,再快的 Q4 vec_dot 也只是 SIMD 优化,不是 GPU 吞吐。GGML_CPU_REPACK 在这种退化路径上反而更有价值:它让 CPU 自己的点积更快,但不能替代你把 CUDA 编进二进制。
和量化一章连起来看:硬件不改变 Q4 权重 × Q8 激活这条数学约定,只改变 kernel。和张量一章连起来看:调度器再切,也只能切 build_forward_expand 之后的节点数组;建图阶段选错 op,没有 Backend 能补救。和总览连起来看:LLaMA-Factory 的显卡利用率属于 PyTorch 训练,llama-server 的显卡利用率属于本节的 Backend;两者不是同一块显存池,不要在训练进程还占着 GPU 时,指望推理调度器还能把 7B 的 Q4_K 全部卸上去。
读完 GGML 三章,你应当能在纸上画出:惰性图如何展开,gallocr 如何按 n_children 回收,GGUF 如何 no_alloc 加载,以及 sched 如何三遍把节点送给 CUDA / Metal / CPU。后面的 llama.cpp 章节不再重复这些机制,只会指出它调用了哪一个入口。若你现在就去启动 llama-server,先确认二进制里确实链上了 ggml-cuda 或 ggml-metal,否则注册表只剩最后的 CPU 兜底,后面所有 RAG 延迟都会被误判成 LlamaIndex 检索慢,而真正慢的是矩阵乘从未离开主机内存。
下一章进入 llama.cpp:它如何把 GGUF 变成可执行的推理图。
正在加载留言…