GGML:Backend 抽象与硬件加速

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_computesynchronize、异步 tensor / event
ggml_backend_device_i supports_opsupports_buftoffload_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
2
3
4
5
6
7
8
Pass 1  已有 buffer 的 leaf/node 绑定 Backend
权重在 GPU → GPU;INPUT 通常绑最后一个 Backend(CPU)

Pass 2 从已绑定节点向邻居扩张到更高优先级 Backend
除非权重就在 CPU,否则尽量离开 CPU

Pass 3 剩余节点看 supports_op 与 supports_buft
不兼容就切开 split,并插入 copy 节点

切完得到 splits[]compute_splits 对每个片段:先把依赖张量拷到目标设备,再调用该 Backend 的 graph_computen_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_MATggml-cuda.cu 里按形状分流:

条件 走哪
二维且是向量情况,量化 mmvqggml_cuda_mul_mat_vec_q
二维且是向量情况,浮点 mmvf
其余量化矩阵 mmqggml_cuda_mul_mat_q
其余浮点批量 cublas

decode 一步、batch=1 时,Q 投影往往是矩阵×向量,mmvq 比 cublas 启动更便宜。prefill 长序列则 mmq / cublas 吃得更饱。GGML_CUDA_FORCE_MMQGGML_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-genglslc.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
2
3
4
5
6
7
8
9
10
11
12
ggml_backend_reg 选出 CUDA / Metal / CPU
ggml_backend_sched_new(backends…) # 下标 0 优先级最高
权重量到带 WEIGHTS 的 GPU buffer
每步 decode:
sched_reset
建图 + ggml_build_forward_expand
sched_alloc_graph → gallocr
sched_graph_compute_async
split_graph 三遍
compute_splits(copy + graph_compute)
sched_synchronize
读 OUTPUT

对照本栈: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-cudaggml-metal,否则注册表只剩最后的 CPU 兜底,后面所有 RAG 延迟都会被误判成 LlamaIndex 检索慢,而真正慢的是矩阵乘从未离开主机内存。

下一章进入 llama.cpp:它如何把 GGUF 变成可执行的推理图。

文章互动

阅读 --

留言

0 条留言

正在加载留言…