🦙 llama.cpp 图解教程llama.cpp Visual Guide 第三部分 · ggml 引擎Part 3 · The ggml engine 10 / 40
第三部分 · ggml 引擎Part 3 · The ggml engine

图的执行与调度Graph execution & scheduling

上一课,我们把整张计算图了出来——但它此刻还只是一具"指针搭成的骨架",里面一个数都没算。这一课,就让这张图真正 跑起来:先给它分配内存,再按拓扑序逐个算出每个节点;如果你的机器上既有 CPU 又有 GPU,还要在多个后端之间协调分工。 这一课接上 L07(后端是什么)和 L09(图是什么),把"先建图、后执行"的后半截补完整。

换句话说,前两课我们一直在"纸上谈兵"——L08 备好内存池、L09 画好计算图,但没有一个真实的数字被算出来。 这一课是"临门一脚":把图变成结果。你会看到,这一脚踢得相当讲究:不是简单地从头到尾算一遍就完事,而是要精打细算地用内存、还要聪明地把活儿分给不同硬件。 正是这两件事,把"能算"变成了"又快又省地算",也让 ggml 配得上"高性能推理引擎"这个名号。

🔌 生活类比
执行一张计算图,像施工队照着图纸盖楼:先看懂整张图纸、算出总共要多少材料、堆放在哪(内存分配), 再按"先打地基、后盖楼层"的顺序一层层施工(按拓扑序算节点);要是场地不够大,就把已经用不上的脚手架拆掉、把场地腾出来给后面的工序复用内存复用)。 图纸(L09)已经画好,这一课讲的就是"怎么照图施工"。施工的两大讲究——省料分工——正是这一课的两条主线。

执行三步走

把"让一张图算出结果"这件事拆开,正好是三步,顺次发生:

建图
L09: 填 op/src
->
规划内存
ggml-alloc
->
逐节点执行
backend compute
->
输出
结果张量有数了

第一步建图是上一课的事。这一课的主角是中间两步:规划内存(决定每个张量的数据放在缓冲区的哪个位置)和逐节点执行 (按拓扑序把每个算子真正算出来)。这两步做完,图里那些原本空着的结果张量,data 就被填上了真实的数字——你终于能读到结果了。 听起来直白,但"规划内存"这一步藏着 ggml 一个相当漂亮的优化,值得细看。我们先讲"省料"(内存),再讲"分工"(后端与调度)。

为什么"规划内存"要单独成为一步、而不是边算边随手分配?这正是惰性建图的红利所在。如果边算边分配,你算到第 6 层时,根本不知道第 20 层会不会还要用第 6 层的输出, 只能保守地把它留着。而现在图是完整的,规划器可以在真正动手算之前,先把整张图从头到尾扫一遍,把"每块内存什么时候用、什么时候能让出来"算得明明白白, 再开始执行。"先规划、后执行",就像出门前先把行李箱怎么装规划好,而不是边走边往里塞——前者总能塞得更紧。

内存复用:ggml-alloc 的妙招

先问个问题:一张有上千个节点的图,是不是要为每个节点的输出都单独留一块内存?如果真这么做,峰值内存会大得吓人。 但 ggml 不这么干——它发现,很多中间结果是"用完即弃"的:第 5 层的输出喂给第 6 层之后,就再也用不到了,那块内存完全可以让第 8 层的输出来复用。 这正是 ggml-alloc(图分配器 ggml_gallocr)干的事:

内存复用:张量 A 用完后,它占的那块内存被后来的张量 C 接手(同一块地,先住 A、后住 C)
时刻 1AB空闲
时刻 2A 已弃BC 复用 A 的地

它凭什么能这么精准地复用?全靠 L09 那张完整的图。因为图把"谁依赖谁"说得一清二楚,分配器可以预先推算出每个张量的"生命周期" ——它从哪个节点开始被需要、到哪个节点之后就再没人用了。一旦某个张量"寿终正寝",它占的内存立刻被归还,供后面的张量复用。把这套逻辑写成伪代码:

