
最近这两周晚上的画风有点分裂一边在翻LLVM的ADT头文件和BumpPtrAllocator的实现一边跟着nano-vllm的调度循环调大模型推理的显存分配。一个是编译器后端的地基工程一个是AI推理服务的性能主战场按理说八竿子打不着。但把两份代码同时摊开之后我越看越觉得它们其实在回答同一组问题数据怎么表达才不浪费、资源怎么分配才不碎片、依赖怎么梳理才不乱、生命周期到底该由谁负责。这篇随笔不打算做教程式输出而是把我这段交叉阅读的收获强制整理一遍。内容大致会扫过LLVM ADT里几个高频数据结构、LLVM的内存管理哲学再顺着显存治理和算子自发现一路聊到大模型推理框架。标题既然挂了个随笔01目标就定得实在一点每个主题不求讲透但画准画像让后面几篇能在这个地基上继续长。1. 编译器蓝本与推理引擎之间的同构感在哪里1.1 三个绕不开的底层问题把LLVM和大模型推理引擎放到一起看不是标题工程而是它们都要回答同样的一组底层问题。第一个问题是数据结构的承载能力。LLVM的IR对象在pass之间来回穿梭需要大量只读视图不拷贝切片的操作推理引擎的tensor在模型前向过程里也是反复引用、变形、transpose理想情况同样是零拷贝。可现实里没人愿意为所有场景各写一套容器所以两边都催生了高度定制的数据结构。LLVM有自己的一套ADTvLLM则用自己的block管理器和tensor元信息描述来减少反复搬运。第二个问题是资源分配的粒度。编译器在优化阶段反复创建和销毁指令节点如果每次创建都走malloc百万级IR的构建成本会直接爆炸。推理引擎则要在几百个并发请求之间分配KV Cache显存的申请释放频率直接决定吞吐和碎片率。两者都不约而同地选择了批量分配、按池回收的路子。第三个问题是编排与依赖。编译器里pass之间有AnalysisManager来维护依赖关系同一个分析结果可以被多个pass共享缓存推理引擎的scheduler要根据tensor依赖、显存水位、请求优先级来决定这一步该让哪些序列前向、哪些序列先抢占。依赖关系处理得好不好直接决定整个执行管线顺不顺。理解了这三点再回头去看LLVM ADT和内存管理机制很多设计就不会只看表面而是能明白它为什么这么设计。1.2 我这段时间的实际学习环境先说LLVM这边。我本地用的是LLVM 19的源码构建时强烈建议只保留host targetcmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPERelease \ -DLLVM_ENABLE_ASSERTIONSON \ -DLLVM_TARGETS_TO_BUILDhost \ -DLLVM_BUILD_TOOLSONLLVM_ENABLE_ASSERTIONS不要关很多ADT和内存管理的不变量比如迭代器失效、数组越界的assert靠它兜底。LLVM_TARGETS_TO_BUILDhost能省掉一大半编译时间我们只是读代码不需要交叉后端。推理那边我在看nano-vllm仓库不大Python 3.10、PyTorch 2.x的环境下clone下来就能跑。用CUDA平台跑一个小规模的Llama模型做推理然后专门在scheduler和block_manager上打断点。这么做的原因很简单同一套问题的两套答题方式放在一起看记忆才深。只读LLVM的arena分配器容易觉得那是编译器特殊需求只有当你在nano-vllm里看到显存池按块分配、按请求归还才会意识到这类方案是高性能系统的通用答案。2. LLVM ADT到底解决了什么问题如果你平时写C业务代码看LLVM ADT的第一反应是这不就是加强版std::vector和std::string_view吗。有这种感觉正常。但你对照GCC或MSVC库里那套面向通用场景的实现再来看LLVM ADT会发现它完全是按编译器的使用方式从零设计的牺牲通用性换取极致的分配次数和缓存局部性。2.1 StringRef/ArrayRef不拥有数据只描述视野StringRef是先于std::string_view出现的东西本质就是一个(const char*, size_t)的二元组引用了别人的字符缓冲区自己不持有、不管理、不拷贝。它的好用之处在于几乎所有字符串操作都可以原地完成StringRef full getFileContent(); StringRef trimmed full.trim(); StringRef firstLine trimmed.split(\n).first; fx: drop_front(2).take_front(16);注意split返回一对StringRef没有产生任何字符串拷贝。这在lexer和parser里可以省掉成千上万次临时string构造。ArrayRef则是把同样的思路泛化成任意类型它描述一段连续内存的只读视图。函数签名用ArrayRef而不是vector好处是调用方可以传SmallVector、std::vector、甚至std::initializer_list不用为了传参构造临时vector。比如bool canFold(ArrayRefValue* ops); canFold({a, b, c}); // 不用临时 vector我实际踩过的坑是永远不要把ArrayRef存成成员变量除非你能严格保证底层数组的生命周期覆盖它。StringRef/ArrayRef的哲学是用的时候再取不要保存它适合做参数和局部变量。2.2 SmallVector栈上预分配的小型仓库SmallVectorT, N给容器预分配了N个元素的栈空间当元素数量不超过N的时候完全不会触发堆分配。LLVM里大量场景是一条指令三五个操作数一个基本块十几条指令用std::vector的话每次构造都要malloc分配器开销比实际元素拷贝还贵。这种设计的本质是用小概率的浪费栈上预留N个元素空间换取大概率情况下的零堆分配。N的选择很有讲究选太大占用栈空间选太小命中率下降。LLVM内部常用的N是4、8、16具体量级看容器在高频路径上的典型大小。使用SmallVector时有个和std::vector不太一样的习惯LLVM风格里经常直接构造后调用push_back很少reserve因为当N够大的时候reserve也只是移动cur指针。当超过N时SmallVector会转到堆上分配扩容策略和张量分配器有相似之处。2.3 DenseMap开放寻址带来的性能与纪律DenseMap是LLVM为高频查找场景自研的哈希表。它采用开放寻址open addressing所有元素放在一块连续内存上而不是像std::unordered_map那样每个节点单独分配再串链表。连续内存带来的收益是缓存局部性一个cache line能载入多个bucket查找效率在数据量大时差距非常明显。代价是使用纪律更严格。开放寻址的删除使用墓碑标记插入触达阈值会整体rehash。最影响使用体验的是DenseMap的迭代器在插入导致rehash后全部失效。这一点和std::unordered_map节点式迭代器相对稳定很不一样。所以遍历DenseMap时如果要插入新元素要么提前把key收集到SmallVector里要么先把迭代器推进完再做插入。类似这种为了快牺牲一点直觉的设计在LLVM ADT里到处都是。APInt表示任意位宽整数Twine延迟字符串拼接iterator_range把指针对封装成可迭代对象。这套容器库最值钱的地方不是单点性能而是它把整个编译器的数据结构底座统一了每个子系统都知道对方的数据布局是什么样尽量减少适配层。3. BumpPtrAllocator与一次性释放的内存哲学聊LLVM就绕不开BumpPtrAllocator。我在读llvm/Support/Allocator.h时反复看了好几遍它其实简单得惊人但哲学足够深刻。3.1 原理内存就是一条持续推进的水位线BumpPtrAllocator维护了一组大块内存。每次allocate时如果当前块剩余空间够用就做两件事记录当前指针、把指针往后推进Size字节然后返回原来的地址。不够用就新开一块更大的块。整个过程没有任何free也没有任何空闲列表。void* allocate(size_t Size, size_t Alignment) { if (Size Threshold) { // 超过阈值单独分配一块大内存 return malloc(Size); } // 否则从当前 slab 的水位线上切一块 uintptr_t AlignedPtr alignTo(CurPtr, Alignment); ... CurPtr AlignedPtr Size; return reinterpret_castvoid*(AlignedPtr); }生活化的类比是装修工地按项目统一订一车砖泥瓦匠用多少就从砖堆里拿多少用完的砖头渣子不单独清理项目整体结束后连同工地垃圾一起清走。如果每个砖头都要单独记账、单独回收成本比砖本身还高。由此引出的特性是BumpPtrAllocator不回收到单个对象粒度只支持整块释放。整个arena析构时所有从它分配出去的内存一起失效。3.2 为什么编译器敢不调用析构函数这一点是我觉得最反直觉的地方。BumpPtrAllocator不管对象析构直接整块扔掉。这在普通业务代码里是不可接受的但在LLVM IR构建场景下完全成立原因有三层。第一层IR节点的成员几乎都是POD或由同一arena分配的容器。比如整数常量就是APInt加一个opcode析构不涉及外部资源。第二层如果某个节点需要字符串内容LLVM不会单独给字符串分配一块独立内存而是用StringMap把字符串字符数据也放进同一个arena字符串的释放成本和arena生命周期一致。第三层IR是在pass之间构建、转换、销毁的典型的生命周期边界就是某个pass的run函数或整个Module的编译单元。所以这里有个朴素但关键的判断当整个内存区的生命周期明确且统一时逐个调用析构是没有意义的。把析构工作推迟到整体回收时一起处理省掉的不只是析构调用本身还有编译器需要维护的哪个对象还活着的记账信息。3.3 所有权与Use列表对象之间靠引用不靠计数再往深一层LLVM对象之间的所有权关系也很特别。IR里的Value、User、Instruction相互引用构成一个巨大的def-use图。对象之间不是通过shared_ptr计数的而是通过Use对象显式连接User内部持有一组Use每个Use指向被使用的ValueValue反过来维护一个use链表记录谁在用我。这种设计的核心是容器所有权。基本块里的指令由BasicBlock的指令链表ilist拥有函数的基本块由Function拥有模块又拥有函数。删除一条指令是调用Instruction::eraseFromParent()它从容器的链表上解链同时把def-use边全部清理掉。它不需要像shared_ptr那样计算引用次数因为所有权链条是树状的、清晰的。把BumpPtrAllocator和ilist放在一起看才真正理解LLVM的内存哲学它选择在编译器的生命周期边界上做整体资源管理而不是在每个对象的生命周期上做细碎管理。空间换时间结构换确定性。4. 带着编译器内存哲学看大模型推理的显存治理读LLVM内存管理读到有点上头的那个周末我正好在看nano-vllm的显存分配代码。两个体系对照着看产生了一种这不就是同一道题吗的既视感。4.1 KV Cache的预分配恐惧症与显存碎片大模型推理的性能大头在KV Cache。生成每个token时注意力层需要把历史token的Key和Value缓存下来否则每一步都要从头重算计算量直接平方级爆炸。代价是显存占用随序列长度线性增长长上下文场景下KV Cache能占到70%以上显存。朴素实现是每个请求进来就给它的KV Cache预分配最大长度max_seq_len的连续显存块。这个方案有两个问题。一是预分配恐惧症多开一个请求就多预分配一大块可实际生成几十个token就结束的请求占不满显存利用率极低二是碎片问题不同请求先后结束释放的显存大小不一新请求分配不上即便总显存还够用。这种场景和编译器里每次new/delete指令的碎片化问题本质是同一个问题只是对象从指令换成了显存块。4.2 PagedAttention把KV Cache做成操作系统的分页vLLM的做法是把KV Cache切成固定大小的块block比如按block_size16个token为一块。逻辑上属于同一个序列的KV物理上可以分散在显存的任意块位置靠一张block table记录逻辑块到物理块的映射。这其实就是操作系统的分页思想。LLVM的arena分配器是一次性分配一大块然后线性切片vLLM则更进一步把切片粒度固定下来允许分散映射。两者共同点是都不依赖逐个释放的malloc/free语义而是用池块的方式来管理高频分配。不同点在于推理场景需要在请求结束时会回收部分块给其他请求所以vLLM的block pool是有块粒度的双向借还而LLVM的arena可以更狠直接整区回收。这种设计带来的实际收益非常直观。在我压测的LLaMA-7B场景里用固定预分配方案batch一超过4就频繁OOM换成分块方案后同样的显存能跑的并发请求数差不多翻倍。原因很简单每个请求只在最后一块尾部浪费几个slot大部分块被填满。4.3 continuous batching像arena一样按批收放显存治理之外scheduler的调度策略也反过来影响显存分配。continuous batching的核心是不让整个batch同步绑定到同一个生命周期。一个请求生成完了立刻在它空出的位置上插入新请求不用等一批全部结束。这里就很像编译器里pass pipeline对IR的批处理一批IR整体进入优化管线pass逐个处理处理完一个模块整体回收。不同的是推理引擎的batch是动态的请求在任意时刻结束、任意时刻加入。所以scheduler需要精确知道每个序列当前占用几个块、哪些块可以腾出来、新请求应该优先从哪个池里拿块。这套逻辑在LLVM里没有完全对应的东西但理解分配器按块管理之后再看scheduler代码会顺畅很多。5. 算子自发现与Pass依赖分析两张同源的编排网标题里还有个热搜词是llvm算子自发现。这个说法本身比较模糊但顺着LLVM的pass机制和推理引擎的graph优化去理解能看出一个很清晰的同源结构。5.1 LLVM里Pass是怎么被发现的传统LLVM pass通过静态注册表暴露自己pass定义一个llvm::RegisterPass某个Pass的全局变量命令行工具按名字查找并创建。新PassManagernew PM在此基础上做了升级pass之间通过AnalysisManager自动发现并缓存分析结果。比如一个优化pass在run函数里调用getAnalysisDominatorTree()AnalysisManager发现之前已经有人算过DominatorTree直接把缓存结果给你没算过就先跑一次分析然后缓存。这就是一种按需发现依赖的机制pass不需要自己维护全局状态。依赖信息不是白拿的拿完之后还要保证分析结果在pass修改IR后仍然有效。所以pass有preserve逻辑明确声明自己改了什么、保留了哪些分析。这和推理引擎里liveness和alias分析的使用方式很像编译器改IR之前要知道哪些指针还活着、哪些可以重新分配推理引擎在算子替换之前也要知道tensor被谁依赖。5.2 推理引擎里的算子自发现与pattern匹配大模型推理侧的算子自发现更接近这种意思从一个kernel注册表中自动嗅探当前计算图中可被替换/融合的子模式。PyTorch的fx graph上做subgraph rewriting是典型的实现方式# 伪代码示意 pattern matcher patterns [fused_attention_pattern, fused_mlp_pattern, rms_norm_pattern] for node in fx_graph.nodes: for pattern in patterns: if pattern.match(node): replace_subgraph(node, fused_kernel)匹配到的子图替换为融合算子比如attention融合、SwiGLU融合这个过程和LLVM里instcombine识别add(x, 1)加add(y, -1)并做常量折叠思路几乎一模一样。区别只在于LLVM有统一的IR和明确定义的pass依赖推理引擎要面对的是TensorRT/ONNX Runtime/Triton各自不同的注册体系和graph表示。Triton里更极端一点叫autotune同一段计算逻辑生成多个不同block_size、不同num_warps的kernel变体启动前先跑一遍benchmark自动选最优。这也可以算一种自发现——从候选池里发现当前输入shape下最合适的实现。5.3 两张编排网的对照把LLVM的AnalysisManager和推理引擎的scheduler/executor放在一张表里对照脉络会清楚很多维度LLVM新Pass管线推理引擎依赖来源AnalysisManager按需计算缓存计算图的tensor依赖、stream依赖资源约束内存、pass修改IR后的有效性显存水位、KV Cache块、stream槽位执行单位Optimization passkernel / op / step生命周期边界Module/Function编译单元request/sequence/scheduling cycle自发现体现PassRegistry按名字实例化、Analysis按需生成算子注册表、pattern matcher、autotune我读LLVM的pass pipeline代码时经常脑子里还在转着nano-vllm里scheduler对sequence的调度。两者一个偏静态编排一个偏动态编排但编排的核心都是搞清楚谁依赖谁、谁现在占用什么资源、谁能释放什么资源。6. 跟着nano-vllm把大模型推理关键功能串一遍前面都是概念层面的同构真正落地还是要回到代码。nano-vllm是个很适合串起一整条推理链路的小项目。6.1 nano-vllm的目录级功能地图我clone下来后按这个顺序读的scheduler.pySequence和SequenceGroup的管理决定每个step哪些序列可以前向block_manager.pyKV Cache block的分配、映射、释放attention.pyPagedAttention的入口传入block table完成attention计算model.pyLlama结构的forward和采样server.py把推理循环包成HTTP服务。这个顺序符合推理主循环的推进逻辑请求进来 → scheduler分块 → 模型前向 → attention读KV → 采样 → 更新block状态。每一条都对应vLLM里的大模块但省掉了分布式、多进程、量化、动态bucketing等工程细节适合断点跟踪。6.2 scheduler和block_manager整个系统的CPU侧大脑我在读nano-vllm时觉得最值钱的是scheduler里谁先跑和block_manager里块从哪来这两个问题的交互。scheduler要维护若干SequenceGroup每个Group有多个Sequencebeam search场景下会分裂。每个step决定把哪些SequenceGroup调度到GPU上时要算清楚这些序列总共需要多少个block当前空闲block够不够。不够的话要么等待要么抢占低优先级序列并释放它们的block。这套逻辑几乎就是操作系统的内存置换只是页面换成了KV Cache块。关键代码是每个sequence维护自己的block_tableclass Sequence: def __init__(self, seq_id, prompt_token_ids): self.seq_id seq_id self.blocks [] # 逻辑 block 索引 self.num_generated_tokens 0 self.status Status.WAITING新token产生后如果当前最后一个逻辑块还有空slot直接延续满了就到block_manager申请新块在block_table里追加一条。这个按需追加的动作就是KV Cache不会一次性打满显存的直接原因。6.3 更好的学习路径动手拆光读代码容易漏细节我建议三个动手操作。第一个操作是调小block池容量观察scheduler出现抢占preemption的时机。把能从十几个block压到四五个block让两个并发请求抢资源看scheduler怎么把一个请求的block交换出去之后又如何恢复。这个现象比任何文章都直观。第二个操作是打印每个step的block_table变化。新生成一个token打印一次逻辑块追加、物理块分配、slot占用率。几轮下来你对KV Cache为什么是显存大头、为什么要尽量复用块会有肌肉记忆。第三个操作是拿nano-vllm的scheduler和vLLM的Scheduler源码做对照。nano-vllm很多分支被砍掉了vLLM里有running、swapped、waiting三个队列的完整状态机。对照着看能发现简化版砍掉的正是工程复杂度的核心vLLM里因为要支撑超高并发序列状态转换需要非常严格的不变性保证。这套学下来再回头去想LLVM的BumpPtrAllocator和pass管理你会发现它们不是两套知识而是一套底层能力在不同领域的投影理解资源边界、理解生命周期、理解依赖关系。编译器和推理引擎的高性能代码最终都建立在把这三件事想得足够清楚之上。最后再分享一个我自己的习惯读这类底层基础设施代码时不要只盯着算法本身把每个数据结构的生命周期图画出来——谁创建、谁持有、谁释放、释放后谁还能引用。LLVM这么画能看懂IR对象的死活推理引擎这么画能看懂显存块的流转。画过几张之后再看任何高性能系统的内存管理基本都不会迷路。