拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LLVM ADT与显存治理:编译器和推理引擎的内存哲学

LLVM ADT与显存治理:编译器和推理引擎的内存哲学

最近这两周晚上的画风有点分裂:一边在翻LLVM的ADT头文件和BumpPtrAllocator的实现,一边跟着nano-vllm的调度循环调大模型推理的显存分配。一个是编译器后端的地基工程,一个是AI推理服务的性能主战场,按理说八竿子打不着。但把两份代码同时摊开之后,我越看越觉得它们其实在回答同一组问题:数据怎么表达才不浪费、资源怎么分配才不碎片、依赖怎么梳理才不乱、生命周期到底该由谁负责。

这篇随笔不打算做教程式输出,而是把我这段交叉阅读的收获强制整理一遍。内容大致会扫过LLVM ADT里几个高频数据结构、LLVM的内存管理哲学,再顺着显存治理和算子自发现一路聊到大模型推理框架。标题既然挂了个"随笔01",目标就定得实在一点:每个主题不求讲透,但画准画像,让后面几篇能在这个地基上继续长。

1. 编译器蓝本与推理引擎之间的"同构感"在哪里

1.1 三个绕不开的底层问题

把LLVM和大模型推理引擎放到一起看,不是标题工程,而是它们都要回答同样的一组底层问题。

第一个问题是数据结构的承载能力。LLVM的IR对象在pass之间来回穿梭,需要大量"只读视图""不拷贝切片"的操作;推理引擎的tensor在模型前向过程里也是反复引用、变形、transpose,理想情况同样是零拷贝。可现实里没人愿意为所有场景各写一套容器,所以两边都催生了高度定制的数据结构。LLVM有自己的一套ADT,vLLM则用自己的block管理器和tensor元信息描述来减少反复搬运。

第二个问题是资源分配的粒度。编译器在优化阶段反复创建和销毁指令节点,如果每次创建都走malloc,百万级IR的构建成本会直接爆炸。推理引擎则要在几百个并发请求之间分配KV Cache,显存的申请释放频率直接决定吞吐和碎片率。两者都不约而同地选择了"批量分配、按池回收"的路子。

第三个问题是编排与依赖。编译器里pass之间有AnalysisManager来维护依赖关系,同一个分析结果可以被多个pass共享缓存;推理引擎的scheduler要根据tensor依赖、显存水位、请求优先级来决定这一步该让哪些序列前向、哪些序列先抢占。依赖关系处理得好不好,直接决定整个执行管线顺不顺。

理解了这三点,再回头去看LLVM ADT和内存管理机制,很多设计就不会只看表面,而是能明白它"为什么这么设计"。

1.2 我这段时间的实际学习环境

先说LLVM这边。我本地用的是LLVM 19的源码,构建时强烈建议只保留host target:

cmake -G Ninja ../llvm \ -DCMAKE_BUILD_TYPE=Release \ -DLLVM_ENABLE_ASSERTIONS=ON \ -DLLVM_TARGETS_TO_BUILD=host \ -DLLVM_BUILD_TOOLS=ON

LLVM_ENABLE_ASSERTIONS不要关,很多ADT和内存管理的不变量(比如迭代器失效、数组越界的assert)靠它兜底。LLVM_TARGETS_TO_BUILD=host能省掉一大半编译时间,我们只是读代码,不需要交叉后端。

推理那边我在看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(ArrayRef<Value*> ops); canFold({a, b, c}); // 不用临时 vector

我实际踩过的坑是:永远不要把ArrayRef存成成员变量,除非你能严格保证底层数组的生命周期覆盖它。StringRef/ArrayRef的哲学是"用的时候再取,不要保存",它适合做参数和局部变量。

2.2 SmallVector:栈上预分配的小型仓库

SmallVector<T, 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_cast<void*>(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指向被使用的Value;Value反过来维护一个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_size=16个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>的全局变量,命令行工具按名字查找并创建。新PassManager(new PM)在此基础上做了升级,pass之间通过AnalysisManager自动发现并缓存分析结果。

比如一个优化pass在run函数里调用getAnalysis<DominatorTree>(),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.py:Sequence和SequenceGroup的管理,决定每个step哪些序列可以前向;
  • block_manager.py:KV Cache block的分配、映射、释放;
  • attention.py:PagedAttention的入口,传入block table完成attention计算;
  • model.py:Llama结构的forward和采样;
  • server.py:把推理循环包成HTTP服务。

这个顺序符合推理主循环的推进逻辑:请求进来 → scheduler分块 → 模型前向 → attention读KV → 采样 → 更新block状态。每一条都对应vLLM里的大模块,但省掉了分布式、多进程、量化、动态bucketing等工程细节,适合断点跟踪。

6.2 scheduler和block_manager:整个系统的CPU侧大脑

我在读nano-vllm时觉得最值钱的是scheduler里"谁先跑"和block_manager里"块从哪来"这两个问题的交互。

scheduler要维护若干SequenceGroup,每个Group有多个Sequence(beam search场景下会分裂)。每个step决定把哪些SequenceGroup调度到GPU上时,要算清楚:这些序列总共需要多少个block,当前空闲block够不够。不够的话,要么等待,要么抢占低优先级序列并释放它们的block。这套逻辑几乎就是操作系统的内存置换,只是页面换成了KV Cache块。

关键代码是,每个sequence维护自己的block_table:

class 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对象的死活,推理引擎这么画能看懂显存块的流转。画过几张之后,再看任何高性能系统的内存管理,基本都不会迷路。

返回列表