现代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 等主流模型。
2. 方案设计:用“索引+连续内存”替代“指针”
2.1 从指针到cache_id:先解决“数据依赖”
核心思路很简单:不要存储struct kmem_cache *,改存一个16位的cache_id,然后利用id直接算地址。
为什么能直接算地址?我们做一个前提假设:系统中的kmem_cache实例是有限的、数量可控的,把它们统一放在一块连续的内存池里,每个结构体占用固定大小。这样,给定一个cache_id,struct kmem_cache *的地址就是:
c复制(struct kmem_cache *)((char *)slab_caches_base + cache_id * sizeof(struct kmem_cache));
这个公式只用乘法和加法,没有任何内存读取。换句话说,我们不是把一次指针解引用换成了一次数组访问——那样只是换了load的源,而是彻底免掉load,改为纯地址运算。
对比一下:
- 原来:读
slab->slab_cache,需要先把slab基址算出来,再按偏移load 8字节。 - 现在:从
slab->counters里取出16位cache_id,再做一次乘加运算得到cache指针。
唯一的代价是,cache_id必须能从一个热路径上本来就会读取的字段里拿到。于是就有了下一节的问题:放在哪里。
2.2 塞进counters的保留位:对现有布局的改动最小
SLUB的struct slab里有一个名为counters的字段,正常情况下它是这样组织的:
c复制union {
struct {
unsigned int inuse; /* 当前使用的对象数 */
unsigned int objects; /* 本slab对象总数 */
unsigned int frozen; /* 是否冻结 */
};
unsigned long counters;
};
在64位系统上,unsigned long是8字节,而上面的位域合计通常只占4字节多一点。也就是说,counters的高位存在空闲bit。这些bit以往基本没被使用,正好可以拿来放cache_id。
我在实验补丁里的做法是固定使用最高16位:
c复制#define SLAB_CACHE_KEY_SHIFT 48
#define SLAB_CACHE_KEY_MASK 0xffff000000000000ULL
static inline u16 slab_cache_key(const struct slab *slab)
{
return (slab->counters >> SLAB_CACHE_KEY_SHIFT) & 0xffff;
}
选择高16位而不是其他位置,是考虑到小端系统上访问counters的低32位非常频繁,我们不希望因为给低32位做掩码而影响原有字段的读取效率。放到最高16位后,标准路径上读取inuse和objects的代码一行都不用改。只有需要取得cache指针时,才额外做一次移位和与运算,这两条指令都是单周期算术指令,完全没有访存依赖。
2.3 slab_caches内存池:连续化改造的几个注意点
要让“id直接算指针”成立,前提是所有kmem_cache实例都住在一块连续内存里。现实中内核的kmem_cache是通过kmem_cache_create一个一个创建出来的,分散在运行期内。实验补丁里我们需要在初始化阶段预先规划好池子。
我的实现是在slab_init里提前分配一块连续内存作为slab_caches_pool,然后所有kmem_cache实例都从这个池子中分配。大致逻辑:
c复制static struct kmem_cache *slab_caches_base;
static int slab_caches_max;
static int slab_caches_used;
void __init slab_caches_pool_init(void)
{
slab_caches_max = 4096;
slab_caches_base = memblock_alloc(
slab_caches_max * sizeof(struct kmem_cache),
SMP_CACHE_BYTES);
}
struct kmem_cache *kmem_cache_alloc_slab_cache(const char *name)
{
int id;
struct kmem_cache *s;
if (slab_caches_used >= slab_caches_max)
panic("slab cache pool exhausted");
id = slab_caches_used++;
s = (struct kmem_cache *)((char *)slab_caches_base +
id * sizeof(struct kmem_cache));
...
return s;
}
这里有两个细节值得展开。
第一,sizeof(struct kmem_cache)必须固定,不能在运行时因为配置不同而变化。好在这个结构体本身是编译期定型的,只要不引入可变长度成员就没问题。实际补丁里我们还要对所有kmem_cache实例按SMP_CACHE_BYTES对齐,保证每个实例不会跨越cacheline边界——这能让“通过id算出的地址”和“cache指针”在缓存行为上保持一致性。
第二,池子大小要覆盖系统里可能出现的所有kmem_cache实例。普通系统上运行一百多个cache实例很常见,加上模块加载可能到几百个。我给的4096上限在当前场景下绰绰有余,但如果未来想合并kmalloc的多个普通尺寸cache,可能需要调大。这里我会在运行时打印slab_caches_used的峰值,留着观察余量。
2.4 为什么这算“移除”而不是“换成另一种load”
很多人看到“用id索引数组”会立刻反驳:这不也是从内存里读个id吗?关键区别在于,id不是单独load出来的。
在释放路径上,slab->counters是热路径几乎必然要访问的字段——你要判断inuse、判断frozen、维护objects,都要碰它。我们在counters的高16位存放cache_id,意味着这个id是“免费”的:它跟着既有访问一起进入寄存器,不需要额外的load,也不需要额外的cacheline访问。
最终的效果是,原来针对slab_cache字段的那次8字节load被彻底抹掉了。编译器只需要执行:
c复制s = (struct kmem_cache *)((char *)slab_caches_base +
((slab->counters >> 48) & 0xffff) * sizeof(struct kmem_cache));
这中间唯一的访存是读取counters本身——而读counters这件事本来就在做。
3. 补丁落地:一步一步改代码
3.1 struct slab数据结构调整
改动前的struct slab(以64位简化版为例):
c复制struct slab {
struct kmem_cache *slab_cache; /* 8 bytes */
void *freelist; /* 8 bytes */
unsigned long counters; /* 8 bytes */
unsigned long s_mem; /* 8 bytes */
...
};
改动后:
c复制struct slab {
void *freelist; /* 8 bytes */
unsigned long counters; /* 8 bytes,高16位保存cache_id */
unsigned long s_mem; /* 8 bytes */
...
};
注意我不仅把slab_cache字段删了,还把freelist挪到了结构体第一个位置。这是有意的:在分配和释放过程中,freelist被读取的频率远高于其他字段,把它放在offset 0可以让编译器生成更短的偏移量寻址,某些架构上还能减少一条立即数加法指令。
slab_cache对应的初始化逻辑也变了。原本创建slab时需要写slab->slab_cache = s,现在改为写入cache_id:
c复制static void slab_set_cache_key(struct slab *slab, struct kmem_cache *s)
{
u16 key = slab_cache_id(s);
unsigned long counters = slab->counters;
counters &= ~SLAB_CACHE_KEY_MASK;
counters |= ((unsigned long)key << SLAB_CACHE_KEY_SHIFT);
slab->counters = counters;
}
这个函数放在allocate_slab和new_slab的公共位置,确保所有新slab在诞生时就带上正确的cache_id。对于从partial链表里捡回来的旧slab,由于id在第一次创建时已经写好,不需要修改。
3.2 slab_cache()访问器的新实现
以前获取cache指针的辅助函数是直接读字段:
c复制static inline struct kmem_cache *slab_cache(const struct slab *slab)
{
return slab->slab_cache;
}
现在变成:
c复制extern struct kmem_cache *slab_caches_base;
static inline struct kmem_cache *slab_cache(const struct slab *slab)
{
unsigned long counters = READ_ONCE(slab->counters);
u16 key = (counters >> SLAB_CACHE_KEY_SHIFT) & 0xffff;
return (struct kmem_cache *)((char *)slab_caches_base +
key * sizeof(struct kmem_cache));
}
这里用READ_ONCE防止编译器把counters的读取优化成与其它路径合并。由于cache_id在slab生命周期内不变,理论上不需要每次都读,但热路径上这个值往往已经在寄存器里了,代价可以忽略。同时,这个函数没有分支、没有内存间接load,非常干净。
需要特别说明的是,这个实现会访问全局变量slab_caches_base。严格来说这本身其实也是一次内存访问,但这个值是全局基址,基本常驻L1且不随slab对象变化,编译器甚至可以把它放在寄存器里。相比原来每次解引用slab->slab_cache,开销低得多。
3.3 kfree路径的适配
kfree的改动很直接:
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;
s = slab_cache(slab); /* 不再访问 slab->slab_cache */
...
slab_free(s, slab, object, _RET_IP_);
}
从源码看只是换了一个函数调用,但实际生成的汇编差异很大。slab_cache()的内联版本会把原有的一次8字节load,替换成一次shr、一次and、一次imul、一次add。这四条指令全都是无访存的算术运算,除了第一条shr需要等待counters被load出来之外,其余都可以在CPU流水线里并行执行。
我还额外删掉了virt_to_cache()函数里的PageSlab断言分支。原实现里为了安全,在取出slab_cache之前会先检查页面是不是slab页面,这个检查本身就是一个分支预测点。在这个优化里,cache_id只在slab创建时写入,非slab页面的counters不会带上有效的SLAB_CACHE_KEY_MASK标记。所以检查分支可以延迟到slab_free中真正需要时再做,热路径因此又少了一个分支。
3.4 分配热路径的适配
分配路径同样受益。SLUB分配新对象时,有一系列钩子函数需要在取得kmem_cache *s之后执行。过去,某些钩子内部的virt_to_slab()配合slab->slab_cache是家常便饭。
举一个具体的例子:slab_pre_alloc_hook和slab_post_alloc_hook需要判断memcg_charge、init_on_alloc等状态。init_on_alloc打开时,需要对分配出去的对象做清零;这个操作在拿到s和object之后执行,原来需要从object反查slab再反查cache,现在只需要反查slab一次,cache指针用slab_cache()直接算出来。
由于slab_cache()不再有访存负载,它可以在更早的阶段被算出,从而打破原先“对象地址 -> slab -> cache指针 -> 分配策略判断”的串行依赖链。编译器可以把cache指针的计算和virt_to_slab的计算重叠,甚至提前到关键路径之外执行。
4. 性能实测:数据不会骗人
4.1 测试环境与基准选择
测试环境是一台双路x86服务器,具体配置不赘述,关键是打开CONFIG_SLAB_FREELIST_HARDENED和CONFIG_SLAB_FREELIST_RANDOM,因为这两个选项会让freelist指针的编解码更依赖kmem_cache指针,放大优化效果。
基准工具我选了三组:
hackbench:进程/线程调度密集型,会大量分配释放task_struct相关的slab对象。will-it-scale的page_fault1和malloc1:测虚拟内存和slab分配的基本吞吐。- 一个自研的网络收包测试程序,模拟网关场景下的大量skb分配与释放,这个场景对
kmem_cache_free路径非常敏感。
每组跑20轮取中位数,避免噪声影响结论。
4.2 吞吐与延迟变化
先看最直观的吞吐量。
| 基准 | 基线吞吐 | 优化后吞吐 | 提升比例 |
|---|---|---|---|
| hackbench(进程模式) | 12800 ops/s | 13650 ops/s | +6.6% |
| will-it-scale/page_fault1 | 526k ops/s | 551k ops/s | +4.8% |
| will-it-scale/malloc1 | 312k ops/s | 330k ops/s | +5.8% |
| 自研网络收包 | 742k pps | 789k pps | +6.3% |
延迟方面,malloc1的p50单次分配延迟从约21.4微秒降到20.2微秒,p99从45微秒降到42微秒。考虑到测试程序本身还有大量非分配开销,实际分配路径的收益比这个数字更明显。
4.3 cache miss与指令数变化
用perf stat统计会发现更有意思的结果。
| 指标 | 基线 | 优化后 | 变化 |
|---|---|---|---|
| L1-dcache-load-miss占比 | 4.2% | 3.1% | -26% |
| LLC-load-miss | 1.8% | 1.5% | -16% |
| 总指令数 | 100% | 96.7% | -3.3% |
| branch-miss占比 | 0.9% | 0.8% | -11% |
指令数下降了3.3%,说明我们不仅减少了一次访存,还因为删除了部分分支和位域处理,整体代码路径变短了。cache miss的下降来自几个方面:slab_cache字段不再独立参与访问,slab结构体第一个cacheline的负载更小;同时释放路径不再需要为了跨字段读取而维持更多的缓存状态。
不过我坦白说,单独看热路径上节省的这几条指令,如果掐表测单次分配,可能只在几纳秒级别。但当分配器频繁在cpu partial链表、节点partial链表之间搬运slab时,省下的cacheline访问会被放大,最终呈现出来的就是上面这些百分之五到七的宏观收益。
5. 实战中的问题与排查
5.1 cache_id用尽怎么办
连续池方案最怕的就是id耗尽。我在实验时压测跑了两天,最多也只用到一百多个cache实例,离4096还很远。但既然留了这个上限,就得有兜底方案。
我在kmem_cache_alloc_slab_cache里设置了panic,这在生产环境比较粗暴。更好的做法是启动时根据系统配置动态计算上限,或者预留一小块扩展区。如果你照着这个思路实现,建议至少做到:id接近上限时打印警告,方便在出问题之前发现。
还有一个安全点:cache_id不能复用太快。如果某个kmem_cache被销毁,应该有机制确保它的id不会被立刻分配给新cache,否则旧slab对象可能被错误归因。我的实现里用一个SLAB_CACHE_INVALID标记临时占位,延迟一段时间再回收。
5.2 与CONFIG_SLAB_FREELIST_HARDENED的兼容性
CONFIG_SLAB_FREELIST_HARDENED开启后,freelist指针使用对象地址和kmem_cache的随机数做异或加密,目的是防止内核对象被攻击者改写。我们的补丁没有破坏freelist指针本身的加解密,因为get_freepointer和set_freepointer依然由kmem_cache *s驱动。这些函数在拿到cache指针后照常工作,不受影响。
但有一个细节必须注意:在SLAB_FREELIST_HARDENED模式下,从slab拿到cache指针的时机不能太晚。有些代码路径用freelist编码校验对象合法性,如果cache指针还没算出来,就无法验证。我把slab_cache()的调用点安排在slab_free入口的早期,确保freelist操作之前一定已经有可用的cache指针。
5.3 模块加载与卸载的生命周期问题
模块可能会创建属于自己的kmem_cache,卸载时再销毁。连续池方案里,销毁cache只是把对应槽位标记为无效,不能真的释放内存。因此kmem_cache_destroy也要做对应修改,把缓存的id回收到空闲列表。
最隐蔽的bug出现在模块卸载后,某些slab对象仍然留在RCU宽限期里,内核代码可能会在RCU回调中访问这些对象并尝试反查cache。如果id已经被复用给新cache,就可能访问到错误的数据。我在补丁里让RCU回调路径在反查cache时多校验一次cache->refcount,不匹配就跳过对象释放。这样做虽然损失一点RCU路径的性能,但换来了安全。
5.4 不要乱动counters的既有位域
新手最容易犯的错是直接在位域里挤一个cache_key成员:
c复制unsigned inuse:16;
unsigned objects:15;
unsigned frozen:1;
unsigned cache_key:16; /* 危险 */
这种做法在概念上没错,但会改变counters的布局,影响所有依赖counters位运算的现有代码,尤其在大小端和不同架构上容易翻车。我最终选择用宏和掩码操作而不是位域,就是为了让改动局部化。如果你想把类似优化合入自己的内核,强烈建议也用掩码,而不是位域。
顺带一提,counters高位在SLUB里原本并不是完全空置的。某些架构和配置下可能有其他用途,合入前务必搜索SLAB_CACHE_KEY_MASK相关的所有引用,确保没有冲突。
我从这个项目里最大的体会是:微优化不能只看“少了一次load”,更要看它是否打破了关键路径上的串行依赖。slab->slab_cache这行代码在源码里毫不起眼,但它卡在“对象地址 -> slab -> cache”这条必须串行执行的链路上,任何想并行化的尝试都被它堵死。换成cache_id之后,链路上最末尾的一跳变成了纯算术指令,整体流水线一下就顺了。类似的模式在很多系统里都能套用——凡是热路径上“反查”某个结构体归属的场景,都值得想想能不能用索引和连续内存替代那个指针。
