Slang IR 设计深度解析:从"万物皆指令"到全局值去重
【免费下载链接】slangMaking it easier to work with shaders项目地址: https://gitcode.com/GitHub_Trending/sl/slang
导读
Slang 是一个面向 GPU 着色器与可微分渲染的编译基础设施,其核心引擎是自定义的中间表示(IR)。本文基于仓库中 docs/design/ir.md 的设计文档,结合 source/slang/slang-ir.h 与 source/slang/slang-ir-insts.h 中的真实实现,系统讲解 Slang IR 的设计目标、灵感来源、六大关键设计决策,以及"全局值与可提升值去重"这一最容易踩坑的机制。读完本文,你将理解 Slang IR 为什么用"块参数"取代传统 phi 节点、如何在不显式维护 CFG 的情况下高效计算前驱/后继、join point 如何为结构化控制流重建服务,以及如何在 IR 上安全地执行替换操作而不破坏不变量。
一、设计目标与非目标
Slang IR 需要在多个有时相互冲突的目标之间取得平衡。文档 docs/design/ir.md 开篇显式枚举了这些目标,它们是理解后续所有设计决策的动机来源:
- 容易将 Slang 源码降低到 IR,但降低过程不要求无损——不需要从 IR 恢复源码级程序结构;
- IR 必须支持标准数据流分析与优化——编译器论文中的算法应当能够"开箱即用"地应用到 IR 上,并达到预期的渐近效率;
- 能够验证流相关属性(例如某个
[unroll]循环是否真的可展开),并产出引用 AST 级名称/位置的错误消息; - 模块可独立编译到 IR 再"链接",链接只依赖 IR 级(而非 AST 级)构造,允许修改模块实现细节而不强制重编译依赖它的 IR 代码;
- IR 模块可往返序列化,保留全部结构;长期目标是序列化格式跨编译器版本稳定(更像 IL 而非 IR);
- IR 必须能显式编码"泛型"(类型参数化)构造,并能在 IR 内表达从泛型到特化/动态分派的变换——一个模块可以使用另一个独立编译模块中的泛型,链接前校验、链接后特化;
- IR 必须接近 SPIR-V、DXIL 这类着色器中间语言(IL)的抽象层级,尽量减少翻译到这些目标时的工作量与问题;
- 能从 IR 翻译回高层语言代码,包括结构化控制流构造;
- IR 要求的不变量应尽可能内建于结构之中,便于长期维护;
- IR 编码(内存中与序列化后)应尽可能紧凑。
从源码结构看,这些目标直接塑造了 source/slang/slang-ir.h 中 IR 数据结构的形态:紧凑的IRUse链接(见第 114–137 行)、指令/类型统一的表达方式、以及通过 opcode 区间实现的 O(1) 动态类型判断,都是上述目标的落地。
二、三大灵感来源
Slang IR 的设计吸收了三个主要项目的思想:
| 来源 | 借鉴内容 |
|---|---|
| LLVM | SSA 的基本思路:类型化 IR、用同一个对象同时表示指令与其产生的 SSA 值、极简的replaceAllUsesWith原语(Slang 对应为replaceUsesWith) |
| Swift SIL | 用"带参数的块"编码 SSA phi 节点;泛型与存在类型(existential types)的编码方式 |
| SPIR-V | 类型统一表示为指令、结构化控制流的 join point(merge point)编码、"decorations"(装饰)作为附加元数据 |
文档特别强调:LLVM 的 SSA 决策"恰好既是动机充分又是广为人知的",但并非唯一正确选择。Slang 在 phi 节点上刻意偏离 LLVM 传统、转向 SIL 风格的块参数,正是这一"重新评估默认决策"态度的体现。
三、关键设计决策之一:万物皆指令
Slang IR 追求极高的统一性,几乎所有概念最终都是一个指令(instruction):
- 普通的 add/sub/mul 等运算、函数调用、分支、函数参数都是指令;
- 函数中的基本块、以及函数本身,是"父指令"(parent instruction),可以拥有其他指令作为子节点;
- 常量值(甚至
true和false)也是指令; - 类型也是指令,且可以拥有操作数——例如向量类型就是
VectorType指令作用于"元素类型"与"数量"两个操作数; - 泛型完全用普通指令编码:一个泛型就像是一个恰好做"类型层面计算"的函数;
- 目前装饰(decorations)尚未完全指令化,但未来装饰也将是指令,从而拥有与其他指令一样的操作数;
- 整个 IR 模块本身也是一个指令,因此存在唯一一棵"拥有一切"的树。
这种统一性极大简化了泛型支持,也让克隆、序列化等需要遍历所有指令的操作可以用单一统一表示实现,避免对特定 opcode 做特判。把类型当作"普通指令"的做法与 SPIR-V 类似,但 Slang 并不强制 SPIR-V 对类型指令与值指令混合方式的诸多约束。
四、关键设计决策之二:指令的统一结构
每条指令都拥有:
- 一个opcode;
- 一个类型(顶层模块是唯一允许类型为 null 的地方);
- 零个或多个操作数(operands);
- 零个或多个装饰(decorations);
- 零个或多个子节点(children)。
指令不允许携带上述清单之外的任何语义相关信息,唯一例外是表示字面常量的指令——它们额外存储数值数据。内存编码在此基础上还附加了一些限制,例如当前一条指令要么有操作数、要么有子节点,不能同时拥有两者。
由于"任何可作为操作数的东西本身也是指令",指令的操作数以高度统一的方式存储为一段连续的IRUse数组(类型本身也与该数组连续存放,需要时可当作额外的一个操作数处理)。IRUse结构维护显式的 use-def 链接,其定义位于 source/slang/slang-ir.h:
struct IRUse { IRInst* get() const { return usedValue; } IRInst* getUser() const { return user; } void init(IRInst* user, IRInst* usedValue); void set(IRInst* usedValue); void clear(); IRInst* usedValue = nullptr; // 被使用的指令 IRInst* user = nullptr; // 使用该值的指令 IRUse* nextUse = nullptr; // 同一值的下一次使用 IRUse** prevLink = nullptr; // 指回引用处的链接,简化更新 void debugValidate(); };文档坦承IRUse的 use-def 信息"当前略显臃肿"——业界有成熟技术可压缩其体积,这是未来优化空间。
五、关键设计决策之三:opcode 镜像的类层次
IR 指令之间存在逻辑上的"类层次结构",Slang 支持(但不强制)声明一个 C++struct类型来暴露某条指令或某一组指令。这些struct的价值在于:
- 编码类型约束:例如把函数参数声明为
IRFunction*,就能在编译期阻止开发者无意中传入未经验证的任意IRInst*; - 提供便捷访问器:为操作数/子节点提供便利的 getter。
为了让"动态转换"(dynamic cast)高效,内存中 IR 的 opcode 被精心排列,保证某个"基类"的所有后代占据一段连续的 opcode 区间——判断一条指令是否属于该区间只需常数时间查看 opcode 字段即可。IROpMask与IROpInfo等基础设施定义于 source/slang/slang-ir.h,其中IROpInfo::isHoistable()与isGlobal()直接服务于下文将要介绍的"可提升值/全局值"判定。
文档也诚实指出:部分 opcode 存在某种"多重继承"式的排序细节,这是应该随时间移除的"设计瑕疵",而非值得骄傲的东西。
六、关键设计决策之四:更简单的 SSA 编码(块参数取代 phi)
传统 SSA 在控制流汇合点(join point)的块首放置 "phi" 指令,根据进入边选择变量取值。phi 指令虽传统,却有四个缺陷:
- 违反"def 支配 use"的表象:phi 的操作数看似在被定义块之外被使用(官方解释是 phi 作用于进入边,仍被前驱块支配),但这对程序员是一个需要小心的特例,也使得序列化时无法保证"每条指令总在其所有使用之前出现在流中";
- 并行语义:块首所有 phi 必须并行执行——先"读"正确的操作数再"写"目标变量。这只有在循环回边场景下才真正成问题,但依然是又一个特例;
- 操作数顺序依赖前驱块列表:任何修改 CFG 的变换都必须同步重写 phi 操作数以匹配前驱顺序,否则需要维护并更新一个记录映射的侧数据结构;
- 解释执行困难:分支到目标块时,必须立即根据来源块执行 phi,同时还要处理并行性与前驱对应关系。
Slang 放弃了传统 phi,转向与 Swift SIL 一致的替代方案。其思想源头并不在 Swift:SSA 形式的 IR 与续延传递风格(CPS)的 IR 语义等价——可以把 SSA 块编码为续延函数,块的参数替代 phi 指令,对块的分支退化为一次调用。
Slang 不使用显式 CPS,而是取中间路线:传统 SSA IR,但基本块拥有参数而非 phi 指令。一个 Slang 基本块的前 N 条指令就是它的参数,每条都是IRParam指令。原本有 N 个 phi 的块现在有 N 个参数,但参数没有操作数;取而代之,分支指令携带 N 个实参(arguments),对应参数在该控制流边被取走时的取值。
这种编码与传统 phi 表示等价,但干净地解决了上述问题:
- phi 操作数从后继块移到了前驱块内的实参,"def 支配 use"无需任何特例即可成立;
- "实参到参数的赋值"由单条指令编码,并行语义更清晰;
- 无需额外工作跟踪"哪个 phi 操作数来自哪个前驱"——操作数附着在前驱块的终结指令上;CFG 变化后也不需要更新 phi。代价是块参数数量的变化需要同步修改所有前驱的终结指令,但这类变化较少见(局限于引入/消除块参数的 pass);
- 操作语义非常清晰:计算目标块 → 将实参复制到临时存储(保证同时性)→ 把临时值复制到目标块的参数。
该表示的主要代价是分支指令需要为实参预留空间:普通无条件分支只需在目标块操作数之后放一串变长实参;双向条件分支需要编码两份实参列表(每个目标一份);N 路switch只会更复杂。
Slang IR 通过禁止关键边(critical edges)来规避在所有分支上存实参的问题。关键边指"从拥有多个后继的块(以条件分支结尾)指向拥有多个前驱的块(CFG 汇合点)"的边。phi/块参数只会在汇合点需要,因此实参只在分支指向汇合点时存在;禁止条件分支直接指向汇合点后,条件分支就不需要编码实参了。任何程序都能表示成无关键边的 CFG,因此这一约束不损失表达能力。
七、关键设计决策之五:CFG 的简单编码
传统 SSA IR 中,函数由一串基本块组成,每个块以终结指令(terminator)结尾;终结指令决定块的后继,进而构成控制流图(CFG)。朴素的表示是把 CFG 显式存成图数据结构,但那样每次修改终结指令都可能要同步更新图。
Slang IR 利用一个关键性质避免维护成本:若块P(终结指令为t)是S的前驱,则t必然有一个引用S的操作数,因此S的 use 列表中必然包含t。于是前驱/后继可以这样计算:
- 找
P的后继:找到终结指令t,识别其中表示后继块的操作数并遍历——O(出边数); - 找
S的前驱:扫描S的 uses,找出"用户是终结指令且该 use 位于表示后继的操作数位置"的指令,其所在块即前驱——O(S的 use 数),实践中与真实前驱数同阶。
需要指出的是,这两个算法遍历的是块的出入边,若一个块通过switch的多个 case 跳到同一个块,列表中会出现重复;需要去重时,调用方要自建 set。这一方案的好处是前驱/后继列表天然由控制流指令的既有编码推导而来,只在修改"带后继的终结指令"时才需要重新审视遍历逻辑。
八、关键设计决策之六:显式编码结构化控制流的汇合点
为了能从底层 CFG 重建高层语言源码,IR 需要编码结构化分支预期的"join point"——控制流在分支后逻辑上"重新汇聚"的位置。文档给出了 HLSL 例子:
if(someCondition) // join point 是 "D" { A; } else { B; if(C) return; } D;注意:join point 不一定是条件分支的后支配者(postdominator)——上例中块D既不是someCondition也不是B的后支配者;甚至可能出现高层 join point 不可达的情况(例如无限循环之后的块)。
Slang IR 通过让 join point 成为结构化条件分支指令的一个显式操作数来编码结构化控制流。与 SPIR-V 不同——SPIR-V 用一条紧邻分支的元数据指令(merge instruction)编码 merge point——Slang 把信息直接放在指令本身上,从而避免"移动了一条却漏掉另一条"或"在元数据指令与终结指令之间意外插入代码"的问题。join point 操作数不参与后继列表计算,因为它不代表控制流边。未来可能改用 decoration 表示 join point。
在循环指令中,join point 同时也是break标签。SPIR-V 的OpLoopMerge同时包含 join point(break目标)与continue目标,而Slang 目前不表示continue块的结构化信息。原因在于:C 系语言中只有for循环的 continue 子句可以不是循环体顶部,且只允许"表达式"而非任意"语句";优化后 IR 级 "continue 子句" 的代码无法保证构成单个表达式,因此即使保留结构信息也难以在高层次代码生成中利用。当前方案下,"continue 子句"中的代码可能最终被发射多次,文档认为这可以接受——因为fxc本来就这么做。
重建高层结构化控制流时,Slang IR 利用结构化信息把代码组织为"单入口区域"(single-entry regions),映射到if、循环、break/continue等高层构造。当前方案要求所有 IR 变换维护结构化信息,并依赖一些对优化行为的约束(例如不得引入多级break)。未来计划研究 Emscripten 的 "Relooper" 算法,以从任意 CFG 恢复合法结构化控制流,从而支持多级break、消除continue子句的代码复制。
九、全局值与可提升值去重(Global and Hoistable Value Deduplication)
9.1 什么是全局值与可提升值
在 Slang IR 中,类型、常量以及对常量的某些运算被视为"全局值"(global value);另一些指令(如Specialize()与Ptr(x))被视为"可提升值"(hoistable inst)——它们总被定义在其操作数可用的最外层作用域。例如:
Ptr(int)的操作数int定义在全局作用域,因此Ptr(int)永远是IRModuleInst的直接子节点(全局作用域);- 而
Ptr(T)(T是泛型参数)则总是定义在泛型的块中。
全局值与可提升值总是被去重的——可以假设两个地址不同的可提升值必然是不同(不相等)的值。
9.2 IRBuilder 负责保证唯一性
IRBuilder类(定义于 source/slang/slang-ir-insts.h)负责保证全局/可提升值的唯一性。任何创建可提升指令的IRBuilder方法——如createIntrinsicInst、emitXXX或getType——都会先检查等价值是否已存在,若存在则直接返回已有指令而非新建。其内部机制是模块持有的IRDeduplicationContext(source/slang/slang-ir.h):
struct IRDeduplicationContext { typedef Dictionary<IRInstKey, IRInst*> GlobalValueNumberingMap; typedef Dictionary<IRConstantKey, IRConstant*> ConstantMap; GlobalValueNumberingMap& getGlobalValueNumberingMap() { return m_globalValueNumberingMap; } Dictionary<IRInst*, IRInst*>& getInstReplacementMap() { return m_instReplacementMap; } void tryHoistInst(IRInst* inst); ... };该上下文包含全局值编号表(GlobalValueNumberingMap)、常量表(ConstantMap),以及一张"已被去重替换但尚未删除"的IRInst* → IRInst*替换映射表(m_instReplacementMap)。
9.3 替换操作数时的级联提升
棘手之处在于修改 IR 时必须始终保持唯一性。当把某指令的操作数从非可提升值改为可提升值时,该指令自身可能也需要被提升。文档给出了完整示例:
%1 = IntType %p = Ptr(%1) %2 = func { %x = ...; %3 = Ptr(%x); %4 = ArrayType(%3); %5 = Var (type: %4); ... }假设需要把Ptr(%x)的操作数从%x(非常量值)替换为int,得到全局值Ptr(int)(应当去重为%p):
%1 = IntType %p = Ptr(%1) %2 = func { %x = ...; // %3 现在变成了 %p。 %4 = ArrayType(%p); %5 = Var (type: %4); ... }此时%4已不再依赖函数内任何局部指令,违反了"可提升指令总定义在最顶层作用域"的不变量,因此%4必须继续被提升到全局作用域:
%1 = IntType %p = Ptr(%1) %4 = ArrayType(%p); // 被提升到全局作用域 %2 = func { %x = ...; %5 = Var (type: %4); ... }由此可见,替换一个操作数可能对 IR 产生大范围连锁影响。
9.4 两种替换 API 的区别
为维护这些不变量,Slang 引入IRBuilder::replaceOperand(inst, operandIndex, newOperand)执行替换后的所有级联修改。而IRInst::setOperand(idx, newOperand)不会执行级联修改——用它修改可提升指令的操作数会触发运行时断言错误。同理,inst->replaceUsesWith(...)也会执行级联修改以保证可提升值唯一性(对应 LLVM 的replaceAllUsesWith语义,声明见 source/slang/slang-ir.h)。
9.5 遍历链表时替换的危险
由于replaceUsesWith/replaceOperand会引发指令被移动(如被提升到更高作用域),在遍历 IR 链表或 def-use 链表时调用它们极其危险。文档中的反例:
IRInst* nextInst = nullptr; for (auto inst = func->getFirstChild(); inst; inst = nextInst) { nextInst = inst->getNextInst(); // 先保存 nextInst // ... inst->replaceUsesWith(someNewInst); // 危险:nextInst 可能已被移动到 parent->parent! }设想在处理%3时希望把它替换为Ptr(int):循环先保存了nextInst = %4,但replaceUsesWith导致%4被提升到全局作用域——下一次迭代会从%4出发沿next指针走到%2(func),循环意外开始处理func而非继续遍历子链表。
正确的做法是:遍历 IR 链表时绝不调用replaceOperand/replaceUsesWith。若确需修改,必须先构造临时 work list 把全部指令加入,再做修改。为此 IR 提供了两个安全工具:
IRInst::getModifiableChildren:返回临时 work list,安全遍历可修改的子节点;traverseUses/traverseUsers:安全遍历 def-use 链表(定义于 source/slang/slang-ir.h)。
这些工具在仓库中被广泛使用,例如 source/slang/slang-ir-dce.cpp(死代码消除中的traverseUsers<IRAnnotation>)、source/slang/slang-ir-inline.cpp(内联 pass 中的getModifiableChildren遍历)以及 source/slang/slang-ir-defer-buffer-load.cpp 中的traverseUses,均可作为安全修改 IR 的参考实现。
9.6 局部引用的失效问题
另一个细节:调用replaceOperand/replaceUsesWith之后,任何对指令的局部引用都可能过期。文档示例:
IRBuilder builder; auto x = builder.emitXXX(); // x 是非可提升值 auto ptr = builder.getPtrType(x); // 创建 ptr(x) x->replaceUsesWith(intType); // 这使 ptr 过时!! auto var = builder.emitVar(ptr); // 用过期指令创建新指令这里replaceUsesWith使ptr表示Ptr(int)——该值可能已存在于全局作用域,此后ptr的所有使用都应替换为全局的Ptr(int)指令。IRBuilder提供了机制:追踪所有因去重而移除(但尚未删除)的指令,并把它们映射到现有指令;当用ptr创建新指令时,builder 会先检查ptr是否应映射到全局去重表中的既有可提升值并替换。因此上述代码执行后,var->type不等于ptr。
十、修改 IR 的最佳实践
文档在最后给出两条铁律,也是所有 IR 相关 pass 开发者必须遵守的准则:
- 遍历裸链表时绝不调用
replaceUsesWith或replaceOperand。始终先构造 work list 再迭代;需要在迭代中修改 IR 时,使用IRInst::getModifiableChildren与traverseUses/traverseUsers。 - 不要假定调用
replaceUsesWith/replaceOperand后任何局部inst引用仍然最新。把它们继续作为操作数/类型来创建新指令是允许的,但不要假定新建的指令会引用传入的同一指令。
结语
从本文可以总结出 Slang IR 的一条主线:用极致的结构统一换取实现简单性与可维护性。无论是"万物皆指令"(类型、常量、泛型、模块本身都是指令)、"块参数替代 phi"(消除 SSA 特例)、"前驱/后继从 use 列表自然推导"(免维护显式 CFG),还是"join point 作为分支指令显式操作数"(支撑结构化控制流重建),每一项决策都在用精心设计的结构把不变量"内建"到表示之中。而全局值/可提升值去重机制,则是结构统一带来的必然延伸——代价是替换操作必须经由IRBuilder级联完成。理解这些决策,是读懂 source/slang 下数百个 IR pass 的前提,也是在此基础上扩展新优化与代码生成后端的基础。
【免费下载链接】slangMaking it easier to work with shaders项目地址: https://gitcode.com/GitHub_Trending/sl/slang
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考