🦙 llama.cpp 图解教程llama.cpp Visual Guide 第六部分 · 底层内核Part 6 · Low-level kernels 33 / 40
第六部分 · 底层内核Part 6 · Low-level kernels

后端调度Backends & dispatch

前两课分别看了 CPU(L31)和 CUDA(L32)各自怎么把算子算出来。可现实里,一台机器上常常同时有 CPU 和一块或多块 GPU——ggml 怎么把它们统一管起来,又怎么决定每个算子该交给谁算?这一课看 ggml-backend 这层后端抽象:它是第六部分的收束,也是把前面所有"怎么算"接回"在哪算"的关键一环。

想想 L09/L10 那张计算图:一串算子,从输入流到输出。现在问题来了——这串算子里,有的最好在 GPU 上算(矩阵乘),有的可能留在 CPU 更省事;张量还可能这会儿在显存、那会儿在内存。谁来决定每个算子去哪、谁来在设备之间搬张量?答案就是 ggml 的后端抽象层和调度器。这一课会看它怎么用一套统一接口屏蔽硬件差异、怎么在运行时动态加载后端、怎么把一张图分派下去执行。举个最贴近的场景:你用一块显存不太够的显卡跑一个大模型,于是把一部分层放 GPU、剩下的留 CPU——这时每跑一遍模型,数据都要在 CPU 和 GPU 之间来回过好几道,靠的全是这一课要讲的后端与调度。理解了它,你才真正明白 -ngl 这个参数背后到底发生了什么。

路线图:先看后端抽象(统一接口),再看注册与动态加载(一份二进制按硬件挑后端),然后是调度器(把图的算子分派到各后端、并按需搬张量),最后扫一眼 ggml 支持的其它后端。

🌍 宏观理解
后端抽象解决的是一个朴素却关键的问题:让上层代码不必关心硬件。计算图(L09)只管"要算什么",至于某个算子最终落在 CPU、CUDA 还是 Metal 上,由各后端的实现去管。这层抽象就像给所有硬件套了一个统一插座:上层对着插座写一次,换什么"电器"(后端)都能插上。把"算什么"和"在哪算"解耦,正是 ggml 能同时支持十来种硬件、还能让它们协同工作的根本。这一课不再钻某一种硬件的内核(那是 L31/L32 的事),而是退到更高一层,看这套"管理 + 调度"的骨架。说得再直白点:前两课像"显微镜",凑近看一种硬件的内核长什么样;这一课像"地图",俯瞰所有硬件是怎么被统一接进来、又怎么协同的。两种视角缺一不可——没有显微镜,你不懂性能到底从哪来;没有地图,你不懂这一堆五花八门的硬件怎么被同一份代码驱动。
🔌 生活类比
把计算图想成一叠工单(每张是一个算子),后端就是各种工种的师傅(CPU 师傅、GPU 师傅……)。ggml_backend_sched 则是调度工头:他拿起每张工单,看看这活儿谁最合适、需要的料(输入张量)现在在谁手里,然后派给对应的师傅;要是料在 CPU 师傅手上、活儿却要 GPU 师傅做,工头就先把料搬过去。上层只管把一叠工单交给工头,至于具体派给谁、料怎么搬,全由这套抽象 + 调度兜住——这也是为什么你用 llama.cpp 时,只要用 -ngl 指定把几层放上 GPU,剩下的派活、搬料就全由这位"工头"在后台替你打理好了——你根本不用操心哪个算子具体跑在哪、张量又在什么时候被搬到哪里。

后端抽象:给所有硬件一个统一接口

一个 ggml_backend 代表"一个能跑算子的设备",它打包了三样东西:一个 device(设备句柄)、一套 buffer(在该设备上分配/读写张量的内存)、以及一组算子实现(这块硬件怎么算 matmul、softmax……)。每种后端(CPU、CUDA、Metal……)都去实现同一套接口(ggml-backend-impl.h 里的 ggml_backend_i),于是上层的计算图只需对着这个抽象写,完全不用管底下到底是哪种硬件。这里的 buffer 是个容易被忽略的关键:一个张量的数据到底躺在 CPU 内存还是 GPU 显存里,就由它决定;同一个张量在 CPU buffer 和 GPU buffer 里是两份不同的内存,后面调度器跨设备搬的,正是这些 buffer 里的数据。设备还带一个类型,方便上层"按需要挑":

// 设备类型 (ggml-backend.h)
enum ggml_backend_dev_type {
    GGML_BACKEND_DEVICE_TYPE_CPU,    // CPU
    GGML_BACKEND_DEVICE_TYPE_GPU,    // 独立 GPU
    GGML_BACKEND_DEVICE_TYPE_ACCEL,  // 加速器 (配合 CPU 用, 如 BLAS/AMX)
    // ... 实际还有 IGPU(集成显卡) / META 等
};
// 挑一个 GPU 设备; 没有就回退到 CPU (用法见 ggml-backend-reg.cpp)
ggml_backend_dev_t dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_GPU);
if (!dev) dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_CPU);