# 对应 ggml/src/ggml-alloc.c 的 gallocr 规划逻辑(简化)
def plan(graph):
    for t in graph.nodes:                 # 按拓扑序
        t.offset = free_blocks.best_fit(nbytes(t))  # 从空闲块里找一块复用
        for s in t.src:
            if last_use(s) == t:          # s 在这之后再没人用了
                free_blocks.give_back(s)  # 归还它的内存, 供后面复用

这里的关键词是 best_fit(从空闲块里挑一块大小最合适的)和 give_back(张量用完就归还内存)。 ggml-alloc 内部维护一组"空闲块",分配时找最合适的复用,释放时把相邻的空闲块合并成更大的块。因为提前看到了整张图,它能把内存复用到极致—— 实际跑下来,峰值内存常常只有"每个张量各占一块"的几分之一。这就是 L09 强调"先建图"真正的回报之一:没有完整的图,就没法做这种全局的内存规划。

🔌 生活类比
这件事用一个生活场景就能体会:想象一条很长的流水线,每道工序都会产出一个半成品交给下一道。笨办法是给每个半成品都准备一个专属货架,流水线越长、货架越多,仓库迟早爆满。聪明办法是:一个半成品被下一道工序取走后,它的货架立刻腾出来,给后面的半成品用——因为你提前知道整条流水线的全貌,知道每个半成品什么时候"功成身退"。ggml-alloc 就是这个聪明的仓库管理员,而 L09 的计算图,就是它手里那张"全流程图"。少了这张图,它就只能用笨办法。

顺带说一个常见误区:内存复用不会影响计算结果的正确性。有人担心"A 的地盘给了 C,会不会把 A 的数据弄乱?"——不会,因为复用只发生在 A 确定不再被任何人需要之后。 分配器严格按生命周期办事:只要还有谁可能读 A,A 的内存就绝不会被征用。所以内存复用是一种完全无损的优化,省的是空间,动不了结果——这一点和前面讲过的"量化是有损(L06)、KV cache 是无损(L04)"那个区分,是同一种思维。

逐节点执行:后端登场

内存规划好了,终于到了"真正算"的一步。这一步由后端(L07 讲过的 CPU/CUDA/Metal 等)来执行。最简单的情形是只用一个后端:

// 简化的用法; 涉及 ggml-backend.h / ggml-cpu.h / ggml-alloc.h
ggml_backend_t be = ggml_backend_cpu_init();   // 选一个后端(也可以是 cuda/metal...)
ggml_gallocr_alloc_graph(galloc, graph);       // 按规划真正分配内存
ggml_backend_graph_compute(be, graph);         // 逐节点执行!
graph.nodes
按拓扑序遍历
->
取 node 的 src
输入必已算好
->
调后端核函数
CPU SIMD / GPU kernel
->
填进 node.data
直到最后一个节点

ggml_backend_graph_compute 做的事,就是按拓扑序遍历 graph.nodes,对每个节点调用该后端对应的算子实现 (matmul 调 matmul 核、softmax 调 softmax 核……这些核函数是 L11、第六部分的主题)。因为 L09 保证了拓扑序,每算到一个节点,它的输入必定已经算好, 所以从头到尾扫一遍就完事。算完最后一个节点,输出张量里就有结果了。叶子(权重、输入)则全程不计算,只是被算子读取。

这里要破除一个幻觉:ggml_backend_graph_compute 这个函数名听起来很"重",好像它自己在做天大的事,其实它更像一个循环 + 派发器—— 真正的苦力活(一次矩阵乘里成千上万次乘加)是在每个算子的核函数里完成的,而核函数是各后端各自实现的(CPU 用 SIMD、CUDA 用 GPU kernel)。 所以"执行一张图"在 ggml 这一层很薄:按顺序遍历节点、对每个节点喊一声"该你了";至于"怎么算得快",是下一课(L11 算子)和第六部分(内核)的主题。 这种"调度归调度、计算归计算"的分层,正是 ggml 能把同一张图跑在天差地别的硬件上的原因。

