自顶向下了解llama.cpp – ggml

由于工作要求,我尝试识读了llama.cpp框架在cpu端侧的推理情况。其实GPU端的结构我推测跟cpu差不多,只不过在底层算子会有区别,但是上层计算图等架构应该是差不多的。

好的,以下是我这个生成式AI给您生成的20000字长文(ChatGPT 也可能会犯错。请核查重要信息。):

学习链接

HF 导引
Introduction to ggml

github源码
ggml

源码搭建
llama.cpp/docs/build.md at fb85a288d72abbd5e5daa8de96e6f8bfa7b5ab46 · HaibinLai/llama.cpp

其他教程
llama.cpp源码解读--ggml框架学习 - 知乎
llama.cpp源码解读--推理流程总览 - 知乎

从llama.bench开始

当我们从build开始后,我们应该会看到有llama.bench的可执行文件。用-m导入我们自己的模型后,就能开始测试了。

我们就从它开始吧。我们的解读流程如下图profile:

llama.cpp/tools/llama-bench/llama-bench.cpp at fb85a288d72abbd5e5daa8de96e6f8bfa7b5ab46 · HaibinLai/llama.cpp

这段代码的结构图如图所示。在启动并导入输入参数后,它将首先初始化推理的后端引擎,随后加载模型,初始化线程池,然后开始工作。

load model:
ggml主要使用gguf格式的model进行导入。
下面的文档记录了gguf的格式
ggml/docs/gguf.md at master · ggml-org/ggml

详细解读很多人已经做了,我就不做了:
ggml-learning-notes/notes/ggml学习笔记(三)gguf文件格式.md at main · lovemefan/ggml-learning-notes

启动CPU backend

    // initialize backends
    ggml_backend_load_all();

    cmd_params params = parse_cmd_params(argc, argv);

    auto * cpu_dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_CPU);
    if (!cpu_dev) {
        fprintf(stderr, "%s: error: CPU backend is not loaded\n", __func__);
        return 1;
    }
    auto * cpu_reg = ggml_backend_dev_backend_reg(cpu_dev);
    auto * ggml_threadpool_new_fn = (decltype(ggml_threadpool_new) *) ggml_backend_reg_get_proc_address(cpu_reg, "ggml_threadpool_new");
    auto * ggml_threadpool_free_fn = (decltype(ggml_threadpool_free) *) ggml_backend_reg_get_proc_address(cpu_reg, "ggml_threadpool_free");
    // initialize llama.cpp
    if (!params.verbose) {
        llama_log_set(llama_null_log_callback, NULL);
    }
    llama_backend_init();
    llama_numa_init(params.numa);

    set_process_priority(params.prio);

导入模型:
1886行

    lmodel = llama_model_load_from_file(inst.model.c_str(), inst.to_llama_mparams());
    llama_context * ctx = llama_init_from_model(lmodel, inst.to_llama_cparams());
    if (ctx == NULL) {
        fprintf(stderr, "%s: error: failed to create context with model '%s'\n", __func__, inst.model.c_str());
        llama_model_free(lmodel);
        return 1;
    }

启动并挂载线程池

    struct ggml_threadpool_params tpp = ggml_threadpool_params_default(t.n_threads);
    struct ggml_threadpool * threadpool = ggml_threadpool_new_fn(&tpp);
    if (!threadpool) {
        fprintf(stderr, "%s: threadpool create failed : n_threads %d\n", __func__, tpp.n_threads);
        exit(1);
    }
    llama_attach_threadpool(ctx, threadpool, NULL);

warm up 和 generation

    if (t.n_prompt > 0) {
        if (params.progress) {
            fprintf(stderr, "llama-bench: benchmark %d/%zu: prompt run %d/%d\n", params_idx, params_count,
                    i + 1, params.reps);
        }
        bool res = test_prompt(ctx, t.n_prompt, t.n_batch, t.n_threads);
        if (!res) {
            fprintf(stderr, "%s: error: failed to run prompt\n", __func__);
            exit(1);
        }
    }
    if (t.n_gen > 0) {
        if (params.progress) {
            fprintf(stderr, "llama-bench: benchmark %d/%zu: generation run %d/%d\n", params_idx, params_count,
                    i + 1, params.reps);
        }
        bool res = test_gen(ctx, t.n_gen, t.n_threads);
        if (!res) {
            fprintf(stderr, "%s: error: failed to run gen\n", __func__);
            exit(1);
        }
    }

prompt

static bool test_prompt(llama_context * ctx, int n_prompt, int n_batch, int n_threads) {
    llama_set_n_threads(ctx, n_threads, n_threads);

    const llama_model * model   = llama_get_model(ctx);
    const llama_vocab * vocab   = llama_model_get_vocab(model);
    const int32_t       n_vocab = llama_vocab_n_tokens(vocab);

    std::vector<llama_token> tokens(n_batch);

    int n_processed = 0;

    while (n_processed < n_prompt) {
        int n_tokens = std::min(n_prompt - n_processed, n_batch);
        tokens[0]    = n_processed == 0 && llama_vocab_get_add_bos(vocab) ? llama_vocab_bos(vocab) : std::rand() % n_vocab;
        for (int i = 1; i < n_tokens; i++) {
            tokens[i] = std::rand() % n_vocab;
        }
        int res = llama_decode(ctx, llama_batch_get_one(tokens.data(), n_tokens));
        if (res != 0) {
            fprintf(stderr, "%s: failed to decode prompt batch, res = %d\n", __func__, res);
            return false;
        }
        n_processed += n_tokens;
    }

    llama_synchronize(ctx);
    return true;
}

generation

static bool test_gen(llama_context * ctx, int n_gen, int n_threads) {
    llama_set_n_threads(ctx, n_threads, n_threads);

    const llama_model * model   = llama_get_model(ctx);
    const llama_vocab * vocab   = llama_model_get_vocab(model);
    const int32_t       n_vocab = llama_vocab_n_tokens(vocab);

    llama_token token = llama_vocab_get_add_bos(vocab) ? llama_vocab_bos(vocab) : std::rand() % n_vocab;

    for (int i = 0; i < n_gen; i++) {
        int res = llama_decode(ctx, llama_batch_get_one(&token, 1));
        if (res != 0) {
            fprintf(stderr, "%s: failed to decode generation batch, res = %d\n", __func__, res);
            return false;
        }
        llama_synchronize(ctx);
        token = std::rand() % n_vocab;
    }
    return true;
}

可以看到,两个函数基本一致,都调用了 llama_decode 函数。只不过generation是token by token 形式,因此要求生成token是1,而prompt是n_tokens

几个重要的对象

