ggml介绍 (3) 上下文 (ggml_context)

170 阅读6分钟

在前两章中,我们学习了 张量 (ggml_tensor) 是如何表示数据的,以及如何通过 量化 (Quantization) 技术来为它们“瘦身”。我们知道,无论是模型的权重、输入数据还是计算的中间结果,都是以张量的形式存在的。

这就带来了一个问题:这么多张量,它们的内存应该如何管理?如果我们为每一个小张量都单独调用系统的内存分配函数(如 malloc),会非常频繁和低效,就像每次需要一颗螺丝钉都要跑一趟五金店一样。ggml 提供了一个优雅的解决方案,这就是本章的主角——ggml_context

什么是上下文?为什么需要它?

核心思想:你可以把 ggml_context 想象成一个专属的工作台或沙盒。

在你开始一个新项目(比如组装一个模型)之前,你不会把工具和零件散落在整个房子的地板上。一个更聪明的做法是,先清理出一张足够大的工作台。然后,你所有的工具、螺丝、木板等材料都放在这张工作台上。这样做的好处是:

  1. 整洁有序:所有相关的东西都在一个地方,易于管理。
  2. 高效快捷:拿取工具和材料只是在工作台上的一个转身,而不是在房间里到处跑。
  3. 清理方便:项目结束后,你只需要把整个工作台清理干净,而不用检查房子的每个角落。

ggml_context 就是这样一个“工作台”。它在任务开始时,一次性地向操作系统申请一大块连续的内存。之后,所有张量的创建和内存分配都在这块预留的内存中进行,通过简单的指针移动来完成。这避免了成千上万次昂贵的系统调用,极大地提高了效率。

如何搭建你的“工作台”

ggml 中,搭建工作台(创建上下文)是一个简单的两步过程:首先定义工作台的大小和属性,然后调用函数创建它。

1. 定义初始化参数

我们使用 ggml_init_params 结构体来描述我们想要的工作台。

#include "ggml.h"

// ... 在你的 main 函数中 ...

// 定义初始化参数
struct ggml_init_params params = {
    .mem_size   = 16 * 1024 * 1024, // 工作台大小:16 MB
    .mem_buffer = NULL,             // 我们不提供内存,让 ggml 自动分配
    .no_alloc   = false,            // 允许 ggml 为张量数据分配空间
};

这段代码告诉 ggml 我们需要一个多大的工作台:

  • mem_size: 这是最重要的参数,指定了我们要预分配的内存池大小,单位是字节。你需要估算一下你的任务大概需要多少内存。
  • mem_buffer: 如果你已经有了一块内存(比如来自另一个库),你可以把它的指针传给 ggml。但对于初学者来说,设为 NULL 是最简单的,ggml 会自动调用 malloc 为你分配这块内存。
  • no_alloc: 这是一个高级选项。保持 false 意味着 ggml 会同时为张量的“元数据”和“实际数据”在上下文中分配空间。我们暂时不需要关心 true 的情况。

2. 初始化上下文

有了参数,我们就可以调用 ggml_init 来创建上下文了。

// 使用上面的参数,初始化上下文
struct ggml_context * ctx = ggml_init(params);

if (!ctx) {
    // 如果创建失败(比如内存不足),程序应该处理这个错误
    fprintf(stderr, "ggml_init() 失败\n");
    return 1;
}

现在,ctx 就是我们通往那个 16MB 工作台的句柄(handle)。之后所有的操作,比如创建张量,都需要通过这个 ctx 来进行。

3. 在上下文中创建张量

还记得我们在第一章中创建张量的代码吗?现在你应该明白 ctx 参数的真正意义了。

// 在我们刚刚创建的上下文中,新建一个张量
// ctx 参数告诉 ggml:“请在这张工作台上为我留出空间”
struct ggml_tensor * my_tensor = ggml_new_tensor_1d(
    ctx,
    GGML_TYPE_F32,
    100
);

每当调用 ggml_new_tensor_* 函数时,ggml 都会在 ctx 管理的内存池中为这个新张量划出一块空间。