这套设计的好处,在 L31/L32 已经体现过一半:上层调 matmul 时根本不写"用 AVX2 还是 CUDA",那是各后端实现里的事。后端抽象把这件事正式化——计算图(L09/L10)描述"做什么",后端负责"怎么做、在哪做"。图和硬件之间彻底解耦:

上层计算图 / 模型
只描述算子和依赖 (L09/L10), 不关心硬件
中间ggml-backend 抽象
统一接口: device + buffer + 一组算子实现 (impl.h 里的几套接口)
底层各后端
CPU / CUDA / Metal / Vulkan ... 各自实现同一套接口

注册与动态加载:一份二进制,按硬件挑后端

抽象有了,可程序怎么知道这台机器上有哪些后端?答案是运行时动态加载。每个后端编译成一个动态库(.so/.dll),程序启动时调 ggml_backend_load_all() 去扫描、把能用的后端库一个个加载进来、登记进一张注册表。底层真正干活的是 dl_load_library

// 运行时加载所有可用后端 (ggml-backend-reg.cpp)
void ggml_backend_load_all();   // 扫描并加载 cuda/metal/vulkan... 动态库

// 底层加载单个库 (ggml-backend-dl.cpp), 跨平台两套
// POSIX:
handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
// Windows:
handle = LoadLibraryW(path);

加载完,程序就能问注册表"现在有几个后端、几个设备":ggml_backend_reg_count()ggml_backend_dev_count(),再用上一节的 ggml_backend_dev_by_type 挑设备。这套动态加载的意义很实在:一份主程序二进制,按机器实际硬件在运行时加载对应后端——有 CUDA 卡就加载 CUDA 后端,没有就不加载,绝不会因为缺少某个库而启动失败。你可以把注册表想成一本"电话簿":每加载进一个后端,它就在簿子上登记一条"我是谁、我有哪些设备、怎么找到我"。上层要用 GPU 时,不是直接去认 CUDA,而是翻这本簿子按类型查——正是这层间接,让"上层不认识具体硬件"能真正成立。

🔬 为什么用动态加载
换个角度想:如果把所有后端都静态编进一个二进制,那它就得同时链接 CUDA、Vulkan、Metal、SYCL…… 一堆庞大又互相冲突的依赖,而且换台没有这些库的机器就跑不起来。动态加载把这件事推迟到运行时:发布一份精简的主程序,到了用户机器上,有什么硬件就加载什么后端。这也是为什么 llama.cpp 的预编译包能做到"一个包、到处能跑"——CUDA 后端在没有 N 卡的机器上只是没被加载,而不是让整个程序崩掉。

调度:把一张图的算子分派到各后端

有了多个后端,最后一个问题是:一张计算图(L09/L10)里的算子,谁来决定每个去哪个后端算、又在后端之间搬张量?这就是 ggml_backend_sched(调度器)的活儿。它的两个主要接口是:

// 建调度器, 交给它一组后端 (ggml-backend.h)
sched = ggml_backend_sched_new({backend_gpu, backend_cpu}, ...);
// 把整张计算图分派到各后端执行
ggml_backend_sched_graph_compute(sched, graph);

调度器拿到图后,逐个看每个算子:它的输入张量现在在哪个后端的 buffer 里?这个算子在哪个后端上算最合适(比如矩阵乘优先 GPU)?据此把算子指派给一个后端;如果某个输入还在别的设备上(算子要在 GPU 上跑、输入却还在 CPU 内存里),调度器就先插一次跨设备拷贝,把输入搬过去,再执行。整张图跑完,结果就落在该在的地方了。值得一提的是,调度器并不是把每个算子孤立地分派——跨设备拷贝很贵(又是访存,呼应 L30/L32),所以它会尽量把连续的、能在同一设备上算的算子成段地交给同一个后端,减少来回搬运。换句话说,它不只看"这个算子在哪算最快",还看"怎么切这张图,整体的跨设备搬运最少"——所以它倾向于把连续的、能在同一设备上算的活儿成段地交给同一个后端,而不是东一层西一层地乱切。把"分派一个算子"这件事定格成一条流水看最清楚:

追踪一次算子分派:调度器看一个算子的输入在哪、挑后端、必要时跨设备拷贝、再执行写回(示意)。
① 取算子
graph 里一个 op
如一次 matmul
看输入
在哪
② 查位置
输入在哪个 buffer
CPU 内存? 显存?

后端
③ 选后端
输入在 GPU -> 派 GPU
就近、最合适
必要时
拷贝
④ 搬张量
跨设备拷贝输入
若输入在别处
执行
写回
⑤ 算并写回
该后端执行 -> 输出
结果留在该设备

