现代CPU在L1缓存命中时取一个指针的延迟大概是4到5个周期,听上去很短,但在slab分配器这种每秒被调用几百万次的热路径上,每一次多余的指针解引用都意味着一次真实的地址依赖等待、一个可能没被占用的load端口,以及大量不必要的cacheline访问。我在做性能分析时发现,某个虚拟化场景下kfree和kmem_cache_free占到了总CPU时间的6%以上,perf top里的热点恰恰是一个看起来人畜无害的操作——从struct slab里取出slab_cache指针。
这篇文章聊聊我最近做的一个实验性优化:把slab分配路径上的一次指针解引用彻底拿掉,用一个更紧凑的cache_id索引加上连续内存池来替代。文章不会讲那种“看起来很美但实际没法落地”的方案,而是完整走一遍从问题定位、数据结构调整、补丁落地到实测数据的全过程。如果你是做内核内存管理、性能调优,或者对底层数据结构如何影响热路径感兴趣的读者,这篇应该能给你一些在别处看不到的细节。
1. 问题背景:slab分配热路径上那次“白付”的间接成本
1.1 一次指针解引用到底贵在哪里
先说清楚一个容易被低估的事实:现代CPU靠缓存和乱序执行掩盖了大量内存访问延迟,但有一个场景掩盖不了,就是地址依赖链。比如你去读slab->slab_cache,这个load要等slab这个指针本身算好;拿到slab_cache之后还要判断它是否为空、用来做参数传递、访问kmem_cache_cpu、执行memcg相关逻辑……每一步都卡在上一步的结果上。
如果目标地址恰好不在L1里,情况更糟。L2可能要14个周期,L3要40到60个周期,内存则是一百多个周期。slab分配器本身平均也就几十纳秒,一次额外的cache miss足以让分配性能直接下降一倍甚至更多。
你可能觉得“一个指针解引用怎么会不在L1”?但现实是,struct slab是分散在mem_map数组里的,系统内存越大,两个slab结构体在物理上离得越远。当分配器在大量slab之间切换时,slab_cache字段所在的cacheline未必还留在L1里。尤其在这个cacheline同时还被freelist、counters、s_mem等其他热字段共享的情况下,一个不必要地读取slab_cache的路径,随时可能把整个cacheline从冷态重新拉进缓存。
我们这个优化的目标很明确:不要让热路径为了拿到所属kmem_cache而额外访问一次内存。
1.2 SLUB释放路径的现场回顾
看一下SLUB默认释放路径的核心逻辑。以kfree为例,内核需要先从对象指针找到它所在的slab,然后从slab找到它属于哪个kmem_cache,再交给slab_free处理。
传统实现大致是这样一个流程:
c复制void kfree(const void *object)
{
struct slab *slab;
struct kmem_cache *s;
if (unlikely(ZERO_OR_NULL_PTR(object)))
return;
slab = virt_to_slab(object);
if (unlikely(!slab))
return;
/* 在这里读了 slab->slab_cache */
s = slab->slab_cache;
...
slab_free(s, slab, object, _RET_IP_);
}
问题就在slab->slab_cache这一句。virt_to_slab本身已经有了一次内存访问(从mem_map数组取struct slab),紧接着又对slab结构体内的一个指针字段做了一次load。虽然这两个字段大概率在同一个cacheline里,但严谨地说,这是一个独立的数据依赖步骤:slab要先算出来,然后才能去读取slab_cache这个偏移处的8字节。
更麻烦的是,struct slab里的slab_cache是一个64位指针,它在64位系统上占据8字节。这8字节放在结构体的临界区域,挤占了本可以放更热字段的空间。从cacheline布局角度看,非常不划算。
1.3 为什么专门盯着这一下
有人可能会说:kfree本来就要virt_to_slab,与其搞什么索引替换,不如顺手把slab_cache读出来,反正都在同一个结构体里。
理论上确实“顺路”,但实际编译器无法保证这一点。你读slab->slab_cache,CPU就是执行一个独立的load。即便目标cacheline已经在L1,这个load依然要占用一个load端口、依然要等待地址计算,依然可能成为乱序执行的瓶颈。而且slab_cache这个字段在SLUB的很多分支路径上被反复读取——分配时要读,释放时要读,slab_free_freelist_hook要读,memcg相关的挂载要读,把一个8字节指针换成一个可以从其他已读字段中直接解出的16位索引,收益并不小。
我在实际项目中看到过太多“反正都热,多读一次无所谓”的假设,最后性能profile出来全是这类隐形成本的累积。L1命中情况下单次load确实只要4个周期,但热路径上挤了二三十个load,积少成多就是可测的差异。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
