ggml 介绍 (6) 后端 (ggml_backend)

在上一章 GGUF 上下文 (gguf_context) 中,我们学会了如何像图书管理员一样,从一个 GGUF 文件中读取模型的"索引卡"(元数据)和"书本内容"(张量数据),并将它们加载到内存中。现在,模型已经准备就绪,计算图也已构建。

但一个核心问题摆在我们面前:这些计算任务,究竟应该在哪里执行?是在我们电脑的中央处理器(CPU)上,还是可以利用强大的图形处理器(GPU)来加速?我们如何告诉 ggml 使用哪一个呢?

这就是 ggml_backend 发挥作用的地方。

什么是后端?为什么需要它?

核心思想 :你可以把 ggml_backend 想象成厨房里的厨师

你的厨房里可以有不同专长的厨师:

  • CPU 厨师:一位全能型厨师,擅长使用普通的炉灶。他能做所有菜,虽然对于某些特别复杂的菜肴(比如大规模矩阵乘法)可能速度不是最快的。
  • GPU 厨师 (如 CUDA, Metal):一位特级厨师,精通使用高性能的专业烤箱。他制作某些特定大餐(并行计算密集型任务)的速度快得惊人,但需要将食材(数据)预先放入他专用的烤箱托盘(显存)里。

ggml_backend 就是对这些不同计算硬件(厨师)的抽象。它接收你的计算图(菜谱),然后调动特定硬件(炉灶或烤箱)的能力来实际完成计算任务。这种设计最大的好处是,同一份"菜谱"可以交给任何一位"厨师"来完成,让你的代码无需修改就能在不同硬件上高效运行。

一个简单的开始:CPU 厨师

到目前为止,我们所有的例子其实都在不知不觉中使用了 CPU 后端。它是 ggml 的默认"厨师"。让我们显式地调用他,看看这个过程是怎样的。

我们将重用第四章中 y = a * x + b 的例子。假设我们已经创建了上下文 (ggml_context) ctx 和计算图 gf

1. 初始化一个 CPU 后端

我们可以通过一个简单的函数来获取 CPU 后端实例。

c 复制代码
// 在 ggml.h 中,但为了使用后端,最好包含 ggml-backend.h
#include "ggml-backend.h"

// ...

// 初始化 CPU 后端
// 这是最简单的后端,因为它直接在主内存上工作
ggml_backend_t cpu_backend = ggml_backend_cpu_init();
if (!cpu_backend) {
    // 错误处理
    return 1;
}

这行代码会返回一个代表 CPU 计算能力的后端句柄。

2. 在指定后端上执行计算

现在,我们不再使用通用的 ggml_graph_compute_with_ctx,而是使用 ggml_backend_graph_compute,并明确告诉它使用哪个"厨师"。

c 复制代码
// 明确指令 CPU 厨师(后端)来烹饪这份菜谱(计算图)
ggml_backend_graph_compute(cpu_backend, gf);

这个函数会接管计算图 gf,并使用 CPU 来执行其中的所有计算节点。因为我们的张量默认就创建在 CPU 可访问的内存中,所以这个过程非常直接。

3. 清理资源

任务完成后,记得"解雇"厨师。

c 复制代码
// 释放后端资源
ggml_backend_free(cpu_backend);

你看,使用 CPU 后端非常简单!但这也引出了一个更深刻的问题...

挑战:引入 GPU 厨师

如果我想用 GPU 来加速计算呢?比如我有一块支持 CUDA 的 NVIDIA 显卡。我能直接把 cpu_backend 换成 cuda_backend 就行了吗?

c 复制代码
// 概念代码,不完整
ggml_backend_t cuda_backend = ggml_backend_cuda_init(0); // 初始化第一个 CUDA 设备
// ...
ggml_backend_graph_compute(cuda_backend, gf); // 这样能行吗?

答案是:不行。问题出在数据存储上。