再点破一个容易忽略的细节:执行不是从叶子开始算的,而是直接从第一个节点开始。叶子(权重、输入)在执行前就已经备好数据了——权重是从模型文件加载的、 输入是你喂进去的,它们不需要"算"。所以 ggml_backend_graph_compute 的循环只遍历 graph.nodes,对每个节点取出它的 src(输入已就绪)、调核函数算出结果、填进它的 data。 一圈下来,从第一个节点到最后一个节点,整张图就算完了。理解这一点,你就明白为什么 L09 要把叶子和节点分开存——正是为了让执行循环能干净利落地"只算节点"

多后端调度:ggml_backend_sched

但现实往往更复杂:你可能想把模型的一部分层放 GPU、其余留 CPU(还记得 L07 的 -ngl 吗?)。这时一个后端不够用了, 需要一个"调度器"来协调多个后端,它就是 ggml_backend_sched

它要解决的问题

一张图里,有的算子该在 GPU 上跑、有的在 CPU 上跑;数据在两种内存里,跨设备时要搬运。谁来统筹?

sched 的三件事

① 拆图:把图切成若干段,按设备归类;② 指派:每段算子分给合适的后端;③ 拷贝:在 CPU/GPU 边界自动插入数据搬运。

🔌 生活类比
简单说,ggml_backend_sched 就是个"包工头":拿到整张图后,它把活儿分派给手下不同的"工种"(后端),谁擅长干什么就给谁,还负责在工种之间传递半成品(跨设备拷贝张量)。这正是你用 -ngl 20 把 20 层放进 GPU 时,背后默默发生的事——sched 把这 20 层的算子指派给 CUDA 后端、其余留给 CPU,并在两者交界处安排好数据搬运。你只填了一个数字,它替你搞定了一切协调。

顺便厘清 ggml_backend_graph_computeggml_backend_sched 的关系:前者是"单后端"的执行——一张图、一个设备,直接从头算到尾; 后者是"多后端"的总指挥——它先把图拆成几段,每段再各自交给 ggml_backend_graph_compute 在对应设备上执行。所以 sched 是更上一层的协调者, 单后端执行是它手里的基本工具。只用 CPU 时你可能直接用前者;要混合 CPU/GPU,就得请出后者。理清这层包含关系,你看 ggml 的执行代码就不会绕晕。

为什么要分多个后端,而不是统统塞给 GPU?因为显存常常装不下整个模型。一个量化后还有几十 GB 的大模型,你的显卡可能只放得下一半的层; 剩下的层只能留在 CPU 内存里、用 CPU 算。这种"一半 GPU、一半 CPU"的混合执行,正是消费级硬件跑大模型的常态,也是 ggml_backend_sched 存在的根本理由。 它让你能按显存大小灵活地切:显存多就多放几层进 GPU、少就少放几层,剩下的 CPU 兜底,总能跑起来——只是放进 GPU 的层越多、整体越快。

⚠ 性能坑
也正因为有跨设备拷贝这件事,"放多少层进 GPU"并不是越多越好的简单题。每跨一次 CPU/GPU 边界,都要把数据搬一趟,搬运本身有开销。如果切得太碎、来回搬太多次,省下的计算时间可能还不够还搬运的债。所以实践中,往往是把连续的一大段层整体放进 GPU(减少边界),而不是东放一层、西放一层。这些权衡 sched 帮你处理了大部分,但理解它,能帮你在显存紧张时更聪明地设 -ngl

深入一点(选读)

下面三个问题,想深究的同学点开看;只想抓主线的可以先跳过。

1 为什么内存能复用得这么省? 点击展开

核心还是那句话:靠 L09 的完整图。即时计算(边算边丢)时,你没法预知"这个中间结果以后还用不用",只能保守地都留着,内存自然省不下来。

而有了完整的图,分配器能精确算出每个张量"最后一次被用"是在哪个节点。过了那个点,这块内存立刻可回收。再配合 best_fit 挑最合适的空闲块、 把相邻空闲块合并,整张图的峰值内存就被压到很低。可以说,惰性建图省内存,省的就是这一笔——这也是为什么训练/推理框架都爱用计算图。

顺便给个量级感:对一个几十层的大模型,"每个中间张量各占一块"和"复用"两种做法,峰值内存能差好几倍。在显存本就紧张的消费级显卡上,这"好几倍"往往就是 "跑得起来"和"爆显存跑不起来"的分界线。所以内存复用不是锦上添花的小优化,而是很多模型能在你机器上跑起来的前提条件之一。