这正好把 L09/L10 那张"静态的图"接到了"动态的执行"上:图描述依赖,调度器按依赖顺序、结合每个张量的实际位置,把算子一个个落到具体硬件上跑。你平时用 -ngl N 把前 N 层放上 GPU、其余留 CPU,背后正是这个调度器在按层分派、并在 GPU 与 CPU 之间搬运边界处的张量。而当 GPU 显存实在放不下整个模型时,这种 CPU+GPU 混合执行往往是唯一能跑起来的办法——代价是边界处那几次跨设备拷贝,但总比完全跑不动强。这也解释了为什么 -ngl 调大调小,速度和显存占用会此消彼长:放上 GPU 的层越多,算得越快,但占的显存也越多、CPU-GPU 之间的搬运点也跟着变。

用一张"整张图怎么被切"的图,把 -ngl 的效果看得更具体——以一个 32 层的模型、-ngl 20 为例:

第 0..19 层 -> GPU

矩阵乘等重活在显存里算;这些层的张量都待在 GPU buffer,层与层之间无需跨设备。

第 19/20 层之间 -> 跨设备拷贝

整张图只在这一个边界上,把激活从 GPU 显存拷回 CPU 内存(调度器自动插入)。

第 20..31 层 -> CPU

显存放不下的剩余层留在 CPU 上算,用 L31 的 SIMD + 多线程。

这张图也解释了为什么"分段放"比"乱放"快:把上 GPU 的层连成一段,整张图就只有一处跨设备边界、只拷一次;要是把 GPU 层和 CPU 层交替着排,每换一次设备就得拷一次,那点可怜的 PCIe 带宽很快就被拷贝吃光。调度器之所以默认"前 N 层整段上 GPU",正是为了把这种边界压到最少。

其它后端一览

除了已经细看的 CPU(L31)和 CUDA(L32),ggml 还实现了一大批后端,覆盖各家硬件。它们都遵循同一套 ggml_backend_i 接口,所以上层代码几乎不用改,换硬件只是换一个加载进来的后端:

后端面向的硬件 / 平台典型场景
MetalApple GPU(macOS / iOS)苹果设备上的首选 GPU 后端
Vulkan跨平台 GPU不限厂商的通用 GPU 加速
SYCLIntel GPUIntel 独显 / 集显
HIPAMD GPUA 卡(对标 CUDA)
CANN华为昇腾 NPU昇腾加速卡
OpenCL跨平台(含移动 GPU)移动 / 嵌入式(如高通 Adreno)
BLASCPU(借现成数学库)用 BLAS 库加速 CPU 矩阵乘
RPC远程机器 / 进程把算子发到另一台机器上跑

这张表最能说明后端抽象的价值:从苹果的 Metal 到华为的 CANN、从本机 GPU 到远程的 RPC,硬件天差地别,但对上层而言都只是"一个实现了 ggml_backend_i 的设备"。想支持一种新硬件,本质上就是再写一份后端实现、注册进来——上层的模型代码一行都不用动。事实上,这些后端里有不少是社区或硬件厂商贡献的:正因为接口是统一的,华为能自己来写 CANN、Intel 能来写 SYCL,而不必去动 ggml 的核心。一个好的抽象接口,等于给整个生态开了一扇"你来适配硬件、我保证上层不变"的门——这也是开源项目能在短时间里支持这么多硬件的组织学原因。

深入:特殊后端与如何加一个后端

最后两个折叠:看两个"不太像 GPU"的特殊后端,以及加一个新后端大致要实现什么。

1 BLAS 和 RPC 这种"特殊后端"是什么? 点击展开

大多数后端对应一种"算力硬件",但有两个例外很有意思。BLAS 后端并不直接写 kernel,而是把矩阵乘转交给系统里现成的高性能 BLAS 数学库(如 OpenBLAS、MKL)去算——相当于"借别人造好的轮子",在某些 CPU 上比 ggml 自带的实现还快。RPC 后端更特别:它根本不在本地算,而是把算子通过网络发到另一台机器(或进程)上跑,再把结果取回来。有了它,你可以把一个单机装不下的大模型,拆到好几台机器的显存里分布式地跑。这两个后端都套着同一层 ggml_backend_i 接口,所以对上层来说,"借数学库"和"发去远程"与"在本地 GPU 上算"没有任何区别——这正是抽象的威力。顺便说,正因为有 RPC 这种后端,"在一台机器上调度、把重活发到另一台或多台去跑"这类玩法才成为可能;而 BLAS 后端则提醒我们:所谓"后端"未必对应一种新硬件,也可以只是"换一套更快的算法实现"。抽象层只关心"你能不能实现这套接口",至于你背后到底是一块芯片、一个数学库、还是一根网线,它一概不问。

2 加一个新后端大致要实现什么? 点击展开