GPU 拥有自己独立的高速显存(VRAM)。它无法直接访问我们用标准 ggml_context 创建在主内存(RAM)中的张量。这就像我们的 GPU 特级厨师,他的专业烤箱有自己专用的、内置的冷藏抽屉(VRAM)。你必须先把食材从厨房的大冰箱(RAM)里拿出来,放进这些专用抽屉,他才能开始烹饪。

ggml 的后端系统完美地解决了这个问题。它不仅仅是关于"计算"的抽象,也是关于"内存"的抽象。

后端的关键概念

为了支持像 GPU 这样的异构硬件,ggml_backend 体系引入了几个关键概念:

  • 后端 (ggml_backend_t) : 代表一个"厨师",它知道如何在一个特定的硬件上执行计算。例如,ggml_backend_cpu_init() 返回 CPU 厨师,ggml_backend_cuda_init() 返回 CUDA 厨师。

  • 后端缓冲区类型 (ggml_backend_buffer_type_t) : 描述了与某个后端关联的内存种类。它就像是不同厨具的"材质说明"。CPU 后端的缓冲区类型描述的是普通系统内存,而 CUDA 后端的缓冲区类型描述的则是 GPU 显存。

  • 后端缓冲区 (ggml_backend_buffer_t) : 根据"材质说明"创建出来的一块实际的内存空间 。例如,一个 CUDA 后端缓冲区就是一块真正在 GPU 显存上分配的空间。这是我们将在下一章 后端缓冲区 (ggml_backend_buffer) 中深入探讨的主题。

下面的图表演示了这些概念如何协同工作,以在 GPU 上执行计算:

graph TD subgraph "CPU 域 (主内存 RAM)" A["常规 ggml_context
(内存池在 RAM 中)"] B["菜谱 (ggml_cgraph)"] end subgraph "GPU 域 (显存 VRAM)" C["CUDA 后端
(GPU 厨师)"] D["CUDA 后端缓冲区
(烤箱的专用抽屉)"] E["张量 T1, T2, ...
(放在抽屉里的食材)"] end B -- "交给" --> C C -- "要求食材放在" --> D D -- "存放" --> E C -- "使用...烹饪" --> E

要真正在 GPU 上运行,你需要:

  1. 初始化 CUDA 后端。
  2. 创建一个 CUDA 后端的缓冲区(在 VRAM 中分配内存)。
  3. 将模型的所有张量都分配在这个 GPU 缓冲区中。
  4. 最后,调用 ggml_backend_graph_compute,将计算图和 CUDA 后端传给它。

这样,ggml 就知道"菜谱"要交给 GPU 厨师,并且所有"食材"都已经在他专用的存储空间里准备好了。

深入幕后:万物皆为接口

ggml_backend 的魔力在于其基于**接口(Interface)**的设计。在 ggml 内部,ggml_backend 结构体包含一个名为 iface (interface) 的成员,它是一系列函数指针的集合。

c 复制代码
// 来自 ggml-backend-impl.h 的简化版接口定义
struct ggml_backend_i {
    // 获取后端名称,如 "CPU" 或 "CUDA"
    const char * (*get_name)(ggml_backend_t backend);

    // 释放后端
    void (*free)(ggml_backend_t backend);

    // 计算一个图(可以是异步的)
    enum ggml_status (*graph_compute)(ggml_backend_t backend, struct ggml_cgraph * cgraph);

    // 等待所有计算完成
    void (*synchronize)(ggml_backend_t backend);

    // ... 以及其他操作,如数据传输等
};

// 后端结构体本身
struct ggml_backend {
    struct ggml_backend_i iface; // 实现了上述接口的函数指针
    // ... 其他上下文信息
};

这个 ggml_backend_i 就像一份"厨师资格认证标准"。任何想成为 ggml 厨师的硬件(CPU、CUDA、Metal),都必须提供这一整套标准操作的实现。

当你调用 ggml_backend_graph_compute 时,它内部的实现极其简单:

c 复制代码
// 来自 ggml-backend.cpp
enum ggml_status ggml_backend_graph_compute(ggml_backend_t backend, struct ggml_cgraph * cgraph) {
    // 调用后端自己的 graph_compute 实现,然后等待它完成
    enum ggml_status err = backend->iface.graph_compute(backend, cgraph);
    ggml_backend_synchronize(backend);
    return err;
}

它只是一个分发器,根据你传入的 backend,调用其 iface 中对应的 graph_compute 函数。

  • 对于 CPU 后端 ,这个函数指针会指向一个循环遍历计算图节点并调用相应 CPU 计算核心(如 ggml_compute_forward_mul_mat_f32)的函数。
  • 对于 CUDA 后端 ,这个函数指针则会指向一个完全不同的函数,该函数负责将 ggml 操作转换为 CUDA 核函数,并将它们调度到 GPU 上执行。

下面是这个分发过程的示意图:

sequenceDiagram participant User as 用户代码 participant GGML_API as ggml_backend_graph_compute() participant CPU_Backend as CPU 后端实例 participant CUDA_Backend as CUDA 后端实例 participant CPU_Impl as CPU 计算函数 participant CUDA_Impl as CUDA 核函数启动器 User->>GGML_API: 调用 ggml_backend_graph_compute(cpu_backend, graph) GGML_API->>CPU_Backend: 调用 iface.graph_compute(...) CPU_Backend->>CPU_Impl: 遍历图节点, 执行CPU计算 User->>GGML_API: 调用 ggml_backend_graph_compute(cuda_backend, graph) GGML_API->>CUDA_Backend: 调用 iface.graph_compute(...) CUDA_Backend->>CUDA_Impl: 转换操作为CUDA Kernel并启动

这种设计是软件工程中强大的"策略模式"的体现,它使得 ggml 的核心逻辑与具体硬件的实现完全解耦,极大地增强了代码的可扩展性和可维护性。

总结

在本章中,我们认识了 ggml 的"大厨"------后端 (ggml_backend)

  • 后端是 ggml 中对不同计算硬件(CPU, GPU 等)的抽象
  • 它允许我们使用统一的 API (ggml_backend_graph_compute) 在不同的硬件上执行计算图。
  • 我们了解到,使用像 GPU 这样的高性能后端,不仅需要指定后端本身,还必须确保数据(张量)存放在该后端兼容的内存中(如 GPU 显存)。
  • ggml 通过一套函数指针接口 (ggml_backend_i) 来实现这种灵活性,使得添加对新硬件的支持变得更加容易。

我们已经知道,为了让 GPU 厨师工作,我们需要把食材放到他专用的存储空间里。那么,这些专用的存储空间------后端缓冲区------到底是什么?我们如何创建和管理它们,以便在 CPU 内存和 GPU 显存之间高效地移动数据呢?

相关推荐
楼田莉子24 分钟前
C++算法题目分享:二叉搜索树相关的习题
数据结构·c++·学习·算法·leetcode·面试
大千AI助手1 小时前
SWE-bench:真实世界软件工程任务的“试金石”
人工智能·深度学习·大模型·llm·软件工程·代码生成·swe-bench
大锦终1 小时前
【算法】模拟专题
c++·算法
方传旺1 小时前
C++17 std::optional 深拷贝 vs 引用:unordered_map 查询大对象性能对比
c++
天上的光2 小时前
17.迁移学习
人工智能·机器学习·迁移学习
Dontla2 小时前
Makefile介绍(Makefile教程)(C/C++编译构建、自动化构建工具)
c语言·c++·自动化
后台开发者Ethan2 小时前
Python需要了解的一些知识
开发语言·人工智能·python
猫头虎2 小时前
猫头虎AI分享|一款Coze、Dify类开源AI应用超级智能体快速构建工具:FastbuildAI
人工智能·开源·prompt·github·aigc·ai编程·ai-native
何妨重温wdys2 小时前
矩阵链相乘的最少乘法次数(动态规划解法)
c++·算法·矩阵·动态规划