jklincn


llama.cpp NUMA 优化源码解读与实验


之前理解较浅,目前做 CPU 和 GPU 混合推理,NUMA 设置的影响较大(16.8 token/s 提升到了 24.5 token/s),目前用的命令如下(CPU是2个numa节点,48核96线程)

numactl --physcpubind=0-47 --membind=0,1 llama-server --seed 0 --api-key sk-1234 --log-verbosity 0 --ctx-size 4096 --model /mnt/data/gguf/GLM-4.5-Air-Q8_0.gguf -ngl 999 -ot blk\.(1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|18|19|20|21|22|23|24|25|26|27|28|29|30|31|32|33|34|35|36|37|38|39|40|41)\.ffn_(?:up|down|gate)_exps.*=CPU --numa numactl -t 48

文章暂时没时间更新,下文内容没什么价值,见谅。

欢迎参考我的项目:https://github.com/jklincn/llama.moe


本文主要是探索 llama.cpp 中不同的 NUMA 优化策略带来的实际性能影响。

选用版本:b4873(2025-03-02)

参考文档:https://github.com/ggml-org/llama.cpp/blob/master/examples/main/README.md

支持的 NUMA 策略简介

从文档可以看到有 3 种策略:

(以下是原文翻译)

  • --numa distribute:将线程以相等比例分配到每个 NUMA 节点的核心上。这将在系统的所有核心之间分散负载,利用所有内存通道,但代价是可能需要内存通过节点间的慢速链接传输。
  • --numa isolate:将所有线程固定到程序启动时所在的 NUMA 节点。这限制了可以使用的核心数量和内存量,但保证所有内存访问都保持在该 NUMA 节点的本地范围内。
  • --numa numactl:将线程固定到通过 numactl 工具启动程序时传递给程序的 CPUMAP。这是最灵活的模式,允许任意的核心使用模式,例如使用一个 NUMA 节点上的所有核心,以及在第二个节点上使用足够的核心来饱和节点间内存总线。

这些标志尝试在 NUMA 系统上进行优化。当前包括上述策略之一,以及为 mmap 禁用预取和预读。后者导致映射的页面在首次访问时才进行页面错误处理,而不是一次全部处理,并且结合将线程固定到 NUMA 节点,更多的页面最终会在使用它们的 NUMA 节点上。请注意,如果模型已经在系统页面缓存中,例如因为之前没有使用此选项运行过,除非首先清除页面缓存,否则这将几乎没有效果。这可以通过重启系统来完成,或者在 Linux 上以 root 身份向 ‘/proc/sys/vm/drop_caches’ 写入 ‘3’。

(以下是通俗解释)

  • distribute:均匀分布模式。线程均匀分布在所有 NUMA 节点上的核心。好处是把系统的所有计算资源和内存通道都用上了,坏处是可能会导致线程访问内存时跨 NUMA 节点,从而引入额外延迟。
  • isolate:隔离模式。程序只会使用启动时所在的 NUMA 节点上的计算和内存资源。好处是保证所有内存访问都是本地的,避免了跨 NUMA 访问的延迟;坏处是资源利用率不高。
  • numactl:手动控制模式。线程的分布由 numactl 工具指定,允许用户手动指定 CPU 绑定策略,即手动指定要使用的计算资源和内存资源(通道)。

额外优化措施:主要是禁用了 mmap 的预取和预读,因为在 NUMA 系统中,可能会出现模型提前加载到 NUMA 0 节点上的内存,但实际的计算发生在 NUMA 1 节点,这就会导致跨 NUMA 节点访问的问题。所以禁用了预取和预读,避免数据提前加载到错误的 NUMA 节点,确保数据在首次访问时加载到正确的 NUMA 位置,从而减少跨 NUMA 访问。如果之前已经加载过模型,可以重启或使用上述命令清除系统缓存。

源码解读

从代码上来看,好像多一种 mirror 模式,但实际进行搜索时,发现只有这一处位置。。。所以暂先不管它。

1
2
3
4
5
6
7
8
9
// llama.cpp/ggml/include/ggml-cpu.h
    enum ggml_numa_strategy {
        GGML_NUMA_STRATEGY_DISABLED   = 0,
        GGML_NUMA_STRATEGY_DISTRIBUTE = 1,
        GGML_NUMA_STRATEGY_ISOLATE    = 2,
        GGML_NUMA_STRATEGY_NUMACTL    = 3,
        GGML_NUMA_STRATEGY_MIRROR     = 4,
        GGML_NUMA_STRATEGY_COUNT
    };

NUMA 初始化的调用栈:

mainllama_numa_initnuma_init_fn(实际是 ggml_numa_init)