想给 ggml 加一种新硬件,核心就是去实现 ggml-backend-impl.h 里定义的那几套接口(设备接口 + 后端接口),主要包括几类:buffer 管理(怎么在这块硬件上分配内存、把张量数据拷进拷出)、算子支持查询supports_op:这个后端能不能算某个算子——不能的就让调度器回退给别的后端)、以及跑计算图graph_compute:把分给我的这串算子真正算出来)。实现完、再注册进注册表,上层的模型和调度器就能立刻用上它,完全不用改。这套"定义好接口、谁都能往里插"的设计,正是 ggml 能在这么短时间里长出十几种后端的原因——也是软件工程里"面向接口编程"最实在的一个例子。

第六部分到这里就收束了。从 L31 的 CPU 指令(标量、SIMD、多线程)、L32 的 CUDA 线程(grid/block、分块、显存层级),到这一课的后端抽象与调度,"一个算子最终怎么落到硬件上算"这条线,算是从头讲透了。再往上回看:模型(第四部分)描述要算什么,计算图(第三部分)把它组织成依赖,后端(这一部分)把图落到具体硬件——三层一接,llama.cpp 跑模型的全貌就清楚了。下一站第七部分,我们去看一些进阶专题。学到这里,你已经把 llama.cpp 从"一个能跑模型的黑盒",拆成了"一摞看得懂的层"——这本身就是啃源码最大的收获:再复杂的系统也不是一团乱麻,而是一层层抽象垒起来的,每一层只解决一个问题、只对上一层暴露一个干净的接口。把这套眼光带走,你以后去读任何一个大项目,都会比从前从容得多。

✅ 关键要点
  • 后端抽象 ggml_backend = 一个能跑算子的设备(device + buffer + 一组算子实现),各后端实现同一套 ggml_backend_i,上层计算图不关心硬件。
  • 注册与动态加载:ggml_backend_load_all 运行时用 dlopen / LoadLibraryW 加载各后端动态库——一份二进制按实际硬件挑后端。
  • 调度 ggml_backend_sched:把一张计算图(L09/L10)的算子分派到各后端,并在设备之间按需拷贝张量。
  • 其它后端:Metal / Vulkan / SYCL / HIP / CANN / OpenCL / BLAS / RPC——同一接口,覆盖从苹果到华为、从本机到远程。
  • 加新后端 = 实现 ggml-backend-impl.h 的接口(buffer、supports_opgraph_compute)并注册。
💡 设计洞察
这一课其实在讲软件工程里一个最经典的招式:用一层抽象,把"变化的部分"和"不变的部分"隔开。硬件年年在变(新 GPU、新 NPU、新指令集),但"计算图描述要算什么"这件事是稳定的。ggml 在这两者之间插了 ggml_backend 这层接口:上面的模型代码十年不用动,下面的硬件想加就加。L31/L32 让你看到了"内核怎么把硬件用满",这一课让你看到"框架怎么把一堆不同的硬件统一管起来"——前者是深度,后者是广度,合起来才是 llama.cpp 能既快又到处能跑的全部秘密。带着这套"分层 + 抽象"的眼光回看整个教程,你会发现它处处都是:tokenizer 之于文本、GGUF 之于权重、计算图之于算子、后端之于硬件——每一层都在把复杂藏进一个干净的接口后面。

🧪 自测 · 想一想为什么这么设计

1. ggml-backend 这层后端抽象主要解决什么问题?
  1. 把模型权重压得更小
  2. 替代 GGUF 文件格式
  3. 给所有硬件一套统一接口,让上层计算图不必关心具体是 CPU、CUDA 还是别的
  4. 让矩阵乘算得更快
看答案与解析 点击展开
答案:C。后端抽象的核心价值是“解耦”:计算图(L09)只描述“要算什么”,每种后端(CPU/CUDA/Metal……)去实现同一套 ggml_backend_i 接口,负责“怎么算、在哪算”。于是上层代码不必为每种硬件改一遍——这是软件工程里“面向接口编程”的经典用法。它不负责把单个算子算得更快(那是 L31/L32 的内核的事),也和量化、文件格式无关。
2. ggml_backend_load_all 在运行时用 dlopen / LoadLibraryW 动态加载各后端,好处是什么?
  1. 让程序启动得更快
  2. 一份主程序二进制,按机器实际硬件加载对应后端,缺某个库也不会启动失败
  3. 自动把模型下载到本地
  4. 把所有后端静态编进一个巨大的二进制
看答案与解析 点击展开
答案:B。动态加载把“这台机器有哪些后端”推迟到运行时决定:每个后端是一个动态库,ggml_backend_load_all 扫描并加载能用的、登记进注册表。意义是“一个包、到处能跑”——有 CUDA 卡就加载 CUDA 后端,没有就跳过,绝不会因为缺某个库而崩。这恰恰与“静态全编进去”相反(那样会拖入一堆冲突依赖、还挑机器)。
3. ggml_backend_sched(调度器)做的是什么?
  1. 把多个 GPU 合并成一块更大的虚拟 GPU
  2. 负责把 token 采样出来
  3. 决定模型用哪种量化格式
  4. 把一张计算图的算子分派到各后端执行,并在设备之间按需拷贝张量
