前阵子梳理大模型推理的显存管理,脑子里突然冒出一个想法:这玩意儿的底层逻辑,跟 LLVM 那套基础设施简直是同一个模子刻出来的。LLVM 的 ADT(基础数据结构库)和内存管理机制,解决的是编译器在极短时间内处理海量中间代码的身存亡续问题;而大模型推理要面对的,同样是在有限的显存里,把数百万 token 的生命周期安排得明明白白。两条看似风马牛不相及的赛道,在“如何高效地分配、复用、回收短生命周期对象”这件事上,撞了个满怀。
这篇随笔第一篇,我打算把这两条线的底层逻辑摊开揉碎,从 LLVM 的侵入式引用计数、BumpPtrAllocator 这类硬核机制讲起,再一路剖析到大模型推理的 KV Cache、PagedAttention 与 Continuous Batching,看看两边是如何用完全不同的思路,解决同一个本质上关于“内存即性能”的命题。适合对编译器后端、LLVM 源码或者大模型推理部署感兴趣的朋友,不一定需要你两边的代码都熟,但如果你正在做推理优化,这篇文章或许能帮你把那些零散的 trick 串成一条线。
1. 内容整体设计与思路拆解
1.1 为什么把 LLVM 和大模型推理放在一起聊
在很多人的认知里,LLVM 是编译器世界的元老级基建,大模型推理是 AI 落地的新贵,两者之间隔着一道不算浅的技术代沟。但说白了,它们落在同一个物理硬件上,都要面对冯·诺依曼体系结构里最残酷的现实:内存墙。CPU 访问主存的速度远比执行一条简单指令要慢,GPU 访问显存同样要忍受几百个时钟周期的延迟。谁能把内存访问的次数和总量压下来,谁就能获得成倍的性能提升。
LLVM 在后端优化的过程中,需要不停构建中间表示(IR)、分析指令依赖、变换基本块结构。每一轮 pass 都会产生大量临时分配的instruction对象、use-def链节点、dominator树节点。这些对象生命周期极短,往往只存在于单个pass的执行期间。如果直接用C++的标准new/delete或者智能指针去管理,malloc和free的系统调用开销会把优化时间拉到不可接受的程度。这里就逼迫 LLVM 设计出一套极其高效的内存管理和对象生命周期方案。
大模型推理则是另一番光景。以自回归生成为例,每生成一个 token 都要把之前所有 token 的 Key 和 Value 投影结果拿出来做注意力计算。这些历史信息不能丢弃,否则上下文就断了。但是显存容量有限,光是一个 7B 参数的模型权重就要占去小 14GB 的显存(以 FP16 算),留给 KV Cache 的空间本就捉襟见肘。于是业界开始精细化地管理这块“缓存城墙”,从 kv cache 的动态分配、页式管理到连续批处理带来的缓存复用率提升,每一招都是在跟显存容量极限博弈。
LLVM 和大模型推理都面临一个核心矛盾:边界明确的总容量,无穷尽的生命周期请求。一边是有限的内存条,一边是有限的显存。看懂一边的解法,另一边几乎就是条件反射式的理解。
1.2 从源码到部署,内存管理机制扮演的角色
先给两块场景定个位。LLVM 的内存管理机制服务于编译时的高通量计算。编译器一天到晚干的活就是读入代码、建AST、生成IR、优化、生成汇编,整个链条中绝大部分对象生命周期短、创建销毁频繁。对这类场景,性能最优的方案不是“精确”管理每个对象,而是“批量遗忘”——分配时迅猛,释放时一锅端。LLVM 的 BumpPtrAllocator(竞技场分配器)就是此思路的极致体现。
而大模型推理的显存管理,服务于运行时的高并发推理请求。多个用户同时请求生成,每个人带着一个动态增长的历史上下文。每个请求的 KV Cache 大小随着生成进度不断膨胀,你不能在请求开始时拍脑袋定一块固定大小的显存分给它(定小了生成到一半就崩,定大了其他请求就挤得慌)。所以这块的核心不是“批量遗忘”,而是“动态共享”——把显存视为一个按页划分的大池子,各家请求需要多少页就各取多少页,用完就到池子里归还。这就是 PagedAttention 的原始出发点。
两种机制看起来南辕北辙,但如果你把 LLVM 每次 pass 执行后整块 reset 的行为,类比成一个请求完成后的所有 KV 页统一归还,会发现它们处于同一条光谱的两端:LLVM 是“集中式销毁”,PagedAttention 是“分布式归还”。这也说明一个道理,好用的机制往往不是凭空发明的,而是把已有模式推到极限后的必然产物。
2. LLVM ADT 与内存管理机制深度解析
2.1 从 C++ 标准库到 LLVM 自研 ADT,到底改了些什么
用 C++ 写过业务代码的朋友,对 std::vector、std::list、std::map 肯定不陌生。标准库的容器设计通用,面向的是五花八门的场景,所以它优先保证正确性和通用性,而不是极致性能。LLVM 作为高度专业化的基础设施,选择了自己磨一套刀——llvm::SmallVector、llvm::StringRef、llvm::ArrayRef、llvm::SmallPtrSet,等等。
拿 SmallVector 举例。它的设计目标极其明确:在处理大量“小规模但高频”的临时对象时,跳过堆分配。SmallVector 内部预留了一块固定大小的栈上缓冲区(比如 N 个元素),只有当元素数量超过 N 才转到堆上分配。这个“小而快”的特性,跟编译器处理基本块中的指令序列简直天作之合——大部分基本块就十几条指令,完全不需要堆分配的内存开销。
再比如 StringRef,它做的事更绝:仅仅保存一个指向外部字符数组的指针和长度,不拷贝字符串本身。编译器每次从 token 或 AST 节点拼接一段字符串做分析时,传一个 StringRef 就能把数据从头传到尾,根本不会产生字符串拷贝。这里头蕴含的底层思路是:对象之间的数据流动,能用引用的就绝不用值传递。这不仅是省一次 memcpy 的事,更是改变系统整体内存峰值的关键决策。
这里的取舍也很直观。LLVM 放弃 std::vector 的完全通用性,换来的是“编译器自身在编译大型源文件时内存不爆炸”的稳定性。如果说标准库是“什么都能装的神奇箱子”,SmallVector 就是“专门装基本块指令的贴身口袋”——贴身当然比神装快。
2.2 侵入式引用计数与所有权归属设计
编译器代码里对象之间互相引用的关系极其复杂:一个 user 会引用 instruction,instruction 也会通过 operand 引用 user。用 std::shared_ptr 可以管理这种引用关系,但每个 shared_ptr 的引用计数区块在堆上独立分配,频繁的增减计数操作会引入原子操作开销和缓存行竞争。LLVM 选择了侵入式引用计数(IntrusiveRefCntPtr)方案。
这套方案的核心在于:引用计数直接嵌在对象自身内存里的前几个字节,而不是放在外部的控制块中。对象知道自己的引用数,外部指针直接用裸指针操作,智能指针包装器只负责在赋值、退出作用域时调整对象内部的计数。一个 mallocation 就同时覆盖了对象内存和计数内存,没有额外的控制块申请,也没有原子操作,性能自然上得去。
还有一个细节,LLVM 里的 Value 基类自带 Use 列表,记录了所有引用它的 user。当指令被替换(RAUW,ReplaceAllUsesWith)时,可以精准地找到所有使用者并批量修正引用关系。这种设计与侵入式计数配合,让编译器在进行全局优化时,对对象的生命周期完全可控。不会出现既没有使用它、却不敢释放它的“幽灵对象”。
这套思路给所有 C++ 大工程一个很好的范本:所有权模型必须根据对象图结构专门设计,照搬某种通用指针到头来只会两头堵。
2.3 Arena 分配与 BumpPtrAllocator 的工程实践
如果说引用计数管的是“对象生命周期”,那 BumpPtrAllocator 管的就是“对象内存来源”。lli 或者 opt 在执行一个 pass 时,往往会创建成千上万个临时对象。用 malloc 挨个分配,不仅慢,还容易因为碎片化让内存利用率变低。BumpPtrAllocator 的处理方式是一口气向系统申请一大片连续内存(比如 256KB),然后在上面按需分配,每次分配只需要移动一个指针(bump),O(1) 时间完成。
这里面有个特别典型的 LLVM 场景:pass 执行期间创建一堆临时 Instruction 节点,跑完后只要调用一次 alloca.Reset(),整块内存被标记为可复用,下一轮接着用。这跟手写内存池的套路一致,但 LLVM 把它揉进了库的骨髓里,任何使用者都能无脑享受它的好处。
有一个极其重要的工程细节:因为分配器只负责分配不负责回收,使用者要自己保证对象之间没有互相持有引用。一旦引用了被 arena 一次性清零的对象,就会产生悬垂指针。LLVM 体系下,大家约法三章,跨 pass 的数据必须通过显式数据流结构传递,禁止在 pass 结束后仍持有多余指针。这种做法约束了工程实践中写代码的人,也保护了整体架构的安全性。
注意:BumpPtrAllocator 不是万能解药。如果你的对象生命周期有长有短、交错混合,一锅端释放会导致内存利用率下降。它最适合的场景是“阶段性创建、阶段性销毁”这种整齐的节奏。理解这一点,比背下接口 API 重要得多。
3. 大模型推理的显存与请求调度梳理
3.1 自回归生成的内存瓶颈,到底卡在哪里
大模型推理里最难啃的硬骨头,就是自回归中的 KV Cache。以 GPT 类的 transformer decoder 为例,生成第 n 个 token 时,注意力层需要计算第 n 个 token 的 query 与之前 1 到 n-1 个 token 的 key、value 的匹配度。如果不缓存前序 KV 向量,那每生成一个新 token 都得把前面的所有 token 重新过一遍网络,计算量呈二次方爆炸——生成一篇文章得把一部电影看上无数遍,谁受得了。
所以业界普遍的做法是,在显存中开辟一块区域存储每个序列历史的 K 和 V 张量,这就是 KV Cache。模型大小为 7B、上下文长度为 2048、批次大小为 8 的情况下,单个请求的 KV Cache 大约需要2(K 和 V) × 层数 × 头数 × 头维度 × 序列长度 × 字节数(2字节 FP16)。粗略一算,8 个请求同时跑就可能吃掉 8GB 以上的显存。这还没算输入输出的激活值和临时张量。
自回归生成是“生成一个 token,扫描一遍历史”,KV Cache 就变成访问最频繁的数据。这块的带宽和容量直接决定生成速度。你显存再大,如果 KV Cache 的组织方式不够高效,每次访问都跨内存页跳来跳去,也只能等着被带宽卡脖子。
3.2 PagedAttention 的页式思想,以及它如何改造显存利用率
这里必须请出 vLLM 的 PagedAttention。它的灵感来源极其朴素——操作系统的虚拟内存分页机制,把物理内存切成固定大小的块,让进程以一个统一的虚拟地址空间访问散布在物理内存各处的页。PagedAttention 干的事就是把 KV Cache 按固定大小的块(block)划分,一个 block 容纳固定数量的 token 的 KV 数据,比如 16 个 token 一页。
过去比较粗放的做法是,给每个请求预先分配一个最大长度的连续显存段。如果请求只生成了 10 个 token,但预留了 512 token 的空间,块的浪费率就高得吓人。PagedAttention 的页式管理在物理块层面按需分配,逻辑上却通过块表(block table)把这些物理块串起来。请求后到的 token 再动态申请一个新块,多一个 token 就多占一块空间,分配粒度精确到 16 个 token 的量级。
这套机制的另一个大杀器是块级别的显存共享。在做并行采样(beam search 或 parallel generation)时,多个序列可能共享前缀 token。比如两个序列历史的前 1024 个 token 完全相同,后面各自扩展到 2048。如果没有显存共享,两份重复的 1024 token KV 得存两遍。PagedAttention 的块表支持多个序列的某些逻辑块映射到同一物理块,那么公共前缀只用存一份。单这一项优化,在某些解码场景下能节省接近 50% 的显存。
3.3 Continuous Batching 与请求排队策略的内存效应
大家可能听过 continuous batching(连续批处理)这个词,但通常只把它当作提升吞吐的调度策略,忽略了它对显存的塑造作用。传统静态 batching 的思路是:凑一批 N 个请求,一起做完再处理下一批。问题是长尾效应严重,一个生成得特别慢的请求会拖住整批请求,其他请求的 KV Cache 也一直占着显存不释放。
Continuous batching 的做法则激进得多:每个 step(或几个 step)结束后,如果某个请求已经完成(生成了结束符或达到 max token),就立刻把它踢出批次,同时从等待队列中拉进一个新的请求,填充进这个 tee 空出来的显存位置。请求不再是“签个到就赖着不走”,而是每时每刻都在动态进出,显存的颗粒度被利用到极致,请求的平均排队时间也大幅缩短。
从内存机制上看,continuous batching 通过动态腾挪,让 KV Cache 拥有只在请求存活期间内占用显存的“租期”语义。这种思想的本质与 LLVM pass 结束时整块释放 arena 如出一辙——它们都放弃了对单个对象生命周期的精细追踪,改用“阶段”来粗粒度管理,从而把调度开销降到最低。
4. 两条线的交集:内存复用与生命周期设计的通用法则
4.1 短生命周期对象的批量释放效率,远超细粒度追踪
观察 LLVM 的 BumpPtrAllocator 与大模型推理的显存管理有个惊人的共性:都是在“批量”层面管理生命周期,而不是在“个体”层面。前者在一个 pass 结束之后把整块 arena reset,后者在一个请求踢出批次之后把整块逻辑上的 KV Cache 页表标记为可回收。个体层面的析构与释放,需要记录每个对象的出生顺序、依赖关系、引用链,复杂度和内存碎片率都会飙升。
当场景允许“阶段性创建、阶段性销毁”时,整块释放的效率是革命性的。这就像搬家时请十几个工人挨箱子拆包再重新打包,看起来操作精细,其实效率极低;倒不如先把所有东西塞进集装箱,运到新家再一次性倾倒出来,虽然过程粗放,但批量吞吐高得吓人。
这话听着稀松平常,但在系统设计里极其容易被忽略。很多人在写内存池的时候总惦记“给每个对象配一个生命周期钩子”,结果体系越写越复杂,性能还不如直接调 new/delete。LLVM 和 vLLM 提供的经验是:让对象的生命周期尽量对齐到某个“阶段”的边界上,阶段切割得越干净,内存管理越轻松。
4.2 对象布局与访问局部性对性能的决定性影响
LLVM 的 SmallVector 把对象紧密排列在连续内存中,不仅省了堆分配,还能让 CPU 缓存命中率大幅提高。大模型推理的 KV Cache 按块划分、块表索引,也是为了让 GPU 的缓存与显存带宽发挥最大威力——K 和 V 按顺序排布,计算注意力时执行连续内存访问,远比随机跳转来得快。
这种对访问局部性的执著,往深了说是在跟硬件体系结构的物理规律较劲。CPU 的主要瓶颈是延迟,GPU 的主要瓶颈是带宽,但它们都非常依赖数据在内存中的排列位置。数据在内存中排列得越紧、越连续,缓存效率越高,单位时间内能处理的数据量就越大。
如果你要优化一个现有的大模型推理服务,不一定要立刻上 PagedAttention 那样的重武器,先检查 KV Cache 的张量布局是否连续、是否存在多余的内存拷贝,或许更能立竿见影。很多时候,内存结构比显卡型号更能决定推理吞吐。
4.3 显式所有权模型,如何让系统边界更可靠
LLVM 里所有对象的所有权关系是显式的:谁创建、谁释放、谁引用,在类型层面就能看出来。比如 Value 的 Use 列表允许你追踪所有使用者,Option 指针的语义让编译器能静态检查是否在 pass 结束后还有逃逸指针。这种显式约定降低了并发和异步场景下的内存安全问题发生概率。
大模型推理框架也在做类似的事。PagedAttention 中的 block table 把物理块索引显式映射给逻辑序列,谁能访问哪块显存、何时归还,框架层一清二楚。GPU 不像 CPU 有 MMU 做越界保护,显存越界往往不会立刻暴露,而是产生 silent corruption,这是最可怕的线上事故。有了显式的所有权映射,越界问题在逻辑层面就能被拦截。
套用一句做系统工程的老话:能通过类型系统或结构设计避免的错误,就不要指望运行时来兜底。把所有权模型建模对了,系统的整体风险就会大幅下降——这在编译器和推理引擎里都是一样的道理。
5. 实操过程与核心环节实现:一轮从编译到推理的体验
5.1 用 LLVM 源码快速验证 BumpPtrAllocator 的魅力
理论说破嘴,不如跑一把代码来得直观。这里我建议你直接在本地拉一个 LLVM 项目(用主干分支即可),然后写一个极简的可执行文件,看看 BumpPtrAllocator 到底快在哪儿。
#include "llvm/Support/Allocator.h" #include <chrono> #include <cstdio> #include <vector> struct Node { int value; Node* next; }; int main() { constexpr int kCount = 10000000; // 标准 new/delete 方案 auto t0 = std::chrono::high_resolution_clock::now(); std::vector<Node*> nodes; nodes.reserve(kCount); for (int i = 0; i < kCount; ++i) { Node* n = new Node{i, nullptr}; nodes.push_back(n); } for (Node* n : nodes) delete n; auto t1 = std::chrono::high_resolution_clock::now(); double msNew = std::chrono::duration<double, std::milli>(t1 - t0).count(); // BumpPtrAllocator 方案 llvm::BumpPtrAllocator alloc; std::vector<Node*> arenaNodes; arenaNodes.reserve(kCount); auto t2 = std::chrono::high_resolution_clock::now(); for (int i = 0; i < kCount; ++i) { Node* n = alloc.Allocate<Node>(); n->value = i; n->next = nullptr; arenaNodes.push_back(n); } alloc.Reset(); auto t3 = std::chrono::high_resolution_clock::now(); double msArena = std::chrono::duration<double, std::milli>(t3 - t2).count(); std::printf("new/delete: %.2f ms\n", msNew); std::printf("arena: %.2f ms\n", msArena); return 0; }这段代码的意思很直白:同是 1000 万个 Node 对象的创建和销毁,new/delete 走的是一趟系统调用一个对象的流程,而 arena 只是把指针在预先申请的内存块上“蹭蹭”地往前挪。实测下来,BumpPtrAllocator 往往能比 new/delete 快出一个数量级。这里额外的两个观察点:第一,BumpPtrAllocator 无需在分配时检查是否已有合适的空闲块,天然零碎片;第二,Release 或 Reset 时无需遍历对象,直接扔掉整块。
注意:这只是一个微观基准,不代表 BumpPtrAllocator 在任何场景都比 new/delete 好。它赢在短生命周期对象集中释放的节奏上,若你的对象需要独立释放、交错创建,那还是老实回到 malloc 大家族。
5.2 手写一个简化的 PagedAttention 逻辑,理解块表管理
想在本地验证 PagedAttention 的思想,不需要真的搭一个 GPU 推理框架,用一个 CPU 上的 Python 模拟足矣。核心在于块表的设计:逻辑块号到物理块号的映射。
下面的代码把 KV Cache 模拟成一个物理块数组,每个物理块可以容纳 block_size 个 token 的 K、V 向量。逻辑序列则持有自己的块表,访问第 i 个 token 时先计算它落在哪个逻辑块,再通过块表找到物理块。
import numpy as np class PhysicalBlock: def __init__(self, block_size, hidden_dim): self.keys = np.zeros((block_size, hidden_dim), dtype=np.float32) self.values = np.zeros((block_size, hidden_dim), dtype=np.float32) self.used = 0 class KVBlockManager: def __init__(self, num_blocks, block_size, hidden_dim): self.pool = [PhysicalBlock(block_size, hidden_dim) for _ in range(num_blocks)] self.free_blocks = list(range(num_blocks)) self.refcount = [0] * num_blocks def alloc_block(self): if not self.free_blocks: raise RuntimeError("out of memory") idx = self.free_blocks.pop() self.refcount[idx] = 1 return idx def free_block(self, idx): self.free_blocks.append(idx) self.refcount[idx] = 0 class Sequence: def __init__(self, block_manager, block_size): self.block_manager = block_manager self.block_size = block_size self.block_table = [] self.length = 0 def append_kv(self, key, value): if self.length % self.block_size == 0: phy = self.block_manager.alloc_block() self.block_table.append(phy) block_index = self.length // self.block_size slot_index = self.length % self.block_size phy = self.block_table[block_index] self.block_manager.pool[phy].keys[slot_index] = key self.block_manager.pool[phy].values[slot_index] = value self.block_manager.pool[phy].used += 1 self.length += 1 def kv_at(self, token_pos): block_index = token_pos // self.block_size slot_index = token_pos % self.block_size phy = self.block_table[block_index] return (self.block_manager.pool[phy].keys[slot_index], self.block_manager.pool[phy].values[slot_index])这个简化模型展示的东西,是把显存当作可以动态分割和分配的共享资源池。每来一个新 token 就 append 一次,按需挂上新的物理块;查询第 i 个 token 的 KV 也是一次数组索引就完成。页式管理的精髓——空间分配与逻辑访问解耦——在这个小实现里体现得清清楚楚。
实际工业界的实现比我这个模拟复杂得多,要处理 GPU 核函数、块分配器并发安全、beam search 场景下的块共享等,但核心思想和这里的代码完全一致。
5.3 从编译优化到推理部署:一次“内存即性能”的完整旅程
要想把两套机制串成一个闭环,最好的方式是把一个模型用自己的工具链走一遍。我最近干的一件事,是用本地编译好的 LLVM 工具链(opt + llc + clang)把一个最小的 transformer 推理程序编译成可执行文件,然后把它放在基于 PagedAttention 的推理框架里跑起来。
编译端看到的是 LLVM 的一次又一次内存 arena 分配与释放:每轮 IR 优化 pass 之间的临时对象全部在 arena 上齐生齐灭,最终生成紧凑的机器码。推理端看到的是 KV Cache 的物理块动态分配与复用:不同请求在不同轮次获得和归还物理页面,显存使用量一路被压着走。两层完全不同的抽象,在“尽力减少内存分配开销”这个目标上达成了默契。
这个旅程虽然有点折腾,但对于理解整个系统的协作关系非常有帮助。运行时一个配置文件里的 block_size 设为多少、显存池上限是多少,本质上和你编译期选用的 arena 块大小是同类决策——太小会频繁调用底层分配器,太大则浪费空间,理想值需要通过基准测试去卡。
6. 常见问题与排查技巧实录
6.1 使用 BumpPtrAllocator 后出现悬垂指针,怎么定位
这是 LLVM 开发新人最容易踩的坑。BumpPtrAllocator 释法粗犷,Reset 之后所有指向该 arena 内存的指针集体失效,而这类指针往往还藏在某个容器或 pass 的成员变量里。排查的方法没什么捷径,先给全工程加上 ASan(Address Sanitizer),再用一小段用例反复触发,ASan 会精准报告“use-after-free 发生在哪个对象、哪次释放之后”。
ASan 报错之后,把报错栈和 Reset 调用的栈对比着看,基本十有八九能立刻找到是哪个 pass 的哪个小字段遗忘了生命周期约定。如果还不行,另一个办法是在 Reset 之后给整个 arena 填充一个魔法数字(比如 0xCD),这样悬垂指针读出来的内容就变成 0xCDCDCDCD,肉眼也能认出来。
注意:在 LLVM 新版本里,BumpPtrAllocator 可能提供 debug 模式的毒化处理,建议开发期一直开着。这个坑的主要成因往往不是复杂逻辑,而是顺手把一个对象指针塞到了全局状态里,违反了“阶段内使用”的基本纪律。
6.2 KV Cache 显存占用暴涨,先查哪些参数
大模型推理服务跑着跑着显存报警,这是线上最常收到的问题之一。很多人第一时间去调模型参数,其实绝大多数情况下问题出在 KV Cache 相关的三个点:
第一,max_num_batched_tokens 和 max_batch_size 是否设置过大。连续批处理框架会尽可能往批次里塞请求,但每个请求的 KV Cache 占用随上下文长度增长,批次一旦超过显存池的承受能力,显存就会瞬间打满。第二,block_size 太大或太小。block_size 越大,块数越少,每个块的分配管理开销越省,但按块分配的浪费越多;block_size 如果设成 1,浪费倒没了,块表膨胀和分配器压力又会飙升。第三,是否忽略了 beam search 场景下的 KV 共享。如果你的在线服务用了 beam search 或 speculative decoding 相关的并行策略,而没有启用共享块表优化,公共前缀会导致成倍的显存浪费。
排查时先用nvidia-smi看总体显存,再通过框架自带的 metrics 接口查看显存池使用率和空闲块数量,往往一眼就能看出是池子太小,还是碎片浪费太严重。
6.3 性能不升反降,可能是生命周期模型与场景不匹配
有一种情况很典型:本地微基准测试下,BumpPtrAllocator 的表现吊打 new/delete;可一旦放进真实的编译流程,整体性能却没什么改善,有时甚至会变慢。
原因在于,现代 glibc 的 malloc 对重复创建和释放同样大小对象的场景早已做了优化,比如 tcache 和 arena 复用,小对象的分配速度不见得慢多少。BumpPtrAllocator 的优势只在“一次性释放巨量对象”的场景下才会放大;如果你的对象本来就不用集体释放,arena 反而会占用大块连续内存,让其他模块的内存申请在物理层面碎片化。
大模型推理也同理。如果在线请求的并发度很低、每个请求的上下文又很短,PagedAttention 的块表管理开销甚至可能让你觉得不划算。但换个角度,这正说明内存机制的选择必须贴合生产环境的画像——没有最佳方案,只有最匹配的方案。
我在实际使用中发现一个很有意思的共性:无论是调整 LLVM 里某个 pass 的分配策略,还是调整推理框架里 KV Cache 的块参数,建模和调参的工具都极其相似。先用计数器统计对象总数与生命周期分布,再决定采用哪种内存策略,比一上来就翻源码改配置要可靠得多。内存管理的优化,本质上是一种“量而后谋”的工作。
最后再分享一个小技巧——在做这些内存相关实验的时候,永远要把“内存分配次数”和“内存峰值”两个指标一起记录下来。LLVM 的优化和推理框架的调度都容易做到前者漂亮、后者难看,如果你只盯着一个指标调优,大概率会在下一个负载下翻车。把两者同时装在心里,许多看起来玄乎的显存或内存问题,往往迎刃而解。这篇随笔先写到这,后续有空我打算把 Arena 分配器在大模型前向计算中的直接应用(比如激活值显存管理)再单开一篇展开聊聊。