资讯动态

从LLVM到推理框架:内存管理与数据结构的设计智慧

发布时间:2026/10/16 3:50:18 来源:尧图企业网站定制
1. 为什么我坚持把LLVM和推理框架放在同一篇随笔里前几天有位做推理加速的同学问我学编译器基础设施到底有什么用他的日常工作就是调KV Cache、优化显存布局、压算子耗时在他看来编译原理离得有点远。我当时没有直接回答而是让他回去把LLVM的ADT源码翻一翻再想想自己每天打交道的推理引擎在数据结构上踩过的坑过几天他回来跟我说确实很像。这个“像”不是偶然。LLVM作为一套庞大的编译器基础设施常年要处理的恰恰是三类问题大量临时对象如何用最少的分配开销存活、数据结构如何在零拷贝前提下安全传递、资源如何在阶段边界被整批回收。而大模型推理框架在服务侧遇到的内存问题从KV Cache的按需分配到连续批处理下的显存碎片再到张量在算子间流动时的视图复用本质上都是同一类问题换了个更大的规模。这就是我把它们放在一篇随笔里的原因。不是硬凑而是从工程实践看它们是同一套系统思维在不同领域的投影。这篇随笔不讲编译器的学院派理论也不去复述某篇推理框架的官方文档而是沿着内存管理和数据结构的线索把两边对照着拆开看。写的时候默认读者有一定的系统软件基础但不要求写过编译器也不要求手撸过推理引擎。只要你在任何一个对性能和资源敏感的系统里写过代码应该都能找到对应的共鸣点。先交代一下阅读路线前两节围绕LLVM的ADT和内存管理机制展开重点讲清楚这些设计解决的是什么痛点第四节开始转向大模型推理把KV Cache和批处理里的内存问题拿来和编译器做类比最后给出我认为从编译器基础迁移到推理系统最有用的三个工程习惯。如果你只想看推理部分可以直接跳到第四节但看完之后大概率会愿意回头补一下前两节。2. LLVM ADT在“零拷贝”和“零分配”之间做设计的艺术LLVM的ADTAbstract Data Types抽象数据类型不是给用户用的玩具库它是整个编译器框架的钢筋混凝土。里面每个容器的设计都回答了一个问题在这个调用频率极高、对象存活期极短、性能敏感的环境里STL默认行为能不能做得更好答案多数时候是能的而且能好不少。2.1 SmallVector内联存储如何干掉90%的小对象堆分配SmallVector可能是LLVM ADT里最出圈的一个组件几乎可以说它是“带内联缓冲的动态数组”。它的模板签名长这样SmallVectorT, N这里的N代表栈上预分配的元素容量。当实际元素数量不超过N时所有数据都保存在栈上不触发堆分配只有超过阈值才切换到堆存储。STL的std::vector在构造时哪怕只放一个元素也会走一遍堆分配而堆分配的成本不是单纯一次malloc那么简单它涉及用户态与内核态的切换、空闲链表的遍历、内存碎片化的加剧。在编译器这种动辄创建数十万个小列表的场景里这些开销会被放大。SmallVector的思路很朴素既然编译器里大多数容器都装不了几个元素那就干脆让它们别上堆。我在自己写的工具里试过把一些高频路径上的索引列表从std::vectorint换成SmallVectorint, 4在只有六七个元素的典型场景下性能提升了将近两倍。你可以观察一下很多解析器、语义分析器里的局部符号表、参数列表、指令序列元素个数几乎不可能超过两位数。这种场景就是SmallVector的主场。2.2 StringRef与ArrayRef非拥有视图的生命周期契约如果说SmallVector解决的是分配问题StringRef和ArrayRef解决的就是拷贝问题。StringRef本质上就是{const char* Data, size_t Length}这个结构体它不拥有字符串数据只是借用别人的内存。和std::string相比StringRef的构造不需要拷贝字符不会发生堆分配甚至不关心字符串是不是以\0结尾。编译器前端的词法和语法分析阶段是字符串操作的重灾区。每个标识符、每个路径、每个诊断消息都可能产生子串提取。如果用std::string去把这些子串存一遍代价同样是要么堆分配要么反复memcpy。StringRef的做法是只记录起始位置和长度需要字符串内容时直接去原缓冲区切片。代价是从引用者必须保证被引用的内存比StringRef活得更久或者说你必须在生命周期上建立一套明确的契约。ArrayRef是StringRef的泛化版本从字符串扩展到任意类型的数组视图。它和StringRef的哲学完全一致传递视图、避免拷贝、把生命周期责任交给调用者。这套设计在LLVM的IR操作里特别常见比如ArrayRefValue* Operands这样的函数签名就是告诉调用者我只查你不拿你数据还是你的。有一个经验教训值得分享不要在函数里把局部std::string隐式转换成StringRef返回给上层这是悬垂引用的经典做法。如果一定要返回子串视图请确保源头存储的生命周期足够长。这个坑几乎每个用过StringRef的人都会踩一次踩完才知道为什么LLVM代码规范里反复强调StringRef的生命周期问题。2.3 Twine用表达式模板把临时字符串拼到最后一刻Twine是一个容易让人误解的组件它实现的是“惰性字符串拼接”。看下面的代码std::string getName(const Module M) { return Twine(module_, M.getName()).str(); }Twine不会立即创建一个拼接完成的字符串而是把拼接操作记录为一个表达式树等真正需要字符串的时候一次性计算出来。这消除了中间过程产生的多个临时字符串。LLVM里大量诊断信息、符号生成、路径构造场景都在用Twine因为代码路径执行频率极高每次都产生三四个临时std::string再做concat分配开销会直接拉高编译器整体耗时。不过Twine不是万能银弹它的使用有一个很重要的限制被拼接的对象必须是const char*、std::string、StringRef或Twine等类型而且Twine表达式本身不能被保存只能即刻使用否则生命周期会出问题。这个设计和StringRef一脉相承——它们都假设使用场景是“短期的、函数内的、立刻消费掉”。2.4 Optional与Expected用类型系统替代错误码纠缠LLVM ADT还包括Optional和Expected两个容器它们解决的是“可能没有值”和“可能出错”的表达问题。Optional可以看作是对裸指针判空的类型安全升级Expected则更进一步它要么返回正常值要么携带一个Error类型的错误信息。在编译器代码里很多函数需要表达“这个查找可能失败”传统C风格会返回错误码加出参或者返回一个可空的指针。这些做法都有信息丢失的风险。Expected把成功和失败作为第一等公民从类型层面强制调用者处理错误配合auto Err canFail(); if (Err) return Err;这样的模式错误传播变得清晰又安全。这套东西跟我后面要讲的大模型推理也有直接关系。推理引擎里很多查询操作同样可能失败比如KV Cache的块分配失败、某个张量的维度不匹配、某次算子调度找不到实现用Expected来建模这些可能性比靠约定返回nullptr再让上层小心翼翼地判断要稳定得多。LLVM ADT的整体设计哲学归结起来就是三句话能省则省不拷贝、不分配、能晚则晚惰性计算、延迟拼接、能显式则显式用类型表达所有权和错误。这套哲学放到任何高性能系统里都适用包括大模型推理。3. LLVM的内存管理机制Arena、所有权与阶段化释放说到LLVM的内存管理很多人第一反应是BumpPtrAllocator但实际上整个机制是一整套层次化的策略分配器、所有权模型、阶段化释放三件事互相配合。3.1 BumpPtrAllocator为什么是编译器基础设施的标配BumpPtrAllocator也就是常说的竞技场分配器Arena Allocator它的工作方式非常暴力一次性向系统申请一大块连续内存然后用一个指针在块内线性分配每分配一个对象就把指针向后移动相应字节。释放单个对象的时候什么都不做只有当整个竞技场生命周期结束时才一次性归还全部内存。编译器为什么需要这个因为编译过程的临时对象实在太多了。每解析一个表达式就要生成一堆AST节点每做一次常量折叠就可能产生新的常量对象这些对象绝大多数都不需要单独释放它们的生命周期和所在编译阶段一致。假如对每个AST节点单独malloc/free光是把分配和释放的记录塞进内存分配器就要消耗大量CPU周期还会导致内存碎片和糟糕的缓存局部性。BumpPtrAllocator的另一个优势是分配速度极快。因为所有对象都挤在一块连续区域里指针递增一次就能完成分配这就让生成和销毁都变得异常高效。而且编译器里各个阶段的生命周期边界非常清晰词法阶段结束Token流整体作废语法阶段结束AST可以整体释放IR优化阶段结束旧的IR布局不再需要。这种“整体释放”的语义和编译器天然匹配。3.2 所有权模型unique_ptr、Immutable与作用域释放有人可能会问既然有BumpPtrAllocator为什么不把所有对象都塞进去原因在于并不是所有对象的生命周期都跟阶段绑定。有些跨阶段共享的对象比如模块级别的元数据、全局符号它们必须在整个编译过程中存活。这种场景下LLVM选择把所有权在类型层面显式表达出来。std::unique_ptr在LLVM代码里出现频率很高它表达的是“唯一拥有离开作用域就释放”的语义。相比之下std::shared_ptr在LLVM内部反而很少见因为引用计数本身有成本而且在编译器这种明确知道谁该负责释放的场景里引用计数是多余的。道理很简单责任明确的东西就不要引入运行时计数开销。LLVM里还有ImmutableSet、ImmutableMap这类持久化数据结构。它们一旦创建就不可修改插入删除会返回新版本而旧版本依然可用。这种不可变设计带来的好处是跨阶段共享时不需要担心别人修改了自己正在使用的数据。和分析器、优化器配合时这种不可变性极大地简化了推理我拿到的数据就是最终数据不会再有人偷偷改动它。这三个工具加起来形成了LLVM完整的所有权工具箱Arena负责阶段内对象的批量管理unique_ptr负责明确归属的单体对象Immutable系列负责共享数据的安全不变性。3.3 从malloc开销到缓存局部性编译器内存策略的性能逻辑看LLVM的内存管理不能只看省了哪几次malloc还要看它背后更深一层的性能逻辑缓存局部性。现代CPU的缓存大小有限而编译器的扫描和遍历模式往往是高度局部的。BumpPtrAllocator把同阶段创建的对象放在连续内存里遍历这些对象时缓存命中率会显著高于使用散落的堆对象。你可以做个实验用std::vectorstd::unique_ptrNode存一万个节点再换成SmallVectorNode, 0配合Arena分配遍历速度通常有数倍差距这个差距就来自缓存局部性。还有一点容易被忽视碎片化。一个长时间运行的分布式编译服务如果到处小对象多次memset和free堆碎片会让后续的大块分配失败或者让内存占用疯狂膨胀。Arena的整块分配、整块释放彻底告别碎片问题对长期运行的推理服务尤其重要。从这里开始你其实已经能隐约看见推理系统的轮廓了显存不就是一个超级大的竞技场吗KV Cache分配块不就是变相的BumpPtr吗把这个问题想透自然就会明白为什么推理引擎也在设计内存池和块分配器。4. 大模型推理里的内存问题一个放大版的编译器难题现在把视线从编译器转到推理服务。如果你做过部署一定对这几件事有印象如果还没做过下面这些内容能帮你理解为什么推理框架的工程实现如此重要。4.1 KV Cache一个必须被显式管理的巨型缓冲区自回归模型每次生成一个token都要把当前步的Key和Value缓存下来供后续步骤计算注意力时使用。这个缓存区就是KV Cache。它的大小和序列长度、batch大小、层数、注意力头数、每个头的维度线性相关。对于一个稍微大一点的稠密模型单个长序列的KV Cache轻轻松松吃下几个GB的显存多路并发时就更夸张。KV Cache的关键特点是它必须跨解码步骤存活但每个序列的存活时长又不一样。一个对话里用户问三句就结束另一个会话可能要生成上千token才结束。如果为每个请求都预留最大长度的KV空间显存利用率会低得可怕。这就回到编译器里的老问题对象的生命周期不一致但你又必须提前分配存储空间怎么办4.2 连续批处理与按页分配从虚拟内存借鉴的分页思想传统批处理的做法是把一批请求凑齐以后再统一执行所有请求按最大长度预留空间。这种方法浪费明显短的请求把大量显存空着但空着也没法用。连续批处理continuous batching改变了调度粒度它在每一步动态决定哪些序列参与计算序列可以随时加入或退出彻底消除了“必须等满一批才能开跑”的限制。要做到动态加入退出KV Cache自然不能再按整段预留。按页分配PagedAttention的思路是把KV Cache切成固定大小的块每个序列只按需申请块用完的块可以归还然后再分配给其他序列。这个设计和操作系统的分页机制如出一辙逻辑上每个序列认为自己有一片连续的缓存物理上这些页可能散布在显存的各个角落由一个块表来维护映射关系。这个思想放在编译器的语境里简直不能再熟悉了逻辑地址空间连续、物理空间离散虚拟内存帮你解决了碎片问题。推理框架在显存层面做的事情和操作系统在内存层面做的事情完全同构。4.3 序列级生命周期不同请求的内存释放时机推理服务和编译器还有一个相似点释放时机决定一切。在编译器里AST的释放时机是语法分析阶段结束的时候在推理服务里KV Cache的释放时机是序列生成结束的时候。一个序列如果提前终止比如触发了停止符、用户主动中断、生成质量不达标被截断它的KV Cache必须立刻收回让给新到达的请求。块分配器在这里的价值就体现出来了固定大小的块可以精准回收、重新分配不存在大小不匹配导致的内部碎片。同样是维护“阶段边界释放”的语义编译器用BumpPtrAllocator推理引擎用显存块池。两者在高层抽象上惊人地一致不跟踪单个对象的析构只跟踪所有者的生命周期。这也是我为什么说大模型推理的内存管理是编译器难题的放大版——问题的典型特征一模一样只是数据规模和硬件约束变了。5. 推理框架的数据结构选型ADT思想的另一次应用LLVM ADT带给我们的不只是几个容器类更是一套数据结构和资源管理的方法论。推理框架在工程实现中大量使用了同构思想只是很多实现不会明说它借鉴了编译器。5.1 逻辑块与物理块的映射类似于IR到机器码的降级PagedAttention那一套逻辑块到物理块的映射可以看作编译器里IR到机器码降级的特例。IR是逻辑的、独立的机器码是物理的、受寄存器数量约束的逻辑KV Cache是序列视角的连续内存物理KV Cache是显存视角的散落页块。实现层要做的事情是维护一个块表每个逻辑块号对应哪个物理块号、每个物理块当前被哪些序列引用、还有多少空闲块。这个表的存在意味着上层计算逻辑根本不需要关心物理分布只需要按逻辑块号访问。推理框架里实现这个映射的数据结构和操作系统里的页表编译器里的地址映射表本质上是同一个东西。从工程上看这类映射结构的选型有一个关键指标查询复杂度。推理服务每一步都要查询块表如果查询是O(n)的整个引擎就被拖垮了。所以工业实现通常会用哈希表或基数树来加速这个取舍和编译器符号表面临的问题一模一样。5.2 用分配器管理推理内存池BumpPtr的变体实践继续往深处挖你会发现不少推理框架在显存管理上走的路径就是BumpPtrAllocator的变体。它们维护一个预分配的显存池块分配器只负责从池里取快、还快不负责真正调用显存分配接口。这是因为显存分配比堆分配更贵频繁申请释放显存不仅慢而且容易触发碎片化。这里有一个很微妙的工程点池本身的容量需要动态扩张但扩张又会导致地址变化或显存复制。所以工业实现通常会设置一个水位线在池使用率超过警戒线时预先进行碎片整理或扩容。这个过程对应到LLVM里就是把Arena的容量增长策略做成了可配置的。如果你在自研推理框架我的建议是在设计存储时优先考虑分配器模式而不是散装malloc式管理。你不需要把每个对象都用分配器管理但KV Cache这类高频、大块、生命周期清晰的缓冲应该走池化路线。5.3 张量视图与零拷贝StringRef的姊妹思想推理引擎里还有一个不可或缺的概念张量视图Tensor View。一个Tensor包括数据指针、形状、步长、设备类型和数据类型等信息。reshape、squeeze、slice这些操作在理想情况下不应该拷贝数据只应该修改视图元数据就像StringRef只修改指针和长度。零拷贝在这里意味着可以在不搬动几GB显存数据的前提下完成算子输入输出形状的变换。但视图的危险性也和StringRef一样数据所有者的生命周期必须覆盖视图使用者的生命周期。如果你把一个已经释放的显存块的视图传给了异步算子算子执行时就会读到已经重新分配的数据这时的错误会表现为看似随机的数值异常有时候还伴随着难查的显存错误。排查过这种问题的人应该都有体会它比普通的空指针更难定位因为出错现场和真正的根因之间往往隔了好几层异步调用。好在这一类问题有一个工程解法显式标记视图的来源缓冲并在算子执行前用调试模式做所有权断言。这和LLVM的断言式编程风格高度一致让错误在开发期就暴露而不是留到线上环境随机爆炸。6. 学编译基础设施对做推理系统最有用的三个习惯技术细节讲完了最后聊几个让我收获很大的工程习惯也是我从编译器基础迁移到推理系统之后觉得最有用的东西。第一个习惯先问生命周期再谈性能。不管是设计一个容器、一个缓存还是一个算子调度器第一个要回答的问题永远是数据从哪里来要活多久谁负责释放这个问题没想清楚后面所有性能优化都是空中楼阁。LLVM的代码规范实际上一直在强迫开发者养成这种思维。推理框架也是KV Cache的回收时机、临时张量的复用窗口、中间结果的释放点都得先想生命周期再做优化。第二个习惯数据结构是接口的一部分。LLVM里你看到函数签名是ArrayRefT还是const std::vectorT不只影响性能还在传达语义前者是“我不拥有、你别改”后者是“你可以沿用容器的接口”。推理框架里同样是这个道理当一个API接收的是Tensor视图而不是拷贝时它就在用接口声明“这里允许零拷贝”。工程协作里这种通过类型达成的默契往往比注释更可靠。第三个习惯一切性能问题都要落在细节数据上。编译器的性能优化从来不是靠直觉而是靠profile数据定位热点再针对热点做结构级调整。推理引擎的显存优化同样如此如果你不看内存池的空闲块率、块表查询次数、物理块的碎片率只凭感觉调参大概率是白费功夫。我自己的体会是刚从编译器转向推理系统时会有一种很强的“似曾相识感”很多问题的轮廓都和编译器里的老问题相似只是换了存储介质、换了并发模型、换了数量级。但也正是这种相似感让我有了一个清晰的方向把编译器里那些成熟的内存管理和数据结构思想转译过来再根据推理硬件的特性做适配。这篇随笔先写到这里。如果你也对LLVM的ADT和内存管理的具体实现细节感兴趣可以自己动手把SmallVector和BumpPtrAllocator的源码翻出来读一读再对照着手边推理框架的显存块分配器看一遍。我敢说你也会发现这些看似不相干的系统底层思考方式至少有一半是重叠的。下一篇文章我准备讲讲具体的块分配器实现以及在不改动模型结构的前提下如何把KV Cache的峰值占用再往下压一压。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