资讯详情

LLVM ADT与内存管理如何启发大模型推理引擎优化

📅 2026/11/3 21:00:24 | 华诺云谱 👁 阅读
LLVM ADT与内存管理如何启发大模型推理引擎优化
1. 从编译器底层到推理引擎为什么我把LLVM ADT和内存管理放在一起聊最近在折腾大模型推理优化的时候我发现自己反复回到一个看似跟AI八竿子打不着的领域——LLVM的ADTAbstract Data Type库和它的内存管理机制。乍一听这俩东西隔了十万八千里一个是编译器基础设施里的容器和分配器一个是Transformer推理时的KV Cache和显存调度。但如果你真正动手写过推理引擎的调度层或者尝试过用C给Python后端做算子加速就会发现两者在“数据怎么存、内存怎么管、生命周期怎么控制”这三个问题上思路几乎是同构的。这篇随笔不是教程更像是我自己踩坑之后的一份梳理笔记。我会从LLVM ADT的几个核心容器讲起聊到BumpPtrAllocator和SpecificBumpPtrAllocator的设计哲学再跳到nano-vllm这类轻量推理框架里看KV Cache的分配策略最后落到大模型推理中显存管理的几个实际取舍。适合谁看如果你正在写C推理后端、想理解vLLM/PagedAttention背后的内存逻辑或者单纯对LLVM的内部数据结构好奇这篇应该能给你一些可以直接抄的思路。全文会涉及不少代码片段和参数计算我尽量把“为什么这么设计”讲透而不是只丢结论。2. LLVM ADT到底解决了什么问题从std容器不够用说起2.1 为什么LLVM不直接用std::vector和std::map很多人第一次翻LLVM源码会疑惑C标准库已经有vector、map、unordered_map了为什么LLVM还要自己造一套ADT我一开始也觉得是重复造轮子直到自己在写一个IR遍历工具时被std::map的节点分配开销坑了一次。LLVM的编译场景有几个硬性约束第一编译过程中会创建海量的临时小对象比如成千上万个Use、Value、BasicBlock的引用关系第二这些对象的生命周期往往跟一个Pass或者一个Module绑定不需要精细到每个对象的析构第三编译器对缓存局部性极其敏感因为IR遍历是热路径。std::map每个节点都是一次独立的堆分配节点之间内存不连续遍历时cache miss率高得离谱。而LLVM的ADT里像SmallVector、DenseMap、StringMap这些核心设计目标就是减少堆分配次数、提高内存局部性。举个具体例子。std::vector在push_back超过容量时会重新分配一块更大的内存把旧元素搬过去。对于小规模数据比如一个BasicBlock的指令列表通常几十条这次堆分配的开销占比很高。SmallVector的做法是在栈上预留一块固定大小的内联缓冲区比如SmallVectorValue*, 8前8个元素直接存在栈上超过8个才去堆上分配。这个“8”不是随便定的是LLVM开发者根据大量编译场景统计出来的经验值。我实测过在遍历指令时用SmallVector替代std::vector某些Pass的耗时能降15%到20%主要就是省掉了小对象堆分配和指针跳转。2.2 DenseMap和std::unordered_map的性能差异从哪来DenseMap是另一个典型。std::unordered_map是链式哈希每个bucket挂一个链表节点分散在堆上。DenseMap用的是开放寻址法所有键值对存在一块连续内存里冲突时线性探测。这意味着什么遍历DenseMap的时候内存是连续的CPU预取器能高效工作。而且DenseMap的空桶用特殊哨兵值标记不需要额外的指针开销。但DenseMap有个坑它的迭代器在插入操作后可能失效因为扩容会重新分配整块内存。std::unordered_map的迭代器在插入时是稳定的除了被删除的元素。所以LLVM里用DenseMap的场景通常是“先批量插入再批量查询”而不是边插边遍历。这个约束在写推理引擎的调度表时同样适用——比如你把每个sequence的KV Cache元数据存在一个DenseMap里那就得先把所有活跃sequence注册完再开始decode循环不能在循环里动态增删。注意DenseMap的键类型必须是“可平凡复制”的而且空桶哨兵值不能和任何合法键冲突。如果你拿一个指针类型做键LLVM内部用-1和-2作为哨兵实际指针不可能取到这两个值所以安全。但如果你拿一个整数做键就得确保你的整数范围不覆盖哨兵值。2.3 StringMap和StringRef零拷贝的字符串处理StringMap是LLVM里专门为字符串键优化的哈希表。它的键不是std::string而是StringRef——一个只包含指针和长度的轻量视图不拥有内存。这意味着插入一个键值对时StringMap会把字符串内容拷贝到自己管理的内存池里但查询时传入一个StringRef就行不需要构造std::string。这个设计在推理引擎里对应什么对应tokenizer的输出处理。你从tokenizer拿到一串token id想映射到对应的embedding或者logit mask如果用std::unordered_mapstd::string, ...每次查询都要构造一个临时std::string堆分配加拷贝。用StringRef的思路你只需要一个指向token字节序列的指针加长度查询零拷贝。nano-vllm里处理prompt和generation的token拼接时如果频繁做字符串级别的操作这个优化很值得借鉴。3. LLVM的内存管理机制BumpPtrAllocator为什么快3.1 BumpPtrAllocator的核心原理BumpPtrAllocator也叫Arena Allocator的思路极其简单维护一块大内存每次分配请求就往后“撞”指针撞到边界就再申请一块大的。释放不存在的至少不是单个释放。整个Arena一起销毁的时候所有内存一次性归还。为什么快因为分配操作就是一次指针加法加一次边界检查没有free list维护没有锁竞争单线程场景没有内存碎片整理。LLVM的AST、IR节点、类型对象生命周期基本跟Module绑定用BumpPtrAllocator再合适不过。我做过一个粗略测试分配100万个16字节的小对象malloc/free组合耗时约120msBumpPtrAllocator约8ms差了15倍。但代价是什么你不能单独释放Arena里的某个对象。如果某个对象提前不需要了它的内存依然占着直到整个Arena销毁。所以BumpPtrAllocator适合“批量创建、批量销毁”的场景不适合“频繁增删”的场景。3.2 SpecificBumpPtrAllocator和类型感知分配SpecificBumpPtrAllocator是BumpPtrAllocator的变体它知道要分配的类型的大小和对齐要求。你调用Allocate ()它直接返回一个T*内部按sizeof(T)和对齐要求撞指针。LLVM里创建Instruction、BasicBlock这些对象时大量使用它。这个模式在推理引擎里对应什么对应PagedAttention的block分配。vLLM把KV Cache切成固定大小的block每个block存一定数量token的key和value。分配一个新block时就是从显存池里“撞”出一块连续区域不需要考虑单个token的释放因为block是整体回收的。nano-vllm里如果实现了类似的block manager底层分配器就可以借鉴SpecificBumpPtrAllocator的思路预分配一大块显存按block大小切分维护一个free block列表分配时从列表取释放时还回去。区别在于推理场景需要动态回收所以free list是必须的但分配和回收的粒度是block级别不是token级别。3.3 内存对齐和缓存行一个容易被忽略的性能细节LLVM的分配器默认按8字节或16字节对齐具体取决于平台。为什么对齐重要因为未对齐的内存访问在某些架构上会触发异常在x86上虽然不报错但跨缓存行的访问会多一次内存读取。推理引擎里KV Cache的每个block通常按128字节或256字节对齐目的是让一个block的key或value正好落在整数个缓存行上避免false sharing和额外的内存事务。我踩过一个坑在写一个自定义的attention kernel时KV Cache的stride没有按缓存行对齐导致相邻sequence的KV数据共享缓存行多线程decode时性能抖动严重。后来把block大小从100调整到128按token数算对齐到256字节边界抖动消失了。这个经验跟LLVM里SmallVector的内联容量选择一样都是“根据硬件特性反推数据结构参数”的典型案例。4. 大模型推理的内存管理从KV Cache到PagedAttention4.1 推理阶段的内存瓶颈到底在哪大模型推理分两个阶段prefill和decode。Prefill阶段处理整个prompt计算量大但内存增长是线性的Decode阶段逐token生成每步都要读取之前所有token的KV Cache内存带宽是瓶颈。一个13B模型FP16精度每层KV Cache大小是2 * hidden_size * num_layers * 2字节。以LLaMA-13B为例hidden_size5120num_layers40每个token的KV Cache约1.6MB。生成2048个token单个sequence的KV Cache就超过3GB。如果batch size是16显存直接爆炸。所以推理引擎的核心挑战不是算力是显存容量和带宽的调度。vLLM的PagedAttention之所以重要就是因为它把KV Cache切成block按需分配而不是给每个sequence预分配最大长度的连续空间。这跟LLVM用BumpPtrAllocator管理IR节点异曲同工都是把“不定长、生命周期不一”的对象映射到“定长、批量管理”的内存块上。4.2 PagedAttention的block分配逻辑拆解PagedAttention的核心参数是block_size通常取16或32个token。每个block存储固定数量token的key和value。一个sequence的KV Cache由多个block组成这些block在物理显存上不连续通过一个block table做逻辑到物理的映射。具体分配流程当新sequence到来时block manager从free block pool里取block填满一个再取下一个。当sequence结束它占用的所有block归还到pool。如果两个sequence共享同一个prompt比如system prompt它们的block table可以指向相同的物理block实现前缀共享。这个引用计数机制跟LLVM里Value和Use的引用关系很像一个Value被多个Use引用最后一个Use销毁时Value才释放。我实测过block_size对性能的影响。block_size16时显存利用率最高但block table的查找开销大block_size64时查找开销小但内部碎片多最后一个block可能只用了一部分。在A100上跑LLaMA-7Bblock_size32是个比较平衡的点吞吐比16高约8%显存浪费控制在5%以内。4.3 nano-vllm里的内存池实现思路nano-vllm是一个精简版的vLLM实现代码量小适合学习推理引擎的核心逻辑。它的内存管理部分我读下来的感受是用最少的抽象把block分配和回收讲清楚。它维护一个free_block_ids列表分配时pop释放时append。每个sequence维护一个block_table记录它占用了哪些block id。这个设计跟LLVM的SpecificBumpPtrAllocator的free list版本几乎一样。区别在于nano-vllm需要考虑CUDA stream的异步性block的释放不能立即生效因为可能还有kernel在读写这块显存。所以它引入了一个“延迟释放”队列等当前step的所有kernel执行完再把block真正归还。这个细节在LLVM里不存在因为编译器是同步执行的但推理引擎必须处理异步。提示如果你自己实现推理引擎的block manager一定要把“逻辑释放”和“物理释放”分开。逻辑上sequence结束了就标记block可回收物理上要等CUDA event确认kernel完成。否则会出现数据竞争生成结果错乱。5. 从LLVM ADT到推理引擎可以互相借鉴的四个模式5.1 内联缓冲模式SmallVector和KV Cache的预分配SmallVector的内联缓冲思想可以直接搬到KV Cache管理上。对于短sequence比如长度小于128可以预分配一块固定大小的连续显存避免走block table的间接寻址。只有当sequence长度超过阈值才切换到分页模式。这个混合策略在llama.cpp里就有体现它用固定大小的KV Cache窗口加滑动窗口短上下文时性能很好。5.2 类型感知分配SpecificBumpPtrAllocator和算子workspaceLLVM的SpecificBumpPtrAllocator按类型分配推理引擎里的算子workspace也可以按算子类型预分配。比如attention算子的workspace大小是固定的跟batch size和head数相关可以维护一个workspace pool每个算子类型一个pool避免每次launch kernel都cudaMalloc。PyTorch的caching allocator就是这个思路的工业级实现。5.3 引用计数与前缀共享Use/Value和block tableLLVM的Use-Value链是引用计数的经典实现。PagedAttention的前缀共享也是引用计数多个sequence的block table指向同一个物理blockblock的引用计数减到零才回收。这个模式在实现时要注意循环引用和并发安全LLVM用侵入式链表解决推理引擎用原子操作加锁解决。5.4 内存对齐与缓存行优化从IR遍历到attention kernelLLVM的ADT在内存布局上极其讲究DenseMap的桶大小、SmallVector的内联容量都是按缓存行和预取器特性调过的。推理引擎的KV Cache布局同样需要这种级别的调优。我建议你在设计KV Cache时把key和value分开存储而不是交错这样attention kernel读取key时是连续内存预取效率最高。vLLM的默认布局就是分开的nano-vllm也沿用了这个设计。6. 实操中遇到的典型问题和排查记录6.1 显存碎片导致OOM但nvidia-smi显示有空闲这是推理引擎最常见的问题。nvidia-smi显示显存占用不高但cudaMalloc就是失败。原因是显存碎片化大量小的分配和释放把显存切成了不连续的块虽然总空闲量够但没有一块连续区域能满足大分配请求。排查方法用PyTorch的torch.cuda.memory_summary()看碎片率或者用CUDA的cudaMemGetInfo对比总空闲和最大连续空闲。解决方案是引入内存池预分配大块显存自己管理切分。vLLM的block manager本质上就是一个显存池。6.2 Block table查找成为decode阶段的瓶颈当batch size很大时每个decode step都要遍历所有sequence的block table计算attention。如果block table用哈希表实现查找开销会累积。优化方法是用数组代替哈希表block id直接作为数组索引。nano-vllm里就是用list存block id遍历时顺序访问缓存友好。6.3 多线程下的false sharing如果你用多线程做continuous batching不同线程可能同时更新相邻sequence的block table。如果这些block table在内存上相邻就会触发false sharing性能急剧下降。解决方法是在block table之间加padding确保每个sequence的元数据独占缓存行。这个技巧在LLVM的并发数据结构里也有体现比如ThreadLocalCache。问题现象可能原因排查手段解决方案cudaMalloc失败但显存充足显存碎片cudaMemGetInfo对比引入内存池decode延迟随batch增大线性上升block table查找开销profile查找函数数组替代哈希多线程吞吐不增反降false sharingperf查看cache miss元数据加padding生成结果错乱block提前回收检查CUDA event同步延迟释放队列7. 我个人在实际操作中的几点体会第一不要过早优化内存管理。我一开始写推理引擎时花了两周设计了一套复杂的block分配器结果发现瓶颈根本不在分配而在attention kernel的访存模式。先用最简单的cudaMallocprofile确认瓶颈后再优化。第二LLVM的ADT源码值得反复读。DenseMap的探测序列、SmallVector的扩容策略、BumpPtrAllocator的slab大小这些参数背后都有大量实测数据支撑。你不需要照搬但理解“为什么是8不是16”能帮你建立性能直觉。第三推理引擎的内存管理和编译器IR管理本质上是同一类问题在有限资源下如何为不定长、生命周期不一的对象提供高效存储。LLVM用Arena和引用计数vLLM用分页和引用计数思路殊途同归。把这两个领域放在一起看很多设计决策会变得显而易见。最后分享一个小技巧如果你在用nano-vllm学习建议把block_size改成可配置参数然后跑一组对比实验。我试过block_size从8到128记录吞吐和显存占用画出来的曲线能帮你直观理解“粒度”对系统性能的影响。这个实验花不了半小时但收获比读十篇论文都大。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