读懂HSAIL:异构计算GPU中间表示与性能调优 开头这块我先说个现象讲到异构计算、GPU 编程的教材或者内部培训材料只要出现“第 3 章 HSAIL 虚拟并行 ISA”这种章节名翻页速度往往是全书最快的。大家会觉得这是过渡章节反正后面要写 OpenCL、要调性能中间语言跟我有什么关系我最初也是这么跳过去的直到有一次手里的 kernel 在两种设备上跑出接近一个数量级的差距排查了索引排布、工作组大小、访存合并最后才发现问题出在“中间表示层把某个访问当成了不对齐的私有内存操作”。那时候我才回头把 HSAIL 这一章从头读了一遍。HSAIL全称 Heterogeneous System Architecture Intermediate Language直译就是异构系统架构中间语言是当年为推动 CPU 与 GPU 统一寻址、统一编程模型而设计的一套虚拟并行 ISA。它不针对任何一块具体的芯片而是定义了一套抽象的指令集、寄存器模型和内存段划分让上层编译器只需要生成一份 HSAIL再由各家硬件背后的 Finalizer 翻译成本地机器码。这套思路今天听起来理所当然但真把规范摊开看里面关于索引抽象、内存段、屏障语义的设计取舍仍然是我理解现代 GPU 中间表示最有用的一块垫脚石。这篇东西不是规范的中文翻译也不是教程复刻。我更想以一个踩过坑的人的角度把这一章里那些“看着像废话、实际上决定了你后面调优方向”的点讲透它站在编译链路的哪一格、三层并行抽象到底怎么对应硬件、手写一段 HSAIL 要注意什么、BRIG 和 Finalizer 之间发生了什么、以及这套东西最后为什么没走出来却依然值得读。适合已经能写 kernel 但对底层表示一头雾水的人也适合完全没接触过并行 ISA、想找个入口的新手。1. 先弄清楚 HSAIL 在整条编译链路里站在哪一格1.1 它要解决的麻烦一份并行代码怎么喂给不同厂商的硬件异构计算最早的痛点不是性能是碎片化。同一个向量加法你在 A 家设备上写的 kernel换到 B 家设备上要么重写要么靠运行时临时翻译而临时翻译又各写各的语义边界完全对不齐。更麻烦的是上层语言比如 OpenCL C到机器码之间如果没有一个公共的中间层每个前端都要对接每个后端组合爆炸。HSAIL 就是在这个背景下提出来的公共层。它的定位非常明确上承高级语言前端下接各家硬件后端。前端把 OpenCL C 或者别的语言编译成 HSAIL这份 HSAIL 是纯抽象的里面没有“多少个流处理器”“多少 KB 共享内存”“wave 多宽”这类硬件参数后端也就是 Finalizer拿到 HSAIL 之后结合目标芯片的实际规格做寄存器分配、指令选择、调度最终产出机器码。这个分层的价值在于同一份 HSAIL 可以在不同代际、不同规格的设备上复用。你不需要为每一块新卡重新编译源码只需要重新跑一遍 Finalizer甚至可以在运行时做 JIT。这个思路和今天主流的做法是一致的只是 HSAIL 是较早把这套东西写成完整规范的一次尝试。我特别想强调一点HSAIL 里刻意回避了硬件细节。规范里没有规定一个 work-group 最多多少个 work-item没有规定寄存器文件多大也没有规定 wave 宽度。这些全部留给 Finalizer 和目标硬件的 profile 去决定。这个设计的好处是可移植坏处是你写 HSAIL 时对性能几乎没有直接控制权只能通过内存段选择和访问模式去“暗示”后端。理解了这一点后面很多“为什么这段代码在两边表现不一样”的问题就有了解释的方向。1.2 和 PTX、SPIR-V 摆在一起看定位差在哪单纯讲 HSAIL 很容易讲虚把它和另外两套中间表示摆在一起对比位置就清楚了。下面这张表是我自己整理的理解框架不是规范原文参数和特性以各自规范为准维度HSAILPTXSPIR-V主要服务对象CPU 与 GPU 统一寻址的异构模型单一厂商 GPU 家族图形与计算通用的跨厂商中间表示是否暴露 wave 概念部分暴露packet id / lane id明确暴露不暴露交给后端内存段划分global / group / private / readonly / kernarg / spill有类似划分但命名不同通过存储类限定符表达二进制形式BRIG文本为主也有二进制本身就是二进制模块是否绑定统一寻址是设计前提就是共享虚拟内存否否由扩展表达从这张表能看出 HSAIL 最独特的地方它假设 CPU 和 GPU 共享同一套虚拟地址空间。这个假设直接影响了它的指针表示和内存段设计。在 HSAIL 里全局段指针是 64 位地址CPU 侧可以直接访问同一块内存不需要显式拷贝。这个思路在当时是很超前的但也正是它把自己绑死在了一套特定的系统架构上。PTX 走的是另一条路它服务的是一个相对封闭但迭代极快的硬件家族所以可以放心地把 wave 宽度、共享内存 bank 数这些细节写进抽象里。SPIR-V 则更“干净”它更像一个带类型系统的通用 IR把并行执行模型的细节全部下推。三者没有绝对优劣是三种不同的取舍HSAIL 选了系统级统一PTX 选了家族内深度优化SPIR-V 选了最大范围的通用性。1.3 一份 HSAIL 从源码到机器码要过几道手把链路拆开看一份 kernel 从源码走到硬件大致要经过这么几道前端编译OpenCL C 源码被编译成 HSAIL 文本同时可能生成元数据比如内核参数布局、work-group 尺寸约束。汇编成 BRIGHSAIL 文本被汇编成二进制形式 BRIG也就是一份带类型信息和内存段标注的字节码。打包与分发BRIG 通常作为一个 section 嵌在可执行文件或者库里面运行时由驱动或者运行时库取出来。Finalizer 翻译目标设备的 Finalizer 把 BRIG 翻译成机器码这一步可能发生在离线编译阶段也可能发生在运行时加载阶段。加载与执行机器码被加载到设备上运行时负责建立参数缓冲、派发 grid。这条链路里最容易出问题的是第 2 步和第 4 步。第 2 步的坑在于元数据丢失比如某个内核参数的对齐信息没有正确带进 BRIGFinalizer 就会按保守对齐处理性能莫名其妙掉一截。第 4 步的坑在于版本错配不同版本的 Finalizer 对同一条 HSAIL 指令的理解可能有细微差别尤其是涉及内存序和原子操作的时候。提示如果你手上有一份历史遗留的 HSAIL 相关代码要维护第一步永远是确认 BRIG 的版本号和 Finalizer 的版本号是否匹配。这一条比看任何文档都管用。我在实际排查中养成了一个习惯拿到任何一个中间表示先问自己“它在链路里的第几格上下游分别是谁交接点上传递了哪些信息”。这个习惯放到今天看 SPIR-V、看 LLVM IR方法论完全一样。HSAIL 这一章真正的价值可能就是帮你把这个思维框架立起来。2. 三层并行抽象work-item、work-group、wavefront 各管什么2.1 四种 id 指令别在核心里混着用HSAIL 的并行模型是三层最细的是 work-item也就是单个执行实例中间是 work-group一组可以互相同步的 work-item最外是 grid一次派发的全部 work-group。这个模型今天看很常规但 HSAIL 把 id 的获取拆得非常细这一点很多人第一次读会懵。规范里跟 id 相关的指令大致有这么几类work-item 在组内的 id包括一维、二维、三维形式以及把它们拍平后的扁平 id。work-item 在全局的绝对 id同样有分维度和扁平两种形式。work-group 的 id当前组在 grid 里的位置。各种尺寸查询grid 的组数量、组的大小、当前组的实际大小。为什么要拆这么细因为扁平 id 和分维 id 的代价不一样。分维 id 需要三个寄存器分别保存扁平 id 只需要一个但在做二维索引还原的时候又要多一次除法。HSAIL 把这个选择权交给写代码的人你既可以用三维 id 直接算偏移也可以自己用扁平 id 手算代价自己承担。我在早期写 kernel 时习惯性地全部用三维 id觉得直观。后来发现对于一维数组这种最常见的情况用扁平绝对 id 能少一次乘加而且 Finalizer 更容易做常量传播。这不是 HSAIL 独有的道理但 HSAIL 因为把两种形式都摆在你面前反而逼着你去想这个问题。还有一个容易被忽略的点组内 id 和全局 id 的边界语义不一样。组内 id 的取值范围由 work-group 尺寸决定这个尺寸在某些实现里是编译期就能确定的常量Finalizer 可以据此做激进的优化全局 id 则依赖运行时派发的 grid 尺寸通常是运行期才知道的。混用这两种 id 去算地址很容易让编译器失去“这个值有上界”的信息进而丢掉越界检查消除或者向量化的机会。2.2 barrier 的语义边界和它的典型误用barrier是 HSAIL 里语义最重的一条指令之一它做两件事让组内所有 work-item 都到达这个点并且保证这个点之前的组内共享内存写入对之后的所有 work-item 可见。注意这里有两个限定词“组内”和“组内共享内存”。第一个限定词意味着 barrier不能跨 work-group。这是一个非常常见的误解。很多人想当然地认为 barrier 是全局同步写完就指望不同组之间能对上。这在 HSAIL 里是不成立的而且不成立的方式很危险——它不会报错只会给你一个偶尔正确、偶尔错误的结果具体取决于调度顺序。规范里其实提供了另一种机制来处理跨组的同步需求也就是灵活屏障那一套但它要求你把屏障对象显式放在全局或者组段里用法完全不同。第二个限定词意味着 barrier只保证组段可见性不保证全局内存的可见性。如果你在 barrier 之前写全局内存barrier 之后另一个 work-item 去读读到的可能是旧值。要保证全局可见需要另外的内存栅栏指令。这两者的区别我在调一个归约 kernel 时被狠狠教育过一次本地归约结果是对的写回全局时偶尔丢数据查了两天才定位到是漏了栅栏。barrier 还有几个实践上的坑值得单独说barrier 不能出现在分歧的控制流里。如果一部分 work-item 走了 if 分支进入了 barrier另一部分没进去行为是未定义的。有的实现会直接挂死有的会给出垃圾数据非常看运气。barrier 有实际开销不是免费的同步点。在组很小的时候barrier 的代价可能超过它带来的收益。我见过为了“保险”在每个循环迭代里都加 barrier 的写法性能直接腰斩。barrier 之前的部分写入可能被编译器重排除非你用带内存序语义的形式。这一点在$fullprofile 下才有完整的表达手段。2.3 wavefront 这条暗线为什么 HSAIL 规范里要提它前面说过 HSAIL 刻意回避硬件细节但它并没有完全回避 wave 这个概念。规范里保留了 packet id 和 lane id 这类指令用来获取当前执行单元在一个 wave 内的编号。这就很有意思了一边说自己是抽象的、可移植的一边又给你留了一个直通硬件的口子。这个口子存在的理由是有些算法确实需要知道自己在 wave 里的位置。最典型的是 warp 级归约、warp 级原语、以及需要在一个 wave 内做无屏障数据交换的场景。如果中间表示完全不暴露这个信息这类算法就没法在 HSAIL 层面表达只能靠后端自己识别模式去优化而模式识别永远不如显式表达可靠。代价是可移植性打折。你用了 packet id 和 lane id就等于假设了目标硬件有一个明确的 wave 结构而且宽度是多少你心里得有数。不同设备上 wave 宽度可能不同同一段代码在宽度 32 和宽度 64 的设备上跑出来的结果可能完全不一样。规范的做法是让你通过查询指令获取宽度但很多老代码是直接写死常量的。注意如果你的代码里出现了依赖 packet lane id 的逻辑务必在所有目标设备上分别验证一遍。这类 bug 的隐蔽性极高因为它只在特定的数据分布下才暴露。我个人的经验是在中间表示层面尽量少用 wave 相关指令把这类优化放到后端或者用更高层的原语表达。除非你非常确定目标硬件只有一种形态否则省下来的那点指令数不足以补偿可移植性的损失。3. 手写一段 HSAIL寄存器、内存段和指令后缀的门道3.1 寄存器种类与类型后缀的对应关系HSAIL 的寄存器是虚拟的、无限的这一点和真实的 ISA 完全不同。你写%s0、%s1、%s2……可以一直往后写Finalizer 负责把它们映射到有限的物理寄存器上映射不下就溢出到 spill 段。这个设计让手写 HSAIL 时不用操心寄存器压力但也意味着你写的寄存器数量会直接影响后端的分配难度。寄存器按用途和宽度分成几类命名上有约定俗成的前缀前缀习惯宽度典型用途%s32 位单精度浮点、32 位整数、地址低半部分%d64 位双精度浮点、64 位整数、完整地址%q128 位四字打包数据%p1 位谓词用于条件判断和分支%c特殊与 64 位地址计算配合的偏移寄存器需要说明的是前缀只是读代码时的惯例真正的类型由指令后缀决定。同一条指令你写add_u32就是无符号 32 位加法写add_s32就是有符号 32 位加法写add_f32就是单精度浮点加法。类型系统在 HSAIL 里是强类型的指令后缀和操作数宽度必须匹配不匹配会在汇编阶段就报错。这个强类型设计其实是 HSAIL 的一个优点。对比某些中间表示里整数和指针可以随便互转HSAIL 要求你显式做转换好处是后端能拿到更准确的类型信息坏处是写起来啰嗦。我一开始很烦这种啰嗦后来在做指针别名分析的时候发现正是因为类型清晰后端敢做更激进的优化。3.2 六个内存段选错一个性能差一截HSAIL 的内存段划分是这一章最值得反复读的部分。粗略分下来有这么几段global 段全局内存所有 work-item 和 CPU 侧都可见通常就是设备显存或者统一内存。访问延迟最高。group 段组内共享内存只有同一个 work-group 的 work-item 可见生命周期跟随 work-group。这就是大家常说的“本地内存”或者“共享内存”。private 段每个 work-item 私有的内存其他 work-item 不可见。通常映射到寄存器溢出区或者栈。readonly 段只读数据可能被映射到常量缓存适合放查找表、系数这类不变的数据。kernarg 段内核参数字段由运行时在派发前填好只读。spill 段Finalizer 用来做寄存器溢出的区域一般不由手写代码直接使用。选段的逻辑其实就一句话访问频率和共享范围决定段的选择。一个被组内所有 work-item 反复读的小数组放 group 段比放 global 段快得多一个每个 work-item 各写各的临时变量放 private 段一份所有组都要读的常量表放 readonly 段能吃到缓存红利。但这里有三个非常实际的坑。第一个坑是group 段容量有限而且不同设备的上限差别很大。你按某一代设备的上限去分配换一代可能就超了Finalizer 会直接编译失败或者退化成用 global 段模拟性能崩掉。稳妥的做法是在代码里留一个降级路径。第二个坑是private 段的数组如果索引是动态的很可能直接落到 spill 段。因为寄存器没法寻址动态索引的私有数组在硬件上就是一段栈内存访问延迟比寄存器高一个量级。这一点在手写 HSAIL 时尤其要注意因为虚拟寄存器无限很容易写出“看起来用了很多寄存器、实际上全在溢出的”代码。第三个坑是readonly 段的写入是未定义的。有些人图省事把输出缓冲也声明成 readonly在小数据量下可能碰巧能跑数据量一大就出问题。提示判断内存段选得对不对最直接的办法是看 Finalizer 的输出统计里 spill 段的用量和 group 段的占用。这两个数字能反映出绝大部分段选择问题。3.3 向量加法核的逐行拆解光讲概念不落地我按规范习惯手写一段简化版的向量加法逐段说明。下面这段是示意性的语法细节以你手上那份规范的版本为准不要直接当可编译代码用version 1:0:$full; module vec_add:1:0:$full:$large; kernel vec_add( kernarg_u64 %kd0, kernarg_u64 %kd1, kernarg_u64 %kd2, kernarg_u32 %ks3) { align 8 arg_u64 %sp0; arg_u64 %sp1; arg_u64 %sp2; arg_u32 %ss3; // 取全局扁平 id workitemflatabsid_u32 %s0; // 越界判断 setp_ge_u32 %p0, %s0, %ss3; cbr %p0, $done; // 计算地址偏移 mul_u32 %s1, %s0, 4; add_u64 %d0, %sp0, %s1; add_u64 %d1, %sp1, %s1; add_u64 %d2, %sp2, %s1; // 读、加、写 ld_global_f32 %f0, [%d0]; ld_global_f32 %f1, [%d1]; add_f32 %f2, %f0, %f1; st_global_f32 [%d2], %f2; $done: ret; }逐段拆一下。开头两行是版本声明和模块声明。version必须是文件的第一条有效指令冒号分隔主次版本后面的$full表示完整 profile。模块声明里的$large表示使用大内存模型也就是 64 位地址。如果你写的是小内存模型指针就是 32 位能寻址的范围小很多但指令更短。这个选择直接决定了后面所有地址计算的宽度改起来牵一发动全身所以要一开始就定好。接下来是内核声明和参数处理。参数分两部分kernarg前缀的形参表示它在参数段里的位置arg前缀的局部变量是它在私有段里的一份拷贝。为什么要拷贝因为参数段是只读的而且访问参数段的延迟未必比访问私有段低拷到私有段之后后端可以把它当普通变量做寄存器分配。这个设计在很多中间表示里都能看到类似处理。然后是取 id 和越界判断。这里我用了扁平绝对 id一条指令拿到全局索引比三维 id 少两次乘加。越界判断用的是谓词加条件分支这是 HSAIL 里最标准的分支写法。注意setp系列指令是把比较结果写进谓词寄存器不是写进通用寄存器这个和很多人的直觉不同。再往下是地址计算。注意我把 32 位的索引乘了 4 之后扩展成 64 位再加到基址上。这个顺序不能反先加后乘会溢出。这是手写地址计算最容易出的错因为虚拟寄存器没有宽度警告你写错了它照样编译只是结果不对。最后是访存和算术。ld_global_f32里的global是段限定f32是数据类型。HSAIL 的访存指令把段和类型都写在操作码里好处是一眼能看出访问落在哪个段坏处是操作码特别长。3.4 原子操作和图像指令的位置除了常规的算术、访存、控制流HSAIL 还有两大类指令值得单独提一句原子操作和图像指令。原子操作的写法是在atom前缀后面跟段限定和具体操作比如加法、交换、比较交换、位运算、最值。这套指令的语义和你在高级语言里见到的原子函数基本一致但有一个关键差别内存序。HSAIL 里原子操作的可见性和顺序保证是可以指定的默认的宽松序只保证操作本身不可分割不保证它和其他访存的相对顺序。如果你的算法依赖“写完 A 再原子加 B别人看到 B 增加就一定看到 A”那就必须用带更强顺序保证的形式。图像指令这块是$fullprofile 才有的包含采样、读写、寻址计算、属性查询几类。它的存在是因为早期异构计算里图像处理是主力场景直接在 ISA 层面支持纹理采样能省掉大量手工计算。这套指令的细节参数非常多采样器状态、坐标归一化、边界处理模式都要显式指定。我个人的建议是除非你在做图像专用管线否则不要在中间表示层面直接用图像指令用普通访存加手工寻址更可控。4. BRIG 与 Finalizer二进制层面发生了什么4.1 BRIG 不是目标码它是带了类型和段信息的字节码很多人第一次看到 BRIG 会误以为它是某种目标机器码其实它和 Java 字节码的定位更像一种结构化的、带类型信息的中间二进制。它把 HSAIL 文本里的指令、寄存器、标签、调试信息编码成紧凑的二进制形式方便分发和加载但里面的指令仍然是抽象的没有绑定任何具体硬件。BRIG 的结构大致包含几块版本和 profile 头、模块级的元数据内核列表、参数布局、work-group 尺寸约束、每个函数的指令流、以及可选的调试段。调试段这一块很关键它保存了源码行号到指令偏移的映射是后面做性能分析和错误定位的基础。我见过一些构建流程为了减小体积把调试段剥掉结果线上出问题时完全无法定位到源码行只能靠猜。BRIG 还有一个容易忽略的特性它是可以带多个入口的。一个模块里可以有多个内核函数运行时按名字查找派发。这个设计让一份二进制可以打包一整套算子库代价是加载时要做符号解析。4.2 Finalizer 分配到硬件资源时的几个硬约束Finalizer 是整条链路里最神秘的一环因为它通常以二进制形式随驱动发布你看不到内部。但从它的输入输出能反推出几个硬约束这些约束直接决定了你写的 HSAIL 能不能跑、跑得好不好。第一个约束是寄存器文件容量。虚拟寄存器无限物理寄存器有限。Finalizer 要做寄存器分配分配不下就溢出到 spill 段。溢出的代价是每次读写都要走内存延迟比寄存器高一个数量级。判断溢出严重不严重看 spill 段的用量就行。第二个约束是组段容量。你声明的 group 段变量总大小不能超过目标设备的上限。超了之后有的实现直接报错有的实现悄悄降级成 global 段后者更坑因为你根本不知道性能为什么掉了。第三个约束是work-group 尺寸的可达性。有些设备对组大小的限制不是简单的上限而是要求组大小是某个数的倍数或者要求某个维度不能超过某个值。这些约束有的写在设备 profile 里有的只能靠试。第四个约束是指令选择的覆盖度。HSAIL 规范定义的指令集是一个超集具体某个 Finalizer 可能只实现了其中一部分遇到没实现的指令要么报错要么用一串等价指令模拟。模拟出来的代码性能可能差很多而且你从源码上完全看不出来。注意遇到“同一份 HSAIL 在 A 设备上很快、在 B 设备上很慢”的情况先别怀疑算法先去查 B 设备的 Finalizer 是不是把某条指令做了软件模拟。这是最常见的原因也是最难自己想到的原因。4.3 编译失败的定位链路从日志到反汇编Finalizer 报错的时候通常只给你一行信息比如“某条指令在某处不支持”信息量极低。我摸索出来的一条定位链路是这样的先确认是汇编阶段还是翻译阶段出错。如果 HSAIL 文本汇编成 BRIG 就失败了那是语法或者类型问题错误信息通常比较明确能直接定位到行。再看是不是 profile 不匹配。用了$full才有的指令但声明的是$base这种错误有些工具会给出明确提示有些只会含糊地说指令非法。然后查资源超限。把 group 段用量、寄存器用量、work-group 尺寸逐个调小看哪个调小之后能过就能定位到是哪类资源超了。最后才怀疑指令模拟。如果编译能过但结果不对就把可疑指令替换成等价的基础指令组合逐一排除。反汇编这一步很多人会跳过觉得中间表示的汇编没什么好看的。实际上把 BRIG 反汇编回文本能发现很多前端悄悄插入的东西比如隐式的类型转换、自动加的对齐填充、额外的屏障。这些“看不见的代码”往往是性能问题的源头。5. HSAIL 的结局以及现在还值得翻这一章的理由5.1 它为什么没能走出实验室说句实话HSAIL 今天的实际使用范围已经非常窄了。原因不是设计得不好而是几个现实因素叠在一起。最根本的一条是它绑定了统一寻址这个前提。HSAIL 的很多设计红利比如指针可以直接在 CPU 和 GPU 之间传递、不需要显式拷贝都建立在两者共享虚拟地址空间的基础上。这个前提在某些系统上成立在很多系统上不成立。一旦不成立HSAIL 相比其他中间表示就没有明显优势了。第二条是生态惯性。中间表示这种东西价值在于两端都有人接。前端愿意生成、后端愿意消费它才活得下去。当主流的前端工具链和后端编译器各自形成了自己的组合中间插一层公共表示的收益就变得很小成本却很高。第三条是规范本身的复杂度。HSAIL 的指令集相当庞大profile 分了好几种每个 profile 支持的子集还不一样。一个后端要完整实现所有 profile工作量极大。实际实现往往只做一个子集于是“可移植”这个承诺就打折了。这三条叠加起来结局基本就注定了。但这不妨碍它作为一份设计文档的价值。5.2 学过 HSAIL 再去看 SPIR-V哪些概念是平移的我自己最直接的收获是看后来的中间表示时不再从零开始。下面这几个概念是直接平移的显式的内存段划分。HSAIL 让你必须在指令里写清楚访存落在哪个段这个习惯一旦养成看任何 GPU 代码都会本能地去判断“这次访问是全局还是本地”。很多性能问题就是在这一步被发现的。强类型的操作码。类型写在指令后缀里逼迫你思考每一次运算的实际位宽和符号性。这个习惯在排查整数溢出和符号扩展问题时非常有用。虚拟寄存器与物理寄存器的分离。理解了这一层就理解了为什么“源码里变量少”不等于“寄存器用量少”也就理解了 spill 统计的意义。屏障的语义边界。组内可见、跨组不可见、全局需要额外栅栏这三条放到任何并行模型里都成立只是表达方式不同。并行三层的索引体系。组内 id、全局 id、扁平 id 的取舍逻辑在别的模型里同样存在只是名字换了。反过来SPIR-V 之类更现代的设计里有些东西是 HSAIL 没有的比如更严格的验证规则、更模块化的扩展机制、更彻底的能力声明。对比着看能明白这些年中间表示设计在往哪个方向走。5.3 想搭个最小实验环境的思路如果你看完还是手痒想实际跑一跑我给一个不依赖具体产品的思路。第一步是找一份 HSAIL 文本。你不用自己从零写很多公开的示例代码里就带着编译好的 HSAIL 产物或者你可以用支持导出中间表示的开源编译器前端生成一份。拿到之后先做纯文本分析把指令分类、把内存段标出来这一步不需要任何硬件。第二步是找一个能把 HSAIL 汇编成 BRIG 的工具以及一个能反汇编回来的工具。这一对工具能让你在文本和二进制之间来回转换是理解 BRIG 结构最快的方式。做几次“改一行文本、汇编、反汇编、看差异”的循环你对 BRIG 编码规则的理解会比读十遍规范都深。第三步才是找 Finalizer。这一步的现实困难比较大因为相关工具链的维护状态各不相同能不能跑起来要看你的环境。我的建议是不要把精力全押在这一步前两步的收获已经足够支撑你理解整个模型了。最后再补一个我觉得最实用的小技巧读规范的时候先读内存段和屏障这两章再回头读指令集。指令集那一章又长又琐碎很容易读睡着而内存段和屏障这两章决定了整个模型的骨架读完再去看指令每条指令为什么这么设计就都有了解释。这个顺序是我踩了几次“从头读到尾、读完啥也没记住”的坑之后才总结出来的。