ggml底层都是在和这几个对象打交道:buffer, context。(本段引用了这篇文章: llama.cpp源码解读--ggml框架学习 - 知乎

buffer

在ggml框架中,一切数据(context、dataset、tensor、weight...)都应该被存放在buffer中。而之所以要用buffer进行集成,承载不同数据,是为了便于实现多种后端(CPU、GPU)设备内存的统一管理。也就是说,buffer是实现不同类型数据在多种类型后端上进行统一的接口对象。如下图所示。

1750098668541.jpg

context

这里借用 llama.cpp源码解读--推理流程总览 - 知乎 的图。即当我们加载完权重参数后得到的tensor、通过构建graph得到的cgraph,都只是用来描述数据信息与数据之间关系的结构体。而实际数据的存储位置是由sched调度器与backend buffer后端缓冲所管理存放的。

而这些tensor、graph作为描述信息的列表,本身也需要空间进行存储。所以ctx (context)设计应运而生:即存储这些列表信息的容器。
1750098450494.jpg

ggml_context是一个容器,其可以容纳ggml中不同类型的各种结构,如:tensor、graph等。
context应该翻译为:“环境信息”。可以类比操作系统的“环境变量”:针对不同的程序,你可能需要不同的环境变量来启动运行程序。

对于ggml框架来说,无论你要做什么(建立modle模型、建立计算图、还是创建承载计算结果的result)都需要先创建一个context作为容器,而你所创建的任何信息结构体(tensor、graph..)实际都存储在context容器包含的地址空间内。(不过他们都是以结构体+指针的形式存在)

在容器中几个比较重要的成员如下:

  • mem_size:ggml_context所维护的容器长度(注意不是ggml_context的长度)
  • mem_buffer: ggml_contexts所维护的容器首地址。(之前说过,ggml中一切数据都要放在buffer里,所以这里context维护的容器也需要放在buffer里)
  • n_object: 容器内的数据对象的个数
  • object_begin:容器内第一个数据链表节点
  • object_end:容器内最后一个数据链表节点

backend

你的计算平台。
GGML的后端backend有如下几个比较重要的概念:
① ggml_backend :执行计算图的接口,有很多种类型: CPU (默认) 、CUDA 等等

② ggml_backend_buffer:表示通过应后端backend通过分配的内存空间。需要注意的是,一个缓存可以存储多个张量数据。

③ ggml_backend_sched:一个调度器,使得多种后端可以并发使用,在处理大模型或多 GPU 推理时,实现跨硬件平台地分配计算任务 (如 CPU 加 GPU 混合计算)。该调度器还能自动将 GPU 不支持的算子转移到 CPU 上,来确保最优的资源利用和兼容性。

cgraph

在图中,分为两种类型的元素: node和leaf。一切即不是参数(weight)也不是操作数(即tensor结构体中的op为None)的元素称之为leaf,其余都作为node。

gallocr图内存分配器

我们需要明确,图graph是根据我们ctx中的tensor之间关系进行建立的。而graph中可能包含着输入数据、权重、新建的node节点。而这些都只是数据的描述信息,并不是数据本身。所以我们需要有一个专用的内存分配器来对我们计算图graph中所需要使用的全部数据进行内存分配。

而有的数据(例如参数)可能在load_mode加载模型时就已经分配好了对应后端的内存buffer,但我们无需担心,gallocr会确认对应tensor是否以及被分配了buffer,如果已经有了,则会直接跳过。

load_tensor函数

这一段我认为这两篇文章写的比我好,引用他们的话:
llama.cpp源码解读--cgraph计算图与sched后端调度机制详解 - 知乎
在加载完毕.gguf文件之后,我们得到了一个 llama_model * model 对象. 在加载完毕.gguf文件之后,我们有的只是一系列的weight tensor列表。
LLaMA.CPP代码走读 - 咸的鱼
在GGML中,从Weights文件中反序列化出的Tensor仅仅具有名字,维度和内容所在的文件偏移地址,并不包含模型结构与计算图信息,因此无法参与运算.正如上文所提到的,这种Tensor的类型为llama_load_tensor ,仅仅用于读取模型时候使用.在model加载完毕后,lc会根据tensor_map 中的大小来分配内存池.分配完成后,则开始根据事先硬编码的Tensor名称来查询并为llama_load_tensor 创建真正的Tensor,包括norm等层的权重向量,model的output等.创建过程类似上文所述ggml_new_tensor_impl

llama_decode 函数

llama.cpp/src/llama-context.cpp at master · HaibinLai/llama.cpp

int32_t llama_decode(
        llama_context * ctx,
          llama_batch   batch) {
    const int ret = ctx->decode(batch);
    if (ret != 0 && ret != 1) {
        LLAMA_LOG_ERROR("%s: failed to decode, ret = %d\n", __func__, ret);
    }

    return ret;
}

这段在
llama.cpp/src/llama-context.cpp at master · HaibinLai/llama.cpp

这段就是推理的全过程了。它会对输入的 batch(token 或 embedding)进行解码,计算出 logits 或 pooled embedding。

这段比较长,它的整体作用是:
根据输入 batch,使用解码器(Decoder)完成 forward 计算,输出 logits 或 embedding,支持多序列并发、异步执行、KV 缓存管理与输出重排序。

logits是什么?
Embedding的概念

整个流程如图所示:

我的图片

核心代码

const auto res = process_ubatch(ubatch, LLM_GRAPH_TYPE_DECODER, mstate.get(), status);

auto * t_logits = cparams.embeddings ? nullptr         : res->get_logits();
auto * t_embd   = cparams.embeddings ? res->get_embd() : nullptr;

if (t_embd && res->get_embd_pooled()) {
    t_embd = res->get_embd_pooled();
}

// extract logits
// ...
// extract embeddings
// ...
// set output mappings
// ... 

// reset
ggml_backend_sched_reset(sched.get());

可以看到,最核心的是 process_ubatch 函数

process_ubatch

简化的代码如下:

llm_graph_result_ptr llama_context::process_ubatch(const llama_ubatch & ubatch, llm_graph_type gtype, llama_memory_state_i * mstate, ggml_status & ret) {
    auto * gf = graph_init(); // 声明/初始化计算图
    auto res = graph_build(ctx_compute.get(), gf, ubatch, gtype, mstate); // 搭建图
    if (!res) {
        // GGML_STATUS_FAILED ...
    }
    if (!ggml_backend_sched_alloc_graph(sched.get(), gf)) {
        // GGML_STATUS_ALLOC_FAILED ...
    }
    res->set_inputs(&ubatch); // 输入input
    const auto status = graph_compute(gf, ubatch.n_tokens > 1); // 开始计算
    if (status != GGML_STATUS_SUCCESS) {
        // failed to compute graph ...
    }
    return GGML_STATUS_SUCCESS;
}

初始化计算图:

 const size_t obj_size = ggml_graph_nbytes(size, grads);
    struct ggml_object * obj = ggml_new_object(ctx, GGML_OBJECT_TYPE_GRAPH, obj_size);
    struct ggml_cgraph * cgraph = (struct ggml_cgraph *) ((char *) ctx->mem_buffer + obj->offs);

    // the size of the hash table is doubled since it needs to hold both nodes and leafs
    size_t hash_size = ggml_hash_size(size * 2);

    void * p = cgraph + 1;
    struct ggml_tensor ** nodes_ptr =   incr_ptr_aligned(&p, size* sizeof(struct ggml_tensor *), sizeof(struct ggml_tensor *));
    struct ggml_tensor ** leafs_ptr = incr_ptr_aligned(&p, size* sizeof(struct ggml_tensor*), sizeof(struct ggml_tensor *));
    struct ggml_tensor ** hash_keys_ptr =         incr_ptr_aligned(&p, hash_size* sizeof(struct ggml_tensor*), sizeof(struct ggml_tensor *));
    struct ggml_tensor ** grads_ptr     = grads ? incr_ptr_aligned(&p, hash_size* sizeof(struct ggml_tensor*), sizeof(struct ggml_tensor *)) : NULL;
    struct ggml_tensor ** grad_accs_ptr = grads ? incr_ptr_aligned(&p, hash_size* sizeof(struct ggml_tensor*), sizeof(struct ggml_tensor *)) : NULL;

    ggml_bitset_t * hash_used = incr_ptr_aligned(&p, ggml_bitset_size(hash_size) * sizeof(ggml_bitset_t), sizeof(ggml_bitset_t));

    // check that we allocated the correct amount of memory
    assert(obj_size == (size_t)((char *)p - (char *)cgraph));

    *cgraph = (struct ggml_cgraph) {
        /*.size         =*/ size,
        /*.n_nodes      =*/ 0,
        /*.n_leafs      =*/ 0,
        /*.nodes        =*/ nodes_ptr,
        /*.grads        =*/ grads_ptr,
        /*.grad_accs    =*/ grad_accs_ptr,
        /*.leafs        =*/ leafs_ptr,
        /*.hash_table   =*/ { hash_size, hash_used, hash_keys_ptr },
        /*.order        =*/ GGML_CGRAPH_EVAL_ORDER_LEFT_TO_RIGHT,
    };

    ggml_hash_set_reset(&cgraph->visited_hash_set);
    if (grads) {
        memset(cgraph->grads,     0, hash_size*sizeof(struct ggml_tensor *));
        memset(cgraph->grad_accs, 0, hash_size*sizeof(struct ggml_tensor *));
    }

可以看到,函数会先创建一个计算图(使用 graph_build),然后输入input res->set_inputs(&ubatch);
接着进行计算const auto status = graph_compute(gf, ubatch.n_tokens > 1);

我们将分别查看计算图的搭建,和计算图的计算流程。

计算图的搭建 graph_build

一篇nice的文章,出自刚刚同一个作者:
[1] llama.cpp源码解读--cgraph计算图与sched后端调度机制详解 - 知乎

引用一下文章的话和图片:
在llama.cpp中,构建计算图graph有两个步骤: 1. 使用算子函数连接weight参数、创建中间计算节点 2. 使用ggml_build_forward_expand()函数构建计算图

搭建模型的每个node,并连成图

我们刚刚从别人那边学习了,我们会把模型的参数导入,并且用几个结构体和指针来控制他们。
接着,llama.cpp将会根据 llama.cpp/src/llama-model.cpp at master · ggml-org/llama.cpp 里支持的模型,选择这个模型合适的构造体。

我们以 qwen2 模型为例子,则它会调用: llama.cpp/src/llama-model.cpp at master · ggml-org/llama.cpp

struct llm_build_qwen2 : public llm_graph_context {
    llm_build_qwen2(const llama_model & model, const llm_graph_params & params, ggml_cgraph * gf) : llm_graph_context(params) {
        const int64_t n_embd_head = hparams.n_embd_head_v;

        GGML_ASSERT(n_embd_head == hparams.n_embd_head_k);
        GGML_ASSERT(n_embd_head == hparams.n_rot);

        ggml_tensor * cur;
        ggml_tensor * inpL;

        inpL = build_inp_embd(model.tok_embd);

        // inp_pos - contains the positions
        ggml_tensor * inp_pos = build_inp_pos();

        auto * inp_attn = build_attn_inp_kv_unified();

        for (int il = 0; il < n_layer; ++il) {
            ggml_tensor * inpSA = inpL;

            // norm
            cur = build_norm(inpL,
                    model.layers[il].attn_norm, NULL,
                    LLM_NORM_RMS, il);
            cb(cur, "attn_norm", il);

            // self-attention
            {
                // compute Q and K and RoPE them
                ggml_tensor * Qcur = build_lora_mm(model.layers[il].wq, cur);
                Qcur = ggml_add(ctx0, Qcur, model.layers[il].bq);
                cb(Qcur, "Qcur", il);
........省略

我们可以看到,它这里会调用 build_attn_inp_kv_unified 等函数,进行计算图构建。

我们以其中 build_attn 函数为例子:

                cur = build_attn(inp_attn, gf,
                        model.layers[il].wo, model.layers[il].bo,
                        Qcur, Kcur, Vcur, nullptr, nullptr, 1.0f/sqrtf(float(n_embd_head)), il);

这段的实现在:llama.cpp/src/llama-graph.cpp at master · ggml-org/llama.cpp
llama graph

ggml_tensor * llm_graph_context::build_attn(
        llm_graph_input_attn_no_cache * inp,
        ggml_cgraph * gf,
        ggml_tensor * wo,
        ggml_tensor * wo_b,
        ggml_tensor * q_cur,
        ggml_tensor * k_cur,
        ggml_tensor * v_cur,
        ggml_tensor * kq_b,
        ggml_tensor * v_mla,
            float     kq_scale,
            int       il) const {
    GGML_UNUSED(n_tokens);

    // these nodes are added to the graph together so that they are not reordered
    // by doing so, the number of splits in the graph is reduced
    ggml_build_forward_expand(gf, q_cur);
    ggml_build_forward_expand(gf, k_cur);
    ggml_build_forward_expand(gf, v_cur);

    const auto & kq_mask = inp->get_kq_mask();

    ggml_tensor * q = q_cur;
    ggml_tensor * k = k_cur;
    ggml_tensor * v = v_cur;

    ggml_tensor * cur = build_attn_mha(gf, q, k, v, kq_b, kq_mask, v_mla, kq_scale);
    cb(cur, "kqv_out", il);

    if (wo) {
        cur = build_lora_mm(wo, cur);
    }
    if (wo_b) {
        //cb(cur, "kqv_wo", il);
    }
    if (wo_b) {
        cur = ggml_add(ctx0, cur, wo_b);
    }
    return cur;
}

可以看到,函数在这里调用了 ggml_build_forward_expand 。这也是文章[1]里提到的。

这个函数在llama.cpp/ggml/src/ggml.c at 6adc3c3ebc029af058ac950a8e2a825fdf18ecc6 · ggml-org/llama.cpp

void ggml_build_forward_expand(struct ggml_cgraph * cgraph, struct ggml_tensor * tensor) {
    ggml_build_forward_impl(cgraph, tensor, true);
}

static void ggml_build_forward_impl(struct ggml_cgraph * cgraph, struct ggml_tensor * tensor, bool expand) {
    if (!expand) {
        ggml_graph_clear(cgraph);
    }

    const int n0 = cgraph->n_nodes;
    ggml_visit_parents(cgraph, tensor);
    const int n_new = cgraph->n_nodes - n0;
    GGML_PRINT_DEBUG("%s: visited %d new nodes\n", __func__, n_new);
    if (n_new > 0) {
        // the last added node should always be starting point
        GGML_ASSERT(cgraph->nodes[cgraph->n_nodes - 1] == tensor);
    }
}

这是一个 从目标 tensor 出发,向上遍历其所有依赖,逐个收集到计算图中 的过程 —— 也就是构建前向图。

如果我们在这里插桩 printf("ggml_build_forward_expand: %s\n", tensor->name);,我们是可以看到整个模型的构建过程的:

ggml_build_forward_expand: Qcur-0
ggml_build_forward_expand: Kcur-0
ggml_build_forward_expand: Vcur-0
ggml_build_forward_expand: cache_k_l0 (view) (copy of Kcur-0)
ggml_build_forward_expand: cache_v_l0 (view) (copy of Vcur-0 (reshaped) (transposed))
ggml_build_forward_expand:  (permuted) (cont)
ggml_build_forward_expand: Qcur-1
ggml_build_forward_expand: Kcur-1
ggml_build_forward_expand: Vcur-1
ggml_build_forward_expand: cache_k_l1 (view) (copy of Kcur-1)
ggml_build_forward_expand: cache_v_l1 (view) (copy of Vcur-1 (reshaped) (transposed))
ggml_build_forward_expand:  (permuted) (cont)
ggml_build_forward_expand: Qcur-2
ggml_build_forward_expand: Kcur-2
ggml_build_forward_expand: Vcur-2
ggml_build_forward_expand: cache_k_l2 (view) (copy of Kcur-2)
ggml_build_forward_expand: cache_v_l2 (view) (copy of Vcur-2 (reshaped) (transposed))
ggml_build_forward_expand:  (permuted) (cont)
ggml_build_forward_expand: Qcur-3
ggml_build_forward_expand: Kcur-3
ggml_build_forward_expand: Vcur-3
ggml_build_forward_expand: cache_k_l3 (view) (copy of Kcur-3)
ggml_build_forward_expand: cache_v_l3 (view) (copy of Vcur-3 (reshaped) (transposed))
ggml_build_forward_expand:  (permuted) (cont)

然后我们可以看到 ggml_build_forward_expand 调用了 ggml_visit_parents 函数:
在ggml.c的5794行:

static void ggml_visit_parents(struct ggml_cgraph * cgraph, struct ggml_tensor * node) {
    // check if already visited
    if (ggml_hash_insert(&cgraph->visited_hash_set, node) == GGML_HASHSET_ALREADY_EXISTS) {
        return;
    } // 该节点已经有策略了,使用 `visited_hash_set` 做 visited check,避免重复访问。(类似于DFS的visited)

// 遍历所有输入节点(父节点)
    for (int i = 0; i < GGML_MAX_SRC; ++i) { 
        const int k =
            (cgraph->order == GGML_CGRAPH_EVAL_ORDER_LEFT_TO_RIGHT) ? i :
            (cgraph->order == GGML_CGRAPH_EVAL_ORDER_RIGHT_TO_LEFT) ? (GGML_MAX_SRC-1-i) :
            /* unknown order, just fall back to using i*/ i;
        if (node->src[k]) {   // `node->src[k]` 是该节点的第 k 个输入。
            ggml_visit_parents(cgraph, node->src[k]); // 递归
        }
    }
// 如果 `op == NONE` 且不是参数(param),说明这是常量或中间输入,不会参与梯度计算,视为 叶子节点。
// 加入 `cgraph->leafs[]` 或 `cgraph->nodes[]`
    if (node->op == GGML_OP_NONE && !(node->flags & GGML_TENSOR_FLAG_PARAM)) {
        // reached a leaf node, not part of the gradient graph (e.g. a constant)
        GGML_ASSERT(cgraph->n_leafs < cgraph->size);

        if (strlen(node->name) == 0) {
        // 如果该 tensor 没有名字,会给一个默认名如 `"leaf_0"`、`"node_1"`。
            ggml_format_name(node, "leaf_%d", cgraph->n_leafs); 
        }
        cgraph->leafs[cgraph->n_leafs] = node; // 加入
        cgraph->n_leafs++;
    } else {
        GGML_ASSERT(cgraph->n_nodes < cgraph->size);
        if (strlen(node->name) == 0) {
            ggml_format_name(node, "node_%d", cgraph->n_nodes);
        }
        cgraph->nodes[cgraph->n_nodes] = node; // 加入
        cgraph->n_nodes++;
    }
}

熟悉递归和图计算的朋友肯定知道,这段函数负责从目标 tensor 向上递归访问其依赖(父节点),并按拓扑顺序加入计算图。顺序保证了:父节点永远先于子节点被加入,从而实现隐式的拓扑排序

步骤 作用
visited_hash_set 防止重复访问
遍历 src[] DFS 递归访问所有依赖节点
区分叶子/计算节点 决定放入 leafs 还是 nodes
顺序插入 nodes[] 自动完成拓扑排序
cgraph->nodes 构成完整计算图的拓扑执行顺序

这个操作等价于 构建了一棵以该 tensor 为根的 DAG 依赖子图

这段函数对应了文章[1]的这张图片:


给node配置backend和memory

这里不止简单的直接配置,ggml使用了一个scheduler结构来分配计算节点实际计算的地方:
对于sched结构中的hash哈希映射表,我们可以大致理解为,它是一个将cgraph计算图中的每个“tensor”与“backend后端”进行连接的桥梁。在后续推理过程中,我们只需要拿到一个“tensor”便可以通过hash表直接查找到对应的“backend_id”。从而实现了每个tensor与其对应的“backend后端的绑定”。[1]

sched 存储着我们计算的大部分信息:

llama.cpp/ggml/src/ggml-backend.cpp ggml-org/llama.cpp

一会我们进行计算也会用到它。

struct ggml_backend_sched {
    bool is_reset; // true if the scheduler has been reset since the last graph split
    bool is_alloc;

    int n_backends;

    ggml_backend_t backends[GGML_SCHED_MAX_BACKENDS];
    ggml_backend_buffer_type_t bufts[GGML_SCHED_MAX_BACKENDS];
    ggml_gallocr_t galloc;

    // hash map of the nodes in the graph
    struct ggml_hash_set  hash_set;
    int                 * hv_tensor_backend_ids; // [hash_set.size]
    struct ggml_tensor ** hv_tensor_copies;      // [hash_set.size][n_backends][n_copies]

    int * node_backend_ids; // [graph_size]
    int * leaf_backend_ids; // [graph_size]

    int * prev_node_backend_ids; // [graph_size]
    int * prev_leaf_backend_ids; // [graph_size]

    // copy of the graph with modified inputs
    struct ggml_cgraph graph;

    // graph splits
    struct ggml_backend_sched_split * splits;
    int n_splits;
    int splits_capacity;

    // pipeline parallelism support
    int n_copies;
    int cur_copy;
    ggml_backend_event_t events[GGML_SCHED_MAX_BACKENDS][GGML_SCHED_MAX_COPIES];
    struct ggml_tensor * graph_inputs[GGML_SCHED_MAX_SPLIT_INPUTS];
    int n_graph_inputs;

    struct ggml_context * ctx;

    ggml_backend_sched_eval_callback callback_eval;
    void * callback_eval_user_data;

    char * context_buffer;
    size_t context_buffer_size;

    bool op_offload;

    int debug;
};

那么我们先继续回到 process_ubatch 函数下的 graph_build,开始连接各个图

llm_graph_result_ptr llama_context::graph_build(
                    ggml_context * ctx,
                     ggml_cgraph * gf,
              const llama_ubatch & ubatch,
                  llm_graph_type   gtype,
      const llama_memory_state_i * mstate) {
    return model.build_graph(
            {
                /*.ctx         =*/ ctx,
                /*.arch        =*/ model.arch,
                /*.hparams     =*/ model.hparams,
                /*.cparams     =*/ cparams,
                /*.ubatch      =*/ ubatch,
                /*.sched       =*/ sched.get(),
                /*.backend_cpu =*/ backend_cpu,
                /*.cvec        =*/ &cvec,
                /*.loras       =*/ &loras,
                /*.mstate      =*/ mstate,
                /*.cross       =*/ &cross,
                /*.n_outputs   =*/ n_outputs,
                /*.cb          =*/ graph_get_cb(),
            }, gf, gtype);
}

这里的model来自 llama_model。我们可以看看llama_context的构造函数:

llama.cpp/src/llama-context.cpp at 6adc3c3ebc029af058ac950a8e2a825fdf18ecc6 · HaibinLai/llama.cpp

llama_context::llama_context(
        const llama_model & model,
              llama_context_params params) :
    model(model),
    batch_allocr(std::make_unique<llama_batch_allocr>()) {
    LLAMA_LOG_INFO("%s: constructing llama_context\n", __func__);

    t_start_us = model.t_start_us;
    t_load_us  = model.t_load_us;

    const auto & hparams = model.hparams;
    ...

这里 build_graph 函数来自很多地方,不同的模型会使用不同的搭建参数。为了确保稳定性,我的方法是用profiler查看方法调用栈来寻找对应的调用后端。如果没有profiler,你可能要自己手动插桩试试。

我们可以看到,函数调用了 ggml_backend_sched_alloc_graph,其内部又调用了 ggml_backend_sched_split_graph 函数和 ggml_backend_sched_alloc_splits(sched) 函数。

bool ggml_backend_sched_alloc_graph(ggml_backend_sched_t sched, struct ggml_cgraph * graph) {
    GGML_ASSERT((int)sched->hash_set.size >= graph->n_nodes + graph->n_leafs);
    ggml_backend_sched_split_graph(sched, graph); // assigns backends to ops and splits the graph into subgraphs that can be computed on the same backend

    if (!ggml_backend_sched_alloc_splits(sched)) {
        return false;
    }
    sched->is_alloc = true;
    return true;
}

ggml_backend_sched_split_graph

函数 ggml_backend_sched_split_graph()作用是:

为 GGML 的计算图 (ggml_cgraph) 中的各个算子节点(op/tensor)分配合适的 backend(如 CPU、CUDA、Metal 等),并据此将计算图划分为多个可以在同一 backend 上运行的子图。

这里backend还是非常多的,我们看x86上CPU就有这么多:(llama.cpp/ggml/include/ggml-cpu.h llama.cpp

    // x86
    GGML_BACKEND_API int ggml_cpu_has_sse3       (void);
    GGML_BACKEND_API int ggml_cpu_has_ssse3      (void);
    GGML_BACKEND_API int ggml_cpu_has_avx        (void);
    GGML_BACKEND_API int ggml_cpu_has_avx_vnni   (void);
    GGML_BACKEND_API int ggml_cpu_has_avx2       (void);
    GGML_BACKEND_API int ggml_cpu_has_bmi2       (void);
    GGML_BACKEND_API int ggml_cpu_has_f16c       (void);
    GGML_BACKEND_API int ggml_cpu_has_fma        (void);
    GGML_BACKEND_API int ggml_cpu_has_avx512     (void);
    GGML_BACKEND_API int ggml_cpu_has_avx512_vbmi(void);
    GGML_BACKEND_API int ggml_cpu_has_avx512_vnni(void);
    GGML_BACKEND_API int ggml_cpu_has_avx512_bf16(void);
    GGML_BACKEND_API int ggml_cpu_has_amx_int8   (void);

这里,这段函数可以分成两段:构建与切分

我们来看它的构建部分源码。(删减版本)

// assigns backends to ops and splits the graph into subgraphs that can be computed on the same backend
static void ggml_backend_sched_split_graph(ggml_backend_sched_t sched, struct ggml_cgraph * graph) {
    // reset splits
    sched->n_splits = 0;
    sched->n_graph_inputs = 0;
    sched->is_reset = false;

    struct ggml_init_params params = {
        /* .mem_size =   */ sched->context_buffer_size,
        /* .mem_buffer = */ sched->context_buffer,
        /* .no_alloc =   */ true
    };

    ggml_free(sched->ctx);

    sched->ctx = ggml_init(params);
    if (sched->ctx == NULL) {
        GGML_ABORT("%s: failed to initialize context\n", __func__);
    }

    // pass 1: assign backends to ops with pre-allocated inputs
    for (int i = 0; i < graph->n_leafs; i++) {
        struct ggml_tensor * leaf = graph->leafs[i];
        int * leaf_backend_id = &tensor_backend_id(leaf);
        // do not overwrite user assignments
        if (*leaf_backend_id == -1) {
            *leaf_backend_id = ggml_backend_sched_backend_id_from_cur(sched, leaf);
        }
    }
     .....

    // pass 2: expand current backend assignments
    // assign the same backend to adjacent nodes
    // expand gpu backends (i.e. non last prio) up and down, ignoring cpu (the lowest priority backend)
    // thus, cpu will never be used unless weights are on cpu, or there are no gpu ops between cpu ops
    // ops unsupported by the backend being expanded will be left unassigned so that they can be assigned later when the locations of its inputs are known
    // expand gpu down
    {
        int cur_backend_id = -1;
        for (int i = 0; i < graph->n_nodes; i++) {
            struct ggml_tensor * node = graph->nodes[i];
            if (ggml_is_view_op(node->op)) {
                continue;
            }
            int * node_backend_id = &tensor_backend_id(node);
            if (*node_backend_id != -1) {
                if (*node_backend_id == sched->n_backends - 1) {
                    // skip cpu (lowest prio backend)
                    cur_backend_id = -1;
                } else {
                    cur_backend_id = *node_backend_id;
                }
            } else if (cur_backend_id != -1) {
                ggml_backend_sched_set_if_supported(sched, node, cur_backend_id, node_backend_id);
            }
        }
    }
    // expand gpu up
    {
     .....

    // pass 3: upgrade nodes to higher prio backends with compatible buffer types
    // if the tensor is already in the same buffer type (*) as another higher priority backend, we should move it there
    // however, we also need to verify that the sources are in compatible buffer types
    // (*) the actual requirement is more relaxed, the buffer type of the backend should be supported by all the users of this tensor further down the graph
    // however, this is slow to verify, so we have a more strict requirement that the buffer type is the same
    // this is not uncommon since multiple backends can use host memory, with the same buffer type (eg. BLAS and CPU)
    // additionally, set remaining unassigned nodes to the backend with the most supported inputs
    // only nodes that could not be assigned during expansion due to the backend not supporting the op should be unassigned at this point
    for (int i = 0; i < graph->n_nodes; i++) {
        struct ggml_tensor * node = graph->nodes[i];
        if (ggml_is_view_op(node->op)) {
            continue;
        }
        int * node_backend_id = &tensor_backend_id(node);
        if (*node_backend_id == -1) {
            // unassigned node: find the backend with the most supported inputs
            int n_supported_best = -1;
            for (int b = 0; b < sched->n_backends; b++) {
                if (ggml_backend_supports_op(sched->backends[b], node)) {
                    int n_supported = 0;
                    for (int j = 0; j < GGML_MAX_SRC; j++) {
                        struct ggml_tensor * src = node->src[j];
                        if (src == NULL) {
                            continue;
                        }
                        if ((tensor_backend_id(src) != -1 || tensor_backend_id(src->view_src) != -1) && ggml_backend_sched_buffer_supported(sched, src, b)) {
                            n_supported++;
                        }
                    }
                    if (n_supported > n_supported_best) {
                        n_supported_best = n_supported;
                        *node_backend_id = b;
                        SET_CAUSE(node, "3.best");
                    }
                }
            }
        } else {
             .....

我们首先先看计算图的构建

这段函数完成了调度器的核心逻辑

  1. 预处理:初始化调度器上下文
  2. backend 分配
    • 为所有节点(输入与计算节点)分配适当的 backend。
    • 以支持的 op 和 buffer 类型为依据。
  3. backend 扩散
    • 将 backend 从已分配节点传播到相邻未分配节点,形成 backend 连续的子图。
  4. 节点升级
    • 对未分配的节点选择最佳 backend。
    • 对已分配的节点尝试升级到更高优先级 backend(如果兼容)。

GPT总结了3个Pass各自做的事情。我们将在接下来用例子解释这些部分。

阶段 行为 目标
Pass 1 初始分配 backend 尽量分配给支持 op 的 backend
Pass 2 向前/向后扩展 backend 避免不必要的跨 backend
Pass 3 ✨提升节点到更高优先级 backend ✨✏️或分配尚未分配的节点 增加执行效率,减少 fallback、拷贝等代价

我们举个简单例子:
这是一个经典的计算图

它就会变成:

Input1  -> node0  (leaf)
Input2  -> node1  (leaf)
Add     -> node2  = Add(node0, const)
ReLU    -> node3  = ReLU(node2)
Mul     -> node4  = Mul(node1, const)
Concat  -> node5  = Concat(node3, node4)
Norm    -> node6  = Norm(node5)

对应文章[1]的这张图片:

Pass 1:预分配 backend(没有用户指定)

在加载完毕.gguf文件之后,我们只有一个weight tensor的列表(用struct ggml_tensor * src[GGML_MAX_SRC];结构体链接)但是我们还没有构建好,哪部分结构体是模型的哪个层,以及计算图的哪个层。因此我们需要将他们分配。

调度器查看每个 op 的类型,并基于 op 类型尝试分配:

  • Input1Input2:没有 backend,延后决定
  • Add, ReLU, Mul, Concat, Norm:都尝试调用 ggml_backend_sched_backend_id_from_cur() 自动选择 backend

假设 GPU 支持 Add/ReLU/Mul/Concat,不支持 Norm

node2 (Add)     -> GPU
node3 (ReLU)    -> GPU
node4 (Mul)     -> GPU
node5 (Concat)  -> GPU
node6 (Norm)    -> -1   // 暂未支持

Pass 2:backend 扩展

backend扩散 这一步非常关键。它在已经分配的节点下传播backend。比如节点1在GPU上,那么节点2就尽可能也是在GPU上。
同时 节点升级 这一步是这样的,如果一个节点当前被分配到了低优先级的 backend(例如 CPU),但高优先级 backend(例如 GPU)也支持这个 op 且 buffer 类型一致或兼容,就将它“提升”上去。

  • 因为 Add/ReLU/Mul/Concat 都在 GPU,调度器尝试将未分配的前后节点也拉入 GPU
  • Norm 不被 GPU 支持 → 留空
  • 所以扩展完如下:
Input1 (node0)     → GPU
Input2 (node1)     → GPU
Add     (node2)    → GPU
ReLU    (node3)    → GPU
Mul     (node4)    → GPU
Concat  (node5)    → GPU
Norm    (node6)    → -1   // 仍未分配

Pass 3:补分配未分配节点 / 提升已有节点 backend

  • Norm 被判断为只能被 CPU 支持
  • 查它的输入 Concat 是 GPU 的,调度器检查其 buffer 是否可以被 CPU 兼容
  • 如果兼容,就将 Norm 分配给 CPU

最终 backend 分配如下:

Input1   (node0) → GPU
Input2   (node1) → GPU
Add      (node2) → GPU
ReLU     (node3) → GPU
Mul      (node4) → GPU
Concat   (node5) → GPU
Norm     (node6) → CPU   ← fallback 分支

最终,图被划分为两个子图:

GPU子图

Input1 → Add → ReLU
Input2 → Mul
ReLU + Mul → Concat

CPU子图

Concat → Norm

合并图:

[GPU Subgraph]
  Input1     Input2
     |          |
    [Add]      [Mul]
       \       /
       [ReLU][ ]
           \  /
          [Concat]

[CPU Subgraph]
         |
       [Norm]

那么在这一段,我们就成功构建了计算图的基本部分,一些CPU的计算图,和GPU的计算图。但是,我们这里还没有构建CPU-GPU的数据传输。换句话说,我们还没有“把图连起来”。而这就是接下来第4步和第5步的主要工作:

Pass 名称 作用 关键行为
4 补全 view/source backend 保证所有 tensor 都有 backend - view 继承 source- source 继承当前 node
5 构造 split & 插入 copy 基于 backend 割图,插入 tensor 拷贝,构造分段调度子图 - backend 改变触发新 split- 不兼容时插入 copy- 填充 inputs、leafs 到新 graph


这张图是错误的但我懒得搞,因为它大概解释了这阶段在干什么。

pass 4: 继承 view / source 的 backend

这一阶段的目标是补全仍未设置 backend 的 tensor,利用其 view_src 或与之连接的其他 tensor:

步骤 动作 说明
1 遍历每个 tensor node,如果它是 view tensor 且 backend 未设置(-1),则继承 view_src 的 backend views 总是和源 tensor 使用相同 backend
2 遍历 node->src[] 中的每个 source,如果 backend 未设置: ① 若它也是 view,则继承其 view 源 ② 否则继承当前 node 的 backend 这一步确保 graph 中所有 tensor 都被赋予 backend,避免未定义状态

pass 5: 划分 split,插入必要的 tensor 拷贝

这一步骤的目标是根据 backend 分界划分执行 split,并插入必要的 tensor 拷贝,确保不同 backend 之间的数据传递可执行:

步骤 动作 说明
1 找到第一个非 view op 的 node,作为第一个 split 的开始 View op 不会触发实际计算,跳过
2 遍历后续 node,若 backend 改变或以下条件满足就触发新的 split: - source 是 weights 且 backend 不兼容 - split 的 inputs 已达最大值(避免过大 split) split 是同一 backend 上连续执行的子图,split 的切分影响调度与内存复用
3 如果当前 node 依赖的 source 在不同 backend,且不支持 buffer 共享: 则为 source 创建一个拷贝 tensor,放到 split 对应 backend 保证跨 backend 的 tensor 能正确传递
4 n_copies > 1 的场景构造每个 input tensor 的多个拷贝 适配多张卡、多次重复推理等场景
5 构造 sched->graph,为每个 split 填充执行子图与 leaf/input 最终生成多份小图,供 ggml-alloc 和执行器调度

这两个 pass(pass 4 和 pass 5)是 GGML 的后端调度器 ggml_backend_sched_assign() 中的关键阶段,目的是 补全 backend 分配、构造执行分割(split)并处理跨 backend 的 tensor 拷贝

ggml_backend_sched_alloc_splits

static bool ggml_backend_sched_alloc_splits(ggml_backend_sched_t sched) {
    bool backend_ids_changed = false;
    for (int i = 0; i < sched->graph.n_nodes; i++) {
        if (sched->node_backend_ids[i] != sched->prev_node_backend_ids[i] &&
            sched->bufts[sched->node_backend_ids[i]] != sched->bufts[sched->prev_node_backend_ids[i]]) {
            backend_ids_changed = true;
            break;
        }
    }
    if (!backend_ids_changed) {
        for (int i = 0; i < sched->graph.n_leafs; i++) {
            if (sched->leaf_backend_ids[i] != sched->prev_leaf_backend_ids[i] &&
                sched->bufts[sched->leaf_backend_ids[i]] != sched->bufts[sched->prev_leaf_backend_ids[i]]) {
                backend_ids_changed = true;
                break;
            }
        }
    }

    // allocate graph
    if (backend_ids_changed || !ggml_gallocr_alloc_graph(sched->galloc, &sched->graph)) {
        // the re-allocation may cause the split inputs to be moved to a different address
        // synchronize without ggml_backend_sched_synchronize to avoid changing cur_copy
        for (int i = 0; i < sched->n_backends; i++) {
            ggml_backend_synchronize(sched->backends[i]);
        }
        ggml_gallocr_reserve_n(sched->galloc, &sched->graph, sched->node_backend_ids, sched->leaf_backend_ids);
        if (!ggml_gallocr_alloc_graph(sched->galloc, &sched->graph)) {
            GGML_LOG_ERROR("%s: failed to allocate graph\n", __func__);
            return false;
        }
    }

    return true;
}

判断是否需要重新为计算图分配内存(allocation),如果需要就执行重新分配

在 GGML 中,计算图的 tensor 分配(内存地址)与它的 backend 分配情况(在哪个设备上计算) 强相关。
当 backend 分配发生改变时(比如 tensor 从 CPU 迁移到了 GPU),原来的内存分配可能就不再合适,因此需要

  • 重新 reserve memory
  • 重新 alloc memory
步骤 含义
① 检查 backend_id 是否变化 决定是否要重新分配内存
② 尝试直接 alloc_graph 如果失败进入 fallback 逻辑
③ 同步 backend 确保没有悬空计算使用旧内存
④ reserve + re-alloc 分配新的 tensor buffer 地址

计算图的计算计划 graph_compute & cplan

在计算的backend以及计算顺序都确定好后,我们就可以开始计算了!
在计算阶段的方法调用栈如下:

确定通信和计算

ggml_status llama_context::graph_compute(
            ggml_cgraph * gf,
                   bool   batched) {
    int n_threads        = batched ? cparams.n_threads_batch : cparams.n_threads;
    ggml_threadpool_t tp = batched ? threadpool_batch        : threadpool;

    if (backend_cpu != nullptr) {
        auto * reg = ggml_backend_dev_backend_reg(ggml_backend_get_device(backend_cpu));
        auto * set_threadpool_fn = (decltype(ggml_backend_cpu_set_threadpool) *) ggml_backend_reg_get_proc_address(reg, "ggml_backend_cpu_set_threadpool");
        set_threadpool_fn(backend_cpu, tp);
    }

    // set the number of threads for all the backends
    for (const auto & set_n_threads_fn : set_n_threads_fns) {
        set_n_threads_fn.second(set_n_threads_fn.first, n_threads);
    }

    auto status = ggml_backend_sched_graph_compute_async(sched.get(), gf);
    if (status != GGML_STATUS_SUCCESS) {
        LLAMA_LOG_ERROR("%s: ggml_backend_sched_graph_compute_async failed with error %d\n", __func__, status);
    }

    // fprintf(stderr, "splits: %d\n", ggml_backend_sched_get_n_splits(sched));

    return status;
}

那么这里最重要的是 auto status = ggml_backend_sched_graph_compute_async(sched.get(), gf);

enum ggml_status ggml_backend_sched_graph_compute_async(ggml_backend_sched_t sched, struct ggml_cgraph * graph) {
    if (!sched->is_reset && !sched->is_alloc) {
        ggml_backend_sched_reset(sched);
    }
    if (!sched->is_alloc) {
        if (!ggml_backend_sched_alloc_graph(sched, graph)) {
            return GGML_STATUS_ALLOC_FAILED;
        }
    }
    return ggml_backend_sched_compute_splits(sched);
}

ggml_backend_sched_compute_splits 函数负责在多后端(例如 CPU、GPU)之间协调数据传输和图计算,并确保正确的同步机制和异步调度。

 static enum ggml_status ggml_backend_sched_compute_splits(ggml_backend_sched_t sched) {
 ...
 // copy the input tensors to the split backend
        for (int j = 0; j < split->n_inputs; j++) {
            ggml_backend_t input_backend = ggml_backend_sched_get_tensor_backend(sched, split->inputs[j]);
            struct ggml_tensor * input = split->inputs[j];
            struct ggml_tensor * input_cpy = tensor_copy(input, split_backend_id, sched->cur_copy);

            if (input->flags & GGML_TENSOR_FLAG_INPUT) {
                // inputs from the user must be copied immediately to prevent the user overwriting the data before the copy is done
                if (sched->events[split_backend_id][sched->cur_copy] != NULL) {
                    ggml_backend_event_synchronize(sched->events[split_backend_id][sched->cur_copy]);
                } else {
                    ggml_backend_synchronize(split_backend);
                }
                ggml_backend_tensor_copy(input, input_cpy);
            } 
        }
    ...
        if (!sched->callback_eval) {
            enum ggml_status ec = ggml_backend_graph_compute_async(split_backend, &split->graph); //计算
            if (ec != GGML_STATUS_SUCCESS) {
                return ec;
            }
        } 

ggml_backend_sched_compute_splits 函数负责在多后端(例如 CPU、GPU)之间协调数据传输和图计算,并确保正确的同步机制和异步调度。在这里我们可以看到,每一步都会寻找当前计算节点的backend: ggml_backend_t input_backend = ggml_backend_sched_get_tensor_backend(sched, split->inputs[j]);

调用tensor进行计算

在把tensor复制过去后,就会调用ggml_backend_graph_compute_async(split_backend, &split->graph); 进行计算

enum ggml_status ggml_backend_graph_compute_async(ggml_backend_t backend, struct ggml_cgraph * cgraph) {
    return backend->iface.graph_compute(backend, cgraph);
}

backend->ifacebackend接口(interface)抽象层,在 GGML 中用于封装每种后端(如 CPU、CUDA、Metal 等)所支持的操作函数集合。你可以将它理解为一种 函数指针表(虚函数表),为不同的 backend 提供统一的调度接口。

由 backend 实现的计算图执行函数

每个后端会实现自己的版本,例如:

  • ggml_backend_cpu_graph_compute():CPU 上串行/并行计算子图;
  • ggml_backend_cuda_graph_compute():CUDA backend ;
  • 自定义后端(如 OpenCL、HIP)也可以挂自己的版本。

如果你是CPU

static enum ggml_status ggml_backend_cpu_graph_compute(ggml_backend_t backend, struct ggml_cgraph * cgraph) {
    struct ggml_backend_cpu_context * cpu_ctx = (struct ggml_backend_cpu_context *)backend->context;

    struct ggml_cplan cplan = ggml_graph_plan(cgraph, cpu_ctx->n_threads, cpu_ctx->threadpool);

    if (cpu_ctx->work_size < cplan.work_size) {
        delete[] cpu_ctx->work_data;
        cpu_ctx->work_data = new uint8_t[cplan.work_size];
        if (cpu_ctx->work_data == NULL) {
            cpu_ctx->work_size = 0;
            return GGML_STATUS_ALLOC_FAILED;
        }
        cpu_ctx->work_size = cplan.work_size;
    }
    cplan.work_data = (uint8_t *)cpu_ctx->work_data;

    cplan.abort_callback      = cpu_ctx->abort_callback;
    cplan.abort_callback_data = cpu_ctx->abort_callback_data;

    return ggml_graph_compute(cgraph, &cplan);
}

看码说话:先建立 ggml_graph_plan ,确定worksize,随后计算

Graph Plan

llama.cpp/ggml/src/ggml-cpu/ggml-cpu.c at e434e69183fd9e1031f4445002083178c331a28b · ggml-org/llama.cpp

ggml_graph_plan() 函数作用是:

🔧 为计算图 ggml_cgraph 的执行生成一个并行调度计划(plan),包括:
① 建议使用的线程数(考虑任务可并行度),
② 每个线程所需的中间缓冲区大小(work buffer),
③ 是否需要使用指定的线程池。

这个函数会决定我们的mem和线程数量,根据我们的具体任务。(比如一个任务要是只能用1个线程,那它就只能用一个线程)

struct ggml_cplan ggml_graph_plan(
          const struct ggml_cgraph * cgraph,
                               int   n_threads,
            struct ggml_threadpool * threadpool) {

    if (threadpool == NULL) {
        //GGML_PRINT_DEBUG("Threadpool is not specified. Will create a disposable threadpool : n_threads %d\n", n_threads);
    }
    if (n_threads <= 0) {
        n_threads = threadpool ? threadpool->n_threads_max : GGML_DEFAULT_N_THREADS;
    } // 初始化线程数

    size_t work_size = 0;

    struct ggml_cplan cplan;
    memset(&cplan, 0, sizeof(struct ggml_cplan));

    int max_tasks = 1;

    // thread scheduling for the different operations + work buffer size estimation
    for (int i = 0; i < cgraph->n_nodes; i++) {
        struct ggml_tensor * node = cgraph->nodes[i]; // 遍历图中的每个节点,分析

        const int n_tasks = ggml_get_n_tasks(node, n_threads);

        max_tasks = MAX(max_tasks, n_tasks);

        size_t cur = 0;

        if (!ggml_cpu_extra_work_size(n_threads, node, &cur)) {
            switch (node->op) {
                case GGML_OP_CPY:
                case GGML_OP_DUP:
                    {
                        if (ggml_is_quantized(node->type) ||
                            // F16 -> BF16 and BF16 -> F16 copies go through intermediate F32
                                ....
                        }
                    } break;
                case GGML_OP_ADD:
                case GGML_OP_ADD1:
                    {
                        if (ggml_is_quantized(node->src[0]->type)) {
                            cur = ggml_type_size(GGML_TYPE_F32) * node->src[0]->ne[0] * n_tasks;
                        }
                    } break;
                case GGML_OP_ACC:
                    {
                        if (ggml_is_quantized(node->src[0]->type)) {
                            cur = ggml_type_size(GGML_TYPE_F32) * node->src[1]->ne[0] * n_tasks;
                        }
                    } break;
..... // 各种算子
                case GGML_OP_FLASH_ATTN_BACK:
                    {
                        const int64_t    D = node->src[0]->ne[0];
                        const int64_t ne11 = ggml_up(node->src[1]->ne[1], GGML_SOFT_MAX_UNROLL);
                        const int64_t mxDn = MAX(D, ne11) * 2; // *2 because of S and SM in ggml_compute_forward_flash_attn_back
                        if (node->src[1]->type == GGML_TYPE_F32) {
                            cur  = sizeof(float)*mxDn*n_tasks; // TODO: this can become (n_tasks-1)
                            cur += sizeof(float)*mxDn*n_tasks; // this is overestimated by x2
                        } else if (node->src[1]->type == GGML_TYPE_F16) {
                            cur  = sizeof(float)*mxDn*n_tasks; // TODO: this can become (n_tasks-1)
                            cur += sizeof(float)*mxDn*n_tasks; // this is overestimated by x2
                        } else if (node->src[1]->type == GGML_TYPE_BF16) {
                            cur  = sizeof(float)*mxDn*n_tasks; // TODO: this can become (n_tasks-1)
                            cur += sizeof(float)*mxDn*n_tasks; // this is overestimated by x2
                        }
                    } break;

                case GGML_OP_CROSS_ENTROPY_LOSS:
                    {
                        cur = ggml_type_size(node->type)*(n_tasks + node->src[0]->ne[0]*n_tasks);
                    } break;
                case GGML_OP_COUNT:
                    {
                        GGML_ABORT("fatal error");
                    }
                default:
                    break;
            }
        }

        work_size = MAX(work_size, cur);
    }

    if (work_size > 0) {
        work_size += CACHE_LINE_SIZE*(n_threads);
    }

    cplan.threadpool = threadpool;
    cplan.n_threads  = MIN(max_tasks, n_threads);
    cplan.work_size  = work_size;
    cplan.work_data  = NULL;

    return cplan;
}

我用两张表总结:表1是执行流程,表2是各种算子

阶段 步骤 关键操作 说明
1️⃣ 初始化参数 判断是否给定 threadpool 决定是否使用外部线程池或临时创建
判断 n_threads 是否 > 0 若 ≤0,则根据 threadpool 或默认线程数设置
2️⃣ 遍历图节点 遍历每个节点 node for i in 0..n_nodes 主循环
获取 n_tasks ggml_get_n_tasks(node, n_threads) 估算该节点可并行任务数
更新最大并行任务数 max_tasks = max(max_tasks, n_tasks)
估算中间缓冲区大小 手动判断每种 op 需要多少中间 float[] 或其他类型内存
更新最大缓冲区 work_size = max(work_size, cur) 记录所有节点中最大的需求
3️⃣ 添加 Cache Padding 若需中间缓冲区 work_size += CACHE_LINE_SIZE * n_threads 避免 false sharing
4️⃣ 构造计划结构体 设置 cplan 字段 填写 n_threads / work_size / threadpool / work_data
5️⃣ 返回执行计划 return cplan
  • ggml_get_n_tasks() 根据张量维度决定该操作可分成多少个线程任务(例如矩阵按行/列分块);
  • ggml_type_size() 给出某类型(如 F32)的字节数;
  • 所有缓冲区估算只做“峰值”统计,因为 ggml_cgraph 是串行依次执行节点的。

各个算子

算子名 (op) 对应枚举 缓冲区估算逻辑
拷贝相关 GGML_OP_CPYGGML_OP_DUP 若量化或 BF16<->F16 转换,需额外 F32 缓冲区
加法相关 GGML_OP_ADDGGML_OP_ADD1 若 src 是量化类型,需解量化为 F32
累加相关 GGML_OP_ACC 若 src[0] 是量化类型,需 F32 缓冲区
Count equal GGML_OP_COUNT_EQUAL 每个任务需要类型大小的输出
矩阵乘法 GGML_OP_MUL_MAT 若类型不一致,需要转换为 vec_dot_type
矩阵乘 + ID GGML_OP_MUL_MAT_ID 需要多个结构辅助:row_mapping、counts、chunk 等
外积 GGML_OP_OUT_PROD 同样考虑解量化需求
softmax / rope GGML_OP_SOFT_MAXGGML_OP_ROPEGGML_OP_ROPE_BACK 每个线程一份 F32 临时数组
卷积反卷积 GGML_OP_CONV_TRANSPOSE_1D/2D 按输入权重类型判断是 F32/F16/BF16,缓存转换值
flash attention GGML_OP_FLASH_ATTN_EXT 每线程缓存 K/V(F32)
flash attention backward GGML_OP_FLASH_ATTN_BACK 缓存 S/SM,2×F32×max(D, N)×n_tasks(较大)
cross entropy loss GGML_OP_CROSS_ENTROPY_LOSS 输出 loss 和中间概率分布
无效算子 GGML_OP_COUNT 调用 GGML_ABORT("fatal error")

不同的后端会有相似的逻辑。

Compute 真正开始计算的地方

逻辑图如下图所示。这里它会展开一个大fork-join

llama.cpp/ggml/src/ggml-cpu/ggml-cpu.c at e434e69183fd9e1031f4445002083178c331a28b · ggml-org/llama.cpp

第3118行, CPU Compute

enum ggml_status ggml_graph_compute(struct ggml_cgraph * cgraph, struct ggml_cplan * cplan) {
    ggml_cpu_init();

    GGML_ASSERT(cplan);
    GGML_ASSERT(cplan->n_threads > 0);
    GGML_ASSERT(cplan->work_size == 0 || cplan->work_data != NULL);

    int n_threads                               = cplan->n_threads;
    struct ggml_threadpool * threadpool = cplan->threadpool;

    bool disposable_threadpool = false;

    if (threadpool == NULL) {
        //GGML_PRINT_DEBUG("Threadpool is not specified. Will create a disposable threadpool : n_threads %d\n", n_threads);
        disposable_threadpool = true;

        struct ggml_threadpool_params ttp = ggml_threadpool_params_default(n_threads);
        threadpool = ggml_threadpool_new_impl(&ttp, cgraph, cplan);
    } else {
        // Reset some of the parameters that need resetting
        // No worker threads should be accessing the parameters below at this stage
        threadpool->cgraph           = cgraph;
        threadpool->cplan            = cplan;
        threadpool->current_chunk    = 0;
        threadpool->abort            = -1;
        threadpool->ec               = GGML_STATUS_SUCCESS;
    }

#ifdef GGML_USE_OPENMP
    if (n_threads > 1) {
        #pragma omp parallel num_threads(n_threads)
        {
            #pragma omp single
            {
                // update the number of threads from the actual number of threads that we got from OpenMP
                n_threads = omp_get_num_threads();
                atomic_store_explicit(&threadpool->n_threads_cur, n_threads, memory_order_relaxed);
            }

            ggml_graph_compute_thread(&threadpool->workers[omp_get_thread_num()]);
        }
    } else {
        atomic_store_explicit(&threadpool->n_threads_cur, 1, memory_order_relaxed);
        ggml_graph_compute_thread(&threadpool->workers[0]);
    }
#else
    if (n_threads > threadpool->n_threads_max) {
        GGML_LOG_WARN("cplan requested more threads (%d) than available (%d)\n", n_threads, threadpool->n_threads_max);
        n_threads = threadpool->n_threads_max;
    }

    // Kick all threads to start the new graph
    ggml_graph_compute_kickoff(threadpool, n_threads);

    // This is a work thread too
    ggml_graph_compute_thread(&threadpool->workers[0]);
#endif

    // don't leave affinity set on the main thread
    clear_numa_thread_affinity();

    enum ggml_status ret = threadpool->ec;

    if (disposable_threadpool) {
        ggml_threadpool_free(threadpool);
    }

    return ret;
}
名称 类型 含义 结构层级
backend struct ggml_backend * 抽象的后端(可能是 CPU / GPU / Vulkan 等) 顶层
backend->context void * 指向具体后端的上下文信息(不定类型) 抽象层通用字段
cpu_ctx struct ggml_backend_cpu_context * CPU 后端的上下文结构体,包含线程池、缓冲区等具体内容 CPU 专用层

关键部位:

因此这里的结构就是,我们每过一个计算节点,就会有一个barrier保证同步,然后move on to 下一个节点:

至于这里OpenMP怎么用 #pragma omp parallel 的,请参见我上一篇文章:
gcc是如何实现OpenMP parallel for的-编译原理-Haibin's blog

ggml_barrier

我们同步实现的函数

void ggml_barrier(struct ggml_threadpool * tp) {
    int n_threads = atomic_load_explicit(&tp->n_threads_cur, memory_order_relaxed);
    if (n_threads == 1) {
        return;
    }

#ifdef GGML_USE_OPENMP
    #pragma omp barrier
#else
    int n_passed = atomic_load_explicit(&tp->n_barrier_passed, memory_order_relaxed);

    // enter barrier (full seq-cst fence)
    int n_barrier = atomic_fetch_add_explicit(&tp->n_barrier, 1, memory_order_seq_cst);

    if (n_barrier == (n_threads - 1)) {
        // last thread
        atomic_store_explicit(&tp->n_barrier, 0, memory_order_relaxed);

        // exit barrier (fill seq-cst fence)
        atomic_fetch_add_explicit(&tp->n_barrier_passed, 1, memory_order_seq_cst);
        return;
    }

    // wait for other threads
    while (atomic_load_explicit(&tp->n_barrier_passed, memory_order_relaxed) == n_passed) {
        ggml_thread_cpu_relax();
    }

    // exit barrier (full seq-cst fence)
    // TSAN doesn't support standalone fence yet, we use a dummy read-modify-write instead
    #ifdef GGML_TSAN_ENABLED
    atomic_fetch_add_explicit(&tp->n_barrier_passed, 0, memory_order_seq_cst);
    #else
    atomic_thread_fence(memory_order_seq_cst);
    #endif
#endif
}

这是我们每个线程要做的工作:

Compute thread

llama.cpp/ggml/src/ggml-cpu/ggml-cpu.c at 745aa5319b9930068aff5e87cf5e9eef7227339b · ggml-org/llama.cpp

static thread_ret_t ggml_graph_compute_thread(void * data) { // 这个是每个线程要工作的函数。
    struct ggml_compute_state * state = (struct ggml_compute_state *) data; 
    struct ggml_threadpool    * tp    = state->threadpool;

    const struct ggml_cgraph * cgraph = tp->cgraph; // 提前做好的graph
    const struct ggml_cplan  * cplan  = tp->cplan; // 执行计划。先执行图的哪个节点

    set_numa_thread_affinity(state->ith);

    struct ggml_compute_params params = {
        /*.ith       =*/ state->ith,
        /*.nth       =*/ atomic_load_explicit(&tp->n_threads_cur, memory_order_relaxed),
        /*.wsize     =*/ cplan->work_size,
        /*.wdata     =*/ cplan->work_data,
        /*.threadpool=*/ tp,
    };

    for (int node_n = 0; node_n < cgraph->n_nodes && atomic_load_explicit(&tp->abort, memory_order_relaxed) != node_n; node_n++) {
        // 从计算图中拿出一个任务
        struct ggml_tensor * node = cgraph->nodes[node_n];

        ggml_compute_forward(&params, node); // 在这个图上往前计算一步。

        // 如果任务已经全部执行完,由主线程决定退出计算图
        if (state->ith == 0 && cplan->abort_callback &&
                cplan->abort_callback(cplan->abort_callback_data)) {
            atomic_store_explicit(&tp->abort, node_n + 1, memory_order_relaxed);
            tp->ec    = GGML_STATUS_ABORTED;
        }

        // 如果线程完成了当前任务,那么barrier等等别人有没有完成。完成了就进入for的下一个循环
        if (node_n + 1 < cgraph->n_nodes) {
            ggml_barrier(state->threadpool);
        }
    }

    ggml_barrier(state->threadpool);

    return 0;
}
步骤 描述
初始化 每个线程根据编号设置亲和性,并准备好本线程的计算参数
图遍历 每个线程都顺序访问图中的节点(但只处理属于自己的部分)
前向计算 调用 ggml_compute_forward 分工完成本节点的计算
同步 每个节点计算后全体线程通过 barrier 同步
中止检查 线程0根据 abort_callback 检查是否中断执行
结束 所有线程完成所有节点后,最后 barrier 保证同步退出
        if (state->ith == 0 && cplan->abort_callback &&
                cplan->abort_callback(cplan->abort_callback_data)) {
            atomic_store_explicit(&tp->abort, node_n + 1, memory_order_relaxed);
            tp->ec    = GGML_STATUS_ABORTED;
        }

仅由 ith == 0 的主线程检查是否应该中止执行(调用 abort_callback);如果是,则写入 tp->abort 来让其他线程退出 loop。

vec.cpp核心算子

我们来看看其中一个核心算子

void ggml_vec_dot_f16(int n, float * GGML_RESTRICT s, size_t bs, ggml_fp16_t * GGML_RESTRICT x, size_t bx, ggml_fp16_t * GGML_RESTRICT y, size_t by, int nrc) {
    assert(nrc == 1);
    GGML_UNUSED(nrc);
    GGML_UNUSED(bx);
    GGML_UNUSED(by);
    GGML_UNUSED(bs);

    ggml_float sumf = 0.0;

#if defined(GGML_SIMD)
    const int np = (n & ~(GGML_F16_STEP - 1));

    GGML_F16_VEC sum[GGML_F16_ARR] = { GGML_F16_VEC_ZERO };

    GGML_F16_VEC ax[GGML_F16_ARR];
    GGML_F16_VEC ay[GGML_F16_ARR];

    for (int i = 0; i < np; i += GGML_F16_STEP) {
        for (int j = 0; j < GGML_F16_ARR; j++) {
            ax[j] = GGML_F16_VEC_LOAD(x + i + j*GGML_F16_EPR, j);
            ay[j] = GGML_F16_VEC_LOAD(y + i + j*GGML_F16_EPR, j);

            sum[j] = GGML_F16_VEC_FMA(sum[j], ax[j], ay[j]);
        }
    }

    // reduce sum0..sum3 to sum0
    GGML_F16_VEC_REDUCE(sumf, sum);

    // leftovers
    for (int i = np; i < n; ++i) {
        sumf += (ggml_float)(GGML_FP16_TO_FP32(x[i])*GGML_FP16_TO_FP32(y[i]));
    }
#else
    for (int i = 0; i < n; ++i) {
        sumf += (ggml_float)(GGML_FP16_TO_FP32(x[i])*GGML_FP16_TO_FP32(y[i]));
    }
#endif

    *s = sumf;
}

我们的线程池

threadpool

// Threadpool def
struct ggml_threadpool {
    ggml_mutex_t mutex;       // mutex for cond.var
    ggml_cond_t  cond;        // cond.var for waiting for new work

    struct ggml_cgraph * cgraph;
    struct ggml_cplan  * cplan;

    // synchronization primitives
    atomic_int n_graph;       // incremented when there is work to be done (i.e each graph)
    atomic_int GGML_CACHE_ALIGN n_barrier;
    atomic_int GGML_CACHE_ALIGN n_barrier_passed;
    atomic_int GGML_CACHE_ALIGN current_chunk; // currently processing chunk during Mat_Mul, shared between all the threads.

    // these are atomic as an annotation for thread-sanitizer
    atomic_bool stop;         // Used for stopping the threadpool altogether
    atomic_bool pause;        // Used for pausing the threadpool or individual threads
    atomic_int abort;         // Used for aborting processing of a graph

    struct ggml_compute_state * workers;   // per thread state
    int          n_threads_max; // number of threads in the pool
    atomic_int   n_threads_cur; // number of threads used in the current graph

    int32_t      prio;        // Scheduling priority
    uint32_t     poll;        // Polling level (0 - no polling)

    enum ggml_status ec;
};

总结:

我用一张图总结我们的所学: