2. 编译基础

各位同学好,我是你们这门课的主讲。今天咱们聊聊编译基础,这部分内容可以说是整个NPU编译优化的地基。你想想看,没有扎实的编译基础,后面那些高级优化技巧根本玩不转。

我个人习惯把编译器想象成一个翻译工厂。前端负责读懂源语言,后端负责输出目标机器码,而中间那层IR,就是工厂内部的半成品。嗯,这个比喻虽然简单,但很实用。

1. 编译器前端:从源码到IR

前端干的事,说白了就是词法分析、语法分析、语义分析这三板斧。词法分析把代码切成token,语法分析构建抽象语法树(AST),语义分析检查类型对不对、作用域合不合理。

我在项目中遇到过一个问题:某个团队自己写了个前端,结果语义分析没做好,导致生成的IR里类型信息丢失。后来调试NPU算子时,死活找不到性能瓶颈在哪。最后发现是前端把float和half类型搞混了,IR里全是隐式转换。

避坑指南:我曾经因为前端没处理好循环展开的语义,导致IR生成了一堆冗余的标量操作。NPU的向量单元根本用不上,性能直接腰斩。所以前端这一步,千万别图省事。

前端输出的AST,一般不会直接用于优化。因为AST太贴近源语言结构,不利于做跨语句的变换。所以编译器会把它降级成更底层的表示——也就是IR。

2. 中间表示(IR):编译器的核心枢纽

IR是编译器的灵魂。没有IR,前端和后端就是两座孤岛。IR的设计好坏,直接决定了你能做哪些优化。

常见的IR有三种形式:

  • 三地址码(TAC):每条指令最多三个操作数,比如 a = b + c。简单直观,适合做数据流分析。
  • 静态单赋值形式(SSA):每个变量只赋值一次。这玩意儿太重要了,后面讲优化时会反复提到。
  • 控制流图(CFG):把基本块用边连起来,表示程序的执行路径。

我个人最喜欢SSA形式。为什么?因为它让数据依赖关系一目了然。你想想看,传统IR里一个变量被赋值多次,你得做def-use链分析才能搞清楚谁用了谁。SSA直接告诉你:这个值只在这里定义,其他地方想用都得通过Phi节点。

核心要点:在NPU编译中,IR的设计要特别考虑向量化和张量操作。我见过有些框架直接用LLVM IR,结果发现NPU的矩阵乘法指令映射不上去,还得自己加一层自定义IR。

这里我画了一张图,展示编译器前端、IR、后端之间的关系:

编译器前端 词法/语法/语义分析 源码 → AST → IR 中间表示(IR) SSA / CFG 独立于源语言 独立于目标架构 优化核心载体 编译器后端 指令选择/调度/寄存器分配 IR → 目标机器码 优化Pass循环 (常量折叠、死代码消除、向量化...) 图:编译器三段式架构与IR的核心地位

3. 后端优化:把IR变成机器能懂的话

后端要做的事情,就是把优化后的IR翻译成目标架构的指令。这里涉及三个关键步骤:

  1. 指令选择:把IR操作映射到目标指令。比如一个加法,在NPU上可能映射成向量加法指令。
  2. 指令调度:重排指令顺序,避免流水线停顿。NPU的流水线比CPU还深,调度不好性能直接崩。
  3. 寄存器分配:把无限虚拟寄存器映射到有限物理寄存器。NPU的寄存器文件通常很大,但分配策略不对照样溢出。

我记得有一次做NPU后端优化,指令选择阶段没处理好,把矩阵乘法拆成了一堆标量乘加。结果NPU的矩阵单元完全没利用上,性能比CPU还差。后来改成直接映射到矩阵指令,性能提升了20倍。

实战技巧:做NPU后端时,我建议先搞清楚目标架构的指令集特点。比如有些NPU支持融合乘加(FMA),有些支持张量核心。指令选择时要优先匹配这些特殊指令,否则优化空间有限。

4. LLVM与MLIR框架

说到编译器框架,LLVM是绕不开的。它提供了一套完整的工具链,从前端(Clang)到IR(LLVM IR)再到后端,一应俱全。LLVM IR采用SSA形式,支持丰富的优化Pass。

但LLVM有个问题:它的IR是面向CPU设计的。对于NPU这种异构架构,LLVM IR的表达力不够。比如张量操作、数据流模式、内存层次结构,LLVM IR很难直接描述。

这时候MLIR(Multi-Level Intermediate Representation)就派上用场了。MLIR允许你定义自己的IR方言(Dialect),每一层IR可以针对不同的抽象级别。比如:

  • 顶层:用Tensor Dialect描述张量计算
  • 中间层:用Linalg Dialect描述线性代数操作
  • 底层:用LLVM Dialect生成目标代码

我在实际项目中用过MLIR做NPU编译。说实话,刚开始觉得这东西太复杂,方言定义、转换Pass、降级规则,一堆概念。但用顺手之后发现,MLIR的灵活性是LLVM没法比的。你可以针对NPU的特定硬件特性,自定义一个Dialect,然后一步步降级到可执行代码。

为什么MLIR适合NPU?因为NPU的硬件结构差异很大。有的NPU是SIMD架构,有的是 systolic array,有的是数据流架构。MLIR的多层IR设计,让你可以在不同抽象级别做优化,而不需要改动整个编译器。

这里我整理了一个对比表格,方便你理解LLVM和MLIR的区别:

特性 LLVM MLIR
IR层级 单层(LLVM IR) 多层(自定义Dialect)
SSA支持 原生支持 原生支持
张量操作 需通过外部库 内置Tensor Dialect
硬件抽象 通用CPU/GPU 可自定义硬件Dialect
优化Pass 固定Pass管线 可组合、可扩展
适用场景 传统编译器 异构计算、AI编译器

嗯,这里要注意一点:MLIR并不是要取代LLVM。实际上,MLIR的底层通常会降级到LLVM IR,然后利用LLVM的后端生成目标代码。两者是互补关系,不是竞争关系。

常见误区:我曾经看到有人试图用MLIR完全替代LLVM,结果发现底层指令选择、寄存器分配这些活,MLIR自己干不了。记住,MLIR擅长的是高层IR变换和方言转换,底层优化还是得靠LLVM。

最后总结一下今天的核心内容:编译器前端负责把源码变成IR,IR是优化的核心载体,后端负责把IR变成机器码。LLVM提供了成熟的单层IR框架,而MLIR通过多层IR设计,让NPU编译变得更加灵活。我个人建议,如果你刚开始接触NPU编译,先从LLVM入手,理解基本的IR和Pass机制。等遇到LLVM搞不定的场景,再引入MLIR。

好了,今天就到这里。记住,编译基础是后面所有优化技巧的根基,别急着跳过去看高级内容。把IR吃透了,后面事半功倍。