ggml_numa_init 主要是根据 sysfs 文件系统填充 g_state 这个全局变量(保存了系统的 numa 信息,包括节点数量和 CPU 对应情况)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct ggml_numa_node {
    uint32_t cpus[GGML_NUMA_MAX_CPUS]; // hardware threads on this node
    uint32_t n_cpus;
};

struct ggml_numa_nodes {
    enum ggml_numa_strategy numa_strategy;
    struct ggml_numa_node nodes[GGML_NUMA_MAX_NODES];
    uint32_t n_nodes;
    uint32_t total_cpus; // hardware threads on system
    uint32_t current_node; // node on which main process is execting
#if defined(__gnu_linux__)
    cpu_set_t cpuset; // cpuset from numactl
#else
    uint32_t cpuset; // no NUMA support outside of Linux at this time. Use a portable datatype
#endif
};

struct ggml_state {
    struct ggml_numa_nodes numa;
};

static struct ggml_state g_state = {0};

实际起作用的位置:

通过搜索可以发现,g_state 中保存的 numa_strategy 只在 set_numa_thread_affinity 函数中有使用到。其调用栈为:

ggml_graph_computeggml_graph_compute_threadset_numa_thread_affinity

代码如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// Android's libc implementation "bionic" does not support setting affinity
#if defined(__gnu_linux__)
static void set_numa_thread_affinity(int thread_n) {
    // 如果系统不支持NUMA,直接返回
    if (!ggml_is_numa()) {
        return;
    }

    int node_num;
    int rv;

    // 获得合适的集合分配长度
    size_t setsize = CPU_ALLOC_SIZE(g_state.numa.total_cpus);

    // 根据不同的NUMA策略选择不同的处理方式
    switch(g_state.numa.numa_strategy) {
        case GGML_NUMA_STRATEGY_DISTRIBUTE:
            // 均匀分布模式:线程均匀分布到各NUMA节点
            // run thread on node_num thread_n / (threads per node)(这个注释有问题)
            // 将线程分配到节点号为 thread_n % 节点数 的节点上。
            // 举例:当系统有 4 个numa节点时的情况
            // thread 0 -> node 0
            // thread 1 -> node 1
            // thread 2 -> node 2
            // thread 3 -> node 3
            // thread 4 -> node 0
            node_num = thread_n % g_state.numa.n_nodes;
            break;
        case GGML_NUMA_STRATEGY_ISOLATE:
            // 隔离模式:线程只运行在当前节点
            node_num = g_state.numa.current_node;
            break;
        case GGML_NUMA_STRATEGY_NUMACTL:
            // 手动控制模式:使用 numactl 提供的 CPU 设置
            // 此处的 g_state.numa.cpuset 是初始化时获取的
            rv = pthread_setaffinity_np(pthread_self(), setsize, &g_state.numa.cpuset);
            if (rv) {
                fprintf(stderr, "warning: pthread_setaffinity_np() failed: %s\n",strerror(rv));
            }
            return;
        default:
            return;
    }

    // 获取选定NUMA节点的信息
    struct ggml_numa_node * node = &g_state.numa.nodes[node_num];
    // 分配 CPU 集合并初始化为空
    cpu_set_t * cpus = CPU_ALLOC(g_state.numa.total_cpus);
    CPU_ZERO_S(setsize, cpus);

    // 将选定节点的所有CPU添加到集合中
    for (size_t i = 0; i < node->n_cpus; ++i) {
        CPU_SET_S(node->cpus[i], setsize, cpus);
    }

    // 使用 pthread_setaffinity_np 函数设置当前线程的 CPU 亲和性
    rv = pthread_setaffinity_np(pthread_self(), setsize, cpus);
    if (rv) {
            fprintf(stderr, "warning: pthread_setaffinity_np() failed: %s\n", strerror(rv));
    }

    // 释放内存
    CPU_FREE(cpus);
}

这里会涉及到一个内核数据结构 cpu_set_t,趁此机会把它搞懂。

CPU 亲和性是指把进程和 CPU 核心进行绑定,可以提高 CPU 缓存的命中率,从而提高性能。

cpu_set_t 是 glibc 中把进程绑定到某个 CPU 上运行的相关数据结构,具体代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/* Size definition for CPU sets.  */
#define __CPU_SETSIZE   1024                   // 支持的最大CPU数量
#define __NCPUBITS  (8 * sizeof (__cpu_mask))  // 每个掩码元素能表示的位数

/* Type for array elements in 'cpu_set_t'.  */
typedef __CPU_MASK_TYPE __cpu_mask;            // 掩码的基本类型,通常是 unsigned long

/* Basic access functions.  */
// 计算 CPU 编号对应的数组索引,即确定某个 CPU 的信息存储在数组的哪个元素中
#define __CPUELT(cpu)   ((cpu) / __NCPUBITS)
// 生成一个位掩码,只有表示指定 CPU 的那一位被设置为 1。
#define __CPUMASK(cpu)  ((__cpu_mask) 1 << ((cpu) % __NCPUBITS))

