【22token/s|又提升20%】榨干ktransformers的每一滴性能
前一阵子,我用2片9275f,24条6000MHz内存,以及一张4070tis,将DeepSeek R1 671b Q4跑出了18token/s的decode速度,已经达到了流畅的程度。同时我也在尝试运行Q8,但发现只能跑12-13token/s,比较膈应,属于能用但比较慢的程度。由于我本身是做DPDK相关开发的,对软件性能优化也有一定经验,所以我决定尝试对ktransformers进行一些外围优
前一阵子,我用2片9275f,24条6000MHz内存,以及一张4070tis,将DeepSeek R1 671b Q4跑出了18token/s
的decode速度,已经达到了流畅的程度。
同时我也在尝试运行Q8,但发现只能跑12-13token/s,比较膈应,属于能用但比较慢的程度。由于我本身是做DPDK相关开发的,对软件性能优化也有一定经验,所以我决定尝试对ktransformers进行一些外围优化。
为什么说是“外围”,因为ktransformers的核心是tensor运算。这一部分运算本身已经经过专家的优化,而我又对神经网络一知半解,更不可能进行架构和算法上的优化。
代码已经开源,在文章末尾可以找到Github链接。
废话不多说,直接开始吧。
一、编译带上符号信息
优化第一步自然是找热点函数。虽然不找基本上也能想到热点在于各种tensor运算函数以及承载它们的worker thread函数,但是万一能找到点奇怪的东西呢。
要perf就需要带上符号信息,所以第一步是改编译脚本,带上符号。
ktransformers/ktransformers_ext/CMakeLists.tx
中,在CMAKE_CXX_FLAGS
中增加-g
。不过这么改之后发现还是没法保留符号信息。观察生成的ninja脚本,发现它有一条命令会主动strip掉符号,所以还得增加set(CMAKE_STRIP "/usr/bin/true")
,直接跳过strip。
二、减少编译时间
ktransformers的编译脚本中会使用nvcc编译cuda代码,相比ext目录的c++来说,nvcc的编译时间实在是太长了。为了快速调试,我们最好只编译一次cuda代码,后续直接跳过该步骤。
ktransformers使用cython的setup.py作为构建入口,修改setup.py
文件的末尾,注释ops_module
这一行即可。
三、perf
准备好之后就可以上perf了。
perf后确实看到热点都在worker thread和mul_mat_xxxxxxx
里。但是却意外的发现,还有一个libc中获取当前时间的函数cpu占比非常高。
观察代码后可以看到,在worker thread中,每个循环都会获取一次时间,只有(当前时间 - 最后一次有任务时的时间)
超过阈值时,才会进入sleep,否则会执行不断重复的死循环。
虽然获取时间是一个开销很小的特殊系统调用,性能不会耗费过多,但是它毕竟还要做一次函数调用。
简单修改一下:
设置一个int idle
当有任务的时候,把idle
重置为0
,当没有任务的时候,执行++idle
。当idle超过某个阈值时,进入sleep。
那么怎么确定这个阈值呢?其实不需要太过精确,只要确保在推理运行时不进入sleep即可。一个简单的方法,就是把阈值设置为cpu Hz数除以10。atomic读取、循环跳转、自增、大小判断通常在10-100的cycle量级,使用cpu Hz数/10,即可保证大约100ms的间隔不会进入sleep,即使考虑睿频,也不会有太大差距。
当然,更简单的方式是,直接实验一下,实测如果配置为cpu Hz数,会经过1-2秒的时间才会进入sleep,所以使用cpu Hz/10即为100-200ms的时间才进入sleep。
四、巨页
模型会被完整地读到内存中,ktransformers的实现是没有使用巨页的,即使开启透明巨页,那每个page也只有2M。我们完全可以使用1G巨页分配内存,在几百G模型的量级上,1G巨页相比2M巨页,有着极大的内存管理优势。
由于ktransformers并非将gguf直接一次性读入内存,而是一个子层一个子层地读。如果为每一子层都分配一块1G巨页,那会产生极大地内存浪费,即使1.48T内存都不可能够用。
所以我们要编写一个简单的内存管理机制。由于ktransformers读取模型后,不需要释放模型,所以我们只要管理一个offset,表示剩余可用的内存区域即可。
我们还应该做得更严谨一点,由于tensor运算常常会利用avx512,所以我们应当尽可能让模型作64字节对齐。每次分配一块内存后,我们需要增加padding直到至少64字节对齐的位置。
不过这一步并不是必选的,因为每一层大小都是高位对齐的,比如某一层是2113929216 = 0x7e000000
。
五、减少启动时间
启动ktransformers后要加载模型,Q8的模型有600多G,要等10分多钟才能加载完成,这给性能调试带来了巨大的试错成本。而性能调试本来就要经常修改并perf,这种启动时间是不可接受的。
刚才我们把模型放到了巨页里,那很自然的可以想到,我们直接使用持久化巨页不就好了?
由于加载顺序是确定的,模型元数据也是确定的,所以每次加载时计算出的每层内存的offset也一定是确定的。那么我们只要加载一次模型,后续只需要计算offset,直接使用持久化的内存内容,而不需要真正地从硬盘读取模型到内存。
使用这样的方式,我们可以把启动时间从十几分钟降低到十几秒!这样我们后续的调整才是可行的。
顺带一提,ktransformers原本的读取机制,是:从硬盘读到node0的内存,再从硬盘读到node1的内存。而实际上简单的三行赋值代码就可以让加载时间降低一半:从硬盘读到node0的内存,再从node0复制到node1的内存即可。
六、核绑定
ktransformers只做了NUMA绑定,但核绑定显然性能会更好。在运行时我们可以观察到,除了llama.cpp的master/worker核之外,还有3个线程在运行。如果不进行绑核,经常会看到100% cpu的worker线程在几个核之间跳动,这显然会导致一些上下文切换的浪费。
在这一步,我一开始尝试使用自动核绑定,每个socket从第一个cpu开始进行绑定。实测下来确实有一点提升,但微乎其微,甚至只是让decode速度更稳定而没有实质性的提升。
接下来我决定在两个socket上分配不一样的线程数。
由于除了worker外,还有3个线程在运行,其中一定有至少一个会用于管理gpu任务。我的gpu在numa0上,所以我决定将3个线程放在socket0上,这样,socket0就会比socket1少3个worker。这样就能在socket1上多3个跑任务的线程。
这样就需要引入“配置文件”了。但通常来说最佳核心数分配完成后就不会再改变,所以做成配置文件是一件很费劲收益又很低的事情。所以我决定把“配置文件”直接hardcode到代码里。
很自然的,我们需要两个数组,数组下标是线程id,一个是“线程id到cpu core”的映射,另一个是“线程id到numa node id”的映射。
使用这种方式,确实略微地提升了一点点性能,毕竟多了3个worker核嘛。
七、numa aware work steal
ktransformers的代码里实现了“work steal”,就是说,如果某个worker执行得比别的worker快,那么它就会把原本别点worker要执行的内容给拿来自己执行。
跨socket的atomic运算非常耗时,我们要尽量减少。而work steal依赖一个自增atomic运算。让work steal不跨越numa理论上能略微的提升性能。
为了实现这一点,我们要在上一步的“配置文件”中再增加两个配置项:“线程id到work steal开始的id”的映射以及“线程id到work steal结束的id”的映射。
除此之外,我们还需要考虑false sharing这种问题。由于cpu是基于cache line标记的SHARE标记,所以我们必须将每个numa的任务表分到不同的cache line中才行。这里给每个numa分配一个单独的结构体存储numa内的任务信息,然后给结构体设置64字节的alignment即可。
八、优化分支预测
在ktransformers中,有一些操作仅首次进入loop会执行,但每个loop都需要判断一次是否需要执行。我们可以给它加上unlikely
标记,方便cpu做分支预测。这样一来,这次判断就等同于不存在。
不过这个简单的“优化”并没有什么实际提升。
九、完全禁用work steal
每个核几乎同时开始执行任务,由于我们做了核绑定,那么我们可以预料到它们应当几乎同时完成任务。那么我们完全没必要执行work steal,那只会白白浪费性能。
禁用后的确有些微的性能提升。
十、减少yield次数
由于Python性能属实不给力,构造http响应也是相当大的一部分性能开销,而且3.12版本有GIL,分配llama.cpp任务也会因构造http响应而阻塞。所以我们应当减少yield的次数。
我们可以规定一个阈值,只有输出的字符串长度达到这个阈值后才执行yield。
十一、提升cpu频率
在调试中,我发现llama.cpp的master核常常只能跑50-60%的CPU负载,从而这个核不会被睿频到4500MHz。
我观察到,每次这个核睿频到4500MHz时,token/s都会稍微高一点,而较低时则token/s会低一点。
所以我配置了performance模式,只要有任务运行,即使负载不是100%也会睿频到较高频率。
for cpu in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
echo "performance" | sudo tee $cpu 1>/dev/null
done
小结
上述优化,有的提升大,有的提升小,但是优化就是一点点抠性能的过程,特别是架构无法大改的时候。
经过上述所有优化,性能从18token/s
提升到了22token/s
,从“流畅”提升到了“快”!
而Q8也能跑16+token/s
。
相关代码也已开源:
force no think
顺带一提。在优化的过程中我也看了下force_think
的实现。
由于transformer架构会把每个输出的token都作为输入再执行一轮,所以如果说“强制”让大模型输出的第一个token是<think>
,则R1几乎100%的会进行思考。
而利用这个思路,如果不想让模型思考,我们只需要“强制”让大模型输出的前几个token是:
<think>
嗯,关于用户的这个问题,我应当按照指定的格式直接回答。
</think>
为了让R1“觉得”这是它自己生成的,我们应当按照R1常见的“开场白”组织上述文本。R1总是会思考“嗯,用户blablabla”。我尝试过,如果不以“嗯,用户blablabla”开头,则R1的思考过程中会显示它认为这段文本是用户的输入。
ktransformers并发
众所周知,现在ktransformers不支持并发。使用openwebui时,标题和tag生成都会使用chat completions接口调用大模型,所以如果启用这两项功能,就可能影响用户的实际使用。
更多推荐
所有评论(0)