看答案与解析 点击展开
答案:D。调度器把 L09/L10 的“静态图”接到“动态执行”:它逐个看算子——输入张量在哪个设备的 buffer 里、这个算子在哪个后端算最合适——据此分派,并在输入还在别的设备上时先插一次跨设备拷贝。为减少昂贵的拷贝,它倾向于把连续、同设备的算子成段分给同一后端。你用 -ngl 把前若干层放 GPU,背后就是它在按层分派、搬运边界张量。
💭 发散思考(没有标准答案,动手或动脑想想)
  • 这一课说,加一个新后端 = 实现 ggml-backend-impl.h 里的接口(buffer 管理、supports_op、graph_compute)再注册进来,上层代码一行都不用改。请用这一课的“分层 + 抽象”视角,说说为什么 BLAS(借数学库)和 RPC(发去远程机器)也能套进同一套后端接口;再联系整个教程,举一两个别的“用一层抽象把变化挡在下面”的例子(比如 GGUF 之于权重、计算图之于算子)。

The last two lessons watched CPU (L31) and CUDA (L32) each compute ops on their own. But in reality a machine often has a CPU and one or more GPUs at once - how does ggml manage them uniformly, and how does it decide which op goes to whom? This lesson looks at the ggml-backend abstraction: the close of Part 6, and the key link tying all the earlier "how to compute" back to "where to compute".

Recall the compute graph from L09/L10: a chain of ops flowing from input to output. Now the question: within that chain, some ops are best on the GPU (matmul), some are simpler left on the CPU; and a tensor may be in VRAM now, in RAM later. Who decides where each op goes, and who moves tensors between devices? The answer is ggml's backend abstraction and scheduler. This lesson sees how it hides hardware differences behind one uniform interface, how it loads backends dynamically at runtime, and how it dispatches a whole graph for execution. A most relatable scenario: you run a big model on a card whose VRAM is a bit short, so you put some layers on the GPU and leave the rest on the CPU - now every pass of the model shuttles data back and forth between CPU and GPU several times, all riding on the backend and scheduler this lesson covers. Understand it and you truly see what happens behind that -ngl flag.

Roadmap: first the backend abstraction (one uniform interface), then registry and dynamic loading (one binary picks backends by hardware), then the scheduler (dispatch a graph's ops to backends and move tensors as needed), and finally a quick tour of the other backends ggml supports.

🌍 Big picture
The backend abstraction solves a plain but crucial problem: letting upper-layer code not care about hardware. The compute graph (L09) only says "what to compute"; whether a given op ends up on CPU, CUDA, or Metal is each backend's implementation's business. This abstraction is like fitting all hardware with one universal socket: the upper layer writes to the socket once, and any "appliance" (backend) plugs in. Decoupling "what to compute" from "where to compute" is exactly what lets ggml support a dozen kinds of hardware at once and have them work together. This lesson no longer digs into one hardware's kernels (that was L31/L32) but steps up a level to the "manage + schedule" skeleton. Put more plainly: the last two lessons were a "microscope", peering up close at what one hardware's kernel looks like; this lesson is a "map", looking down on how all the hardware is unified and made to cooperate. Both views are indispensable - without the microscope you do not see where performance comes from; without the map you do not see how this motley pile of hardware is driven by one codebase.
🔌 Analogy
Picture the compute graph as a stack of work orders (each one an op), and backends as tradespeople of various crafts (a CPU hand, a GPU hand, ...). ggml_backend_sched is the dispatching foreman: he picks up each order, sees who suits the job and where the materials (input tensors) currently are, then assigns it to the right hand; if the materials are with the CPU hand but the job needs the GPU hand, he moves them over first. The upper layer just hands the foreman a stack of orders; who gets each one and how materials move is all absorbed by this abstraction + scheduler - which is why, using llama.cpp, you only set -ngl for how many layers go on the GPU, and the rest - assigning the jobs, moving the materials - is quietly handled for you by this "foreman" in the background; you never have to worry which op runs where, or when a tensor gets moved.

The backend abstraction: one uniform interface for all hardware

A ggml_backend represents "a device that can run ops", bundling three things: a device handle, a set of buffers (memory to allocate/read/write tensors on that device), and a set of op implementations (how this hardware computes matmul, softmax, ...). Every backend (CPU, CUDA, Metal, ...) implements the same interface (ggml_backend_i in ggml-backend-impl.h), so the upper compute graph only writes to this abstraction and never minds which hardware is underneath. The buffer here is an easily-overlooked key: it decides whether a tensor's data sits in CPU RAM or GPU VRAM; the same tensor is two different chunks of memory in a CPU buffer versus a GPU buffer, and what the scheduler later moves across devices is exactly the data in these buffers. Devices also carry a type, so the upper layer can "pick by need":

// device types (ggml-backend.h)
enum ggml_backend_dev_type {
    GGML_BACKEND_DEVICE_TYPE_CPU,    // CPU
    GGML_BACKEND_DEVICE_TYPE_GPU,    // discrete GPU
    GGML_BACKEND_DEVICE_TYPE_ACCEL,  // accelerator (used with the CPU, e.g. BLAS/AMX)
    // ... also IGPU (integrated GPU) / META, etc.
};
// pick a GPU device; fall back to CPU if none (usage in ggml-backend-reg.cpp)
ggml_backend_dev_t dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_GPU);
if (!dev) dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_CPU);

