1. 收到这个Patch时,我第一反应是:还有哪次解引用能省?
先说结论,这个标题真正值钱的部分,不在“slab allocations”本身,而在“pointer dereference”到底指哪一次访问。很多人一听说slab分配器优化,第一反应就是“减少lock contention”或者“优化percpu partial列表”,但这次要抠的是更底层的一件事:每次从kmem_cache里取一个对象,CPU为了知道“下一个空闲对象在哪”,都必须额外去读一次刚刚取出来的那个对象的内存首部。这一个读取动作,就是标题里说的pointer dereference。
我在代码评审里看到这个思路的第一反应是:内核的SLUB分配器已经这么成熟了,kmalloc那条路走了二十年,怎么还会有这么基础的间接访问可以移除?直到我把slab_alloc_node这条路径重新捋了一遍才意识到,问题出在free list的载体上。SLUB里,空闲对象之间是用指针串成链表的,而这些指针不是放在某个单独的管理结构里,而是直接存放在对象本身的内存中。这意味着,分配器把对象A交给你之前,必须先从A的内存里读出A中记录的next指针,才能更新per-CPU的free list头。
反直觉的地方就在这里:上层代码拿到这个对象,通常马上要往里写数据;可分配器为了准备下一次分配,得在这之前就把对象内存的头几个字节读一遍。更麻烦的是,这个对象刚从free list上摘下来,它的cache line状态未必是热的。如果这个slab刚被refill、对象很久没有被访问,这一次get_freepointer就很可能成为一整条分配路径上最不可预测的cache miss。
1.1 kmalloc只是入口,真正的热路径是slab_alloc_node
我们平时在驱动里写kzalloc(64, GFP_KERNEL),以为内核分配器就是简单从某个池子里切一块内存。实际上kmalloc只是个转发层,它会根据请求大小找到对应的kmem_cache,然后走进kmem_cache_alloc -> slab_alloc_node。对SLUB而言,绝大多数分配根本不会走到伙伴系统,而是在struct kmem_cache_cpu这个per-CPU变量上直接完成。
简化后的逻辑大致是这样的:
c复制static void *slab_alloc_node(struct kmem_cache *s, ...)
{
struct kmem_cache_cpu *c = raw_cpu_ptr(s->cpu_slab);
void *object;
object = c->freelist;
if (!object)
object = ___slab_alloc(s, ...); // refill路径
// 取出当前object之后,立刻读它内部的next指针
c->freelist = get_freepointer(s, object);
...
return object;
}
注意看,代码里并没有在“下一次分配”时才去读next。当前这次分配里,get_freepointer(s, object)就会访问object这个地址,从它的某个偏移处解引用出下一个空闲对象。s->offset通常为0,也就是说,空闲对象头几个字节就是freepointer。
这背后的设计假设是:对象一旦被释放回slab,它里面的数据对上层已经无意义了。既然如此,干脆把对象内存的头8个字节复用,当作指向下一个空闲对象的指针。整个free list就这样被嵌入到对象集体里,不需要额外为每个对象准备一份“元数据”。
1.2 取出object的同时要回头读object本体,这很反直觉
很多第一次看SLUB源码的人会困惑:c->freelist不是已经告诉我们下一个对象在哪了吗?为什么还要再调用一次get_freepointer?因为c->freelist只保存了free list的“头”。在链式free list里,头节点本身并不携带“下一个是谁”的信息,你必须通过头节点内部存放的指针去找下一跳。于是每次分配,都必须跟随一次额外的指针访问:
code复制当前free list: freelist -> objA -> objB -> objC
第一步:从c->freelist拿到objA的地址
第二步:读objA内存中的next指针,得到objB
第三步:把c->freelist更新为objB
关键就在第二步。如果SLUB把free list用单独的内存区域维护,那这次读取会集中在一块总是被访问的元数据区里,cache命中率会高得多。但SLUB偏不,它把每一跳的next都散落在各个对象曾经栖身的内存位置。对象用过一次、释放、再分配,内存地址不变,可它所在的cache line早就被上层数据冲刷掉了。于是你在分配路径上的每一次“拿新对象”,本质上都可能是一次冷cache line读取。
如果这块对象的cache line恰好在L1/L2里是热的——比如刚被释放、又被紧接着分配——那这次解引用几乎没有成本。可一旦slab在node partial列表上待了一会儿,或者在CPU partial列表上被其他任务冷落,这块cache line基本就凉透了。分配器从partial列表拖出一个slab来refill时,头几个对象几乎必然会触发cache miss。
1.3 省掉这次访问意味着什么:cache miss与分配器延迟
一次cache miss在现代CPU上大概是几十到一百多个cycle,如果内存控制器繁忙、跨NUMA节点,那会更夸张。单看一次分配,几十个cycle对内核吞吐的影响似乎可以忽略。但分配器是全局基础设施,网络收包、文件系统IO、系统调用上下文都可能高频调用。每次分配都多一次cache miss,累积起来就是相当可观的IPC下降。
还有一个更隐蔽的影响:这个解引用是“串行依赖”的一部分。你必须读到objA内部的next指针,才能更新c->freelist;c->freelist没更新,下一次分配就无法开始;而下一次分配又是驱动继续走下去的前提。这不是CPU可以乱序执行预取解决的,因为依赖链是数据依赖,不是地址依赖。分配器热路径的延迟,几乎被这条链完全卡住。
理解了这一层,再回头看标题“Removing a pointer dereference from slab allocations”,你就知道这个优化想干的事非常具体:让分配器不再必须从对象内部读取下一跳,或者至少让free list的next指针存放在一个比“散落在各个对象里”可预测得多的地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么freelist指针一开始会被放在对象头部:这设计其实很聪明
在批评一个设计之前,最好先搞清楚它为什么存在。SLUB把freepointer塞进对象头部,并不是偷懒,而是同时解决了两个很麻烦的问题:一是内存开销,二是并发控制。
2.1 每个空闲对象自带next,不需要额外分配元数据
假设一个slab有60个对象,每个对象64字节。如果SLUB把free list的next集中放在某个struct slab管理区里,那么至少需要60个指针。按8字节一个指针算,就是480字节。这些内存无论对象是否空闲都必须存在,否则你没法把空闲对象串起来。而把next嵌入对象本身,空闲对象本来就不承载任何数据,头8字节闲着也是闲着,不占额外内存。
这被称为“零开销free list”。对SLUB这种把内存效率看得很重的分配器来说,这几乎是必须的。尤其在内核里,内存碎片与浪费是被反复追着打的。每多占用1%的内存,可能就意味着系统可分配给page cache和用户态的内存少了一截。用一个本来就空闲的机器字来存指针,远比另外维护一份元数据要省。
我见过有人拿用户态malloc的实现来类比,说glibc的malloc也不把next放在用户块内部。但内核里的kmem_cache和用户态malloc有个本质区别:前者管理的对象大小固定,后者大小任意。固定大小意味着你可以精确知道哪一段内存属于哪个对象,也就可以放心地在对象空闲期间覆盖它的头部。用户态malloc反而更复杂,因为一个chunk释放后,它内部是否还存着管理信息,直接影响了再次分配时的安全与效率。
2.2 对象头复用让无锁取用成为可能
SLUB设计里一个很核心的目标是:同一个slab的free list即使被两个CPU同时访问,也希望尽量避免用锁。传统做法是,每个CPU装一篮子本地对象,篮子空了再去全局区域抓一把。但篮子之间也会出现“同一时刻都在抓同一个slab”的情况,总不能每次都把这个slab锁住。
把next指针放在对象内部后,SLUB可以通过原子操作在对象链表头部做“卸载”。比如要把某个slab的free list头从objA切到objB,实际上只需要对objA中存放的next做一次原子的比较交换,把free list头更新成objB。这个操作只触碰一个指针位置,粒度很细,不需要给整个slab上锁。如果free list的head是放在struct slab里的共享字段,那每次修改都要原子更新那块共享字段,多个CPU争抢同一个cache line,反而更容易出现性能瓶颈。
所以,对象内嵌free list不仅仅是省内存,还为“细粒度并发”提供了便宜路径。对象各自分散在不同的cache line上,至少天然分散了争抢。你如果强行把next都搬到struct slab里,会让所有CPU的分配操作都去敲同一扇门,锁竞争策略可能被完全改写。
2.3 很多调试与加固特性都长在freelist指针旁边
这个细节很多人忽略,它却是所有想动freepointer的人必须先搞清楚的泥潭。SLUB的对象布局并不是永远“对象头8字节=freepointer”这么简单。当CONFIG_SLUB_DEBUG打开后,对象可能有red zone、padding、poison等区域;s->offset也未必是0。当你使用slub_debug=P时,每个空闲对象会被填上特定的poison值,而freepointer要放在让调试功能不被破坏的位置上。
此外,内核还实现了CONFIG_SLAB_FREELIST_HARDENED和CONFIG_SLAB_FREELIST_RANDOM。前者会在freepointer写回对象前做一个指针混淆计算,防止攻击者通过读对象内存预测free list布局;后者会在slab初始化时把对象free list的顺序随机打乱,防止堆风水攻击。这些安全机制全都依赖“freepointer必须存在于对象内部”这个前提,至少目前的内核实现如此。一旦你想把freepointer外置,这些机制的实现路径就要重新设计。
这也就解释了为什么主线社区在考虑移除这次指针解引用时,始终非常谨慎。一个看似简单的“少读一次对象内存”,底层牵连着内存开销、并发模型和内核安全加固。
3. “去掉一次解引用”的两个方向,以及主线为什么没有全盘推翻
顺着“不让分配器从对象内部读next”这个思路,方案大致有两条:一是把free list的next指针从对象挪到slab自身的元数据里,二是干脆抛弃链表式free list,改用索引或位图表达空闲对象集。这两条路看似都能消除标题里说的pointer dereference,但实际做起来,各自都要付出不小的代价。
3.1 方向一:把freelist从对象挪到slab元数据的问题
一个比较自然的想法是,给每个struct slab增加一个指针字段,让c->freelist每次从这个字段拿“下一跳”,而不是去解引用对象内部。听起来不错,但仔细推演一下就发现:如果你仍然用的是“链表式next”,那每个对象仍然需要一个next指针指向下一个空闲对象。就算你把当前链表的头指针放到struct slab里,在取走objA之后,你还是要知道objA的下一个是谁。这个信息如果不在objA里,那就得存在别的数组里,本质上还是给每个对象额外配一个指针槽。
真正能做到的是:分配时从struct slab的freelist头取一个对象,但不要立刻准备下一跳,等到这个slab里的对象快耗尽、或需要整体把一批对象搬到CPU freelist时,再一次性扫出整条链。这么做可以把“每分配一次都读对象头”变成“批量refill时读一次”。在per-CPU freelist已经缓存了一批对象的窗口期内,后续分配确实不再触碰对象内部的next。
代价是,当你把一批对象从slab转移到CPU freelist时,你得先知道这批对象内部的完整链条。而这个链条依然记录在每个对象的头部,你仍然要把它们读一遍,只是把成本从“每次分配都付”改成了“每次refill付一次”。如果每批转移32个对象,那相当于把32次访问集中到一次refill里。对某些高频分配场景,这个摊销思路是有效的;对另一些低频场景,反而会因为refill时一次性读太多对象,造成prefetch效果变差。
3.2 方向二:用索引或位图表达空闲集合,彻底移除对象内next
既然链表的next总要有个地方存,那能不能不用链表,改成一个纯粹的数字索引?比如,每个slab里有一份bitmap,bit为1代表对象空闲,分配时找到第一个空闲bit即可。这样分配器从struct slab的元数据里拿到的是“第几个对象”,再通过base_addr + idx * obj_size算出对象地址,全程不需要读取对象内部的任何指针。
这个方向的诱惑力很大,因为它真的把free list的“解引用”从对象内存转移到了固定、紧凑、可预测的元数据区。bitmap可能只有8字节甚至更小,存满对象的slab用几十个bit就能表达。而且bitmap天然支持批量分配:一次找到连续N个空闲bit,一次性分配给per-CPU列表,refill成本更低。
但问题也被bitmap化后放大了。SLUB本身是高度依赖cache locality的分配器,bitmap一般是“哪个bit空闲”需要扫描,而不是O(1)弹栈。为了让分配也变成O(1),你得维护“下一个可能空闲的位置”之类的游标。释放时,如果释放的对象位置比游标更靠前,你是直接更新游标还是敲回去?这就要么引入排序,要么引入更复杂的数据结构。我在自己的最小实现里试过用per-CPU的局部bitmap缓存来缓解,但只要同一slab被多个CPU同时释放,全局位图的原子更新就会变成新的瓶颈。
3.3 综合来看,真正的成本不只是少一次内存访问
做这类优化的核心心法,不是“减少一次内存读”,而是“把不可预测的读变成可预测的读”。对象内部的freepointer,散落在slab各处,每次可能在不同cache line上;而struct slab的元数据字段,始终集中在那几个固定的地址。两者的cache行为完全不同。
但可预测的读也会带来可预测的争抢。当所有的CPU都去查同一个struct slab的字段时,它的cache line会成为热点。SLUB原设计用一个slab里的对象分散承担缓存热度,在外置freelist后,热度全部集中到一处,等于把局部问题变成了全局问题。
我在实验中得到的感受是:对单个slab生命周期短、创建销毁频繁的场景,外置freelist反而会增加开销;对单个slab长期驻留、对象周转率高的场景,外置才可能占到便宜。社区里那些真正被合入的优化,大多不是“彻底去掉对象内next”,而是通过批量转移、预取和路径裁剪,减少访问对象头的次数。毕竟“移除一次pointer dereference”听起来爽,实际工程里必须在内存、延迟和并发之间重新找平衡。
4. 如果照这个思路真改内核,这三处会先踩坑
纸上谈兵很容易,真把改动写出来,参加内核review,你会发现一堆隐藏依赖。下面这几点是我实际动手验证后印象最深的,基本决定了一个“移除对象内freepointer”的补丁能不能活下去。
4.1 struct slab里其实没有你想象的那么多空闲字段
很多第一次改SLUB的人以为,可以顺手在struct slab里加一个void *freelist,或加一个unsigned int next_idx。问题是,struct slab在大多数配置下与struct page共用同一段存储,而struct page本身是整个内核内存管理最宝贵的数据结构。每多一个字段,意味着系统里每一物理页都要多承担相应的内存占用。
内核有大量机制在复用一个page状态下的字段:slab在用它的freelist字段,buddy allocator在用它的lru字段,folio也在尝试压缩各种状态。你很难在struct slab里找到一个“完全没人用”的8字节空间。如果为了一个优化让struct page整体变大,这个补丁在内存占用上就先被否决了一半。
替代办法是像某些分配器那样,单独为slab分配一块元数据区域,放在slab page之外。这样可以不打爆struct page,但会引入额外的一次内存查找:从slab page找到对应的metadata区域,这个查找过程本身又是一个间接访问。绕了一圈,可能又回到原地。
4.2 释放路径对freepointer的依赖比分配路径更顽固
一般讨论这个主题时,所有人都在看kmem_cache_alloc,但真正难改的在kmem_cache_free。释放对象时,分配器要把这个对象重新挂到free list上,同时还要写一个freepointer进去,使它指向当前的free list头。这个写入动作天然会把freepointer放进对象内部。一旦你决定对象内部不再存freepointer,释放路径就必须把新释放对象的位置信息注册到别的数据结构里去。
这不仅仅是“改一行赋值”的事。SLUB有一个非常隐蔽的优化叫“frozen slab”,意思是某些slab被per-CPU列表冻结,释放时不需要立刻更新全局slab状态。如果你把freepointer外置,冻结slab被释放对象时,必须把信息写进per-CPU甚至全局元数据,这个更新是否原子、是否需要锁,直接决定了释放路径会不会变慢。
我用perf比较过,很多场景下释放路径和分配路径是一样热的。如果一个优化只让分配快了,却让释放慢了,整体收益很可能是负的。
4.3 调试对象布局让“对象头可用”这个假设时灵时不灵
默认配置下,kmem_cache对象的第0字节就是freepointer。但只要开了slub_debug、KASAN或init_on_alloc/init_on_free中的任何一个,事情就会变复杂。s->offset会从0变成某个对齐偏移,freepointer不一定在对象头,而对象头可能被红色区、越界检测标记或初始化模式占据。
如果实现里硬编码“对象首8字节不再写入”,那在调试配置下几乎一定会触发各种异常检测:要么poison被意外覆盖,要么KASAN报告越界,要么clear-on-free把freelist标记一起清掉。内核里很多“看起来简单的优化”最终死在调试配置兼容性上,就是这个原因。写这类补丁,你必须把CONFIG_SLUB_DEBUG、KASAN、init_on_alloc和init_on_free全开一遍跑测试。
4.4 多CPU共享同一个外置freelist后,锁的粒度会让你头疼
最后一个深坑是并发。对象内嵌freelist时,不同对象散落在不同cache line,即使多个CPU操作同一个slab,也只是各自竞争不同对象的内存。把freelist头集中到struct slab后,所有CPU都会去更新同一个cache line。哪怕你用原子Cmpxchg,也会让这块cache line在不同CPU之间来回 bouncing,造成大量的cache coherence流量。
我试过给struct slab维护一个共享的next_idx,用原子递增来分配对象。单线程和多线程测试的差异非常明显:单线程下确实能省掉一部分指针解引用,但多线程一旦达到一定竞争强度,原子递增带来的cache line ping-pong把省下的cycle全部吃回去,甚至反超。后来我用per-cpu的批量缓存缓解了一部分,但那实际上又变回SLUB原本的per-CPU设计思路了。这说明,分配器的每一个局部优化都会被“多CPU共享结构”这个现实约束摁住。
5. 用数据验证“少解引用一次”值不值
讲完原理和坑,最终还是要回答一个问题:这个优化到底值不值得做?如果你不自己跑数据,很容易被“少一次cache miss”的叙事带偏。我的做法是分三步验证:先确认这次访问是不是真的在贡献可观测的cache miss,再分别用微基准和真实负载做对比,最后看指令分布和CPI变化。
5.1 插桩与perf:先确认这次访问确实在贡献cache miss
第一步不要直接对比补丁前后的吞吐,而是先测量当前基线上get_freepointer这次访问到底花了多少代价。用perf probe在get_freepointer上插桩不太现实,因为它是static inline,内联到了各个分配路径里。我通常用perf的cache-miss事件加调用栈采样,把分配器函数的cache miss占比拉出来看。
更直接的辅助手段是perf c2c,它专门用于检测cache line伪共享和冷读。在分配器测试场景里,如果perf c2c报告显示大量cache miss集中在一个slab page的不同对象偏移上,那基本能说明对象内freepointer的访问确实在主导开销。如果报告显示热点在struct slab的公共字段,那就不该优化freepointer,而该优化结构体布局。
也可以用bpftrace做一个粗粒度统计,在____slab_alloc入口和返回处分别统计cycles,看refill路径的时延分布。虽然它无法精确到“一次freepointer读”用了多少cycle,但能帮你确认分配器是不是真的在等内存返回。
5.2 微基准和真实负载为什么会得出相反结论
很多优化在微基准上漂亮,一上真实负载就平庸甚至倒退,slab allocator尤其如此。我用过两个典型微基准:一个是单线程里反复kmalloc/kfree固定大小对象,另一个是用hackbench跑进程间通信调度压力。前者的结果几乎必然偏乐观,因为单线程里对象释放后立刻被再分配,cache line大概率还在L1/L2里,freepointer读取的代价本来就不高。后者的结果才更能反映“冷slab被唤醒、对象从partial列表拖回来”的真实场景。
真实负载方面,我建议看网络收包路径,比如用perf抓softirq里的skb分配;或者用sysbench跑数据库时观察内核分配相关的cache miss。网络路径对分配延迟极其敏感,对象周转快,而且是多CPU并发,能最快暴露锁或伪共享问题。
我在实验里观察过一个明显现象:如果负载中对象分配后立即被上层填满数据,freepointer读取导致的cold miss会被“上层将来写入这个对象”的miss掩盖掉一部分,因为反正这块cache line马上要加载进来。这时候移除freepointer并不会让总cache miss显著下降。反过来,如果对象被分配后只使用头部一小块数据,且整个对象cache line确实命中,那移除freepointer可能会让分配路径完全不再触碰“对象内存”,收益才真正体现。
5.3 我实际跑下来的一点体会
我个人的结论是:这是一个“理论上成立但工程上高度场景相关”的优化。它不像去掉一次锁等待那样动不动就能带来两位数提升,而是像一个精细的微调,只在一部分分配模型下能看到收益。
如果你要复现或评估这个方向,我的建议是先用现有SLUB,手动把slab对象的对象头留空不做freepointer,再配一个外置的小数组模拟next索引,做一个最小验证补丁。不需要立刻改struct slab,先在per-CPU路径里把freepointer访问换成从一个小数组读索引,把数据跑出来,看看cache miss和分配的ns/op变化,再决定要不要往主线方向推进。这个最小实验大概几百行代码就能完成,但它能让你快速理解“解除一次dereference”背后真正的天花板在哪。
最后提醒一句:SLUB这么多年没有大改freelist布局,不是因为维护者看不见这次解引用,而是因为对象内嵌freelist在内存开销、并发安全、调试兼容性上的综合得分很高。想撼动它,不能只靠“减少一次cache miss”这个单点收益,你得在完整工程维度上给出更有说服力的证明。