/* Data structure to describe CPU mask.  */
typedef struct
{
  __cpu_mask __bits[__CPU_SETSIZE / __NCPUBITS]; // 位图数组
} cpu_set_t;

由此可见 cpu_set_t 是一个位图,位图的每个位表示一个 CPU。

假设 __cpu_mask 是 64 位 unsigned long

  • 每个 __cpu_mask 可以表示 64 个 CPU(__NCPUBITS = 64)
  • 数组大小为 1024 / 64 = 16 个元素
  • CPU 0 对应 __bits[0] 的第 0 位
  • CPU 63 对应 __bits[0] 的第 63 位
  • CPU 64 对应 __bits[1] 的第 0 位
  • 以此类推…

具体的 CPU_ZERO_S/CPU_SET_S / CPU_CLR_S 宏实现这里就不展开了,只需要知道他们分别用于初始化、设置和清除即可。这里的后缀 _S 主要是指定长度,这样的 cpu_set_t 是一个可变的数组,适用于大于 1024 个 CPU 的情况。

对于均匀分布模式和隔离模式来说,switch 后面的语句都是把选定的 numa 节点上的所有的 CPU 核心都加入到 CPU 集合中,确保调度时不会跑到另外一个 numa 节点上。

对于手动控制来说,由于在初始化时已经使用 ggml_get_numa_affinity 获取到了由 numactl 设置的 cpu_set_t,因此把这个变量传递到 pthread_setaffinity_np 即可。

pthread_setaffinity_np 是 glibc 中的接口,后续猜测可能会调用 sched_setaffinity 等系统调用来和内核进行交互。这部分就是 Linux 内核的知识,在此不展开。

实验数据

构建命令

cmake -B build
cmake --build build --config Release -j 16

运行设置

huggingface-cli download Qwen/Qwen2-7B
python convert_hf_to_gguf.py --outfile qwen2-7B.gguf --outtype bf16 /home/li
n/.cache/huggingface/hub/models--Qwen--Qwen2-7B/snapshots/453ed1575b739b5b03ce3758b23befdb0967f40e

model_path="qwen2-7B.gguf"
prompt='Please help me write a paragraph introducing Beijing.'
n_predict=200

默认模式(不进行设置)

llama-cli --no-mmap --mlock -m $model_path --prompt "$prompt" --n-predict $n_predict

结果

image-20250328161129669

llama_perf_sampler_print:    sampling time =      21.66 ms /   180 runs   (    0.12 ms per token,  8311.02 tokens per second)
llama_perf_context_print:        load time =    1915.30 ms
llama_perf_context_print: prompt eval time =     269.11 ms /    27 tokens (    9.97 ms per token,   100.33 tokens per second)
llama_perf_context_print:        eval time =   17509.84 ms /   152 runs   (  115.20 ms per token,     8.68 tokens per second)
llama_perf_context_print:       total time =   59699.12 ms /   179 tokens

均匀分布模式(distribute)

llama-cli --no-mmap --mlock -m $model_path --prompt "$prompt" --n-predict $n_predict --numa distribute

结果

image-20250328161042988

llama_perf_sampler_print:    sampling time =      18.13 ms /   202 runs   (    0.09 ms per token, 11139.30 tokens per second)
llama_perf_context_print:        load time =     571.69 ms
llama_perf_context_print: prompt eval time =     231.60 ms /    27 tokens (    8.58 ms per token,   116.58 tokens per second)
llama_perf_context_print:        eval time =   19320.91 ms /   174 runs   (  111.04 ms per token,     9.01 tokens per second)
llama_perf_context_print:       total time =   27429.34 ms /   201 tokens

隔离模式(isolate)

llama-cli --no-mmap --mlock -m $model_path --prompt "$prompt" --n-predict $n_predict --numa isolate

结果

image-20250328161016066

llama_perf_sampler_print:    sampling time =      22.27 ms /   219 runs   (    0.10 ms per token,  9833.86 tokens per second)
llama_perf_context_print:        load time =     655.23 ms
llama_perf_context_print: prompt eval time =     262.45 ms /    27 tokens (    9.72 ms per token,   102.88 tokens per second)
llama_perf_context_print:        eval time =   21776.99 ms /   191 runs   (  114.02 ms per token,     8.77 tokens per second)
llama_perf_context_print:       total time =   30476.31 ms /   218 tokens

总结

实验数据和具体硬件、具体模型都有关系,总体看上去三个模式的区别不是很大,后续会再持续关注这个参数的影响。


本站不记录浏览量,但如果您觉得本内容有帮助,请点个小红心,让我知道您的喜欢。