Half the payoff of this design already showed up in L31/L32: when the upper layer calls matmul it never writes "AVX2 or CUDA" - that lives inside each backend's implementation. The backend abstraction formalizes this: the compute graph (L09/L10) describes "what to do", the backend handles "how and where". Graph and hardware are fully decoupled:

topcompute graph / model
describes ops and dependencies only (L09/L10), hardware-agnostic
middleggml-backend abstraction
uniform interface: device + buffer + a set of op impls (the interfaces in impl.h)
bottombackends
CPU / CUDA / Metal / Vulkan ... each implements the same interface

Registry and dynamic loading: one binary, backends by hardware

We have the abstraction, but how does the program know which backends this machine has? The answer is runtime dynamic loading. Each backend compiles to a shared library (.so/.dll); at startup the program calls ggml_backend_load_all() to scan and load the usable backend libraries one by one and register them in a registry. The low-level worker is dl_load_library:

// load all available backends at runtime (ggml-backend-reg.cpp)
void ggml_backend_load_all();   // scan and load cuda/metal/vulkan... shared libs

// low-level single-library load (ggml-backend-dl.cpp), two platform paths
// POSIX:
handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
// Windows:
handle = LoadLibraryW(path);

Once loaded, the program can ask the registry "how many backends, how many devices now": ggml_backend_reg_count(), ggml_backend_dev_count(), then pick a device with last section's ggml_backend_dev_by_type. The point of this dynamic loading is concrete: one main binary loads the matching backend at runtime per the machine's actual hardware - load the CUDA backend if there is a CUDA card, skip it otherwise, never failing to start over a missing library. Think of the registry as a "phone book": each loaded backend writes one entry - "who I am, what devices I have, how to reach me". When the upper layer wants a GPU, it does not go straight to CUDA but looks it up in the book by type - it is exactly this indirection that lets "the upper layer not know the concrete hardware" actually hold.

🔬 Why dynamic loading
Flip it around: if all backends were compiled statically into one binary, that binary would have to link CUDA, Vulkan, Metal, SYCL... a pile of huge, mutually conflicting dependencies, and would not run on a machine lacking those libraries. Dynamic loading defers this to runtime: ship one slim main program, and on the user's machine load whatever backend matches whatever hardware is present. This is why llama.cpp's prebuilt packages can be "one package, runs everywhere" - on a machine with no NVIDIA card the CUDA backend is simply not loaded, rather than crashing the whole program.

Scheduling: dispatching a graph's ops across backends

With several backends, the last question is: in one compute graph (L09/L10), who decides which backend each op runs on, and who moves tensors between backends? That is ggml_backend_sched's (the scheduler's) job. Its two main interfaces:

// build a scheduler, hand it a set of backends (ggml-backend.h)
sched = ggml_backend_sched_new({backend_gpu, backend_cpu}, ...);
// dispatch the whole compute graph across backends
ggml_backend_sched_graph_compute(sched, graph);

Given the graph, the scheduler walks each op: which backend's buffer are its input tensors in now? Which backend best suits this op (matmul prefers GPU, say)? It assigns the op to a backend accordingly; if some input is still on another device (the op runs on GPU but the input is still in CPU memory), the scheduler first inserts a cross-device copy to move the input over, then executes. When the whole graph is done, results land where they should be. Worth noting: the scheduler does not dispatch each op in isolation - cross-device copies are expensive (memory traffic again, echoing L30/L32), so it tries to give consecutive ops that can run on the same device to one backend in segments, cutting the back-and-forth. In other words, it weighs not just "where does this op run fastest" but "how to cut this graph so total cross-device movement is least" - so it leans toward giving consecutive work that can run on the same device to one backend in segments, rather than cutting it up layer by layer. Freezing "dispatching one op" into a flow shows it clearest:

Trace one op dispatch: the scheduler checks where an op's inputs are, picks a backend, copies across devices if needed, then executes and writes back (illustrative).
1 take op
an op in the graph
e.g. a matmul
where are
inputs
2 locate
which buffer
CPU RAM? VRAM?
pick
backend
3 select
inputs on GPU -> GPU
nearest, fittest
copy if
needed
4 move
cross-device copy
if input elsewhere
run +
write
5 compute
backend runs -> output
result stays there

