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 模式,但实际进行搜索时,发现只有这一处位置。。。所以暂先不管它。
|
|
NUMA 初始化的调用栈:
main – llama_numa_init – numa_init_fn(实际是 ggml_numa_init)
ggml_numa_init 主要是根据 sysfs 文件系统填充 g_state 这个全局变量(保存了系统的 numa 信息,包括节点数量和 CPU 对应情况)
|
|
实际起作用的位置:
通过搜索可以发现,g_state 中保存的 numa_strategy 只在 set_numa_thread_affinity 函数中有使用到。其调用栈为:
ggml_graph_compute – ggml_graph_compute_thread – set_numa_thread_affinity
代码如下
|
|
这里会涉及到一个内核数据结构 cpu_set_t,趁此机会把它搞懂。
CPU 亲和性是指把进程和 CPU 核心进行绑定,可以提高 CPU 缓存的命中率,从而提高性能。
cpu_set_t 是 glibc 中把进程绑定到某个 CPU 上运行的相关数据结构,具体代码如下:
|
|
由此可见 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
结果
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
结果
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
结果
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
总结
实验数据和具体硬件、具体模型都有关系,总体看上去三个模式的区别不是很大,后续会再持续关注这个参数的影响。
本站不记录浏览量,但如果您觉得本内容有帮助,请点个小红心,让我知道您的喜欢。