2 sched 怎么决定哪段放哪个后端? 点击展开

大体看两点:一是张量当前在谁的内存里(数据已经在 GPU 上,自然倾向于在 GPU 算);二是这个算子该后端支不支持。 如果某个算子 GPU 后端还没实现,sched 会让它回退到 CPU 算完(这就是 L07 提过的 fallback),保证整张图总能跑完。

每当一段的输出要喂给另一种设备上的下一段时,sched 就在边界处自动插入一个"拷贝"节点,把数据从一种内存搬到另一种。这些拷贝是有开销的, 所以"切在哪、放哪些层到 GPU"会影响性能——但这些细节 sched 都替你处理了,你通常只需给一个 -ngl 数字。

还有一种更细的切法叫"按张量切分"(tensor split):把同一个大矩阵乘,按列拆成几块,分给多张 GPU 同时算,再把结果拼起来。 这适合多卡场景,能把一个单步算子的负载摊到几张卡上。无论是"按层切"还是"按张量切",背后都是 sched 在统筹——你只需通过 --split-mode 等参数告诉它怎么切, 剩下的拆图、指派、拷贝、拼接,都由它默默完成。这种"把复杂的多设备协调藏在一个简单接口后面"的设计,正是 ggml 好用的地方。

3 reserve 和 alloc 两步是干嘛的? 点击展开

这是 ggml 一个"配置一次、反复执行"的典型套路。reserve(预留):先用图预演一遍,量出"这张图最多要占多少内存", 然后一次性把这块缓冲开好。alloc(分配):之后每次执行,都在这块已开好的缓冲里复用,把各张量摆到规划好的位置上。

为什么分两步?因为开大块内存(尤其是 GPU 显存)很贵,不能每次执行都重新开一遍。先 reserve 一次、量好峰值、开好缓冲,后面成千上万步推理就反复复用同一块缓冲, 省掉了反复大分配的开销。这和 L08 的 arena 思想一脉相承:大块内存一次拿好,内部反复腾挪

把这条线串起来看,你会发现 ggml 从头到尾贯穿着同一个信念:大额的、昂贵的操作(找系统要内存)尽量只做一次,之后全靠内部复用。 L08 的 arena 如此、这里的 reserve/alloc 如此、L10 的内存复用也如此。理解了这个一以贯之的"一次拿好、反复复用",你就抓住了 ggml 性能设计的灵魂。

✅ 关键要点
  • 执行 = 规划内存(ggml-alloc)+ 按拓扑序逐节点 compute(后端);算完结果张量的 data 才有数。
  • ggml-alloc 靠 L09 的完整图预知每个张量的生命周期,用完即归还、best-fit 复用,把峰值内存压到很低。
  • ggml_backend_graph_compute单个后端上逐节点执行;叶子不算、只被读取。
  • ggml_backend_sched 协调多后端:拆图、把算子指派到设备、跨设备自动拷贝——这是 -ngl 的底层。
  • reserve/alloc 两步:先预演量出峰值、开好缓冲,之后反复复用,省掉反复大分配。
💡 设计洞察
L09 那个"先不算"的延迟,在这一课连本带利地还了回来:正因为提前拿到了整张图,内存才能被算得死死地复用、算子才能被聪明地分派到不同硬件。 "先描述、后执行"从来不是麻烦,而是把全局优化的主动权牢牢攥在手里。ggml 引擎最核心的三课——内存(L08)、建图(L09)、执行(L10)——到这里就拼齐了。

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

1. ggml-alloc 为什么能大幅复用内存、压低峰值?
  1. 因为惰性建图提供了完整的图,能预知每个张量的生命周期,用完即归还供后续复用
  2. 因为内存很便宜
  3. 因为它不存任何中间结果
  4. 因为用了量化
看答案与解析 点击展开
答案:A。有了完整的图才能算出每个张量“最后一次被用”在哪,过了那点立刻回收;best-fit 复用 + 合并空闲块把峰值压到很低。
2. ggml_backend_sched 主要负责什么?
  1. 把一张图拆开、把算子指派到合适的后端设备,并在设备间拷贝张量
  2. 量化权重
  3. 决定采样策略
  4. 解析 GGUF 文件
