前两课分别看了 CPU(L31)和 CUDA(L32)各自怎么把算子算出来。可现实里,一台机器上常常同时有 CPU 和一块或多块 GPU——ggml 怎么把它们统一管起来,又怎么决定每个算子该交给谁算?这一课看 ggml-backend 这层后端抽象:它是第六部分的收束,也是把前面所有"怎么算"接回"在哪算"的关键一环。
想想 L09/L10 那张计算图:一串算子,从输入流到输出。现在问题来了——这串算子里,有的最好在 GPU 上算(矩阵乘),有的可能留在 CPU 更省事;张量还可能这会儿在显存、那会儿在内存。谁来决定每个算子去哪、谁来在设备之间搬张量?答案就是 ggml 的后端抽象层和调度器。这一课会看它怎么用一套统一接口屏蔽硬件差异、怎么在运行时动态加载后端、怎么把一张图分派下去执行。举个最贴近的场景:你用一块显存不太够的显卡跑一个大模型,于是把一部分层放 GPU、剩下的留 CPU——这时每跑一遍模型,数据都要在 CPU 和 GPU 之间来回过好几道,靠的全是这一课要讲的后端与调度。理解了它,你才真正明白 -ngl 这个参数背后到底发生了什么。
路线图:先看后端抽象(统一接口),再看注册与动态加载(一份二进制按硬件挑后端),然后是调度器(把图的算子分派到各后端、并按需搬张量),最后扫一眼 ggml 支持的其它后端。
一个 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)描述"做什么",后端负责"怎么做、在哪做"。图和硬件之间彻底解耦:
抽象有了,可程序怎么知道这台机器上有哪些后端?答案是运行时动态加载。每个后端编译成一个动态库(.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,而是翻这本簿子按类型查——正是这层间接,让"上层不认识具体硬件"能真正成立。
有了多个后端,最后一个问题是:一张计算图(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),所以它会尽量把连续的、能在同一设备上算的算子成段地交给同一个后端,减少来回搬运。换句话说,它不只看"这个算子在哪算最快",还看"怎么切这张图,整体的跨设备搬运最少"——所以它倾向于把连续的、能在同一设备上算的活儿成段地交给同一个后端,而不是东一层西一层地乱切。把"分派一个算子"这件事定格成一条流水看最清楚:
这正好把 L09/L10 那张"静态的图"接到了"动态的执行"上:图描述依赖,调度器按依赖顺序、结合每个张量的实际位置,把算子一个个落到具体硬件上跑。你平时用 -ngl N 把前 N 层放上 GPU、其余留 CPU,背后正是这个调度器在按层分派、并在 GPU 与 CPU 之间搬运边界处的张量。而当 GPU 显存实在放不下整个模型时,这种 CPU+GPU 混合执行往往是唯一能跑起来的办法——代价是边界处那几次跨设备拷贝,但总比完全跑不动强。这也解释了为什么 -ngl 调大调小,速度和显存占用会此消彼长:放上 GPU 的层越多,算得越快,但占的显存也越多、CPU-GPU 之间的搬运点也跟着变。
用一张"整张图怎么被切"的图,把 -ngl 的效果看得更具体——以一个 32 层的模型、-ngl 20 为例:
矩阵乘等重活在显存里算;这些层的张量都待在 GPU buffer,层与层之间无需跨设备。
整张图只在这一个边界上,把激活从 GPU 显存拷回 CPU 内存(调度器自动插入)。
显存放不下的剩余层留在 CPU 上算,用 L31 的 SIMD + 多线程。
这张图也解释了为什么"分段放"比"乱放"快:把上 GPU 的层连成一段,整张图就只有一处跨设备边界、只拷一次;要是把 GPU 层和 CPU 层交替着排,每换一次设备就得拷一次,那点可怜的 PCIe 带宽很快就被拷贝吃光。调度器之所以默认"前 N 层整段上 GPU",正是为了把这种边界压到最少。
除了已经细看的 CPU(L31)和 CUDA(L32),ggml 还实现了一大批后端,覆盖各家硬件。它们都遵循同一套 ggml_backend_i 接口,所以上层代码几乎不用改,换硬件只是换一个加载进来的后端:
| 后端 | 面向的硬件 / 平台 | 典型场景 |
|---|---|---|
| Metal | Apple GPU(macOS / iOS) | 苹果设备上的首选 GPU 后端 |
| Vulkan | 跨平台 GPU | 不限厂商的通用 GPU 加速 |
| SYCL | Intel GPU | Intel 独显 / 集显 |
| HIP | AMD GPU | A 卡(对标 CUDA) |
| CANN | 华为昇腾 NPU | 昇腾加速卡 |
| OpenCL | 跨平台(含移动 GPU) | 移动 / 嵌入式(如高通 Adreno) |
| BLAS | CPU(借现成数学库) | 用 BLAS 库加速 CPU 矩阵乘 |
| RPC | 远程机器 / 进程 | 把算子发到另一台机器上跑 |
这张表最能说明后端抽象的价值:从苹果的 Metal 到华为的 CANN、从本机 GPU 到远程的 RPC,硬件天差地别,但对上层而言都只是"一个实现了 ggml_backend_i 的设备"。想支持一种新硬件,本质上就是再写一份后端实现、注册进来——上层的模型代码一行都不用动。事实上,这些后端里有不少是社区或硬件厂商贡献的:正因为接口是统一的,华为能自己来写 CANN、Intel 能来写 SYCL,而不必去动 ggml 的核心。一个好的抽象接口,等于给整个生态开了一扇"你来适配硬件、我保证上层不变"的门——这也是开源项目能在短时间里支持这么多硬件的组织学原因。
最后两个折叠:看两个"不太像 GPU"的特殊后端,以及加一个新后端大致要实现什么。
大多数后端对应一种"算力硬件",但有两个例外很有意思。BLAS 后端并不直接写 kernel,而是把矩阵乘转交给系统里现成的高性能 BLAS 数学库(如 OpenBLAS、MKL)去算——相当于"借别人造好的轮子",在某些 CPU 上比 ggml 自带的实现还快。RPC 后端更特别:它根本不在本地算,而是把算子通过网络发到另一台机器(或进程)上跑,再把结果取回来。有了它,你可以把一个单机装不下的大模型,拆到好几台机器的显存里分布式地跑。这两个后端都套着同一层 ggml_backend_i 接口,所以对上层来说,"借数学库"和"发去远程"与"在本地 GPU 上算"没有任何区别——这正是抽象的威力。顺便说,正因为有 RPC 这种后端,"在一台机器上调度、把重活发到另一台或多台去跑"这类玩法才成为可能;而 BLAS 后端则提醒我们:所谓"后端"未必对应一种新硬件,也可以只是"换一套更快的算法实现"。抽象层只关心"你能不能实现这套接口",至于你背后到底是一块芯片、一个数学库、还是一根网线,它一概不问。
想给 ggml 加一种新硬件,核心就是去实现 ggml-backend-impl.h 里定义的那几套接口(设备接口 + 后端接口),主要包括几类:buffer 管理(怎么在这块硬件上分配内存、把张量数据拷进拷出)、算子支持查询(supports_op:这个后端能不能算某个算子——不能的就让调度器回退给别的后端)、以及跑计算图(graph_compute:把分给我的这串算子真正算出来)。实现完、再注册进注册表,上层的模型和调度器就能立刻用上它,完全不用改。这套"定义好接口、谁都能往里插"的设计,正是 ggml 能在这么短时间里长出十几种后端的原因——也是软件工程里"面向接口编程"最实在的一个例子。
第六部分到这里就收束了。从 L31 的 CPU 指令(标量、SIMD、多线程)、L32 的 CUDA 线程(grid/block、分块、显存层级),到这一课的后端抽象与调度,"一个算子最终怎么落到硬件上算"这条线,算是从头讲透了。再往上回看:模型(第四部分)描述要算什么,计算图(第三部分)把它组织成依赖,后端(这一部分)把图落到具体硬件——三层一接,llama.cpp 跑模型的全貌就清楚了。下一站第七部分,我们去看一些进阶专题。学到这里,你已经把 llama.cpp 从"一个能跑模型的黑盒",拆成了"一摞看得懂的层"——这本身就是啃源码最大的收获:再复杂的系统也不是一团乱麻,而是一层层抽象垒起来的,每一层只解决一个问题、只对上一层暴露一个干净的接口。把这套眼光带走,你以后去读任何一个大项目,都会比从前从容得多。
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.
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:
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.
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:
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:
heavy work like matmul runs in VRAM; these layers' tensors stay in GPU buffers, with no cross-device hop between layers.
the whole graph has just this one boundary, copying activations from GPU VRAM back to CPU RAM (the scheduler inserts it automatically).
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.
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:
| Backend | Target hardware / platform | Typical use |
|---|---|---|
| Metal | Apple GPU (macOS / iOS) | the go-to GPU backend on Apple devices |
| Vulkan | cross-platform GPU | vendor-agnostic general GPU acceleration |
| SYCL | Intel GPU | Intel discrete / integrated graphics |
| HIP | AMD GPU | AMD cards (CUDA's counterpart) |
| CANN | Huawei Ascend NPU | Ascend accelerator cards |
| OpenCL | cross-platform (incl. mobile GPU) | mobile / embedded (e.g. Qualcomm Adreno) |
| BLAS | CPU (borrowing a math library) | accelerate CPU matmul via a BLAS library |
| RPC | remote machine / process | send 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.
Two last folds: two "not very GPU-like" special backends, and roughly what adding a new backend takes.
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.
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.