This is exactly where L09/L10's "static graph" connects to "dynamic execution": the graph describes dependencies, and the scheduler, following them and each tensor's actual location, drops the ops one by one onto concrete hardware. When you use -ngl N to put the first N layers on the GPU and leave the rest on CPU, this scheduler is what dispatches by layer and shuttles boundary tensors between GPU and CPU. And when the GPU's VRAM truly cannot hold the whole model, this CPU+GPU hybrid execution is often the only way to run at all - at the cost of a few cross-device copies at the boundary, but far better than not running. It also explains why turning -ngl up or down trades speed against VRAM use: the more layers on the GPU, the faster it computes, but the more VRAM it takes, and the CPU-GPU handoff points shift too.

One "how the whole graph is cut" picture makes -ngl's effect concrete - take a 32-layer model with -ngl 20:

layers 0..19 -> GPU

heavy work like matmul runs in VRAM; these layers' tensors stay in GPU buffers, with no cross-device hop between layers.

between layer 19/20 -> cross-device copy

the whole graph has just this one boundary, copying activations from GPU VRAM back to CPU RAM (the scheduler inserts it automatically).

layers 20..31 -> CPU

the leftover layers that VRAM cannot hold run on the CPU, using L31's SIMD + multithreading.

This picture also explains why "place in segments" beats "scatter": chaining the GPU layers into one run leaves the whole graph with a single cross-device boundary and one copy; interleave GPU and CPU layers and every switch costs a copy, and that meager PCIe bandwidth is soon eaten by copying. The scheduler defaults to "the first N layers as one GPU run" precisely to keep such boundaries minimal.

A tour of the other backends

Beyond the CPU (L31) and CUDA (L32) we examined closely, ggml implements a whole crowd of backends covering everyone's hardware. They all follow the same ggml_backend_i interface, so upper-layer code barely changes - switching hardware just means loading a different backend:

BackendTarget hardware / platformTypical use
MetalApple GPU (macOS / iOS)the go-to GPU backend on Apple devices
Vulkancross-platform GPUvendor-agnostic general GPU acceleration
SYCLIntel GPUIntel discrete / integrated graphics
HIPAMD GPUAMD cards (CUDA's counterpart)
CANNHuawei Ascend NPUAscend accelerator cards
OpenCLcross-platform (incl. mobile GPU)mobile / embedded (e.g. Qualcomm Adreno)
BLASCPU (borrowing a math library)accelerate CPU matmul via a BLAS library
RPCremote machine / processsend ops to another machine to run

This table best shows the value of the backend abstraction: from Apple's Metal to Huawei's CANN, from a local GPU to remote RPC, the hardware is worlds apart, yet to the upper layer each is just "a device implementing ggml_backend_i". Supporting a new piece of hardware is essentially writing one more backend implementation and registering it - not a single line of the upper model code needs to change. In fact, many of these backends were contributed by the community or hardware vendors: precisely because the interface is uniform, Huawei can write CANN themselves and Intel can write SYCL, without touching ggml's core. A good abstract interface opens a door for the whole ecosystem - "you adapt the hardware, I guarantee the upper layer stays put" - and that is the organizational reason an open-source project can support so much hardware so fast.

Deeper: special backends and how to add one

Two last folds: two "not very GPU-like" special backends, and roughly what adding a new backend takes.

1 What are "special backends" like BLAS and RPC? click to expand

Most backends correspond to a piece of "compute hardware", but two exceptions are interesting. The BLAS backend does not write kernels itself; it hands matmul off to an existing high-performance BLAS math library on the system (OpenBLAS, MKL) - "borrowing someone else's ready-made wheel", and on some CPUs faster than ggml's own implementation. The RPC backend is more unusual: it does not compute locally at all but sends ops over the network to another machine (or process) to run, then fetches the results back. With it you can split a model too big for one machine across several machines' VRAM and run it distributed. Both wear the same ggml_backend_i interface, so to the upper layer "borrow a math library" and "send it remote" are no different from "compute on a local GPU" - exactly the power of abstraction. By the way, it is precisely because of a backend like RPC that tricks like "schedule on one machine and send the heavy work to another, or several others" become possible; and the BLAS backend reminds us that a "backend" need not correspond to new hardware at all - it can just be "a faster algorithm implementation". The abstraction only cares "can you implement this interface"; whether behind you is a chip, a math library, or a network cable, it never asks.

2 What does adding a new backend take? click to expand

To add a new piece of hardware to ggml, the core is implementing the interfaces defined in ggml-backend-impl.h (a device interface + a backend interface), mainly a few categories: buffer management (how to allocate memory on this hardware and copy tensor data in and out), op-support query (supports_op: can this backend compute a given op - if not, the scheduler falls back to another backend), and running the graph (graph_compute: actually compute the chain of ops assigned to me). Implement these, register it, and the upper model and scheduler can use it immediately with no changes. This "define the interface, anyone can plug in" design is why ggml grew a dozen backends in so short a time - and one of the most concrete examples of "program to an interface" in software engineering.

Part 6 closes here. From L31's CPU instructions (scalar, SIMD, multithreading), through L32's CUDA threads (grid/block, tiling, the memory hierarchy), to this lesson's backend abstraction and scheduling, the thread of "how one op finally lands on hardware to compute" has been told end to end. Zooming back out: the model (Part 4) describes what to compute, the compute graph (Part 3) organizes it into dependencies, and the backend (this part) lands the graph on concrete hardware - join the three layers and the whole picture of how llama.cpp runs a model is clear. Next stop, Part 7, where we look at some advanced topics. By here, you have taken llama.cpp from "a black box that runs models" to "a stack of legible layers" - and that is the biggest reward of reading source: however complex, a system is never a tangle but a stack of abstractions, each solving one problem and exposing one clean interface to the layer above. Carry this lens away and you will read any large project far more calmly than before.

✅ Key points
  • The backend abstraction ggml_backend = a device that can run ops (device + buffer + a set of op impls); every backend implements the same ggml_backend_i, and the upper compute graph ignores hardware.
  • Registry and dynamic loading: ggml_backend_load_all uses dlopen / LoadLibraryW at runtime to load backend shared libs - one binary picks backends by actual hardware.
  • The scheduler ggml_backend_sched: dispatches a compute graph's (L09/L10) ops across backends and copies tensors between devices as needed.
  • Other backends: Metal / Vulkan / SYCL / HIP / CANN / OpenCL / BLAS / RPC - one interface, covering Apple to Huawei, local to remote.
  • Adding a backend = implement the ggml-backend-impl.h interfaces (buffer, supports_op, graph_compute) and register it.
💡 Design insight
This lesson is really about one of software engineering's most classic moves: use a layer of abstraction to separate "what changes" from "what stays". Hardware changes every year (new GPUs, new NPUs, new instruction sets), but "the compute graph describes what to compute" is stable. ggml inserts the ggml_backend interface between the two: the model code above need not change for a decade, and hardware below can be added at will. L31/L32 showed you "how a kernel fills the hardware"; this lesson showed you "how the framework unifies a crowd of different hardware" - the former is depth, the latter breadth, and together they are the whole secret of llama.cpp being both fast and runnable everywhere. Carry this "layering + abstraction" lens back over the whole guide and you will see it everywhere: the tokenizer for text, GGUF for weights, the compute graph for ops, the backend for hardware - each layer hiding complexity behind a clean interface.

🧪 Self-test - think about the design

1. What problem does the ggml-backend abstraction mainly solve?
  1. compressing model weights smaller
  2. replacing the GGUF file format
  3. one uniform interface for all hardware, so the upper compute graph need not care whether it is CPU, CUDA, or something else
  4. making matmul compute faster
Show answer & explanation click to expand
Answer: C. The core value of the backend abstraction is decoupling: the compute graph (L09) only describes 'what to compute', and each backend (CPU/CUDA/Metal...) implements the same ggml_backend_i interface, handling 'how and where'. So upper-layer code is not rewritten per hardware - the classic 'program to an interface' move. It does not make a single op faster (that is L31/L32's kernels), and is unrelated to quantization or file format.
2. ggml_backend_load_all dynamically loads backends at runtime via dlopen / LoadLibraryW - what is the benefit?
  1. making the program start up faster
  2. one main binary loads the matching backend per the machine's actual hardware, and a missing library does not prevent startup
  3. automatically downloading the model locally
  4. statically compiling all backends into one huge binary
Show answer & explanation click to expand
Answer: B. Dynamic loading defers 'which backends this machine has' to runtime: each backend is a shared library, and ggml_backend_load_all scans, loads the usable ones, and registers them. The point is 'one package runs everywhere' - load the CUDA backend if there is a CUDA card, skip it otherwise, never crashing over a missing library. This is the opposite of 'static-link everything' (which drags in conflicting deps and is machine-picky).
3. What does ggml_backend_sched (the scheduler) do?
  1. merge multiple GPUs into one larger virtual GPU
  2. handle sampling tokens
  3. decide which quantization format the model uses
  4. dispatch a compute graph's ops across backends and copy tensors between devices as needed
Show answer & explanation click to expand
Answer: D. The scheduler connects L09/L10's 'static graph' to 'dynamic execution': it walks each op - which device's buffer the inputs are in, which backend best suits the op - dispatches accordingly, and inserts a cross-device copy first when an input is still elsewhere. To cut expensive copies it tends to give consecutive same-device ops to one backend in segments. When you put the first N layers on the GPU with -ngl, this is what dispatches by layer and moves boundary tensors.
💭 Open questions (no single right answer - just think or try)
  • This lesson says adding a new backend = implementing the interfaces in ggml-backend-impl.h (buffer management, supports_op, graph_compute) and registering it, with not one line of upper code changed. Using this lesson's 'layering + abstraction' lens, explain why BLAS (borrowing a math library) and RPC (sending to a remote machine) also fit the same backend interface; then, across the whole guide, give one or two other examples of 'using a layer of abstraction to hold change underneath' (e.g. GGUF for weights, the compute graph for ops).