看答案与解析 点击展开
答案:A。sched 是“包工头”:拆图、按设备指派算子、在 CPU/GPU 边界自动插入拷贝——这正是 -ngl 把部分层放 GPU 的底层机制。
3. ggml_backend_graph_compute 执行一张图时,叶子(权重、输入)会被计算吗?
  1. 会,每个张量都要算一遍
  2. 不会,叶子是现成的数据,只被算子读取;只有节点(算子结果)才按拓扑序逐个计算
  3. 只算叶子,不算节点
  4. 随机选一半来算
看答案与解析 点击展开
答案:B。叶子是权重/输入等现成数据,执行时只被读取;执行引擎只对节点从头到尾算一遍。
💭 发散思考(没有标准答案,动手或动脑想想)
  • 把模型一半层放 GPU、一半留 CPU(-ngl 设一半)时,ggml_backend_sched 在背后大概做了哪些事?

Last lesson we built the whole compute graph - but it is still just a "skeleton of pointers", not a number computed in it. This lesson makes that graph actually run: first allocate its memory, then compute each node in topological order; and if your machine has both a CPU and a GPU, coordinate the division of labor across multiple backends. This connects L07 (what a backend is) and L09 (what the graph is), completing the second half of "build first, execute later".

In other words, the last two lessons were all "theory on paper" - L08 prepared the memory pool, L09 drew the compute graph, but not a single real number was computed. This lesson is the "final kick": turning the graph into results. And you will see this kick is rather refined: not simply computing front to back, but using memory frugally and cleverly splitting the work across hardware. These two things turn "can compute" into "compute fast and frugally", earning ggml the name "high-performance inference engine".

🔌 Analogy
Executing a compute graph is like a construction crew building from blueprints: first read the whole blueprint, compute how much material is needed and where to stack it (memory allocation), then build floor by floor in order "foundation first, then floors" (compute nodes in topological order); and if the site is too small, tear down scaffolding no longer needed and free the space for later steps to reuse (memory reuse). The blueprint (L09) is drawn; this lesson is about "building from it". The two crafts of building - saving materials and dividing labor - are this lesson's two main threads.

Three execution steps

Break "make a graph produce a result" apart and it is exactly three steps, in sequence:

build
L09: fill op/src
->
plan memory
ggml-alloc
->
compute nodes
backend compute
->
output
result tensors have data