4. 清理工作台

当你的任务完成,不再需要这些张量时,你不需要一个一个地去释放它们。你只需要调用 ggml_free,它会释放整个上下文,包括当初分配的那一大块内存。

// 任务结束,清理整个工作台
ggml_free(ctx);

这就像把工作台上的所有东西一次性扫进垃圾桶,简单又高效!

深入幕后:上下文是如何工作的?

当你调用 ggml_init 时,ggml 会在内存中创建一个管理结构,我们可以把它想象成下面这样:

// 一个简化的 ggml_context 内部概念结构
struct ggml_context_internal {
    size_t mem_size;   // 工作台总大小
    void * mem_buffer; // 指向工作台内存的指针
    size_t used_mem;   // 已经使用了多少内存

    // ... 其他管理信息 ...
};

当你调用 ggml_new_tensor_1d 时,ggml 内部的执行流程大致如下:

sequenceDiagram
    participant User as 用户代码
    participant ggml as ggml 核心库
    participant ctx as 上下文 (ggml_context)

    User->>ggml: 调用 ggml_new_tensor_1d(ctx, ...);
    ggml->>ggml: 计算所需总空间<br>(元数据大小 + 数据大小)
    ggml->>ctx: 检查剩余空间是否足够?<br>(used_mem + total_size <= mem_size)
    alt 空间足够
        ctx->>ggml: 确认可以分配
        ggml->>ggml: 在 mem_buffer + used_mem 位置创建张量
        ggml->>ctx: 更新 used_mem 指针<br>(used_mem += total_size)
        ggml-->>User: 返回新张量的指针
    else 空间不足
        ggml-->>User: 返回 NULL (分配失败)
    end

这个流程的核心在于**指针碰撞(Pointer Bumping)**分配策略。ggml_context 内部维护一个指向“已使用内存末端”的指针(used_mem)。每次分配请求到来时:

  1. 计算需要分配的大小(包括对齐)。
  2. 检查剩余空间是否足够。
  3. 如果足够,就将这个“末端”指针向后移动相应的距离。
  4. 返回移动前指针的位置作为新分配内存的地址。

这个过程不涉及复杂的搜索或系统调用,速度快得惊人,几乎就是几次加法和一次比较操作。

下图直观地展示了内存布局的变化:

graph TD
    subgraph "初始状态 (ggml_init 后)"
        direction LR
        A["<b>mem_buffer</b><br>(0x1000)"] --> B("已使用: 0 MB / 16 MB")
    end

    subgraph "创建第一个张量 T1 (1MB)"
        direction LR
        C["<b>mem_buffer</b><br>(0x1000)<br>T1 (1MB)"] --> D("已使用: 1 MB / 16 MB")
    end

    subgraph "创建第二个张量 T2 (2MB)"
        direction LR
        E["<b>mem_buffer</b><br>(0x1000)<br>T1 (1MB) | T2 (2MB)"] --> F("已使用: 3 MB / 16 MB")
    end

    A --> |调用 ggml_new_tensor| C
    C --> |再次调用 ggml_new_tensor| E

这个设计使得 ggml 非常适合处理包含大量张量的计算任务,比如神经网络的推理。在推理开始前,我们创建一个上下文;在推理过程中,所有中间张量都在这个上下文中快速创建;推理结束后,一次性释放整个上下文。

总结

在本章中,我们认识了 ggml 的内存大管家——上下文 (ggml_context)

  • 它像一个工作台,通过预先分配一大块内存来避免频繁的系统调用。
  • 我们使用 ggml_init_paramsggml_init 来创建上下文。
  • 所有张量都必须在一个上下文中创建,这确保了内存管理的高效和有序
  • 分配内存采用快速的指针碰撞策略。
  • 任务结束后,只需调用 ggml_free 即可一次性释放所有资源

现在我们知道了如何表示数据(张量 Tensor),如何优化它们(量化),以及如何为它们高效地管理内存(上下文)。但我们还缺少关键的一环:如何将一系列的计算操作(比如 a * x + b)组织起来并执行呢?