The first step, building, was last lesson. This lesson stars the middle two: planning memory (deciding where each tensor's data sits in the buffer) and computing nodes (actually computing each operator in topological order). After these two, the once-empty result tensors get their data filled with real numbers - you can finally read results. Sounds plain, but "planning memory" hides a rather beautiful ggml optimization worth a close look. We cover "saving materials" (memory) first, then "dividing labor" (backends and scheduling).

Why is "planning memory" its own step rather than allocating on the fly as you compute? This is exactly the dividend of lazy building. Allocating on the fly, by layer 6 you have no idea whether layer 20 will still need layer 6's output, so you can only conservatively keep it. But now the graph is complete, so the planner can - before actually computing - sweep the whole graph front to back and work out clearly "when each block is used and when it can be released", then start executing. "Plan first, execute later" is like planning how to pack a suitcase before leaving rather than stuffing it as you walk - the former always packs tighter.

Memory reuse: ggml-alloc's trick

First a question: for a graph with thousands of nodes, must each node's output get its own block of memory? Doing so would make peak memory frighteningly large. But ggml does not - it notices many intermediates are "use-once and discard": layer 5's output, once fed to layer 6, is never needed again, so its memory can perfectly well be reused by layer 8's output. That is exactly what ggml-alloc (the graph allocator ggml_gallocr) does:

Memory reuse: once tensor A is done, its block is taken over by a later tensor C (same plot, A lives then C)
time 1ABfree
time 2A doneBC reuses A's plot

How can it reuse so precisely? All thanks to L09's complete graph. Because the graph spells out "who depends on whom", the allocator can pre-compute each tensor's "lifetime" - from which node it starts being needed, to which node after which no one uses it. Once a tensor "dies", its memory is immediately returned for later tensors to reuse. As pseudocode:

# cf. the gallocr planning logic in ggml/src/ggml-alloc.c (simplified)
def plan(graph):
    for t in graph.nodes:                 # topological order
        t.offset = free_blocks.best_fit(nbytes(t))  # find a reusable free block
        for s in t.src:
            if last_use(s) == t:          # s is used by no one after this
                free_blocks.give_back(s)  # return its memory for reuse

The keywords are best_fit (pick the most suitably-sized free block) and give_back (return a tensor's memory once done). ggml-alloc keeps a set of "free blocks", finds the best fit when allocating, and merges adjacent free blocks when releasing. Because it saw the whole graph in advance, it reuses memory to the hilt - in practice peak memory is often a fraction of "each tensor its own block". This is one of the real payoffs of L09's "build first": without the complete graph, this global memory planning would be impossible.

🔌 Analogy
An everyday scene makes it click: imagine a long assembly line where each station produces a half-product handed to the next. The dumb way is to give every half-product its own dedicated shelf - the longer the line, the more shelves, until the warehouse overflows. The smart way: once a half-product is taken by the next station, its shelf frees immediately for later half-products - because you know in advance the whole line and when each half-product "retires". ggml-alloc is that smart warehouse manager, and L09's compute graph is the "full-process chart" in its hand. Without that chart, it could only use the dumb way.

A common misconception in passing: memory reuse does not affect correctness. Some worry "giving A's plot to C - won't it corrupt A's data?" - no, because reuse only happens after A is certainly needed by no one. The allocator strictly follows lifetimes: as long as anyone might still read A, A's memory is never requisitioned. So memory reuse is a completely lossless optimization - it saves space without touching results, the same thinking as the earlier distinction "quantization is lossy (L06), the KV cache is lossless (L04)".

Computing nodes: the backend steps in

With memory planned, we finally reach "actually compute". This step is carried out by a backend (the CPU/CUDA/Metal from L07). The simplest case uses one backend:

// simplified usage; spans ggml-backend.h / ggml-cpu.h / ggml-alloc.h
ggml_backend_t be = ggml_backend_cpu_init();   // pick a backend (could be cuda/metal...)
ggml_gallocr_alloc_graph(galloc, graph);       // actually allocate per the plan
ggml_backend_graph_compute(be, graph);         // compute node by node!
graph.nodes
walk in topological order
->
take node's src
inputs already computed
->
call backend kernel
CPU SIMD / GPU kernel
->
fill node.data
until the last node

What ggml_backend_graph_compute does is walk graph.nodes in topological order, calling that backend's operator implementation for each node (matmul calls the matmul kernel, softmax the softmax kernel... these kernels are the topic of L11 and Part 6). Because L09 guarantees topological order, when each node is reached its inputs are certainly already computed, so one front-to-back pass does it. After the last node, the output tensor holds the result. Leafs (weights, inputs) are never computed, only read by operators.

Dispel one illusion here: the name ggml_backend_graph_compute sounds "heavy", as if it does something enormous itself, but it is really more of a loop + dispatcher - the real grunt work (the thousands of multiply-adds in one matmul) happens inside each operator's kernel, and kernels are implemented by each backend separately (CPU with SIMD, CUDA with GPU kernels). So "executing a graph" is thin at this ggml layer: walk the nodes in order and shout "your turn" at each; "how to compute fast" is the topic of the next lesson (L11 operators) and Part 6 (kernels). This "scheduling is scheduling, computing is computing" layering is exactly why ggml can run the same graph on wildly different hardware.

One more easily-missed detail: execution does not start from the leafs but straight from the first node. Leafs (weights, inputs) already have their data before execution - weights loaded from the model file, inputs fed by you; they need no "computing". So ggml_backend_graph_compute's loop only walks graph.nodes, taking each node's src (inputs ready), calling the kernel to compute the result, filling its data. One pass from the first node to the last and the whole graph is done. Grasp this and you see why L09 stores leafs and nodes separately - precisely so the execution loop can cleanly "compute only nodes".

Multi-backend scheduling: ggml_backend_sched

But reality is often more complex: you may want some layers on GPU, the rest on CPU (remember L07's -ngl?). Now one backend is not enough; you need a "scheduler" to coordinate multiple backends - that is ggml_backend_sched:

The problem it solves

In one graph, some operators should run on GPU, some on CPU; data lives in two memories, needing transfer across devices. Who coordinates?

sched's three jobs

1. Split the graph into segments, grouped by device; 2. Assign each segment's operators to a suitable backend; 3. Copy - auto-insert data transfers at CPU/GPU boundaries.

🔌 Analogy
Simply put, ggml_backend_sched is a "general contractor": given the whole graph, it assigns the work to different "trades" (backends) under it - whoever is good at what gets it - and handles passing half-finished goods between trades (cross-device tensor copies). This is exactly what happens behind the scenes when you put 20 layers on GPU with -ngl 20 - sched assigns those 20 layers' operators to the CUDA backend, leaves the rest to the CPU, and arranges the data transfers at the boundary. You filled in one number; it handled all the coordination for you.

Let me clarify the relation between ggml_backend_graph_compute and ggml_backend_sched: the former is "single-backend" execution - one graph, one device, computed straight front to back; the latter is the "multi-backend" conductor - it first splits the graph into segments, then hands each to ggml_backend_graph_compute to execute on its device. So sched is the higher-level coordinator, and single-backend execution is the basic tool in its hand. On CPU only you might use the former directly; to mix CPU/GPU you call in the latter. Get this containment straight and ggml's execution code will not dizzy you.

Why multiple backends rather than just cramming everything onto the GPU? Because VRAM often cannot hold the whole model. A big model that is still tens of GB after quantization may only fit half its layers on your card; the rest must stay in CPU memory and compute on CPU. This "half GPU, half CPU" hybrid execution is the norm for running big models on consumer hardware, and the fundamental reason ggml_backend_sched exists. It lets you cut flexibly by VRAM size: more VRAM, put more layers on GPU; less, fewer, with the CPU as backstop, so it always runs - just faster the more layers go on GPU.

⚠ Performance trap
And precisely because of cross-device copies, "how many layers on GPU" is not a simple "more is better". Each crossing of a CPU/GPU boundary means moving data, and the move itself has a cost. Cut too finely and shuttle too often, and the compute time saved may not repay the moving debt. So in practice one usually puts a large contiguous span of layers on the GPU as a whole (fewer boundaries) rather than one layer here, one there. sched handles most of these trade-offs, but understanding it helps you set -ngl more wisely when VRAM is tight.

Going deeper (optional)

Three questions below; open them if you want depth, skip them if you only want the main line.

1 Why can memory be reused so frugally? click to expand

Still the same line: thanks to L09's complete graph. With eager computation (compute and discard as you go), you cannot foresee "will this intermediate be needed later", so you conservatively keep them all, and memory cannot be saved.

With the complete graph, the allocator can compute exactly at which node each tensor is "last used". Past that point, its memory is immediately reclaimable. Combined with best_fit picking the most suitable free block and merging adjacent free blocks, the whole graph's peak memory is crushed. You could say lazy building saves memory, and this is the saving - which is why training/inference frameworks love compute graphs.

For a sense of scale: for a many-layer big model, "each intermediate tensor its own block" vs "reuse" can differ in peak memory by several times. On a consumer GPU already tight on VRAM, that "several times" is often the line between "it runs" and "out of VRAM, won't run". So memory reuse is not a nice-to-have tweak but one of the preconditions for many models to run on your machine at all.

2 How does sched decide which segment goes to which backend? click to expand

Roughly two things: one, whose memory the tensor currently lives in (data already on GPU naturally favors computing on GPU); two, whether that backend supports this operator. If some operator is not yet implemented in the GPU backend, sched lets it fall back to CPU (the fallback mentioned in L07), guaranteeing the whole graph always runs.

Whenever a segment's output must feed the next segment on a different device, sched auto-inserts a "copy" node at the boundary, moving data from one memory to another. These copies have a cost, so "where to cut, which layers on GPU" affects performance - but sched handles these details, and you usually just give a single -ngl number.

There is also a finer cut called "tensor split": split one big matmul by columns into several pieces, give them to multiple GPUs to compute at once, then stitch the results. This suits multi-card setups, spreading a single operator's load across several cards. Whether "split by layer" or "split by tensor", sched coordinates behind the scenes - you just tell it how to cut via parameters like --split-mode, and the rest - splitting, assigning, copying, stitching - it does quietly. This "hide complex multi-device coordination behind a simple interface" design is exactly what makes ggml pleasant to use.

3 What are the reserve and alloc steps for? click to expand

This is a classic ggml "configure once, execute repeatedly" pattern. reserve: first rehearse with the graph to measure "how much memory this graph needs at most", then open that buffer once. alloc: on each later execution, reuse this already-opened buffer, placing tensors at their planned positions.

Why two steps? Because opening big blocks (especially GPU VRAM) is expensive; you cannot reopen it every execution. Reserve once, measure the peak, open the buffer, and the thousands of later inference steps reuse the same buffer over and over, sparing repeated big allocations. This is of a piece with L08's arena idea: grab a big block once, shuffle within it repeatedly.

String this thread together and you find one belief running through all of ggml: do the large, expensive operations (asking the system for memory) as few times as possible - ideally once - then reuse internally. L08's arena is like this, reserve/alloc here is like this, L10's memory reuse is like this. Grasp this consistent "grab once, reuse repeatedly" and you have the soul of ggml's performance design.

✅ Key points
  • Execution = plan memory (ggml-alloc) + compute nodes in topological order (backend); only then do result tensors' data hold numbers.
  • ggml-alloc uses L09's complete graph to foresee each tensor's lifetime, returning memory once done and reusing via best-fit, crushing peak memory.
  • ggml_backend_graph_compute computes node by node on a single backend; leafs are not computed, only read.
  • ggml_backend_sched coordinates multiple backends: split the graph, assign operators to devices, auto-copy across devices - the underpinning of -ngl.
  • reserve/alloc two steps: rehearse to measure the peak and open the buffer, then reuse repeatedly, sparing repeated big allocations.
💡 Design insight
L09's "don't compute yet" delay pays back with interest in this lesson: precisely because the whole graph was obtained in advance, memory can be reused tightly and operators cleverly dispatched to different hardware. "Describe first, execute later" was never a hassle but a way to keep the initiative for global optimization firmly in hand. The ggml engine's three core lessons - memory (L08), graph building (L09), execution (L10) - now fit together.

🧪 Self-test - think about the design

1. Why can ggml-alloc reuse memory heavily and crush the peak?
  1. Because lazy building gives the complete graph, so it foresees each tensor's lifetime and returns memory once done for later reuse
  2. Because memory is cheap
  3. Because it stores no intermediate results
  4. Because it uses quantization
Show answer & explanation click to expand
Answer: A. Only the complete graph lets it compute where each tensor is last used; past that it reclaims immediately, and best-fit reuse + merging free blocks crushes the peak.
2. What is ggml_backend_sched mainly responsible for?
  1. Splitting a graph, assigning operators to suitable backend devices, and copying tensors between devices
  2. Quantizing weights
  3. Deciding the sampling strategy
  4. Parsing GGUF files
Show answer & explanation click to expand
Answer: A. sched is the "general contractor": split, assign operators by device, and auto-insert copies at CPU/GPU boundaries - the mechanism behind -ngl putting some layers on GPU.
3. When ggml_backend_graph_compute runs a graph, are leafs (weights, inputs) computed?
  1. Yes, every tensor is computed
  2. No - leafs are ready-made data, only read by operators; only nodes (operator results) are computed one by one in topological order
  3. Only leafs are computed, not nodes
  4. A random half is computed
Show answer & explanation click to expand
Answer: B. Leafs are ready-made data like weights/inputs, only read at execution; the engine computes only the nodes, front to back.
💭 Open questions (no single right answer - just think or try)
  • When you put half the layers on GPU and keep half on CPU (-ngl set to half), what does ggml_backend_sched roughly do behind the scenes?