LLVMCon-2024-笔记-全-

LLVMCon 2024 笔记(全)

001:基于MLIR的ML编译器性能分析插桩

在本教程中,我们将学习如何为基于MLIR的机器学习编译器进行性能分析插桩。我们将探讨在GPU编程环境中进行编译器级性能分析所面临的挑战,并介绍一种通过LLVM插件和自定义编译过程来实现高效、低开销数据收集的解决方案。

概述

我是Corman,来自MDD的ML编译器团队。本次分享我们将介绍我们为基于MLIR的机器学习编译器进行性能分析所做的工作。

背景是,许多机器学习编译器和框架广泛使用MLIR和GPU内核。这从基于编译器的性能分析角度提出了挑战。虽然可以使用动态二进制信息工具,但它们通常粒度较粗,难以获取更高级别的源码级信息。调试信息虽有帮助,但会丢失编译器内部可用的信息。因此,通过在编译器中插桩,我们可以追踪导致性能瓶颈的特定GPU内核缺陷,穿越通常非常繁琐的各个降级阶段,而目前缺乏内置于编译流程的工具。

我们发现,在程序源码层面进行插桩以获取更高级别的数据操作信息非常有用。本项目的总体目标是开发一套轻量级、可定制的开源编译器过程,并将其集成到一些流行的ML编译器和流程中。

对于不熟悉此概念的听众,这里的“插桩”指的是通过编译器过程,将分析代码注入GPU代码的性能关键部分,以获取各类信息。

挑战

在基于MLIR并使用GPU的框架中工作时,会遇到哪些类型的挑战?

首先,GPU插桩并不像在CPU上那样直接。分析代码在GPU上生成数据后,必须以某种方式将这些数据移回CPU。这涉及到主机调用(host call)类型操作,例如AMD实现printf的方式,但这会带来巨大的开销,因此不适用于任何重量级操作。在我们的示例中,一个小的注意力模型就生成了约10GB的数据。因此,任何生产环境中的方案都必须能处理这种情况。

其次,LLVM(至少在我们的案例中)将GPU和CPU代码分离到不同的模块中。因此,必须找到在它们之间协作的方法。

再者,通常我们希望使用基于Clang的工具链来编写插桩函数(即用C++编写),然后将其插入到基于MLIR的框架中,而该框架本身可能并不包含Clang驱动和运行时环境。

此外,当在MLIR流水线中进行插桩时,尤其是在我们工作的框架中,通常只能看到GPU内核,而没有CPU主机代码可供添加调用。因此,我们只能获得基于MLIR的GPU内核。

应用框架与视角

我们通常工作的流行框架包括Triton、IREE,以及部分PyTorch(仍在进展中)。在这些框架中工作的一项挑战是,GPU代码对用户是刻意隐藏的,旨在提供易用性,用户无法直接访问实际的代码生成过程。因此,在进行性能分析和调优时,你希望了解幕后做出的决策,并找出问题所在。

这种需求根据你的身份而有所不同:

  • 如果你是硬件架构师,设计内存系统,你想了解特定模型的内存访问模式。
  • 如果你是编译器开发者,你想查看数据移动、计算与通信的重叠机会、内核内计时等。
  • 如果你是用户,你希望获得即时反馈,并能关联到流水线中的各种对象。你不仅想知道源码级别的信息,还想知道:“能否给我关于特定对象(例如Triton中的张量对象)的信息?能否告诉我哪一行源码的哪个张量对象存在内存合并或存储体冲突等性能瓶颈?”

解决方案:插桩机制

我们通过结合LLVM插件、特定分析过程和Clang生成的内核来实现插桩。我们通过“优化模块阶段”插入它们,这是MLIR框架中一个常用的部分。这种方法具有通用性,可以在不同框架的多个位置插入。

插桩过程的工作原理细节如下:

  1. 克隆内核并添加额外参数:过程会克隆内核函数,并添加一个额外的内核参数。这是数据传递发生的地方,用于将信息从GPU传回CPU。在模块化编译的方式下,这是将GPU生成的数据高效移回CPU(用于写入磁盘或进行分析)的最佳体验方式。
  2. 设置设备端缓冲区:我们在GPU上设置一个设备端缓冲区,GPU线程将数据写入其中。这一切都是通过LLVM过程设置的。
  3. 缓冲区管理与信号机制:你拥有一个用户定义大小的缓冲区。当发现缓冲区已满时,GPU会发出一个信号,通知一个主机线程来清空缓冲区,然后GPU可以继续运行。这样,设备可以持续填充这个缓冲区,当它周期性变满时,处理过程会暂停。这与主机调用不同,主机调用需要停止所有操作,破坏性很大。

示例:内存访问模式追踪

这是一个相当常见的插桩示例,用于获取加载和存储操作访问虚拟内存地址的热力图。

其工作方式是,在每个全局加载和存储操作处添加一个编译器过程。这是一段C++分析代码,它被编译后插入到没有Clang依赖的MLIR框架中。这很酷,意味着我们可以将C++代码插入到任何想要进行分析的地方。

我们计算虚拟地址,并能够获取元数据,例如:

  • 源码位置
  • MLIR级别的对象信息
  • 时间戳
  • 硬件特定信息,如生成数据的波前(wave)、计算单元(CU)或小芯片(chipplet)

这是一个示例。在一个Flash Attention模型中,你可以获得各种有趣的数据,可以将它们与内存地址关联起来,获取硬件特定信息,并将其关联回源码。此外,还有一个独立的部分允许你获取更高级别的对象信息。

当前状态与总结

本节课中,我们一起学习了为基于MLIR的ML编译器进行GPU性能分析插桩的方法。

当前的状态是,我们已经开发了用于以下方面的插桩过程:

  • 内核内计时
  • 内存访问模式(最成熟的部分)
  • 各种基础性能瓶颈,如内存合并、存储体冲突

其优点是高度可定制,你可以基于现有过程进行扩展以满足需求。相关基础设施代码已经上游化,我们正致力于将其集成到现有工具中。我们还有一些开源的过程可供使用,如果大家有兴趣,可以取用并进行实验。

我的时间到了。谢谢,Carbin。

002:LLVM中的浮点运算——现状、问题与缺失

在本节课中,我们将要学习LLVM中浮点运算的语义。我们将探讨其良好定义的部分、当前存在的问题以及完全缺失的功能。课程内容基于IEEE 754标准,但会重点分析LLVM实现中的具体细节和挑战。

概述:为什么IEEE 754标准不是完整答案?

上一节我们介绍了课程主题,本节中我们来看看为什么单纯引用IEEE 754标准不足以定义LLVM的浮点语义。

首先,并非LLVM中的所有浮点类型都基于IEEE 754定义的类型。其次,标准本身在某些方面定义不够明确,例如NaN的有效载荷(payload)行为或“粘性”(tininess)的定义。此外,硬件实现经常做出与标准不同的行为,有时是提供额外功能(如非规格化数刷新),有时则是不得不偏离标准(例如某些嵌入式硬件可能完全没有浮点环境)。最后,浮点环境本身是一种共享的、隐藏的、可变的全局状态,这对编译器优化者而言是一个难题。许多用户有时也愿意为了速度而牺牲一点正确性,这正是快速数学(fast math)标志的基本前提。

硬件实现的约束

在讨论LLVM描述的浮点语义之前,让我们先了解一些硬件层面的差异,这些差异限制了我们提供不同语义的能力。

非规格化数刷新

最著名的硬件差异之一是非规格化数(denormal)刷新。它包含两个独立的控制位:

  • 非规格化数为零(DAZ):如果浮点操作的输入操作数是非规格化值,则将其视为带符号的零。
  • 刷新到零(FTZ):如果操作的结果是微小的(tiny),则将其刷新(刷新)为带符号的零。

需要注意的是,“微小结果”和“非规格化结果”之间存在区别。在某些情况下,即使结果是规格化数,如果启用了FTZ,它仍然可能被刷新为零。

一些硬件允许按类型设置非规格化刷新,例如仅对32位浮点数启用,而64位则完全支持非规格化数。另一些硬件则不给选择,例如在x86架构上使用AVX-512和Bf16时,总是会刷新非规格化数,与状态寄存器中的控制位无关。

最后一个问题是,某些编译器在使用快速数学标志链接时,会在进程启动时设置模式位,从而在整个进程范围内启用非规格化刷新,这可能导致问题。

x87浮点单元(FPU)

x87 FPU主要与32位x86代码相关。其核心问题是它只支持80位浮点类型。当加载或存储32位和64位浮点数时,会进行隐式转换,这种转换会静默NaN,可能引发问题。此外,80位类型实际上不足以在不进行双舍入的情况下充分模拟64位浮点类型。

浮点环境

浮点环境是另一个主要问题。IEEE 754标准强制要求一定程度的支持,特别是动态舍入模式和一些异常支持。在浮点运算中,异常与常规硬件陷阱不同,它们可以同时返回一个值并“抛出”异常。默认行为是设置粘性位(sticky bits),这些位将一直保持设置状态,直到被显式清除。一些硬件还支持在异常发生时产生硬件陷阱。

许多硬件也支持非规格化处理位,并且不同架构还有各种奇特的控制位,这些都以各种奇怪的方式改变值语义,使得为LLVM设计通用的、与目标无关的浮点语义变得困难。

基本操作与扩展

IEEE 754定义了一组核心算术运算,包括基本算术、比较、转换以及平方根和融合乘加(FMA)。大多数硬件都提供这组操作。此外,IEEE 754还定义了一组映射到标准初等函数(主要是超越函数,如指数函数、三角函数等)的附加算术函数,这些在硬件中不太常见,且精度往往较低。硬件还可以在此基础上添加其他操作,特别是在向量类型和低精度近似操作(如用于除法和平方根的倒数近似)方面。

一个值得注意的趋势是,忽略动态舍入模式的静态舍入模式在硬件(尤其是加速器硬件)中变得越来越普遍,因为这意味着不再需要关心浮点环境。

LLVM IR中的浮点语义

现在我们对硬件和IEEE 754标准有了一些基础了解,接下来开始审视LLVM IR内部的浮点语义。

良好定义的部分

LLVM浮点语义有一些好的方面。所有浮点类型都有明确定义的格式,其模式(pattern)也定义得相当好。在互操作中使用这些类型时,它们都有特定的预期行为,不会随机改变。NaN的有效载荷也不会被任意更改。

需要指出的是,我们定义的 fnegfabsfcopysign 操作本质上是针对符号位的整数操作。因此,如果你有特殊的NaN有效载荷,可以保证LLVM在通过这些操作传递时不会改变它们。

几年前,我们确定了处理NaN有效载荷的完整行为规则,本质上是一种传播机制。如果你的硬件不引入奇怪的NaN有效载荷,LLVM也不会。但如果上述情况不成立,那么发生什么就难以预料了。

在此过程中,我们还规定操作不要求将信号NaN(SNaN)转换为静默NaN(QNaN),这使我们能够将 1.0 * x 优化为普通的 x 表达式,从而消除FMA操作。

可能最重要的优化规则是:就浮点操作的值结果语义而言,必须保证其符合IEEE 754标准。这意味着优化不能改变该值,例如不能用 a * (1.0 / b) 替换 a / b。然而,后端和各种代码生成中存在许多错误,这意味着如果你依赖后端正确执行,不一定能得到相同的一致性。

需要强调的是,x87的行为被视为一个错误,尽管它加载数据时无需进行任何转换即可获得精确的32位到64位转换。这是一个错误,只是目前修复它的优先级不高。

浮点环境带来的复杂性

浮点环境是事情开始变得有点奇怪的地方。LLVM合理地假设浮点环境处于默认状态,并且大多数情况下没人真正关心它。如果你熟悉C语言中 #pragma STDC FENV_ACCESS 的规则,这实际上等同于默认关闭 FENV_ACCESS。基于这个假设,我们可以认为浮点操作是没有副作用的纯操作,因此可以自由推测、添加或删除。

如果你不希望遵守这些规则,即你想使用浮点环境,那么就需要使用 strictfp 属性。一旦启用,就不能再推测这些操作了。然而,这引发了一些关于这些操作在该点语义的问题。

关于浮点环境,另一个有趣的细节是:非规格化刷新不是通过 strictfp 指示的,而是通过 denormal-fp-math 属性指示的,因为它与快速数学(fast math)相关,而不是与更高级的环境操作相关。

由于前端在链接目标文件时可能为整个进程启用此功能,因此按翻译单元工作的前端无法始终正确地设置它。此外,将其作为函数属性也存在一些问题,例如它无法反映函数中间环境发生变化的可能性。

约束内部函数(Constrained Intrinsics)

如果你想在非默认浮点环境中使用浮点操作,应该用约束内部函数替换它们。这些内部函数被设计为具有不同的语义,并且能够感知环境。实际上,创建这些内部函数的初衷是:首先移除我们对浮点操作的所有优化,仅在我们知道安全的情况下才添加它们。

需要注意的是,约束内部函数目前被认为是实验性的,并且它们并不算新。因此值得问一个问题:这个实验失败了吗?我认为是的。我们注意到了这些内部函数的几个主要问题。

首先是大量的重复。当我们添加向量谓词(vector predication)内部函数时,这个问题变得尤为明显,因为现在每个操作都需要四套内部函数:带约束的、带向量谓词的、两者都带的以及两者都不带的。对于目标特定的内部函数来说情况更糟,因为它们最初就没有这些变体。

其次,如果你想对这些内部函数进行优化(例如,你想使用非默认舍入模式但不关心浮点异常),那么现在必须在代码生成器中复制每一个模式,这对编译器来说是大量的重复工作。

其他问题包括:这些内部函数没有提供指定更奇特浮点环境功能(如非规格化处理)的方法。在LLVM的浮点工作小组中,已经形成了一个初步共识:我们希望从约束内部函数转向使用操作数包(operand bundles)。目前已经有相关的补丁,但尚未实现,因此目前唯一的选择是使用约束内部函数。

数学库函数的问题

接下来要讨论的是数学库函数的问题,这些函数来自IEEE 754的附加操作(不包括平方根或FMA,因为它们被视为核心函数)。

根据IEEE 754,所有这些函数都应该是正确舍入的。但数学库的实现者表示这太难了,他们不会进行正确舍入。因此,不同的库对于相同的输入可能会给出不同的结果。

数学库函数在LLVM中可以通过两种方式使用:使用带有C语言名称修饰(name mangling)的C函数名,或者使用带有LLVM名称修饰的LLVM内部函数。使用C名称修饰可能有问题,因为C语言中的 long double 类型根据编译目标的不同,可能映射到四种不同的LLVM IR类型。

另一个问题是,这些内部函数被定义为等效于库函数调用,但不应该设置任何异常。然而在代码生成中,它们通常被降低为库调用,而这些库调用在大多数情况下会设置 errno

我们还有其他问题:我们愉快地对这些内部函数进行常量折叠,这可能会改变结果,特别是在交叉编译或主机与目标库不同的情况下。由于C函数名和LLVM内部函数之间的重复,我们用于优化各种表达式的模式通常只匹配其中之一,两者都匹配的情况 surprisingly 很少。

这些函数与快速数学标志的交互也很有趣:我们何时被允许应用各种数学恒等式?我们需要快速数学标志来说明 sin(-x) 等价于 -sin(x) 吗?对于其他情况呢?是否有一组小的常数值,我们知道可以常量折叠,并且应该对所有架构都相同?更复杂的表达式呢?例如,将 sin(x) / cos(x) 转换为 tan(x),因为这代数上是正确的,需要哪些快速数学标志?实际上,目前的答案是 reassoc(重关联)。有多少人预料到了这个答案?

快速数学标志的现状

快速数学标志本身目前也是一个棘手的问题。目前主要通过七个标志位来指定,这些标志可以附加到单个指令上。但事实证明,我们并非在所有浮点指令上都支持快速数学标志,特别是转换指令上没有,我找不到原因。

你也可以通过函数属性来指定这些标志,但这些属性主要只在代码生成的选择DAG期间使用,我们应该在所有情况下都逐渐弃用它们。

这些标志的实际底层语义在很大程度上是未定义的,许多情况下不清楚它们的真正含义。例如,如果你读过 afn(近似函数)的定义。我一直在慢慢梳理所有这些标志,并试图为它们提出更好的定义,但这确实需要大量时间来分析和确定应有的情况。

然而,我可以非常肯定地说,我们需要更多的标志位。例如,reassoc 标志做的事情太多了:它不仅用于表示可以对浮点表达式进行结合律和分配律变换(以便进行归约树优化),还用于许多代数表达式变换,比如将 sin/cos 转换为 tan 或其他类似的代数定律。

Andy在他的下一个演讲(几分钟后开始)中会更多地谈到这个问题。还有一个问题是,许多现有的优化并没有遵循我们已有的快速数学标志规则。总的来说,这个领域需要更多的工作。

总结与现状评估

本节课中我们一起学习了LLVM中浮点运算的语义、存在的问题以及缺失的功能。

我相信你们会喜欢我继续谈论浮点语义的各种问题,但这次演讲的时间不足以覆盖所有内容。以下是我对LLVM中浮点支持现状的评估:

  • 表现良好的方面:例如,值语义(value semantics)。
  • 完全缺失的方面:例如,我们没有任何对静态舍入模式操作的支持,也基本上没有对低精度近似操作的支持。
  • 半工作状态:例如浮点环境支持,部分工作,部分不工作。

我将很乐意把这张幻灯片留在这里,供大家提问时参考。

谢谢。

003:迈向实用的快速数学优化

概述

在本节课中,我们将探讨LLVM编译器中的“快速数学”优化。我们将分析其带来的性能优势与潜在风险,并讨论如何使其对开发者更加实用和可控。


快速数学的“坏处”:潜在风险

上一节我们概述了课程内容,本节中我们来看看快速数学优化可能带来的问题。它虽然能提升性能,但也伴随着改变程序行为的风险。

以下是快速数学可能导致的一些主要问题:

  1. 改变数值结果:最常见的例子是重关联优化。对于浮点数运算,(a + b) + ca + (b + c) 的结果可能不同。编译器在启用快速数学后,可能会为了规范化或优化而改变运算顺序。
  2. 优化掉显式的NaN/Infinity检查:启用 -ffast-math 通常会包含 -fno-signed-zeros-fno-trapping-math 等假设,这可能导致编译器认为程序中不存在NaN或无穷大,从而移除开发者编写的显式检查代码。
  3. 悄无声息地刷新次正规数到零:在许多情况下,快速数学会隐式启用刷新次正规数到零的选项。这可能导致当数值进入次正规数范围时,结果突然变为零,而开发者可能对此毫无察觉。
  4. 完全丧失精度:某些算法对数值变化非常敏感。快速数学优化可能导致关键的小量值在计算中被丢弃,从而使算法失效。例如,在计算 (A - B) + epsilon 时,如果 AB 非常大,重关联为 A + (epsilon - B) 可能导致 epsilon 因数量级差异而被完全忽略。

为了总结本节内容,我们来看一个最极端的案例。这是一个经典的用于计算机器精度的算法:

// 寻找最小的 k,使得 1.0 + 2^(-k) != 1.0
for (int k = 0; ; k++) {
    double a = 1.0 + pow(2, -k);
    double c = (a + 1.0) - a; // 理论上,若未发生精度丢失,c应为1
    if (c != 1.0) break;
}

在启用快速数学后,编译器看到 c = (a + 1.0) - a,并应用重关联优化,将其简化为 c = 1.0。这导致循环条件永远为假,进而可能被优化为无限循环。根据语言标准,无限循环是未定义行为,编译器甚至可能将整个循环删除。


快速数学的“好处”:性能提升

既然快速数学有这么多风险,为什么还有人使用它?答案在于它能显著提升程序性能。本节我们来看看它带来的好处。

以下是快速数学启用后带来的主要优化机会:

  1. 循环不变代码外提:通过重关联,编译器可以将循环内部分计算移到循环外部。例如,将 A[i] = x * B[i] * y 重写为 tmp = x * y; A[i] = tmp * B[i],减少循环内的计算量。
  2. 启用向量化:对于浮点数循环,许多简单的向量化优化需要重关联语义才能进行。如果没有快速数学,编译器可能无法生成高效的SIMD指令。
  3. 基于无NaN/Infinity假设的优化:如果开发者能确定程序中不会出现NaN或无穷大,并告知编译器,则可以启用更多优化。例如,知道 x 不是NaN后,编译器可以将 x - x 优化为 0

现状:控制选项与局限

我们已经了解了快速数学的双面性,本节中我们来看看目前开发者有哪些工具可以控制它。主要从Clang编译器的角度进行说明。

目前控制浮点数模型和快速数学的选项主要继承自GCC,但存在一些混淆:

  • -ffast-math-funsafe-math-optimizations:从名字上看,哪个听起来风险更大?显然是“unsafe”。但事实上,-ffast-math 包含了“无NaN/无穷大”的激进假设,通常比 -funsafe-math-optimizations 更“不安全”。
  • -ffp-model= 选项:为了提供更清晰的语义,Clang引入了此选项。其模式包括:
    • strict:严格遵守标准,禁用所有可能改变结果的优化。
    • precise:允许不影响精度的安全优化(默认)。
    • fast:启用一组被认为“较快”但可能改变结果的优化(类似 -funsafe-math-optimizations)。
    • aggressive:启用更激进的优化(类似传统的 -ffast-math)。
  • 精细控制选项:开发者可以单独启用或禁用特定的优化,例如:
    • -ffp-contract=fast:允许跨表达式的融合乘加(FMA)优化。
    • -fno-honor-nans:假设没有NaN。
  • 编译指示(Pragmas):可以在代码局部范围内控制优化行为,例如 #pragma clang fp reassociate(on/off)。这为控制快速数学的影响范围提供了有力工具。

实用工作流程与未来构想

面对众多选项,如何安全有效地使用快速数学?本节将介绍一个建议的工作流程,并探讨编译器未来如何能提供更多帮助。

一个通用的探索性工作流程如下:

  1. 使用 -ffp-model=fast 编译整个项目。
  2. 测试结果是否在可接受范围内。
  3. 如果结果不可接受,则需定位问题。可以逐个文件禁用快速数学,找到引入问题的源文件。
  4. 在问题文件中,使用编译指示(Pragmas)在更小的作用域内重新启用优化,逐步缩小范围,直到找到引发问题的具体代码区域或优化。

然而,这个过程非常耗时且容易出错。因此,我构想了一个长期的解决方案:在优化器中引入一个“许可检查”机制。

其核心思想是:每当优化器想要执行一个依赖于快速数学假设的变换时,它首先调用一个“许可检查”函数。这个机制类似于现有的“优化燃料”(Bisect)功能,但目的是为了报告和诊断。

设想中的工作方式:

  • 开发者可以通过一个编译选项(如 -ffast-math-debug)启用此报告功能。
  • 当优化器执行一个快速数学变换时,它会输出一条信息,包含源位置、所做的变换类型等。
  • 如果因为快速数学标志未启用而无法进行某个潜在优化,它也可以报告这个“错失的机会”。
  • 这能帮助开发者直观地看到哪些优化被应用了,并在结果出错时,快速定位到元凶变换。

代码层面的设想是在每个优化变换点插入检查,例如在重关联优化的代码中:

// 假设的代码修改处
if (I->hasAllowReassoc() && K->hasAllowReassoc()) {
    if (shouldReportFastMathOpt()) {
        emitFastMathRemark(“正在进行浮点数重关联优化”, I);
    }
    // ... 执行实际的优化变换
}

实现这一构想面临挑战,例如需要维护大量的检查点、确保报告信息的准确性,以及平衡运行时开销。但它有望使快速数学优化变得更加透明和可控。


总结与问答环节要点

本节课中我们一起学习了LLVM中快速数学优化的利与弊。我们了解到,虽然它能通过重关联、向量化等优化大幅提升性能,但也会改变数值结果、移除安全检查,甚至引入错误。目前,开发者可以通过 -ffp-model、精细控制选项和编译指示来管理其影响范围。

在问答环节,讨论要点包括:

  • 优化报告:有开发者指出,现有的优化报告(Remarks)机制可以为基础框架,但需要更好的用户体验(如与源文件行号对应)和更完整的覆盖。
  • 混合标志:当不同指令具有不同的快速数学标志时(例如,由于局部使用了Pragma),优化器的检查必须考虑所有相关操作,目前的实现有时存在遗漏。
  • 替代方案:有建议认为,或许可以通过语言扩展(如OpenMP的SIMD指令)或引导用户手动重写代码(例如使用C++并行算法)来获得性能,从而减少对全局快速数学标志的依赖。
  • 维护性与开销:关于“许可检查”机制的实现,主要挑战在于长期维护(确保每个优化点都添加检查)和在发布版本中的运行时开销。解决方案可能包括通过编译选项完全禁用该机制,或将其设计为非常轻量的内联判断。

核心目标是让编译器在追求性能的同时,为开发者提供足够的透明度和控制力,使快速数学优化真正变得“实用”。


本节课中我们一起学习了:快速数学优化的风险与收益、当前可用的控制选项(如-ffp-model和Pragmas)、一个定位优化问题的实践工作流程,以及一个关于未来通过“优化报告”机制来增强可控性和透明度的构想。

004:当前状态与未来方向

概述

在本教程中,我们将学习 LLVM libc 数学库的当前状态与未来发展方向。我们将了解其目标、实现特点、性能表现以及对最新 C23 标准的支持情况。

什么是 LLVM libc?

LLVM libc 是 C 标准库的一个实现,属于 LLVM 项目的一部分。它专注于实现最新的 C23 标准,并填补 POSIX 等其他用户所需的功能。实现语言主要为 C++,并辅以少量汇编代码。

项目目标

我们的目标是完整实现 C23 标准。一个更宏大的目标是提供一个“通用 libc”,使其能在任何你关心的操作系统、现代 CPU 或嵌入式系统上运行。我们也在 GPU 上开展工作。

数学函数实现特点

我们正在实现所有 C23 数学函数,首要关注点是精度。当然,为了使其随处可用,我们也需要性能。此外,还有可移植性可配置性,因为我们知道没有一种方案能适合所有人。当前我们主要遵循的浮点标准是 IEEE 754-2019。

关于精度

我们致力于使数学函数尽可能精确,最终目标是使其在所有舍入模式下都能正确舍入。目前,我们已在 x86-64 的 4 种默认舍入模式下进行了测试。正确舍入的数学函数将带来一致性,这种一致性将跨越所有平台、CPU 和操作系统。这能显著减少将新库版本集成到应用程序中的工作量。

关于性能

许多人在谈论精度时会担心性能。那么,为了达到这种精度,我们付出了什么代价呢?事实证明,代价并不大。总体而言,在延迟方面,我们平均比 Glibc 快约 15-16%。在吞吐量方面,我们平均比 Glibc 快约 25-30%。这有助于大幅降低机器学习和 AI 的计算成本。

关于可移植性

我们的大多数函数实现都是平台无关的,使用 C++ 编写。得益于当前的努力,它可以在没有任何浮点单元甚至浮点运行时库支持的平台上构建。我们拥有构建通用软浮点库所需的所有组件。

关于可配置性

我们在构建时提供对多种用例的支持。你可以创建自己喜欢的数学函数集。我们还提供一些优化选项,例如禁用 NaN 处理、指定舍入模式以及优化代码大小。我们也可以允许你通过跳过最终精度提升步骤来降低一些精度,以换取性能。

对 C23 标准的支持进展

在 C23 中,新增了高精度类型(如 float16_tfloat128_t)以及对固定点数的支持。我们已经完成了所有浮点类型的基本数学运算实现。目前,我们已实现大约四分之一的超越函数,并且所有已实现的函数在我们测试的舍入模式下都能正确舍入。

高精度类型支持

  • float16_t:这是 C 标准指定的新类型。它在未来硬件上具有巨大的性能潜力,也非常适用于其他小型平台。
  • float128_t:C23 将其指定为 _Float128。实际上它比听起来更复杂,但 float128_t 是确保跨平台一致性的一个很好的选择。
  • 固定点类型:这是 2008 年作为嵌入式处理器扩展指定的。当适用时,它能提供显著的加速和代码大小缩减。Clang 和 LLVM libc 是目前唯一开箱即用支持固定点的开源选项。

近期与远期规划

在近期,我们希望完成所有 C23 数学函数的实现,并开始支持复数运算。我们还需要确保所有其他平台都能获得与当前 x86-64 平台同等的优先级支持。

更长远来看,我们将持续为不同目标优化性能和代码大小。我们计划启动向量数学库(libmvec)的工作。此外,我们还将支持 C++26 的 constexpr 数学函数,并探索对其他浮点类型(如 bfloat16 或 Intel 80 位浮点)的支持。

总结

本节课我们一起学习了 LLVM libc 数学库的现状与未来。我们了解到它是一个追求高精度、高性能、可移植和可配置的 C 标准库数学实现,正积极跟进 C23 标准,并规划了包括向量化支持在内的未来发展路线。

005:提升后端稳定性与应对 GlobalISel 的挑战 🧩

在本教程中,我们将学习如何将高级的 LLVM IR 稳定地翻译为 SPIR-V 中间语言。我们将探讨 SPIR-V 后端开发的核心挑战,特别是在使用 GlobalISel 框架时遇到的语义鸿沟问题,并介绍一系列实用的解决方案和权衡策略。


概述:SPIR-V 后端的目标与挑战

SPIR-V 是一种用于表示图形着色器阶段和计算内核的中间语言。其规范由 Khronos Group 驱动,后端由 Khronos 成员公司开发。SPIR-V 是一个可移植、稳定、跨供应商的程序表示,其作用类似于 LLVM IR,旨在使开发者从供应商特定的指令集和前端语言中抽象出来。

将 LLVM IR 映射到 SPIR-V 的主要挑战在于,两者都是高级表示,但 SPIR-V 是一种语义丰富的语言。这个任务更像是“翻译成 SPIR-V”,而非传统的“指令选择”,因为它并不总是遵循常规的指令选择转换和机器码验证的期望。

另一个关键挑战是 SPIR-V 不代表任何真实的硬件。它是一个可移植的、与供应商和硬件无关的抽象目标。因此,我们期望 SPIR-V 代码稍后被翻译成加速器指令集,或者可以编码回 LLVM IR。这意味着我们不需要寄存器分配和调度,并将硬件相关的优化推迟。


类型系统的鸿沟与映射策略 🔍

上一节我们介绍了 SPIR-V 后端的基本目标,本节中我们来看看类型系统带来的核心挑战。

SPIR-V 拥有丰富的类型系统,包括指针类型、数组、结构体等。相比之下,LLVM IR 的低级类型(如 i32i64)设计用于机器指令和低级语言,所有复杂类型都被分解并简化为最小信息。

为了将原始的 IR 类型关联到最终的 SPIR-V 类型,我们实施了两项操作:

  1. 用注解来丰富 IR 类型。
  2. 追踪 Value 之间的关系,推导隐式存在的类型,并显式地链接 IR 值和 SPIR-V 类型,因为代码生成器不会跟踪每个 Value 关联的类型。

我们通过寻找已知模式来关联值和显式类型,分析复合体和函数调用,并在所有函数处理完毕后重新审视不完整的类型。后端使用一个全局状态来追踪关联,并引入内部服务内部函数(intrinsics),以保留代码高级视图与其低级表示之间的链接。


聚合体(Aggregate)降级的挑战与解决方案 🧱

聚合体(如结构体)的降级是展示技术权衡解决方案的一个很好例子。

GlobalISel 预期物理寄存器的出现。然而,SPIR-V 没有指令集架构,它期望单个 Value 代表一个聚合体,并且不会被分解成低级类型。当一个聚合体参数在 IR 翻译器中被扁平化时,会产生多个 Value,这使得调用无法被翻译。

初始方法是移除聚合体,将调用变异为用 i64 替换聚合体,并记录这些更改,以便稍后存储一个有效的类型。但这种方法的缺点在 SPIR-V 测试套件中显现出来:由于普遍存在的类型 i64,在 IR 翻译器之前无法推导和存储正确的类型。

为了测试正确的聚合体类型,我们使用完全变异来在类型推断期间获取原始函数类型。但这并没有消除根本原因。例如,带有溢出的算术内部函数(intrinsic)就很好地演示了这个问题,因为代码生成管道可能在后期插入它们,而后端传递无法变异该调用,并且它们返回一个结构体而不是 i64

翻译器没有为用于将高级值映射到 Value 的后端内部函数提供特殊翻译。一个后端服务调用对翻译器来说是一个未知的内部函数。因此,当 LLVM IR 被映射到 Value 时,聚合体被扁平化,产生多个 Value,这样的调用将不会被翻译。

我们有哪些选择?
以下是几种可能的解决方案:

  • 手动展开内部函数:提前将它们翻译成等效的 IR。这可行,但忽略了通用的共享代码,并且当我们希望修改全局的 ISel 逻辑时,这不是一个可取的方案。
  • 采用自定义调用降级:这将使解决方案复杂化,无论是在开发还是维护方面。
  • 改进现有方法:我们最终选择了一个成本更低的解决方案。后端利用 LLVM 的 fake use 内部函数来追踪聚合体参数的原始 IR 值。这链接了代码的高级视图与其低级表示。我们还插入后端内部内部函数,在元数据中存储 IR 值的名称和原始类型,否则这些信息将被丢弃。

这些问题在此案例中得到了解决,我们重用了代码生成逻辑,避免了维护目标无关更改的负担,也避免了开发自定义代码降级的开销。


控制流处理的特殊性 🌊

控制流处理也存在特殊性。与 IR 类似,SPIR-V 也有函数、基本块等模型,但 SPIR-V 没有“调用单元”(call units)。GPU 特定的编译器希望看到控制流图(CFG)结构。计算着色器(Compute flavor)能解决这个问题,但着色器(Shaders)需要结构化的控制流。

一个常见但出乎意料的点是:在 SPIR-V 中,标签(Label)实际上是一条指令,它开始一个逻辑基本块。此外,根据规范,在无条件分支之后不能删除指令,因为它是块中的最后一条指令。SPIR-V 中没有指令来编码“if-then-only-else”结构。

因此,我们不允许像分支折叠、if 转换这样的分支优化来重构块,因为这可能会破坏结构化的控制流,而且无论如何,这种优化用处不大。


具体案例分析与解决方案 💡

现在,我们通过几个最近的 Pull Request 来简要演示具体问题。

案例一:标签打印问题
AsmPrinter 被硬编码为忽略目标中可能不存在的“标签”概念。为了保持打印器对目标的不可知性,它总是在函数末尾存在有效分支指令时为其创建一个符号。解决方案是检查目标格式,并授予目标决定是否不同的权利。我们检查格式,但通常,“标签”概念是在目标级别定义的。

案例二:指针类型推断与位转换(Bitcast)
一个更复杂的例子涉及指针类型推断。在左侧的 IR 中,有两个指针被推断为 i8i32 类型。IR 没有显式的指针类型,但通过 alloca、函数返回和赋值的链,可以进行类型推断。在 phi 节点处,传入的 %r1%r2 值的类型必须相同。

在右侧的后端代码中,我们看到在赋值和 phi 节点之间插入了一个位转换(bitcast)。这在 LLVM 中是一个无操作(no-op)指令,但对 SPIR-V 验证器来说并非如此。没有这个位转换,验证器会处理不一致的显式类型。机器码验证器会验证 G_BITCAST,并看到错误:“bitcast 必须改变类型”。对于 SPIR-V,我们确实要求位转换来改变指针类型。

鉴于我们不能对操作码施加可选限制,我们仍然可以使其依赖于目标。可以考虑添加一个虚拟调用来微调降级过程,后端可以实现其目标降级规则并覆盖无操作位转换的默认状态。目前,我们所能做的就是选择一种错误类型:要么由于 phi 中的类型不匹配而无效,要么在指针上使用 G_BITCAST

案例三:Phi 节点处理
更重要的是关于 phi 节点的例子。在 SPIR-V 中,OpPhi 就是 phi 节点。它开始一个基本块,每个前驱块有一对传入值和标签。在指令选择步骤之后,G_PHI 操作码变为 OpPhi,验证器开始报告“定义不支配所有使用”的问题。

问题在于,传统指令集不实现 phi 指令,但 SPIR-V 实现。可能的解决方案是让代码生成器不检查 phi 指令,或者允许我们覆盖该检查,引入一种检查机制来判断操作码是否代表 phi 指令。

我们提出了三种情况:

  1. 首先,接受 SPIR-V 是不同的。
  2. 其次,机器验证器迫使我们适应一个不可行的方案。
  3. 一个有些可疑的修复方法(不修改 ISel)是直接生成 OpBitcast,而不重用 G_BITCAST。这需要在翻译前访问 IR 并将原始位转换转换为调用点,带来开销。同时,在这种情况下,我们不重用共享的代码数据库,并忽略现有的位转换操作码。

这个简短的案例对指令选择本身的定义提出了质疑。理论上,指令选择会产生目标特定的机器指令,而 SPIR-V 是一个后端。根据规范,SPIR-V 在函数的 SSA 图中实现了 phi 节点。因此,在指令选择之后报告目标规范认为有效的内容似乎是一个问题。


应对不透明指针(Opaque Pointers)的过渡 🔄

首先,我们讨论一下向 LLVM 17 中不透明指针过渡带来的一些挑战。虽然不透明指针简化了 LLVM IR 的某些方面,但这一过渡给 SPIR-V 代码生成带来了独特的挑战。

在 SPIR-V 中,指针元素类型不仅用于发出必要的类型声明,而且对于解析 OpenCL 内置类型和函数调用、保留正确的指针类型,以及降级未在 SPIR-V 中扁平化的嵌套类型(如结构体或数组)都至关重要。现在,没有了显式的指针类型,SPIR-V 后端必须推导这些类型,并且必须在编译管道的早期就正确获知。

为了确保我们恢复并保留这些类型,我们有三个相互协作的传递(passes),在代码生成的不同阶段工作,以解决 GlobalISel 降级和扁平化带来的问题。

让我们关注第一个传递:SPIRVEmitIntrinsics
类型推断过程是逻辑性的,但非线性且通常是嵌套或递归的。首先,类型从常见指令(如 G_PTR_ADDG_LOAD)中推导出来。对于每个类型赋值,该传递会发出一个目标内部函数:spv_assign_ptr_typespv_assign_type(在 OpenCL 或 SPIR-V 内置函数的情况下)。然后,该传递遍历调用并调整操作数类型,当被调用函数内部期望不同类型时,发出 spv_ptr_cast 内部函数。随后,SPIRVEmitIntrinsics 传递尝试推导剩余函数的类型,并在最后重新访问每条指令以修复任何不一致之处,或者换句话说,经常只是注入新的指针请求。

需要注意的是,这个过程非常复杂,有时是主观的。例如,phi 节点可能具有不同指针元素类型的传入值。当这种情况发生时,出现最频繁的类型会被分配。另外,一些 OpenCL 或 SPIR-V 内置函数具有众所周知或预定义的类型,在这些情况下,该传递使用带有硬编码值的查找表。

SPIRVEmitIntrinsics 和 IR 翻译器之后,我们有一个 SPIRVCallLowering 传递。SPIR-V 标签声明或类型也在定义函数的指令序列中被引用。因此,该传递尝试合并序列,使用通过 spv_assign_typespv_assign_ptr_type 内部函数分配的类型。一个例外是 OpenCL、GLSL 或任何 SPIR-V 内置函数调用的情况,我们在模块中只有一个存根(stub)。在这些情况下,调用通过使用一个 TableGen 文件进行反混淆(demangle),SPIR-V 内置类型和正确的实现(包括返回和函数参数类型)被发出到机器模块中。

在这两个传递之后,最后我们有一个 SPIRVPreLegalizer 传递,顾名思义,它在合法化之前运行。该传递从机器模块中移除所有 spv_assign_ptr_type 和/或 spv_assign_type 内部函数,并将每个机器 IR 寄存器映射到相关类型。这些映射存储在一个全局注册表中,以确保没有重复的类型声明,并且每个后续传递都可以使用全局注册表来获取现有类型或创建新类型。这不仅适用于指针,也适用于任何类型。


目标扩展类型(Target Extension Types)的作用 🎯

一个重要注意事项是,SPIR-V 有多种类型,例如 OpTypeImageOpTypeEvent,它们在 LLVM 16 中以前被表示为指向不透明结构体的指针。得益于 Joshua 的贡献,引入了一种新的目标扩展类型(Target Extension Type),以在某种意义上被目标无关优化忽略的同时,保留这些类型。这对于像 SPIR-V 这样的目标很重要,对于 DXIL 和其他在代码生成管道中需要更复杂自有类型的后端也很重要。

然而,一个缺点是,由于不透明指针的过渡,这些后端现在与来自旧版本 LLVM 的 IR 真正不兼容了。目标扩展类型的另一个用例是表示来自 SPIR-V 图像和采样器路径的嵌套类型,而不是依赖无法用于创建值的类型化指针类型作为起点。


测试策略与未来展望 🧪

回归测试(Lit tests)在不透明指针过渡中非常有帮助,其数量已显著增加。我们将其视为最重要且最简单的调试方法或捕捉回归的方法。这与通常依赖特定硬件或需要专用驱动程序栈的外部一致性测试套件形成对比。

我们已扩展了大多数回归测试,添加了 spirv-val 检查行。因此,每个测试的 SPIR-V 输出都被传输到 Khronos 的 SPIR-V 验证工具中,以确保二进制文件符合规范。此外,Nathan 贡献了一个名为 spirv-sim 的工具,用于测试当前正在开发的 SPIR-V 结构化器(Structurizer)传递。该工具专注于控制流和跨通道交互,最重要的是,它比简单的 FileCheck 行更健壮。当然,FileCheck 仍然可以用于这些测试,但检查行的顺序需要始终匹配 CFG 的输出,而不是 IR,这意味着任何修改这些测试的贡献者都需要具备相关知识并进行正确的调整。spirv-sim 帮助我们避免了影响后端的更改所产生的连锁反应。

SPIR-V 后端在过去两年中发生了很大变化,在许多方面是 Khronos LLVM-SPIRV-Translator 更好的替代品,通常能提供更简洁或性能更好的代码。因此,下一步自然是将 SPIR-V 后端确立为官方或非实验性目标。我们将在开发者会议之后跟进此事。我们也希望在不久的将来贡献 SPIR-V 消费者(consumer)部分。


总结 📚

本节课中我们一起学习了 LLVM SPIR-V 后端开发的核心内容。我们探讨了将高级 LLVM IR 翻译到语义丰富的 SPIR-V 所面临的主要挑战,特别是类型系统映射、聚合体降级和控制流处理方面的难题。我们深入分析了几个具体案例,展示了如何通过引入内部函数、改进现有逻辑等方式解决这些问题。我们还讨论了向不透明指针过渡带来的影响,以及如何利用目标扩展类型和增强的测试策略来确保后端的稳定性和正确性。最后,我们展望了将 SPIR-V 后端确立为官方目标以及开发 SPIR-V 消费者工具的未来方向。

006:使用新型卸载模型增强SYCL卸载支持

在本节课中,我们将学习如何为SYCL编程模型增强卸载支持,使其采用社区中已广泛使用的新型卸载模型。我们将概述SYCL卸载的基本原理,探讨新模型的设计方案,分析其与现有社区方案的差异,并介绍当前的工作进展与未来计划。

SYCL卸载概述

SYCL是为数据并行和异构计算设计的编程模型。它基于C++提供了一致的编程接口,允许开发者编写适用于CPU、GPU、FPGA和AI加速器的代码。编译器通常涉及至少一次主机编译和针对每个指定目标的一次设备编译。

以下是一个简单的SYCL程序示例:

#include <sycl/sycl.hpp>
sycl::queue q;
float *data = sycl::malloc_device<float>(N, q);
q.memcpy(data, host_data, N * sizeof(float)).wait();
q.parallel_for(N, [=](sycl::id<1> i) { data[i] += 1.0; }).wait();

该程序展示了在设备上分配内存、传输数据并提交内核执行的基本流程。黄色高亮部分即为SYCL内核。

新型卸载模型的设计

上一节我们介绍了SYCL的基本概念,本节中我们来看看新型卸载模型的具体设计。该模型将编译和链接过程分离,以实现更灵活的代码生成和处理。

编译阶段

使用Clang编译SYCL程序时,会进行两阶段编译。第一阶段生成主机目标文件,第二阶段生成设备中间表示。

编译命令示例如下:

clang++ -fsycl -fsycl-targets=spir64 my_program.cpp

此命令会为指定的SPIR-V目标生成代码。

在编译阶段,设备IR会通过clang-offload-packager工具打包,并可使用-fembed-offload-object选项嵌入到主机目标文件中。

链接阶段

链接阶段使用称为clang-linker-wrapper的工具。以下是链接过程的核心步骤:

  1. 从包含主机和设备代码的“胖”目标文件中提取设备代码(LLVM IR)。
  2. 链接所有必需的设备库,生成完全链接后的设备LLVM IR。
  3. 执行SYCL后链接步骤,其关键功能之一是代码分割。

以下是代码分割的主要原因:

  • 功能隔离:将每个内核分割到独立的镜像中,便于管理。
  • 条件执行:根据内核特性(如是否使用FP64)分割,以便在运行时根据硬件能力选择合适的内核。
  • 减少JIT开销:仅编译实际使用的内核,而非整个镜像中的所有内核。
  • 并行AOT编译:分割后可以并行调用后端编译器,减少编译时间。
  1. 使用llvm-spirv工具将LLVM IR转换为SPIR-V格式。若用户指定,则在此阶段调用AOT编译器生成设备镜像。
  2. 使用clang-offload-wrapper工具将所有设备镜像(或单个镜像)打包成一个文件。
  3. 最后,调用主机链接器,将主机代码和包含设备镜像的目标文件链接成最终的可执行文件。

我们计划将上述链接阶段中高亮部分的功能迁移到一个新的子工具中,称为clang-sycl-linker

与现有社区方案的差异

上一节我们介绍了新模型的设计流程,本节中我们来看看它与现有社区方案的主要区别。这些差异是为了更好地适应SYCL的特性和Intel的硬件支持。

  1. 使用LLVM链接器:目前使用llvm-link进行设备代码链接,而非LTO位码链接。未来SPIR-V后端就绪后,将切换至使用ThinLTO或LTO。
  2. 链接时链接设备库:在链接时使用llvm-link按需链接设备库,而非在编译时链接。未来同样计划整合到LTO阶段。
  3. 向运行时传递信息:需要将编译信息(如优化标志-O0)从设备镜像传递到运行时,以确保JIT编译时使用正确的选项。我们使用字符串数据映射来存储这些信息。
  4. 内核属性传递:SYCL语言规范要求传递内核属性(如是否使用FP64)。我们同样使用字符串数据映射,通过device_image_property结构(包含名称、值、类型和大小等字段)来存储,以便运行时在JIT前检查硬件兼容性。
  5. 代码分割:如前所述,出于功能隔离、条件执行和性能考虑,我们在SYCL后链接阶段执行代码分割。
  6. 使用外部工具转换SPIR-V:目前使用外部的llvm-spirv工具将LLVM IR转换为SPIR-V。未来直接生成SPIR-V代码的backend可用后,将移除对此工具的依赖。

当前进展与未来计划

我们已经为支持完整的SYCL编程模型提交了RFC,并设计了针对SPIR-V目标的SYCL卸载方案。相关演讲已在EuroLLVM 2024会议上分享。

代码贡献方面,已提交两个主要的PR:

  • 添加SYCL设备库。
  • 引入SYCL链接包装器(clang-sycl-linker)。

感谢社区成员Joseph、Matt、Chris和Tom的宝贵反馈与帮助。

接下来的工作计划包括:

  1. 在SYCL链接器包装器中添加运行SYCL最终步骤的逻辑。
  2. 修改clang-linker-wrapper,使其通过调用Clang并传递-sycl-link选项来调用SYCL链接器。
  3. clang-linker-wrapper中添加SYCL卸载逻辑。
  4. 为Intel、AMD、NVIDIA等目标添加AOT编译支持(目前仅支持Intel GPU的JIT)。
  5. 待SPIR-V后端就绪后,更新工具链以直接使用SPIR-V,并转向使用LTO。

总结

本节课中我们一起学习了SYCL卸载模型如何迁移到新型社区标准模型。我们回顾了SYCL卸载的流程,深入探讨了新模型在编译、链接、代码分割和信息传递方面的设计,分析了其与现有方案的差异及原因,并了解了当前的工作进展与未来的整合方向。最终目标是构建一个高效、灵活且与上游社区模型兼容的SYCL卸载实现。


问答环节摘要

  • :关于使用LTO,SPIR-V是否有定义的链接规范?最终目标是否是像WebAssembly那样拥有lld -syclld.lld -spv
    • :这是计划中的目标。但目前没有专门的SYCL链接器,因此暂时使用llvm-linker
  • :为什么需要创建一个独立的新工具(clang-sycl-linker),而不是作为上游现有工具的一部分?
    • :为了与现有对NVIDIA和AMD等目标的卸载支持模式保持一致,避免过度偏离社区做法。clang-linker-wrapper的接口旨在调用Clang为GPU目标生成有效的镜像,为SYCL创建专用工具符合这一设计思路。未来可能演变为lld-spv这样的工具。

007:从开发者桌面到用户设备

概述

在本教程中,我们将探讨 LLVM 项目的软件供应链安全。我们将从代码的源头(Git 仓库)开始,一直追踪到代码最终运行在用户设备上的整个过程,分析其中存在的安全风险,并讨论如何通过改进策略和流程来加强整个链条的安全性。


1:软件供应链安全简介

上一节我们概述了本教程的内容,本节中我们来具体看看什么是软件供应链安全。

软件供应链是指代码从开发者编写开始,到最终在用户设备上运行的整个过程。供应链安全的核心目标,是确保代码在这个传递过程中不被篡改。

这个概念不仅适用于软件世界,对于实体产品同样重要。有时通过实体产品的例子更容易理解。

一个实体产品的例子:
假设有一家公司生产世界上最安全的锁。他们将锁和钥匙装入盒子,用卡车运往商店。途中,司机在酒店过夜时忘记锁车。小偷潜入卡车,打开所有盒子,复制了钥匙,再把原钥匙放回。最终,消费者买到的锁虽然本身坚固,但小偷手中的复制钥匙可以轻易打开它。

这个例子说明了供应链安全的重要性:即使产品本身是安全的,如果运输过程(供应链)存在漏洞,整个系统的安全性也会崩塌。

对于像 LLVM 这样被数百万甚至数十亿设备使用的软件,我们不仅要确保软件没有缺陷,还必须保证它在传递过程中没有被篡改。想象一下,如果有人通过供应链攻击在大多数手机中植入后门,其破坏性将难以估量。


2:LLVM 的软件供应链

上一节我们介绍了供应链安全的基本概念,本节中我们来看看 LLVM 项目的具体供应链流程。

下图描绘了 LLVM 的软件供应链:

  1. 起点:开发者直接向 Git 仓库提交代码,或通过拉取请求(Pull Request)提交。
  2. 主分支:代码进入 Git 仓库的主分支。
  3. 发布分支:定期从主分支创建发布分支,以提供更稳定的代码版本。
  4. 打包分发:基于发布分支,代码被打包成源代码压缩包和二进制文件分发给用户。

通常,代码在流水线中移动时会受到更严格的审查。例如,合并到主分支的代码可能经过(也可能没有经过)审查或 CI 测试。合并后,总会运行提交后 CI 测试来验证正确性。代码也可能在提交后被审查。一旦代码进入发布分支,管控会更加严格,任何新更改都需要审查者和发布经理的批准。只有发布经理有权向发布分支提交代码。

虽然随着代码在流水线中移动,审查力度会增加是好事,但必须记住 LLVM 的供应链并非一个简单的、只有一个起点和终点的管道。

一个重要事实是: 人们几乎从流水线的每一步都在使用我们的代码。有些人(可能是大多数人)直接从主分支获取代码,有些人从发布分支获取,还有些人使用我们发布的资源(如压缩包和二进制文件)。记住这一点至关重要,因为如果我们只为发布分支增加安全措施,对于那些直接从主分支拉取代码的人来说并无帮助。


3:保护 Git 仓库

上一节我们梳理了 LLVM 的供应链,本节中我们聚焦于供应链的起点:如何保护 Git 仓库的安全。

Git 仓库是代码的起点,也是所有分发 LLVM 的参与者共有的供应链环节。

保护 Git 仓库的方法

以下是几种保护 Git 仓库安全的主要方法:

  1. 限制提交权限

    • 做法:要获得 LLVM 项目的提交权限,只需满足两个要求:1) 提出申请;2) 提供一个理由(例如“我刚提交了第一个 PR,需要权限来合并它”)。
    • 现状:可以说,我们项目获得提交权限的门槛相当低。没有贡献数量或时间的要求。
  2. 制定提交规则

    • 做法:我们有一些项目级的策略,规定允许提交什么以及何时提交。例如,重大更改需要提交 RFC 提案;较小的更改可能需要提交前审查;通常 CI 测试应该通过。
    • 问题:这些规则主要是“被动执行”的,即仅通过政策来执行。如果有人违反规则,可能有人会发现并要求其回退,但没有技术屏障能实际阻止人们违反这些规则。我们严重依赖庞大的开发者基础来手动检查仓库、拉取请求和 CI 运行,以确保规则被遵守。
  3. 提交后审查

    • 做法:这是一种被动安全措施,主要由关注各种提交邮件列表的人进行,他们会审查对自己重要的补丁。
    • 挑战:在项目规模较小时这很常见,但现在我们每天在提交列表上会收到超过 1000 封邮件,这使得这种审查变得非常困难甚至不可能。

总结当前 Git 仓库的安全状况:

  • 任何人都可以获得提交权限。
  • 几乎任何时候都可以提交。
  • 我们的政策主要依靠人工手动执行。

这引出了一个关键问题:我们是否在晚上“没有锁车”? 看起来可能是的。


4:谁应该关心以及潜在风险

上一节我们分析了 Git 仓库的安全现状,本节中我们来探讨如果安全措施不足,谁会受到影响以及可能面临哪些风险。

每个人都应该关心供应链安全。请思考你如何构建你的产品:

  • 恶意代码的影响:如果恶意代码被推送到 LLVM 项目的主分支,它多快会进入你的构建流水线?在上游社区有人注意到问题之前,你将其拉取到自己的构建环境中会有多少时间?
  • 构建流水线的安全:如果你直接从主分支构建,请记住,你正在执行不受信任的代码。你的系统是否针对此进行了加固?我们上游的被动策略可能无法保护你免受攻击。
  • 具体场景:如果你在每个星期二午夜从主分支拉取代码,而有人在 11:55 推送了恶意代码,会发生什么?

风险远不止于你的构建流水线。即使你只是运行 CI(无论是内部系统还是发布在 Buildbot 网页上的系统),如果主分支被推送了恶意内容,你的系统也可能面临风险。

执行构建脚本时,它有能力在你的系统上运行任意命令。 攻击者可以轻易修改 CMake 文件来启动 SSH 服务器、下载安装恶意软件,或者更糟的是——修补编译器,在编译器本身或编译器构建的任何产品中插入后门。这类编译器后门尤其难以追踪。

再次总结风险:

  • 我们向任何申请者授予提交权限。
  • 我们没有技术屏障来防止人们未经审查直接推送到主分支。
  • 我们的构建和 CI 系统可能面临被入侵的风险。
  • 我们的产品可能被插入后门。

这一切听起来很糟糕。我们怎么会错得这么离谱?实际上,我认为我们并没有做错什么。在项目历史上,我们只是优先考虑了安全之外的其他事情。


5:历史背景与改进思路

上一节我们看到了潜在的风险,本节中我们来分析这些策略形成的原因,并探讨可能的改进方向。

我们当前政策的主要原因是:我们希望让新贡献者尽可能容易地参与,并给予有经验的贡献者改进代码的灵活性。这个政策总体上对项目非常有利:项目快速增长,每天约有 100 次提交,每月有超过 600 位独立贡献者,开发者会议有近 500 名参与者。

但现在可能是时候重新审视一些政策,看看我们能否在满足其他项目目标的同时,改善项目的供应链安全。

针对新贡献者体验的考量

在项目早期(使用 SVN 甚至 CVS 时),新贡献者如果没有提交权限,流程会非常繁琐:需要将补丁发送到邮件列表,说服审查者下载、应用并推送。这可能导致数天甚至数周的延迟。因此,当时快速分发提交权限确实节省了大家的精力,改善了新贡献者体验。

然而,如今技术已大大改进:我们可以一键合并新贡献者的拉取请求。但我们的政策却保持不变。

针对有经验贡献者的考量

我们仍然希望给予他们工作上的灵活性。我们能否定义不同类别的贡献者,并给予有经验的贡献者更多权限?

可能的改进措施

以下是一些可能的改进方向:

  • 对新贡献者要求提交前审查
  • 增加获得提交权限的要求,例如要求一定数量的补丁被合并,或自首次提交后经过一定时间。
  • 强制要求提交前 CI 通过,甚至可以在拉取请求通过 CI 之前禁用合并按钮(尽管这面临 CI 不一致等挑战)。

如何提供帮助

在保护 Git 仓库方面,你可以通过以下方式提供帮助:

  1. 分享你的想法:如果你发现问题,请提交错误报告或在 Discourse 上发帖。不一定需要完整的 RFC,有时仅仅是发起讨论就能带来改进。
  2. 审查内部流程:如果你有内部流程,请理解你可能存在的漏洞,并告诉我们上游可以做什么来帮助你保持安全。

6:保护拉取请求与 CI 基础设施

上一节我们讨论了 Git 仓库的改进思路,本节中我们来看看与 Git 仓库紧密相关的部分:如何保护拉取请求和 CI 基础设施的安全。

对于拉取请求,关键问题是如何保护我们的基础设施,主要是 CI 基础设施,如 GitHub Actions、Buildkite 和 Buildbot。

我们首先关注 GitHub Actions,因为由于 GitHub Actions 工作流可能拥有的访问权限种类,这里是项目本身最脆弱的地方。保护 Buildkite 和 Buildbot 系统也很重要,但通常那里的漏洞只影响运行任务的机器或网络。而 GitHub Actions 的漏洞可能导致权限升级,允许攻击者获得对仓库的写入权限,这非常危险。

GitHub Actions 简介

对于不熟悉的人来说,GitHub Actions 允许你在 GitHub 基础设施或自己的自托管机器上运行自动化任务。工作流定义用 YAML 编写,每个文件对应一个工作流,每个工作流可以包含多个任务。任务可以通过多种方式触发,不仅限于拉取请求。

安全核心:GitHub Token

在保护仓库安全方面,最需要关注的是 GitHub Token。这是赋予每个任务的唯一令牌,授予任务与仓库交互的权限。除了这个令牌,工作流还可以定义特殊的 Secrets(可以是访问令牌或其他东西),这些可能拥有比普通 GitHub Token 更高的权限。

GitHub Actions 的风险类型

我们需要警惕几种风险:

  1. 令牌/Secret 泄露:可能导致某人获得对仓库的提升权限。
  2. 拒绝服务攻击:例如,有人可以打开 1000 个拉取请求,使我们的 CI 过载。
  3. 资源窃取:有人可能接管我们的 Actions 流水线,用于加密货币挖矿或其他资源密集型任务。

本教程主要关注 令牌泄露,因为这里我们的供应链风险最高。

GitHub Token 与 Secrets

GitHub Token 可以用来创建问题、为拉取请求添加标签、合并拉取请求,甚至将代码推送到主分支。只要存在相应的 REST API,它几乎可以用于 Git 仓库的任何操作。

我们可以为每个任务配置权限。通常我们将默认权限定义为只读,然后根据需要添加额外权限。GitHub Token 的一个安全特性是它在任务完成后过期,这增加了利用难度。

但有一个注意事项:由 GitHub Token 发起的事件不会触发新的工作流运行。

Secrets 的使用

Secrets 允许工作流链式触发(这是我们使用 Secrets 的主要原因之一)。另一个使用场景是当需要某种额外权限组合时。Secrets 也可以是任何类型的秘密值,如部署 Python 包或安全密钥。

我们尽可能避免使用 Secrets,因为任何拥有提交权限的人都可以很容易地看到它们。而且与 GitHub Token 不同,Secrets 不会在任务结束时过期。通常我们每月轮换一次 Secrets,但如果有人获得访问权限,这仍然给了他们充足的利用时间。

真实世界的漏洞案例

令牌或 Secret 泄露是真实存在的威胁,并非理论上的。近期有几个 GitHub Actions 被成功利用的例子:

  1. PyTorch 项目:研究人员通过利用其 GitHub Actions 配置获得了项目的写入权限。他们利用了项目使用自托管运行器的事实,通过带有恶意 GitHub Actions 工作流的拉取请求入侵了其中一个运行器,从而窃取了其他任务中的 GitHub Token。
  2. GitHub Runner 镜像仓库:使用了类似的技术被利用。这个仓库托管了 GitHub 上所有 Actions 任务安装的镜像,如果攻击者是恶意的,可能造成巨大破坏。
  3. 令牌通过构件上传泄露:许多仓库通过构件上传工作流泄露了 GitHub Token。问题在于,用于从仓库检出源代码的 GitHub Actions 代码会将 GitHub Token 写入源代码目录的 Git 配置文件中。如果任务打包该目录并上传为构件,就会泄露令牌。GitHub 随后修改了上传操作以忽略 Git 配置目录。LLVM 项目也曾被发现存在此问题,但由于遵循了最佳实践,泄露的令牌是只读的,没有构成风险。

如何避免这些漏洞

我们可以采用一些最佳实践来保持安全:

  • 优先使用 GitHub Token 而非 Secrets:GitHub Token 生命周期更短,权限范围有限,危害潜力更小。
  • 使用最小权限原则:确保 GitHub Token 只拥有完成任务所需的最小权限。
  • 使用临时运行器:如果必须使用更强大的运行器,请确保使用在每个任务结束后被销毁的临时运行器。
  • 禁用首次贡献者的工作流运行:LLVM 项目已经这样做了。首次提交拉取请求时,需要审查者或组织成员手动启动相关的 GitHub Actions 工作流,以防止针对多个仓库的自动化攻击。

工作流文件示例与权限控制

在工作流文件中,我们总是在顶部定义最小权限,例如:

permissions:
  contents: read

这确保所有任务默认具有最小权限。如果特定任务需要更多权限,可以在任务级别授予,例如:

jobs:
  my-job:
    permissions:
      issues: write

pull_requestpull_request_target 事件

GitHub Actions 支持两种拉取请求事件:pull_requestpull_request_target。它们允许你在不同上下文中处理拉取请求事件。

  • pull_request:在分支(fork)的上下文中执行工作流。无法访问目标仓库的 Secrets,无法执行对目标仓库的写入操作,限制非常严格。
  • pull_request_target:在目标仓库的上下文中执行工作流。可以访问 Secrets,可以执行对仓库的写入操作。

经验法则:如果你正在从用户的分支检出代码(例如运行 CI),绝对不应该使用 pull_request_target 事件,因为这可能将写入权限或 Secret 访问权授予不受信任的用户。

LLVM 仓库中确实有一些工作流使用 pull_request_target,但通常被认为是安全的,因为它们只从主分支检出代码。不过,为了更加安全,我们仍然可以将它们移植到 pull_request 事件。

其他 CI 基础设施

除了 GitHub Actions,我们还有其他 CI 基础设施(如 Buildbot)。同样需要确保它们能抵御不受信任的代码。使用临时节点有助于缓解许多攻击,这不仅适用于公共 CI,也适用于任何内部 CI 系统。


7:保护流水线的其他部分

上一节我们深入探讨了拉取请求和 CI 的安全,本节中我们来看看如何保护供应链中其他环节的安全。

发布分支

我们每六个月创建一个新的发布分支,目标是为偏好稳定代码而非快速迭代主分支的用户提供稳定的代码版本。

在这个分支上,我们有非常严格的提交规则:

  • 只有发布经理(目前仅两人)可以提交到此分支。
  • 所有更改都通过拉取请求提交,并且必须在审查后才能提交。

发布资源

基于发布分支,我们生成一些发布资源:

  1. 发布压缩包

    • 使用 GitHub Actions 生成。
    • 由我们的发布经理进行密码学签名。
    • 我们开始使用 GitHub 的新功能 “Artifact Attestations” 来为压缩包建立来源证明。
  2. 发布二进制文件

    • 与发布压缩包类似,由发布经理签名并使用 Artifact Attestations 建立来源证明。
    • 一个不同点:我们允许第三方贡献者提供二进制文件。大多数情况下,这些二进制文件发布在远离我们官方二进制文件的地方。但确实有一些来自受信任第三方的二进制文件被发布在发布页面上。

Artifact Attestations 简介

Artifact Attestation 是一个 JSON 文件,包含描述构件的多个字段。其中最重要的两个字段是:

  • 构建时所检出代码的 Git 哈希值
  • 生成该二进制文件的 GitHub Actions 运行链接

在发布页面下载二进制文件时,你会看到旁边有 .jsonl 文件,这就是来源证明。你可以下载此文件与二进制文件一起,使用 GitHub CLI 工具来验证该二进制文件确实是由某个 GitHub 工作流生成的。

我们开始这样做的一个原因是:GitHub 的权限控制不够精细。任何拥有提交权限的人都可以向发布页面上传资源。这再次表明,获得提交权限会带来很多权限:不仅可以提交代码、查看 Secrets,还可以向发布页面上传文件。

如何处理 GitHub 的精细权限问题?

我们有一个审计任务,每小时检查一次上传,以确保只有授权人员上传了二进制文件。我们也考虑过其他解决方案,例如创建一个单独的仓库(如 llvm-project-releases)并只给发布经理权限。但问题是,即使这样做,我们也无法关闭主项目仓库中的发布页面,而这仍然是人们首先会去查看的地方,因此我们仍然容易受到恶意上传的攻击。

一个相关的 GitHub 问题

请看这个文件链接:https://github.com/llvm/llvm-project/blob/main/...。它看起来来自官方仓库,文件名也没问题,似乎很安全。但实际上并不安全。 这个文件是用户附加到某个问题(Issue)上的上传文件。任何拥有 GitHub 账户的用户(即使完全无权访问 LLVM)都可以上传这样的文件。

重要提示:如果你要下载发布版本或其他构件,请务必前往发布页面获取链接。不要下载别人给你的链接,无论它看起来多么可信。


8:真实攻击案例分析

上一节我们讨论了发布流程的安全,本节中我们通过一个真实发生的供应链安全攻击案例,将之前讨论的要点串联起来。

今年早些时候,XZ 项目遭受了一次成功的供应链安全攻击。幸运的是,它被迅速发现,但被入侵的构建版本还是进入了某些流行的 Linux 发行版。

攻击步骤

  1. 获取提交权限:攻击者花了大约 8 个月时间获得提交权限。
  2. 建立信任:获得权限后,他们在项目中建立信任。
  3. 推送恶意代码:最终,他们向仓库推送了恶意代码,这些代码隐藏在项目测试使用的某些二进制文件中。
  4. 转移官方资源托管:他们说服项目将官方发布压缩包的托管从自定义域名转移到 GitHub。
  5. 上传恶意压缩包:由于拥有提交权限,他们能够向 GitHub 发布页面上传自定义的压缩包。这些压缩包内含额外代码,会在构建 XZ 库时将测试文件中的恶意代码插入其中。

恶意代码的作用

由于 XZ 库被某些发行版中的 OpenSSH 使用,该攻击使得恶意代码在每次调用 RSA_public_decrypt 函数时都可能被触发。

对比分析:这种攻击会发生在 LLVM 上吗?

我分析了促成此次攻击的主要因素,并与 LLVM 项目进行了对比:

因素 XZ 项目 LLVM 项目
获取提交权限时间 约 8 个月 通常几天(取决于管理员繁忙程度)
仓库中存在测试二进制文件
强制代码审查 没有 没有严格的技术强制(主要靠人工监督)
维护者数量 主要是一位过度劳累的维护者 拥有大量维护者和关注者

LLVM 项目的优势:我们拥有大量的维护者和利益相关者,有很多双眼睛关注着项目。这增加了攻击不被注意的难度。然而,这并不能抵消我们可能存在的其他问题。


9:下一步行动与总结

上一节我们通过真实案例看到了风险,本节中我们将探讨 LLVM 项目未来的改进方向,并对本教程进行总结。

我认为我们需要做出一些改变。单独看我们的某些政策似乎没问题(例如,设置较低的提交权限门槛或最少的提交规则)。但当这些因素组合在一起时,就给项目带来了真实的风险。

我们可以做什么来改进?

以下是一些可能的改进措施:

  • 实施强制拉取请求:这有助于提高更改的可见性,并使得在更改被推送前要求 CI 通过或其他要求成为可能。
  • 要求所有更改都经过审查:可以更进一步。
  • 提高获取提交权限的要求:减少权限分发。

在考虑这些变化时,我们必须记住要在便利性安全性之间取得平衡。我们不希望项目开发因为一堆严苛的安全措施而陷入停滞。

给下游用户的建议

如果你是一个下游用户,你必须意识到所有这些风险,并学会保护自己:

  1. 了解你的风险:理解你的流水线依赖于 LLVM 供应链的哪个部分,以及你面临的风险是什么。
  2. 考虑为上游改进做贡献:如果这对你很重要,可以考虑为上游的供应链安全改进做出贡献。
  3. 资助相关工作:你可以雇佣专人全职从事这项工作,或者向 LLVM 基金会捐款。有了足够的资金,基金会或许可以雇佣全职管理员来帮助维护仓库安全。
  4. 权衡成本:如果你担心成本,请想想如果你的产品被入侵,并向客户或用户交付了恶意软件,代价会有多大。

总结

我希望你同意供应链安全非常重要。我们的项目取得了巨大成功,但在供应链安全方面,我们仍在沿用一些旧方法,这增加了遭受攻击的风险。

我们有责任对我们的用户和下游消费者认真对待此事,并努力改善现状。对于任何安全问题,一次成功的攻击都可能带来毁灭性后果,我们必须保持警惕。


本节课总结

在本节课中,我们一起学习了:

  1. 软件供应链安全的基本概念及其重要性。
  2. LLVM 项目具体的软件供应链流程,从代码提交到分发给用户。
  3. 当前保护 Git 仓库的主要方法(权限、规则、审查)及其局限性。
  4. 供应链安全不足可能带来的广泛风险,特别是对下游构建和CI系统的影响。
  5. 现有政策形成的历史背景以及未来可能的改进方向
  6. 如何保护拉取请求和 CI 基础设施(特别是 GitHub Actions),包括令牌管理、事件类型和最佳实践。
  7. 如何保护发布分支和发布资源,以及引入 Artifact Attestations 的作用。
  8. 通过 XZ 真实攻击案例的分析,将理论风险与实际威胁联系起来。
  9. LLVM 项目下一步可能的行动,以及对下游用户的实用建议。

通过理解这些环节,我们可以共同努力,提升 LLVM 生态系统的整体安全性。

008:使用 LLVM CAS 进行细粒度编译缓存

在本教程中,我们将学习如何使用 LLVM 的内容寻址存储(CAS)系统实现细粒度的编译缓存。我们将从 CAS 的基础概念开始,逐步深入到其在调试信息存储、重放速度优化以及对 Swift 语言支持方面的具体应用和改进。

概述:什么是内容寻址存储(CAS)?

我们使用 CAS 的目标是创建一个可与 ccache 媲美的构建缓存。我们希望将目标文件分割成更小的 CAS 对象进行细粒度存储,这种模式我们通常称之为 MC CAS。选择与 ccache 比较,是因为 ccache 是一个广为人知且易于理解的工具,为我们所做的工作提供了一个很好的比较基准。

CAS 基础概念 🔍

上一节我们介绍了 CAS 的目标,本节中我们来看看 CAS 的核心工作原理。

CAS 对象的地址是其内容的哈希值。在缓存对象中存储的数据与其地址之间存在一一映射关系,并且这是不可变的。因此,如果数据发生变化,其地址也会随之改变。然而,如果你尝试在 CAS 中存储冗余数据,系统不会再次存储它,而是返回一个指向该数据的引用,这被称为去重。这正是我们追求的目标,也是我们试图减少增量构建体积的本质方法。

数据在 CAS 中的表示形式是一个有向无环图(DAG),由 CAS 对象组成。每个 CAS 对象都包含一些数据和一个指向其他 CAS 对象的引用列表。

ccacheMC CAS 📊

为了理解 MC CAS 的优势,我们首先需要将其与 ccache 进行对比。

以下是 ccacheMC CAS 的主要区别:

  • 粒度ccache 的粒度在目标文件级别,而 MC CAS 的粒度要小得多,低于函数级别。
  • 增量增长:由于 ccache 的粒度是目标文件级别,即使目标文件中只有微小改动,也必须重新存储整个文件,导致增量构建的体积增长率很高。而 MC CAS 并非如此,我们只存储更改的部分,因此增量增长率要小得多。这是我们设计时有意考虑的特性。

调试信息在 CAS 中的表示 🐛

在去年的工作中,我们初步实现了调试信息在 CAS 中的表示。为了模拟开发者的工作流程,我们进行了为期10天的构建测试。结果显示,如果不进行细粒度优化,CAS 的体积会膨胀到约 65 GB,增长率高达 708%。而使用 MC CAS 后,体积降至约 17.5 GB,增长率也大幅降低到 4000%。

然而,当我们叠加 ccache 的数据进行比较时,发现一个有趣的现象:10次构建后,ccache 的体积(约 15.5 GB)反而小于 MC CAS(17.5 GB),但 ccache 的增长率(约 730%)更高。我们相信,如果进行更长时间的构建(如20或30天),MC CAS 的体积最终会更小。同时,我们也相信自己可以做得更好,这就是本次教程后续要讨论的内容。

优化调试信息存储 📉

分析 MC CAS 17.5 GB 的体积构成,我们发现调试信息部分占了约 9.9 GB,超过总容量的一半。因此,优化调试信息在 CAS 中的表示是减少总体积的关键。

首先,我们需要了解调试信息部分的构成。调试信息以调试信息条目(DIE)的形式表示。一个 DIE 可以代表一个函数、一个参数等。DIE 中包含一些无法去重的数据,我们将其提取出来,存储在一个单独的 CAS 块中,称为独立数据。DIE 还可以有子 DIE。此外,调试缩写部分包含了 DIE 的类型信息,多个 DIE 可以共享同一个缩写。

综合来看,去年我们的表示方式如下:顶部是一个代表调试信息部分的 CAS 对象,接着是代表独立数据的块,然后是缩写块,最后是各个 DIE 块。我们并非为每个 DIE 创建一个 CAS 对象,而是使用一些启发式方法来确定子 DIE 是否应从其父 DIE 中分离出来。

这种表示方式体积较大,因此我们进行了两项主要改进来减少其在 CAS 中的大小:扁平化节布局使用压缩减少独立数据块的大小

改进一:扁平化节布局 🏗️

CAS 对象的地址是其内容的哈希值,而内容不仅包括数据,还包括对其他 CAS 对象的引用列表。如果其中任何一项发生变化,CAS 引用也会改变。此外,CAS 对象总是有序的。

我们遇到的问题是:在增量构建中,即使一个小子 DIE 中只有一个字节发生变化,它的引用也会改变。问题在于,它的父 DIE 包含一个指向它(以及其他子 DIE)的引用列表。因此,即使其中一个引用发生变化,父 DIE 也会改变,进而导致父 DIE 的引用改变。这就产生了一种级联效应,即使 DIE 中的微小变化也会导致大量去重失效,这是我们想要缓解的问题。

为了解决这个问题,我们采用了一种扁平化的表示结构。在这种结构中,没有子 DIE 附加到其父 DIE 上。因此,如果一个子 DIE 发生变化,只有它自己的引用改变,其父 DIE 不会改变。这样,我们提高了增量构建的整体去重率。

改进二:压缩独立数据 🗜️

MC CAS 约 17.5 GB 的体积中,调试信息占 9.9 GB,而独立数据块就占了约 9 GB,几乎是整个调试信息表示的 90%。这是一个巨大的数据块,每次新构建时都必须存储。

鉴于每个独立数据块平均约为 630 KB,并且在整个构建过程中有大量此类块,它非常适合进行简单的压缩。因此,我们采用了压缩技术来进一步减少调试信息表示的大小。

将这两项改进结合起来后,调试信息部分的大小从优化前的约 9.9 GB 显著下降到约 5 GB。当我们纵观整个 CAS 的大小时,可以看到总体积从 17.5 GB 下降到了约 12.6 GB。

此时,再与 ccache 叠加比较,可以发现从第三次构建开始,MC CAS 的体积就严格小于 ccache。在第十次构建后,ccache 约为 15.5 GB,而 MC CAS 约为 12.6 GB。

扩展测试与 DWARF5 支持 📈

为了验证模式的正确性,我们进行了更长时间的测试。在为期30天的构建中,ccache 体积膨胀到约 42 GB,而 MC CAS 约为 32.5 GB。MC CAS 的增长率(约1000%)远低于 ccache(约2200%),这证实了我们最初的假设。

此外,我们还测试了200次连续增量构建。结果显示,虽然初始时 MC CAS 体积较大,但随着时间的推移,其增长率更低,因此在200次构建后,MC CAS 总体积更小。图中出现的一些大峰值可能是因为某些提交更改了大量内容,导致许多目标文件重建,这是无法避免的。

接下来,我们讨论 MC CAS 对 DWARF5 的支持。Darwin 系统现已支持 DWARF5,我们也希望 MC CAS 能够支持。然而,测试发现 DWARF5 在 MC CAS 中的体积反而比 DWARF4 大,这与 DWARF5 本应更高效的预期相反。

调查后发现,原因是 DWARF5 引入了一个新的节——调试字符串偏移节。这部分数据同样无法去重,只是一个必须存储的数据块。因此,它也是压缩的绝佳候选对象。对其应用压缩后,DWARF5 的体积与 DWARF4 基本持平。

优化重放速度 ⚡

“重放”在此上下文中指的是重新构建先前已缓存的构建。例如,当你切换分支后又切换回来时,由于构建已被缓存,你期望构建速度会快很多。

在优化之前,MC CAS 的重放速度与 ccache 基本持平。但我们认为可以做得更好。我们发现了影响重放速度的两个主要问题:一是多次物化相同的缩写,二是使用的 ULEB128 解码器不是最优的。

调试缩写描述了调试信息节中的 DIE,多个 DIE 可以由一个缩写描述。因此,缩写数量总是小于或等于 DIE 数量。我们之前的问题是,每次物化一个 DIE 时,都会同时物化与之关联的缩写,这非常耗时,因为需要进行大量的 ULEB128 解码。一个简单的解决方案是只物化所有缩写一次并缓存它们,这显著减少了物化时间。

另一个问题是,我们使用的 ULEB128 解码器不是最优的。物化操作需要进行大量 ULEB128 解码,而我们使用的是 LLVM 中的 BinaryStreamReader 类。这个类不能保证流是连续的,但我们读取的所有 CAS 对象都是连续流,这导致了一些性能开销。实际上,这是物化时间的主要瓶颈。快速的解决方案是用 DataExtractor 类替换 BinaryStreamReader 类,后者只处理连续流。

进行这些优化后,MC CAS 的重放时间比之前严格更快。与 ccache 叠加比较,MC CAS 现在重放缓存构建的速度严格快于 ccache。虽然差异不大(约8%),但确实存在。

Swift 语言支持 🦅

目前,Swift 编译器也可以创建 MC CAS。我们仅在一个名为 AmoFire 的小型开源项目中进行了测试,似乎工作正常。但在正式批准之前,我们还需要进行进一步的测试以确保其正确性。

总结与未来展望 🚀

本节课中我们一起学习了 MC CAS 如何展示了在 LLVM 中拥有 CAS 的实际用例。我们相信,与仅使用 ccache 这样的解决方案相比,CAS 可以为 LLVM 带来许多优势。事实上,我们还可以缓存 Clang 模块。

对于未来的工作,我们希望测试和基准测试 MC CAS 在 Swift 上的表现,确保其正常工作,并针对 Swift 进行任何必要的优化。我们还希望为其他 DWARF 节(如调试行节、调试范围节等)实现更多 CAS 特定的优化。

如果你有兴趣贡献代码,我们在 GitHub 上有一个 LLVM CAS 实现的初始补丁。链接在此,请积极参与,留下你的评论和建议,以便我们将这项工作集成到 LLVM 主干中。我们期待看到更多社区对此工作的反馈。

009:编译流程与性能提升

在本教程中,我们将学习一个基于MLIR的编译器,它已被用于生产环境,以在Intel Gaudi加速器上编译工作负载。我们将了解其整体架构、编译流程以及如何通过优化带来显著的性能提升。

概述

Intel Gaudi是第三代AI加速器,用于推理和训练。它提供了高达约2 PetaFLOPS的矩阵乘法计算能力,并内置了高带宽内存和以太网控制器以实现规模化。其计算加速主要来自两个引擎:矩阵乘法引擎和64个张量处理核心。

为了支持工作负载在此加速器上运行,Intel提供了一套软件编译器、库和框架。本次课程将重点介绍其中用于编程TPC的组件——Fuser。

编译流程总览

上一节我们介绍了Gaudi加速器的硬件和软件栈。本节中,我们来看看Fuser在整体编译流程中的位置。

PyTorch代码首先被追踪并转换为数据流图,然后交给图编译器进行一系列优化。图编译器擅长处理矩阵乘法并配置MME,但它不负责为TPC生成代码。因此,它会将TPC相关的操作子图交给Fuser处理。

Fuser接收这个子图,将其融合并创建LLVM IR,再由LLVM后端生成最终的融合内核。对于Fuser无法处理的情况或用户自定义的内核,则由性能库提供。最终,图编译器负责在芯片上调度所有工作。

Fuser编译流程详解

现在,让我们深入了解Fuser内部的编译流程。

导入与表示

图编译器提供的输入IR并非MLIR格式。因此,第一步是使用一个导入器将其转换为MLIR IR。这个IR使用一个自定义的方言表示,我们称之为TPC内核方言。它主要用于与系统中的其他组件互操作。

聚类与降级

接下来,我们进行一系列聚类过程,将操作分组为多个簇。每个簇最终通常会作为一个独立的内核运行。聚类完成后,我们将其降级到另一个自定义方言——S方言。S方言包含基本的元素级操作、广播和归约操作。更复杂的操作(如归一化、Softmax)都会被分解为这些基本操作。

高级优化与融合

在S方言层级,数据流非常清晰,我们可以方便地进行一系列高级优化。之后,便进入核心的融合阶段。

融合过程将多维张量操作分解为处理标量值的标量操作,并将这些操作嵌套在类似循环的结构中。这样,值就可以在寄存器中传递,避免了内存读写。

在进行增量式融合时,我们并非简单地采用贪心策略,而是枚举多种融合可能性,并使用成本模型对它们进行评分。开发过程中的一个重要经验是,我们需要在剪枝选项时注意保持解的多样性,以避免陷入局部最优。

循环优化与代码生成

融合后,我们得到包含上游方言(如affinememrefarithmath)的循环状IR。我们可以进行进一步的循环融合和优化,最终得到可以并行化、向量化并在64个TPC上运行的IR。

我们利用了许多上游的affine优化,例如使用affine方言中的循环融合算法,使用超级向量化器进行向量化,并使用多面体工具来分析循环和发现并行循环,从而将计算分布到不同的TPC上。

最后,我们进行一系列后端优化,并生成优化的LLVM IR,交由LLVM后端生成最终的二进制代码。

元数据提取与协同优化

除了生成代码,我们还使用MLIR来提取元数据并提供给图编译器。

一个关键信息是:对于内核迭代空间中的一个点,图编译器需要知道在执行此迭代时,内核将访问张量的哪个区域。我们通过affine分析获得此信息并传递给图编译器。

图编译器利用这些信息进行非常有趣的优化:

  • 首先,它对内核进行切片。
  • 然后,它可以在TPC上调度一个切片的执行。
  • 该切片完成后,可以立即将其所有输出写入本地缓存或SRAM。
  • 同时,位于不同引擎(如矩阵乘法引擎)上的消费者可以立即开始工作。
  • 与此同时,图编译器可以在TPC上调度下一个切片的执行。

这种机制实现了MME和TPC之间非常高效的重叠执行,并充分利用了高带宽缓存和SRAM,实现了类似Flash Attention的高效调度。

性能提升

本节展示使用Fuser带来的整体性能改进。

左侧图表显示了X轴上的不同PyTorch工作负载,Y轴显示了使用Fuser带来的加速比。可以看到,我们获得了高达50%的加速,在Gaudi2上平均加速约为30%。

右侧图表则聚焦于加速器本身的执行性能。我们收集了这些模型的大量跟踪数据,对比使用和不使用Fuser的情况。平均而言,我们看到了约50%的加速。

总结

本节课中,我们一起学习了:

  1. Intel Gaudi加速器的硬件架构及其软件栈。
  2. Fuser在整体编译流程中的关键作用:接收图编译器生成的子图,进行融合优化,生成高效的TPC内核代码。
  3. Fuser详细的编译流程,包括导入、聚类、降级、融合、循环优化和代码生成。
  4. 如何利用MLIR提取元数据,并与图编译器协同工作,实现MME与TPC的执行重叠等高级优化。
  5. Fuser在实际部署中带来的显著性能提升,平均可带来30%-50%的加速。

总而言之,我们介绍了一个基于MLIR的Fuser,它作为Gaudi软件栈的一部分部署,通过与图编译器协同工作来优化整个加速器的执行,并带来了显著的性能提升。该编译器充分利用了上游的affinearithmath等方言以及内部自定义方言。

010:通过用户自定义方言解锁高性能

概述

在本节课中,我们将探讨如何在Mojo语言中通过用户自定义方言来实现库级别的优化,从而解锁更高的运行时性能。我们将分析传统系统编程语言(如C++)在优化标准库操作时面临的挑战,并介绍Mojo提供的一种新颖解决方案。

问题背景:传统语言优化的局限性

上一节我们介绍了课程主题,本节中我们来看看传统语言在优化库代码时遇到的具体问题。

让我们看两个C++代码的例子,并观察Clang编译器目前如何优化它们。

示例一:未使用的字符串

如果我们看第一个例子,我们有一个函数,它只是创建一个字符串,但没有对它做任何操作。这个字符串足够长,不会触发小字符串优化。

你可能期望Clang编译器直接删除它,因为它是未使用的。但如果你询问它的好朋友Compiler Explorer,你会发现它实际上并没有这样做。

原因在于,至少在C++17中,有一个函数调用没有被内联。因为它没有被内联,编译器无法推断这只是一个malloc操作,没有发生任何真正重要的事情。

事实证明,如果我们设法内联这个调用,那么所有东西都可能被消除,一切都会正常工作。

我们在这些例子中遇到的一个问题,不仅限于字符串,也适用于数据结构,那就是编译器需要内联所有内容才能理解它在做什么。这可以说是C++等语言的一个根本性问题。

示例二:向量操作

另一个展示不同类型问题的例子是向量操作。假设我们有一个整数向量,我们向其中推入一个值,然后获取向量的最后一个值(即我们刚刚推入的值),接着移除我们刚刚添加的最后一个值,最后返回这个值。

我们一眼就能看出,在push_back之后获取最后一个值,就等价于直接返回你刚刚添加的值。有人可能认为,如果你对一个向量推入某物然后弹出它,这应该等价于对向量什么都不做。

如果我们再次询问Compiler Explorer,会发现事情并不像看起来那么简单。但这次的问题不是内联问题。这次的问题是,从语义上讲,执行push_back实际上比仅仅添加一个元素更复杂,在某些情况下它可能会使容量翻倍。当你使容量翻倍时,会发生重新分配。这是编译器可以观察到的副作用。因此编译器知道它不能将其优化掉,因为使容量翻倍实际上产生了一种副作用。所以,推入然后弹出并不是什么都不做。它意味着如果向量在其当前容量下已达到最大尺寸,则使其容量翻倍。

更多优化机会与信息丢失

即使在C++标准库中,我们也能看到很多类似的例子,我们可能希望进行一些优化,这些优化要么需要大量内联,要么需要在LLVM级别上实际非法的优化。

以下是几个例子:

  • 如果我们检查一个元素是否在哈希映射中,如果不在就添加它,这本质上就是一个try_emplace操作。
  • 如果我们有一个乘法后跟一个加法,它可以被融合乘加指令替代。
  • 如果我们先排序然后搜索一个元素,我们可以直接进行二分查找。
  • 如果我们在循环中进行push_back,我们知道可以在循环前先reserve,这样我们一开始就有精确的容量,无需多次重新分配。

所有这些例子都很有趣。它们虽然很小,但你希望在编译器进行内联和自动优化之后应用它们。据我所知,目前没有任何系统编程语言能完成这些优化。

其中一个原因是,我们在编译器管道的早期阶段就丢失了太多信息。当我们定义库时,比如STD向量库,我们只有部分定义的函数。我们说容量可能会改变,但我们没有说它实际上会翻倍,我们没有具体说明在哪些情况下它会改变。从我们的库规范中,我们定义了实际的实现(比如.cpp文件)。在这种情况下,一切都是完全定义的,然后我们移除了库中许多未指定的部分。之后,我们将其交给Clang前端,再交给LLVM。问题在于,在Clang和LLVM级别,这些优化在此时实际上是“非法”的,因为我们很早就丢失了信息。

正如我所说,在C++中会发生这种情况,在Rust中也会发生同样的事情,在Zig中也是如此。

Mojo的解决方案:用户自定义方言

因此,我们一直在尝试弄清楚,在Mojo中,我们是否能做一些不同的事情。我们能否在库实现中添加一些额外的东西,从而解决这些问题?这里的“额外的东西”就是使用用户自定义方言进行用户自定义优化。

这样,我们在管道中就有了早期优化,同时也减轻了LLVM的压力,因为我们移除了许多需要内联的情况,我们不需要再移除函数或进行更多优化。

那么,我们如何实际解决问题呢?作为一个长期从事MLIR工作的人,我的第一个想法是:我们能否添加更多方言?就像我们已经存在的传统Mojo方言一样,我们能否为标准库的每个部分都设置方言?

问题是这根本无法扩展,因为标准库会变得越来越大,实际上会超过你在基于MLIR的编译器中所拥有的传统方言数量。另一个问题是,它会使编译器本身固化——库成为了编译器的一部分,而不是独立的东西。此外,它也不能推广到用户库。如果有人想编写自己的向量实现,他们无法受益于编译器为标准库所做的所有优化。

实现方式:装饰器与模式匹配

我们拥有的解决方案是能够以某种方式“插入”你自己的方言,但它不会是MLIR方言,而是有点不同的东西。如果你能对标准库这样做,你可能也能为任何用户库方言这样做。这就是为什么我们称之为库优化。它针对所有类型的库。

那么它实际上是什么样子的呢?假设我们想在Mojo中添加一个新操作,用于将整数乘以二。以前你会在库中有一个函数。现在我们想要一种类似MLIR的操作。

解决方案就是添加一个装饰器。这实际上就是你定义新操作所需的全部,因为这比在ODS中做要更好。例如,对于Python用户来说很熟悉,你只需添加一个装饰器,无需为了新方言去学习像C++ ODS TableGen这样的新语言。

这对于之前提出的所有情况都足够了。编译器只需要进行少量更改,因为你仍然在操作函数,我们不需要在管道的每个地方都添加新的优化集来处理自定义操作。

它也非常充分,因为我们在通常的MLIR操作中拥有所需的一切。我们有一个验证器(这只是函数的输入输出)。我们有一个降级(这实际上就是一个函数调用)。我们也有一个解释器(因为它也只是一个函数调用)。我们还可以定义一些接口。例如,我们知道在这种情况下没有副作用,因为我们只进行整数操作。

由此,我们现在可以定义优化。如果我们有两个操作:X * 2 和一个执行加法的操作。我们可以在Mojo中为这些操作定义一个实际的优化,我们可以称之为规范化模式。这些优化会在任何自定义操作出现时被调用。所以在这里,每当我们看到add函数被调用时,我们会在管道的某个点调用add_mojo优化。

这些优化只使用MLIR模块API,这些API调用可以在Mojo编译器内部使用。

在此基础上,我们可以通过使用一点Mojo元编程和一些“魔法”来直接引用操作。它就能工作。

由此,我们可以处理简单的基于模式的操作。问题是,如果你想实现融合乘加,这在MLIR中很容易匹配。但如果你想做像push_back/pop_back这样的例子,我们当前有appendpop操作,这就更复杂了,因为现在我们需要某种内存分析,因为你想知道pop是否紧接在append之后发生,这不是我们能轻易处理的。

如果你中间有操作,你实际上不知道pop和之前发生的操作之间有什么关系。你不知道在这种情况下VW是否别名相同,这意味着你不知道是否可以触发你的优化。

因此,为此我们有一个更小的实现,或者说是LLVM中所谓的“内存SSA”。在这种情况下,它会根据是否存在别名来告诉你优化是否可以触发。所以在这里,如果它们没有别名,你知道pop紧接在append之后发生。如果存在别名,那么你知道pop可能有两个前驱之一:append发生在V上或发生在W上。

应用实例

有了这个,我们实际上可以完成我在一开始展示的第一个优化。对于push_back/pop_back,这实际上就是查看pop,看是否有一个唯一的前驱是append,然后我们用MLIR API重写它,我们只需检查向量的值是否相同,然后我们就可以移除这些操作。

类似地,对于字符串的例子,就是检查在初始化之后是否有字符串的删除操作,这就可以工作。如果你看它,其实相当简单。

总结

本节课中我们一起学习了Mojo如何通过用户自定义方言来解决库优化问题。

我们得出的结论是,我们想要编写很多库优化。原因是在大多数编程语言中,管道早期就有很多信息,我们希望通过能够在你的语言中编写新操作,并使用内存SSA来简化一切,使其成为基于模式的重写,从而解决这个问题。

谢谢。😊

011:原子规约操作

在本节课中,我们将要学习原子规约操作。我们将探讨它们是什么、为什么需要它们以及如何实现它们。

概述

首先,简要概述LLVM中的原子原语指令。原子原语指令用于实现高级软件结构,例如C++的原子 fetch_* 操作。它执行从内存位置加载值,然后使用加法等二元操作修改加载的值,最后将修改后的值存储回内存。所有这些步骤都是原子执行的,这意味着在加载和存储之间,不允许其他并发写入修改该内存位置。

许多工作负载,例如并行直方图构造,使用像 fetch_add 这样的原子操作,但会丢弃加载的值。原子读-修改-写操作总是返回内存被修改前加载的值。

原子规约操作的定义与动机

上一节我们介绍了原子读-修改-写操作,本节中我们来看看原子规约操作。

原子规约操作指的是那些不返回旧值的原子读-修改-写操作。在许多工作负载中,修改前的旧值实际上并不相关。

例如,我们有一个并行for循环,它生成多个线程,每个线程遍历一个整数数组。根据整数落入哪个原子桶,线程将该桶的计数加一。对于这个算法,修改前桶中的旧值并不需要。存在许多类似的工作负载。

因此,我们今天将研究如何为那些旧值不相关的工作负载优化原子读-修改-写操作。

许多LLVM目标架构对这类不返回旧值的原子规约操作有良好的硬件加速支持。在这些目标上,从内存排序和内存模型的角度看,这些原子规约操作的行为类似于存储操作。

以下是原子规约操作的一些硬件指令示例:

  • ARM架构的 STADD 指令。
  • RISC-V架构的 AMOADD 指令,当使用零寄存器作为目标时,其语义变为原子规约操作。
  • PTX(NVIDIA GPU)的 red 指令,它没有目标操作数,不加载旧值。

性能优势

当我们查看并行直方图构造模型,并将其编译为使用PTX的 atom 指令(执行读-修改-写并加载旧值)或 red 指令(不加载旧值)时,在H100 GPU上,我们观察到 1.2倍到2倍的吞吐量提升

所以,我们需要这些指令的原因是,它们能在旧值不被需要的工作负载中提供更好的性能。

优化挑战与安全性

第三个问题是LLVM是否能够优化使用原子读-修改-写的代码,使其变为使用原子规约操作的代码。

虽然在某些情况下这样做是安全的,但在一般情况下,无条件地进行这种优化是不安全的。

我不会深入这个程序的细节,但这是一个示例程序,如果我们实际执行这种优化,会引入一个被C++内存模型禁止的结果。因此,在这个示例程序中,这种优化是不安全的。

这种优化曾在多个编译器(包括GCC和LLVM)中被错误地执行。在LLVM内部,不仅在一个地方(如中间表示层),而且在多个后端和多个地方都出现过。我们一直在努力修复这些问题以获得正确结果。

但我们面临的问题是,当我们修复时,会损害那些优化本应正确的工作负载。并且,很难区分哪些程序优化是安全的,哪些是不安全的。

C++标准的扩展

那么,C++委员会对此在做些什么呢?我们正在努力将原子规约操作作为C++内存模型、C++原子操作和标准库中原子类型的扩展引入。

这本质上是在原子类型等中引入新的API。这些API执行写入操作,但返回 void,即不返回旧值。这允许程序表达意图,并允许像Clang这样的实现暴露内置函数,这些函数随后被降级为LLVM的目标无关中间表示。然后,后端等编译器可以选择将其降级为特定的硬件指令。

虽然将原子读-修改-写优化为原子规约操作可能不安全,但反方向是安全的。也就是说,将原子规约操作编译为原子读-修改-写是安全的,只是会施加更强的内存排序约束。

树形规约与浮点运算

当前实现使用的一种技术是使用树形规约来实现某些整数原子操作。这对于整数是安全的,因为以不同顺序执行操作实际上不可观察。

然而,我们的目标之一是提高原子浮点操作的性能。实现这一目标的一种方法是让它们也能利用树形规约。

问题在于,在树形规约中,操作在内存中执行的顺序略有不同。因此,有可能观察到浮点加法以不同顺序结合。

我们已经扩展了此功能的规范,以支持这些允许树形规约的更弱的内存排序。它们还允许进行优化,例如,如果你有单线程中连续的两个原子规约操作,你可以合并这两个值,然后发出一次规约操作。在GPU等硬件上,这允许在一个线程束内跨线程执行水平规约,然后只向内存发出一次原子规约操作,而不是发出32次。

在无序上下文中的使用

当前C++原子操作的另一个限制是,它们在无序上下文中使用不安全。例如,在使用并行算法时,有多个并行执行策略。其中一种称为 par_unseq,它支持多线程并假设向量化是安全的。C++标准本质上禁止在这些上下文中使用原子操作,因为加载值的原子操作可能引入数据竞争问题。

这些原子规约操作不加载值,这与读-修改-写操作形成对比,这使我们实际上可以在保证自动向量化的无序上下文中允许它们。

这意味着,通过赋予这种操作稍弱的内存排序保证,我们实际上可以增加人们可以在大多数现代硬件上编写的安全程序的数量。好处是,实际上许多异构架构只支持 par_unseq,并且它们确实提供原子操作,但由于标准禁止,它们无法在C++中公开这些操作。而原子规约操作解决了这个问题。

总结与未来工作

本节课中我们一起学习了C++原子操作的原子规约扩展。

我们将准备一份提案,在LLVM中引入目标无关的内置函数来编译原子规约操作,因为这至少允许后端将其降级为某些可用指令,这总是安全的。

支持浮点树形规约的扩展是可用的。对于主要的编程语言,你将能够在并行算法中使用它,并且是在所有并行执行策略中,而不仅仅是在特定的策略中。

谢谢。

013:扩展MLIR方言以支持基于分片的编程

概述

在本节课程中,我们将学习如何扩展MLIR方言以更好地支持深度学习编译器,特别是针对基于分片(Tile)的编程模型。我们将介绍英特尔团队提出的X Tile方言,它旨在解决当前MLIR生态系统中在GPU上实现工作组分片编程时遇到的挑战。

深度学习编译器的挑战

深度学习编译器目前是一个非常活跃的领域。一方面,算法快速发展,催生了像Flash Attention这样的高度专业化内核。另一方面,AI硬件领域变化迅速,业界正大力推动使用低精度类型、脉动阵列和块加载/存储操作来提升性能。从用户角度看,他们既希望获得接近硬件的性能,又希望保持相对简单的编程模型。所有这些因素都指向了对灵活编程模型以及支持这些模型的深度学习编译器的需求。

基于分片编程的兴起

近年来,基于分片的编程模型被广泛采用。这是因为它在使用分片表达算法和并行策略方面具有更大的灵活性。同时,这种方法也很容易集成到现有的图编译器中。

然而,如果我们审视当前以GPU为目标的MLIR生态系统和方言,会发现使用现有MLIR方言实现基于分片的编程并不直接。存在一些限制。

现有MLIR方言的局限性

以下是当前MLIR方言在支持GPU分片编程时面临的主要挑战:

  1. 数据所有权问题:在memrefvector方言中,没有直接的方法来指定数据所有权。这在处理复杂的GPU层次结构(如工作组、子组和工作项线程)时是必需的。
  2. 抽象层级缺失:有时我们希望在工作组或块级别指定程序,并隐藏所有复杂的硬件细节,但目前没有可以直接用于实现这一点的方言。

X Tile方言的引入

为了弥合这些差距,我们提出了X Tile,这是一个用于工作组分片编程的方言。在设计X Tile时,我们主要考虑了三个因素:

  1. 可配置的分片大小:支持在工作组、子组甚至子组内的块级别配置分片大小。
  2. 支持高级优化:X Tile主要针对类GEMM算法设计,因此需要支持像预取或软件流水线这样的高级GEMM优化。
  3. 显式控制分解逻辑:提供对如何将计算分解到子组和工作项线程的显式控制。

X Tile的实现核心

实现X Tile需要解决的一个关键问题是如何在memrefvector方言中指定数据所有权和分解逻辑。

  • 扩展memref方言:我们通过引入一种新的数据类型以及一些所有权属性来扩展memref方言,并定义了一组操作来操作这种分片数据类型。
  • 扩展vector方言:对于vector数据类型,由于其添加属性的空间有限,我们选择将分解或所有权属性附加到向量操作本身。这是一个我们希望与社区讨论并获得反馈的设计点:是否可能扩展向量数据类型本身,从而使设计更简单。

分片数据类型详解

分片数据类型描述了一个memref内部的二维内存区域。除了分片的大小和数据类型外,它还包含一些额外的信息。这些附加属性描述了分解逻辑。具体来说,你可以指定拥有此分片的子组布局,以及每个子组在此分片中拥有多少数据。

为了将数据切片分配给子组,我们使用了循环分配策略。以下通过两个简单例子说明其含义。

示例一:标准分配

假设子组布局为 4x4,每个子组拥有一个 32x32 的数据切片,而总的分片大小为 128x128。在这种情况下,布局中的每个子组都唯一地拥有大分片内的一个数据切片。

示例二:共享数据分配

假设你有一个相对“瘦长”的分片,只有32列,但子组布局和每个子组的数据大小保持不变。在这种情况下,当你在列维度上应用循环分配时,同一列内的所有子组将共享该数据切片。这种机制非常强大,可以用来指定数据共享,并表达诸如协作加载到共享本地内存或协作预取等操作。

X Tile操作示例

以下是一个在工作组级别用X Tile指定的类GEMM示例。我们将用它来快速浏览一些重要的X Tile操作。

// 伪代码示例,展示X Tile操作概念
%tile = x_tile.define_tile %memref, %offset_x, %offset_y, %size_m, %size_n
%reg_tile = x_tile.load_tile %tile
%result = x_tile.mma %reg_tile_a, %reg_tile_b

以下是关键操作的解释:

  1. x_tile.define_tile:用于描述memref内部的一个分片。它需要源memref和描述分片位置的二维偏移量。它还有一个更新偏移量参数,特别用于K循环中,其作用是在memref内移动分片,而不是初始化一个全新的分片,从而节省地址计算开销。
  2. x_tile.load_tile / x_tile.store_tile:用于在分片和寄存器之间加载/存储数据,这部分比较直观。加载到寄存器分片后,我们使用vector方言来表示这些数据。
  3. x_tile.mma:这是一个在向量寄存器分片上执行矩阵乘积累加的操作。

一个重要的细节是,每个向量操作都附加了这些所有权或分解属性。这些属性非常灵活,你可以通过改变子组布局或子组数据属性来控制分解方式。例如,在K循环内部进行预取时,如果硬件要求预取操作与相邻子组协作需要不同的子组布局,你可以为预取操作和加载操作分别指定不同的布局。

从工作组到子组的分解转换

在从工作组到子组的分解转换过程中,我们直接消费这些所有权属性来生成子组级别甚至更细粒度的代码。

在左侧的工作组级别代码中,操作在一个更大的分片(例如128x128)上进行,并定义了子组数据和子组布局。在右侧的子组端,生成了更小的分片GEMM计算(例如32x32)。每个子组可以使用自己的子组ID来计算它所拥有的数据切片在全局中的偏移量。同样重要的是,A和B分片在列和行上的子组会共享数据,这为进一步预取或加载到共享本地内存以提升性能创造了条件。

降低流水线与性能

这是目前为X Tile规划的高级降低流水线。一旦处于子组级别的X Tile表示,可以直接转换到特定供应商的GPU方言,例如可以转换到Intel的X GPU方言。之后,我们可以将其进一步降低到SPIR-V或LLVM IR,然后传递给后端编译器驱动。

我们也测量了类GEMM操作的性能。这项工作仍在进行中,但我们很高兴地分享,在工作组级别的X Tile上,我们达到了手写优化MLIR代码性能的约90%,并且我们正在努力继续改进。

总结

本节课我们一起学习了扩展MLIR以支持深度学习编译器中基于分片编程的关键技术。我们了解到,当前MLIR缺乏工作组或块级别的分片方言,以及指定分片内数据所有权的方法。为了弥合这一差距,英特尔团队引入了X Tile,这是一个为GPU设计的工作组级别分片方言。X Tile通过扩展memrefvector方言,实现了基于分片的编程。初步的性能结果表明X Tile非常有前景。这项工作是开源的,欢迎大家查看并提供反馈。

014:将LLVM用作量子中间表示所面临的挑战 🧠

在本节课中,我们将学习如何将LLVM用作量子计算的中间表示,并探讨这种做法的优势与挑战。我们将从量子编译的基本约束开始,了解LLVM如何帮助满足这些约束,然后讨论如何简化高级程序员的开发流程,最后介绍如何让非编译器专家的量子领域研究者也能轻松使用我们的编译框架进行优化。

量子计算基础与编译挑战 ⚛️

量子程序随时间作用于量子比特。量子比特在电路图中通常用水平线表示。程序会应用单量子比特门(操作一个量子比特)和多量子比特门。门从左到右依次应用,类似于经典电路,整个结构被称为量子电路。

将这样一个电路从编程语言转化为可在后端(如物理量子芯片、超级计算机模拟器,甚至个人电脑)上运行的程序,是一个多步骤的过程。由于量子设备的限制,这个过程需要考虑许多编译约束。

英特尔量子SDK编译流程概览 🔧

我们将主要关注英特尔量子SDK中基于LLVM的量子编译器。为了理解设计决策,我们先快速了解一下编译流水线中的其他组件。

主要前端是英特尔通过SDK提供的、基于C++的混合量子-经典编程语言。我们的量子中间表示本质上是添加了量子内联函数的LLVM IR。与整个LLVM生态系统一样,这允许我们开发不同的前端,包括英特尔自己的OpenQASM前端(一种可移植的电路表示),然后将其翻译成我们的量子IR。

编译器承担了大部分工作,负责重新配置和规范化量子程序,以满足量子技术栈其余部分的限制和要求。在高层级上,这意味着:

  • 运行时中的经典代码将使用经典编译流程。
  • 我们必须从程序中提取量子指令。
  • 利用物理设备或模拟器的架构信息,来匹配设备的连接性限制(例如,有限的寄存器数量、有限的量子比特数,以及只有特定量子比特可以相互作用)。
  • 我们需要减少设备使用的量子资源。在当前技术下,使用设备越多,程序失败的可能性就越大。
  • 量子编译器最终会生成量子指令集架构级别的程序。这些量子组件随后被链接到二进制文件的其余部分,以便运行时稍后访问。

虽然量子编译器中使用的一些抽象对整个量子计算领域是通用的,但我们确实有针对英特尔后端(模拟器和硬件)的优化和特定ISA。程序运行时,会调用量子运行时。它处理量子ISA,将量子程序部署到设备,并处理返回的任何测量结果。

只有在量子运行时这个层级,我们才能在量子指令之间运行任何经典计算组件,因为运行时和处理器通常在空间和温度上(从室温到接近绝对零度)与量子设备分离。由于量子设备上量子比特的寿命很短,这实际上意味着整个量子程序或其片段不能依赖任何经典操作来重现其结果。量子指令通过控制电子设备传递到量子设备。在这里,指令在实际执行前,不能有任何条件执行或经典指令,直到它们被返回给运行时。

这种无法基于用户输入(甚至任何条件)执行条件操作的能力,是我们在英特尔量子编译器设计中做出决策的关键部分。

总结一下,我们的编译器面临的主要挑战是:

  1. 量子指令中不能有分支。
  2. 需要减少使用的量子操作数量。
  3. 需要匹配设备约束。
  4. 量子代码中不能有任何经典操作。

需要说明的是,这些决策很多是基于当前的量子技术,并非永恒不变,整个领域和英特尔都在努力改进。

利用LLVM匹配约束 🛠️

上一节我们介绍了量子编译的核心挑战,本节中我们来看看编译器如何利用LLVM来匹配这些约束。

以下是一个用户使用英特尔量子SDK可以编写的代码示例:

__quantum__ void kernel(int count) {
    Qubit q0 = __quantum__rt__qubit_allocate();
    Qubit q1 = __quantum__rt__qubit_allocate();
    for (int i = 0; i < count; ++i) {
        __quantum__qis__h(q0);
        __quantum__qis__cnot(q0, q1);
    }
    Result m0 = __quantum__qis__mz(q0);
    if (__quantum__rt__result_equal(m0, Result_One)) {
        __quantum__qis__x(q1);
    }
}
  • __quantum__ 函数属性在编译过程中传递,标志着该部分需要被特殊处理为量子代码。
  • 我们有 QubitResult 类型,它们本质上是特定的整数类型,标志着这是可以执行量子门操作或存储测量结果的数据。
  • 头文件中定义了许多标准的量子操作,使用户能够定义量子算法。
  • 整个内核可以像经典函数一样被调用,它会被提取、处理,然后由运行时通过指向量子指令块来调用。在这个层级,程序能够与量子数据交互、捕获测量值并对其做出反应。

代码中,多个量子操作被包含在循环和条件语句中。但我们不能在最终程序中保留这些结构,需要将其展开,并且所有量子比特参数在编译时必须已知。

幸运的是,LLVM基础设施拥有许多工具来处理这些结构并展开它们。然而,这些结构显然也存在于经典代码部分。因此,对程序的每个部分都应用展开操作将是耗时且不必要的。我们使用LLVM的Pass管理器来处理这些情况。

但是,为了在最后得到一个有效的量子内核,我们需要满足一系列条件。我们不能直接使用现成的Pass管理器框架,因为我们可能需要“回退”或根据条件重新运行某些操作。早期,这主要通过多次调用 opt 工具来实现。但现在,我们在自己的驱动程序中使用了五个不同的Pass管理器来实现这一目标。

以下是各个Pass管理器的作用:

  1. 第一个管理器:将头文件声明替换为量子内联函数。
  2. 第二个管理器:递归地内联量子内核,以便展开和常量折叠操作能够获得所需的所有信息来完全展开电路。
  3. 第三、第四和第五个管理器:实际上会运行多次,是使用LLVM Pass最多的编译部分,并且它们仅限于量子操作。
  4. 中间循环展开和常量折叠Pass:仅作为函数Pass运行在量子内核上(由前端添加的属性标识)。在优化之前会检查循环是否包含任何量子操作,如果没有,则无需展开。
  5. 最后一个管理器:如果内核未被发现有效,则运行一组与O1函数简化流程非常相似的Pass。这个过程会持续到函数被展开和常量折叠,或者达到尝试次数上限(此时向用户显示错误)。最后一个管理器还负责实际的量子处理和量子处理,这是一组Pass,用于将用户友好的数学门降低到硬件上实际可用的物理门集,以及将程序量子比特映射到物理位置(必要时移动它们),以便每个需要相互作用的量子比特能够实现。这里也是针对英特尔后端进行性能优化的地方。

增强高级程序员的开发体验 💡

上一节我们探讨了如何利用LLVM的基础设施来满足量子编译的硬性约束,本节中我们来看看如何通过一些策略为高级程序员提供更强大的工具,让他们能更轻松地表达程序。

由于我们需要在编译时知晓一切,编写可能涉及递归的函数可能需要一些技巧,例如使用模板来递归索引程序中的量子比特。左侧的代码展示了一种标准但可能繁琐的实现方式。我们可以用递归实现,但方式并不简单直观。

因此,我们为量子计算引入了函数式语言扩展(Fleck)。这是一个小型领域特定语言,在某些情况下可以帮助用户更简洁地表达代码,同时也允许编译器直接从程序中推断更多信息,而无需在最终的IR中进行推理。右侧的代码展示了使用Fleck后的递归函数,它更类似于函数式流水线,并能产生基本相同的电路。对于英特尔量子编译器来说,由于右侧代码中使用的原语,推理右侧代码变得简单得多,我们也能够更高效地优化这些程序。

Fleck还允许我们在编译器内部处理一些经典控制流。一个例子是进行基本的字符串处理,并在编译步骤中根据不同的输入进行动态适配。

由于量子表达式(QExpr类型)需要按值传递,并且在C++环境中被视为整数,仅在IR级别处理可能比较困难,因为它可能被转换或某些操作被优化掉。但我们可以使用Clang插件在前端进行一些处理。基本上,我们可以通过查找特定的二元运算符来修改抽象语法树,并用头文件中特定的函数调用替换它们。用户可以直接调用这些函数,但我们希望提供最佳的编程体验。通过这个Clang插件,我们也能够提供比仅在IR级别更好的错误信息。

我们替换了几个不同的运算符为量子特定的调用。虽然这对前端来说是一个相对简单的操作,但能够在Clang级别而非IR级别确定这些操作,使我们能够对生成的函数调用做更多处理,这对我们来说是一个强大的功能,也让识别量子操作变得容易得多。

在我们的LLVM流水线中,Fleck处理本质上就是一个Pass。我们将Fleck调用转换为内部图,并在此执行重构操作,从而减少电路中的门数量和长度,这通常特别针对英特尔设备。从语言而非IR推断这些信息的能力尤其有用,因为它可以传递更多上下文信息,这对于更复杂的量子算法很有帮助。

此外,这让我们能够处理和通知任何顶层操作,例如逆操作。量子计算是可逆的计算环境,如果没有像Fleck这样的东西以及用于识别二元/一元运算符以执行逆操作的Clang插件,我们实际上很难推断(除非手动编写)在哪里执行这个逆操作。

因此,虽然我们不得不稍微超出标准的LLVM流水线以获得更多能力,但SSA形式允许我们构建量子程序的简洁操作图并执行优化。LLVM本身仍然足够灵活,让我们能够以各种形式执行这些优化和更改。

为量子研究者提供易用的优化工具 🧑‍🔬

上一节我们介绍了如何通过语言扩展和前端插件提升开发体验,最后一个挑战则更具人文色彩:如何让不熟悉编译器的量子物理学家也能轻松使用编译栈进行优化。

编译器开发者不一定需要了解量子计算来编写编译器部件,同样,物理学家也不想为了编写一个优化而去了解编译器。但我们仍然希望让他们更容易地使用编译器栈。

以下是一个用英特尔量子SDK编写的简单量子程序,编译为LLVM IR后的示例。左侧是之前展示的电路结构,而LLVM代码对于不熟悉它的人来说可能有些难以理解。此外,许多量子优化是使用量子电路的抽象来编写的。虽然我们可以从IR中获得相同的信息,但这需要大量的簿记工作:必须理解如何跟踪量子比特以及如何在不同函数调用中跟踪数据。我们希望为英特尔量子SDK(包括英特尔内部和外部的用户)开发优化的用户,很多是物理学家,并不熟悉这些计算机科学概念。因此,让他们能够开发优化的一个重要部分,就是“在他们所在的地方与他们相遇”。

这类似于CIRCUIT或Qiskit等工具。我们有一个基于图的结构来表示量子电路。每个包含量子操作的基本块都有一个图,并且这个图只包含量子操作。每个节点就是一个量子操作,每条边表示两个操作之间共享的量子比特。例如,Prep Z 操作连接到 Hadamard 门,而 Prep ZQ1HadamardQ0 都连接到 CNOT 操作。这些依赖关系仅告知电路中操作的执行顺序。

从IR转换到这种图结构比较容易,但我们希望用户能够操作这个图,而无需实际处理IR本身。由于我们处于这种仅包含量子操作的受限模式,我们能够提供一系列标准的图操作,这些操作将能够被翻译回IR本身,主要涉及处理传递给每个门的参数。

作为一个简单的例子,假设我们想在 Q0Q1CNOT 操作之前,在量子比特1和一个当前不在图中的新量子比特3之间插入一个 CNOT 操作。

用户可以像在LLVM中创建指令一样,为量子操作调用一个创建方法。这会实例化一个新指令,并出于内存管理的考虑,让量子模块拥有其所有权。然后,他们可以在该新操作上使用插入方法,将其放在 Q1Prep Z 操作之后。

我们需要做几件事:首先,像处理任何有向图一样更新电路对象;然后,我们也需要将其添加到IR中,并相应更改IR。在简化形式中可以看到,紫色框是新添加的指令,它添加了那个 CNOT 操作以及为 Qubit 3 的寻址,同时它还将 Qubit 0 的寻址移到了 Prep ZCNOT 操作之上。这是我们需要为LLVM做的额外处理,但优化开发者不一定需要担心这些。这让他们能够利用LLVM的能力,同时只专注于量子算法的需求。

还有其他操作可以嵌入,例如作为量子内核的父函数的一部分,但由于时间关系,这部分暂时跳过。

总结与展望 📚

本节课中我们一起学习了将LLVM用作量子中间表示的完整过程。

使用LLVM是英特尔量子编译器的一个强大组成部分,它让我们能够将经典计算与量子硬件的需求结合起来。Clang插件和LLVM Pass系统的模块化使我们能够实现独特的优化,并通过Fleck等语言扩展为高级开发人员提供更多可能性。此外,我们希望提供操作IR以进行优化的工具,并通过量子电路对象来实现这一点,试图为那些开发者创建一个仅触及量子部分的、领域特定版本的IR。

最后,这些组件很多都是开源的。我们欢迎来自更广泛社区的反馈和贡献,特别是关于如何改进我们结构设计的建议(我们团队大部分成员是物理学家,可能做出了一些有趣的决定),以及关于更高效使用LLVM组件或我们未知工具的建议。同时,也欢迎研究人员使用量子电路对象进行优化。


本节课中我们一起学习了:

  1. 量子编译的核心约束:包括无分支、资源优化、设备匹配和无经典操作。
  2. LLVM在量子编译中的作用:通过多级Pass管理器处理内联、展开、常量折叠和硬件映射,满足量子编译的特殊需求。
  3. 提升开发体验的工具:引入Fleck函数式语言扩展和Clang插件,简化量子算法表达,并增强编译器的推理和优化能力。
  4. 面向领域专家的抽象:提供量子电路图对象,让物理学家能在熟悉的抽象层面进行优化,而无需深入LLVM IR细节。

通过结合LLVM的强大基础设施与量子计算的专业需求,我们正在构建一个既高效又易用的量子编译工具链。

015:半精度浮点数支持 🧮

概述

在本节课中,我们将学习LLVM libc项目中关于C23标准新增的_Float16(半精度浮点数)类型及其数学函数的实现工作。我们将了解其背景、实现目标、已完成的工作、遇到的挑战以及从中获得的经验教训。


C23 标准与 _Float16 类型

上一节我们介绍了课程概述,本节中我们来看看_Float16类型的定义。

C23标准定义了新的_FloatN类型,其中之一是_Float16。它对应于IEEE 754标准中的binary16格式(如右图所示)。这种格式也被称为半精度或FP16。它最近因神经网络而流行,但也在图形领域使用了相当长的时间。

随着C23定义这些新的浮点类型,它也相应地定义了新的数学函数。例如,为了获取浮点数的绝对值,我们已经有fabsf函数。现在对于_Float16,我们有了fabsf16函数。

项目目标 🎯

这引出了本次Google Summer of Code项目的目标:在LLVM libc中实现这些新的C23 _Float16数学函数。显然,这样做的好处是向支持C23标准迈出了一步。据我所知,这使得LLVM libc成为第一个实现这些新C23数学函数的libc库。

已完成的工作

以下是我们在项目中完成的主要工作。我们将数学函数分为两类:基本运算和高等数学函数。

基本运算实现:
我们实现了所有计划中的70个_Float16基本运算。这包括绝对值函数、舍入函数、最大值函数等。如果你对完整列表感兴趣,可以查看相关的跟踪问题。

高等数学函数实现:
我们实现了54个计划中的_Float16高等数学函数中的17个。这些包括指数函数、对数函数、双曲正弦、余弦等。我们知道无法在夏季完成所有实现,如果你对完整列表感兴趣,这里同样有跟踪问题的链接。

性能优化 ⚡

作为项目目标的一部分,我们也希望优化一些基本运算,因为其中一些操作与某些CPU上的特殊硬件指令非常匹配。但我们不希望使用内联汇编或特定于目标的内部函数(intrinsics),在LLVM libc中我们不太喜欢这样做。相反,我们希望使用编译器内置函数(builtins)。

我们成功地使用内置函数优化了_Float16,以及以下基本运算的floatdouble变体:一系列舍入指令(如四舍五入到最接近的整数)以及最大值和最小值函数。

以下是这些优化的一些示例结果:

  • 在Pixel 8手机上,ceilf16函数的初始实现耗时可达8.92纳秒,而使用内置函数的版本仅需0.79纳秒。
  • 在第13代Intel Core i7上,fmaxf16函数:如果不启用任何可选的F16指令支持,耗时可达133纳秒;如果启用F16C支持(即用于与float16相互转换的更高指令),则降至6.17纳秒;但无论如何,使用最佳实现仅需3.81纳秒。

最后,这里有一个关于_Float16数学函数与float数学函数在延迟上如何比较的简单示例。同样在第13代Core i7上,_Float16的指数函数耗时可达16纳秒,而float版本仅需3.18纳秒。目前在x86_64上,_Float16的表现并不理想。而在具有几乎完全硬件支持_Float16的ARM CPU(如Pixel手机)上,_Float16版本实际上比float版本稍快一些。

遇到的问题与挑战 🐛

我们遇到了相当多的问题。我们遇到了编译器错误,甚至是崩溃。因为在LLVM libc网站上,我们仍然声明支持Clang 11,并且确实在一些合并后的CI中使用它。但当我们使用该版本的Clang编译一些手写代码并针对AArch64时,它会崩溃(你可以看到“指令选择失败”)。

另一方面,在当前版本的Clang上,我们遇到了错误编译。可能你已经知道,并非所有CPU都有完整的_Float16硬件支持。大多数x86_64 CPU只有与_Float16相互转换的指令,有些则根本没有特殊的_Float16指令。在大多数情况下,当前版本的Clang可能会生成改变代码结果的转换。你可以在这里看到fabsf16函数的例子:它本应只是将符号位设置为0(左边是Clang 19的情况),但在右边(Clang 18),你可以看到它生成了对软件转换函数的调用。这些软件转换函数已经存在,但当你向该函数传递一个信令NaN时,它会将其转换为静默NaN,而两者的编码方式不同。因此,当你更改符号位并转换回来时,不会得到相同的结果。

我们还遇到了次优代码生成的问题,你可以认为这没那么严重。在右边,Clang 18生成的代码比GCC在x86_64上生成的代码更长更慢,但即使GCC生成的代码在这种情况下也并非最优。

使用编译器内置函数的困难

我之前谈到了使用编译器内置函数进行优化,但这并不像我希望的那么简单。我制作了一个大表格,尝试了一系列内置函数,包括直接的_Float16内置函数,以及使用从_Float16转换而来的float内置函数。我在所有支持的架构(x86、ARM、RISC-V)以及Clang和GCC上进行了测试。

当你看到一个绿色的对勾时,意味着我们实际上可以尝试使用它,它似乎能生成有趣的代码。如果是黄色或红色,它只是生成一个可调用的libc函数,我们可以自己实现。首先,它会使编译器崩溃。其次,即使是一个绿色的对勾,我们也可以尝试使用它,但最终可能不值得使用。例如,floorf16内置函数在x86_64上生成的代码实际上比我们在LLVM libc中该数学函数的当前实现还要慢。

编译时问题

最后,我们遇到了一系列编译时问题,因为我之前提到的一些软件转换函数可能缺失,这取决于你使用的是哪个编译器以及目标平台。

  • 在ARM和RISC-V上,GCC缺少将_Float16long double相互转换的内置函数。
  • 在x86_64上,Clang缺少将_Float16x86 long double相互转换的内置函数(无论目标平台如何)。
  • 即使这些函数可用,它们也可能给出错误的结果。它们可能忽略当前的舍入模式,只假设使用默认的舍入模式。这就是在ARM 32位(AArch32)上使用GCC时发生的情况。它为该目标单独部署了这些函数(我不知道为什么)。Clang在所有目标上使用相同的实现,因此在所有目标上都有同样的问题:总是向最近的偶数舍入。
  • 我们还有一个奇怪的问题,Clang在用于与_Float16相互转换的函数中使用了错误的寄存器。我在LLVM 19中提交了一个补丁来修复这个问题,但人们说这现在引起了其他问题。棘手的是,这取决于你从哪个上游LLVM和Clang项目构建编译器运行时库。

经验教训 📚

我们从所有这些遇到的问题中学到了什么?嗯,你可以看出一个模式:我们在某个目标上对一种新类型的支持越少,就越可能遇到问题。在ARM目标上,如果你有完整的硬件支持,你就不需要所有这些软件转换函数。目前,硬件供应商似乎主要对他们制造的、拥有硬件支持的类型感兴趣。

为了让我们未来在库中添加新的浮点类型更容易,我想我们可以尝试反过来:首先为该新类型和编译器实现完全可用的软件操作实现,然后才通过使用特殊的硬件指令来为具有硬件支持的目标进行优化。这样,库从一开始就可以使用新类型,即使代码很慢,但至少它能实际工作。这样,库就可以避免 tangled conditions(根据编译器版本和目标平台来决定是否启用对这些新类型的支持)。但这可能需要说服硬件供应商改变他们的优先级,所以我不确定这是否会发生。

总结

本节课中我们一起学习了LLVM libc在实现C23 _Float16数学函数方面的工作。

总结来说,LLVM libc是第一个实现C23 _Float16数学函数的libc库,但我们尚未实现全部。所有函数都在x86_64上受支持,但我们暂时在AArch64和GPU上禁用了其中一些。由于主要的编译时问题,我们暂时在ARM上禁用了所有_Float16函数,但我们正在努力重新启用它们。

016:DynamicAPInt - LLVM的无限精度算术

概述

在本节课中,我们将要学习一个名为 DynamicAPInt 的类,它用于实现无限精度算术。这里的“无限”并非字面意义上的无限,而是为了与LLVM中已有的、需要预先固定宽度的任意精度类 APInt 相区分。DynamicAPInt 允许你使用大到足以放入内存的数值,而无需事先指定其宽度。

为什么需要 DynamicAPInt?

LLVM上游有一个名为 Presburger 的库。我们无需深究其具体定义,只需知道它是一个用于求解特定类型约束系统的求解器集合。该库被用于多个编译器环节,例如计算静态单赋值值的边界或寻找循环融合机会。为了确保编译器运行快速且正确,避免错误编译,我们需要一个高效的整数类。

理论上,在最坏的情况下,这些计算可能需要指数级大的整数,无法用64位表示。但实践表明,98%的时间,问题都可以用64位整数解决。因此,我们真正需要的是一个在常见情况下(64位)速度极快,同时为了正确性,也为极少数需要大数的情况提供备用路径的整数类。

初步构想与目标

一个简单的想法是创建一个类,内部仅存储一个64位整数。在执行乘法等操作时,使用内置的溢出检查指令。如果发生溢出,则退出并通知用户,而不是静默地产生错误结果。在大多数没有溢出的情况下,它运行得很快。

代码示例:

// 伪代码:简单的溢出检查
int64_t val;
if (__builtin_mul_overflow(a, b, &val)) {
    // 处理溢出,切换到高精度路径
} else {
    // 使用快速结果
}

但这并非生产级方案。我们的目标是让 DynamicAPInt 在64位快速路径上的性能尽可能接近这个简单方案,同时保证完全正确。

DynamicAPInt 的设计

DynamicAPInt 的核心设计是一个联合体,它包含一个64位整数和一个 APInt 对象。我们使用一个标签来指示当前使用的是哪种存储方式。

核心概念:

DynamicAPInt = Union {
    int64_t smallVal; // 快速路径:64位整数
    APInt bigVal;     // 慢速路径:高精度整数
} + tag

所有运算首先尝试在64位整数上执行。如果检测到溢出,则回退到使用 APInt 的高精度计算路径。由于绝大多数情况都走快速路径,因此我们的优化重点也在于此。

上一节我们介绍了DynamicAPInt的基本设计,本节中我们来看看如何优化其快速路径的性能。

性能优化探索

为了优化,我们首先需要一个基准测试。我们创建了一个微基准测试,反复执行大量乘法操作。这个测试为我们设定了性能优化的“天花板”。

在优化过程中,我们发现了一些有趣的教训。代码的布局和内存对齐会显著影响性能,即使在不同的函数中也是如此。例如,在特定的Intel微架构上,关键循环如果位于某个对齐的内存段,速度可能会变慢。

我们通过切换到AMD平台并参考相关博客,使用编译器指令来调整基本块的对齐方式,从而获得了约1.2倍的性能提升。内联函数调用也带来了显著的性能改善。

当我们观察优化后的汇编代码时,发现微基准测试的循环中存在三个紧密相关的分支:检查标签、检查溢出、以及继续循环的条件跳转。在现代处理器(如AMD Zen架构)上,执行端口有限,处理三个分支会成为瓶颈。

然而,我们也意识到,这个微基准测试的实用性是有限的。真实的代码场景要复杂得多,包含更多分支。在这个微测试上进行的极致优化,在真实场景中可能收效甚微。

实际效果与瓶颈分析

那么,在集成了所有优化之后,我们在实际的 ML Presburger 测试中取得了什么效果呢?

以下是性能对比:

  • APInt(即使没有溢出检查)相对较慢。
  • 带有溢出检查的 APInt 更慢。
  • 我们的 DynamicAPInt(完全正确且带有溢出检查)比纯 APInt 快约1.5倍。
  • 而我们设定的性能“天花板”(仅使用带检查的64位整数)比 DynamicAPInt 快约2倍。

这个差距看起来不小,但仍有希望。我们发现,在实际代码中,仍然存在许多包含大量标签检查的紧密循环。如果能将这些检查提前、集中进行,那么核心计算代码的性能将大幅提升。

一个未来的工作方向是提供一个函数,允许用户传入一个lambda表达式,该函数会预先执行所有检查,然后再执行实际计算。初步实验表明,通过这种方式,性能可以非常接近“天花板”,差距缩小到30%左右。

另一个发现是,尽管 DynamicAPInt 相比“天花板”方案增加了约25-30%的分支数量,但分支预测失误的次数并没有显著增加。这说明处理器的分支预测器能够较好地应对这些额外分支。

真正的性能瓶颈可能在于L1数据缓存。从“天花板”方案切换到 DynamicAPInt 后,L1数据缓存未命中次数大约翻了一倍。这导致我们无法完全保留“天花板”方案的性能。

未来可能的方向

基于以上分析,我们提出了两个可能的未来优化方向:

以下是两个潜在的优化思路:

  1. 使用更小的快速路径类型:考虑不使用64位整数,而是使用32位整数加一个标志位。这样,两者组合起来仍然可以放入一个64位指针大小的空间中。在快速路径中使用32位可能仍然足以覆盖绝大多数情况,同时改善缓存局部性。
  2. 预检查Lambda函数:如前所述,提供一个接口,让用户能够预先集中进行所有模式检查,从而让核心计算循环摆脱这些检查,运行得更快。

总结

本节课中我们一起学习了 DynamicAPInt 的设计与优化。我们了解到,它通过联合64位整数和高精度 APInt,在保证计算正确性的前提下,为最常见的64位计算场景提供了快速路径。我们探讨了通过代码对齐、内联、分支分析等手段优化快速路径的性能,并分析了当前方案与理想性能“天花板”之间的差距主要源于缓存未命中的增加。最后,我们展望了通过使用更小的快速路径类型和预检查机制来进一步提升性能的可能性。DynamicAPInt 是平衡性能与正确性的一个实践案例。

017:FPOpt - 在LLVM中平衡浮点计算的成本与精度

概述

在本教程中,我们将学习一个名为Poseidon的系统。该系统能够在编译器中对浮点程序进行自动优化,旨在以给定的计算成本预算为约束,最大化程序的精度。我们将了解其工作原理、核心组件以及如何评估优化效果。

系统架构与工作流程

上一节我们介绍了Poseidon的目标,本节中我们来看看它的整体架构。Poseidon是一个类似PGO(Profile-Guided Optimization)的双阶段编译系统,构建在LLVM和MLIR的Enzyme自动微分器之上。

以下是Poseidon系统的主要工作流程:

  1. 插桩与分析阶段:Poseidon首先对输入程序进行插桩,运行增强版本的程序以收集运行时信息。
  2. 子图识别阶段:系统分析程序,识别出可进行浮点优化的子计算图。
  3. 候选变换生成阶段:针对识别出的子图,生成表达式重写和混合精度分配两类优化候选方案。
  4. 评估阶段:使用内部成本和精度模型,评估每个候选变换对全局程序成本和精度的影响。
  5. 求解与选择阶段:利用动态规划求解器,在用户定义的成本预算内,选择能最大化程序精度的一组变换。

接下来,我们将详细探讨每个阶段。

阶段一:插桩与分析 🧪

为了进行优化,Poseidon需要了解程序的运行时行为。它通过插桩来收集这些信息。

Poseidon使用反向模式Enzyme为输入程序合成梯度代码,并在合成的代码中插入日志函数调用。这些日志函数会记录原始指令的值和梯度,并将其传递到一个日志数据结构中。

执行这个增强后的程序后,Poseidon会得到一个程序剖析文件。这个文件包含了指令值的范围、执行次数以及值和梯度的几何平均值。

阶段二:子图识别 🔍

上一节我们收集了程序运行时数据,本节中我们来看看Poseidon如何确定需要优化的代码区域。Poseidon需要理解程序的哪些部分可以优化。我们将可以被Poseidon优化的LLVM值称为“Poseidonable值”,这包括基本算术运算和一些初等函数。其他LLVM值,如加载和存储,则不可优化。

Poseidon运行一个泛洪填充算法来遍历整个程序。该算法沿着指令的操作数和用途进行追踪,直到遇到不可优化的值为止。在此过程中,Poseidon还会识别出将数据传入或传出子图的LLVM值,并将它们标记为输入值和输出值。

阶段三:生成变换候选方案 ⚙️

识别出子图后,Poseidon开始生成两类优化候选方案:表达式重写和混合精度分配。

表达式重写候选

Poseidon从子图的输出指令开始,向上追溯到输入值和常量,从而构建出浮点表达式。然后,Poseidon将这些表达式传递给表达式重写器(如Herbie)来生成候选表达式。重写器会利用在插桩阶段提取的输入值范围信息。Poseidon从重写器获取多个表达式候选,并解析这些表达式以备后续使用。

混合精度分配候选

Poseidon通过使用从插桩路径捕获的指令值及其梯度的绝对值乘积,来估计改变一个中间指令对最终结果的影响程度。基于这些指令的敏感度信息,Poseidon生成变换候选,这些候选会改变浮点子图中最不敏感部分的精度,并在被改变的区域周围插入浮点类型转换指令。

阶段四:评估成本与精度 ⚖️

当Poseidon准备好所有变换候选后,它开始使用内部模型评估这些不同类型的变换如何改变全局程序的成本和精度。

  • 成本模型:Poseidon要么使用来自LLVM TargetTransformInfo的每指令成本,要么使用通过对LLVM浮点指令进行微基准测试得到的更精确的自定义成本模型。对于两类候选变换,Poseidon会将该候选中所有指令的执行次数与指令成本的乘积求和。
  • 精度模型:Poseidon使用一个基于MPFR的高精度求值器。该求值器使用任意精度浮点数计算“真实值”,同时使用常规机器精度模拟原始计算。然后,Poseidon通过计算“真实值”与模拟结果之间的差异来计算局部误差。接着,用梯度放大这个局部误差,以估计该变换候选对全局误差的贡献。

阶段五:动态规划求解 🧮

Poseidon需要求解出一组变换,使得在满足用户定义的成本预算的前提下,全局误差贡献的总和最小。这个问题本质上是一个更复杂的0/1背包问题变体,我们试图找到将物品放入背包的最佳方式,以在不违反容量约束的情况下最大化总利润。

0/1背包问题可以使用动态规划在多项式时间内求解。不同之处在于,Poseidon需要将所有计算成本四舍五入到最近的整数,以便能够将具有较大预算的原始程序拆分为许多具有较小预算的子问题。Poseidon还需要调整子图候选的成本和误差贡献,以考虑求解器已经选择的表达式候选,从而避免同一段指令被多次修改。

评估与结果 📊

我们在FPBench上评估了Poseidon。FPBench是一个用函数式编程语言编写的微基准测试集。我们将这些微基准测试导出为C语言,并使用Poseidon对它们进行优化。

在所有46个可直接优化的微基准测试中,我们计算了运行时间改进的几何平均值。在评估相对误差为0.01%的条件下,Poseidon平均带来了5.3%的运行时间改进。同时,在几个微基准测试上,Poseidon可以获得高达5个数量级的精度提升。

让我们看一个具体的微基准测试示例。图表顶部的虚线是原始程序的运行时间,底部的虚线是原始程序的相对误差。蓝色和绿色的点分别是优化后程序的运行时间和相对误差。对于这个微基准测试,Poseidon提供了6个优化后的程序供选择,包括那些更快但精度较低的程序,以及那些更精确但更慢的程序。在图的右下角预览中,Poseidon提供了两个程序,它们都比原始程序更快且更精确。

总结

本节课中我们一起学习了Poseidon系统。这是一个能够在编译器中对浮点程序自动执行基于剖析的双阶段优化的引擎系统。它通过插桩收集运行时信息,识别可优化子图,生成并评估表达式重写与混合精度分配候选,最后利用动态规划在给定成本预算下选择最优变换组合,从而在性能与精度之间实现智能平衡。该系统目前正在大型代理应用程序上进行进一步评估。

018:Clang 的新常量表达式解释器

概述

在本节课中,我们将学习 Clang 中一个新的常量表达式解释器。这个解释器的主要改进在于,对于常量表达式和常量求值函数,它不再每次调用时都解析抽象语法树,而是将其编译为字节码,后续仅解释字节码,旨在提升编译时的求值性能。

背景与动机

欢迎来到关于 Clang 新常量表达式解释器的分享。我是 Tim,在 Red Hat 工作。如果你今天在听讲时有一种似曾相识的感觉,可能是因为这个主题。早在 2019 年,也就是五年前,就有一个几乎同名的分享。我不仅借鉴了标题,还“借用”了第二张幻灯片,这是一个不错的开始。最初的工作是由 Nandor Licker 完成的,他在 2019 年向代码库提交了一个初始框架的提交,之后我在 2021 年接手了这项工作。接下来的 20 分钟,我将浓缩这三年的工作成果。

这个解释器的核心目标是在编译时解释表达式。与当前解释器的关键区别在于:对于常量表达式和常量求值函数,我们不再每次调用时都查看 AST,而是将其编译为字节码一次,后续仅解释字节码。这应该会更快。对于表达式,我们仍然直接解释,不生成任何字节码,这一点稍后会看到。

基础架构

解释器的所有内容都位于 clang::Interp 命名空间下。我们有一个名为 Context 的类,它管理着整个程序。一个程序基本上就是一个全局变量列表,以及我们编译的所有函数和相应的字节码。同时,它持有对 AST 上下文的常规引用。

在 Clang 的 Expr 类中,有一系列用于求值表达式的 API,例如 EvaluateAsKnownConstantEvaluateForOverflowEvaluateAsRValue 等。但最终,它们都会调用以下四个函数之一:

  • PotentialConstantExpression:这个函数名可能不太直观,但它接收一个函数声明,本质上负责将函数编译为字节码。
  • EvaluateEvaluateAsRValue:接收一个表达式,对其进行求值并返回结果。EvaluateAsRValue 变体在最后会执行一次隐式的左值到右值转换。
  • EvaluateAsInitializer:接收一个声明,对其初始化器进行求值并返回结果。

常量求值无处不在

为了让大家有个直观感受,我们来做个小练习。假设我使用以下命令行运行这段代码,你能告诉我有多少东西被进行了常量求值吗?

// 示例代码
for (int i = 0; i < 10; ++i) {
    // 一些操作
}

答案是 16。这包括了所有字面量的溢出检查,以及 C++ 范围 for 循环中大量你看不到的隐式变量。我想强调的是,常量求值无处不在,可能比我们想象的要多得多。

程序、函数与编译器

我已经提到了 ProgramFunction 基本上符合你的预期,它包含编译后的字节码以及关于函数的更多信息。我们还有一个名为 Compiler 的类。所以,你的编译器现在内部也有一个编译器,它负责根据发射器将函数编译为字节码。我们有一个字节码发射器来实际生成字节码,还有一个求值发射器,它在我们发射指令时立即进行求值,而不是生成字节码。

字节码与解释栈

字节码是基于栈的。我们使用解释栈来操作。解释帧本质上就是通常的函数帧,包含局部变量、大小等信息。

类型系统

对于类型,我们有各种不同大小的整数、浮点数、指针等。我们没有为向量和复数类型设计专门的东西,只是使用一个具有合适大小的基本类型数组。对于复数,大小就是 2。对于类和结构体,我们实际生成记录,这简单地包含了类或结构体的信息,特别是我们计算基类、字段和虚基类的偏移量。之后,当我们引用一个字段时,只需使用该偏移量即可。

指针与内存块

对于指针,最重要的是块指针。这里的“块”不是 Objective-C 中的概念,对我们来说,块就是实际分配的数据。例如,如果你有一个整数,我们需要分配内存。通常,局部变量分配在解释帧中,全局变量分配在程序中。最近,我们还支持了编译时的动态内存分配,它也会分配一个块。

为了描述块的内容,我们有一个称为“描述符”的东西。描述符提供了我们可能需要的关于块内数据的所有信息。如果是数组,它给出数组大小和元素类型;如果是记录,它给出记录信息等。

对于字段,它们前面总是有一个我们称为“内联描述符”的东西。内联描述符提供关于字段的数据,例如字段的描述符,或者最重要的是,有一个位指示该字段是否已被初始化。

示例:结构体布局

以下是一个结构体布局的示例:

struct Player {
    int health;
    float position;
};

对于这个结构体,我们将计算偏移量。字段 health 的偏移量是 16 字节(即内联描述符的大小)。因此,如果你想引用字段 health,你会指向偏移量 16 处,然后才是字段的实际数据。对于 position 字段,我们最终会到达偏移量 64 处,其大小是 32(即 APFloat 的大小,对于浮点数我们也使用 APFloat)。

图中绿色部分显示了全局变量所需的另一部分元数据。这里只是初始化状态。如果全局变量被声明为 constexpr,则必须被初始化,但这可能会失败。如果失败且我们后来没有使用该变量,我们需要正确地诊断这一点,这就是我们使用它的目的。

现在,如果你要引用一个全局变量的 position 字段,你会指向第二个内联描述符之后,也就是 position 实际数据之前的位置。

解释函数调用过程

我想实际看看如何解释一个函数调用。我刚刚给 Player 结构体添加了一个成员函数,并声明了一个名为 p 的全局常量变量并初始化了它。我们要查看的表达式是静态断言中的二元运算符(一个等号比较 ==)。左边是一个成员调用,成员调用的基址只是一个声明引用表达式,也就是 p

当我们求值这样一个声明引用时,它基本上会以一个指向声明的指针结束。此时,我们开始实际求值 getHealth 函数。

在解释栈中,我们从一个指针开始。实际内容并不有趣,但它是一个根指针,因为我们还没有应用任何偏移量。

如果你记得 getHealth 函数体,它只是 return health;,但前面当然有一个隐式的 this->。所以我们首先要做的是获取实例指针,在这里实例指针就是 p,所以我们得到的是完全相同的东西。现在栈上有两个相同的指针。

接下来我们想做的是获取 health 字段。我们使用 GetPtrField 指令来完成。GetPtrField 指令会弹出栈顶,应用一个偏移量,然后将结果推回栈顶。这里我们应用之前看到的偏移量 64。结果是指向同一内存但应用了 64 偏移量的指针。

接下来我们想实际加载这个值,不出所料,这将是我们之前的 30。

然后,就像在其他函数调用位置一样,我们将通过一个返回指令结束。这将从栈中弹出返回值,清理函数调用后的现场(即从栈中移除所有传递给函数的参数),销毁解释帧,然后将返回值放回栈顶。

此时,函数的调用者才能实际使用它。二元运算符现在已经求值了左侧,当然,接下来它会求值右侧并进行比较。结果将是相等。

如你所见,然后结果将被转换成一个 APValue。在这个例子中,这是我们唯一一次将任何东西转换为 APValue。我们将把它返回给 EvaluateAsRValue。你的编译器不会因为断言未失败而报错。

性能测量

我进行了一系列性能测量。我认为目前两个解释器还没有达到可以进行有意义性能比较的状态。但鉴于我演讲的主题,我觉得必须展示一些数据。

例如,处理一个 GitHub issue (#61425) 的测试表明确实有实际需求。现在你可以在 4 秒内完成原来需要 11 秒的工作。如果你想以一种非常低效的方式编译斐波那契数列,现在也可以更快地完成。

这里我使用了 embed(一个现代特性)。我嵌入的文件是 SQLite3 的合并文件,大约 8.6 MB,我只是对每个字节求和。结果是 18 秒对 31 秒,有提升。

当然,我也有 SQLite3 文件,可以直接运行它来证明我没有编造数字,但这次它变慢了。我还不确定原因,尚未调查。重申一下,我仍在努力让功能正常工作,还没有投入大量时间来优化性能。

测试

对于测试,我们在 Clang 测试目录中有通常的目录。我们也在向现有文件添加越来越多的新运行行。顺便说一下,如果你想尝试这个功能,这是你需要传递给 Clang 的参数。

今年四月,我开始定期测试整个 Clang 测试套件。开始时,有近 600 个测试失败,现在在这个图表上是 179 个。如果你访问下面的链接网站,是 175 个。如果看我的笔记本电脑,是 156 个。总的趋势是下降的,虽然中间有一次上升,但我正在努力解决。

挑战与待办事项

有一个关于“固定构建常量指针”的问题,我不会详细说明它是什么,因为我不想破坏任何人的好心情。但它是我们在生成字节码时以及后来解释时需要做的一系列奇怪组合。据我所知,这是一个新事物。如果你看 Clang 的做法,它与 GCC 完全不同。

类型 ID 指针方面,我没有什么有趣的可说,只是一堆如果实现了就会通过的测试,但实现起来工作量很大。

位域支持方面,有一个补丁在等待审核。它比当前的实现更好,因为它支持位域。当你声明一个巨大的数组但只初始化其中一个元素时,如果我们不为你创建巨大的数组,那会很好。

总结

在本节课中,我们一起学习了 Clang 新常量表达式解释器的设计动机、核心架构、类型与内存布局、函数解释过程,以及目前的性能表现和测试状态。这个新的解释器旨在通过编译为字节码并解释执行的方式来提升常量表达式求值的效率,是 Clang 未来发展的重要方向。

问答环节

问: 你如何测试以确保新解释器的语义是正确的?例如,与编译成本地代码运行相比,如何确保常量求值的结果一致?

答: 你可以编写测试,确保它们一定在编译时运行。例如,通过将变量声明为 constexpr 来确保初始化器在编译时求值。但更主要的是,我试图成为当前解释器的直接替代品。所以当前解释器做什么,我就尝试做什么。我们依赖现有的测试套件,并希望它们有良好的覆盖率。

问: 你是否考虑过不再使用 APValue 作为最终结果的表示形式?

答: 是的,某种程度上考虑过。但 APValue 在 Clang 内部被大量使用。我认为这可能是我们在切换解释器之后可以做的事情,届时我们可以简化很多东西。

问: 感谢你的工作。新的常量表达式解释器确实是 Clang 的未来,它明显更快,尽管你还没有投入太多精力优化它。这是一个巨大的工程,谢谢你为之付出的努力。

答: 谢谢。

019:Carbon与Clang中的泛型实现策略

在本节课中,我们将学习C++模板在Clang中的实现方式,分析其优缺点,并探讨Carbon语言工具链所采用的全新实现策略。

概述

C++模板和Carbon泛型的基本原理相似:它们在定义时并不进行完整的类型检查,而是等到通过参数替换确定具体类型后,才决定代码的有效性和含义。替换模板会产生一个具体的实体,例如函数或类。Carbon在此基础上引入了“受检泛型”,它在定义时就进行类型检查,并要求泛型参数必须带有约束条件。

上一节我们介绍了泛型的基本概念,本节中我们来看看它们在编译器中的具体实现方式。

C++模板的现有实现策略

当前C++编译器主要有两种实现模板的方法。

方法一:令牌序列法

这种方法将模板主体视为一系列令牌序列。在实例化时,编译器根据模板参数的含义重新“回放”这些令牌,从而构建出具体的函数体。EDG前端和旧版MSVC编译器采用了此方法。

方法二:抽象语法树法

这是GCC和Clang采用的方法。编译器会预先构建一个抽象语法树,其中明确表示出依赖于模板参数的部分。

让我们更深入地看看Clang使用的方法。

当Clang解析一个函数模板时,它会像处理非依赖代码一样构建AST。关键区别在于,它会为依赖部分创建明确的表示。例如,一个依赖类型的构造表达式会被标记为“未解析的构造表达式”,其具体含义要等到替换时才能确定。

在实例化时,Clang会从原始AST出发,为每个节点创建一个新节点,并在此过程中执行替换。所有模板参数T都会被替换为具体类型(如int)。原先具有依赖类型的节点现在获得了具体的类型。最终生成的AST形状可能与原始模板的AST略有不同。

在Clang中,这项工作由TreeTransform类完成。它有一个重要的优化:尝试重用那些不依赖于模板参数、因此在多次实例化中保持不变的AST部分。这是避免模板实例化开销的最大来源。

然而,在当前的Clang中,这种重用通常失败。主要原因如下:

  • 如果一个节点被重用,其父节点也必须重建,因为它包含指向子节点的指针。
  • 局部变量包含指向其父函数(作用域)的指针。由于每次实例化都会生成新的函数,因此也需要新的局部变量,这导致任何涉及局部变量的部分都会被重建。
  • Clang中表达式的类型表示方式意味着,如果类型改变,整个表达式都需要重建。
  • 由于句法形式和语义形式表示的差异,初始化器总是从头开始重建。
  • 包展开也总是完全重建。

因此,Clang中构建实例化的成本,大致相当于解析模板源代码或从头构建实例化的成本,尽管在词法分析和非限定名称查找方面能节省一些开销。

Carbon的覆盖层模型

考虑到Clang模型的局限性,Carbon团队设计了一种新的实现策略:使用覆盖层来表示泛型和特化。

其核心思想是:像Clang一样,尽可能完整地解析泛型,形成一个依赖表示。然后,将特化表示为一组应用于泛型的“补丁”。这意味着,在特化中,我们只存储那些在实例化之间发生变化的部分,并且只花费时间重建这些变化的部分。

以下是该模型的工作原理:

  1. 构建依赖数组:解析泛型时,编译器会构建一个数组,包含泛型中出现的所有依赖构造。
  2. 替换为索引引用:泛型代码中所有对这些依赖项的引用,都被替换为它们在数组中的索引。
  3. 计算具体值:当需要从泛型生成特化时,编译器通过计算数组中每个“槽位”对应的值,来生成具体的值数组。

这个过程本质上将泛型转换为一个编译时函数。该函数的“代码体”就是那个依赖数组中的指令,而生成特化的过程,就是对这个编译时函数进行求值。

公式/代码表示

特化 = 编译时求值(泛型函数, 具体类型参数)

这种方法的优势非常明显:

  • 表示更紧凑:特化只存储变化的部分,数据密度高。
  • 速度更快:只计算需要变化的部分,避免了大量重复工作。
  • 便于优化:由于明确分离了不变和可变部分,编译器可以在后续阶段(如代码生成)做更多优化,例如识别并合并两个最终代码相同的特化。

处理模板泛型(非受检泛型)

上述覆盖层模型很好地处理了“受检泛型”,因为其类型和常量值可以符号化确定。但对于更传统的、类似C++的“模板泛型”,代码的含义和有效性在替换前是未知的,问题更为复杂。

Carbon的解决方案是:在中间表示中引入一种新的指令。这种指令代表一种“原子实例化”操作,用于实例化单个表达式或构造。

例如,对于一个调用未知函数F的表达式,编译器会构建一个指令,表示“类型检查这个成员访问并实例化它”。在生成特化时,对这个指令进行编译时求值,就会计算出具体的调用指令。

这意味着,Carbon的表示不是一个预先确定程序最终含义的依赖语法树(如Clang),而仍然是一个基于参数计算含义的过程。生成特化,依然是编译时函数求值。

权衡与总结

本节课中我们一起学习了两种泛型实现策略。

Clang的模型(基于AST)主要优点是正交性。编译器中的大部分代码在处理表达式或声明时,无需关心它是否来自模板实例化。这极大地简化了编译器内部不同功能模块之间的交互,避免了组合爆炸问题。

Carbon的覆盖层模型则追求极致的速度和紧凑性。代价是损失了一定的正交性。在Carbon的IR中,任何时候想要遍历指令操作数、查看声明初始化器或查找结果,都必须考虑当前处于哪个特化的上下文中,因为边(引用关系)本身不包含这些信息。这给编译器内部API的使用者带来了额外的负担。

总结如下

  • Clang模型:强语义表示,优秀的正交性,保持工具链相对简单。
  • Carbon模型:更小的内存表示(初步数据显示约10倍缩减),更快的实例化速度(初步数据显示约10倍提升),并为后续优化(如特化合并)提供了便利。

最终,这是一个工程上的权衡。如果你构建的编译器将速度置于几乎其他一切之上,那么Carbon所采用的覆盖层模型是一个非常值得考虑的方向。

020:大规模使用 Clang Modules

在本教程中,我们将学习 Clang Modules 在大规模项目中的应用。我们将回顾过去十年在 Apple 平台进行模块化的经验,探讨如何避免常见的模块化陷阱,了解预处理器的使用,并学习如何高效地构建模块。

1:Clang Modules 基础

Clang Modules 是可导入的接口,类似于 Swift、C++、Python 等语言中的模块导入功能。它们与 C++ 模块类似,但早于后者约五年出现。它们最初是为 Swift 与现有 C 代码交互而设计的,但也可以从 C 和 C++ 中使用。

Clang Modules 在模块映射文件中定义,由头文件组成,并作为独立的翻译单元进行构建。这一点非常重要,我们将在后续多次提及。

2:模块化历程与早期挑战

最初的模块是与 Swift 1.0 一同创建的。大多数库都有自己的模块,这相对简单。然而,当我们处理用户包含路径时,我们为整个路径创建了一个大型模块,并为 Clang 头文件和 libc++ 分别创建了模块。

这种方法导致了一系列问题。我们不得不引入一些编译器变通方法来绕过模块映射的限制,但这些问题最终仍需解决。在 Swift 1.0 发布后,库的所有者开始负责管理自己的模块,并有一些早期采用者开始使用模块。

我们很快发现,对于 C 开发者来说,模块的使用并不直观,存在一些容易陷入的陷阱。我们发现了头文件中各种各样的内容和用法,并且发现构建性能并非理所当然就能获得。

3:模块化陷阱详解

在深入探讨具体陷阱之前,我们需要了解关于模块的更多信息。模块是预编译并可复用的,它们作为独立的翻译单元构建。#include 语句会被转换为模块导入语句,这是一个关键点,因为模块不会继承包含者的预处理器环境。

这与 C 语言的传统行为有很大不同,并且是许多陷阱的根源。此外,在 Swift 中,模块名是声明标识符的一部分。这意味着对于同名但位于不同模块的声明,它们是不同的类型,类型合并规则不适用。

以下是几个关键的陷阱:

陷阱一:必须自底向上按依赖顺序模块化

这意味着模块化的头文件只能包含其他模块化的头文件。我们最初没有遵循这一点,结果发现包含非模块化头文件会导致大量难以理解和排查的 Bug。

这些 Bug 包括:#include 似乎不起作用、出现无意义的类型重声明错误、类型不兼容错误(在 Swift 中尤其常见),以及其他各种莫名其妙的错误。

为了说明这一点,我们来看一个例子。假设有一个基本的模块映射,其中第二个头文件包含了一个非模块化的头文件。这个非模块化头文件只定义了一个类型。当用户同时包含另一个模块化头文件和非模块化头文件时,他们可能会发现非模块化类型变成了“未找到”的类型。这看起来像是编译器错误,但实际上与模块的实现方式有关。唯一的解决方法是按依赖顺序模块化所有头文件。

陷阱二:模块图必须是无环的

这很容易理解,你不能有循环包含或循环模块依赖。当涉及 C 标准库头文件时,情况会变得棘手,因为看似简单的包含可能会在搜索路径之间来回跳转。

我们最初为 usr/includeusr/include/c++ 中的所有内容创建了大型模块。这直接导致了模块循环。我们最初的变通方法是阻止包含从 libc 模块跳转到标准库,但这在 C++ 中引起了许多奇怪的问题,并且可能引入额外的循环依赖。我们今年终于修复了这个问题,现在我们的模块图是线性的。

陷阱三:确保预期的宏被定义

这源于“模块作为独立翻译单元构建”这一事实。这意味着头文件不能依赖包含者的预处理器环境。

一个常见的例子是 stddef.h。几乎所有东西都需要它,但有时你会忘记包含它。在传统包含方式下,由于其他头文件包含了它,你侥幸成功了。但在独立的预处理器环境中,包含 stddef.h 就变得至关重要。

更令人困惑的是,用户提供的宏现在需要通过命令行传递。假设有一个模块,它根据客户端传入的宏定义有条件地添加额外的 API。通常,你会在包含前定义这个宏。但对于模块,这行不通,因为模块在构建时,该宏可能未被定义。解决方法是将该宏通过命令行传递给编译器。

陷阱四:单一定义规则

你听说过的所有单一定义规则在这里同样适用。对于类型、宏、函数等,只能有一个定义。有时你可以使用 #ifndef 保护来规避不便,但模块作为独立翻译单元构建,这可能导致类型重声明错误。

例如,Apple 的 ICU 副本中,UBreakIterator 类型在几个不同的头文件中都有声明。在模块化后,这就成了类型重声明错误。解决方法是指定一个头文件来拥有该声明,或者创建一个新的公共头文件来存放这些通用类型。

陷阱五:#undef 可能导致问题

有时我们使用 #undef 来修复其他头文件的问题。但模块作为独立翻译单元构建,这同样会导致重声明错误。修复这个问题更具挑战性,因为需要让头文件对模块敏感,并小心避免重声明 Clang 坚持要拥有的内容。

陷阱六:extern "C" 的类似问题

extern "C" 有时也被用来修复其他头文件的错误。但模块作为独立翻译单元构建,同样会引发问题。例如,一个坏的头文件忘记使用 extern "C",另一个好心的中间层头文件试图用 extern "C" 包裹它来帮忙。但在模块构建时,坏的头文件得不到包裹,仍然缺少 extern "C",最终导致链接错误。

这个问题尤其棘手,因为它诱使你将一堆头文件包裹在 extern "C" 中。例如,C++23 中的 stdatomic.h 是 C++ 感知的,包含了许多 C++ 原子操作。如果把它包裹在 extern "C" 中,会导致大量错误和奇怪的行为。因此,在 C++ 模式下构建模块时,我们将此视为错误。同样,在命名空间括号内包含头文件也会导致类似问题。

由于 Apple 内部有许多混乱的头文件,我们使用了一个模块变通方法来解决这个问题,并一直在努力修复。

4:预处理器的使用与文本头文件

当你的头文件中需要进行非模块化操作时该怎么办?模块映射文件有一个名为 textual 的特性。你可以在模块映射中的头文件前加上 textual,这样它基本上就变成了一个普通的头文件。

这些文本头文件不作为模块的一部分构建。因此,如果你导入该模块,实际上看不到该头文件的内容。包含该文本头文件也不会转换为导入语句,除非该头文件自己导入了模块。它们不作为独立的翻译单元构建,只是普通的头文件。

使用文本头文件存在一些危险,类似于我们之前讨论的非模块化头文件问题。你不能让一个声明存在于多个模块中,它必须属于一个模块。例如,assert.h 很难从模块化头文件中使用,因为如果在调试和发布版本中有不同的值,然后导入到后续的翻译单元中,Clang 会感到困惑。

文本头文件也有一些用途。一个常见的例子是 X 宏,例如 llvm/Support/Options.inc 文件,它只是一堆类似函数的宏,需要标记为 textual。而 llvm/Support/Options.h 是模块的一部分,它文本式地包含 Options.inc,并在该上下文中创建声明,因此这些声明只属于实际的模块。

另一种常见情况是私有实现头文件。有时你会因为头文件太长而将其拆分,但拆分不当,只是被切成几块,并期望按正确顺序包含。另一种情况是并行实现,例如在不同架构上需要不同的内联汇编。解决方案是确保它们有一个单一的包含点,并标记为 private textual,这可以防止其他模块包含它,从而保证这些声明只属于一个模块。

有时头文件确实同时包含文本部分和声明部分。一个常见的例子是 GCC 风格的 .td 文件,其中有一些巧妙的宏。为此,我们创建一个顶层的文本头文件来处理所有的预处理部分,然后创建单独的模块化头文件来提供声明。

5:构建模块与性能优化

现在我们有这么多模块,需要构建它们。首先了解一些基础知识:Clang 将模块编译成磁盘上的 PCM 文件(预编译模块),它们是 LLVM 位码格式,包含 AST 密钥和其他信息。PCM 文件的内容取决于编译器标志,如 -D-target、语言版本等。特别是 -D,在不同的源文件或目标上设置不同的 -D 很常见,因此会产生很多变体。

命令行参数的一个子集构成了模块上下文哈希值,这包括所有可能影响 AST 的命令行参数。由于这是从命令行形成的,我们实际上不知道它们是否一定会影响,只知道它们可能影响。因此,具有不同哈希值的同一模块的 PCM 文件称为变体。我们可能对同一个模块有很多变体。只有当哈希值匹配时,PCM 文件才能安全地在编译之间重用。混合哈希值非常危险,Clang 会阻止你,如果强行覆盖,有时能工作,但很多时候会导致崩溃或随机错误。

我们最初构建 Clang 模块的方式是隐式构建模块。在这种方式下,模块由每个编译器进程在需要时单独构建。如果另一个编译器进程需要同一个模块,它会阻塞等待该模块构建完成。如果模块已经构建好并存在于磁盘上,每个 Clang 进程都需要检查构建该模块的输入文件是否发生了更改。在大型项目中,跨翻译单元拥有不同的模块上下文哈希值很常见,因此最终会产生许多变体。这导致 PCM 重用率低和重复验证工作,从而拖慢构建速度。

我们有一些变通方法。构建太慢,所以隐式模块会“作弊”,认为那些不同的命令行选项可能不重要,因此我们使用了非严格的上下文哈希。这显著提高了隐式模块的重用频率,但牺牲了正确性,有时会导致编译器错误或崩溃,例如当构建的不同部分因头文件搜索路径不同而包含不同头文件时。

重复验证问题通过“构建会话”的概念得到部分解决。这是构建系统的一个微小改变,它可以告诉 Clang 在某个时间点之后,任何内容都已经被验证过了。

我们想出了一个更好的解决方案:显式构建模块。在这种方式下,构建系统在实际构建任何翻译单元之前,先扫描每个翻译单元以确定它需要哪些模块,然后提前构建这些模块。扫描器可以检测出不重要的配置差异,因为它实际上在查看代码。这允许我们在扫描时使用严格的哈希,但不必为那么多不同的变体付出代价。这基本上让我们修复了正确性问题。虽然 Clang 仍有 Bug,但这些随机的非确定性 Bug 现在几乎消失了。这种方式更精确,因此有时你会构建更多的模块,但那些模块是你之前侥幸成功但实际上需要的。

这让我们能够从隐式模块构建转变为显式构建,所有模块都预先构建好。由于我们的构建系统很智能,它可以调度任务并将它们打包在一起。

6:性能考量与最佳实践

目前,构建模块有很大的开销。这不是固有的,也不是必需的,可以修复。但这是当前的现状。大量的变体和小模块会显著降低扫描性能和构建性能。

有两种主要的修复方法。第一种是拥有全局唯一的配置。如果你能在整个构建中,尤其是在大规模构建中(例如编译 10 万个源文件),为 libclibc++ 使用一个配置,那么你只需要解析它们一次。如果能做到这一点,那就太好了。

第二种是合并头文件,无论是在有意义的地方,还是在不会产生循环的地方。本质上,你需要在单一巨型模块(基本上是 PCH)和每个头文件一个模块之间找到平衡。这两种极端效果都不好:每个头文件一个模块太慢,单一巨型模块在大型规模下存在逻辑问题。我们发现,每个库一个模块通常是最好的。有时库之间存在循环依赖,最好避免,但如果发生了,我们会拆分这些库以最小化地打破这些循环。

7:总结与要点

在本教程中,我们一起学习了 Clang Modules 在大规模项目中的应用、常见陷阱及解决方案。

主要要点如下:

  • 模块从根本上改变了预处理器的工作方式:这是我们反复强调的最重要的一点。模块作为独立翻译单元构建,不继承包含者的预处理器环境。开发者和代码编写者都需要理解这一点。
  • 头文件分层应在模块化之前完成:你需要在开始时就把这个做好,后期很难更改。
  • 模块化后的修复非常困难:这使得增量更改变得困难。你希望采用增量采用策略,例如在开发过程中逐步修复 SDK。同样,最好从一开始就做对。
  • 模块影响构建性能:你不能天真地直接启用模块并期望获得性能提升。你必须进行构建系统方面的工作。如果你没有单一的配置,还需要进行客户端工作以减少变体的数量。

021:显式构建模块

在本节课中,我们将要学习 Swift 如何处理模块,并深入了解其模块加载策略。我们将从 Swift 模块的基础概念讲起,分析传统隐式模块构建方式的挑战,最后介绍新的显式构建模块模型及其优势。

Swift 模块基础

Swift 是一门现代语言,其设计从一开始就是模块化的。模块是 Swift 的核心概念,它封装了一组源文件,构成了一个库。模块定义了它向客户端提供的 API 契约,并最终映射到二进制文件中的符号。

与 C 语言家族不同,Swift 的模块接口并非由开发者在文本头文件中手动编写。相反,它们通过使用访问控制关键字直接从源代码中捕获。例如,public 关键字定义了构成模块接口的所有声明、类型和函数。

为了让客户端能够使用模块并引用其中的声明,模块需要被编译成编译器可处理的二进制表示形式,即二进制 Swift 模块。这是一个 LLVM 比特码容器,捕获了所有相关声明的抽象语法树。在 Apple 平台的 Swift 5 中,用户可以选择构建弹性模块,它提供了 ABI 稳定性保证,确保库在演进时,新版本能与基于旧版本构建的客户端兼容。

为了实现跨编译器版本的兼容性,编译器会生成文本模块接口文件,其工作原理与二进制模块类似,同样使用访问控制机制来捕获相关声明。

与 C 语言的互操作性

Swift 从一开始就与 C 和 Objective-C 代码有深度互操作性,最近也扩展到了 C++。Swift 提供了一种机制,可以直接导入文本头文件及其中的所有内容。然而,要在大型混合语言 SDK 中实现大规模互操作,就需要模块化的头文件接口。为此,我们使用Clang 模块机制。

Clang 模块由一个模块映射文件定义,该文件指定了模块名称及其属性,以及构成该模块的头文件。Clang 模块被构建成二进制预编译模块文件

传统模块加载策略

我们可以通过一个小例子来了解 Swift 导入模块的所有可能方式。假设我们正在编译一个 Swift 程序,其源文件导入了模块 FooBarFoo 是一个位于编译搜索路径上的弹性 Swift 模块,Bar 是一个 Clang 模块。

当编译器遇到 import 语句时,它会发现该模块有一个文本接口。这个模块需要被构建成编译器可处理的格式,然后才能被主源文件编译器使用。对于 Bar 这样的 Clang 模块,我们还需要经过一个额外的步骤,即 Clang 导入器。这是 Swift 编译器的一个子系统,负责遍历 Clang 模块中的所有声明,并将其转换为 Swift 原生的格式。

这里的核心概念是,每个命名的模块都需要被编译成编译器可以实际使用的格式。

隐式构建的挑战

如果我们退一步,观察编译一个包含多个源文件、每个文件都有自己 import 语句的 Swift 目标时,这个过程会变得复杂。

Swift 编译模型将模块本身定义为一个编译单元。但为了提高多核 CPU 的利用率,我们实际上将模块拆分为按源文件进行的编译任务。如果你正在构建一个供客户端使用的库,还会有一个单独的任务来生成模块本身。

在这个例子中,一旦编译器遇到 import 语句,它就需要在文件系统的给定搜索路径集中查找该模块。假设其中一个任务遇到了对 Foo 的导入,它会启动一个编译子任务。这个子任务会获取自己的编译器进程和一部分编译上下文(如标志、目标三元组、-D 标志等),并启动一个新线程去编译那个模块。在编译线程完成之前,原始任务无法继续前进。

很可能在同一模块中,另一个源文件会有完全相同的模块依赖。当其他源文件遇到对 Foo 的导入时,我们不希望它也启动线程去构建同一个模块,以避免重复工作。因此,我们会在该模块上放置一个文件系统锁,让后续进程等待。同样,这些进程也无法继续前进,因为它们也需要 Foo 的内容。这个过程会不断重复,导致许多任务长时间等待。

FooBar 本身也是模块,并且它们也有自己的模块或头文件包含、import 语句时,情况会变得更加复杂。处理方式完全相同:编译 Foo 时遇到 import 语句,该编译实例会启动自己的子编译实例线程,放置文件系统锁,其他进程最终会等待它,依此类推。

在构建任何有意义的 Swift 程序时,例如 Apple 平台上的 macOS 或 iOS SDK,一个给定的编译任务拥有一个由数百个 Swift 和 Clang 模块组成的模块依赖图是很常见的。

这个过程虽然有效,并且服务了我们很多年,但在实践中也发现了一些缺点,特别是在扩展到我们想要构建的构建系统技术时。

以下是传统隐式构建模型的一些主要缺点:

  • 对构建系统不透明:构建系统创建一组任务来编译给定目标时,并不知道每个任务将进行多少工作、将产生多少线程,因此无法最佳利用机器资源。
  • 资源浪费:任务最终会等待文件系统锁,无法取得进展,同时却占用着宝贵的执行槽,这显然不是最优的。
  • 文件系统锁机制脆弱:当进程因某种原因挂起或遇到错误时,需要非常小心。
  • 嵌套编译上下文难以推理:这会导致用户难以调试的错误,对编译器工程师来说,在数十层深的编译线程中追踪问题根源也非常棘手。
  • 任务非隔离:构建系统和编译器都不知道这些任务的输入和输出文件是什么,这限制了优化和分发的能力。
  • 错误发现延迟:许多事情需要在编译过程中发生,我们需要沿着数十层深的子编译线程链,才能最终发现一个循环依赖。为了向用户提供有意义的诊断信息,我们需要做大量工作来重建导致该问题的所有事件链。

解决方案:显式构建模块

针对上述问题的解决方案是一种我们称之为显式构建模块的模型。其核心概念是将构建过程分解为三个阶段。

  1. 依赖扫描阶段:在执行任何编译之前,我们先进行依赖扫描。我们会找到 Swift 代码中的所有 import 语句,将它们解析为模块依赖,并传递性地解析这些模块的依赖。最终结果是得到一个依赖图。这个图包含了每个模块的信息:它是什么、由哪些文件组成,以及构建它所需的完整命令行配方。
  2. 模块构建阶段:有了依赖图后,编译器驱动程序或任何编排此过程的构建系统可以按照依赖顺序调度这些模块的构建。
  3. 源文件编译阶段:最后,重新编译我们的源文件。关键的是,这些源文件编译任务现在不会产生任何新线程,它们自己不会等待任何东西。所有的输入都被直接且明确地指定。

如果你没有编译那么多源文件,或者在 Swift 编译模型中,如果我们以全模块优化流程构建整个模块,你将只有一个编译任务。你仍然可以在构建依赖项时利用机器上的多个核心,而我们可以更好地调度这些任务。

显式构建模型的优势

与之前那些对开发者和用户都难以理解的、长期运行的任务集合相比,我们现在最终得到了一个更加连贯的任务图。

以下是显式构建模型的一些优势:

  • 解锁更多调度并行性机会:我们的依赖图可以包含数百个模块,而机器的核心越来越多。由于我们提前发现了更多并行工作,因此可以更好地利用这些核心。
  • 编译任务被隔离:所有任务的输入和输出都提前精确指定。这是一个非常有用的特性,特别是如果我们希望将来使它们可分发。
  • 极大简化了调试:这不仅对查看此过程的编译器工程师有益(他们现在可以查看特定任务,更精确地知道该任务在做什么以及可能出了什么问题),也对许多在办公桌前可能遇到各种模块化问题的用户有益,同时对构建工程师也很有价值,他们在构建 SDK 和确保其健康时经常需要处理各种模块化问题。能够拥有一个预先的依赖图以及对故障的精确归因是极其宝贵的。
  • 过程现在是确定性和可解释的:我们不再受限于等待哪个特定源文件首先遇到 import 语句并启动其编译,这种情况在连续编译同一内容时不一定发生。我们现在对编译模型有了更清晰的了解。

关键技术:依赖扫描器

这个新构建模型中最有趣的技术是依赖扫描器。Swift 依赖扫描器是一个供构建系统使用的库服务。它本质上是一个包装了整个编译器的库,其执行模式严格限定为:接收一个 import 语句,并将其解析为文件系统中的给定模块。

由于 Swift 直接与 Clang 模块互操作,Swift 依赖扫描器实际上完全包装了 Clang 依赖扫描器。该系统的一大优势是,Swift 源代码在导入模块时无需区分该模块是用 Swift 还是 Clang 编写的,这一切都是透明的。

依赖扫描服务本身由一组工作线程组成。当有许多不同的 import 语句需要解析时,我们可以尝试并行解析它们。

以下是扫描过程的核心步骤:

  1. 我们从一个源文件集合开始,每个文件都有自己的 import 语句集合。
  2. 对于每个 import 语句标识符,我们提出一个问题:我的搜索路径集合在文件系统中是否有这个模块?
  3. 如果有,很好,我们找到这个 Swift 模块,获取它的导入项,并将其添加到工作列表中。
  4. 如果没有,我们现在查询内置的 Clang 依赖扫描器。Clang 依赖扫描器的工作方式有所不同,它不能简单地给我们一个模块及其依赖项,它需要给我们所查询模块的完整依赖子图。原因是,你导入的 Clang 模块的依赖集和接口实际上可能受到其依赖项的影响,因此我们需要从 Clang 依赖扫描器获取整个子图。
  5. 如果第二个问题的答案也是“没有”,那么我们知道没有这样的模块,我们可以告诉用户检查他们的搜索路径。

Swift 依赖扫描器用于此的 API 实际上非常简单。Clang 模块在模块映射中命名。我们使用一个名为 moduleDependencies 的 API 来查询扫描器,它接收模块名称和一个定义编译环境的命令行,从而定义了包含目标三元组、所有宏定义等的模块上下文哈希。

性能优化:批量处理 Clang 模块

在将编译模型过渡到使用显式构建模块的过程中,我们学到的一个关键经验是:依赖扫描器的性能至关重要。我们通过拥有更丰富的前期模块依赖任务图解锁了更多并行性,但请记住,为了解锁这种并行性,我们首先需要运行这个计算图的瓶颈任务。

在研究此过程的性能特征时,我们发现 Swift 模块发现的成本相对非常低。原因之一是 Swift 从一开始就是完全模块化的语言,并且模块接口可以表达的内容类型也更具限制性。例如,文件系统中的 Swift 模块以模块名称命名。

相比之下,为了找到一个 Clang 模块标识符,你首先需要找到出现在搜索路径上的每一个模块映射文件,打开它,解析它,找到正确的标识符,然后去解析和预处理它的头文件,这最终会变得非常昂贵。

此外,为了计算构建给定 Clang 模块依赖项的配方,你需要计算其整个传递闭包图。因此,我们最近探索的一个见解是:利用 Clang 模块实际上不能依赖 Swift 模块这一事实,我们可以提前计算完整的 Swift 模块依赖图。所有未解析的节点都必须是 Clang 模块,然后我们可以尝试将它们作为一个大批量进行解析。

现在这个过程看起来是这样的:和之前一样,我们从源文件集合开始,它们有一组 import 语句标识符。我们将所有这些解析为 Swift 模块,这花费的时间相对微不足道。然后我们知道,图中每个未解析的节点都必须是 Clang 模块。我们可以收集它们,放入一个大的查询批次中,然后分派给 Clang 依赖扫描器并行执行这些查询。

问答环节

:当有修改需要重新编译时,你们会重建依赖图吗?是部分重建吗?

:不是从头开始重建。Clang 依赖扫描器在计算依赖图子集时,会序列化图的状态。因此,在增量构建中,当我们启动依赖扫描过程时,每当扫描器遇到一个查询(例如,我正在编译一个需要找到 Foo 的源文件,给定某个命令行调用),扫描器就能够重用之前计算的工作。我们不会重新计算图的部分内容,而是增量地重新计算整个图,但我们不需要做太多工作。我们只需要有适当的机制来进行所有必需的一致性检查:我序列化的内容是否是最新的?这个模块依赖是否发生了变化?如果没有,我们就可以重用;如果有,我们需要使该节点失效并进行一次全新的查询。因此,存在这种增量机制,它获取整个图,并使需要增量重新计算的部分失效。

总结

本节课中,我们一起学习了 Swift 的模块系统。我们从 Swift 模块的基础概念和与传统 C 语言模块的区别讲起,深入分析了传统隐式模块加载策略的工作原理及其在并行性、资源利用和可调试性方面面临的挑战。接着,我们介绍了显式构建模块这一解决方案,它将构建过程清晰地分为依赖扫描、模块构建和源文件编译三个阶段,从而带来了更高的并行性、任务隔离性、更简单的调试体验以及确定性的构建过程。最后,我们探讨了实现此模型的关键技术——依赖扫描器,以及通过批量处理 Clang 模块查询来优化性能的实践。显式构建模块模型为 Swift 构建系统带来了更强的可扩展性和可维护性。

022:提升优化代码行表质量

在本教程中,我们将探讨如何提升 LLVM 编译器生成的优化代码的调试信息质量。我们将聚焦于两个核心项目:一是自动化检测 LLVM 中的源码位置缺陷,二是改进“语句起点”的放置策略,以获得更流畅的交互式调试体验。第一个项目已基本完成,第二个项目仍在进行中。

行表简介

行表是调试信息的核心组件,用于在指令地址和源码位置之间建立双向映射。许多工具依赖行表,例如调试器。SPGO(采样分析引导优化)使用行表将程序执行描述为源码形式,以便编译器理解性能剖析数据。在 LLVM IR 中,我们使用 DILocation 元数据(有时也称为 DebugLoc)来追踪指令的源码位置。

项目一:自动化检测源码位置缺陷

上一节我们介绍了行表的基本概念。本节中,我们来看看 LLVM 优化过程中可能出现的源码位置“误归属”问题。

问题:源码位置误归属

有时,在代码转换过程中,调试位置未能正确更新。这被称为“误归属”,即指令被错误地赋予了不正确或缺失的源码位置。这会导致多种问题:

  • 缺失行号:发生崩溃时,堆栈跟踪可能无法提供精确的行号。
  • 错误的崩溃行号:提供错误的源码位置。
  • 额外覆盖:指令被移出其原始基本块但仍保留原位置,可能导致调试器错误显示分支路径。
  • 步入不可达代码:调试器可能引导用户步入实际上不会执行的代码。

现有工具与局限

我们有一个名为 debugify 的工具,它可以统计每个优化过程中丢失的调试位置数量并生成报告。但其主要问题是会产生误报,因为它无法区分编译器有意丢弃调试位置和真正的缺陷。

解决方案:增强意图表达

我们的解决方案很简单:要求优化通道的作者在有意丢弃调试位置时,通过一个新的 API 来明确声明其意图。例如,将默认的 DebugLoc() 构造函数调用替换为一个特定的函数。

// 旧方式(可能无意丢弃)
setDebugLoc(DebugLoc());
// 新方式(明确声明有意丢弃)
setDebugLoc(DebugLoc::getDiscard());

debugify 工具可以利用这个额外信息来区分意外丢弃和有意丢弃。重要的是,为了编译器性能,这个新 API 默认是禁用的,需要通过编译标志手动启用。

增强的报告功能

由于这是一个可选功能,我们可以更深入地增强元数据。我们可以在报告中嵌入堆栈跟踪,精确指出缺陷在 LLVM 中的起源位置。更进一步,我们可以提供一个 opt-bisect-limit 编号,方便开发者创建可复现的测试用例,使得修复问题变得相对容易。

项目一小结

误归属会导致 SPGO 和调试错误。我们扩展了 debugify 工具,使其能够无误报地定位调试位置丢失的问题,并精确指出问题根源。这只需要通道作者使用新 API 编码少量额外信息。我们目前正在修复已发现的问题,并计划将其集成到构建机器人中,以自动检测未来补丁中的问题。

项目二:减少优化代码调试的跳跃性

现在转换话题,谈谈第二个项目:如何减少调试优化代码时的跳跃感,提升体验。

问题:当前 is_stmt 放置策略

is_stmt 是行表条目中的一个标志,用于向调试器指示某个指令是一个推荐的断点位置。目前 LLVM 的 is_stmt 放置策略非常简单:在生成目标代码时,如果当前指令的行号与上一条指令不同,就设置 is_stmt。这虽然有效,但会导致调试步进时出现过多的跳跃行为。核心问题在于,LLVM 将源码归属调试步进这两个概念混为一谈了。

解决方案:关键指令

好消息是,前人已经研究过这个问题。我们的方法受到了 Caroline Tice 等人关于“关键指令”工作的启发。核心思想是:源码由有趣的“原子”操作组成,如控制流、赋值、函数调用等。每个原子操作通常有一条指令来实现其核心功能,从调试者视角看,这条指令触发或完成了该操作。那么,我们为何不只为这些“关键指令”设置 is_stmt 呢?这样,重排非关键指令就不会影响调试体验。

原型实现

我们有一个原型实现。我们在 DILocation 元数据中增加了两个字段:atom_groupatom_rank

  • 具有相同 atom_group 编号的指令属于同一个源码原子操作。
  • 通常,只有组内 atom_rank 最低的最终指令会获得 is_stmt 标志。

在优化通道运行之前,我们使用一个预置通道,通过启发式方法将这些新元数据应用到指令上。大多数优化通道无需做任何更改。最后,在生成 DWARF 信息时,我们使用新元数据来决定 is_stmt 的放置。

成本与要求

编译时间成本:原型目前导致带有调试信息的 LTO Release 构建的编译时间增加约 0.6%,我们认为考虑到对调试的改进,这是合理的。对于 O0 构建,开销略高(约 1.6%)。

对通道作者的要求:同样,我们需要通道作者在特定转换中编码更多信息。主要涉及那些会复制控制流的转换(例如循环展开)。默认情况下,克隆指令会复制其调试位置(包括 atom_group),这会导致所有克隆实例属于同一组,可能只有一个获得 is_stmt。为了让调试器能在每个实例上步进,我们需要重新映射组编号,使每个实例处于不同的组中。这可以通过一个新的 API(ValueMap 类)来实现。由于大部分逻辑可以放在公共辅助函数中,实际需要特殊处理的代码点并不多。

评估与风险

理想的断点位置可能带有主观性。我们未能找到一个满意的量化指标,但发现了一种良好的可视化定性评估方法:绘制调试步进轨迹图。

通过比较 O0、O2 以及启用关键指令的 O2 构建的步进轨迹,我们可以直观地看到改进。在许多案例中,启用关键指令后,O2 构建的步进轨迹变得与 O0 构建非常相似,跳跃性大大减少。

主要风险:我们可能意外丢弃了本应保留的断点位置。好消息是,源码归属不受影响,因此崩溃分析和 SPGO 不受影响。风险可以通过策略缓解,例如原型目前无条件地对所有函数调用应用 is_stmt,我们可以对其他必要指令采取类似措施。我们认为,步进体验的整体提升值得承担这个相对较小的风险。

项目二小结

我们可以通过更智能的 is_stmt 放置策略,减少由指令调度、代码移动等优化引入的步进熵。这只需要 LLVM 社区中的通道作者编码更多关于优化行为的意图信息。目前存在一定的编译时间成本,前端可能也需要一些工作(详见 RFC)。

总结

在本教程中,我们一起学习了如何通过改进源码归属和调试步进策略(并将两者分离),来提供更高质量的优化代码行表。我们介绍了两个相关的项目:

  1. 自动化检测源码位置缺陷:通过增强 debugify 工具和引入新的 API,无误报地定位并修复调试信息丢失问题。
  2. 基于关键指令改进步进体验:通过识别并只为“关键指令”设置断点标志,显著减少调试优化代码时的跳跃性,使体验更接近 O0 构建。

这两个项目都需要优化通道作者调用新的 API 来编码更多关于转换过程的信息。这些改进将共同提升 LLVM 在提供高质量调试信息方面的能力。

023:为你的 ELF 平台添加指针认证 ABI 支持

概述

在本节课中,我们将学习如何为基于 ELF 的平台添加指针认证 ABI 支持。我们将从理解指针认证的基本概念开始,探讨其工作原理、ABI 规范,并逐步介绍在 LLVM 中实现此功能所需的步骤和组件。


指针认证简介

指针认证是一种安全缓解技术。为了理解其作用,我们先来看看代码指针在程序中的常规流程。

通常,代码指针从只读内存中加载到寄存器或临时存储中。随后,指针可能被溢出到可读写内存,最终再被重新加载回寄存器,用于执行间接调用、间接跳转或在少数情况下进行数据访问。

现在,假设攻击者能够修改内存,即拥有内存写入权限。那么,攻击者就可以在可读写内存中替换指针值,从而导致调用链被破坏,最终执行恶意代码或从不适当的位置访问数据。

指针认证试图解决这个问题。其核心思想是,代码指针永远不会以明文形式存储在可读写内存中。相反,当指针位于寄存器中时,会为其附加一个类似密码学签名的值。我们假设攻击者无法直接修改寄存器中的内容。

之后,带有签名的指针被溢出到内存。当它被重新加载后,会执行一个特殊的验证步骤,以确认指针是否完好无损。如果验证失败,则可以推断指针被篡改,并采取必要的处理措施。

指针认证的工作原理

指针认证的实现依赖于几个关键组件。

首先,指令集依赖于一组不同的密钥。此外,还有一个额外的修饰符值,可用于根据某种上下文改变签名,稍后会详细说明。

指针认证指令会结合所有这些信息(指针、密钥、修饰符)来计算签名,并将其附加到指针本身。最终,我们得到一个带有附加签名的指针。

这之所以可行,是因为在许多平台(例如我们讨论的 AArch64)上,地址的高位通常未被使用。我们可以利用这些高位来存放签名。

指针认证是指令集扩展,最初出现在 Armv8.3-A 中,并在后续架构版本中有一些增补。原始版本大约包含 150 条指令,其中一些在 HINT 空间,这意味着它们是对旧指令的提示,可以以基本兼容的方式在旧款核心上使用;但大多数是全新的指令。

然而,我们现在通常不再直接使用汇编编程。因此,问题在于如何将这些底层指令与高级语言(如 C++)联系起来。

答案在于具体的实现细节。我们需要考虑代码是如何被编译和降级的。以 C++ 为例,我们需要考虑哪些代码指针值得保护。在我们的案例中,有三种不同的指针可能需要保护:虚函数表指针、指向虚函数的指针,以及 C++ 成员函数指针。

那么,我们如何知道该保护哪些指针,以及如何操作呢?

答案是 指针认证 C++ ABI 规范。该规范大约在五年前由 Ahmed 和 John 提出。它规定了哪些指针应该被保护。实际上,需要保护的间接跳转/调用和代码指针集合是相对有限的,通常包括:函数指针、switch 语句、符号导入(如 C++ 虚函数)、C++ 成员指针等。此外,还有一些特殊情况,例如执行静态对象构造的函数的指针、某些平台上的 compute goto 扩展、Objective-C 中的块扩展等。

ABI 规范还定义了签名方案,指定了应使用哪个特定密钥以及如何计算附加的修饰符。指令集允许使用最多 64 位的修饰符。

ABI 本质上提供了两种所谓的“多样性”方案。

一种方案是使用指针本身的存储地址作为修饰符。这当然会带来额外的复杂性,因为指针的复制肯定需要重新签名(即先验证,再重新签名)。这可能与许多场景不兼容,例如要求指针必须是“可平凡复制”的情况。

另一种方案是使用所谓的“语义多样性”,即利用指针类型或数据的语义作为修饰符。例如,我们可以为虚函数、C++ 成员函数指针和普通 C 风格函数指针使用不同的修饰符。这些修饰符可以源自不同的源头,从而将签名划分到不同的“命名空间”中,使得一个命名空间的指针无法被替换为另一个命名空间的指针。

另一个优点是,地址的高 16 位通常是保留的,可用于内存标记扩展或其他用途。从我们的角度来看,这意味着我们甚至可以结合地址多样性和语义多样性。我们可以将修饰符的高 16 位用于语义信息,从而尝试同时利用两种多样性。

Arm 平台的现状

如前所述,直到最近,指针认证 C++ ABI 仅在 Apple 平台及其下游的 Clang 分支中可用。

首先,存在一份针对 Arm 平台的指针认证 ABI 规范,由 Arm 公司开发。该规范目前处于 Alpha 状态,意味着可能会发生变化,但预计不会有重大改动。该规范定义了一组 Arm 特定的重定位和重定位操作,并规定了如何标记 ELF 对象文件以确保不混合不兼容的内容。此外,它还规定了一些平台决策,例如是否对全局偏移表(GOT)和过程链接表(PLT)进行签名。

LLVM 19 中的进展

在 LLVM 19 中,我们取得了以下进展:

首先,大部分必需的前端补丁已从 Apple 下游分支移植过来。感谢所有为此做出贡献的人。

同时,必需的代码生成支持也从 Apple 分支移植过来。我们为 LLD 提供了特定的指针认证代码生成支持,并对 Linux 内核和对象文件支持进行了修改。

我们还为 AArch64 Linux 添加了一个实验性的测试 ABI。简而言之,我们尝试了许多其他功能,它们在与指针认证一起启用时都能正常工作。

需要说明的是,我们有一个名为 pcs-test 的测试 ABI。之所以称为“测试”ABI,是因为我们目前无权为 Linux 或其他 ELF 平台正式定义指针认证 ABI。这个测试 ABI 主要遵循 Arm 64e(即 Apple 平台)的签名方案。可以通过 -mabi=pcs-test 开关和目标三元组中的环境字段来启用它,这与 Arm 上处理 -mfloat-abi=hard 的方式非常相似。

此外,我们添加了一个所谓的“测试”供应商以确保 ELF 标记。同样,我们无权定义新的供应商。我们没有编码版本号,而是编码了整个签名方案的位置。这样,在测试过程中,如果有任何 ABI 不匹配,就能更容易地发现差异。

当然,使用指针认证需要配套的支持指针认证的标准库。

目前还存在一些已知问题:少数边界情况仍然不能完全正常工作,通常围绕 noexcept 相关的问题。另外,对于 compute goto 中一些不支持的情况,我们缺少诊断信息。不过,代码会直接崩溃,这至少让我们知道出了问题。

此外,在混合使用弱指针时,安全性可能并不完美,跨异常边界时可能会包含一些侧信道漏洞。

如果你想为你的平台启用指针认证支持,目前这个过程仍然比较手动,我们还没有像 sanitizer 那样提供方便的钩子。

LLVM 19 版本仍然缺少一些内容:有几项关于 compute goto 和 TLS 支持的拉取请求正在审核中;还有一份关于特殊 ptrauth 限定符的开放 RFC,如果你想手动使用指针认证的指针,这个限定符会让事情变得容易得多。

在优化和松弛方面还有很多工作可以做。Apple 下游分支中存在一些补丁,应该可以移植到 LLVM 主线。代码也需要一些重构和清理,因为路径功能存在一些差异。此外,我们需要确保所有指针认证相关的安全功能能很好地协同工作。当然,文档也仍然缺失。

如何为新平台添加指针认证支持

如果你想为新平台添加指针认证支持,应该怎么做?

首先,最重要的是要认识到指针认证是一个 ABI。这意味着你不能安全地混合使用不同 ABI 的代码。

如果我们尝试界定所有情况,指针认证可以作为默认平台 ABI 部署在裸机平台上,也可以作为子集部署在常规平台上,用于隔离的操作系统内核或某些安全关键进程。

如果你想为平台部署指针认证,有四个组件需要实现:

  1. 内核支持:内核必须支持这组操作,包括密钥分配(共有五组密钥,其中一些是进程相关的)、确保在上下文切换时保留密钥、处理相关寄存器的假设。关于 fork 操作,在某些情况下,你可能希望密钥被继承。如果你运行在 Linux 上或平台已有支持,这部分通常已经完成。

  2. 签名方案与实现:这是核心的构建模块。当前可用的方案允许许多不同的变体和自定义选项,例如鉴别器的使用、对哪些内容进行签名等。重要的是,每一种变体实际上都定义了一个全新的、不能混合使用的 ABI。在部署时,你可以从默认集合开始,类似于 Arm 64e 或 pcs-test 中的设置。你需要决定是否使用函数指针鉴别器,以及是否向用户暴露所有选项,还是只坚持某个默认集合。

  3. 多样性与标记:ELF 标准规定使用 GNU_PROPERTY 笔记来标记对象文件。你需要定义如何指定和编码平台版本。你可能还想在该版本中编码一些额外的元数据。请注意,静态链接和动态加载都需要检查 ABI 兼容性。静态链接器(如 LLD 或 gold)已经支持,但动态加载器需要你自己实现。

  4. 库与运行时支持:编译器运行时(如 libc++、libc++abi、compiler-rt)应该可以直接工作。你需要在动态链接器中添加对指针认证重定位的支持,决定如何处理共享库(例如,GOT 和 PLT 是只读还是可读写),并在 C 标准库中添加一些支持(例如,调用静态构造函数的代码)。如果你有像 jumpsignal 这样的扩展库,也需要进行相应处理。

我们有一个针对所有这些组件的概念验证实现,可以作为基础来适应你的需求。如果你有完全自定义的标准库,则需要自行实现。

最后,测试有些非平凡。仅仅匹配预期的代码模式并假设安全性足够是不够的。我们目前正在开发一个所谓的“崩溃测试套件”。该测试套件尝试在不同的上下文中执行指针替换。如果指针认证有效,替换将失败,测试会崩溃;否则,替换会成功,测试不会崩溃,从而表明某个特定组件未被指针认证保护或工作不正常。这项工作仍在进行中,我们希望很快能将其开源。此外,我们可能还需要决定是否跟进其他工具,例如用于检查整个指针认证 C++ ABI 的指针检查器和扩展。

总结

本节课我们一起学习了指针认证的基本原理、其在 Arm 平台的规范现状,以及在 LLVM 中实现的进展。我们还详细探讨了为一个新的 ELF 平台添加指针认证 ABI 支持所需的关键步骤和组件,包括内核支持、签名方案定义、ELF 标记以及库和运行时支持。虽然已有基础实现和测试框架,但完全集成和优化仍需努力。希望本教程能为你理解和实现指针认证提供清晰的指引。

024:在C和C++中缓解释放后使用安全漏洞

概述

在本节课中,我们将学习一种名为“类型隔离分配器”的技术,用于缓解C和C++程序中一种常见且危害性高的内存安全漏洞——“释放后使用”。我们将探讨该漏洞的原理、现有缓解措施的局限性,并详细介绍一种通过编译器自动将无类型内存操作转换为类型化操作的新方法。

什么是释放后使用漏洞?

上一节我们介绍了课程主题。本节中,我们来看看“释放后使用”漏洞的具体含义。

释放后使用是一种特定类别的内存安全漏洞,其可利用性非常高。由于C和C++语言本身的特性,在代码中静态地预防此类漏洞非常困难。

其根本问题在于,程序使用了一块已经被释放,并且可能已被重新分配用于其他目的的内存。这为攻击者创造了一个高度可利用的原始条件,因为它可能导致后续的类型混淆、堆内存损坏以及程序数据的破坏。

攻击者可以利用此漏洞,控制那些本应被视为可信或包含特定信息的程序数据。这可能导致远程代码执行、权限提升或程序状态的全面破坏。

类型隔离分配器:一种缓解思路

理解了漏洞的危害后,本节我们来看看一种有效的缓解措施:类型隔离分配器。

释放后利用漏洞的核心可利用点在于后续的类型混淆。因此,如果我们能保证地址空间中的特定内存地址只用于分配特定类型或特定类型子集的对象,就能显著削弱此漏洞的威力。

这意味着我们可以防止攻击者混淆指针与用户数据、指针与控制数据等。这种防御机制在多个安全敏感环境中被证明是有效的。

以下是其核心优势:

  • 防止不同类型对象之间的内存复用。
  • 降低攻击者通过类型混淆实现利用的可能性。
  • 已在iOS/Mac的XNU内核、现代浏览器(如使用PartitionAlloc的Chrome)等场景中广泛部署。

挑战:无类型API与手动适配成本

虽然类型隔离分配器效果显著,但在实践中推广却面临巨大挑战。本节我们来分析这些挑战。

主要问题在于手动适配类型隔离分配器的成本非常高。它需要大量的代码注解和对现有代码的调整。

根本原因在于,分配器需要知道“类型”信息才能进行隔离。然而,绝大多数现有的C/C++内存分配API都是无类型的。例如,mallocoperator new只接受一个字节数参数,并不指明这些字节用于何种类型。

此外,C语言家族在语言层面缺乏可作为运行时值使用的“类型”概念。因此,绝大多数现有代码都使用完全无类型的API进行编写。

解决方案:类型化内存操作注解

面对手动适配的难题,我们引入了一种新的解决方案。本节将介绍其核心概念:类型化内存操作注解。

这是一种库作者可以使用的注解,用于标记某个分配API有一个对应的类型化版本可供使用。然后,Clang编译器将承担起将代码迁移到该类型化版本的重任。

这个过程是自动的,作为代码生成的一部分,无需手动重写或更新仓库中的源代码。

注解允许库作者指定其类型化入口点,并指明哪个参数将用于推断类型信息。编译器将对该参数进行类型推断,并随后调用对应的类型化API。

目前,我们已将此功能作为RFC提交到llvm.org,正在收集反馈。

实现模型与类型描述符

了解了解决方案的思路后,本节我们深入其实现模型,并介绍一个关键组件:类型描述符。

从注解的角度看,这个模型非常轻量。库作者只需在声明类型化入口点后,为其分配API添加属性,指明类型入口点和用于推断的参数。

例如,对于malloc这样简单的单参数API,注解非常直接。对于更复杂的API,注解同样适用。

在编译期间,Clang会查看调用点,分析你指定用于类型推断的表达式,然后简单地用对类型化入口点的调用来替换原始调用,并传入一个类型。

当然,我们需要一种方式来表示这个“类型”。考虑到系统级分配器API对代码体积有严格限制,并且我们推断出的类型可能在C/C++层面并不存在(例如组合了多种类型的单次分配),我们设计了一种紧凑的类型表示法:类型描述符。

类型描述符是一个64位的值,它并非指针,因此其开销仅相当于在调用点传递一个64位参数。我们在这个值中保留了一些位来提供类型的语义重要信息,其余部分则用于类型的唯一标识。

目前我们提供的语义信息包括:

  • 是否为多态类型(含有虚函数表)。
  • 是否为联合体(union)。
  • 是否包含指针。
  • 是否包含函数指针。
  • 调用点信息(如是否为固定大小分配、是否为数组分配等)。

通过提供这些语义信息,我们允许分配器采取更细致的策略来隔离类型,而不是简单地为每个唯一类型分配独立内存,后者会带来巨大的内存开销。

类型标识符与自动类型推断

仅有语义信息还不够,我们还需要一个唯一的类型标识符。本节我们来探讨如何生成这个标识符,以及编译器如何自动推断类型。

类型标识符是类型的一个结构哈希值。我们并不关心类型的名称或C语言层面的标识,而是将类型线性化(类似于结构体布局算法的产物),枚举类型中的每个字节并记录其数据类型(如是否为指针)。最后将所有信息哈希到我们可用的空间内。

这个标识符源自我们几年前为支持XNU内核类型模型所做的工作,现在为了更广泛的适用性而设计得更加紧凑。

接下来是实现自动适配承诺的关键:类型推断。我们知道存在一些标准的内存分配模式。编译器会尝试匹配这些模式,以推断出在特定位置分配的是什么类型。

以下是我们匹配的部分模式示例:

  • malloc(sizeof(T))
  • calloc(num, sizeof(T))
  • realloc(ptr, sizeof(T))
  • aligned_alloc(align, sizeof(T))
  • malloc(sizeof(T) + sizeof(U)) (此例表明我们可能推断出在C层面不存在的类型)

当然,这种推断可能失败。C语言的表达能力太强,有些惯用法会阻碍推断,例如:

  • malloc的包装函数。
  • 因代码风格指南(如限制表达式长度)而拆分的表达式。
  • 出于性能考虑进行的非常局部的、不涉及流程控制的代码变换。

对于包装函数的情况,我们提供了一个内置函数__builtin_tmmo_get_type_descriptor,你可以传入一个表达式,它会生成与推断结果相同的类型描述符。这能很好地与源代码宏配合工作。

C++环境的特殊考量与提案

许多开发者更关注C++。本节我们探讨C++环境下的特殊情况和相关语言标准提案。

C++的内存分配(如operator newoperator delete)在语义上对编译器是可见的。然而,不幸的是,这种可见性仅对编译器和用户可见,对分配器并不可见。

为此,Louis Dionne和我已向C++标准委员会提交了提案P2719。该提案旨在语言层面为分配函数提供类型感知能力。

它增强了operator newoperator delete的表达能力,使其能以代码可见的方式指明正在分配或删除的类型。这是一个相当自解释的模型。其中type_identity_t标志用于区分现有代码和新的类型化分配器,它是一个零大小的空结构体,在现代ABI中成本为零。

如果你在希望使用类型化内存操作的环境中,你可能会关心C++模型如何与TMO模型交互。这很简单,你可以直接使用我们提供的__builtin_tmmo_get_type_descriptor内置函数来获取类型描述符,两者在语义和实践中几乎完全相同。

总结

本节课中我们一起学习了如何缓解C/C++中的释放后使用漏洞。

我们介绍了一种注解属性,它允许你将无类型的API注解为可自动适配的类型化版本。该注解使得Clang编译器能够自动将调用从无类型API重定向到类型化版本。

它提供了足够的信息,让分配器能够做出有意义的安全和策略决策,并且这些决策具有可调整的、实际有效的性能指标。

我们已为此提交了LLVM RFC提案,并根据反馈讨论增加额外模式(而不仅仅是特定的类型描述符模型)的方法。

我们还简要介绍了C++标准提案P2719,它为这一问题提供了更符合标准、更根本的解决方案。

通过本节课的学习,你应该对释放后使用漏洞的缓解,特别是通过编译器辅助实现类型隔离分配器自动适配的技术路径,有了清晰的认识。

025:从构建Mojo优化流水线中学到的经验

概述

在本节课中,我们将学习Mojo编程语言编译器优化流水线的构建经验。我们将首先了解Mojo语言及其编译器架构,然后深入探讨其优化流水线的两个核心部分:MLIR部分和LLVM部分,并分享在构建这些部分过程中获得的宝贵经验。

Mojo语言简介

Mojo是一种新的、具有Python风格的系统编程语言,于去年年初首次发布,目前仍在Modular公司积极开发中。

Mojo具有Python式的语言美学,并拥有许多与Python兼容的特性。

除了这些,Mojo还拥有一些“超能力”特性,例如广泛的泛型编程能力、管道系统、安全高效的内存模型,以及提供类似C语言级别的程序性能控制能力。

Mojo还支持异构编程模型,允许开发者使用同步编程方式同时编写CPU和GPU代码。

Mojo编译器架构概览

上一节我们介绍了Mojo语言,本节中我们来看看Mojo编译器的整体架构。

Mojo编译器基于MLIR框架构建,并使用LLVM作为后端。

以下是编译过程的一般流程:

Mojo程序(.mojo文件)首先经过Mojo前端处理,然后进入参数化领域(IR),此时代码仍然是泛型的。

我们会将一些库代码打包成Mojo包,供其他程序后续使用,从而避免重复进行前端处理。

我们会在泛型领域进行一些优化,然后进入“具体化”阶段。

具体化是Mojo特有的一个术语,与C++模板实例化非常相似。

我们会运行一系列解释器,尝试将IR具体化。

具体化之后,我们会得到具体化的IR,并进行更多优化,然后将Mojo方言转换为LLVM方言。

最后,我们使用LLVM基础设施生成最终的二进制目标代码。

我们还会以一些非常规的方式使用LLVM,我将在后续内容中详细展开。

Mojo还采用库驱动的编译模型来支持异构平台。

对于GPU代码,开发者通常会编写一些主机(CPU)代码和GPU部分代码,可能还有其他主机代码。

在Mojo中,GPU部分是一个参数化函数,因此与GPU相关的编译发生在具体化阶段。

当我们尝试具体化GPU代码时,我们会为GPU运行一个协同编译器。

这部分与主流程非常相似,我只是简要提及,以便在幻灯片上展示。

然后,我们生成PTX代码作为具体化阶段的结果,并将其插回主机代码的具体化流程中,继续向下编译。

编译器目标与初始挑战

与大多数编译器一样,Mojo编译器的目标是实现快速的编译时间,并生成高性能的代码。

然而,在最初实现编译器时,我们发现LLVM部分在整体Mojo编译时间中占据了非常长的时间,大约为60%到80%。

从时间追踪数据中可以看到,O0优化级别的编译时间比O3高出一个数量级。

这有点违反直觉,因为O0运行的优化更少,理论上应该生成性能较差的代码,但编译时间却更长,生成的代码也更差。这并不理想。

问题根源:泛型编程与IR膨胀

我们发现问题首先在于Mojo拥有非常广泛的泛型编程支持。

这导致具体化后的IR规模变得非常大,出现了代码规模爆炸的情况。

特别是对于O0和O3,O0的IR规模实际上比O3大得多。

从提供的数据可以看出,在LLVM优化之前,O0的IR规模大约是O3的10倍。经过LLVM优化后,有些情况下差异甚至达到100倍。

对于O0,当IR规模非常大时,会给整个编译流水线带来巨大压力,包括MLIR部分和LLVM部分。

MLIR层面的优化策略

基于以上观察,我们在MLIR层面(因为我们对这部分有完全的控制权)尝试替换一些优化,并努力在每一步都严格控制IR规模,避免将大量IR送入流水线并使其执行大量工作。

我们在MLIR层面实现了一系列优化过程,包括稀疏条件常量传播和循环展开等简化操作。

这些优化与LLVM中的类似,但我们将其提前到MLIR层面,这样我们可以在进行所有性能相关优化的同时,控制IR规模。

我们还在MLIR层面实现了一些功能特定的优化,例如针对闭包和协程的优化。

当然,我们也运行了一些基础优化过程,包括规范化、公共子表达式消除和死代码消除等。

在MLIR层面实现优化的优势

除了在MLIR层面控制IR规模外,在MLIR层面实现一些优化也非常有益,因为我们在MLIR层面拥有更高级的IR表示。

例如,我们有协程的IR表示,如栈分配操作和可变参数包的操作。

这些表示对程序有更多意义,可以帮助我们简化优化过程的实现。

我们还拥有基于区域的结构化控制流表示。

这与我们在LLVM IR或通用MLIR中通常见到的通用控制流图不同,后者在基本块中是直线代码,仅在基本块末尾有分支。

我们拥有这种结构化的控制流表示,可以看到其中有循环、条件判断,并且可以在区域中间进行提前退出,从而更好地匹配程序逻辑的高级表示。

这实际上在我们实现数据流分析(如稀疏条件常量传播和活跃变量分析)时,确实有助于保证一些最佳情况。

并行化努力

在尝试实现MLIR层面的优化过程时,我们还非常注重并行化,这主要是为了缩短编译时间。

首先,MLIR框架天然支持函数级优化过程的并行化。

我们只需要确保在创建Pass管理器时,相应地创建Pass,以便自动利用框架提供的并行化能力。

当我们实现那些过程内优化(如模块级Pass)时,我们也尝试注入过程内并行化。

例如,对于我们在MLIR层面实现的函数内联器,我们努力实现Pass内部的并行化。

当我们内联调用图时,我们希望自底向上进行,并且希望内联每个函数的调用。

我们为每个函数创建异步的并行任务,并根据函数间的调用图依赖关系设置任务间的依赖。

如果函数间存在调用路径,它们必须等待彼此完成。但如果函数间没有直接路径,它们实际上可以并行运行。

在这个非常简单的调用图示例中,F1和F2可以并行运行,而F3和F4也可以并行运行。

我们还尝试将类似的方法应用于具体化部分,真正提升所有MLIR层面优化过程中的并行化水平。

这确实帮助我们改善了编译时间,同时没有损失太多生成代码的性能,因为我们也在进行内存数据流分析等优化。

更重要的是,它有助于控制IR规模。

LLVM流水线策略

现在,让我们来看看LLVM流水线。我们拥有MLIR部分,但我们不想重新实现一切。我们仍然希望利用LLVM流水线的强大功能。

LLVM在标量优化、加载存储优化以及目标特定代码生成方面非常出色。

我们不必重新发明轮子,我们仍然希望保留这些功能。

然而,LLVM并非完美无缺。例如,它的循环优化较弱且不可预测,包含大量启发式方法。优化可能发生,也可能不发生。从程序的角度来看,开发者对此没有太多控制权。

更重要的是,LLVM是单线程的,不支持并行化,因此很难利用现代机器中的多处理器能力。

简化LLVM流水线

在构建LLVM流水线时,我们尝试真正简化它,让LLVM只做重要的工作,减少工作量。

因此,我们移除了循环向量化器和循环展开器,并将这些功能上移到Mojo库层面,让开发者在编写程序时控制它们。

我们禁用了与协程相关的Pass,因为我们可以在MLIR层面实现协程等功能。

我们还尝试禁用所有过程间优化Pass,以便可以创建函数流水线并并行运行它们。

我在这里打了一个问号,因为这并不是实际发生的情况。我们尝试实现它,然后意识到内联对于生成代码的性能实际上非常关键。

内联的重要性

我们移除了LLVM的内联器和所有IPO Pass,然后发现对于运行的一些基准模型,在没有内联器的情况下,性能下降了约2倍。

实际上,我们可以通过在MLIR层面采用非常激进的内联策略来恢复性能。

我们确实有更高级别的内联器。如果我们变得非常激进,我们可以恢复性能。

但这是有代价的。正如这里所示,采用激进的内联策略时,CPU利用率更高,因为发生了更多的并行化。

但内存使用也增加了,因为代码规模更大,总体编译时间大致相同。

所以,认为我们可以直接摆脱LLVM内联器并用MLIR层面的工作替代它,可能有点天真。

这里的结论是,在我们能够在MLIR层面构建一个同等复杂的内联器之前,我们仍然需要LLVM内联器。

两级并行化拆分策略

基于以上考虑,我们仍然希望并行化流水线。

因此,我们选择了一种两级并行化拆分策略来并行化流水线。

基本上,我们尝试将模块拆分为多个子模块,并尝试在这些子模块上并行运行整个流水线。这就像将C++文件拆分为多个文件并并行编译它们。

我们进行两级拆分。第一级是将优化模块拆分为子图,每个子图将保留完整的函数调用图。这样,在运行优化时,我们仍然可以运行内联器,并且它们可以相互看到。我们在该级别保持优化。

一旦我们优化了所有内容,我们将子图进一步拆分为函数。

然后,我们为每个函数并行运行代码生成部分。这就像进一步的并行化。

最后,我们将输出合并在一起。

并行化示例

作为一个示例,我们输入一个LLVM模块,其中包含两个函数foobar,它们都调用了gnu

这是一个非常简单的例子,在实际情况下,gnu很可能被优化掉。但作为一个示例,我将展示它会保留在那里。

第一步是将LLVM模块拆分为子图。这里我们有两个子图foobar,我们保留了完整的调用栈,所以这里有gnu

这里有一个小问题,因为存在重复的代码,gnu被复制了。

然后,对于子图,我们运行优化并获得优化的模块。

接着,我们将优化的LLVM模块进一步按函数拆分。现在,我们分别有foognubar在单独的模块中,我们可以用代码生成器运行这些模块。

然而,重复的代码仍然在这里。对于foobar调用gnu,我们还必须将符号链接类型从内部更改为弱链接,以便链接器能够优化掉重复的函数,否则调用会失败。

这并不理想,因为我们有重复的代码,并且必须更改符号链接类型。

非常规的链接方法

因此,我们实际上增加了一个额外的层,我们在这里做了一些非常规的事情。

首先,当我们运行代码生成部分(类似于llc)时,我们运行完整的代码生成,进行指令选择等工作,但在每个拆分的ASM打印之前停止。

然后,我们构建一个叫做“MC链接器”的东西,它将在内存中链接代码生成结果。

它将执行一系列归约工作,将所有常量池、IR和全局MC符号放入一个MC上下文中。

然后,我们只运行一次ASM打印,为链接结果生成一个输出文件,这样我们就可以消除重复的函数,并修复符号链接类型。

现在,我们有一个LLVM模块作为输入,仍然只有一个.o文件作为输出。

这是一种非常规的使用方式。我们很乐意将其上游化,因为我们也遇到了一些基础设施问题。

我相信,如果我们能将其上游化,社区中的其他人也可以使用它,并使这部分在代码树中更加稳定。

使用MC链接器的效果

使用这个特殊的MC链接器,正如你所看到的,对于生成的目标代码,我们不再有重复。

gnu实际上消失了,因为它们是内部函数。你现在不应该在输出目标文件的符号表中看到它。所以一切都被修复了,很好。

并行化LLVM时遇到的基础设施挑战

在尝试并行化LLVM时,我们确实遇到了一系列基础设施挑战。

首先,因为LLVM不是线程安全的。对于优化和代码生成所需的LLVM上下文和MC上下文,我们必须确保在并行运行编译时,每个拆分都有单独的副本。

我们还必须确保IR以高效且安全的方式移入这些上下文。

因此,我们实际上使用比特码来序列化和反序列化这些IR,以便在不同上下文之间迁移。

我们还遇到LLVM比特码的一些低效问题,例如,字符实际上被编码为64位,占用了所需空间的8倍。

字符串的复制性能也不佳,惰性加载对于函数体很好,但对于其他部分则不然。

我们还遇到了PTX的早期性能问题,因为PTX目前基本上只是作为具体化结果在IR中的一个巨大字符串。

因此,我们必须设法解决这些问题,不完全依赖基础设施来处理。

在代码生成层面的链接也存在类似问题,当我们将它们链接在一起时,必须将所有拆分放回同一个LLVM上下文中进行链接,因此又发生了一次序列化和反序列化。

我们用于代码生成的目标机器不是无状态的,因此我们现在还必须为每个拆分保留多个副本,这实际上增加了代码生成期间的内存占用。

对于特定目标的后端,我们还必须对归约进行一些小的修改,而且它们大多是私有API,因此从代码树外部更改访问权限具有挑战性。

总结

在构建Mojo流水线时,我们得出的结论是:

首先,编译时间是输入LLVM的IR规模的函数,而不是我们想要优化的实际工作量。

为了解决这个问题,我们将许多优化Pass移到MLIR层面,以便我们可以逐步优化IR并控制最终规模,从而控制IR大小。

我们还拥有更高级的IR表示,这有助于简化一些Pass的实现。

我们还努力利用并行化,将并行化注入到Pass中。

我们以一种非常规的方式使用LLVM,进行两级拆分,并尝试进行MC链接以确保并行化。

我们还编写了代码生成和结果链接器。

因此,如今,LLVM流水线约占Mojo编译时间的20%到30%。

总而言之,我们认为可以构建一个编译器,充分利用MLIR和LLVM两者的优势。

我们不必纯粹在LLVM中或纯粹在MLIR中构建编译器。我们可以让两者协同工作。

问答环节

问: 您没有讨论这种方法的运行时性能影响。我的意思是,您有关于运行时性能的数据吗?

答: 运行时性能是指代码运行时的性能吗?我没有这方面的数据,因为这主要是关于编译时间的。

问: 当您拆分为子图时,有时会复制函数,对吧?您拆分是为了并行化以改善编译时间,但如果您复制了函数,那么每个子图的编译时间可能会增加,对吧?您的方法是什么来解决这个问题?

答: 目前方法并不非常复杂。我们确实尝试根据IR规模来排序,作为优化所需时间的估计,并尝试平衡工作负载。但目前,我们只是创建并行任务,并让现有的运行时系统来调度代码。我们并不是直接创建线程,我们实际上有一个异步运行时系统。我们只是创建任务,运行时系统会调度工作负载以平衡代码。

问: 我很好奇,是否存在一个模块大小阈值,低于这个阈值时,进一步并行化不会带来好处?您知道吗,就像在您放弃并说“我不再费心并行化了,因为开销会超过并行化带来的加速”之前,您希望模块达到的最小粒度是多少?

答: 这实际上是一个非常好的问题。我认为我们还没有深入研究到那个细节。我确实看到,当我们有太多并行化时,在某种意义上会压垮系统。就像我们常说的,并行化有点像骗局,只有在有足够资源可以利用时才有益;如果没有足够资源,并行化会增加开销,可能没有好处。这绝对是我们微调工作负载平衡时应该考虑的事情,也是我们应该努力解决并弄清楚细节的事情。

问: 您通常是在一个编译单元中编译相对较大的程序,还是较小的程序?

答: 我们关注的大多数东西都是ML图(机器学习图)。所以通常至少会有100个函数。因此,每个函数都有足够级别的并行化可以利用。对于这个微小的例子,可能没有太大意义,因为只有四个函数。

问: 在您展示如何克隆函数的示例中,我想知道,拥有内部链接副本与使所有这些副本在外部可用,然后拥有一个包含可丢弃副本的单独模块(如果存在重复)之间的权衡是什么?就副本而言,这并不是最关键的事情。

答: 我们之所以在模块内保留重复副本,是因为我们希望内联器能够看到函数体,以便内联器可以对其进行操作。因此,过程间优化仍然可以做到这一点。当然,正如您所说,我们可以将它们保留为单独的外部实体,并在LTO层面进行优化,但LTO不如早期进行优化那么理想。

问: 我指的是特定的“available externally”链接,理论上它应该使函数体可用于内联,但仍然告诉链接器不要保留所有副本。

答: 哦,您是指一种叫做“available externally”的链接检查,您为IPO目的提供函数体,但它仍然不应该被代码生成。我不太了解这个,但我肯定会研究一下。非常感谢。

问: 出于好奇,您是否研究过Pass级别的并行化?几年前GCC中有一个项目,他们并行化了不同的优化Pass,基本上将其作为一个作业系统运行,其中优化依赖于另一个优化,构建了一个任务图,他们从中获得了相当好的加速。我认为在主题演讲中提到过,LLVM Pass管理器正在朝着这个方向发展。然而,这非常困难,因为LLVM的数据结构不是线程安全的。要使其工作,我们必须非常侵入性地修改数据结构,因为当并行运行时,如果上下文不安全并且您正在修改IR,就会出现竞争条件。因此,除了并行化Pass之外,我们还必须确保数据结构是安全的。MLIR在这方面要好得多,因为框架支持它,只要您从上层创建隔离,就可以做到。LLVM正在朝着这个方向发展,但还没有完全实现。

答: 是的,谢谢。再次感谢大家。谢谢。

026:作为C和C++编译器的Clang现状

在本教程中,我们将跟随Aaron的视角,了解Clang作为C和C++编译器的最新发展、已完成的工作、未来方向以及社区反馈。我们将重点关注Clang 17、18、19版本中的关键特性,并探讨其在标准支持、性能、工具链等方面的表现。

近期工作:C++2C与C2Y标准支持

上一节我们介绍了教程的概述,本节中我们来看看Clang近期在支持未来C++和C标准方面所做的工作。

Clang团队从Clang 17开始,就致力于支持C++2C(预计2026年发布)和C2Y(预计本十年内发布)这两个下一代标准。目前,这两个标准尚未引入需要大规模实现工作的变革性特性,主要是一些小功能、错误修复等。

以下是Clang为C++2C标准实现的一些特性:

  • 用户生成的静态断言消息:静态断言现在可以接受一个上下文相关的字符串,允许调用函数以生成更健壮的消息。
  • 常量表达式中的void*转换:允许常量表达式通过void指针传递数据。
  • 未求值字符串:编译时字符串具有特定的编码,与运行时编码无关,属于语言清理工作。
  • 无名占位符变量:在结构化绑定时,对于不关心的字段,可以使用下划线_作为占位符,无需为其命名。
  • 结构化绑定上的属性:可以为结构化绑定中的每个绑定单独添加属性。
  • = delete应提供原因:当选择了一个已删除的方法时,现在可以提供用户生成的额外诊断信息。
  • 平凡无限循环不再是未定义行为:修复了优化构建中,平凡无限循环可能导致调用完全不相关函数的问题。
  • 包索引:对于参数包等,现在可以直接通过索引获取第N个元素,无需遍历整个包,有助于提升编译速度。
  • 禁止将返回的GL值绑定到临时对象:这可以静态捕获悬垂引用,避免潜在错误。

此外,C++委员会移除了算术类型与枚举类型之间的转换。Clang 18中将其实现为错误,但很快调整为默认错误的警告,以提供过渡期。在Clang 20中,这预计将恢复为硬错误。

对于C2Y标准,Clang的工作主要是测试和确保对现有扩展的标准化支持。

以下是C2Y标准中的一些特性:

  • 复数字面量:支持IJ后缀,用于指定复数。
  • 复数递增/递减:标准化了对复数进行++--操作的支持。
  • typeoftypeof_unqual操作符:类似于C++的decltype,用于获取表达式的类型。typeof_unqual会去除类型限定符。
  • 泛型选择表达式增强:现在允许直接使用类型操作数,与typeof操作符配合良好,可以匹配带有限定符的类型。

C++23与C23标准支持现状

上一节我们介绍了Clang对C++2C和C2Y的支持,本节中我们来看看对已批准的C++23和C23标准的支持情况。

C++23和C23标准已于2023年定稿。Clang 19已近乎完成对C++23的支持,主要缺失的特性包括constexpr数学函数、常量表达式中的未知指针和显式生命周期管理。对于C23,Clang 19也提供了大量支持,主要缺失十进制浮点数支持等特性。

以下是Clang已实现的C++23特性:

  • 推导this:类似于Python的self,可以推导对象的const限定符。
  • 放宽常量表达式限制:允许常量表达式函数执行更多操作。
  • 可移植假设:通过[[assume]]属性向优化器提供更多可假设的信息。但需注意,过度使用可能降低性能,且假设在不同编译器或版本间可能失效。

以下是Clang已实现的C23特性:

  • 移除三字符组支持:与C++17保持一致。
  • auto关键字:从C++引入,用于推导变量类型,目前仅适用于对象定义。
  • 标签的自由定位:标签后可以跟随声明或闭合花括号,无需强制分号。
  • 对象定义的常量表达式:允许在C中定义真正的常量对象。
  • embed指令:将二进制文件的内容作为逗号分隔的整数值列表嵌入到源代码中,便于初始化大型数组。
  • 关键字拼写修订:C99的所有关键字现在都有了更美观的名称,无需使用下划线前缀或包含头文件。
  • nullptrnullptr_t:提供类型化的空指针。
  • unreachable:用于指示不可达的流控制。
  • 使用花括号的一致性初始化:保证零初始化整个对象。

C++20与C++17标准支持

上一节我们讨论了C++23和C23,本节中我们回顾一下对更早的C++20和C++17标准的支持情况。

C++20是一次重大的演进,引入了模块、概念和三路比较运算符等大型特性。Clang 19已近乎完全支持C++20,主要缺失的是对模块的完整支持。而C++17的所有特性在Clang 19中已全部实现。

以下是Clang对C++20特性的支持历程:

  • Clang 17:支持consteval(真正的常量求值)、三路比较运算符<=>、未求值上下文中的Lambda表达式(允许在decltype中使用Lambda以获取其类型,便于实现递归Lambda调用)。
  • Clang 18:支持标量类型的非类型模板参数(例如,可以使用float作为模板参数)。
  • Clang 19:完整支持概念,并定义了相应的功能测试宏,提供了比std::enable_if更自然、易用的语法来约束模板类型。

关于模块,尽管它们是在C++20中引入的,但Clang从第11版就开始相关工作,至今已投入约五年。虽然尚未定义功能测试宏,表明其不完全,但模块在Clang中已得到良好支持,适用于大多数使用场景。剩余工作主要涉及模板本地实体和模块级查找的诊断信息。

标准符合性数据

上一节我们回顾了C++20和C++17的支持,本节中我们通过数据来量化Clang在各个标准上的符合性程度。

在C语言方面:

  • C90:完全符合。
  • C99:93%符合,7%部分支持,无强制性特性缺失。
  • C11:约85%-95%符合,部分特性因测试覆盖不足状态未知。
  • C23:81%符合,约10%状态未知,6%明确需要继续工作。
  • C2Y:56%符合,24%需要编写测试。

在C++语言方面:

  • C++98/11/14/17:完全符合。
  • C++20:91%符合,9%部分支持(主要缺失模块相关特性)。
  • C++23:89%符合,11%尚未完成。
  • C++2C:83%符合。

缺陷报告处理

上一节我们看了标准符合性数据,本节中我们关注另一个影响编译器开发的重要方面:缺陷报告。

缺陷报告是标准委员会发布的错误修正或澄清。C语言自C90以来共有427份缺陷报告,Clang实现了其中47%(与编译器相关的部分),约2%明确未实现,9%状态未知。

相比之下,C++语言自C++98以来有近2900份缺陷报告。Clang仅确认完成了21%,2%明确未完成,而高达54%处于未知状态。这带来了挑战,因为C++委员会假定所有实现都已应用所有缺陷报告,并在此基础上设计新特性。大量的未知状态会拖慢Clang实现新特性的速度。

从Clang 17开始,团队加大了缺陷报告符合性测试的力度,并取得了进展。目标是持续提升解决缺陷报告的比例。

当前工作重点与未来方向

上一节我们了解了缺陷报告的处理情况,本节中我们来看看Clang团队当前正在推进的工作和未来的发展方向。

对于C++2C,Clang 20已包含constexpr友元和常量替换new等特性。社区也在分叉中探索反射和合约等尚未正式采纳的特性。

对于C++23,Clang 20实现了基于范围for循环的生命周期扩展,但仍需完成constexpr数学函数、未知指针/引用等工作。

对于C++20,模块支持仍是首要任务。

在C语言方面,团队正持续测试C2Y的扩展,并实现新特性,如if声明。对于C23,Clang 20正在改进枚举支持,包括固定底层类型的枚举和改进的普通枚举(自动确定适合的底层类型)。

安全扩展方面,边界安全检查备受用户期待。团队也在改进灵活数组成员与计数的关联以提供更好的诊断。

此外,Clang持续强化诊断,将一些警告默认设置为错误,以帮助用户避免问题。标准符合性测试也在持续进行。

社区反馈与外部评价

上一节我们探讨了Clang的技术发展方向,本节中我们听听来自用户和社区的反馈。

在性能方面,用户认为Clang与GCC在运行时性能上具有竞争力,与MSVC互有胜负。调试模式下的向量数学运算表现优异。但编译时间明显变慢的问题受到了关注,用户开始更多使用-ftime-trace来分析,但工具本身有待完善。编译时间增长也是C++新特性带来的普遍问题。

在工具链方面,clang-tidyclang-format被广泛采纳并集成到CI/CD和构建流程中。clangd作为语言服务器也日益流行。

标准委员会方面,C委员会对Clang和GCC快速采纳C23特性表示赞赏。C++委员会总体上对Clang满意,但对某些新特性的实现速度略有担忧。委员会既欣赏编译器的扩展为标准化提供了实践经验,又担心这些扩展会挤占未来的语言设计空间。

总体而言,用户对Clang的扩展(如边界安全检查)感到兴奋,欣赏其提供的完整工具套件、有竞争力的特性和性能、优秀的诊断信息、标准符合性以及与GCC/MSVC的兼容性。

但也存在一些批评:用户期望完全符合最新标准;对编译时间变慢感到沮丧;文档严重不足;存在一种误解,认为企业利益主导了项目方向,而实际上Clang是一个由企业、学生和志愿者共同贡献的真正的开源社区。

项目进展与改进空间

上一节我们汇总了外部评价,本节中我们来看看Clang项目自身的进展和可以改进的地方。

Clang社区在沟通和协作方面取得了显著进步。从Clang 13/14版本几乎空白的发布说明,到17/18/19版本内容丰富,改变了用户认为项目停滞的看法。问题跟踪管理更加主动,有专人进行分类和标记。社区规模持续增长,参与代码审查、问题分类、Discord/Discourse答疑的人数前所未有。

需要改进的方面包括:

  1. 文档:特别是关于实现定义行为和扩展的文档严重缺乏。
  2. 测试:需要加强标准符合性测试,目前存在大量未知状态。
  3. 问题响应:需要更积极地关注代码合入后的问题跟踪,减少回归。
  4. 社区多元化发展:除了编码,需要吸引更多贡献者参与文档、QA、网站等非编码领域的工作。

Clang的成功离不开整个社区的贡献,这是一个非常关键和成功的项目。

总结

在本教程中,我们一起学习了Clang编译器在支持最新及未来C/C++标准方面的进展,包括C++2C、C2Y、C++23、C23等特性的实现状态。我们回顾了其在C++20和C++17标准上的完成情况,并通过数据了解了其标准符合性。我们还探讨了处理大量缺陷报告的挑战、当前的工作重点、来自用户和标准委员会的反馈,以及项目内部在协作、沟通上的改进和未来发展的方向。Clang作为一个活跃的开源项目,在性能、工具链、兼容性等方面持续进步,其成功得益于广泛而多元的社区贡献。

027:从回退到性能——迈向更好的GlobalISel性能

概述

在本节课中,我们将学习LLVM编译器后端中一个名为GlobalISel的指令选择器。我们将探讨如何通过分析一个名为TSVC的小型基准测试套件,来识别和修复GlobalISel的性能问题与回退现象,最终使其性能与传统的SelectionDAG指令选择器相当。

从后端与GlobalISel开始

上一节我们介绍了编译器后端的背景。本节中我们来看看GlobalISel的具体情况。

GlobalISel是LLVM代码库中的另一个指令选择器,已存在超过五年。它在AArch64架构的O0优化级别已是默认选项,但在其他优化级别(如O3)下,仍会回退到使用SelectionDAG。从基础设施角度看,GlobalISel不创建新的指令有向无环图(DAG),而是直接处理通用的机器指令,通过多个阶段将其降低并优化为具体的机器指令。

然而,在编译不同基准测试时,我们发现许多甚至几乎所有测试用例都会回退到SelectionDAG。从长远看,我们希望能在AArch64后端的O3级别启用GlobalISel。我们的方法是经验性的:逐个基准测试地分析问题。目前我们还没有一个完整的计划,也不完全清楚需要达成哪些具体目标。因此,我们决定从一个简单的小目标开始。

切入点:TSVC基准测试套件

以下是我们的切入点选择:

我们选择了名为TSVC(向量化编译器测试套件)的小型基准测试。它最初用于调优向量化器,但其规模足够小(包含152个小型计算内核),便于我们启动分析工作。在本演讲范围内,我们将聚焦于启用GlobalISel标志(-global-isel)且机器代码优化器等于机器代码生成器(-mcp=MC=mir)的情况。

在2024年2月编译时,我们发现只有一个内核会回退到SelectionDAG,其他部分都运行良好。这个内核本身并不复杂,但其生成的中间表示(IR)经过向量化器处理后,会产生交错(interleave)内部函数。通过研究SelectionDAG的实现,我们发现它是通过shufflevector指令来处理的。因此,我们在GlobalISel路径中模仿了这一行为,并成功修复了问题。这是我们工作的第一个里程碑。

性能对比与挑战

但性能究竟如何?我们最终需要让GlobalISel与SelectionDAG一较高下。

观察性能对比图:上半部分显示GlobalISel更快的案例,下半部分显示GlobalISel更慢的案例。其中三个内核的性能相比SelectionDAG要慢得多。让我们放大看第一个内核。

第一个内核的代码也很简短。但在其指令序列中,出现了fnegselect指令,随后又出现了freeze指令。在后续的处理阶段中,这些指令被物化(materialized)了,这并不理想,因为最终会留下不必要的指令。我们通过向规则集中添加规则,避免了这种物化,从而修复了性能差距。这个补丁贡献显著。

提交该补丁后,我们看到这个原本落后SelectionDAG超过50%的内核得到了修复。但仍有另外两个内核(s3110s3111)存在性能问题,它们本质上是同一个内核,却阻碍了整个测试套件的性能表现。

深入分析:寄存器组选择问题

进一步分析这个内核,它是一个非常简单的计算二维数组最大值的函数,实现很朴素。通过GlobalISel路径观察,它有加载、浮点乘法和最大值计算指令,这符合预期。但在后续多个阶段中,我们看到循环内有一条加载指令,之后还有多条复制指令。

仔细观察,第一条加载指令发生在通用寄存器(GPR)中,而在复制指令中,数据从通用寄存器复制到浮点寄存器(FPR),然后又从FPR复制回GPR。这并不合理:如果数据的使用者都在FPR寄存器组中,为何要加载到GPR中?这是我们提出的第一个问题。

深入研究后,我们发现负责分析和分配寄存器组的RegBankSelect通行还不够成熟。这正是GlobalISel的优势所在:它允许我们跨越基本块进行分析。SelectionDAG仅限于单个基本块内,而GlobalISel可以超越基本块边界,触发SelectionDAG无法实现的优化选择。这个内核恰好展示了这种能力。

我们利用了这一点,增强了分析机制,使其能够更全面地审视数据流。我们增加了一项分析:如果加载的数据最终在某个不同的寄存器组中被使用,那么为何不考虑从一开始就使用相同的寄存器组进行加载和存储操作?这是针对该内核的主要实现和贡献。

成果总结与噪声处理

我们讨论的是为修复特定内核乃至整个基准测试而提交的一系列补丁。观察修复前后的性能对比图,通过大约11到12个补丁的组合,我们基本消除了所有性能差距。现在,整个测试套件的性能已非常接近SelectionDAG。

细心的读者会注意到,修复后图的Y轴范围缩小了,最大值是-20%,而之前是75%。这-20%主要源于测量噪声。请记住,这些都是非常小的计算内核,即使运行五次取几何平均数或最小值,仍然存在一些噪声。消除这些噪声可能不值得,因为它不一定能带来新的优化机会。

至此,我们可以宣布,除了噪声等因素,GlobalISel的性能已进入与SelectionDAG相当的范围内。

最终结论与未来展望

最后,我想总结一下。我们通过TSVC这个小基准测试,展示了如何提升性能、修复回退,甚至实现了超越SelectionDAG的能力——例如将信息传播到基本块之外,从而触发新的优化机会。这个基准测试为我们提供了绝佳的平台,证明GlobalISel具备足够的能力进行各种优化。

幸运的是,这一切并没有以牺牲编译时间为代价。GlobalISel的设计初衷之一就是改善编译时间。在我们应用所有补丁后收集的编译时间数据中,没有发现任何性能回退,这是一个好消息。同时,我们也赢回了运行时性能,这是重要的结论之一。

但故事并未就此结束。观察其他基准测试,仍然存在大量回退现象。有趣的是,在CPU开发者非常看重的SPEC 2017基准测试中,9个测试里有4个可以在不回退到SelectionDAG的情况下完成编译。这意味着对于SPEC 2017,GlobalISel至少是部分功能完整的。

但对于其他大型基准测试套件(如llvm-test-suite),仍需要针对性的修复。目前我们观察到的一个主要问题是内联汇编支持不足。我们需要研究SelectionDAG如何处理内联汇编,并找到在GlobalISel中实现的方法。我们在内部运行了一个小型的持续集成系统,它会在主干代码顶部构建LLVM,并使用脚本收集这些回退信息。这些数据来自上周,非常接近现状。

我们正在积极努力减少这些回退,最终目标是实现梦想:宣布AArch64后端默认全面启用GlobalISel。这绝非一人或一个团队能完成的任务,我们欢迎补丁,也欢迎大家加入我们。

谢谢大家。感谢各位的参与。特别感谢所有审阅我们补丁的同行。

谢谢,Madoric。

028:向量数据依赖图——用于更好的可视化和验证

概述

在本节课程中,我们将学习向量数据依赖图的概念。向量化是一种能显著提升程序性能的优化技术,但理解向量化后的数据流和依赖关系可能非常复杂。我们将介绍一个项目,该项目扩展了现有的数据依赖图,旨在为LLVM IR的向量化提供更好的可视化效果和验证机制。

引言

向量化是一种高效的优化技术,它通过对多个数据元素同时执行相同操作来利用硬件并行能力,从而带来巨大的性能提升。

然而,由于数据流的改变以及向量通道的存在,以数据依赖图的形式可视化向量化过程可能非常困难。向量化引入了并行性,这使得依赖关系变得更加复杂。

因此,我们的项目旨在通过扩展现有的数据依赖图来应对这一挑战。我们提供相应的可视化工具,并利用构建的图来验证已执行的向量化是否正确。

可视化挑战与解决方案

让我们考虑以下示例。这里有一系列指令,它们极大地改变了数据流。

如果我们查看原始的数据依赖图,我们无法获得关于混洗指令的任何信息,该指令修改了数据通道。我们也无法得知提取元素指令从哪些通道获取其值。

现在,考虑我们的项目生成的图。我们可以清晰地看到指令之间以及数据流上存在的逐通道依赖关系。这使得我们更容易理解此处发生的向量化。

除此之外,我们还计划移除像混洗指令这样的“胶水”指令,这些指令并未为程序逻辑增加实质性的依赖关系。移除它们可以使图变得更简洁,进一步降低图的复杂性。

验证算法

上一节我们介绍了可视化部分,本节中我们来看看验证器部分。让我们从以下示例开始,考虑两个输入值 P 和 P2。

我们在此尝试做的是比较现有的标量DDG图和向量化后的DDG图。这个过程并不简单。为了使其更简单,我们实际上将对创建的向量DDG进行“标量化”。

所谓“标量化”,是指将一个向量指令根据其拥有的通道数拆分成独立的节点。例如,考虑第一个向量加载指令 VLoad,它有两个通道 VLoad0VLoad1。这将被拆分成各自独立的节点,如第二张图所示。依此类推,对图中所有向量指令都遵循相同的步骤,最终得到一个标量化后的DDG,可用于与原始标量DDG进行一对一比较。

除了我们表示的数据依赖关系外,我们还将确保相应地描述内存依赖关系。在原始图中,我们无法了解两个节点之间如何存在内存依赖。而在我们标量化DDG的第二张图中,我们可以观察到向量加载的第一个通道与向量存储的第0个通道之间存在清晰的内存依赖。因此,我们确保在标量化向量图时,同时保留了数据和内存流依赖。

核心验证算法

现在,我们来到验证算法的核心定义。给定以下假设,一个向量DDG等价于标量DDG,当且仅当对于标量化向量DDG中的每一条路径,在标量DDG中都存在一条对应的路径。

该算法旨在为我们尝试验证的两个图之间推导出一个一一映射关系。

构成我们图的元素如下:

  • 节点:可以是标量指令或向量通道。
  • :可以是数据依赖或内存依赖。

为了确保验证器产生正确结果,所需的假设是:标量DDG端和向量DDG端具有相同的指令集。我们还必须确保没有在向量DDG上执行非平凡的变换,因为这会导致消除一些我们原本用于比较图的节点,从而导致错误结果。

以下是我们遵循的算法的简要描述:我们获取两个图,并按拓扑顺序遍历它们,进行逐层比较。首先比较输入值,它们构成了我们DDG的根。一旦通过归纳法比较了这些值,我们就可以通过确保父节点也相同,来确认对应图的两个节点是等价的。

如果此比较成功完成,并且两边的指令集都已穷尽,我们可以得出结论:图是等价的。然而,如果在任何一点匹配失败,我们可以说图不等价。

验证示例

这里有一个示例,展示了两个图之间的差异。

在右上角的标量图中,我们有从 P0 到 P3 的输入值,接着是四个标量加载,最后是两个标量加法。

我们可以看到,标量化后的向量DDG看起来与原始标量DDG非常相似。它也将通道描绘为独立的节点,形式为第一个向量加载的 VLoad0VLoad1,以及第二个向量加载的 VLoad2VLoad3

比较过程相当简单:我们首先从两个图的根开始。因此,我们比较两边的 P0 到 P3。一旦该比较完成,我们移动到相应的层级,通过检查节点的父节点来比较 Load0VLoad0,依此类推,直到到达叶节点的末端。之后,我们可以得出结论:验证过程完成,向量化成立。

算法的可靠性

我想在此讨论我们算法的可靠性。在我们的案例中,如果验证器得出结论认为结果是等价的,那么我们实际上可以说它是等价的。但是,如果它判定为不等价,则可能有两种情况。

验证过程本质上是可靠的。然而,不等价的部分是由于向量DDG中存在某些特殊指令,例如混洗指令、提取元素、插入元素等,这些指令在标量DDG中没有对应项。尽管我们处理了这些特殊指令,但可能存在其他异常情况或难以判断的其他指令组合,这需要我们进一步推理以确保两个图实际上是等价的。因此,在这些情况下,我们实际上返回“不等价”的结果,从而使我们的验证成为一个可靠的过程。

未来工作

在未来工作中,我们计划将此作为一个RFC提交给LLVM社区,并提交给Google Summer of Code项目以进行进一步开发。

我们还需要确保我们拥有的可视化工具本质上是简洁的,因为对于非常密集的图,视图可能会非常混乱。因此,我们还需要以一种更简单易懂的形式来表示子图。

此外,目前的验证器仅适用于SLP向量化器,我们还需要扩展对循环向量化器的支持。

总结

本节课中,我们一起学习了向量数据依赖图项目。我们了解了向量化可视化面临的挑战,以及如何通过扩展DDG来清晰地展示逐通道依赖关系。我们深入探讨了验证向量化正确性的核心算法,该算法通过标量化向量图并与原始标量图进行路径匹配来实现。最后,我们讨论了算法的可靠性以及未来的改进方向。该项目旨在帮助开发者更好地理解和验证LLVM中的向量化优化。

029:教程

概述

在本教程中,我们将学习如何使用 Polygeist 工具,将领域特定语言嵌入到 C++ 代码中。我们将了解其动机、核心机制,并通过一个简单的例子来演示如何实现自定义语法和优化规则。


动机:为何需要嵌入 DSL? 🎯

当前,硬件编程变得越来越复杂。硬件本身变得更加异构和多样化。软件社区对此的典型应对方式是增加软件栈的结构化程度。实现这一点的一种方法是开发领域特定语言和编译器。

然而,这种方法并非没有代价。通常,领域特定语言与通用语言之间存在兼容性和可组合性问题,并且很难集成到现有的大型代码库中。主要原因是这些工具通常是庞大的单体软件,这源于语言与软件之间缺乏紧密集成。

因此,本项目的目标是设计一套机制,使我们能够以更简单的方式将领域特定语言引入现有的 C/C++ 项目中。我们本质上希望打破领域特定世界和通用编程世界之间的壁垒。

我们通过重用新颖的编译器技术来实现这一目标。具体来说,我们在 MLIR 的基础上,为 Polygeist 添加了一个扩展机制。这个机制允许你将领域特定语言和编译器作为一组插件引入。我们称这种扩展机制为“语法插件”。


什么是 Polygeist? 🔧

对于那些不了解 Polygeist 的人,Polygeist 是 MLIR 的一个 C/C++ 前端。它允许你将任意的 C 或 C++ 代码片段,翻译成 MLIR 框架内不同方言所对应的中间表示。

下面的幻灯片展示了一个简单的例子。左边是一个 2D 循环的 C 代码,右边是其对应的 IR。Polygeist 的美妙之处在于,它能够保留源语言的丰富语义。例如,C 语言中的 for 循环对应 MLIR 标准控制流方言 scf 中的 scf.for 操作。

同时,它还能保留维度信息。在这个例子中,你有一个 2D 数组,并且可以看到内存访问也是 2D 的。

Polygeist 本质上依赖于 Clang。它构建抽象语法树,然后遍历每个 AST 节点,并发出相应的 IR。


我们的计划:如何将 DSL 引入通用流程? 🗺️

我们计划主要在两个方面开展工作。

  1. 引入自定义语法:例如,如果你是线性代数领域的专家,你可能希望使用自定义语法编写应用程序,而不是 C/C++。
  2. 提供引入自定义知识的机制:这可以通过两种方式实现:一种是重写规则的形式,另一种是操作的形式。例如,你可能能够动态地向编译器注入一个操作,并赋予该操作语义。

为了本次讨论的焦点,我们将只关注语法和 IR 规则。在 Polygeist 中,我们已经对操作形式进行了一些实验。例如,你可以用 #pragma 标记一个函数,然后该函数会被降级到 MLIR 中的一个自定义操作,但这还不是动态的,我们需要选择一个方言并降级到它。


实现自定义语法 📝

为了实现自定义语法,我们实现了一个几年前由 Pletal 等人提出的想法。这本质上是一个 Clang 扩展,允许你从根本上改变 Clang 解析器解析的语法。

这意味着,只要遵守一些基本的解析规则(例如括号必须平衡),你现在就可以将 C 代码与 DSL 代码混合编写。

以下是一个简单的例子。你可以看到,C 函数被标记了属性 clang::syntax,然后在括号内是插件的名称。这个插件是一个简单的共享库,会被 Polygeist 加载,并告诉 Polygeist 如何解释函数体。

函数的参数是简单的 C 代码,但函数体是 DSL 代码。在这个特定案例中,我们使用了 Tensor Comprehensions,这是一种用于表达线性代数计算的 DSL。

实际上,这采用了一种两步法:

  1. 解析阶段:Clang 会解析函数体。我们获取解析后的词法单元,将它们存储起来。
  2. 下一阶段:当 Polygeist 遍历 AST 并构建 IR 结构时,我们检索存储的词法单元,并将其传递给插件。插件将负责发出函数体的 IR。

如前所述,插件是一个简单的共享库,包含解析器和 IR 发射器。在这张幻灯片上,左边是插件生成的 IR,代表一个转置操作;右边是发射出的 IR。在这个例子中,转置操作被降级为 linalg.generic 操作,这是 linalg 方言中的一个类循环操作。

在函数边界处,Polygeist 会处理 IR 的发射,并将符号表等信息传递给插件,以便插件理解参数等内容。此外,我们还会将内存引用提升为张量,以便重用 linalg 方言中可用的一些优化。


引入自定义优化规则 ⚙️

现在我们已经引入了语法,接下来看看如何引入优化。假设你正在编写自己的库,现在你有一种很好的方式来编写转置内核,但你还想在其中注入优化。

为了注入优化,我们提供了一种称为“策略”的机制。一个策略本质上代表了一条重写规则。模式部分告诉编译器你想要匹配什么,而替换部分则告诉编译器你希望如何替换匹配到的内容。

在这个案例中,策略非常简单:检测转置操作,并用一个简单的复制操作替换它。

实际上,这些策略会被降级到 Transform 方言。具体来说,是降级到 linalg 方言中一些可用于推理操作属性(如结构和访问模式)的匹配器。

例如,假设你想检测转置操作,你需要确保:

  • 你的 generic 操作是 2D 的。
  • 所有维度都是并行的。
  • 输入和输出符合你对转置的预期。
  • 检查访问模式:输入必须是恒等映射,输出必须是完美的排列。
  • 最后,检查操作体,确保它没有做任何复杂的操作,在这个例子中应该只是一个直通操作。

一旦匹配成功,我们就可以进行替换。为此,我们再次使用 Transform 方言,具体是使用 structured.replace 操作。它允许你输入一组新的 SSA 操作作为要注入的操作,其有效负载就是替换内容。在这个例子中,替换内容是另一个 generic 操作,但它是复制操作而不是转置操作。所有这些实际上都是从策略中生成的。


总结 📚

本节课中,我们一起学习了如何使用 Polygeist 在 C++ 中嵌入领域特定语言。

我们首先了解了这样做的动机:为了应对日益复杂的异构硬件编程,并打破 DSL 与通用语言之间的壁垒。接着,我们介绍了 Polygeist 作为 MLIR 的 C/C++ 前端的基本功能。

然后,我们深入探讨了实现这一目标的两个核心方面:

  1. 自定义语法:通过 Clang 扩展和插件机制,允许在 C/C++ 函数中直接编写 DSL 代码,并由插件负责将其转换为 MLIR IR。
  2. 自定义优化:通过“策略”机制定义重写规则,这些规则会被降级到 MLIR 的 Transform 方言中,从而在编译时应用领域特定的优化。

总的来说,我们的目标是构建一种机制,能够轻松地将领域特定语言和编译器集成到现有的 C/C++ 项目中,充分利用 MLIR 等现代编译器技术的优势。

030:为Python开发者设计的MLIR DSL

概述

在本节课中,我们将学习PyDSL,这是一个为Python开发者设计的MLIR领域特定语言。我们将了解其设计动机、核心特性,特别是自去年以来新增的类型推断和宏系统功能。PyDSL旨在简化MLIR编程体验,让开发者能够使用接近原生Python的语法进行高性能计算。


动机与设计目标

上一节我们介绍了PyDSL的基本概念,本节中我们来看看其设计动机和目标。

MLIR的Python绑定通常非常冗长,并且其IR是编译器前端的输出,并非为语言用户直接编写而设计。同时,Python在AI和科学计算社区中非常流行。

因此,PyDSL的设计要求是:

  • 它应该能直接从Python中使用。
  • 它应尽可能遵循默认的Python语法。
  • 理想情况下,用户只需对现有Python代码进行极少的修改,即可在PyDSL上运行。
  • 它应促进异构代码生成。

架构与工作流程

了解了设计目标后,我们来看看PyDSL是如何工作的。

PyDSL解析程序,生成Transform Dialect IR和内核IR。这些IR经过优化器处理,最终被降低到CUDA或ROCm后端。

以下是一个对比示例,展示了手写MLIR IR的冗长程度:

# PyDSL代码(简洁)
@dsl.kernel
def simple_add(A, B, C):
    for i in range(128):
        C[i] = A[i] + B[i]

# 等效的手写MLIR IR(非常冗长)
# 此处省略大量MLIR代码...

仅从代码行数来看,使用PyDSL能带来约3倍的生产力提升。


核心特性

现在,我们深入探讨PyDSL的核心特性。

PyDSL支持多种MLIR方言,包括arithscffuncmemrefaffinetransform。它提供了Python风格的语法,例如a + b会输出一个arith.addi操作。此外,它支持通过NumPy数组直接编译和调用函数。

以下是使用NumPy数组的示例:

import numpy as np
import pydsl as dsl

@dsl.kernel
def vec_add(A, B, C):
    for i in dsl.tag(“vectorize”, dsl.range(A.shape[0])):
        C[i] = A[i] + B[i]

# 创建NumPy数组并直接传入内核
A = np.ones(128, dtype=np.float32)
B = np.ones(128, dtype=np.float32)
C = np.zeros(128, dtype=np.float32)

# 编译并执行内核
vec_add_compiled = dsl.compile(vec_add)
result = vec_add_compiled(A, B, C)
print(result)

类型推断

上一节我们介绍了PyDSL的基本语法,本节中我们重点看看其类型推断功能。

由于PyDSL需要生成MLIR,而MLIR要求所有类型信息都是明确的,因此它是静态类型的。但如果要求用户到处写类型提示会非常繁琐。

PyDSL能够推断类型。例如,它为常量创建了数字类型包装器。在操作12 + argument_a中,如果argument_ai64类型,那么常量12也会被推断为i64类型。这为用户提供了便利。

此外,对于循环中的符号、维度和仿射映射,PyDSL现在也能进行推断,使得代码更加简洁。它还增加了对使用整数集的仿射if的支持。


宏系统

类型推断简化了代码编写,而宏系统则提供了强大的扩展能力。本节我们来详细了解宏系统。

宏系统因其工作方式类似宏扩展而得名。它接收AST节点的一部分,并输出相应的MLIR。该系统专为可扩展性和模块化而设计,并在最新的PyDSL中被广泛用于重构代码。

以下是其工作原理:

  1. 主访问器:它遍历由Python ast模块生成的AST。对于大多数简单、基础的Python语法节点(如函数定义、常量、加法),主访问器可以直接处理并生成对应的MLIR操作(如func.funcarith.constantarith.addi)。
  2. 遇到复杂操作:当遇到更复杂的AST节点时(例如for循环或用于标记循环的tag),主访问器会将处理工作委托给相应的
  3. 宏的处理:例如,for节点会将其自身以及整个循环体子树传递给RangeIteratorMacro。这个宏知道如何根据范围类型(如affine范围或scf范围)生成正确的MLIR for操作。
  4. 委托与回调:有趣的是,当宏需要处理循环体时,它发现循环体内的代码是常规的Python基础语法。这时,主访问器在调用宏时将自己作为参数传入,因此宏可以将处理循环体的工作“回传”给主访问器。

这个过程涉及双重委托:主访问器 -> 宏 -> 主访问器。这样设计的目的是让主访问器专注于基础Python语法,而所有领域特定的复杂功能都由宏来处理。你可以将PyDSL方言宏理解为特定的自定义操作,其扩展过程就是这些传递。


总结

本节课中,我们一起学习了PyDSL,一个旨在提升Python开发者MLIR编程体验的DSL。

我们回顾了其设计动机:解决原生MLIR Python绑定的冗长问题,并利用Python在科学计算中的流行度。我们了解了PyDSL的工作流程,从解析Python代码到生成并优化MLIR IR,最终降低到硬件后端。

我们重点探讨了两个关键新特性:

  1. 类型推断:自动推断常量、循环变量等的类型,减少了显式类型声明的负担,使代码更简洁。
  2. 宏系统:一个模块化的扩展机制,通过委托模式处理复杂的、领域特定的AST节点(如各种循环),使核心语法处理器保持简洁,同时支持灵活的功能扩展。

PyDSL通过提供近乎原生Python的语法、与NumPy的无缝集成以及强大的扩展机制,显著降低了使用MLIR进行高性能计算编程的门槛。

031:LLDB中的MD5校验和 🔍

在本节课中,我们将要学习LLDB调试器如何利用DWARF 5调试信息格式中的MD5校验和功能,来确保调试时显示的源代码与编译时使用的源代码完全一致。这对于保证调试信息的准确性至关重要。

调试信息与源代码映射

上一节我们介绍了调试器的基本目标。当使用调试器时,用户通常希望查看源代码,而非编译器生成的机器码。这一功能是通过调试信息实现的。

在Linux和Darwin系统上,我们使用DWARF调试信息格式。源代码与机器指令之间的映射关系被编码在行表中。行表包含了程序中指令与源代码行号、列号的对应关系。

以下是一个简化的行表示例,展示了机器指令地址如何映射到源文件的具体位置:

地址: 0x1000 -> 文件: test.c, 行: 9, 列: 10
地址: 0x1004 -> 文件: test.c, 行: 10, 列: 1

需要指出的是,行表中并不直接包含源代码内容,它只包含源代码文件的路径。因此,为了在调试器中显示源代码,用户必须在本地拥有该文件,并且该文件必须与编译时使用的文件完全一致。

源代码变更带来的问题

上一节我们了解了行表的工作原理,本节中我们来看看一个由此引发的实际问题:如果在编译和调试之间,源代码文件发生了改变,会发生什么?

例如,假设在示例中,函数调用从 foo() 被改成了 bar()。这可能是因为我在午餐前修改了代码,回来后忘记重新构建;或者我使用了版本控制系统,切换到了一个完全不同的文件版本。

无论原因如何,LLDB都不应该向用户显示 bar() 的调用。因为调试器的基本原则之一是:它不能撒谎。如果显示的内容与实际执行的代码不符,会严重误导开发者。

DWARF的解决方案演进

为了解决上述问题,DWARF标准提供了几种机制来防止和检测这类错误。

以下是DWARF 4中提供的两种方法及其局限性:

  • 文件修改时间:行表可以记录源文件的最后修改时间戳。如果当前文件的修改时间与记录不符,则发出警告。
    • 缺点:可能导致误报。例如,使用版本控制系统切换分支时,文件内容可能未变,但时间戳被更新了。此外,时间戳不利于实现可重复构建,因此经常被禁用。
  • 文件大小:行表可以记录源文件的大小。如果当前文件的大小与记录不符,则发出警告。
    • 缺点:可能导致漏报。就像之前的例子,将 foo 改为 bar,两者都是三个字符,文件大小保持不变,因此无法检测到这种变更。

DWARF 5中,上述两种方法得到了扩展,增加了第三种更可靠的机制:能够在行表中编码一个16字节的MD5校验和

实际上,在继续之前需要说明,当Clang编译器以DWARF 5为目标时(这是Linux上的默认设置,从今年起也是Darwin上的默认设置),它会在行表中生成MD5校验和,而不是修改时间。

在LLDB中实现校验和检查

上一节我们介绍了DWARF 5提供的MD5校验和机制,本节中我们来看看LLDB调试器如何利用这一信息来检测不一致性。

为了让LLDB能够检测这种差异,我们需要在其中加入对校验和的支持。在实现时,主要有两种设计方案:

  1. 复用FileSpec类FileSpec 是LLDB中表示任意路径的类,行表中的所有文件都由它表示。最简单的办法是直接在这个类中添加一个校验和字段。
    • 缺点FileSpec 类在LLDB中用途广泛,很多实例并不需要校验和功能。为所有实例都增加这个字段会造成内存浪费。
  2. 创建新的SupportFile类:另一种方案是创建一个新类(我们称之为 SupportFile),专门用来表示来自行表的文件及其关联的校验和。
    • 缺点:这需要修改LLDB中所有处理行表文件的地方,使其能识别这个新类,意味着更多的工作量和代码改动。

最初,我选择了第一种方案进行原型设计,因为它更容易。但最终,我决定采用第二种方案,即实现 SupportFile 类。

做出这个决定还有一个重要原因:Swift宏需要依赖DWARF 6的一项新功能,该功能允许将源代码直接编码进行表。因此,我们已经有需求来区分“普通文件路径”和“带有额外信息的源文件”。我的同事Adrian在去年维也纳的LLVM开发者大会上有一个关于此主题的演讲(题为“Debug information for macros”),如果你对此感兴趣,可以查看。

最终效果

当使用DWARF 5进行构建时,如果本地源代码文件的MD5校验和与行表中记录的校验和不匹配,LLDB现在会显示一个警告,提示用户源代码可能已变更,从而避免了显示错误代码的风险。


本节课中我们一起学习了MD5校验和在LLDB调试器中的作用。我们了解了DWARF调试信息如何映射源代码,探讨了源代码变更可能带来的调试误导问题,并介绍了从DWARF 4的时间戳/文件大小检查到DWARF 5更可靠的MD5校验和的演进。最后,我们看到了LLDB如何通过实现 SupportFile 类来利用这一机制,确保调试器显示的源代码始终准确无误。

032:两阶段表达式求值实验

概述

在本节课中,我们将学习如何通过一种名为“两阶段表达式求值”的技术来优化调试体验,特别是针对大型程序的调试。我们将探讨这项技术如何显著减少调试器在显示变量值、评估条件断点时的延迟,从而提升整体调试效率。

调试大型程序的挑战

上一节我们介绍了调试信息精确性的问题,本节中我们来看看调试体验本身面临的挑战。

调试大型程序可能令人沮丧。现代集成开发环境功能强大,能够显示大量变量的值、自定义格式和摘要信息。然而,这也导致在单步执行(如步入、步过)时,调试器需要评估大量表达式,使得操作变得缓慢。

寻求解决方案:LGBW

为了解决这个问题,我们首先研究了现有的开源方案。

三年前,Google在LLVM开发者大会上介绍了LGBW。LGBW是一个针对C++语言有限子集的快速解释器。它自带C++解析器,能够处理算术运算、类型转换和有限的模板功能,基本覆盖了调试时常用的表达式类型。它通过LLDB API获取调试信息进行求值。

然而,LGBW最初为Stadia项目开发,随着该项目不再活跃,LGBW也停止了更新。此外,它被设计为一个供IDE直接调用的库,集成度不高。

我们的改进:集成与复兴

我们决定复兴并改进这个项目。

首先,我们基于最新的LLVM代码库重写了LGBW。接着,我们将其深度集成到LLDB调试器中,使其能够自动用于条件断点评估和表达式值显示,作为显式表达式求值的第一步。

我们的核心策略是两阶段求值

  1. 第一阶段(快速求值):首先尝试使用快速的LGBW解释器来评估表达式。
  2. 第二阶段(完全求值):如果表达式过于复杂,LGBW无法处理,则自动回退到LLDB原有的、功能完整但较慢的求值器。

这种设计确保了兼容性的同时,为简单表达式提供了加速通道。

性能提升实例

以下是该方案带来的具体性能改进案例:

案例一:大型游戏引擎调试

  • 场景:在启用自定义类型格式后,调试一款使用该引擎的游戏。
  • 改进:单步执行(步过)时,显示所有变量所需的总时间从2秒减少到约100毫秒,提升超过10倍。LGBW带来的额外开销几乎可以忽略不计。

案例二:条件断点评估

  • 场景:设置一个每5000次迭代才触发的条件断点。
  • 改进:断点被触发前的等待时间减少了超过一半。表达式求值本身的总时间从12秒降至1秒

总结与展望

本节课中我们一起学习了如何利用两阶段表达式求值来优化调试体验。

总体来看,这种方法是行之有效的。加速效果取决于调试会话中需要评估的表达式的数量和复杂程度。目前,相关代码已存在于下游分支中。

我们开放讨论:是否应该在LLDB中默认启用类似机制?或者,还有哪些其他方法可以提升调试体验?

这项实验表明,通过智能地结合快速近似求值与精确完全求值,可以显著改善开发者在调试大型复杂项目时的效率。

033:2024年 LLVM 开发者大会 Flang 进展报告 🚀

在本节课中,我们将学习 Flang 编译器项目的最新进展。Flang 是 LLVM 项目中的 Fortran 语言前端,我们将了解其发展历程、当前状态以及未来的规划。

项目历史与现状

上一节我们介绍了课程概述,本节中我们来看看 Flang 项目的发展背景。

Flang 项目启动已久,最初由 NSSA 和美国能源部推动。它曾是第三个或第四个 Fortran 前端实现。项目于 2019 年并入上游并成为 LLVM 的官方项目,至今已有数年。

关于编译器驱动程序的更新:该驱动程序多年未被构建,曾长期被称为“F New”,因为在其准备就绪前不希望用户使用。就在最近,它被重命名为“F”。因此,在 LLVM 20 版本中,将同时存在 clangflang 命令,为 CPU 提供一个一流的 Fortran 编译器,目前运行状况良好。

社区贡献与测试套件

了解了项目背景后,我们来看看社区的贡献和支持。

在过去的三个月里,贡献情况如下:

  • NVIDIA 贡献者:提交了 262 个提交,目前仍是主要贡献者,但占比已低于一半,这是一个积极的现象。
  • 其他贡献者:来自 AMD、IBM、Arm 等多家公司的 67 位贡献者提交了 272 个提交。

测试套件方面,非常感谢富士通和 IBM 开源了他们庞大的测试套件。这些是优秀的 Fortran 2003 测试,包含约 30,000 个测试用例。他们完成法律和工程审查以开放这些资源,对我们所有人来说都是一项巨大的贡献。

调试支持与性能表现

社区的支持为项目夯实了基础,本节中我们来看看在调试和性能方面的具体工作。

在调试方面,存在针对 Fortran 各种数组类型的 DWARF 调试信息扩展,目前正在更新相关支持。

在性能方面,人们使用 Fortran 并非希望程序运行缓慢。他们希望运行循环并且运行得快。以下是与 GFortran 的性能对比(数值越低越好,单位为秒),使用的是常见的基准测试套件 Polyhedron。我们使用 flang -mlir -mlir-print-ir -O3 命令编译到 LLVM IR,未进行大量优化。

我们进行了一系列内联内部函数的工作,并尝试进行临时变量消除,努力为向量化器提供所需信息以完成其工作。从结果看,我们已非常接近。几何平均值是值得关注的数字,两者非常接近。图中存在少数异常值,其中大部分可能是因为在传递参数时发生了重复,我们尚未进行别名分析。

在 MLIR 中间表示方面,我们有一个用于 Fortran 的大型别名分析,它已于前两周成功并入上游代码库,这对我们来说是一个重要的里程碑。

未来一年规划

在回顾了当前成就后,接下来我们展望一下未来一年的重点方向。

以下是未来的主要工作目标:

  1. 进一步提升 CPU 性能:以 GFortran 为良好基准,但商业编译器表现更优。我们希望更多地利用 MLIR 和 LLVM 现有的能力,特别是在循环优化和向量化方面,同时消除现有的额外数据拷贝。
  2. 处理内部函数:许多用于归约、正弦、余弦等操作的内部例程需要特殊处理,以追赶商业编译器的水平。
  3. 支持多核 OpenMP:几乎每个 Fortran 程序都会进行某种形式的多核处理。这项工作主要由 Arm 及其他贡献者推进,进展非常顺利,已能运行大量程序,并致力于提升用户友好性,例如对未实现的功能显示“未实现”提示。
  4. 支持 GPU 离核计算:利用当前上游 LLVM 中对 Flang 的标准支持,将 OpenMP 任务卸载到 GPU 执行。目前已能较好地运行程序,有时速度非常快,优化工作仍在进行中。
  5. 支持 cudaFortran:类似于 CUDA,但用于 Fortran。允许编写内核并在设备上运行,相关代码正在上游化。
  6. 支持 OpenACC:这是另一种为加速器编写程序的方式,将作为后续工作跟进。

社区参与方式

最后,如果您对参与 Flang 项目感兴趣,可以通过以下方式加入:

  • Discourse 频道:参与讨论。
  • 源代码:您可以在 LLVM 官方仓库找到。
  • 项目例会:每周一和周三交替举行,有时讨论项目事务,有时讨论技术问题,通常两者会有重叠。所有相关信息均可在 llvm.org 网站上找到。

本节课中我们一起学习了 Flang 编译器项目从历史、现状到未来规划的全面更新。我们看到它已成长为一个由多元社区驱动的成熟编译器,在性能、调试、异构计算支持等方面持续进步,并拥有清晰的未来发展路线图。

034:在 Arm 嵌入式工具链中使用 LLVM libc

概述

在本节课中,我们将学习如何在基于 Arm 架构的 LLVM 嵌入式工具链中使用 LLVM libc 库。我们将了解 LLVM libc 的基本概念、当前可用的功能、集成方法以及实际使用中需要处理的关键步骤。


LLVM libc 简介

LLVM libc 是 LLVM 项目的一部分,旨在提供一个完整的 C 标准库实现。它可以通过两种模式构建:overlay 模式和 full 模式。对于嵌入式系统,通常使用 full 模式,因为系统上只有一个库。此外,还有一个 bare metal 配置,它指示 LLVM libc 使用针对不同架构(如 Arm 和 RISC-V)优化的特定函数实现。

核心构建模式

  • overlay 模式:作为现有系统库的补充。
  • full 模式:作为系统唯一的 C 库。
  • bare metal 配置:为裸机环境提供特定实现。

LLVM libc 当前功能状态

上一节我们介绍了 LLVM libc 的基本构建模式,本节中我们来看看它目前提供了哪些功能。

虽然 LLVM libc 的实现尚未完全覆盖所有 C 标准库函数,但对于大多数嵌入式系统来说已经足够。这些系统通常对 C 库的依赖较低,主要使用像 string.h 中的字符串操作函数(例如 memcpy)等基础功能。另一类嵌入式应用会密集使用数学库函数,而 LLVM libc 的数学库实现已经相当完整。

目前最大的限制在于不支持文件操作(FILE*)。这意味着虽然可以实现 printf 输出,但无法使用 fopenfclose 等文件流函数。此外,据我所知,在 bare metal 构建中也不支持浮点数的 printf 格式化输出。


集成 LLVM libc 到工具链

我们之所以进行这次分享,是因为我们为 LLVM libc 实现了一个覆盖包。目前,在嵌入式系统中使用 LLVM libc 最困难的事情之一,就是获得一个集成了它的、真正可用的工具链。

在 Arm 的 LLVM 嵌入式工具链中,我们提供了一个可以单独构建的覆盖包。这个包会构建我们支持的所有架构变体(如 v6-Mv7-M 等),然后将其解压覆盖到主目录中。之后,你只需要修改一个配置文件来指定 C 库的路径,工具链的多库支持就会自动处理其余的事情。

目前在我们的工具链中,这仅支持 C 语言。我们尚未使用此方法构建 libc++,但希望未来能解决这个问题。


实际使用步骤与注意事项

接下来,我们看看在实际项目中集成 LLVM libc 需要做哪些具体工作。以下是关键步骤列表:

首先,一个比较棘手的部分是提供你自己的启动代码。如果你使用的是非常简单的微控制器,通常只需要很少的代码。我们的工具链中提供了一些示例,最好是从中借鉴。

启动代码核心任务

  1. 设置栈指针。
  2. 执行必要的数据复制(例如,对于微控制器,通常需要将 .data 段复制到 RAM 中的正确位置)。
  3. 如果是在 Arm 系统上,还需要处理半主机调用。

对于半主机调用,你需要实现 _read_write 等函数。这些函数本质上会将操作转发给调试器的半主机调用。

如果你使用了 malloc 或堆,你需要在链接脚本中提供一块内存区域,并用符号 end__heap_limit 来界定其边界。

如果你使用了 math.h 中的任何函数,需要提供一个能获取 errno 的函数。因为在 bare metal 构建中,库不会为你提供 errno


示例与替代方案

这是一个非常简单的概念验证示例,虽然不会实际执行,但它展示了你可以做什么。如果你想在 QEMU 上运行,嵌入式工具链中有文档说明如何使用覆盖包,但提供的示例可能是最好的起点。

如果不提一下 Pigweed 就太疏忽了。谷歌提供了一个可用于树莓派 Pico 的工具链。如果你想整体上体验 Pigweed 并获得一个完整的示例,这可能是最简单的方式。而 LLVM 嵌入式工具链则更适合那些想在 Pico 之外的其他平台上进行开发的场景。


总结

本节课中,我们一起学习了在 Arm LLVM 嵌入式工具链中使用 LLVM libc 的完整流程。我们了解了它的构建模式、功能范围,并通过覆盖包的方式将其集成到工具链中。我们还探讨了实际使用时需要提供的启动代码、半主机接口、堆管理以及 errno 处理等关键组件。最后,我们提到了 Pigweed 作为另一个可选的实践途径。希望这些内容能帮助你开始在嵌入式项目中使用 LLVM libc。

035:为LLDB启用RISC-V支持 🚀

在本节课中,我们将学习如何为LLVM的调试器LLDB添加RISC-V架构支持。我们将跟随一位开发者的实际经历,了解从发现问题到最终将代码贡献到上游项目的完整过程,并理解其中的关键步骤与挑战。

背景与动机

上一节我们介绍了LLDB调试器的基本概念。本节中我们来看看一个具体的架构支持案例。

去年四月,高通公司的LLVM团队与管理层开会讨论工作。RISC-V编译器团队的负责人介绍了相关工作。这引发了一个问题:如何运行RISC-V程序?得到的答案是使用QEMU模拟器。由于LLDB可以与QEMU通信,一个想法自然产生:为LLDB添加RISC-V调试器支持。这个想法得到了支持,从而开启了一段开发之旅。

初步尝试与问题发现

开发者首先为RISC-V构建了一个简单的调试目标,预期它会顺利工作。然而,现实并非如此。

加载一个RISC-V程序并在main函数设置断点后运行,调试器并未在预期位置停止,甚至无法正确显示程序计数器(PC)的值。核心问题在于:QEMU虽然能正确提供PC和其他寄存器的值,但LLDB无法识别哪个寄存器是PC。

问题分析与解决:架构插件

经过调查发现,其他架构的PC信息是通过一个“架构插件”提供的。于是开发者去查看RISC-V的架构插件,发现它根本不存在。这是第一个需要解决的问题。

遵循开源精神,开发者参考了其他架构的实现,编写了第一个RISC-V架构插件。这解决了PC识别的问题。

问题二:反汇编显示异常

接下来,程序成功在main函数处停止,但反汇编显示出现问题,约一半的指令无法正常显示。

根本原因在于原子操作、乘法与除法等扩展指令集未被启用。启用这些扩展后,反汇编功能恢复正常。

问题三:源代码单步执行不稳定

尝试使用“步入”功能进入被调用函数时,行为不一致,有时成功有时失败。

深入调查后发现,问题出在一条被圈出的指令:c.jal(压缩跳转并链接指令)。它在调试信息文件中没有被标记为“分支”指令。因此,LLDB在执行时没有将其视为控制流改变指令,直接执行了过去。

通过与编译器团队沟通,并将问题反馈至上游,最终修正了调试信息文件。此后,单步执行功能工作正常。

贡献代码至上游项目

在解决了基本功能问题后,下一步是将架构插件和反汇编修改贡献到LLDB上游项目。

开发者发现已有人提交过类似插件,但已一年未被审阅。于是,再次借鉴了该代码,改进自己的实现,并提交到上游。随后收到了大量的代码审查意见,这些意见极大地提升了代码质量。在逐一处理并改进后,代码最终获得批准并合并。

现在,上游的LLDB已经能够调试32位和64位的RISC-V程序了。

下游集成与产品发布

高通团队将上游合并的代码拉取回自己的分支,构建了完整的工具链,并基于LLVM 18将其集成到产品中发布。

致谢与总结

本节课中我们一起学习了为LLDB添加RISC-V支持的完整流程。这个过程始于一个实际需求,经历了识别问题、编写架构插件、修复反汇编与调试信息、应对代码审查等多个关键阶段,最终成功将贡献合并到上游并应用于产品。

以下是项目成功的关键助力:

  • Ana Paz及其高通RISC-V编译器团队:提供了最初的灵感和持续的RISC-V技术问题解答。
  • Jason, Melinda和David Spickett:在代码审查过程中提供了巨大帮助,提升了代码质量。
  • Palo the bat:实现了允许LLDB启动QEMU的关键组件。

通过这个案例,我们看到了开源协作如何推动工具链的完善,以及解决复杂技术问题所需的耐心和细致工作。

感谢观看,祝大会圆满成功。

036:LLVM治理提案进展与实施计划 🚀

在本节课中,我们将学习LLVM治理提案的最新进展、核心架构以及即将实施的选举计划。我们将了解新设立的“领域团队”和“项目委员会”如何运作,以及它们将如何帮助社区更高效地做出决策。


治理提案的演进与批准

上一节我们介绍了治理提案的背景。本节中我们来看看该提案在过去一年中的演进与最终批准过程。

去年的大会上,我介绍了LLVM治理提案。我们希望通过本次分享,向大家通报该提案的进展和未来方向。

需要指出的一件大事是,我们收集了大量反馈。这些反馈来自去年的会议、EuroLLVM会议,以及在Discord和Github上的评审与讨论。我们获得了非常多的反馈意见。这些反馈使提案变得更好、更精简、更简化。

我们随后走完了LLVM提案流程。目的是以一种结构化和恰当的方式,为社区做出此类重大决策。上周,评审经理们召开了一次会议。这是提案流程的一部分,评审经理们批准了该提案。

因此,我们已经准备就绪,即将开始实施。


核心治理架构:领域团队与项目委员会

现在,让我们来谈谈具体内容。我们计划组建五个初始的领域团队。我们缩小了初始范围,以减少所需的初始志愿者数量。然后,我们将把如何扩展此架构以及如何随时间满足更大需求的问题,交给治理团队来解决。

这些领域团队将组成初始的项目委员会。初始的项目委员会将从此处接手处理所有其他事务。我们计划在明年一月进行首次选举。

这是一个起点,并非一个完美的解决方案。正如我们在LLVM中所做的一切,这将是一个迭代的过程。我们确实希望确保这成为LLVM迭代文化的一部分,以找到正确的解决方案。我们在提案后期添加了一项内容:项目委员会将发布年度报告,说明治理过程的进展。这将是我们用来提出改进建议的渠道。


对贡献者的影响与益处

对于贡献者而言,实际上很多事情应保持不变。目前,许多事情运转良好。我们不想改变那些有效且能让你高效工作的部分。

然而,我们确实希望确保我们有足够的维护者来覆盖代码库,以确保你的拉取请求得到评审,事务得以推进。

以下是领域团队将带来的具体益处:

  • 确保代码审查与所有权:领域团队将帮助我们确保这一点,因为我们将指定人员负责确保所有权的落实。
  • 推动RFC决策:领域团队将确保在Discourse上讨论的RFC得到评审和关注,并达成决策。我们社区经常面临的一个问题是,不一定能像应该的那样快速达成决策。
  • 管理提案流程:最后,领域团队将接管运行LLVM提案流程,用于那些我们需要通过该流程来达成结果的情况。

领域团队的构成与职责

以下是关于领域团队构成与职责的详细信息:

  • 团队规模:初始将由三名成员组成。这再次减少了我们启动该项目所需的志愿者数量。但我们将允许团队规模增长至最多九人,以便在领域需要更多视角和人员时,可以适当扩展。
  • 职责范围:每个领域团队在项目中都有一个定义的领域或范围,他们将在该范围内提供协助和关注。但领域团队的核心使命是促进决策制定,并帮助确保决策得以达成。
  • 培养社区领导力:我们希望领域团队也扮演帮助培养社区其他成员、帮助增长人才和扩展社区领导力的角色。因此,提案中的一项期望是,领域团队成员将帮助识别维护者,并帮助识别未来可能加入领域团队的人员,真正承担起引导者的角色。
  • 解决维护者覆盖问题:当然,最后一点至关重要。维护者覆盖是一个大问题。这已成为社区多年来的一个难题。因此,我们确实、确实、确实需要确保有人对此负责。

项目委员会的职能

项目委员会将由每个领域团队的一名代表组成。它将充当监督机构,并且是一个“兜底”机制。任何没有领域团队负责的事务,都将提交给项目委员会。这意味着在LLVM开源项目下,不会有任何领域缺乏负责协助促进决策制定的人员。

项目委员会也将是最终的决策者。如果其他各方无法达成一致,项目委员会将能够介入并做出决定。这项工作的核心很大程度上围绕着决策制定。提案中有一行我认为非常关键:“及时地说‘不’比说两年的‘可能’要好。” 因此,我们真正希望的是确保决策在时间线上做出,并且每个人都知道这些决策将在何时做出。

当然,如前所述,项目委员会将发布年度报告,确保每个人都知道进展情况、社区如何成长、我们可以做哪些改进,以及社区在未来几年面临的问题。


选举流程与时间表

进入领域团队的方式是通过选举。你只需要是LLVM GitHub组织的成员。任何人都可以提名自己或他人竞选领域团队的职位。希望在未来的年份里,领域团队能积极招募,以保持人员的流动。

以下是关于选举流程的具体安排:

  • 投票资格:运行选举时,GitHub组织中的任何人都有资格投票。我们不会根据你的贡献领域或方式限制投票权,但我们将使用GitHub组织作为衡量标准,因为这很简单。这可以随时间改变,并不完美,但真正的目标是使执行尽可能简单。
  • 投票方式:我们将有一个自动化流程来关联电子邮件地址和GitHub ID。LLVM项目最近有政策变更,要求你的GitHub账户关联一个你监控的电子邮件地址。只要人们遵守这一点,我们的脚本就能识别你拥有电子邮件地址,并将其用于选民注册。所有投票首次将通过电子邮件进行。我们将使用一个被Python和其他一些项目使用的电子邮件投票系统。
  • 灵活调整:我们将观察进展。如果这行不通,如果很混乱,我们下次会尝试其他方法。

以下是一些重要的日期安排:

  • 我将在十二月初发布选民识别脚本。
  • 我们将进行一些测试:运行脚本,生成每个人的选民信息,发送一些电子邮件,然后在Discourse上发帖通知大家。如果你收到了电子邮件,你就注册投票了;如果没有,请告诉我,以便我们找出问题所在。
  • 我们将进行几轮测试,以确保良好的选民覆盖。
  • 然后,我们将按照这些日期尝试进行。在我们解决了相关后勤工作后,我们将按照提案中规定的方式(包括每阶段持续多少周)进行提名和投票。
  • 希望在二月的第二周,我们将选出我们的第一个项目治理机构。

总结与展望

本节课中我们一起学习了LLVM治理提案的最新进展。我们了解了新设立的领域团队项目委员会的架构与职责,它们旨在促进决策、确保代码审查覆盖并培养社区领导力。我们还回顾了即将实施的选举流程和时间表。

这基本上就是我今天要分享的全部内容。我对我们在此方面取得的进展感到非常兴奋,希望这能解决我们社区中长期存在的一个难题。谢谢大家。😊

037:为什么你应该使用Scudo

在本教程中,我们将学习一个名为Scudo的强化内存分配器。我们将了解它的历史、设计、安全特性、性能表现以及如何开始使用它。本教程旨在让初学者能够轻松理解Scudo的核心概念。

历史与概述

大家好,我是Chris,这位是Chiang。我们都在谷歌的Android操作系统团队工作。本次我们将介绍为什么你应该使用Scudo。

首先,让我们快速了解一下Scudo是什么。Scudo是一个经过强化的内存分配器。它的名字来源于意大利语中的“盾牌”(Scudo)。它最初是作为LLVM Sanitizers工具套件中的分配器诞生的。

从Android的视角来看,它的历史是这样的:由于它最初内置于Sanitizers中,无法独立使用。主要问题是它与Sanitizer的构建过程紧密耦合,导致我们无法在其他地方使用它。后来,一位名叫Coia的开发者进行了大量工作,将其重构、分离出来,并修改了构建系统,使其能够以独立的方式构建。

大约在2020年,Android 11正式采用了Scudo。实际上,我们尝试在Android中采用它可能比这早几年。但要成为一个系统级分配器,它确实需要额外的工作、更好的性能和更低的内存占用。因此,直到2020年的Android 11版本,谷歌Pixel设备才首次真正使用了Scudo。

在之后的一段时间里,它基本处于维护模式。我们将其集成后,主要任务是确保它稳定运行。但在过去两年里,我们投入了大量资源和专职人员来改进Scudo,提升其性能和降低内存消耗。

Scudo的设计架构

上一节我们介绍了Scudo的起源,本节中我们来看看它的核心设计。我们知道有很多内存分配器,每个都有其独特的术语。这里我们快速概述一下Scudo的高层设计。

在Scudo中,主要有三个核心组件:

  1. 主分配器
  2. 次级分配器
  3. 缓存系统

主分配器是一个基于大小类表的分配器。对于每个内存请求,其大小会被向上取整到大小类表中最接近的条目。这里有一个术语叫“区域”。一个区域用于管理相同大小的内存块。因此,区域的数量取决于大小类表的定义。每个区域本质上是一个快速的内存池分配器。

次级分配器是一个基于mmap系统调用的分配器。顾名思义,如果缓存中没有可用内存,就需要通过系统调用来满足请求,因此它比主分配器慢。

缓存系统是第三个组件。像其他分配器一样,它有独占缓存,即每个线程的缓存。此外,Scudo还有一个特性叫共享缓存。访问共享缓存比访问主/次级分配器快,但比访问独占缓存慢。共享缓存的好处是,我们能够释放其中的内存块缓存,因此它比独占缓存占用更少的内存。

高级特性与安全功能

了解了基本架构后,我们来看看Scudo的一些高级特性和最重要的安全功能。

我们想提到的另一个特性是主动页面释放。我们知道,内存分配器通常会为不同大小的分配预留一些页面。当这些页面不再使用时,可能不会及时释放,或者需要手动释放。在Scudo中,我们支持一种模式,可以主动为你释放这些页面。

在Scudo中,我们还支持一个选项,可以转储内部状态。它包含了几乎所有细节信息,格式易于阅读,包括碎片信息、缓存命中率或任何区域使用率等信息。这对于理解程序的内存使用行为非常有用。

正如开头提到的,Scudo最重要的方面之一是安全性。它支持多项安全功能。以下我们列出其中最重要的一些:

  1. 指针随机化:在Scudo中,两个连续分配得到的指针地址不太可能相邻,因为我们进行了一些随机化处理。
  2. 指针隔离:对于一个被释放的指针,它在一段时间内不会被重用。
  3. 双重释放或越界访问检测:这些检测在指针被释放时进行。

需要说明的是,这些检查发生在指针释放时,因此不如其他工具(如ASan)那样强大。但最后,我们有一个更强大的功能,称为内存标签扩展。这是一个ARM v9架构的硬件特性,用于检测每次内存加载和存储操作中的无效访问。Scudo也支持此功能。

性能与内存占用

上一节我们探讨了安全特性,本节中我们来看看Scudo的性能和内存占用表现。这里我们不会涉及太多细节。

因为我们工作在Android领域,而geekbench是Android团队关注的重要基准测试之一。到目前为止,Scudo的性能与Android之前默认的内存分配器jemalloc非常接近,差异小于1%。

由于安全特性,我们知道指针随机化在开始时不可避免地会消耗更多内存。但我们付出了许多努力来减少内存使用。现在,我们认为两者的内存使用量已经相当接近。对于一些第三方游戏,Scudo甚至比jemalloc消耗的内存更少。

未来工作与如何开始使用

我们仍在进行一些未来的工作。首先,我们希望支持更多的内存使用数据,例如页面使用率。这主要针对较大的分配(例如100页的分配),因为我们有时注意到只有很少的页面被实际写入。这意味着程序可能不需要那么大的内存。从RSS的角度看没问题,因为未写入的页面不消耗物理内存。但从分配性能角度看,请求更小的尺寸会更好。另一件事是,我们希望支持一个更好的推荐系统,用于定义大小类表等参数。

最后,也是最重要的一点,你如何开始使用Scudo

以下是几种方式:

  1. 如果你有Android手机、模拟器或类似设备,可以尝试使用adb命令dumpsys meminfo来查看任何应用或进程的日志,你会在日志中看到Scudo的内部信息,阅读它可以了解Scudo的情况。
  2. 如果你对性能、内存或与实时程序相关的问题有疑虑,可以在开发或测试中尝试启用它。它可以帮助你更早地检测到一些内存错误,而且你不需要像设置Sanitizer那样复杂的配置。
  3. 在Android上,Scudo是bionic libc中的默认内存分配器。如果你使用LLVM的libc,你也已经拥有了它。

最后我想提一下配置。有一个allocator_config_default.h文件,它包含了所有分配器参数。当你准备创建自己的配置时,请查看它。

本次分享到此结束。如果你有任何问题或反馈,请随时联系我们。谢谢。

总结

本节课中我们一起学习了Scudo强化内存分配器。我们从它的历史讲起,了解了其作为独立分配器的发展过程。然后深入探讨了它的三层设计架构:基于大小类表的主分配器、基于mmap的次级分配器以及包含独占和共享缓存的缓存系统。我们还介绍了其关键的安全特性,如指针随机化、隔离以及对ARM MTE硬件的支持。最后,我们看到了它在性能上接近jemalloc,并了解了如何通过ADB命令或配置开始使用和探索Scudo。

038:将 RISC-V 支持引入 LLVM libc - 32位与64位的挑战与解决方案 🚀

在本节课中,我们将学习如何将 LLVM 的 libc 库移植到 RISC-V 架构(包括 32 位和 64 位版本)。我们将探讨移植过程中的基本步骤、遇到的主要挑战以及相应的解决方案。

概述:什么是 LLVM libc 和 RISC-V?

首先,我们来了解一下 LLVM libc。它是 LLVM 项目中的一个 C 标准库实现。它支持多种架构,如 x86、ARM、ARM64,现在也支持 RISC-V 的 32 位和 64 位版本。此外,它还支持为 AMD 和 NVIDIA 的 GPU 进行构建。该库主要使用 C++ 编写。

接下来是 RISC-V。RISC-V 是一种开放的指令集架构(ISA),它本身不是一个具体的芯片(如 Cortex-A7),也不是一个 IP 核(如 ARM Cortex 或 x86)。它类似于 x86 或 ARM 的指令集架构。

那么,为什么要将 libc 移植到 RISC-V 呢?主要动机并非物质性的,而是为了更深入地了解这个项目和 RISC-V 架构本身。我一直在寻找一个能为 LLVM 做贡献的项目,而 libc 正是一个完美的选择。

巧合的是,在我开始之前的一个月,已经有一个补丁为 libc 添加了基本的 RISC-V 支持,包括一些内存函数、字符串函数和 C 类型函数。但是,仍然缺少许多组件,例如 CRT(C 运行时)、浮点环境、线程、长跳转和信号跳转等。

如何为 libc 添加新架构支持?

为 libc 添加一个新架构支持,实际上只需要修改三个文件。以下是为 RISC-V 64 位启用支持的提交示例。

第一个文件是 llvm/libc/architecture/CMakeLists.txt。它基本上是从编译器查询架构名称。例如,它会查询 riscv64,这个名称将被用作构建路径的一部分。

第二个是入口点文件,例如 llvm/libc/src/__support/riscv64/entrypoint.cpp。这个文件列出了将为该架构启用的函数。

第三个是头文件列表,例如 llvm/libc/src/__support/riscv64/headers.txt。它列出了将为该架构启用的头文件。

基本上,通过这三个更改,你就为新架构在 libc 中建立了基本的支持框架。之后的工作就是添加具体的函数实现、运行测试、修复错误,并不断重复这个过程。

支持 RISC-V 的主要挑战

在支持 RISC-V 的过程中,我遇到了几个主要挑战。

第一个挑战是硬件可用性。当我开始时,并没有现成的 RISC-V 硬件可供使用。解决方案是使用模拟器,例如 QEMU 或 Spike。我最初使用的是 QEMU 用户模式,但它有时会以奇怪的方式失败。例如,某些系统调用会返回与预期相反的结果。

一个具体的例子是 madvise 系统调用,它用于向系统提供内存使用建议,成功时返回 0。但是,如果你传递一个无效地址作为第一个参数,它应该返回 ENOMEM(错误码 12)。在 QEMU 用户模式下,即使传递 nullptr,它也可能返回成功。

对于 RISC-V 64 位,解决方案是使用 QEMU 系统模式,这很容易,网上有很多可用的镜像。我一开始使用了 Ubuntu 的镜像。对于 RISC-V 32 位,你需要自己构建镜像。我使用了 Yocto,因为我希望镜像中包含编译器(如 GCC),以便在 QEMU 内部进行测试。我找不到用 Buildroot 实现这一点的简单方法。唯一的限制是生成的镜像大小被限制在 1GB,原因不明,但这对于测试 libc 来说已经足够了。

我认为最大的挑战是系统调用(syscall)的差异。RISC-V 不向后兼容一些旧的系统调用,它根本不提供它们。因此,你必须更新大量代码,通过条件编译来支持这些新的系统调用。

有些情况比较简单,比如 openlink,它们只是将名称改为 openatlinkat。特别是对于 RV32,它们只是添加了后缀 64time64

在其他情况下,RISC-V 会摒弃一组旧的系统调用,只使用一个新的。例如,waitwaitpidwait3wait4 被合并为单一的 waitid 系统调用。

代码适配示例

接下来,我将展示一些我提交的补丁。我不期望你完全理解代码,但它们应该比较简单明了。我们从易到难来看。

1. 简单重命名
对于像 fcntlflock 这样的系统调用,通常只需要更改调用的名称,参数保持不变,直接就能工作。

2. 参数填充
对于 dup2dup3 这类情况,数字通常表示系统调用接受的参数数量。如果你想用 dup3 来实现 dup2,你只需要填充一个额外的参数(例如设置为 0),它会被忽略,从而表现得像 dup2

3. 参数拆分
对于 preadv2 这样的调用,在 RV32 上,64 位的偏移量 offset 需要被拆分成两个 32 位的参数传递。你需要先传递高 32 位,再传递低 32 位。

4. 参数转换
对于 sched_rr_get_interval,你需要将一个 kernel_timespec 结构体传递给系统调用。内核会填充这个结构体,然后你需要将结果转换回原始调用所期望的参数格式。这稍微复杂一些。

5. 多功能合并
对于 waitid,正如前面提到的,RISC-V 没有单独的 waitwaitpidwait3wait4。你只有 waitid,因此必须处理所有不同的参数和返回值,确保每个旧调用的行为都符合预期。相关代码就是使用 waitid 来模拟所有这些旧调用。

其他棘手问题

除了系统调用,还有一些其他奇怪的问题。

1. 结构体成员顺序
在某些架构上,一些结构体的成员顺序是交换的。例如,struct siginfo_t 中的 errnocode 成员。由于传递参数的方式不同,x86 后端使用了 MIPS 的格式,但我们没有进行检查,导致出现了随机的错误。调试这个问题花了不少时间。

2. 不可失败的系统调用
一些新的 RISC-V 系统调用不会失败(例如 epoll_create),因此相关的测试用例需要被禁用。

3. 随机数生成器
在内部,我们使用 xorshift64* 作为伪随机数生成器,但在 32 位系统上它的随机性不够。因此,我们不得不为 RV32 使用另一种算法。

4. 隐式类型转换
由于 size_tlong 等类型的大小差异,存在大量的隐式转换问题。当在 libc 中启用 -Werror(将警告视为错误)时,很多 32 位代码会编译失败。不过,这至少让我们发现了那些需要显式处理的转换。

5. 长双精度与128位整数
RISC-V 有 128 位的长双精度浮点数(long double),但没有原生的 128 位整数类型。虽然可以强制编译器提供 128 位整数支持,但我们的代码库中有基础设施来避免依赖它。因此,一些测试用例在 RV32 上会失败,但 libc 的基础设施提供了绕过这个问题的方法。

当前支持状态与总结

最后,我们来看看当前的支持状态。目前,LLVM libc 共有 946 个函数,我们对其中约 86% 提供了 RISC-V 支持。剩下的部分中,有大约 30% 是几周前由一位学生添加的,我还没有时间详细检查,但它们应该可以正常工作。

目前最大的问题是 float16(半精度浮点数)的支持。几个月前我发现了一个问题:在 RISC-V 上,无法启用从 long doublefloat16 的转换。我已经提交了一个 issue,并且有一个修复补丁。但不幸的是,这个补丁有一个小问题:它会破坏 x86 后端。因此,要合并这个补丁可能会比较困难。

本节课中,我们一起学习了将 LLVM libc 移植到 RISC-V 架构的基本流程、核心挑战和解决方案。从建立基本的架构支持框架,到处理系统调用差异、结构体成员顺序、随机数生成等具体问题,这个过程充满了挑战,但也极大地加深了对底层系统软件和硬件架构的理解。希望本教程能为想要参与 LLVM 或系统软件移植的初学者提供一个清晰的指引。

039:在Windows on Arm上对Clang进行基准测试 - 构建与运行SPEC 2017 📊

概述

在本节课中,我们将学习如何在Arm架构的Windows系统上,使用Clang编译器构建和运行SPEC 2017基准测试套件,并将其性能与微软Visual Studio编译器进行对比。


测试环境与配置 🖥️

上一节我们介绍了课程主题,本节中我们来看看具体的测试环境与配置。

测试环境是一台较新的Surface Pro笔记本电脑。它配备了32GB内存和12核处理器,运行最新的Windows系统。

本次演示主要对比两个编译器的性能:LLVM 19和Microsoft Visual Studio 2022。GCC for Windows on Arm版本正在开发中,我们希望在下次会议上展示LLVM与GCC的对比数据。

我们比较了三种配置:

  • 优化体积
  • 优化执行性能
  • 调试模式(不优化)

请记住一个关键点:环境温度为26摄氏度。这一点稍后会很重要。


SPEC基准测试套件简介 📦

上一节我们了解了测试配置,本节中我们来认识一下基准测试工具。

SPEC基准测试套件包含C、C++和Fortran语言的基准测试程序。LLVM社区正在努力启用新的Fortran前端Flang(最近已更名为F18)。F18目前应该可以编译Windows上的所有基准测试程序,但在Windows上可能仍有一个程序无法编译。


性能对比结果 📈

上一节介绍了测试工具,本节中我们来看看具体的性能对比数字。

以下是C和C++基准测试的结果。如图所示,无论是优化速度、优化体积还是调试模式,Clang编译出的程序执行时间通常比微软Visual Studio编译器快5%到15%。

在浮点性能测试中,我们看到了相似的结果。

当我们查看多线程性能时,有时会发现更大的差距,在某些情况下甚至达到40%到60%。深入分析Clang为何能获得如此大的优势会非常有趣。我的猜测是,LLVM可能能够跨几个线程并行化AArch64的解码器和编码器。

在Fortran基准测试中存在一些异常值。但请记住,LLVM中的Flang项目在过去几年一直处于启用阶段,目前可能尚未有人真正关注其性能。因此,预计其性能会变得更好,并且速度会相当快。


代码体积对比 📉

上一节我们讨论了执行性能,本节中我们来看看代码体积的对比。

结果显示,Clang生成的代码体积甚至比MSVC更小,并且提升相当显著,达到了20%、30%、40%的改进。这包括在优化体积的配置下也是如此。O1实际上是优化体积,O2是优化速度,而这是调试构建。

最后,关于调试文件,PDB是Windows的调试格式。Clang生成的PDB文件也更小。这里有一个注意事项:比较调试信息的质量并不那么容易。所以,虽然文件可能更小,但我们不一定知道Clang和MSVC生成的调试信息质量是否相同。


Windows性能分析工具 🔧

上一节我们对比了编译结果,本节中我们来了解一个性能分析工具。

Windows on Arm有一个名为Windows Perf的工具,其设计灵感来源于Linux Perf,旨在与之相似。你可以使用它收集整个系统或单个进程的性能计数器数据,然后进行后续分析。该工具已在GitHub上发布。


总结与要点 🎯

本节课中我们一起学习了在Windows on Arm平台上使用Clang进行SPEC 2017基准测试的全过程。

如果你要从本次演示中记住两点,那应该是:

  1. Windows on Arm上的Clang已达到生产就绪质量。社区和Mike McLay投入了大量精力,确保Clang和LLVM在Arm架构上得到非常好的支持。如果你在为Windows构建应用程序时追求性能和更小的体积,请认真考虑使用Clang。
  2. 26摄氏度的环境温度。正确地进行可靠的基准测试很困难。如果你的工作包含基准测试,那么你完全有理由为家庭办公室申请一个啤酒冰箱——用来放置你的笔记本电脑或测试机器,并在其中进行基准测试。

040:项目概述与挑战

在本节课中,我们将学习如何使用LLVM工具链来构建glibc库。这是一个概念验证项目,我们将探讨其动机、面临的挑战、当前进展以及未来的工作方向。

项目动机 🎯

大家好,我是来自Leardo的Caros Seo。今天我将讨论如何为LLVM工具链构建glibc库。

首先,你可能会好奇我们为何决定进行这个项目。主要动机有以下几点:

  • 编译器多样性:我们希望允许glibc能够使用不同的编译器。
  • 代码质量提升:我们希望通过使用Clang的编译器警告集来改进glibc的代码质量。
  • LLVM功能增强:我们也希望通过添加最初为GCC开发的功能来改进LLVM。
  • 性能验证:我们希望验证glibc在两个编译器之间是否存在性能差异。
  • 生态系统构建:由于glibc是许多Linux软件包的基础依赖,我们希望未来能够开启使用LLVM构建完整Linux发行版的可能性。

面临的挑战 ⚠️

上一节我们介绍了项目的动机,本节中我们来看看实现这一目标所面临的巨大挑战。

  • C标准扩展:glibc使用了新的C标准,并依赖GCC扩展来实现许多功能。
  • 工具链依赖:glibc假定默认的汇编器和链接器来自GNU Binutils,这引入了另一层依赖。
  • 扩展不兼容:一些GCC扩展已被社区讨论过,并且LLVM将永远不会实现。此外,像128位浮点数这样的扩展在两个编译器之间并不完全兼容。

当前工作进展 🛠️

面对这些挑战,我们目前正在进行以下工作:

  • 移除扩展:我们移除了所有已知的、LLVM永远不会实现的GCC扩展。
  • 适配构建系统:我们调整了构建过程,使glibc能够使用LLVM的llg
  • 解耦依赖:我们重构了部分代码,以减少glibc与Binutils之间的硬性依赖。

这个概念验证项目可在Sourceware的官方glibc Git仓库中找到,位于Adir Vosonnel的分支下。我们的同事Adir在glibc方面完成了大部分工作。

以下是已完成工作的量化总结:

  • 架构支持:我们完成了约三分之一的工作,使glibc能够在x86_64和AArch64架构上构建。
  • 测试套件:我们完成了约三分之二的工作,以构建glibc测试套件中的所有测试用例。

这项工作是在使用LLVM Clang和GCC 11运行时库的情况下完成的。到LLVM Clang发布时,我们为支持此工作所需的所有补丁都已上游合并到LLVM中。

现有局限性与未来工作 🔮

由于这只是一个概念验证,目前存在一些局限性:

  • 架构支持不完整:glibc的架构支持尚未完成。
  • AArch64 ABI问题:AArch64由于编译器间的ABI差异而无法正常工作。
  • 代码生成问题:在数学测试套件中存在一些代码生成问题,主要涉及long doublefloat128类型。
  • 缺少LLVM运行时支持:目前完全没有LLVM运行时的支持。虽然可以使用LLVM的libc包装器来绕过此问题,但存在许多错误,因此实际上并不可用。

除了需要在glibc方面继续完成的工作,在LLVM侧也有很多事情需要做:

  • 尝试不同方法:我们需要尝试多种不同的方法来编译整个Linux系统。
  • 处理GCC依赖:glibc在许多方面依赖GCC,例如线程取消和软件浮点运算。GCC运行时和LLVM运行时在这些方面差异很大。
  • 添加必要符号:我们需要添加glibc用于线程取消所需的一些业务符号。
  • 扩展舍入模式:目前LLVM只支持一种舍入模式,我们需要为其添加额外的舍入模式。
  • 解决软件浮点问题:在软件浮点运算方面存在另一个问题,因为LLVM运行时会为每一个软件浮点调用生成PLT存根,这是我们希望避免的。

总结与邀请 🤝

本节课中我们一起学习了使用LLVM工具链构建glibc的概念验证项目。

以上就是我今天要分享的关于这个项目状态的全部内容。这个PR/POC可以用于实验目的。正如所说,它可以在Sourceware仓库中找到,你可以下载并构建它。它也能与当前最新的LLVM协同工作。

如果你对这个项目感兴趣,请务必与我们保持联系。我们正在寻找志同道合的人一起推进这项工作。

我的分享到此结束。谢谢大家。

041:基于模式的IR重写在MLIR中的现状

在本节课程中,我们将学习MLIR中两种主要的模式重写器:贪婪模式重写器和方言转换器。我们将探讨它们近期的变化、过去几年总结出的最佳实践,以及针对方言转换器正在进行的一些重构计划。

🧭 概述:MLIR中的IR遍历机制

首先,我们快速回顾一下MLIR中现有的IR遍历机制。

  • IR遍历:这是最简单的机制,基于访问者API。它为每个操作、基本块或区域提供一个回调函数,你可以在Lambda函数内部执行操作。
  • 贪婪模式重写驱动器:这是一个基于模式的重写引擎。它会持续应用模式,直到IR不再发生变化,即达到一个不动点。
  • 方言转换:这也是一个基于模式的重写器,但略有不同。它不进行不动点迭代,只关注被标记为“非法”的操作,并且总是从上到下遍历IR。
  • 转换方言:它通过句柄来匹配IR,然后转换操作决定如何处理IR。

从左到右看,这些系统的复杂性和运行时开销逐渐增加,其中方言转换已被证明是相当昂贵的。

本节课我们将重点讨论贪婪模式重写器和方言转换器。

🔄 贪婪模式重写器 vs. 方言转换器

如果将两者并排比较:

  • 入口点
    • 贪婪模式重写器:applyPatternsAndFoldGreedily
    • 方言转换器:applyFullConversionapplyPartialConversion
  • 应用对象
    • 贪婪模式重写器:尝试对所有操作应用模式,并尝试折叠和擦除操作。
    • 方言转换器:只对根据转换目标标记为“非法”的操作应用模式。它也会尝试折叠操作,但这实际上是不安全的。
  • 遍历顺序
    • 贪婪模式重写器:不保证遍历顺序,也不保证访问操作的次数。如果IR发生变化,重写器可能会回溯并重新尝试。
    • 方言转换器:只查看操作一次,只查看非法操作,并且总是从上到下查看。因此,你对其开销有一定预期。
  • 回滚机制
    • 方言转换器:具有回滚机制,可以撤销更改。
    • 贪婪模式重写器:没有此功能。

📈 近期更新

上一节我们对比了两个重写器的核心差异,本节我们来看看它们近期的具体更新。

贪婪模式重写器的更新

  • 监听器支持:可以监听贪婪模式重写驱动器所做的更改。这是为了将其集成到转换方言中。
  • 转换方言集成:新增了 transform.apply_patterns 操作,允许从转换方言脚本中运行贪婪模式重写。
  • 昂贵的模式检查:新增了调试API或额外的调试检查,帮助你发现重写模式的问题。
  • 区域简化:添加了一些区域简化的功能。
  • 入口点统一:所有入口点现在都接受一个 GreedyRewriteConfig 配置。
  • 其他小改动

方言转换器的更新

  • 监听器支持:已添加,但并非所有情况都支持。例如,当操作被移动时,监听器回调不会告知操作的原始位置。
  • 转换方言集成:已集成到转换方言中。
  • 源/目标具体化可选:源参数和目标具体化现在是可选的。
  • 新API支持:添加了对新API的支持,特别是 moveOpBefore/moveOpAfter
  • 内部修复和检查:进行了许多内部错误修复并添加了额外检查,以增强基础设施的健壮性。

🛠️ 最佳实践与API规则

了解了更新内容后,我们来看看一些在社区讨论中总结出的最佳实践和API规则。

如何选择重写器?

首先,面对一个任务时,你该如何选择?

  1. 优先使用 IR Walk:在大多数情况下,你可能只需要使用操作遍历(访问者API)。这始终是最有效的解决方案,因为没有工作列表,没有驱动器开销。如果可行,请使用它。
  2. 使用贪婪模式重写器:如果你确实需要不动点迭代(即需要多次查看操作直到没有变化),或者想要组合来自不同组件的模式集合。
  3. 使用方言转换器:当你需要进行类型转换时(例如,用一个具有不同类型的值替换操作),基础设施会负责插入必要的转换。

重写模式中的 matchAndRewrite 规则

在重写模式中,仅当IR被修改时才应返回 success

违反此规则的后果:

  • 返回 success 但未修改IR:告诉重写器模式已匹配并应用,导致驱动器进行另一次迭代,可能触发无限循环。
  • 返回 failure 但修改了IR:告诉重写器模式未匹配,但IR已处于不一致状态,可能导致后续模式看到部分应用的更改。

对于转换模式,规则更宽松:如果模式成功(操作被擦除或就地修改为合法),则返回 success。实际上,允许返回 failure,方言转换器会自动回滚所有更改。但为了提高驱动器效率,我们正尝试移除后一点。

IR验证与修改规则

  • 模式应用后IR应验证:这不是严格的MLIR规则(MLIR只要求每次传递后IR有效),但在实践中非常有用,尤其是对于公开的重写模式。这有助于构建可组合的系统。但请注意,并非总是可行(例如,重写函数签名和调用操作时)。
  • 所有IR修改必须通过重写器:这是一个硬性API规则。不要直接使用 op->erase() 等方式擦除IR。必须通过重写器进行。原因是方言转换和贪婪模式重写器会附加监听器来获知更改。如果直接擦除操作,它可能仍在工作列表中,导致驱动器在悬空指针上运行并崩溃。

为了捕获此类问题,我们新增了一个CMake标志:MLIR_ENABLE_EXPENSIVE_PATTERN_API_CHECKS

它会检查以下情况:

  • 修改了IR但返回了 failure
  • 每次模式应用后IR是否验证。
  • 确保通过重写器进行修改(尽管不能检测所有情况)。

使用此标志时,建议同时启用地址消毒器,以便在违反规则导致程序中止时获得更清晰的错误信息(例如,访问悬空指针的位置)。

关于规范化器的注意事项

不要依赖规范化器传递来保证正确性(这里主要指在 lowering 过程中取得进展)。

  • 规范化器是一个优化传递,应被视为可选的。它内部运行贪婪模式重写并有截止限制,因此不能保证结果IR是完全规范的。
  • 你的所有传递流水线也应该能在没有规范化器传递的情况下工作。
  • 规范化器运行所有规范化模式,数量众多,可能带来效率问题。其他人添加的新模式也可能导致你的IR发生变化。

此外,我们添加了另一个CMake标志:MLIR_GREEDY_REWRITE_RANDOMIZER_SEED。由于贪婪模式重写不保证操作处理顺序,此标志允许你通过手动随机化来强化你的传递,检查是否因处理顺序不同而遇到边界情况。

方言转换模式中的注意事项

方言转换中存在一些不允许的操作,目前并不完全清楚什么是允许的、什么是不安全的。

  • 不要遍历IR:方言转换的问题在于,它执行的某些更改不是直接具体化的,而是延迟具体化的。例如,删除一个操作时,它只是被标记为待删除,但仍然存在。替换操作时,操作数的使用尚未更新。如果你查看IR,看到的仍然是旧的IR。只有在确保方言转换会成功时,才会执行这些更改。因此,检查IR的代码可能看到的是旧IR。所以,通常不安全查看与你匹配的操作不同的操作,无论是向前看还是向后看。
  • 注意不支持的API:例如,不要在重写模式或转换模式中尝试附加监听器或从重写器获取监听器,因为驱动器会附加自己的监听器,覆盖它会破坏驱动器。
  • 方言转换的重写器上有些函数不受支持:例如 replaceAllUsesWith,这会扰乱方言转换的内部状态。如果你需要此功能,replaceOpapplySignatureConversion 可能就足够了。

不要混合使用重写模式和转换模式

不要尝试在方言转换中使用重写模式。虽然从类结构看,一切继承自 PatternRewritePattern 继承自 PatternConversionPattern 继承自 RewritePattern,看起来API是可组合的,但事实并非如此。因为重写模式可能使用 replaceAllUsesWith 或遍历IR,这在方言转换期间是不安全的。因此,通常不应混合使用两者。上游MLIR中存在一些违反此规则的情况,但在那些情况下,模式并未使用此类API,所以实际上是安全的。但请记住,混合使用通常是不安全的。

反之亦然,不要在贪婪模式重写中使用转换模式。转换模式会尝试将重写器向下转型为 ConversionPatternRewriter,这可能会失败并导致程序崩溃。

调试技巧:buildMaterializations=false

如果遇到错误信息,可以尝试使用新标志 buildMaterializations=false。它的作用是禁用所有具体化,改为插入 unrealized_conversion_cast 操作。这是一个非常有用的调试工具,因为转换会运行完成,你可以查看IR中所有的 unrealized_conversion_cast,并推理为什么会出现类型不匹配。

学习方言转换的快速提示

许多东西是可选的:

  • 类型转换器是可选的。
  • 源/目标具体化是可选的(近期更改后)。
  • applySignatureConversion 是可选的。

你可以使用 inlineBlockBeforereplaceUsesOfBlockArgument 来完成大部分工作。因此,学习方言转换时,可以先忽略这三样东西,单独看待方言转换,这样可以减少很多复杂性。

转换目标是必需的,因为它指定了哪些操作是非法的。

🔮 未来计划

最后,我们来看看方言转换器的一些未来计划。

添加一对多支持

方言转换器已经对块参数有初步的一对多支持(通过 applySignatureConversion API),但目前不支持 replaceOp。我们计划添加一个名为 replaceOpWithMultiple 的新函数,其中每个结果被一个值范围替换。这在某些场景下很有用,例如,一个内存引用(一个SSA值)可以扩展为构成内存描述符的多个值。

这需要一个转换模式的新入口点,该入口点接受一个适配器,该适配器接受一个值范围列表。我们有一个原型实现了这一点,并且它可以与自动生成的C++类(操作适配器)一起工作。

一旦实现,我们就可以移除参数具体化,只保留源和目标具体化(它们已经是可选的)。参数具体化是方言转换中的一个变通方案,用于将多个SSA值转换回单个SSA值,因为方言转换在某些地方不支持多个替换值。

然后,最终可以删除一对一方言转换(位于 OneToNTypeConversion.h 中),这是一个与主方言转换基础设施并行的独立方言转换基础设施,届时将不再需要它。

一次性方言转换

另一个正在讨论的RFC是关于一次性方言转换。其思想是提供一个更快、更高效的方言转换驱动器,它没有回滚机制。

如前所述,在当前方言转换驱动器的转换模式中,你可以开始修改IR,然后返回 failure,这意味着该模式无法取得进展,我们会回滚所有更改。这实际上非常昂贵,因为我们需要跟踪模式期间发生的所有事情。在EuroLLVM的主题演讲中,我们看到方言转换实际上相当昂贵:每次模式应用每个操作大约需要5000纳秒,而贪婪模式重写每个操作只需160纳秒。这对编译时间造成了相当大的负担。

当前方言转换方法的另一个问题是难以理解和调试。例如,使用 -debug 标志运行时,你会看到新旧IR的混合,一些SSA值被标记为无效,很难理解发生了什么。所有这些问题在新方法中都不应再是问题。

此外,这将允许我们将重写模式和转换模式组合在一起,并且可以支持像 replaceAllUsesWith 这样的方法,因为所有更改都会立即具体化,不再需要额外的簿记。

🎯 总结

本节课我们一起学习了MLIR中两种核心模式重写器(贪婪模式重写器和方言转换器)的对比、近期更新、一系列重要的最佳实践和API规则,以及方言转换器未来的发展方向。理解这些内容将帮助你更安全、更高效地使用MLIR的重写基础设施。

042:LLVM 预合并测试 - 现状与未来规划

概述

在本节课中,我们将学习 LLVM 项目当前的预合并测试基础设施的现状、面临的挑战,以及正在开发的新系统。我们将了解从代码提交到测试运行的完整流程,并探讨如何通过技术改进提升整个系统的效率、透明度和社区参与度。

当前系统:已“弃用”的架构

上一节我们概述了课程内容,本节中我们来看看 LLVM 当前正在使用的预合并测试系统。演讲者将其幽默地比作谷歌内部常说的“已弃用”的系统。

当前流程始于开发者向 GitHub 提交一个拉取请求。在 PR 页面中,会出现一个指向 Buildkite 的详情链接。任何用户都可以点击查看,无需特殊权限。

进入 Buildkite 页面后,你会看到测试任务的状态。例如,一个 Linux 构建任务正在队列中等待,而一个 Windows 构建任务正在执行,已运行了15分钟。

测试的核心是一个存储在 LLVM 代码库中的脚本:docs/generate_build_pipeline_premerge。该脚本会分析 PR 中修改的文件路径,试图判断需要运行哪些测试。脚本运行速度很快。

在这个案例中,系统判定需要在 Linux 和 Windows 上测试几乎所有组件。Windows 任务等待了9秒后开始,运行了23分钟。而 Linux 任务在等待了36分钟后,仍在队列中。

这种排队延迟在高峰期尤为严重。此前,Windows 队列延迟曾达到数小时,后来通过增加机器资源暂时缓解了问题。排队情况呈现明显的昼夜规律,与美国西海岸的工作时间高度重合。

以下是当前基础设施的工作原理:

  1. PR 提交到 GitHub。
  2. GitHub 发送 Webhook 到 Buildkite。
  3. Buildkite 执行分析脚本。
  4. Buildkite 将构建任务分派到运行在 Google Cloud Kubernetes 集群上的机器。
  5. 这些机器(包括 Linux 和 Windows 节点)执行测试。

通过监控 Kubernetes 实例可以发现,这些机器在高峰期的 CPU 使用率几乎始终处于高位,资源被充分利用。

当前系统面临的问题

上一节我们介绍了当前系统的工作流程,本节中我们来看看这个系统存在的主要问题。演讲者列举了以下几个核心挑战:

  • 缺乏自动扩缩容:机器始终处于运行状态。这意味着在周末等低使用期仍需支付费用,而在高峰期又因资源固定而导致排队延迟。理想情况是只在需要时才启动机器,以提高资源利用效率。
  • 缺乏分析与告警:目前所有数据都依赖人工观察,没有系统的分析平台。我们无法定量评估平均排队时间或成功率,只能定性描述“变好”或“变差”。同时,由于没有监控告警,问题往往需要社区成员通过 Discord 等渠道手动报告才能被发现。
  • 稳定性问题:系统需要大量人工干预来维护稳定性,例如手动重启故障机器。
  • 流程冗余:在 LLVM 迁移到 GitHub 后,Buildkite 作为中间环节显得有些多余。直接从 GitHub 触发构建可能提高可靠性和反馈及时性。
  • 可用性与社区参与度低:当前系统不是一个真正的社区项目。部分组件只有谷歌员工才能修改,文档陈旧且存放在独立仓库中,对社区成员不够透明和友好。

这些问题共同导致了当前基础设施的现状。

新系统:即将“就绪”的解决方案

上一节我们探讨了旧系统的诸多痛点,本节中我们来看看旨在解决这些问题的新系统。这套新架构由 Aden Grosman 主要开发,其核心是用 GitHub Actions 配合 自托管运行器 集群来替代 Buildkite。

新系统的工作流程如下:

  1. 用户创建或更新 PR。
  2. GitHub 工作流 被触发。
  3. 工作流任务被分派到自托管的运行器集群。
  4. 集群上的 GitHub Actions Runner Controller 为每个工作流启动对应的运行器 Pod。
  5. 这些 Pod 会被调度到已有的集群节点上,如果资源不足,集群可以自动扩容,增加新节点。这是相较于旧系统的一大改进。

对于 Linux 任务,系统会配置为在 Pod 中启动一个容器任务。这样做有两个关键好处:

  • 允许在集群内使用 Kaniko 等工具构建容器镜像。
  • 允许社区任何成员修改构建环境所用的容器镜像。而在旧系统中,镜像只能由谷歌通过 Google Cloud Build 来构建和更新。

对于 Windows 任务,由于 GitHub 目前不支持在 Windows 工作流中运行容器,因此构建直接在 Pod 内进行。未来希望实现与 Linux 类似的环境。

测试完成后,GitHub 基础设施会负责报告状态和日志。新系统的日志以分步骤的形式呈现,比 Buildkite 单一的巨型日志更易于查阅。

关于合并后测试,计划在同一套基础设施上运行,即也通过 GitHub Actions 和自托管运行器执行。这能确保预合并和合并后测试环境一致,避免因配置不同步导致的维护负担,并能更好地测试基础设施本身。

新系统的配置遵循 GitHub 官方推荐的最佳实践,并有详细的设计文档,这提高了项目的规范性和可维护性。

数据分析与可视化

上一节我们了解了新系统的架构,本节中我们来看看与之配套的数据分析平台。新系统引入了 Grafana 来提供强大的数据分析和可视化能力。

我们建立了一个 Grafana 实例,并创建了示例仪表盘。目前展示的指标包括:

  • 运行时与排队时间
  • 成功率移动平均

选择 Grafana 的原因包括:文档完善、行业标准、应用广泛。目前使用的是 Grafana 的托管服务,以确保完全遵守其开源许可。

我们计划追踪的关键指标有:

  • Linux 和 Windows 的排队延迟
  • 任务运行时间(与排队延迟相关)
  • 任务成功率(例如,如果95%的任务失败,则很可能是基础设施问题)
  • 服务器利用率(辅助容量规划)

这个平台是开放的,社区可以提议和添加其他有用的分析维度。

未来规划与总结

上一节我们介绍了新系统的数据分析能力,本节中我们来看看项目的下一步计划并进行总结。

下一步计划

  1. 完成初步实现:完善分析平台和文档,确保在征求社区反馈时,大家能聚焦于未知问题而非已知缺陷。
  2. 影子发布:计划在年底(假设存在假期代码提交放缓期)进行影子发布。届时新旧两套系统将并行运行,以验证新系统的稳定性。选择假期是希望减轻因资源加倍带来的压力。
  3. 正式切换:当新系统被验证为“基本就绪”后,将关闭“已弃用”的旧系统。

未来愿景

  • 基于数据的决策:引入数据后,社区可以更理智地讨论资源分配问题,例如:在固定预算下,如何在排队延迟、测试覆盖范围(如是否测试 Flang)、以及 Linux 与 Windows 资源分配之间取得平衡。
  • 提升社区参与:开放系统,让更多社区成员能够参与维护和扩展。
  • 建立清晰的告警与升级路径:改变目前依赖非正式渠道(如 Discord @某人)报告问题的状况,实现主动告警。

总结
本节课中我们一起学习了 LLVM 预合并测试的演进。我们从当前基于 Buildkite 的、面临扩容、分析和社区参与挑战的系统出发,深入探讨了新的基于 GitHub Actions 和自托管运行器的解决方案。新系统带来了自动扩缩容、强大的 Grafana 数据分析、更高的社区可参与度以及更规范的文档。项目正朝着在年底进行影子发布并最终替换旧系统的目标迈进,旨在为 LLVM 社区提供一个更可靠、透明和高效的基础设施。

043:面向嵌入式系统的高级链接器脚本

在本教程中,我们将探讨如何为嵌入式系统开发更高级的链接器脚本。我们将了解传统链接器脚本在嵌入式开发中的局限性,并学习两种新机制:非连续区域节类。这些机制允许链接器更智能地分配代码和数据,减少开发者的手动配置工作,并支持链接时优化。

嵌入式系统背景介绍

上一节我们介绍了本课程的主题。本节中,我们来看看嵌入式系统,特别是微控制器领域的一些基本背景和约束。

“嵌入式系统”对不同的人意味着不同的事物。我们将其范围缩小到微控制器领域。

这些系统通常对人类在以下一个或多个方面极为敏感:成本功耗延迟。这里的延迟指的是硬件和软件对来自数字输入线或中断等事件的反应速度。

如果对其中一项有极端约束,那么通用应用处理器通常无法超越某个特定点来解决这些问题。通用应用通常也没有能力满足这些约束。

因此,在这个领域中,你需要运行非常专用的硬件和非常专用的代码,以使某些东西足够便宜、功耗足够低或反应足够快,以满足你的设计需求。

这些关注点对硬件设计施加了非常具体的约束。我不是硬件专家,所以请对我的话持保留态度。我算是间接相关。

这是一张树莓派 RP2040 微控制器的芯片照片,上面画出了所有组件的小方框。这确实像是“这些晶体管用于这个东西”。

外面两个大的斑块是 SRAM,它们非常大。如果你看到最右边 SRAM 的左侧,有 Proc0 和 Proc1,那是两个 ARM Cortex-M0 内核。你可以把它们放进其中一个 RAM 斑块里,一次、两次,也许三次。

假设所有硬件都按比例缩放(当然实际并非如此),如果你愿意接受一半的 RAM,你可以拥有一个八核微控制器,但这不是典型做法。

之所以如此,是因为静态 RAM 是我们目前拥有的唯一一种功耗足够低、延迟足够小、能在这些领域中工作的 RAM 技术。

但不幸的是,它在第三个标准——成本上失败了。在低端,每个比特大约需要四个晶体管,这是一种非常、非常昂贵的晶体管使用方式。当你拥有 256KB 字节乘以 8 位时,相对于你做的几乎所有事情,它都需要大量的晶体管。但你仍然需要一些,所以它很昂贵,人们会尽一切可能避免需要大量 SRAM。

这带来了一些直接的影响。这些系统往往没有内存管理单元的原因之一是,高效使用 MMU 意味着你必须不断地换入换出页表。页表的大小与主 RAM 成比例,所以通常没有空间让所有页表都常驻内存。如果你最终拥有大量 RAM,情况更是如此。

在大多数情况下,在这些系统上,最终所有东西都是物理地址。你没有进行地址转换的那一层,因为有效实现地址转换所需的相联存储器放不下。

同样常见的是,使用廉价的片外存储器来替代这种昂贵的、通常位于芯片上的 SRAM。这样做的一个原因是,你可以通过外部总线连接到微控制器外部的存储器,使其制造成本更低。在某些情况下,如果你能承受其功耗,可以使用动态 RAM,这要便宜得多。你也可以使用只读存储器,同样更便宜。或者,你可以在更便宜的工艺上使用速度非常慢的存储器。也许你并不总是给它供电,也许它在启动时不可用,需要你进行设置。也许你通过有限的总线与其通信。这种存储器实际如何工作有很多约束。

因为这些系统往往没有 MMU 和可以动态进行内存重映射的操作系统,所以处理这些问题的成本或多或少落在了链接器身上。链接器重定位代码,它指定某个东西将位于哪个地址,并用指令中对这些地址的实际引用来替换所有符号地址。因此,所有处理这些问题的逻辑都必须以某种方式在链接器中完成。

传统链接器脚本的挑战

上一节我们了解了嵌入式系统的硬件约束。本节中,我们来看看开发者如何通过链接器脚本来应对这些约束,以及传统方法的局限性。

这些东西都非常定制化。因此,开发者必须告诉链接器这些约束以及如何处理它们。

他们使用的主要机制是一种叫做链接器脚本的语言。存在许多嵌入式链接器,它们都有不同的机制。GNU LD 的 LD 在 LLVM 中大部分被复制了。下面是一个简单的例子。

假设我们有一个 fastRam 和一个 slowRam。你可以说这是两个内存区域,指定它们在内存中的起始位置和长度。

然后你可以说:我希望你把每个文件中所有属于 data 的东西都放到 fastRam 里,然后把所有其他属于 data 的东西放到 slowRam 里。

请注意,这并不是这个链接器脚本实际表达的意思,我们稍后会回到这一点。现在,我们先假设这就是它的意思。

如果它真的这么说,工作方式可能是这样的:你有一些数据节输入,比如一个 800 字节和一个 400 字节的节。将第一个分配给第一个区域,这没问题,fastRam 有空间容纳它。你可能尝试分配第二个,但会发现“不,不,不,我们没有足够的 RAM 了,放不下”。于是你将其“溢出”到下一个可以放置的地方,并承受性能损失,因为你需要所有东西都能放得下。这不仅限于数据,代码也需要以同样的方式工作。

然而,如果你真的尝试这样做并使用 GNU LD 或 LLD,实际发生的情况是链接器会抛出错误并失败。

原因是这个链接器脚本并不完全是一个约束系统。它的语义是,这些通配符节会被放在它们首次匹配的地方。从上到下阅读这些规则,你确实可以在脑海中预先确定所有东西将放在哪里。实现没有余地以任何其他语义来放置东西。

这意味着程序员说所有这些都将进入 fastRam。然后当链接器实际上无法将其放入时,它别无选择,只能失败。根据这些脚本被赋予的语义,它不允许做任何不同的事情。

传统应对方法及其问题

上一节我们看到,链接器脚本的“首次匹配”语义导致分配不灵活。本节中,我们来看看开发者通常如何应对这个问题,以及这种方法带来的麻烦。

人们通常处理这个问题的方式是在代码库中玩一种“俄罗斯方块”游戏。你不一定必须使用通配符。你可以说这个文件或这个静态库的节要放在这里。因此,你可以以非常精细的粒度将东西放在不同的地方,为自己优化性能,或者即使你只是需要东西能放得下,你仍然必须这样做。如果你有几个 RAM,情况就是这样。

我们不断看到人们随着代码和数据大小的变化而调整这个。通常这些适配非常紧凑。因此,你必须用非常精细的梳子梳理,挑选出足够多的东西,确保在一个节中调用足够多但又不至于太多的东西,然后用通配符将所有其他东西放入第二个节。

这有点糟糕。也许只是从我的角度来看,但我们也从合作过的人那里听到了同样的看法。在我看来,这恰恰是我们最初制造计算机的原因——为了不必做这种文书工作。这不是一项有意义或有趣的任务,而且似乎是机器完全可以解决的那种问题。

更糟糕的是,这种手动分配依赖于文件名能传递过来。正是这个特性使得链接器脚本无法与 LTO 一起工作,正如 Peter 在他的演讲中提到的。LTO 在大多数情况下会删除文件,它接收所有输入文件,将它们全部合并为一个对象文件,然后传递给链接器的通常是类似 lto.o 这样的东西。虽然可能有办法解决这个问题,但它们对链接器来说有些侵入性,至少目前,无论是 GNU 领域还是 LLVM 领域,都没有人真正敲定解决方案或使其工作。所以至少今天,这是阻碍 LTO 的一个问题。

解决方案一:启用非连续区域

上一节我们讨论了传统方法的繁琐和与 LTO 的冲突。本节中,我们来看看在 GNU LD 中发现的一个解决方案:--enable-noncontiguous-regions 标志。

当我们寻找 LTO 的解决方案时,实际上发现了 GNU LD 中一个我们团队没人听说过的奇怪标志。我问了一圈,也谷歌了,似乎没人知道这个标志的存在。关于它的最高搜索结果是在 GNU LD 手册中,也许还有一个 Stack Overflow 问题在问它是否有效。

它叫做 --enable-noncontiguous-regions,这个名字对于它的功能来说有点奇怪。它使事情像第一张幻灯片那样工作。它确实启用了溢出机制,并将通配符的含义从“这放在它首次匹配的地方”改为“每个匹配项都是它可能去的地方”,并且链接器会在东西超过大小时从一个地方溢出到下一个、再下一个地方。这很棒。

这个功能在 GNU LD 中。我们将其移植到了 LLD。如果东西放不下,它们会溢出到后面的地方。

这本身就在很多情况下解除了 LTO 的限制。如果你不必指定文件名,如果你可以只使用通配符,那么你可以使用这种自动溢出和放置机制,将一切进行 LTO 链接,然后让链接器根据空间填充各个节。

你无法获得那么多控制,我认为这是我们希望解决的问题,但它确实有效。

这个功能的主要缺点是它全局地改变了链接器脚本的含义。因此,如果你依赖这种“首次匹配、落空”的行为,那么可能无法将脚本移植来使用这个标志。拥有全局改变语言行为和语义的标志本身就有点令人担忧。

解决方案二:节类

上一节介绍的全局标志有其缺点。本节中,我们介绍一种更精细的新语法:节类,它保留了通配符原有的工作方式。

我们做的另一件事是共同提出了一种称为节类的新语法,它保留了通配符原有的工作方式。

现在,你可以使用通配符将一堆输入节绑定到一个名称,在这个例子中我给它起名叫 data,这就变成了一个你可以稍后引用的节类。一旦你这样做了,这些节就像往常一样从池中移除。在这种情况下,首次匹配的是这个类,然后它们就不允许匹配任何其他通配符了。但是,一旦你将它们匹配到一个类,你就可以在与使用其他通配符相同的上下文中使用它们,来表示这个类可以去的所有地方。

因此,你可以说 data 可以先去 fastRam,然后 data 可以去 slowRam。这样做的主要优点是它提供了上一张幻灯片的所有功能,但你可以增量地将其添加到现有的链接器脚本中。

目前,它只在 LLD 中实现,但当我们讨论时,GNU LD 的维护者似乎对此也相当认可。所以这最终可能会出现在两个链接器中。

溢出算法的工作原理

上一节我们介绍了两种新机制。本节中,我们深入了解一下溢出逻辑在高层是如何工作的,以及它带来的一些复杂性。

这就是那个项目,我们做的事情。听起来简单,但并不简单。GNU LD 中的一切都充满了意想不到的复杂性,而这是我想讨论的事情之一。这种复杂性是用户过去必须处理的东西,但现在我们将其引入了链接器。我将此演讲命名为“高级链接器脚本”的原因是我认为这对嵌入式领域是一个好的方向,开始将更多决策性和编译性元素构建到链接器中,这可以减少嵌入式系统中生成这类布局所需的时间。

我将通过一个简单的例子,从高层次说明溢出逻辑是如何工作的,以及它的一些复杂性,然后我们会更深入地探讨。

假设我们有两个内存区域,都像上一个例子一样是 1000 字节。我将它们重命名为 A 和 B。我们有三个节:一个可以进入 A 然后 B,一个只能进入 A,另一个可以进入 A 或 B。

我们可能开始分配这些节。像之前一样,我们发现存在溢出。但与之前不同的是,我最初想象的工作方式是,你只是从左到右进行,一旦遇到溢出,你就开始将东西移到下一个它们能放得下的地方。但是,导致溢出的那个节是你实际上无法移动的。你最终可能会发现后面还有节。因此,你需要回溯并溢出一些东西,直到你有可能清除这个溢出。你至少需要溢出 0x200 字节到下一个区域,然后你可能能够继续并获得一个有效的分配。

我们实际上确实这样做了。链接器中有一个不动点算法,用于处理地址分配决策可能增加或减少东西大小的情况,而这需要反馈到同一组决策中。这就是一个类似的案例。很难提前猜测溢出某物的确切影响,所以我们采用“后进先出”的方式溢出一些东西,然后再次运行那个循环。它被添加到这个不动点循环中,给它另一个机会看看是否真的解决了冲突并让东西能放得下。这个过程会继续固定的次数,直到我们确定这不够或链接失败。

实现细节与挑战

上一节概述了算法流程。本节中,我们探讨实现这个功能时遇到的具体挑战和设计权衡。

即使这样,也不是说破坏了一切,但这里列出了链接器脚本和链接器中所有与此功能以有趣方式交互的现有特性。实际上有很多,其中一些特性并没有明确的语义。这个列表的大小至少对我来说有点令人惊讶,我们实际上必须逐一研究这些特性,并试图找出对它们来说有意义的东西。这背后是有原因的。

即使是这样一个功能,也会在链接器的逻辑中引入我称之为可怕的循环依赖

它影响了关于哪些输入节进入哪些输出节的决策,比如用户代码进入二进制文件的位置,而这在 GNU LD 中发生得非常早。大量决策都需要这些信息,比如“这个东西最终会放在哪里?”,“那么那个东西的对齐方式是什么?”,“程序头会是什么?”。有很多决策需要做,然后所有这些都需要用来分配最终地址。但是,最终地址是会导致溢出的东西,而这又会改变输入到输出的映射关系。

因此,如果我们想要拥有那种“仿佛”从一开始一切就都在其最终位置的完美语义,我们将不得不在一个大循环中运行几乎整个链接器以达到不动点。由于性能和架构原因,这基本上是不可行的。

我们没有那样做,而是采用了一种方法,记录所有某个东西可能最终到达的地方作为潜在的溢出位置,并将它们视为链接器操作的实际项、节。它准备这些位置、这些节,以便在最终分配地址时可能接收真正的节。然后,当我们基于此进行地址分配时,我们可以做出决策,并知道无论这个东西最终去哪里,它去那里都是安全的,然后清除所有东西实际上没有去的地方。

算法示例与扩展可能性

上一节我们了解了实现中的循环依赖问题及其解决方案。本节中,我们通过一个修改后的例子来展示这个机制,并探讨未来的扩展方向。

在展示另一个例子之前,我先做一点修改。我现在说所有东西都可以去任何地方。前两个节说它们里面有字符串,链接器可以将它们合并在一起。这是链接器可以在中间对你的节做的事情的一个例子。

我们首先将东西放在它们能去的第一个地方,然后在它们能去的下一个地方创建它们的副本,即潜在的溢出。然后我们可以进行链接器逻辑,比如将这两个节合并在一起,创建一个新节。假设这个新节是 0x400 字节。那个 0x400 字节的潜在溢出节现在什么都不做了,它消失了,因为可能溢出到那里的节已经不存在了。

有很多情况会导致节被插入或删除。我们基本上不去管这些溢出节,让整个过程顺其自然。这样做的结果是,当我们进行地址分配时,我们可以将节“传送”到它们最终要去的地方。链接器的其余所有语义都保持一种一致的语义:那些依赖于地址的关注点都依赖于最终位置,而那些不依赖于地址的则具有一种“所有东西都仿佛被放在所有地方”的语义。

我承认这并不理想,但这是一种“两害相权取其轻”的处理方法。它最终允许像这样复杂的功能在 LLD 中以相对较少的代码实现,并且易于维护。

如果你去阅读代码,大致上就是这样工作的。希望这能让你比较容易理解。

我们想研究的一件事是某种优先级排序。这个算法并不真正依赖于考虑节的顺序和分配区域的顺序。因此,如果你说按“最好优先”的顺序排列内存区域,并按重要性递减的顺序排列节,那么已经存在的算法将进行一种首次适应贪婪放置,以优化性能。这真的很有趣。

我们可能能够做的一件事是将某种分析信息合并到节元数据中,使用类似 Propeller 的工具将节分组到这些可以很好放置在一起的原子组中,然后使用基于性能分析的优化将这些优先级编码到这些节中,然后让这种溢出以优化性能、最小化 thunk 的方式发生顺序进行,这是链接器或嵌入式开发者关心的其他问题。

总结与问答

本节课中,我们一起学习了为嵌入式系统开发高级链接器脚本的动机和方法。

我们首先了解了嵌入式系统,特别是微控制器领域的独特约束,如成本、功耗和延迟,这些约束导致了特殊的硬件设计,如使用昂贵但低功耗的 SRAM 和可能没有 MMU。

接着,我们探讨了传统链接器脚本的局限性。开发者使用链接器脚本来控制代码和数据在内存中的布局,但其“首次匹配”语义非常死板,要求开发者手动进行精细的“俄罗斯方块”式分配,这不仅繁琐,还阻碍了链接时优化 的使用。

然后,我们介绍了两种解决方案:

  1. 启用非连续区域:一个 GNU LD 和 LLD 都支持的标志,它改变了通配符的语义,允许链接器在空间不足时将节自动“溢出”到后续匹配的内存区域,从而实现了自动布局并解除了 LTO 的限制。
  2. 节类:一种新的语法,允许开发者将通配符匹配的节分组到一个命名的“类”中,然后指定这个类可以去的多个内存区域。这提供了自动溢出的能力,同时允许增量式地修改现有脚本,而不会全局改变语义。

最后,我们深入了解了实现自动溢出功能背后的算法挑战,包括处理循环依赖、节合并、垃圾回收和链接器松弛等复杂情况。算法采用了一种“不动点”迭代和记录“潜在溢出位置”的方法,在功能性和实现复杂性之间取得了平衡。

这些高级机制的目标是将更多决策权交给链接器,减少嵌入式开发者在内存布局上的手动劳作,使他们能更专注于核心功能开发。


问: 这发生在垃圾回收之前还是之后?
答: 两者都有点涉及。输入节到输出节的成员关系确定发生在垃圾回收器之前。垃圾回收器之后运行并清理节,但这只会导致那些节的潜在溢出节变得无效或无害,它们基本上被留在那里。然后当你进行地址分配时,它们被清理掉。所以地址分配是在垃圾回收之后吗?不,地址分配通常是 GNU LD 的最后阶段。所以你创建的节类(新关键字)发生在地址分配期间。

问: 这对客户维护链接器脚本本身来说是否困难?
答: 我们并没有特别发现这一点。实际上,对于我们尝试此功能的嵌入式项目,我们能够删除链接器脚本中大量大量的代码。与手动将所有东西到处放置的当前维护开销相比,我们发现的例子只是试图打包东西。到目前为止,我们还没有收到任何更改请求或问题。

问: 你能谈谈算法的复杂度吗?在这个上下文中重要吗?
答: 时间复杂度?我不知道,大概是线性的。就像链接器中的许多东西一样,它是一个运行到不动点的东西。在实际案例中,我们从未见过它使用超过两次迭代。可能有一种方式可以折磨它,但……在 LLD 这样的项目中,指令似乎是“不要折磨你的链接器”。如果你想,你确实可以折磨一个链接器。

问: 这如何与可能改变节大小但不改变节数量的东西交互,比如 RISC-V 上的链接器松弛?
答: 谢天谢地,这是我们如何实现此功能时的一个主要关注点。所有这些事情都发生在与这个填充发生的同一个不动点循环中。这是一个非常好的点,我忘记在演讲中提到了。这些东西的大小可能会因为放置位置的差异而改变,这会改变重定位的距离,可能需要生成额外的 thunk。GNU LD 倾向于不清理 thunk,但松弛可能会……我认为它们实际上每次都会重新发生(不过别引用我这句话)。所以这是一个大的“汤”:不动点、松弛……我们通过确保节只能在列表中向前移动、永远不能向后移动或倒退来保持向前进展。所以它实际上就顺理成章了。它不一定在固定次数的迭代中完成,但如果尝试超过大约 10 次,链接器就会退出。所以这一切都是尽最大努力,这有点是故意的。通过使用这个,你是在做一个声明:“我认为这可能没问题。”实际上这有很多力量,就像现代优化编译器中的寄存器分配也是尽最大努力一样。写一些内联汇编来打破 LLVM 的贪婪寄存器分配器真的很容易,但它对很多人来说都很好用,以至于人们实际上认为它是完美的,即使它不是。

谢谢 Daniel 的精彩演讲。

044:利用MLIR抽象提升硬件验证效率

在本节课中,我们将学习如何利用MLIR中的高级抽象来改进硬件验证流程。我们将从硬件设计的角度出发,探讨如何通过保留高层次结构信息,使形式化验证变得更高效、更直观。

硬件设计流程与挑战

大家好,我是B。这位是Louisa。我们将讨论如何利用抽象来改进硬件验证。这并非硬件领域特有的问题,只是我们从这个角度切入。

首先,我们会重点介绍Circuit项目。对于不熟悉Circuit的听众,我先做一个简要介绍。

如果你在设计硬件,通常会使用SystemVerilog或VHDL这类低级语言。很多时候你会直接使用它们,有时也会使用高级语言,但最终都需要将这些设计输入到EDA工具中。

流程大致如下:你设计了一个CPU,然后说“我觉得设计对了,但不确定”。于是你会进行仿真。将设计输入给编译器和优化器,运行仿真。之后,你可能想把它放到FPGA上,或者制成芯片。这时,通常需要一个全新的编译器和优化器来进行综合,生成实际的硬件。

你可能会想:“我还是不确定设计是否正确,我想做一些形式化验证,比如逻辑等价性检查或模型检测。”同样,你往往会看到完全独立的工具链来处理这些任务,尤其是在开源领域。显然,我们不希望这样。这意味着你需要学习大量工具,没有真正的代码复用,一切都被重复实现,存在大量冗余。我们迫切需要一个工具来改善这种状况。

Circuit项目与MLIR

在Circuit项目中,我们使用MLIR来解决这个问题。我们有一个Circuit核心方言,它代表了寄存器传输级(RTL),包含门电路、寄存器等。所有设计在流向各种后端工具时,都会经过这个中心表示层。实际上,许多后端工具我们已经在MLIR中提供了。

实际流程看起来更复杂,包含一些导入和导出方言以及不同的表示形式。但为了便于理解,我们现在可以将其简化为这个核心概念。

标准验证流程:边界模型检查

接下来,我快速介绍一下Circuit中的标准验证流程。我们有一个叫circuit-bmc的工具,它执行RTL级别的模型检查。

假设你有一些寄存器和逻辑门,比如一个与门连接到一个寄存器。这个设计会被转换成一堆SMT公式。对于不熟悉SMT求解器的听众,简单来说,SMT求解器接收一组方程,并检查是否存在一组赋值能使这些方程成立。

我们将设计转换为SMT公式,所有MLIR值都变成SMT变量。然后,我们按时钟周期展开设计。在周期0,我们问:“当前设计状态是否违反了我希望它满足的属性?” 希望SMT求解器回答:“我找不到任何破坏该属性的情况。” 然后我们继续问周期1、周期2,依此类推,直到达到设定的边界。希望在整个过程中都找不到违反属性的情况。这被称为边界模型检查

Circuit提供了circuit-bmc来执行此操作。此外还有很多其他工具,例如针对SystemVerilog的验证后端,以及针对硬件模型检查通用表示形式BTOR2的后端,该后端可以对接ABCT、UCLID等众多工具。如果你在验证硬件,选择非常丰富。

高层次抽象的挑战与机遇

问题在于,如果你有一个高级方言,比如这个FSM方言(有限状态机方言)。它可能来自高级综合工具或我们的Python前端。你仍然可以走上述验证流程,但问题在于,FSM方言包含了许多对验证非常有用的结构信息。当你将其降级到核心方言时,这些结构信息就丢失了,而这部分信息非常有价值。

我举一个直观的例子。假设我们有一个FSM,变量X初始为0。从状态A转到状态B时,X递增。从状态B转回状态A时,X递减。可以看出X在0和1之间切换,永远不会大于1。如果我们有一个状态C,其进入条件是X > 1,然后问“状态C是否可达?”答案显然是否定的。对于求解器来说,解决这类问题有清晰的结构。

但如果面对的是RTL表示,它就像一个混乱的大电路,问题就变得复杂得多,查询起来也不那么清晰。我相信大家都会同意,如果给我这个电路图、一支笔和一张纸,让我判断X是否能大于1,我需要花更长的时间。

解决方案:直接从高级抽象生成验证模型

因此,我们的做法是:目前,我们并不关心下游那些RTL的具体细节。我们将直接从FSM生成SMT模型。这意味着我们可以在模型中保持原有的结构。我们这样做是为了绕过从FSM到核心方言的降级步骤,直接进入SMT验证。

需要强调的是,这不一定只适用于硬件。它同样适用于从CIRCT到LLVM,或者从FIR到LLVM的流程。但在本例中,我们是从FSM到SMT。接下来,Louisa将详细介绍我们使用的模型。

FSM方言结构与SMT建模

我们已经看到Circuit包含了一些高级方言,允许我们表示不同的硬件抽象。现在的问题是:我们能否在高级别上获取一些对验证有用的信息?特别是,是否存在一些有用的属性,可以在降级之前通过查看FSM来检查?

为了回答这个问题,我们引入了一种降级方法,将FSM转换为SMT模型。我们希望通过这种降级,创建一个描述有限状态机行为的SMT模型,然后在这个模型上检查一些属性,这些属性将保证FSM的行为符合预期。

在深入降级细节之前,我们先看看FSM方言的结构。FSM方言具有非常规整的结构:在FSM作用域内存在一些变量;有一些状态;每个状态可以产生一个或多个输出,并可以有一个或多个转移。每个转移可以由一个守卫条件激活,并可以引发一个动作,从而更新FSM作用域内的变量。

考虑一个例子:一个从状态A到状态B的转移,当满足守卫条件G时激活,并执行动作A来更新变量。

当我们想要描述一个有限状态机的状态时,首先需要引入一个时间模型。我们需要一个离散的时间模型,以便说明在某个特定时间,某个状态在FSM中是活跃的还是非活跃的。同样,我们需要描述FSM在某个时间步接收到的输入。

总的来说,为了完整描述FSM的状态,我们引入一个布尔未解释函数,它是变量和时间的函数。当这个函数返回true时,我们知道该状态在那个时间是活跃的;返回false时则不活跃。

我们对转移的目标状态也做同样处理,该状态将在时间T+1到达,并拥有另一组作为时间T+1函数的输入。我们引入另一个未解释布尔函数。

现在的问题是:如何连接它们以表示实际的转移?我们通过一个蕴含关系来连接。这个大的蕴含式表示:如果状态A在时间T是活跃的,并且守卫条件在此时得到满足,那么就意味着在时间T+1,转移的目标状态B将是活跃的。

在查看这个蕴含式时,有两点很重要:首先,我们只考虑用于描述输入和状态的未解释布尔函数。另一个重要方面是,这个蕴含式只规定了状态激活的必要条件。在设计我们想要在此模型上检查的属性时,牢记这一点非常重要。

在SMT模型上验证属性

说到属性,我们想要检查的第一类属性是活性属性。我们已经转换了模型中的所有转移,我们想检查在FSM的遍历过程中,最终是否会必然到达某个特定状态。我们如何编码这个属性呢?我们再次使用蕴含式。我们说:对于所有时间步和所有变量值,表示该状态的函数蕴含false。我们可以将这个蕴含式表示为合取形式并进一步简化表达式。如果求解器对此属性返回unsat,则意味着在FSM遍历的某个时间点,该状态必然会被到达

考虑一个简单的有限状态机,初始状态为S0。我们想检查在任何情况下,是否总会到达状态SN。我们可以遍历这个FSM,每当我们到达状态N,并且是被迫到达的,求解器就会返回结果,从而保证SN会在执行的某个时间点被到达。

接下来是安全性属性。我们同样希望检查某个条件在FSM的整个遍历过程中是否始终成立。例如,某个变量在某个特定状态下是否总是具有某个特定值。

在这种情况下,我们也希望将此属性表示为蕴含式:处于某个特定状态意味着某个变量具有特定值。然而,此时我们期望得到一个sat结果。管理这类结果在我们的模型中仍是一项进行中的工作,因为我们仍在研究如何更好地表达这种属性,以便SMT求解器能够处理它们。

测试与性能评估

我们如何测试整个基础设施?我们考虑了两个主要基准测试:第一个来自硬件综合项目“Lord of the Race”的真实FSM;第二个是我们构建的线性FSM合成基准,用于评估我们模型的扩展性。

首先,我们将FSM转换为SMT方言,并以SMT-Lib格式导出。然后,我们使用Z3检查这些SMT格式文件。同时,我们将Z3的性能与circuit-bmc进行比较:我们将相同的有限状态机降级到核心方言,并使用circuit-bmc检查相同的属性。让我们看看结果如何。

这些是相当初步的结果,请谨慎看待。在左侧我们有真实FSM的结果,右侧是合成FSM的结果。红线代表1倍速基线,绿条代表加速比。事实证明,FSM的结构确实非常有价值,SMT求解器能够以更高效的方式逐步处理。值得一提的是,我们是在与边界模型检查进行比较。为了不让边界模型检查处于劣势,我们给了它一个相当低的时间边界;在实践中,你可能会设置更高的边界。但总的来说,直接使用FSM抽象进行验证更快

确保模型等价性:翻译验证

下一个问题是如何信任我们的方法?显然,我们看起来值得信赖,但我们要声称这个SMT模型和核心方言模型是等价的。在验证硬件时,这是一个相当大的信任跨越。因此,当前进行中的验证方法是边界展开翻译验证

想法相当简单:我们基本上有这个FSM的SMT模型和这个核心方言表示。我们一步一步地按周期进行,因为RTL边界模型检查是按周期进行的。在每个周期,我们会从FSM得到一个X值,从电路(核心方言)得到另一个X值。我们基本上就是问SMT求解器:这两个输出是否总是等价的?是否存在它们可能不同的例子?然后我们逐步检查时间1、时间2,依此类推,直到达到一个边界,以基本确认这两个模型在功能上是等价的,正如我们所希望的那样。

总结与展望

我们的工作还处于早期阶段,但基本的结论是:MLIR拥有大量抽象,但它们并不总是被充分利用。我们不妨确保在任何可能的地方使用它们。它们可能非常有用,并且可以使验证变得非常快速。

谢谢。

谢谢。请大家提问。看来大家都准备好去吃午饭了。我有一个问题。

你说你检查两者,比如高级和低级表示,在每个状态下是否等价。我假设你实际上发现它们是等价的。这正是我们所期望的,就像我说的,这是进行中的工作。我省略了一些细节,可能会有变化。但在这个特性上游之前,我们计划完成这项工作,使其值得信赖。非常酷。谢谢。谢谢Bill和Louisa。


本节课总结
在本节课中,我们一起学习了如何利用MLIR中的高级抽象(特别是FSM方言)来提升硬件验证的效率和直观性。我们探讨了传统硬件验证流程的冗余问题,介绍了Circuit项目如何利用MLIR作为中心表示层。重点讲解了绕过RTL降级、直接从FSM生成SMT模型的方法,以及如何在该模型上编码和验证活性与安全性属性。通过性能对比,我们看到了保留高级结构信息带来的显著加速。最后,我们了解了通过翻译验证来确保不同抽象层级模型等价性的重要性。核心在于充分利用现有抽象,避免信息丢失,从而构建更高效、可信的验证流程。

045:MLIR功能完备了吗?生产就绪了吗?

在本节中,我们将探讨MLIR项目当前的状态,讨论其是否功能完备、生产就绪,以及社区面临的挑战和未来的发展方向。

大家好,欢迎午休回来。这是我们关于MLIR的小组讨论环节,主题是“MLIR功能完备了吗?生产就绪了吗?”。我是Alexenco,担任本次讨论的主持人。我们邀请到了一组优秀的嘉宾:来自NVIDIA的Medi,他在MLIR和Veri实践方面经验丰富;来自Intel的Renato,一位资深的LLVM贡献者;来自AMD的Stella,她领导着一个在Erie项目中大量使用MLIR的团队;Tobias,我们最早的学术合作伙伴之一,现在拥有最大的使用和开发MLIR的学术团队;以及Chris,LLVM和MLIR的创始人之一。

讨论概述

讨论从一个引人深思的问题开始:MLIR是否正在衰落?数据显示,今年MLIR相关的提交数量、开放会议数量似乎有所下降。这引发了关于项目健康状况的讨论。

社区演变与联邦式发展

上一节我们提出了关于MLIR活跃度的问题,本节中我们来看看嘉宾们如何看待社区的发展模式。

Chris指出,MLIR社区的发展模式与传统的LLVM“大教堂”式开发不同,它更像一个“联邦式”的组织。MLIR创建了一个高度去中心化的编译器生态系统,这带来了新的挑战,但也创造了前所未有的合作机会,使得不同领域(如AI、硬件设计)的团队能够首次使用同一种“语言”进行交流。

Tobias补充道,这种联邦式(或有人称之为“无政府主义”)的模式虽然看起来混乱,但正是这种多样性使得MLIR能够跨越传统CPU模型的界限,触及更广泛的领域。我们现在正处于理清这片“混乱”、并从中构建秩序的早期阶段。

成熟度与“毕业”现象

那么,如何解读贡献度数据的波动呢?这是否意味着MLIR已经“完成”了?

Chris和Stella认为,这更像是一种“毕业”的标志。早期,大量讨论和贡献集中在理解MLIR核心基础设施本身。如今,核心部分已经相对稳定和成熟,社区的焦点转移到了如何利用MLIR构建实际的应用和编译器。因此,在LLVM大会上,直接以“MLIR”为标题的演讲可能变少了,但几乎所有AI编译器、新硬件支持等主题的演讲都在底层使用MLIR。这恰恰是MLIR成功和实用的表现。

Medi提醒我们,这种分散也带来了一些问题。即使大家都在使用MLIR,不同团队关注的重点(如AI模型优化、硬件特定算术)可能截然不同,导致沟通和理解上的隔阂。这引出了下一个核心问题:我们是否需要更好的方式来组织和定义MLIR生态中的各个部分?

项目治理与结构化的需求

随着MLIR生态的扩大,缺乏清晰的结构带来了挑战。新贡献者很难了解项目的全貌,也不知道该向谁咨询特定领域的问题。

Alex和Renato提出了一个类比:目前进入MLIR社区,就像被要求在一个巨大的体育场里自己找到“36排F座”(某个特定领域的位置)。这不利于社区的健康成长。我们需要创建更清晰的“家园”,让人们知道各个组件的维护者、成熟度状态和适用范围。

Chris建议借鉴LLVM目标平台支持的管理经验:为MLIR中的组件引入明确的分类和升级标准。例如:

  • 稳定特性:如核心数据结构和canonicalize,它们有明确的维护者、完善的测试和稳定的接口。
  • 实验特性:仍在积极开发和演变中,使用者需要承担API变更的风险。
  • 研究项目/废弃代码:如果长期无人维护,则应考虑移除。

引入这样的分类可以迫使社区就组件的状态和维护责任进行对话,从而激励相关利益方投入资源,确保项目的长期健康。

生产就绪性的 nuanced 解读

回到标题的核心问题:MLIR生产就绪了吗?

嘉宾们一致认为,这个问题没有简单的“是”或“否”的答案。MLIR不是一个单一实体,而是众多组件的集合。

  • MLIR核心:其基础架构(如SSA、方言、通道管理器)已被广泛使用,相当稳定,可以说是生产就绪的。
  • 特定方言:情况则复杂得多。例如,arith(算术)方言对标量和向量运算的支持很稳定,但对张量运算的支持可能就不够完善。scf(结构化控制流)方言被广泛使用,但其中的parallel for可能在某些目标平台上缺乏完整的实现。

因此,“生产就绪”是一个高度上下文相关的概念。它取决于你使用的具体方言、操作,以及你的目标硬件。社区需要的是更精细化的文档和标签,来指明每个组件的适用场景和成熟度,而不是一个笼统的论断。

工具与规范的作用

为了管理这个庞大的生态系统,我们是否需要新的工具?

Tobias提出,LLVM社区通过FileCheck等工具和流程来组织协作。MLIR社区是否也需要类似的工具来量化组件质量(如测试覆盖率、提交活跃度)、可视化依赖关系(如Alex的MLIR图谱),并促进知识共享?这些工具可以帮助新成员快速了解生态,并辅助治理决策。

关于是否需要对MLIR方言进行形式化验证(如WebAssembly那样),大家认为需视情况而定。对于边界方言(用于对接外部系统),清晰的规范至关重要;但对于内部中间表示,过于严格的形式化可能会阻碍创新和实用性。重点可能在于为不同层级的组件制定恰当的“描述”和“验证”标准。

总结与展望

本节课中我们一起探讨了MLIR项目的现状。我们认识到:

  1. MLIR并未衰落,而是进入了新的阶段:从基础设施构建转向大规模应用开发。
  2. 联邦式发展带来了力量与挑战:它促进了跨领域协作,但也需要更好的社区结构和治理。
  3. 生产就绪性需具体分析:MLIR核心已很稳定,但上层组件的成熟度各不相同。
  4. 社区需要更多结构化努力:包括明确组件状态、划分责任领域、以及开发辅助管理工具。

讨论以呼吁行动结束:所有MLIR的利益相关者,特别是团队管理者,需要思考自己最关心MLIR的哪些部分,并承诺投入时间和资源去维护和建设它们,以确保项目的长期成功和传承。

MLIR的旅程不是关于是否“完成”,而是关于如何让我们征服的这片“新星球”变得更适合居住和繁荣发展。前方的道路在于协作、清晰的沟通以及对共同成功愿景的追求。

046:一个基于MLIR的、面向所有方言的张量分区系统

概述

在本教程中,我们将学习Shardy,一个由Google和Google DeepMind在过去一年中开发的开源、基于MLIR的张量传播与分区系统。我们将了解其设计背景、核心概念、工作原理以及如何应用它来高效地在大规模设备上部署大型AI模型。


背景:为何需要张量分区

上一节我们介绍了Shardy的定位,本节中我们来看看它要解决的核心问题。

像Gemini、ChatGPT这类生成文本、图像、音频和视频的大型生成式AI模型,其规模极其庞大,内部包含海量的矩阵乘法运算。单个设备无法容纳整个模型,因此公司需要将模型部署在成千上万个设备上。

为了理解如何部署,我们需要了解加速器的设置方式。虽然Google的TPU和NVIDIA的GPU存在差异,但简而言之,它们都拥有各种高速内部连接,使得加速器之间可以相互通信。

然而,这只是物理系统的设置。当研究人员将模型分布到多个芯片上时,他们操作的是一个被称为逻辑网格的抽象层,它隐藏了底层硬件拓扑的细节。当然,你需要考虑大型系统中可能存在的不同连接速度,因为并非所有连接速度都完全相同。

以下是两种常见的分布策略(从现在起我称之为分片策略):

  • 批量并行:将样本拆分到多个芯片上,以并行计算预测结果。
  • 张量并行:将模型的参数张量拆分到多个设备上,在通信汇总之前计算部分激活值。

例如,在进行批量并行时,设备的每一行获得一个唯一的样本切片。在进行张量并行时,每一列获得参数张量的一个唯一切片。你可以将每一行视为拥有分布在这三个设备上的完整模型,而每一列则计算其所有样本上的激活子集。


现有系统概览

上一节我们了解了分区的必要性,本节中我们来看看现有的一些自动化分区系统,以便更好地理解Shardy的设计出发点。

让我们快速看一个在4x2网格上进行张量并行的程序示例。我们看一个只做两次矩阵乘法的超小程序,这里展示的是分区传播后的最终程序。每个设备将看到...我们使用一种策略,让我们在被迫对所有主机上的第二个矩阵乘法的收缩维度进行求和之前,可以并行计算这两个矩阵乘法。这里的%p1被分片了。为此,我们需要分片%p1%parameter1%parameter2中高亮显示的维度。

现在,在分区传播之后,你可以看到在第二个矩阵乘法的收缩维度上添加了all_reduce操作,因为它被分片了。这很巧妙。但我们有系统为我们完成这一切,所以我们不需要修改图中的每一个操作并手动添加集合通信。

以下是现有的一些系统,我们将重点关注分片传播部分,而非分区或SPMD化部分。

  • GSPMD:这是这类系统中的先驱。它是一个基于属性的传播系统,不使用网格轴名称,并按照预定义的顺序传播操作。它内置了相当广泛的分片冲突解决策略。
  • PartIR:它不传播分片属性,而是将程序重写为我们称为平铺循环的结构。它使用网格上的命名轴,拥有一个数据结构来告诉系统如何对任意操作进行分片,但没有冲突解决策略,因为它要求用户将分片策略定义为单独的传播过程。
  • Mesh Dialect:它正在上游化到MLIR中,是用于传播的操作和属性的组合。它不使用轴名称,只使用网格上的轴大小,并要求用户实现一个接口来告诉系统如何通过一个操作进行传播。

引入Shardy

上一节我们回顾了现有系统,本节中我们将正式介绍Shardy,看看它如何博采众长。

我们引入的Shardy借鉴了GSPMD和PartIR的许多思想:

  • 我们像GSPMD一样使用带有命名轴的网格进行传播。
  • 我们像PartIR一样使用基于操作和基于区域的传播。
  • 我们也像Mesh Dialect一样有一个接口来告诉我们如何分片,但其数据结构更接近PartIR,我们很快就会看到具体是什么样子。

Shardy的核心:新的分片表示与API

上一节我们介绍了Shardy的设计理念,本节中我们来深入了解其核心的分片表示和为用户简化标注而定义的API。

首先介绍新的分片表示。我们定义了一组运行时API,以方便用户进行标注。

分片表示概述
分片信息作为操作的属性存在,描述了操作的结果如何被分区。它通常绑定到一个特定的网格。例如:

// 分片属性示例
#shard = #shardy.sharding<mesh = @mesh, partitions = [("x", 2), ("y", 4)]>

在这个例子中,分片绑定到@mesh网格。第一个维度沿着x轴分区,然后沿着y轴。第二个维度没有沿着任何轴分片,意味着这个维度是完全复制的。而z轴在这个分片中没有被使用,意味着对于这个张量,它在z轴上是隐式复制的。

定义传播的源与目标

  • 源端:我们定义了显式复制轴(不能用于进一步分区张量)和就地复制轴(可以用于进一步分区张量,即在传播阶段要传播的轴)。
  • 目标端:我们定义维度是开放的还是封闭的。开放维度可以进一步使用轴进行分片,而封闭维度则不能。源只能是就地复制轴,目标只能是开放维度。

用户优先级
我们为用户提供了优先级。这些优先级可用于确定传播顺序,为用户提供更多控制和更好的可调试性。例如,用户可以更容易地实现不同层次的传播:在最高优先级实现批量并行,然后是微批次分片,最后是零冗余分片。优先级可以附加到分片标注上,如右侧示例所示,P0表示该分片标注具有最高的传播优先级。

分片规则
我们定义了分片规则,它可视化和简化了传播算法。分片规则展示了不同张量维度之间的关系。以点积为例,其分片规则本质上就是它的爱因斯坦求和标记。我们可以使用这些标记轻松定义批次维度、非收缩维度和收缩维度。这更容易可视化,如下一节我们将展示如何沿着因子传播分片。这些策略规则提供了这种分片关系的可视化。

我们支持三个方向的传播:

  1. 前向传播:从操作数传播到结果。
  2. 后向传播:从结果传播到操作数。
  3. 侧向传播:在不同操作数或不同结果之间传播。

分片传播算法详解

上一节我们定义了分片规则,本节中我们来看看如何利用这些新API进行分片传播。

下图概述了我们如何传播分片。本质上,我们沿着因子传播分片。为此,我们有三个关键步骤:

  1. 将维度分片投影到因子分片空间。
  2. 沿着因子传播分片。
  3. 将因子分片投影回维度分片空间。

正如前面提到的,维度分片和因子分片之间的关系由分片规则界定。

让我们以dot_general操作的一个非常简化的版本为例。它有两个非收缩维度(I, J)和一个收缩维度(K)。其分片规则等价于爱因斯坦标记 [I, K] * [K, J] -> [I, J]

第一步:投影到因子空间
处理传播的第一步是将分片从维度空间投影到因子空间。为此,我们使用分片规则(即 I, K, J)。我们投影到结果,它对应两个非收缩维度。对于每个因子,我们也有其大小,对应于原始维度的大小。利用这些分片规则,我们可以将维度分片从维度空间投影到因子空间。在这个例子中,我们有三个因子(I, K, J)。由于我们只在左侧输入有分片轴,我们可以用相应的轴填充这个表格。注意,几个张量并不包含所有因子。例如,左侧输入不包含右侧输入的非收缩维度,这意味着因子J在左侧输入中缺失。

第二步:沿因子传播分片
首先,我们可以沿着因子I传播分片,因为没有冲突,我们将直接沿着这一列传播batch轴。类似地,我们可以沿着因子K传播tensor轴。

第三步:投影回维度空间
第三步,我们将向量分片投影回维度空间,以形成原始的张量分片。由于因子分片和维度分片之间的关系由这些分片规则形成,我们可以直接映射回维度空间。在这个例子中,右侧输入将获得沿着因子Ktensor轴分片,而结果将获得沿着因子Ibatch轴分片。


冲突解决策略

上一节的例子不包含任何冲突解决需求,因为表格的每一列或每一行都没有重复的轴。本节中,我们进一步开发了一个完整的层次结构来解决这些冲突。

  1. 用户优先级传播:用户可以定义特定标注的优先级。我们首先处理最高优先级的分片,然后逐步处理较低优先级的分片。这样,用户可以轻松实现批量并行和零冗余分片等标注层次。
  2. 操作优先级传播:这是一种启发式方法,借鉴自GSPMD。我们会先处理特定的“直通”操作,然后逐步处理更复杂的操作。例如,我们总是先传播逐元素操作,而点积操作的优先级则低于逐元素操作。
  3. 单优先级内冲突解决:我们开发了多种不同的策略来解决同一优先级内的冲突。例如,当我们在不同因子间发生冲突,或者在同一列内发生冲突时(无法轻松地沿单列传播batch轴),我们会应用特定策略。
  4. 基础传播层:这是应用特定策略并在因子分片空间中沿因子传播分片的基本层。

我们想强调的是,所有这些过程都遵循相同的接口。我们将基础传播层扩展到最高层,使得所有这些传播策略都遵循相同的接口,外层只是内层的派生类。


实现方言无关性

上一节我们讨论了传播算法,本节中我们来看看Shardy如何实现其长期目标——成为一个与方言无关的独立组件。

目前,Shardy依赖于StableHLO,但我们正在通过各种抽象和接口来解除这种依赖。

  • 分片规则:正如之前讨论的,这个属性编码了我们如何通过特定操作进行传播。只要一个操作实现了这个接口,Shardy就能够通过它进行传播。
  • 基于区域的操作:例如scf.whilescf.for等循环操作,情况更复杂。我们需要确保能够传入和传出这些区域进行传播。分片规则不足以处理这种情况,因为它们只描述了操作数和结果之间的对应关系,而区域操作没有这种关系。因此,我们定义了一个 ShardableDataflowOpInterface 接口,用户可以通过定义各种方法来告诉我们哪些值(操作数、区域入口块参数、区域终止符操作的操作数、操作结果)拥有分片信息。
  • 常量拆分:在MLIR中看到的大多数张量程序中,常量只有一个实例,被所有需要该值的操作重用。当常量需求相同时,这是合理的。然而,在对程序进行分片时,我们希望每个使用处都拥有它具体需要的常量,而不受其他操作如何使用该常量的影响。换句话说,我们希望允许常量的每个使用处,如果请求的话,可以拥有一个不同的分片常量。例如,在右侧,如果加法操作被分片了,为什么计算不同部分的除法操作也要以同样的方式分片常量呢?这并不合理。我们称之为假依赖,因为每个常量都很廉价,而使用相同常量的操作之间存在真正的依赖关系。因此,我们要求用户告诉我们什么是他们的常量操作,以及哪些操作是常量类(如iota)和哪些操作是可折叠的。
  • 可配置的传播顺序:回顾一下,GSPMD有基于操作类别的传播顺序(例如先传播逐元素操作,然后是MatMul等)。这需要最终用户进行配置,因为我们不了解他们的方言和操作。因此,我们只需要用户按他们希望分片传播的顺序传递一个操作列表。

以上就是使Shardy传播工作所需的一切。对于SPMD化或分区过程(主要是集合通信),可能需要更多工作,这将是我们明年的重点。


调试与可视化工具

上一节我们讨论了如何使Shardy工作,本节中我们来看看其另一个主要优势——改进的可调试性和可解释性。

我们已经基本完成了核心传播算法的工作,因此开始着手为用户创建调试工具,以理解传播过程。这里我们描述一个我们正在开发的工具。

背景:Google在5月份开源了一个名为 Model Explorer 的工具,它可以可视化包括MLIR在内的不同图类型,并且速度极快。你们中的一些人可能已经在刚过去的周二的MLIR研讨会上看到了这个演讲。

由于传播处理的是这些巨大的图,我们希望利用它来帮助用户理解他们的分片来自何处。回顾一下,最终用户只在他们的函数输入、输出和一些中间值上用少量分片属性标注他们的图,然后由Shardy在整个图中传播它们。

例如,这里展示了一个传播后的MLIR图。看着那个乘法操作,我们真的能确定轴AB来自哪里吗?不能。

因此,我们希望在MLIR重写模式执行此传播期间存储额外信息,并在图之上保存元数据,让我们能够可视化每个轴的来源轨迹。在这里,我们看到轴A通过dot_generaladd操作最终来自input0,而轴B来自图的输出。

我们实现的方式是使用 MLIR Action Tracing框架。当我们要更新一个操作的操作数或结果的分片时,我们创建一个Action,它计算出操作本地的轨迹信息。然后我们有一个处理器为整个程序保存这些信息。传播完成后,我们将保存这些信息供Model Explorer理解。


如何使用Shardy

上一节我们介绍了调试工具,本节中我们来看看目前如何体验Shardy。

目前,Shardy依赖于StableHLO。用户可以指定一个带有部分分片标注的StableHLO模块作为输入。用户可以使用我们上面讨论的API或特定的API来指定部分标注。特定的API用于演示用户如何希望对几个操作进行分区。

然后,我们将应用传播流水线将分片传播到整个图。你可以使用Python API或直接在模块上使用C++ pass来处理带有部分标注的模块。最终,结果将是一个分片遍布整个图的模块。

例如,现在你可以直接在JAX中体验分片。你只需启用相关标志,使用分片分区器,并结合原始的JAX分片相关API(例如jax.named_shardingjax.sharding.Meshjax.ShardingConstraint等)。我们将直接将其降低到Shardy的表示形式。例如,我们将named_sharding降低为#shardy.sharding属性,将jax.sharding.Mesh降低为#shardy.mesh,将jax.ShardingConstraint降低为#shardy.constraint。然后,我们可以直接在带有部分分片的模块上应用传播流水线。

进一步,我们将应用由XLA和其他XLA passes提供的分区器,以获得在不同硬件后端上的机器码。


未来计划

上一节我们介绍了当前的使用方式,本节中我们展望一下Shardy的未来发展方向。

以下是几个我们想要探索的未来计划方向:

  1. 实现分区器:我们希望在Shardy中使用原始的Shardy方言来实现和设计分区器,这将是我们明年的主要重点。
  2. 扩展对其他ML框架的支持:我们希望能扩展对PyTorch等其他ML框架的支持。目前Shardy依赖于使用Bazel构建整个系统,对CMake的支持正在进行中。
  3. 完全实现方言无关:我们希望使Shardy完全与方言无关,并将其与当前的StableHLO解耦。

总结

在本教程中,我们一起学习了Shardy,一个基于MLIR的张量分区系统。我们了解了工程师如何使用分区技术将大型AI模型扩展到成千上万的设备上,也接触了MLIR中一些有趣的工具,如Action Tracing和Model Explorer。最后,我们希望你能尝试使用Shardy。

047:加速构建时间的蓝图 🚀

在本教程中,我们将探讨如何借鉴LLVM在语言合规性和代码优化方面的成功经验,将其应用于改善软件构建时间。我们将通过游戏开发的实例来理解快速迭代的重要性,分析当前构建流程中的痛点,并最终提出一个基于LLVM的、旨在显著减少构建时间的系统性计划。


游戏开发中的用户体验 🎮

上一节我们介绍了本教程的目标,本节中我们来看看构建时间问题在游戏开发这一具体领域中的体现。

游戏开发者的用户体验通常如下:

  • 如果你足够幸运,并且正在开发游戏(这意味着你不能依赖最终确定的数据,因为数据会不断变化),你需要修改一些代码。
  • 如果幸运,你可以使用类似 Live++ 的解决方案来即时构建并应用你的更改。
  • 然后你可以玩游戏、调试,并重复这个过程。
  • 这是一个非常短的、大约5秒的迭代循环。

但更可能的情况是,流程会像下面这样漫长:

  • 你必须时不时地构建你的目标项目。
  • 启动编辑器。
  • 编译着色器(这在游戏开发中是个难题,尤其是随着行业大规模转向基于图块的延迟渲染,可能产生成千上万个被称为“着色器变体”的衍生代码,这需要很长时间)。
  • 加载你的游戏关卡。
  • 在编辑器中运行游戏。
  • 移动到可以测试你更改的特定位置和情境。
  • 然后你才能开始调试并进入下一个循环。
  • 当你只是迭代一小段代码时,这可能是一个长达15分钟的过程。

然而,还存在更长的路径。当数据和代码之间存在相互依赖时,你需要重新评估所有内容。这种情况发生的频率远超你的想象,可能持续数小时。以下是这个“噩梦”流程:

  • 预计算光照。
  • 构建整个游戏。
  • 构建编辑器。
  • 运行单元测试。
  • 烘焙地图(先烘焙简单的地图,以便你的机器人可以在世界中漫游,自动测试帧率或内存等方面的回归问题)。
  • 烘焙所有地图。
  • 打包你的游戏。
  • 签名、通知等等。
  • 整个过程可能需要 2到8小时

为什么这是个问题?🤔

上一节描述了漫长的构建流程,本节中我们来探讨这为何是一个严重问题。

这是一个问题,因为我们在进行游戏玩法编程,而迭代时间在游戏玩法编程中至关重要。以下是一个快速示例:

  1. 我有一个跟随角色的摄像机。它撞到了一面墙。
  2. 但只要没有墙,摄像机就会突然回到它的位置,这看起来很糟糕。
  3. 我们想修复它。首先,你可能会想:“好吧,我要给摄像机运动添加阻尼。”你添加一小段代码,做一些修改。
  4. 然后你意识到:“哦,不,等等。我不想一直给摄像机加阻尼,否则会产生我们不希望出现的感知输入延迟。我只想在角色和摄像机之间的视线与墙壁碰撞时才启用阻尼。”于是你修改了代码。
  5. 接着你发现:“不,我的假设错了。因为视线是否与墙壁碰撞,并不足以让我切换到阻尼模式,因为墙后面可能还有另一面墙。而我仍然希望摄像机有平滑的阻尼效果。”于是你再次修改。
  6. 然后你相当满意。但随后你意识到,在某些平台上帧率不同,摄像机的行为也因此不同,因为你的阻尼函数不是帧率无关的。
  7. 你实现了一些指数衰减等方法,直到达到一个非常好的效果:摄像机缓慢而平滑地回到它的自然位置。

显然,你以为这只需要10次或30次迭代,但实际上花费了更多时间(这是游戏行业的经验法则),最终你可能进行了100次迭代

为什么如此频繁地迭代至关重要?这不是一个缺陷,而是任何创造性过程的特性。这很关键,因为我们是人类。作为人类,我们有一个试图尽可能频繁更新的世界心智模型(显然是为了生存原因)。你有一个世界的心智模型,世界有其自身的现实,你预测某事,尝试,你的感官感知变化,然后更新你的心智模型。当这个过程进行得非常快时,你就可以使用所谓的“本体感觉”——你使用的工具成为你身体的延伸。然后你可以进入一种“心流”状态,事物仿佛是你身体的一部分在移动。这就是当你能在不到半秒内进入这种来回循环时,工艺与工程的根本区别。只有这样,你才能真正开始变得有创造力,甚至富有表现力,并提升游戏体验。

对于我们来说,这是关于解决问题,专注于为玩家带来最佳的游戏体验。但如果编译需要永远,这就不可能发生。

这还不是你遇到的唯一障碍,因为它会产生许多连锁反应。例如:

  • 迭代时间显然会影响时间,而时间就是金钱。损失金钱和时间会造成挫败感,挫败感会导致缺乏动力。
  • 所有这些都会相互影响。

但我要说,这甚至不是最糟糕的。最糟糕的是对于最终用户——我们的玩家——因为他们无法获得高质量的产品。在一个迭代循环需要数小时的世界里,你不可能对一个摄像机进行100次迭代。更可能发生的情况是,你会降低标准,一旦问题被修复就认为“足够好”,但游戏体验将无法达到应有的水平。这才是最大的代价


构建过程中的具体问题 🔍

上一节我们讨论了迭代缓慢的深远影响,本节中我们来看看一些可以由LLVM解决的具体技术问题。

以下是几个具体问题示例:

问题一:不一致的构建性能

  • 现象:两个用户使用相同的硬件、相同的游戏、相同的代码进行构建,但一个用户编译时间良好,另一个则很差。
  • 原因:Windows的迷你过滤器驱动程序。这些驱动程序位于用户空间和实际设备驱动程序之间,可以执行压缩、加密、安全栈等任何操作。在这种情况下,用户的某个驱动程序存在资源争用问题。

问题二:多线程编译时的性能下降

  • 现象:使用 -j2(两个线程)编译Clang时性能出现差异。
  • 原因:Windows上的 C运行时库分配器malloc/free 分配器是带锁的。一旦机器上有许多核心,你就会再次遇到这类资源争用问题。
  • 难点:这类问题很难定位,因为你只看到效果,直到实际捕获跟踪数据,你才知道真正发生了什么。

问题三:进程启动开销

  • 现象:在Linux上编译LLVM大约需要1分钟,在Windows上则需要近10分钟。
  • 原因:进程的启动和关闭在Windows上非常慢,原因有很多,但本质上是资源争用。我们在Windows上看到很多争用情况。
  • 总结:这导致了不一致的体验。当然,机器可能有所不同。

问题四:中间数据膨胀

  • 现象:一个虚幻引擎游戏在非Unity构建(即单独编译每个翻译单元)时,生成约 270 GB 的数据。链接后,它减少到 2 GB。这是大量的数据流转和生成。
  • Unity构建模式:如果以Unity模式(即聚合翻译单元)构建同一个游戏,生成的数据量更小,构建时间也更短。
  • 核心问题:我们生成了如此多的数据,而最终大部分都被丢弃了。这同样是时间和能源的浪费。

问题五:编译器缺乏项目上下文感知

  • 现象:为什么每次调用编译器时,源文件看起来总是“全新”的?
  • 历史原因:这可能是过去50年来的工作方式。
  • 现实:今天的代码实际上是一个项目中的因果图。在整个代码生命周期中,你只做了很小的更改。但编译器只考虑当前快照,即你给它的输入。它甚至不知道项目是什么
  • 期望:如果它能知道所有变化的排列组合(这个变化的“超空间”),那将是非常好的。

问题六:工具链的孤立性

  • 难点:工具链无法独立存在。它需要其他工具,如构建系统(CMake, Ninja)。
  • 它还需要某种构建加速,因为今天的代码库非常庞大,你不可能在自己的机器上编译所有内容(虽然可以,但很慢)。
  • 此外,有些情况下,小型工作室负担不起编译这类引擎的大型机器。
  • 现有方案:已经有一些内部化这些功能的实验,其中最有前途的之一是 Alecium,这是一个非常好的前进方向。

加速构建的蓝图 📋

上一节我们列举了构建过程中的具体挑战,本节中我们将提出一个旨在解决这些问题的系统性计划。

我们在此做几个假设:这个计划只需要为“黄金路径”工作即可。我们不必处理LLVM支持的所有情况。我们可以先证明它适用于这个主要部分,之后再考虑其他情况。具体来说,我指的是:Windows、大型项目(如虚幻引擎或Chromium)、确定性构建(构建期间没有任何变化)、模块化等。

我认为我们应该做的第一件事是进程内编译。我将在后面详细介绍。

第二是多线程的进程内编译

第三是构建守护进程,这在过去已经讨论过(例如,Reid在2013年左右就有一篇相关的文章)。这是一个古老的想法。

我认为,这三件事可以解锁其他更难的议题,让我们在真正改善构建时间方面走得更远。

以下是具体步骤:

第一步:进程内构建

  • 目标:能够在一个单独的进程内编译这些文件,而不是启动多个进程,即在LLVM驱动程序内部按顺序运行各个工具。
  • 方法:使用 ToolContext 来存储状态,并确保在运行每个工具之间没有内存泄漏。
  • 需要改变的事项
    • 例如,为每次编译启用 -disable-free 标志。
    • 此外,如果有多于一个任务,集成的 -ftime-trace 就不工作。
    • 如果我们禁用 -disable-free,我们需要类似 LEAK_DETECTOR 的测试,它本质上在同一进程中运行相同的测试两次,以验证我们是否释放了所有内存。
  • 已有基础lld 已经可以在同一进程中连续运行多个链接操作。我们必须为 clang 做同样的事情。

第二步:多线程进程内编译

  • 目的:出于性能原因,这将是很好的。
  • 主要问题全局状态。这不是新问题,但我想你们许多人都意识到了。
  • 任务:我们必须选择哪些托管静态变量应该是线程局部的,哪些应该是进程范围的,等等。
  • 实现:在这一点上,或许可以为单个进程建立一个工具线程池。
  • 进展:这已经在一个分支中实现(见底部链接)。同样适用于上一张幻灯片的内容。底部有链接,指向已经涵盖所有这些内容的实际测试。

第三步:构建守护进程

  • 描述:这可以与前面的步骤并行完成。它本质上是一个长期运行的服务,就像一个大的守护进程。
  • 架构:在那里,我们可以启动另一个进程(即进程内执行器),或者一个进程池。
  • 集成:我们必须找到一种方法与现有的构建系统通信,这可以是一个像 LSP 这样的通信协议(例如扩展LSP),或者其他不同的方式。

一旦我们完成了所有这些,我认为我们可以做一些其他巧妙的事情,例如:

  • 各种缓存
  • 按需编译调试信息。我们生成了大量的调试信息,之前提到的270 GB大部分都是调试信息。
  • 增量优化。这将非常酷。我们现在已经有一个使用 Live++ 的案例:如果你运行发布版本构建,并且有Live++连接到进程,它可以对文件进行去优化并打补丁,然后你就可以调试它。这真的很棒,但它有点粗糙,并且是LLVM外部的。

总结与呼吁 🤝

本节课中,我们一起学习了构建时间对创意工作(尤其是游戏开发)的关键影响。我们分析了导致构建缓慢的一系列具体技术问题,包括系统资源争用、中间数据膨胀、编译器缺乏上下文感知以及工具链的孤立性。

基于这些分析,我们提出了一份基于LLVM的蓝图,旨在系统性加速构建过程。该计划的核心是三个渐进步骤:

  1. 进程内构建:减少进程启动开销和资源争用。
  2. 多线程进程内编译:充分利用多核性能,同时管理好全局状态。
  3. 构建守护进程:提供长期运行的服务,实现更高效的缓存和通信。

实现这些步骤将为更高级的优化(如智能缓存、按需生成调试信息和增量优化)铺平道路。

让我们联合起来! LLVM社区一直处于创新的前沿。我认为我们应该在构建时间优化方面发挥领导作用,而不是等待其他地方发生改变。通过共同努力实施这份蓝图,我们可以为所有开发者带来更快速、更流畅的创作体验。

048:Rust与LLVM的合作与挑战

在本节课中,我们将探讨Rust编程语言与LLVM编译器基础设施项目之间的协作关系。我们将了解Rust如何利用LLVM生成高效代码,以及双方在合作过程中面临的主要挑战,包括编译时间、运行时性能和正确性等方面。

高层概述:治理与流程

上一节我们介绍了课程主题,本节中我们来看看Rust与LLVM在组织和流程层面的协作。

Rust项目由多个团队组成,其中两个关键团队是编译器团队和语言团队。在编译器团队之下,设有LLVM工作组,负责处理所有与LLVM相关的事务。LLVM工作组一方面与上游LLVM项目合作,另一方面主要与操作语义团队协作,因为Rust的语义建立在LLVM语义之上,需要确保两者保持一致。

从个人角度而言,我是Rust侧LLVM工作组的负责人,同时也是上游LLVM项目的首席维护者。我的工作主要涉及LLVM版本升级、解答问题,并在上游LLVM项目中处理影响Rust的问题,代表Rust社区的利益。

Rust通常积极采用新的LLVM版本,主要得益于Google维护的集成构建系统,该系统将Rust主线与LLVM主线结合,确保其持续构建和工作。官方Rust二进制文件倾向于使用最新发布的LLVM版本,目前是LLVM 19,同时也会支持一两个旧版本供Linux发行版使用。

需要强调的是,当我们提到LLVM时,指的是未经修改的上游LLVM。我们有自己的LLVM分支,但仅用于版本管理,不希望有任何Rust特定的补丁。我们希望所有对Rust的支持都集成在上游LLVM项目中。

编译流程:从Rust代码到机器码

上一节我们了解了组织架构,本节中我们来看看Rust代码是如何通过LLVM转换为可执行代码的。

从Rust代码到LLVM的流程非常简单。我们从左侧的Rust代码开始,然后降级到称为MIR的中级中间表示,接着转换为LLVM IR,最后由LLVM施展其“魔法”进行优化和代码生成。

如今,LLVM不再是唯一受支持的后端。它是默认后端,但存在一些替代方案。其中之一是Cranelift,它来自WebAssembly生态系统,能够更快地生成未优化的构建。另一方面,GCC后端主要用于针对LLVM本身不支持的架构。后端系统是可扩展的,因此存在更多后端,例如针对.NET CLR的后端,但上述三个是重要的。

最后值得注意的是,如今Rust不再完全依赖LLVM优化。它在MIR级别也有少量自己的优化,其原因将在后面更详细地说明。

挑战一:编译时间

上一节我们概述了编译流程,本节中我们来看看使用LLVM时Rust面临的首要挑战:编译时间。

编译时间可能是所有Rust开发者持续抱怨最多的问题。即使它不是最重要的问题,也可能是最紧迫的问题。

Rust编译缓慢的原因有很多。其中之一更多是社会性而非技术性问题。因为Rust有自己的包管理器Cargo,添加一个额外的依赖非常容易,但这可能会拉入20个其他依赖,所有这些代码都必须编译。这有其优点,但在构建时间方面绝对是个问题。

以下是更技术性的方面:

  • 编译单元与并行性:在C和C++中,编译单元是单个文件。因此,对于包含许多文件的大型项目,编译基本上是一个“令人尴尬的并行”问题,可以轻松地通过并行编译所有文件来充分利用多核构建服务器。在Rust中,这并不容易,因为Rust的编译单元是crate,而crate由许多文件组成,通常可以包含大量代码。LLVM本身不支持内部并行性。因此,Rust通过将crate人工拆分为代码生成单元来并行优化它们。但这样做会损失优化效果,所以主要通过结合链接时优化来恢复这些优化。最终结果是优化质量比整体编译稍差,并且总体上做了更多工作,但在大多数情况下,并行化的好处仍然值得。
  • 泛型与代码膨胀:Rust泛型和C++模板在这方面工作方式相同。如果你有一个泛型函数并用三种不同的类型使用它,实际上会创建该函数的三个副本。LLVM优化这些副本时,必须对每个副本应用相同的优化。改进方法是先在多态级别进行一些优化,然后再处理各个副本。任何在多态级别进行的优化都能节省单态级别的时间。这正是Rust进行MIR优化的原因。当然,在Rust中直接进行优化还有其他原因,但编译时间是目前的主要动机。
  • LLVM自身的速度:Rust编译缓慢的最后一个原因是LLVM本身就很慢。除了让LLVM更快之外,我们对此无能为力。LLVM 10升级对Rust来说是一个警钟,当时出现了大量10%到150%范围内的性能回归。这是LLVM启用内存SSA的版本,也是导致这些回归的原因。这促使我开始在LLVM侧跟踪编译时间,以便我们能看到每个提交的影响,而不是六个月开发工作的结果。自那以后,总体趋势是向下的。LLVM 19升级尤其显著,我们在那次升级中看到了10%到15%范围内的许多改进。

挑战二:运行时性能

上一节我们讨论了编译时间,本节中我们来看看第二个挑战:运行时性能。

理论上,符合Rust习惯的代码应该比符合C++习惯的代码更快。原因之一是Rust语言提供了更强的保证,特别是在指针别名方面,这对优化非常有用。当然,理论和实践并不总是一致。目前,我认为Rust的性能承诺在实践中并未完全实现。

原因如下:

  • 边界检查消除:为了确保内存安全,部分工作可以在编译时完成,但部分必须通过运行时检查进行。在大多数情况下,这些运行时检查可以被优化掉,但这并不总是可靠发生。从程序员的角度来看,很难预测边界检查何时会被优化掉,何时不会。LLVM目前擅长优化具有常量上限和直线代码的边界检查,但不擅长优化循环内的边界检查,而这恰恰是最重要的情况,因为这些检查会被频繁执行,并且会阻碍其他优化,例如向量化。
  • 内存复制消除:在C++中,返回值优化允许在语言级别避免许多拷贝。Rust没有这个特性,因此会发出大量内存拷贝,并依赖LLVM来优化掉它们。近年来LLVM在这方面有了很大改进,但仍有改进空间,特别是对于小型对象拷贝。
  • 特定优化问题:Rust有两种类型的范围:不包含上界的独占范围和包含上界的包含范围。独占范围的优化效果比包含范围好得多,尽管从程序角度来看它们看起来基本相同。原因是包含范围在循环中有一个条件增量,而LLVM无法很好地处理这种情况。要解决这个问题,可能需要在LLVM中进行一些相当Rust特定的优化。

更有趣的方面是更普遍的问题:Rust语言有所有这些非常强的保证,我们希望教会LLVM这些保证,以便LLVM能利用它们进行优化。这是通过多种不同机制完成的,如指令标志、属性、元数据假设。Rust成功地使用了所有这些机制,并启用了许多优化。这个领域的一个问题是,元数据和属性在优化过程中很容易丢失。相反,假设则存在相反的问题,因为LLVM会尽力保留这些假设,即使它们不再有用。这是一个非常困难的权衡,我认为LLVM尚未达到这个权衡的最佳点。当然,我们一开始就没有足够的属性来告诉LLVM所有Rust保证的语义。不过,随着时间的推移,越来越多的属性被添加进来。

以下是几个例子:

  • 分配器属性:允许我们告诉LLVM关于自定义分配函数的信息。
  • 范围属性:允许我们告知函数参数的输入范围是有限的。
  • noundefnowrite:用于改进内存拷贝消除。
  • getelementptr inbounds:告诉LLVM数组索引可以为负。

我想强调的一个共同点是,虽然所有这些事情的动机可能来自Rust的需求,但它们也总是有益于C++和其他语言的优化。我认为我们从未遇到过为Rust添加某些属性,而不同时帮助LLVM其他用户的情况。

挑战三:正确性

上一节我们探讨了性能优化,本节中我们来看看最后一个也是最重要的挑战:正确性。

如果Rust真正关心一件事,那就是正确性、安全性、可靠性、稳定性。这里需要强调的重要一点是,最终,Rust的语义必须以某种方式基于LLVM的语义。因此,我们为Rust选择的任何语义都必须得到LLVM的支持,并且不仅仅是支持,还要有良好的优化质量支持。这个领域的一个风险因素是,LLVM有相当一部分语义要么未指定,要么完全未决定。经典的例子是来源。我们知道在LLVM中我们有来源,但细节都未确定。当然,如果底层的LLVM语义未知,就很难指定明确的Rust语义。其次,Rust只能和LLVM一样正确,而LLVM有相当数量的已知错误编译案例,即LLVM生成不正确代码的情况。需要说明的是,这些情况通常是问题很难修复,并且在实践中不太可能影响任何人。所以我会说这些是理论上的错误编译。但Rust开发者很特别,他们不喜欢任何错误编译,即使它们主要是理论上的。我不想在这里给人错误的印象,这不是LLVM特有的问题。Rust肯定也有自己目前未指定和未决定的语义,Rust也有自己的健全性错误。因此,为了得到一个整体上健全的系统,必须在两个项目中都解决这个问题。

我想举几个历史例子,说明Rust的正确性如何受到LLVM侧问题的显著影响:

  • 无限循环语义:在C和C++中,不包含副作用的无限循环是未定义行为。历史上,LLVM采用了相同的C++语义,这对Rust来说是个问题,因为Rust的无限循环是明确定义的。最终发生的情况是,LLVM语义被更改,现在默认情况下无限循环是明确定义的。相反,你可以指定mustprogress属性来选择加入C++的前向进展保证。这样我们两全其美:Rust获得了明确定义的无限循环,而C和C++可以保留甚至增加基于前向进展的优化。
  • 别名保证与noalias属性:Rust有非常强的别名保证。这可以在LLVM中使用noalias属性来表示,这是一个预先存在的属性,因为相同的语义也存在于C中,使用restrict限定符。区别在于,在C中restrict很少使用,而在Rust中,基本上所有东西都是restrict。当Rust尝试启用noalias时,遇到了一长串优化错误。这些问题随着时间的推移得到了解决,如今它工作可靠。

对于这两个例子,第一个是实际语义不匹配的情况,LLVM语义与C语义紧密绑定,无法有效地支持Rust语义。在第二种情况下,更多的是纯粹的正确性问题,平均的C代码无法充分测试LLVM优化器。

对于当代问题,我只想举两个例子,带有很强的近期偏见,只是因为我最近经常讨论这些:

  • 调用约定问题:调用约定是关于如何向函数传递值和返回值的规则。核心问题是其规则非常复杂且特定于目标。LLVM IR本身没有足够的信息来正确选择调用约定。因此,调用约定处理的大部分必须在前端实现。这意味着它由Clang、Rust以及每个支持C外部函数接口的其他编译器实现。这是非常棘手的代码,也很难测试,几乎可以保证存在一个或十个不同的错误。这不是一个理想的状态,因为至少对我个人而言,LLVM项目承诺的一个重要部分是它抽象了所有这些架构特定的细节,而调用约定是它真正没有做到这一点的地方。调用约定的第二个问题与第一个无关,是LLVM混淆了允许使用哪些指令以及它们如何影响调用约定。如果你告诉LLVM启用AVX目标特性,那告诉LLVM在函数内部可以生成AVX指令,但也告诉LLVM可以使用AVX寄存器跨调用传递值。任何时候更改调用约定都意味着使用一组目标特性的代码可能与使用不同目标特性的代码不兼容。因此,不清楚这两段代码何时可以安全组合。当然,LLVM有数百个目标特性,其中很大一部分会修改调用约定,而这些都没有文档记录。
  • 非主流路径支持:如果你偏离主流路径,做一些在C代码中可能不常见的事情,最近的例子是Rust目前正尝试公开16位和128位浮点数,你会观察到基本上LLVM的中端非常可靠,像x86、ARM这样的主流后端也工作得很好。但如果你超越这些,你会看到到处都是各种错误,如崩溃、错误编译等。我认为部分根本原因是LLVM的后端,或者至少是SelectionDAG后端,默认情况下似乎经常做错事。你必须实现某种钩子、设置选项或调用函数才能获得正确的行为,但如果你不这样做,你默认就会得到错误的行为,而且是静默的。这也因LLVM的测试策略而加剧,该策略主要基于单元测试。我们没有某种集中测试来检查16位浮点类型在LLVM支持的所有目标上是否正常工作。如果我们这样做,这些问题会立即被注意到,而目前则不会。

总结与展望

本节课中我们一起学习了Rust与LLVM的协作关系及其面临的挑战。

正如开始时所说,我认为LLVM对Rust的成功绝对不可或缺。同时,我认为Rust驱动的LLVM变更也使该项目及其其他用户受益。我认为Rust和LLVM有着非常好的工作关系,我希望这种关系能够持续到遥远的未来。

总的来说,Rust与LLVM的合作是互利共赢的。Rust依靠LLVM获得高性能,而Rust的需求也推动了LLVM在语义明确性、优化能力和对新硬件特性的支持等方面的进步。尽管在编译时间、性能优化和正确性方面仍存在挑战,但双方持续的协作和努力正在不断改善这一状况。对于编译器开发者和语言设计者而言,理解这种关系及其中的权衡,对于构建更高效、更安全的系统至关重要。

049:从C和Python使用MLIR

在本教程中,我们将学习如何通过C语言和Python语言使用MLIR框架。我们将探讨MLIR C API的设计理念、基本用法,以及如何基于此API构建Python绑定。内容涵盖IR遍历、IR对象创建、构建系统集成以及运行转换通道。

MLIR C API设计目标与命名约定

上一节我们介绍了本教程的概述,本节中我们来看看MLIR C API的核心设计目标和命名约定。

MLIR C API的设计遵循几个简单目标。C语言能与几乎所有其他语言互操作,因此提供C API是自然选择。我们希望有一个统一的接口,便于MLIR开发者(主要使用C++)理解和维护。该API提供弱稳定性保证,虽然MLIR不承诺API稳定性,但我们会尽量避免破坏C API。C语言没有函数重载、类和继承,只有函数名,这有助于保持稳定。该API偏向于简单和最小化可用性,并不期望用户直接用C编写复杂应用,而是作为其他语言绑定的基础。

以下是关键的命名约定:

  • 前缀:所有MLIR C API函数和类型均以 mlir 开头。
  • 类型:类型名称首字母大写,例如 MlirOperation, MlirAttribute
  • 函数:函数名称首字母小写。
  • 所有权语义
    • 函数名包含 create:创建新对象并将所有权交给调用者。调用者需负责调用对应的 destroy 函数释放对象。
    • 函数名包含 get:获取一个由其他对象(如上下文Context)拥有的对象。调用者无需负责释放。
    • 函数名包含 take 或参数类型为 owned:函数将从调用者那里取得对象的所有权。

类型模型与IR遍历

了解了API的基本设计后,本节中我们来看看MLIR在C API中如何表示类型,以及如何遍历已有的IR。

C API采用极简的类型模型,只暴露MLIR中的顶层对象,如操作(Operation)、属性(Attribute)和类型(Type)。它不暴露任何子类信息。例如,整数类型或加法操作在C API中都使用其基类表示(MlirTypeMlirOperation)。函数名中会暗示其期望的具体子类,例如 mlirIntegerTypeGetWidth 暗示其第一个参数 MlirType 必须是一个整数类型,否则会触发断言。所有对象在C中都是可空的指针,使用前需要检查是否为NULL。

MLIR IR具有递归结构:模块(Module)等顶层操作包含区域(Region),区域包含块(Block),块包含操作,操作本身又可以包含区域。

以下是如何遍历IR的步骤:

  1. 从顶层操作(如模块)开始。
  2. 查询操作拥有的区域数量:mlirOperationGetNumRegions
  3. 通过索引获取区域:mlirOperationGetRegion
  4. 获取区域中的第一个块:mlirRegionGetFirstBlock
  5. 由于块和操作在区域/块内是链表结构,获取下一个块或下一个操作需使用:
    • 获取下一个块:mlirBlockGetNextInRegion
    • 获取下一个操作:mlirOperationGetNextInBlock

创建IR对象与所有权模型

上一节我们介绍了如何遍历现有IR,本节中我们来看看如何创建新的IR对象,并明确其所有权模型。

所有权模型源自MLIR C++实现,并通过命名约定在C API中体现。默认情况下没有所有权转移,除非特别说明。

  • create:调用者获得返回对象的所有权。
  • take/owned:函数从调用者处取得对象所有权。
  • get:返回的对象通常由上下文(Context)拥有,无所有权转移。

创建操作通常涉及以下步骤:

  1. 创建上下文(Context):mlirContextCreate
  2. 创建操作状态(OperationState):mlirOperationStateCreate。这是一个临时对象,用于收集创建操作所需的所有信息(名称、位置等)。
  3. 配置操作状态:例如,为模块操作添加区域。
    1. 创建区域:mlirRegionCreate
    2. 将区域添加到操作状态:mlirOperationStateAddOwnedRegions。此调用会将区域的所有权转移给操作状态。
  4. 创建操作:mlirOperationCreate。此调用会消耗操作状态并返回最终的操作对象。现在,该操作对象拥有其包含的区域。
  5. 清理:调用者需销毁其拥有的对象,例如 mlirOperationDestroymlirContextDestroy

注意:此通用创建方法不执行操作的构建器函数(Builder)或验证(Verification)。构建逻辑(如类型推断)需调用者手动实现,验证需单独调用 mlirOperationVerify

为自定义类型和属性提供C API

上一节我们学习了通用操作的创建,本节中我们来看看如何为特定的类型和属性提供C API绑定。

与操作不同,类型和属性没有通用的字符串创建接口。需要为每个要暴露的类型定义特定的C函数。这通常涉及三个关键函数:

  1. mlirTypeIsA[TypeName]:检查给定的 MlirType 对象是否为特定类型。
  2. mlir[TypeName]TypeGetTypeID:获取该类型的唯一类型ID。
  3. mlir[TypeName]TypeGet:创建该类型的实例。

实现这些函数通常只是对底层C++ API的简单包装。MLIR提供了 wrapunwrap 辅助函数,用于在C类型 (MlirType) 和C++类型 (mlir::Type) 之间转换。

以下是为自定义类型创建C API的步骤:

  1. 在头文件中声明函数和类型注册宏。
  2. 在源文件中定义函数,并调用 MLIR_DECLARE_CAPI_DIALECT_REGISTRATIONMLIR_DEFINE_CAPI_DIALECT_REGISTRATION 宏来注册方言。
  3. 使用CMake宏 mlir_add_public_c_api 将代码编译为库。

接口(Interfaces)的暴露方式类似,主要依赖类型ID进行查询。

在C中运行转换通道

现在我们已经可以创建和查询IR了,本节中我们来看看如何在C语言环境中运行通道(Pass)对IR进行转换。

运行通道管线的步骤与C++中类似:

  1. 创建通道管理器(PassManager):mlirPassManagerCreate。需要指定其操作的顶层操作类型。
  2. 解析通道管线:mlirParsePassPipeline。可以传入一个描述通道序列的字符串(例如 "canonicalize,cse")。
  3. 运行通道管理器:mlirPassManagerRun

关键点:通道注册。要在文本管线中使用自定义通道,必须先在上下文中注册它们。MLIR通过TableGen自动生成通道声明和描述。除了生成C++代码,还需要生成C语言的注册函数,并在创建上下文和运行通道管线之前调用这些注册函数。

Python绑定设计

前面我们详细探讨了C API,本节中我们来看看基于该C API构建的Python绑定的设计。

Python绑定的主要设计目标是提供符合Python习惯(Pythonic)、易于使用的接口。用户只需 import mlir 即可开始使用。选择基于C API构建的主要原因是为了避免C++异常处理与LLVM/MLIR默认编译设置(无异常)的冲突。此外,C API相对稳定,也简化了二进制兼容性问题。

一个重要警告:目前,将所有MLIR相关组件(核心库、各方言库)链接到单个动态库(.so)中是使Python绑定正常工作的可靠方式。尝试加载多个独立的MLIR库可能会失败。

在Python中遍历IR

了解了Python绑定的设计后,本节中我们来看看如何在Python中遍历IR,其语法比C更加简洁。

Python API充分运用了Python的语言特性:

  • 迭代:操作(Operation)的区域(regions)、块(blocks)和操作列表本身就是可迭代对象,支持 for 循环。
  • 属性访问:操作的属性(attributes)以类字典对象形式暴露,可以通过名称或键(key)访问。
  • 属性与特性:尽可能使用特性(property)而非方法。如果访问可能失败(返回 None),则使用方法。
  • 模块化:核心IR结构位于 mlir.ir 包中,各个方言位于 mlir.dialects 子包下(如 mlir.dialects.func)。

以下是一个遍历IR的Python示例:

with mlir.ir.Context() as ctx:
    module = mlir.ir.Module.parse(module_str)
    first_region = module.regions[0]
    first_block = first_region.blocks[0]
    for op in first_block.operations:
        visibility_attr = op.attributes.get("sym_visibility")
        if visibility_attr is not None and str(visibility_attr) == "public":
            print(f"Public function: {op.attributes.get('sym_name')}")

Python中的IR创建与所有权

上一节我们看到了Python中遍历IR的便利性,本节中我们来看看如何在Python中创建IR,并理解其所有权规则。

Python中的所有权由Python运行时自动管理,但需理解其规则以避免错误:

  • 上下文(Context)和模块(Module):由Python直接管理其生命周期。推荐使用 with mlir.ir.Context(): 语句块来确保上下文的作用域。
  • 分离的操作(Detached Operation):未插入到任何模块或区域中的操作,由Python的垃圾回收器管理。
  • 保持存活(Keep-alive)规则
    • 由上下文拥有的对象(如某些类型)会保持其上下文的引用。
    • 操作会保持其所有祖先节点(直至模块)的存活。持有深层操作的引用足以保持整个IR树不被销毁。
  • 重要警告:如果通过C++代码(例如在通道中)擦除(erase) 了一个操作,Python端对此操作的任何后续访问都可能导致崩溃或未定义行为。因此,不建议在Python中直接进行复杂的IR突变(Mutation)

创建操作在Python中更加直观。可以使用通用的 ir.Operation.create 方法,但更推荐使用为每个操作自动生成的特定类。

定义自定义操作的Python类

Python绑定支持为TableGen(ODS)定义的操作自动生成Python类,也允许用户自定义更友好的构造函数。

自动生成的构造函数严格遵循ODS中的参数顺序。为了提供更符合Python习惯的接口,可以定义自定义类来覆盖自动生成的类。

以下是如何定义自定义操作类的步骤:

  1. 使用 mlir._cext 中的 replace_cls 装饰器来替换自动生成的类。
  2. 在新类中定义 __init__ 方法,实现自定义构建逻辑。
  3. __init__ 内部,最终需要调用原始类的构造函数(通常命名为 _cext 或类似名称)。

对于类型和属性,也需要手动创建Python绑定,这通常涉及:

  1. 定义一个继承自 Ir.TypeIr.Attribute 的子类。
  2. 实现 typeid 属性(对应C API中的类型ID)。
  3. 实现 get 类方法来创建实例。

这些绑定需要集成到项目的CMake构建系统中,使用 declare_mlir_python_extension 等宏。

总结与警告

本节课中我们一起学习了如何通过C API和Python绑定与MLIR进行交互。

我们首先探讨了MLIR C API的设计理念、命名约定和所有权模型。接着,我们学习了如何遍历和创建IR对象,以及如何为自定义类型提供C API。然后,我们了解了如何在C中运行转换通道。基于稳定的C API,我们构建了Python绑定,它提供了更符合Python习惯的接口,用于IR的遍历和创建。我们看到了Python中迭代、属性访问的便利性,也了解了其所有权规则和重要的生命周期警告。

核心警告重申

  1. 在C中,务必管理好由 create 返回的对象的所有权,及时调用 destroy
  2. 在Python中,强烈建议将主要代码放在 with mlir.ir.Context(): 语句块中。
  3. 避免在Python中执行可能擦除IR的操作,因为这会导致Python对象引用悬空,引发不可预知的问题。Python绑定的主要设计目标是用于IR的创建和只读遍历,而非复杂的原地变换。

希望本教程能帮助你开始在C和Python中使用MLIR。对于更高级的用法,请参考官方文档和上游代码库中的示例。

050:MLIR中的协程实现

概述

在本节课中,我们将学习如何在MLIR中高效地实现协程。我们将从异步编程的背景和问题定义开始,探讨LLVM现有的协程解决方案及其局限性,最后详细讲解我们为MLIR设计的协程降级方案。


第一部分:问题定义与背景

上一节我们介绍了课程概述,本节中我们来看看异步编程的核心问题。

假设我们需要执行一些计算并等待用户输入。给定用户输入后,我们启动一些内核并等待它们完成,同时模拟执行一些其他工作。第一个版本是同步的,流程如下:

  1. 首先执行任务:收集用户输入。
  2. 通过写入命令缓冲区来启动一些内核计算。
  3. 花费大量周期轮询状态寄存器,直到计算完成。
  4. 写入更多命令。
  5. 再次花费周期轮询状态寄存器,直到完成。
  6. 最后模拟一些其他工作。

这种方法效率低下,因为在轮询时会阻塞主应用程序。理想情况是能够交错执行任务,流程如下:

  1. 检查状态,如果未完成,则执行一些其他工作。
  2. 返回再次检查状态,再执行其他工作。

为了实现这种交错执行,我们需要进行以下转换:

  • 引入一些状态。
  • 将函数转换为状态机。
  • 编写结构体来存储所有中间状态。

这种转换工作量大且包含大量样板代码,难以维护。幸运的是,编译器擅长处理此类任务。

2012年,C#推广了 async/await 模式,现已成为行业标准。许多语言,包括Python、Kotlin、Rust、C++、Swift以及现在的Mojo,都采用了这种模式。

从标签可以看出,许多这些语言使用LLVM作为其后端来实现协程。那么,为什么Mojo不直接使用它呢?让我们来评估一下。


第二部分:评估LLVM的协程方案

上一节我们了解了异步编程的需求,本节中我们来评估LLVM的协程解决方案。

LLVM中有三种不同的协程降级方案:

  1. Switched-Resume 降级:包含一个 resume 函数、一个 ramp 函数和一个 destroy 函数(可能还有一个 cleanup 函数)。帧(frame)存储在上下文中。
  2. Return-Continuation 降级:与Switched-Resume类似,但返回指向下一个 resume 函数的函数指针。每个挂起点都有一个 resume 函数。前端还必须指定传递给每个恢复点的固定缓冲区大小。
  3. Async 降级:被Swift使用。类似于Return-Continuation,每个挂起点有多个 resume 函数;同时也类似于Switched-Resume,帧存储在每个 resume 函数传递的上下文中。

Return-Continuation降级不常用,因此未经过充分测试。我们重点分析Switched-Resume降级,因为C++使用它。

回到我们的示例,使用C++ API后,代码更清晰。我们不再需要自定义协程状态机,而是使用 co_awaitco_yield 等关键字。编译器会生成一个协程句柄来包装任务。

我们真正关心的是底层实现:生成的帧是什么样子?为了分析,我们简化了示例,只保留一个挂起点和一个跨越该点的值。

查看生成的IR,帧结构包含:

  • 指向 resume 函数的指针。
  • 指向 destroy 函数的指针。
  • 承诺类型(此例中为 i32)。
  • 其他值:前两个32位整数是帧,最后一个是状态。但为什么存储两个32位整数,而只有一个值跨越了挂起点?

查看 rampresume 函数,发现我们的操作被克隆了。有时克隆操作比加载/存储到帧中更廉价,但此例中我们执行了两次克隆和两次加载/存储,这并不合理。它只存储恢复所需的值,因为输入在第二次操作中使用。

关闭优化(-O0)后,帧中存储了所有值:输入、中间值和最终值。此外,每个挂起点还有一个指示是否挂起的值(这可能特定于C++)。我们不希望帧中存储这些额外信息。

关于帧分配:尽管没有值逃逸,但它仍然在堆上分配。

以下是评估记分卡:

  • 内存占用:所有值都存储在帧中,导致帧大小臃肿和不必要的存储操作。
  • 协程分配:堆分配未在可能时进行优化。
  • IR占用:不算太差,有一个 ramp 和一个 resume 函数(Mojo使用ASAP销毁,不需要 destroycleanup 函数)。
  • 热启动resume 函数的第一个状态被移入 ramp 函数,部分实现。

总体而言,LLVM的方案并非我们所需。但Swift使用的Async降级可能更好,让我们看看。

将示例转换为Swift后,发现即使是一个简单的函数也存储了许多整数。添加另一个不应存储在帧中的中间值后,它仍然被存储,这对帧大小不利。

IR占用方面:为每个挂起点生成一个函数(一个 ramp 和多个 resume 函数)。这并不理想,因为IR中函数过多会减慢其他函数级传递的速度,并且难以推理。

历史上,我们尝试过此方案,但观察到非活跃值被存储在帧中、分配未优化,并且由于协程传递依赖于同一模块中的多个函数而存在并行化LLVM的问题。因此我们决定放弃。

我们的新目标是:

  1. 更精细地控制帧大小。
  2. 控制生成的函数数量。
  3. 控制降级顺序以优化分配。

第三部分:MLIR协程实现设计

上一节我们分析了LLVM方案的不足,本节中我们开始设计MLIR中的协程实现。

首先,我们定义一个方言(dialect)。我们不打算在管道早期降级这些函数,因此需要一种简洁的方式预先表示它们。

其次,我们需要确定降级时机。为此,必须识别我们希望支持的优化,以及它们是否依赖于特定传递及其发生时间。确定这些后,就可以定义实际的转换,因为我们已经确定了输入。

以下是我们设计的方言,分为两类:函数和帧。我们将专注于函数的抽象,暂不深入 tl. 操作(这些操作用于访问存储在生成帧中的元素,对于编写运行时库或支持错误处理很重要,但不在本次讨论范围内)。

核心操作包括:

  • code.async:表示对尚未生成的协程函数的调用。
  • code.suspend:表示函数中的挂起点。
  • code.await:一个隐式挂起点,也表示在生成的子协程中设置某些字段(例如,设置回调到父协程)。
  • code.destroy:通常不嵌入语言中,将由负责处理生命周期的其他传递发出。

接下来讨论降级顺序。我们希望支持哪些优化?

  1. 支持热启动和冷启动协程。
  2. 将堆分配的协程提升到栈上。
  3. 考虑降级后的IR形态:控制流应是非结构化的,以便将 resume 函数转换为状态机。

什么是热启动和冷启动协程?

  • 通常,协程被降级为 rampresume 函数。将 resume 函数的第一个状态放入 ramp 函数,就形成了热启动协程。这意味着调用协程时,不仅创建它,还立即执行它。
  • 相反,冷启动协程的 ramp 函数仅分配内存并配置协程,然后返回,完全不启动它。

我们尽可能希望热启动,因为这意味着更小的帧大小和完成协程所需少一次函数调用。但并非总是可行。例如,如果我们创建协程后想生成一个线程并让该线程运行它,就不希望立即启动。

如何生成热启动与冷启动协程?它们有不同的 ramp 函数,因此需要提前知道哪些需要哪种 ramp 函数。我们将引入一个新的表示方式,称之为“热调用”(hot invoke)。如果我们在创建协程后立即调用其 resume 函数,那就是一个热调用。我们可以通过规范化模式轻松实现此转换。

以下是我们的降级计划:

  1. 解析器发出 await 调用(范围之外,假设已有)。
  2. 应用上述规范化转换。
  3. 在函数内联之前降级剩余的 code. 方言操作。
  4. 使用堆到栈提升传递将堆分配提升到栈上。
  5. 降低控制流。
  6. resume 函数转换为状态机。

我们需要在函数内联之前降级协程,因为只有在内联 ramp 函数后,才能知道分配是否逃逸,从而进行堆到栈提升。控制流在异步函数降级阶段是嵌套的(结构化),这使得表达“继续执行”变得困难。我们可以等到控制流降级后(非结构化),再轻松地进行状态跳转。但为了内存提升,我们又需要结构化控制流。最简单的折衷方案是将降级分为两步。

现在聚焦于“如何”执行转换。状态机转换和规范化模式相对简单,我们重点看降低异步函数传递。

以下是主要步骤:

  1. 预处理:降低隐式挂起点。
  2. 帧计算:确定需要存储在帧中的值。
  3. 生成 rampresume 函数
  4. 将所有帧操作降级为指向刚生成帧的 getelementptr 操作。

降低隐式挂起点(code.await)的原因在于它代表了设置回调和提取结果等复杂操作。

计算帧是棘手的一步。由于此时控制流是结构化的,我们必须通过创建由操作表示的虚拟块来模拟非结构化控制流。算法试图计算需要存储在帧中的值:如果从定义到使用的路径经过一个挂起点,则该值需要存储在帧上。这很棘手,因为不能只考虑唯一路径。例如,在某些循环路径中,如果不注意,可能会忽略某个值需要存储在帧上。

另一个挑战是,有时克隆廉价操作比将其放入帧中更好。例如,指针运算不应存储另一个已在帧中的值,而应克隆该偏移量。

最后,栈分配必须替换为帧分配。我们不能依赖SSA使用链来建模,因此使用生命周期标记来识别栈分配是否需要存储在帧上。

还有一个挑战是,一个协程可能同时需要热启动和冷启动 ramp,但我们理想情况下只希望有一个 resume 函数以减少IR占用。解决方案是将初始状态到第一个状态的所有特定状态存储在帧的尾部。这样,在第一个状态(仅可从冷启动协程到达)中,我们可以将其强制转换为更大的尺寸。

生成函数后,挂起点仍然完好无损。


总结与未覆盖主题

本节课我们一起学习了在MLIR中设计高效协程实现的完整过程。我们从异步编程的问题出发,评估了LLVM现有方案的优缺点,然后详细阐述了为MLIR设计的新方案,包括方言定义、降级顺序规划以及具体的转换步骤。

还有许多主题未在本次讨论中覆盖:

  • 帧中值的生命周期管理(Mojo使用ASAP销毁)。
  • 错误处理及如何在帧中存储相关值。
  • 调试回调。
  • 尾调用必须标记为 musttail
  • 如果没有挂起点,应避免异步调用的开销。
  • 从前端角度看,为什么必须用 async 标记函数?

这些将是未来探索的方向。

051:开发与发布流程对比

在本教程中,我们将学习LLVM和GCC这两大主流编译器项目的发布工程策略。我们将对比它们的历史背景、开发流程、发布模型以及各自的优缺点,帮助初学者理解不同开源项目的管理模式如何适应其生态系统的需求。

历史背景与开发流程

首先,我们来了解一下GCC的历史和开发流程。GCC(GNU编译器集合)拥有悠久的历史,曾是BSD和Linux系统的默认编译器。其开发流程中的一个关键转折点是EGCS(实验性GNU编译器系统)分支与主干的重新合并,这确立了由社区主导的发布模式。

GCC的开发模型围绕一个指导委员会构建,但战略目标主要由社区驱动。项目设有维护者和发布经理,他们通常来自Red Hat和SUSE等主要Linux发行版供应商。GCC的核心特点是注重稳定性和向后兼容性,其主干开发版本应始终保持稳定。

以下是GCC开发流程的一些关键数据点:

  • 2023年,GCC编译器部分(不包括库)约有6600次提交,来自389位不同的开发者。
  • 开发遵循可预测的年度节奏:7个月的功能开发,随后是2个月的错误修复和3个月的回归测试,以确保发布稳定性。
  • 测试主要依赖庞大的、积累了30年的测试套件,但项目自身缺乏CI(持续集成)系统,这限制了快速反馈的能力。

GCC的发布模型提供长达三年的统一维护。所有针对已发布版本的错误修复都必须首先在主干中提交,然后才向后移植到维护中的发布分支。官方发布遵循固定的节奏,而Linux发行版则会选择特定的GCC版本进行更深入的测试和长期支持。

LLVM的开发与发布模型

上一节我们介绍了GCC注重稳定性的开发流程,本节中我们来看看LLVM是如何在高速创新的同时保持主干稳定的。

LLVM项目的贡献流程近年来变得更加结构化,采用了更正式的决策机制和新的治理模型,包括为子项目选举区域团队。尽管变更速度极快(每月提交数和作者数约为GCC的五倍),但项目强烈强调保持主干分支的稳定

这是通过以下策略实现的:

  • “先回退,后讨论”政策:对于造成破坏的变更,优先回退。
  • 庞大的构建机器人集群:进行持续的提交后测试。
  • 下游用户的定期测试:他们能运行比构建机器人更广泛的测试。

LLVM开发模型与GCC的一个显著不同是,在发布分支为下一个版本稳定代码的同时,主干分支仍继续进行功能开发。这是因为许多下游用户只关心主干的内容,冻结主干会导致不满。

LLVM的发布周期为六个月,非常可预测。从主干分出发布分支后,会经历约一个月的稳定期,然后进行为期数月的错误修复,期间每两周发布一个版本。目前有一个提案,计划将错误修复期从三个月延长至五个月,以确保始终有一个社区支持并修复错误的LLVM版本可用。

近年来,LLVM的发布流程已高度自动化,包括自动化的后移植、GitHub上的状态跟踪项目以及针对发布分支的CI测试,这极大地降低了发布成本。

生态系统差异与挑战

了解了两个项目的基本流程后,我们来看看它们如何被不同的生态系统所消费,以及由此带来的挑战。

GCC与Linux发行版共同演化,非常适合作为共享的、多用途系统(如服务器、桌面)的系统编译器。其优势在于长期的ABI(应用二进制接口)稳定性和向后兼容性。在Linux发行版中,升级GCC版本不会破坏之前用旧版本构建的软件包。

相比之下,LLVM面临不同的挑战。其C++库在主要版本之间没有稳定的ABI或API。这意味着:

  • 应用程序在每次LLVM库升级后都需要重新构建。
  • 通常,应用程序还需要修改源代码以适应API的变化。

这导致Linux发行版难以直接升级系统中的LLVM版本。作为解决方案,发行版(如Fedora)会并行提供多个可安装的LLVM版本。虽然这不是理想的支持模式,但为了确保依赖特定LLVM版本的软件包能够工作,这是必要的。

这种差异源于消费模型的不同。LLVM的模块化设计和快速迭代使其非常适合容器化和微服务场景,在这种场景下,应用与其工具链被打包在一起,持续交付和更新,对长期系统级ABI稳定的需求较低。

优势、劣势与未来思考

最后,我们对两种策略的优势和影响进行总结,并探讨未来的可能性。

GCC的主要优势在于其长期支持、稳定性和统一的维护。它为整个Linux生态系统提供了一个一致、可靠的基石,确保了系统级二进制兼容性。但其弱点在于相对僵化的开发流程,这可能抑制创新速度和贡献者快速集成变更的能力。

LLVM的主要优势在于创新速度快、设计灵活且模块化。然而,其缺乏长期支持以及ABI/API的不稳定给下游用户和希望将其作为系统编译器的发行版带来了更新上的麻烦和适配成本。目前,许多基于LLVM技术的供应商编译器都在独立提供长期支持,造成了工作的重复。

一个核心问题是:如何让LLVM在保持创新活力的同时,也能更有效地作为替代的系统编译器,提供长期支持?

对于LLVM项目而言,实施LTS(长期支持)版本面临一些挑战:

  • 开发带宽:为多个活跃分支后移植和审查修复需要大量人力。
  • 日程对齐:固定的LTS发布周期很难与所有下游用户的内部日程匹配。
  • 技术难度:项目每年有海量提交(约4万次),长时间分支的后移植工作会变得异常复杂。

如果考虑LTS,社区需要明确其范围:支持频率、支持时长、修复何种类型的错误等。这是一个值得深入讨论的话题。

总结

本节课中,我们一起学习了LLVM和GCC的发布工程策略。我们看到,GCC采用了一种以稳定性和长期兼容性为核心、与Linux发行版深度集成的模型。而LLVM则采用了一种支持快速创新、更适合现代容器化部署的敏捷模型,但牺牲了系统级的ABI稳定性。两者不同的策略反映了它们所服务生态系统的不同需求。理解这些差异有助于我们根据具体的使用场景选择合适的工具,并思考开源项目如何平衡创新与稳定这一永恒的主题。

052:让上游MLIR对编程语言更友好

概述

在本节中,我们将探讨如何使上游MLIR对传统编程语言更加友好。MLIR在机器学习编译器领域取得了巨大成功,但其设计主要服务于该领域,导致在处理通用编程语言时存在一些限制。我们将分析这些限制,并介绍一些旨在改善此状况的初步工作和未来构想。

动机与背景

上一节我们概述了本节的主题。本节中,我们首先来看推动这项工作的动机。

MLIR在机器学习编译器(如JAX、XLA等)中取得了巨大成功,并驱动了显著的性能提升。这些成功也反映在上游MLIR中。这意味着,例如,你会看到tosalinalg这类方言,而不是在通用编程语言中更常见的传统组件。

然而,我们现在看到更多传统前端开始出现。例如,flang(Fortran前端)已开发一段时间,并正接近生产就绪状态。另一方面,Plier(一种Python前端)也获得了上游批准。重要的是,它们持有CIRFIR等表示形式,并尝试将其降级到LLVM IR,这是一条相当直接的路径。

作为社区,我们期望或希望未来有更多编程语言(如Julia、Rust等)进入MLIR生态系统。大量通用编程语言涌入MLIR,可能会给下游编译器带来负担,因为MLIR对表示传统编程语言的支持不足。这最终导致大量重复工作。例如,许多方言可能会实现相同的逻辑操作、相同的if操作,甚至相同的load操作,仅仅因为它们认为上游MLIR中没有可用的语义等价操作。这进而导致控制流扁平化等过程也被重复实现。我们的想法是,如何缓解这种情况。

上游MLIR的当前限制

了解了背景后,现在我们具体看看上游MLIR存在哪些限制。

存在一些相当基本的限制,反映了MLIR长期以来主要受到机器学习编译器社区的贡献。这意味着,例如,像结构体、数组的加载和存储等操作无法在高级方言中表示。如图所示,如果你有一个结构体操作,你不能直接将其降到memrefLLVMSPIR-V,因为缺少这种高级表示。相反,这意味着如果你想处理结构体操作,你必须决定是直接降到LLVM IR、memref还是SPIR-V。最终,这导致许多编译器开始重新实现这些降级过程,而在许多情况下,这些过程是相当直接的。

MLIR中另一个问题是缺乏早期退出控制流。在传统编程语言中,你通常有breakcontinuegoto等。其负担在于,如果你想支持这些,你必须重新实现基础设施,例如再次扁平化控制流,或者增强数据流基础设施以处理这类表示,因为如果你现在尝试使用它们,它们并未得到真正的支持。

初步解决方案:指针方言模块化

认识到这些限制后,我们希望缓解其中一些情况。这是该进程的第一步,即关于模块化LLVM指针操作的提案。

其主要思想是,我们可以将指针操作从LLVM方言中提取出来,放入它们自己的方言中。在许多情况下,我们可以拥有这种在更高层次上运作的双重表示。例如,图中显示指针操作甚至可以在张量上运作,这是目前如果你使用LLVM指针无法做到的,因为LLVM会抱怨该类型不受支持。我们的想法是使其目标独立,以便它可以降到memref或SPIR-V。这个指针方言已经通过了RFC流程,并已开始上游化。

在具体作用方面,让我们从指针降级的影响开始看。在这种情况下,我们希望例如能够从memref降到指针,然后发出memref或SPIR-V,这将增加对这些方言的支持,而这些方言在许多情况下被忽视,因为LLVM在某些情况下具有优先权。

我们在这里开始做的最大区别是,这些指针操作不会降级到memref,而是直接转到LLVM IR并在那里进行翻译。因此,指针和LLVM之间的操作不会重复。

将指针操作抽象到更高层次的一个好处是,我们可以获得许多新功能。例如,我们可以引入限制操作。在这种情况下,我们可以拥有常量地址空间。例如,如果你尝试在仅具有内存空间的指针上进行存储,你将得到一个验证错误。这也可以用于别名分析等工作,因为如果内存空间是常量,我们可以假设它不会与其他任何空间冲突。

为了使其可扩展且对下游用户友好,我们决定创建一个属性接口,将一些语义包含其中并抽象到这个接口中。在这个接口中,你可以获得例如isValidLoad等功能,它将返回某个类型是否受地址空间支持。这就是我们如何获得例如常量地址空间的方式。

内存模型很大程度上受到LLVM的启发,例如,你拥有相同的内存排序语义。一个好处是,这促使我们清理一些上游内容。例如,我们可以拥有与memref之间的转换操作。这样做的想法是,它还允许我们将bareptr调用约定变成一个过程,从而稳定了ABI,因为如果你曾经使用它,bareptr调用约定在内存具有静态大小和动态大小时效果不佳,因为它也经过方言转换,然后变得棘手。

我们需要说明的一个重要事项是,我们需要衡量将指针操作从LLVM模块化出来的性能影响。在性能方面,我们在一些合成测试中进行了测试。在大多数情况下,我们看到影响小于3%。此外,这可以完全替代LLVM指针操作。例如,在实现方面,这意味着你可以在开始时从一个切换到另一个,而不会注意到差异。例如,在我的补丁实现中,flang代码从未出现重大中断。这也很重要。

扩展模式:重用与目标独立抽象

感谢Fabian展示了指针方言,这可能是我们从LLVM方言中提取某些内容以泛化其处理高级类型能力的第一个实例。这要归功于类型接口或属性接口。但相同的模式存在于LLVM方言的其他方面。

我们在下游实现的一些编译器中看到,有一系列操作和方言的语义非常接近LLVM,以至于它们可以直接翻译成LLVM IR。例如cf方言,如果你只有一个ifbr,直接将其翻译成LLVM IR中的块是很直接的。当不在张量上操作时,arith方言与LLVM算术操作是一一对应的。正如Fabian刚才展示的,仅在操作LLVM类型时的指针方言,也是可以直接翻译的。这对JIT来说非常重要,因为涉及编译时间,我们节省了方言转换过程。因此,这是我们下游必须经历的事情,我们开始拥有一个直接以LLVM方言为目标的编译器,而不是使用arith方言,仅仅是为了编译时间。我们节省了转换过程。

我们遇到的问题是,许多规范化器和其他功能只存在于arith方言中。因此,我们现在必须回溯。我们在编译器中添加了arith,但我们的编译时间退步了。所以我们有了这个权衡。现在,我们正在添加从arith到LLVM的直接翻译来补偿这一切。

因此,在这一点上存在这种不幸的重复。我们为LLVM方言提出的模块化建议是,让我们不要重复操作,而是尽可能重用。我们可以停止使用llvm点操作,当它与arith点操作在LLVM类型上完全一一对应时。

我们可以开始做的另一件事是,为高级类型重用这些抽象,并开始构建目标独立且可重定向的抽象。这样做的问题是,LLVM方言不再是一一映射到LLVM IR。它是代表MLIR中LLVM IR的方言集合。这是一个需要权衡的问题,它简化了我们不重复文档、不重复大量内容,但我们必须处理一个共同代表MLIR中LLVM IR的方言集合。

这基本上是我们试图解决的一些权衡。关于arithllvm,随着时间的推移,arith最初在定义方式上语义较为宽松。但随着人们开始构建生产编译器,他们希望有更好的定义语义,并希望使用arith来控制以定位所有LLVM语义。因此,我们最终在添加所有相同标志和相同语义方面,使arith方言与LLVM方言事实上趋同。

这确实减少了一些随机性。在我谈到的编译时间影响方面,我们构建了这个翻译的概念验证和合成测试。我们在这里测量的到LLVM的转换是从arith方言开始,必须进行方言转换和翻译。最后一行显示,通过直接从arith翻译,而不是进行额外的、基本上是一一对应的方言转换,我们获得了1.5倍的加速。在实践中这样做确实有真正的好处。

超越模块化:高级抽象与ABI

除了LLVM方言的模块化之外,还存在如何向高级类型公开这些操作以及如何抽象ABI等问题。

例如,像结构体或数组这样的东西,它们可能是相当简单的方言,不需要很多操作,但可以统一我们操作结构体和其他聚合类型的方式。并允许人们注入自己的类型和降级过程,同时仍然能够访问所有这些功能。

这只是一个可能样子的例子。我们还没有构建这些方言,我们只是在集思广益,思考前端需求以及我们如何统一它们。

那么问题是,我们是否正在走向一个为编程语言服务的方言集合?我们需要走多远?我们能在多大程度上构建对Fabian一开始想到的那种语言真正有用的可重用组件?例如,这里我们展示了一个向上作用域退出,例如,我们展示了一个try-catch。这是一个异常抽象的例子。目前尚不清楚我们能在多大程度上泛化这类事物并使它们普遍有用。

因此,这目前主要是集思广益,并向社区征求评论,试图了解需求是什么以及什么可能有用。

我知道有用且许多人在过去两天里一直在请求的是,在循环内和嵌套if中,你想要跳出循环或继续或从封闭函数返回的这种结构。这在MLIR中是不合法的。如果你不重用太多上游组件,你可以在自己的编译器中实现它,例如,Mojo就大量依赖这类东西。但MLIR中没有对此的一流支持,这将破坏数据流分析,破坏上游许多试图理解操作间后支配属性的组件,因为我们假设这种情况不可能发生。

所以这是最后一个要点,它确实对分析、支配性等有影响。但有很多人正在研究它。所以它即将到来。我们没有具体的日期,也没有最终设计,但希望到下一次LLVM开发者大会时,我们应该已经实现了它,这确实是我的期望。

最后,目标ABI抽象是一件大事。如你所知,LLVM是ABI特定的,意味着前端必须了解目标ABI,并在发出LLVM IR之前编码所有这些决策。在MLIR中也是如此。如果你使用LLVM方言,你必须做出所有这些决策。这使得与C的交互有点复杂。这就是为什么如果你曾经使用过memref并尝试在MLIR中为memref编写C运行时,会有一个复杂的ABI,MLIR试图实现它,只是为了不必了解各种目标之间的差异。

因此,Clang前端本身编码了关于每个目标平台的Itanium、Microsoft等调用约定的所有知识。这里有一些工作可以做,即从Clang中提取这些知识,使其独立于Clang的C类型系统,并使其可用于其他前端,包括MLIR。这将允许在MLIR中构建这样的东西:人们可以用C类型系统来表达他们的类型系统,并免费获得针对x86或其他任何平台的到LLVM调用约定以及其他ABI方面(如布局等)的降级。

LLVM最初试图成为一种数据布局独立的IR。这个目标在十多年前就被放弃了。但在MLIR中,我们现在希望朝着构建这种属性发展,即在LLVM方言和LLVM方言之上,我们应该能够以非常ABI独立的方式表达事物。

总结与展望

本节课中,我们一起探讨了如何使上游MLIR对传统编程语言更加友好。我们从动机和现有限制出发,介绍了模块化LLVM指针操作作为初步解决方案,并探讨了重用抽象、构建目标独立方言以及处理高级控制流和ABI抽象等更广泛的构想。

我们希望这能开启更多讨论。我们正在寻找MLIR中编程语言所需的更多想法。感谢参与。


问答环节

问: 对于基于区域的控制流,例如cf方言,您是否知道目前的主要障碍是什么?我记得有一个关于采用Mojo中使用的cf方言的提案。

答: 问题不在于方言本身,而在于MLIR的核心。首先,我们必须拥有那个长图(long graph)来允许这种结构。然后我们必须决定如何建模这些新操作,例如,当你看到if内部的continue时,你如何知道这个continue映射到哪个操作?我们需要某种标签来表达这一点。我们是要通过为操作类添加新字段来支持,真正修改MLIR核心以在图中构建新边,还是使用类似符号接口、更基于名称的方式?转换应该如何更新它?例如,如果你想展开这个循环,你如何找到所有的continuebreak?我们必须解决一些基础设施问题和语义问题。

问: 我猜想,但有些部分可能争议较小,例如放宽每个终结符必须是基本块终结符的规则。

答: 是的,没错。这是我们必须要解决的那种问题。

问: 第二个问题是关于构想。我在想除了结构体和数组之外,设计是否也会考虑表示数据集合。我特别想到今年CGO论文中的设计,以SSA形式表示数据集合,这允许向编译器传授关于各种数据集合(如哈希表、映射或树)的知识。

答: 我还没看过这篇论文。我会去查一下。谢谢你的建议。

问:cf方言层面,我想你可以有continuereturn(不确定return)。但我猜你希望在scf层面拥有这些,对吗?因为cf层面更接近LLVM IR。

答:cf方言层面,如果你没有区域,没有嵌套,你有一个扁平的CFG,那么一切都是分支。所以那里没有什么可添加的。问题只针对结构化控制流。但我们希望在scf层面有这个。那么下一层呢,比如affine,你不能有这个。所以我想,它出现在这里。它可以是长IR(long IR),我认为长IR可能需要这类东西,在任何像Mojo这样的编程语言中,以及任何甚至高于scf的语言中,如果它们不使用这个的话。但没错,那些是针对scf类型的。

答: 好的,谢谢。

主持人: 还有其他问题吗?好的,让我们再次感谢演讲者。

053:使用LLVM进行现代嵌入式开发

在本教程中,我们将学习如何利用LLVM工具链进行现代嵌入式开发。我们将探讨从传统工具链迁移到基于LLVM的解决方案的经验,深入了解LLVM运行时库在嵌入式环境中的应用,并分析当前的优势与面临的挑战。

背景与动机

上一节我们介绍了教程的主题。本节中,我们来看看推动我们采用LLVM进行嵌入式开发的背景和动机。

我们观察到,在谷歌内部不同的嵌入式产品项目中,大多数都使用其芯片供应商提供的工具链。这些工具链通常版本过时,很少更新。由于芯片供应商使用这些工具链来构建其SDK附带的平台库,他们常常会无意中强制客户使用这些工具链。每个工具链都带有自己的C/C++库,它们的API或ABI可能各不相同,统一它们极具挑战性。

当开始混合使用不同架构时,情况变得更加复杂。我们看到越来越多的产品结合了不同指令集的处理器核心,例如ARM和RISC-V的组合已很常见。开发者也可能在不同的主机上进行开发,如Linux、Mac、Windows。因此,要构建一套能在所有这些环境中工作的工具链,是一项艰巨的任务。

我们通常看到两种应对方式:一种是找到一组能协同工作的工具链,并在产品的整个生命周期内基本保持不变。但我们相信有另一种方式,这也是谷歌长期以来在生产负载及Chrome、Android等项目中一直采用的方式,即“前沿开发”(live at head)。这意味着始终尝试从源代码编译一切,紧跟上游最新进展,并定期更新以避免菱形依赖和合并冲突。我们认为前沿开发模式同样适用于嵌入式领域。

我们的目标与方法

上一节我们讨论了传统嵌入式工具链的挑战。本节中,我们来看看我们为此设定的目标和采用的方法。

我们的目标是提供一个开源的、用于裸机的交叉编译Clang工具链,其中不包含任何遗留组件。我们使用Clang和LLD等工具,使用LLVM的libclibc++compiler-rt作为运行时库。我们直接在上游进行开发,不维护下游补丁,紧密跟随上游最新进展。这使我们能够在新功能(如引入Clang和这些库的功能)出现时立即采用,也让我们能向LLVM社区提供大量即时反馈。

前沿开发的关键在于自动化和测试。你需要知道LLVM中是否有东西被破坏,是什么被破坏了,并希望能够尽快提供反馈,以便相关更改可以被回滚或修复。

选择测试平台:树莓派 Pico

上一节我们介绍了前沿开发模式。本节中,我们来看看为实现这一模式而选择的理想测试平台。

进行裸机开发可能相当具有挑战性。存在大量专用硬件,处理起来非常棘手,尤其是在开始与LLVM打交道时。为了使这项工作可持续,我们需要一个优秀的平台:它应该是公开可用的、成本低廉、文档完善,理想情况下应该是开源的,可以作为我们内部不易被LLVM开发者访问的产品的代理。我们认为树莓派Pico系列可能是一个绝佳选择。

树莓派Pico是树莓派推出的一系列流行的微控制器产品。目前有两代:第一代Pico 1于2020年发布;就在几个月前,树莓派基金会宣布了第二代Pico 2,它结合了ARM和RISC-V核心。这里的一个问题是,用于构建在这些开发板上运行代码的SDK并不支持官方的Clang工具链,至少直到几周前还不支持。

今年早些时候,我们与树莓派基金会、Pium和谷歌合作,共同使Pico SDK成为我们所知的第一个可以直接使用开源Clang和LLVM工具链(使用compiler-rt、LLVM libclibc++)构建的树莓派项目。

构建与使用工具链

上一节我们介绍了测试平台。本节中,我们来看看如何为Pico SDK构建和使用基于LLVM的工具链。

以下是构建和使用工具链的基本步骤:

  1. 从GitHub克隆Pico SDK。
  2. 使用CMake配置正确的编译器、平台,并指向我们想要使用的工具链。

你可能会问,从哪里获取工具链?如果你参加了去年的LLVM开发者大会,我实际上做了一个关于LLVM构建和CMake的教程。现在是时候复习一下你记住了多少。让我们继续,为自己构建一个工具链。

我们将编写自己的CMake缓存文件,这是一种更可维护的方式,用于保存设置并将其输入到CMake构建中。我知道这看起来像是一堆乱码,但如果你仔细看,应该会相当清楚我们在做什么:我们选择要支持的目标(ARM和RISC-V),选择要构建的项目和运行时库,配置一些默认值以避免总是在命令行中指定,并选择要包含在工具链中的工具和组件。

接下来,我们需要为编译器内置函数设置目标。这将是Pico 2支持的两个架构。我们设置一些其他选项,最重要的是底部的CMake C标志。然后,我们对运行时库(即libclibc++)做同样的事情。

基本上,此时我们可以从GitHub获取LLVM源代码,启动CMake并指向我们的缓存文件,然后构建一个可用于Pico SDK的工具链。如果你不想照着幻灯片上的内容重新输入,我已经将这个文件上传到GitHub,你可以稍后查看并自己尝试。

运行时库使用经验

上一节我们介绍了工具链的构建。本节中,我们来看看在使用各个LLVM运行时库时的具体经验。

对于compiler-rt,我们发现它基本上是GCC运行时的一个很好的直接替代品。我们没有遇到太多问题。虽然存在一些差异,尤其是在处理更奇特的类型(如float16float128)时,但在Pico SDK中我们并未真正遇到这些问题。我们知道的一个限制是,LLVM目前并不知晓正在使用的编译器运行时,因此无法充分利用compiler-rt特有的例程。但这更多是一种优化不足,未来仍有改进空间。

libc方面,libc已经开发了几年,我们相信它终于为更广泛的采用做好了准备,尤其是在裸机世界。它是一个采用宽松许可证的C库实现,可以从大型服务器扩展到像Pico这样的小型开发板。为了使它能用于Pico SDK,我们必须实现大量缺失的功能,如高等数学函数、malloc实现、基本I/O支持。libc仍然缺少一些部分,如启动代码、半主机I/O等。这些都是我们正在积极研究并希望在未来几个月内实现的方面。

此外,我们还开始研究低级嵌入式API。可以将其视为类似POSIX或系统调用层,但是针对嵌入式的。它是一组LLVM libc期望任何平台提供的符号,因为我们不希望将Pico SDK或FreeRTOS等特定平台的知识硬编码到libc中,而是希望这些功能存在于平台端。目前我们只有少数几个函数来处理标准输入、标准输出和终止等操作,但我们预计这个接口会随着时间的推移而增长,特别是当我们添加线程支持等功能时。

libc++方面,libc++作者提供了许多选项来禁用不必要的功能,我们也使用了这些选项。但我们也发现,可能需要更多的配置点来更好地支持嵌入式环境,因为仍然存在一些地方,libc++使用了动态内存分配、线程局部存储或浮点运算等功能,这在许多裸机平台上可能是不可取的。我们还必须引入对使用libc构建libc++的支持,这在以前是不存在的。

因此,回到我最初的例子,除了之前提到的内容,我们还需要设置一些更多的选项来禁用当前不支持或不希望使用的功能。最值得注意的是本地化、Unicode宽字符支持等。这是暂时的,我们正在libc++上积极工作,希望随着时间的推移,所有这些都将得到实现,因此未来许多这些选项将不再需要。

当前挑战与改进方向

上一节我们讨论了运行时库的经验。本节中,我们来看看在嵌入式开发中使用LLVM时遇到的主要挑战和未来的改进方向。

总的来说,我认为构建工具链正变得越来越容易。直到最近,还不可能在LLVM项目内构建一个功能齐全的裸机工具链。但尽管如此,仍有很大的改进空间。例如,你在前面看到的CMake缓存文件中仍然有很多样板代码,我认为我们可以随着时间的推移减少和消除它们。

多库支持对于裸机非常重要,因为硬件差异很大。现有的多库支持相当有限,效率低下,需要大量的CMake配置,并且会显著延长构建时间。目前也没有对新的基于YAML的配置格式的支持。但除此之外,我甚至不确定从长远来看我们是否应该使用多库。我认为我们应该探索实际根据需要使用正确的标志从源代码构建库的想法。这在我们内部的PixelWatch项目中进行了实验,我们有一个最小可行原型,但目前它非常依赖于特定的构建系统。随着时间的推移,我们希望看看是否可以将此通用化并直接在Clang和LLVM中支持。

我们还发现性能方面仍存在一些差距。许多裸机项目对二进制大小和内存使用非常敏感,使它们适配通常是一项精细调整的工作。我们看到GCC通常在-Os级别能更好地平衡大小和性能。使用Clang的-Oz,我们通常会得到更小的二进制文件,但这实际上可能以性能下降为代价,这并不总是可取的。未来我们也许可以通过像机器外联器这样的其他工具恢复一些性能,但我认为我们还可以做得更多。

我们还发现LLVM中的堆栈分配存在很多不足,这可能导致堆栈使用效率低下,也经常在裸机上引起问题。实际上我们有一个跟踪错误,其中包含更多细节。这里只是我们一个项目中的例子,展示了我们为了将所有内容控制在预算下而设置的所有标志。虽然这可行,但可维护性不高,我们真的不希望将这些标志从一个项目复制到另一个项目。我希望随着时间的推移,我们能想出比这更好的方案。

调试也可能具有挑战性。我们注意到的一件事是,目前LLVM和LLDB并不支持所有的DWARF调试信息构造。这可能是个问题,尤其是在处理像中断处理程序这样的手写汇编代码时,如果你需要支持栈回溯(我们在嵌入式开发中偶尔会遇到这种情况)。LLDB对裸机的支持也存在一些不足,例如,目前对RISC-V的支持基本缺失,这是我们希望改进的。

LLVM高级功能在嵌入式的应用前景

上一节我们探讨了当前的挑战。本节中,我们来看看如果这些问题得到解决,LLVM中哪些高级功能能为嵌入式开发带来益处。

假设所有这些问题都得到解决(我相信在未来几个月内会),我认为LLVM非常适合嵌入式开发。我相信LLVM中有很多功能可以让嵌入式开发者受益,但LLVM开发者也需要了解这些系统所面临的约束。

让我们看看其中的一些。首先是异构内存支持。这在嵌入式系统中非常普遍,嵌入式系统使用像__attribute__和链接器脚本这样的工具将符号放置在特定内存的特定地址上。但目前这些与LTO和PGO不兼容。LTO是一个极好的工具,我们在嵌入式领域进行了一些早期实验,看到了超过20%的二进制大小缩减。但今天的LTO与链接器脚本不兼容,这是个问题,因为链接器脚本在裸机中非常普遍。同样,PGO可以提供巨大的性能改进,通常超过20%,但它并没有真正考虑到像数字替换这样的方面,而这又是一个要求。未来我们也希望研究像Propeller或BOLT这样的后链接优化工具,这仍然是一个开放的研究领域。

接下来是消毒剂。消毒剂是发现错误的绝佳工具,但由于内存限制,在嵌入式系统上使用它们非常具有挑战性。我们实际上在一些开发中成功使用了UBSan来发现像未对齐访问这样的问题。但我们也注意到,虽然UBSan有几个针对不同环境定制的运行时实现,但没有一个完全适合裸机平台,我们可能需要另一个。ASan目前基本无法工作,主要是因为插桩开销太高,我们真的需要找到一种方法来降低开销,使其适用。还有其他消毒剂,目前我不太确定那些是否能在嵌入式设备上可行,仅仅因为高开销,但我们仍在研究。

一个更普遍的主题是,所有消毒剂运行时最初都是为类POSIX系统开发的,后来才移植到Windows等平台,它们通常假设底层平台类似于POSIX,但在嵌入式设备上情况并非如此,这使得移植非常困难。我认为需要更多的工作来清理实现,使它们更具可移植性。

最后是源代码覆盖率。这是一个非常有用的工具,可以帮助跟踪测试进度。但我们从经验中得知,现有的运行时存在很多代码质量问题,使移植复杂化,需要更多的工作。也有机会减少覆盖率功能的插桩开销,比如单字节计数器、条件计数器更新或此类计数器。所有这些实际上都是我们正在努力的方向,有一个活跃的开发者社区正在努力改进源代码覆盖率,并使其在裸机上更有用。

总结与社区参与

上一节我们展望了LLVM高级功能在嵌入式的应用。在本节中,我们将对教程内容进行总结,并介绍如何参与相关社区。

总而言之,我认为LLVM非常适合嵌入式开发。LLVM是一个交叉编译器,这意味着单个工具链可以用于所有不同的目标平台和不同的主机,你不需要多个工具链,这简化了维护。我们正努力使在单个LLVM构建中构建工具链变得更加容易。LLVM现在提供了模块化、宽松许可证的C和C++库实现,我们正努力使它们在裸机上真正可用。我们也在积极考虑持续测试,我们的长期目标是让LLVM CI中有一个机器人,实际使用树莓派Pico进行持续测试,以确保我们不会在像libclibc++这样的库中对裸机的支持上出现退步。最后,有很多优秀的工具和功能可以真正帮助嵌入式开发者,如性能分析、LTO、消毒剂和覆盖率,我们真的希望将所有这些都带到裸机领域。

如果这些方面中有任何一点让你感兴趣,请务必参与进来。有多种方式可以参与。我们有一个活跃且不断壮大的社区,每四周举行一次会议,时间是太平洋时间周四上午9点,通过Zoom进行,欢迎所有人加入。我们也有一个嵌入式工具链研讨会,我们昨天刚刚举办了第二届研讨会,希望这不是最后一次,希望明年还有。最后,我们在GitHub上也有一个“embedded”标签,用于跟踪与嵌入式使用相关的问题。如果你想了解我们正在处理哪些问题,请使用该标签。

本节课中,我们一起学习了使用LLVM进行现代嵌入式开发的完整流程:从背景动机、目标方法,到具体平台选择、工具链构建、运行时库经验,再到当前挑战和未来功能展望。LLVM为嵌入式开发带来了前沿开发、统一工具链和丰富工具集等巨大潜力,虽然仍面临一些挑战,但社区正在积极推动其发展。

054:为 RISC-V 向量扩展新增 llvm-exegesis 支持 🚀

在本节课中,我们将学习如何使用 LLVM 的 llvm-exegesis 工具来调优 RISC-V 向量扩展的调度模型。我们将了解该工具的重要性、在支持 RISC-V 向量指令时遇到的挑战、解决方案,以及如何通过改进工具来提升其效率和实用性。


概述 📖

llvm-exegesis 是一个用于调优 LLVM 调度模型的工具。调度模型是一个庞大的数据库,包含了指令延迟、占用率(与吞吐量相关)以及所使用的硬件资源等信息。在引入 exegesis 之前,手动改进调度模型是一个耗时且难以扩展的过程。exegesis 通过自动化生成微基准测试、执行测量并与调度模型对比,极大地提高了效率。


什么是 llvm-exegesis?🔧

上一节我们介绍了课程概述,本节中我们来看看 llvm-exegesis 到底是什么。

LLVM 调度模型中的最小信息单元不是单条指令,而是一个称为“调度类”的概念。RISC-V 架构中的调度类数量远多于 LLVM 支持的其他目标平台,这意味着手动调优 RISC-V 的调度模型将花费更多时间,因此使用 exegesis 工具至关重要。

exegesis 通过自动化大部分流程来解决手动调优的问题:

  1. 它为每条指令生成一个微基准测试代码片段。
  2. 它在硬件上执行该代码片段并测量性能。
  3. 它将测量结果与调度模型进行自动对比,并向开发者反馈不一致之处。

开发者只需根据反馈修改调度模型即可,这使得整个过程更高效、更省时,且扩展性良好。


一个示例:测量延迟与吞吐量 📊

在了解了 exegesis 的基本概念后,我们通过一个具体例子来看看它是如何工作的。

对于每条指令,exegesis 会生成类似以下的元数据和代码片段,用于描述和进行性能测量。

测量延迟的代码片段
为了测量指令延迟,需要创建串行执行的代码片段,即将前一条指令的输出作为后一条指令的输入。

# 元数据
...
# 测量代码片段(重复指令多次以测量延迟)
loop:
    vadd.vv v1, v2, v3
    vadd.vv v1, v2, v3 # 输出v1作为下一条指令的输入
    ...

测量吞吐量的代码片段
为了测量吞吐量,需要创建并行执行的代码片段,即指令间没有数据依赖。

# 测量代码片段(并行执行指令以测量吞吐量)
loop:
    vadd.vv v1, v2, v3
    vadd.vv v4, v5, v6 # 使用不同的寄存器,避免依赖
    ...

测量完成后,exegesis 会生成报告。例如,它可能发现某条指令的实际测量延迟与调度模型中记录的数据不一致,从而指导开发者进行修正。


RISC-V 向量扩展的独特挑战 ⚙️

现在我们已经知道 exegesis 如何工作,本节我们将探讨在支持 RISC-V 向量扩展时遇到的独特挑战。

RISC-V 向量扩展指令有一个独特属性:同一条指令可以附加不同的配置,这些配置可以在运行时改变。例如,对于向量加法指令 vadd.vv,可以配置元素大小和同时处理的向量数量。

  • vadd.vv v1, v2, v3, e32, m2:使用 32 位元素,同时处理 2 个向量。
  • vadd.vv v1, v2, v3, e64, m4:使用 64 位元素,同时处理 4 个向量。

尽管指令助记符相同,但不同的配置会导致完全不同的性能特征。为了在 LLVM 中处理这种隐式的配置,代码生成器使用了“伪指令”,并通过操作数使这些配置显式化。同时,不同的向量长度组也对应不同的调度类,因为它们的性能通常差异很大。


在 exegesis 中支持 RISC-V 向量指令 🛠️

面对 RISC-V 向量指令的复杂性,我们来看看如何让 exegesis 支持它们。

LLVM 使用伪指令来显式化向量配置,这为 exegesis 生成代码片段提供了便利。支持流程如下:

  1. 枚举配置:为每条伪指令枚举所有合法的配置(如元素大小、向量长度组)。
  2. 生成代码片段:基于枚举的配置生成初始的基准测试代码片段。
  3. 过滤与处理:由于 RISC-V 向量规范复杂,存在许多限制(如元素大小限制、寄存器组不允许重叠等),需要过滤掉非法的指令组合。此外,还需要处理伪指令中隐含的“直通”操作数,避免在创建串行片段时错误地连接依赖。
  4. 重用现有流程:利用现有的机器函数通道进行后处理,例如插入必要的设置指令、清理虚拟寄存器等。

最终,我们为每种元素大小、向量长度组和配置都生成了合法的代码片段。


案例研究:发现并修正不一致 📈

在实现了 RISC-V 向量支持后,exegesis 工具帮助我们发现了调度模型中的问题。

例如,对于向量滑动指令 vslideupvslidedown,调度模型原先认为它们属于同一个调度类,具有相同的性能。但通过 exegesis 测量发现:

  • 它们的实际延迟与模型记录不符。
  • 更重要的是,这两条指令本身具有不同的性能特征。

因此,我们不仅修正了调度模型中的数据,还将它们拆分到了两个不同的调度类中。这些改进已经合并到上游的 LLVM 调度模型中。


挑战与优化:提升工具效率 ⚡

虽然 exegesis 很强大,但在处理 RISC-V 向量指令时,我们遇到了新的挑战:规模与效率。

RISC-V 向量指令会产生海量的代码片段组合(可达数万甚至超过十万个)。传统的 exegesis 工作流是顺序的:生成片段 -> 汇编 -> 测量。在 FPGA 或 RTL 模拟器等预硅开发环境中,测量速度极慢,导致整个流程耗时过长。

我们的解决方案是将流程拆分为两部分:

  1. 在性能强大的工作站上快速生成所有代码片段。
  2. 将序列化后的基准测试发送到慢速的预硅环境进行测量。

但直接序列化所有目标文件会导致数据量巨大(可达 5 GB),在资源有限的预硅环境中无法承受。因此,我们引入了压缩。由于每个基准测试包含大量重复的指令,现代压缩算法可以获得极高的压缩率(例如 99% 的空间节省)。这使得存储和传输开销大幅降低。

结合这些优化,exegesis 的整体运行效率提升了近两倍。


成果与总结 🏆

通过本课程的学习,我们了解了 llvm-exegesis 工具及其在调优 RISC-V 向量指令调度模型中的关键作用。

本节课我们一起学习了

  1. llvm-exegesis 如何自动化调度模型的验证和调优过程。
  2. RISC-V 向量指令的独特配置方式带来的挑战。
  3. 我们如何通过伪指令、配置枚举和过滤流程,在 exegesis 中实现对 RISC-V 向量指令的支持。
  4. 面对海量测试用例和慢速预硅环境,我们如何通过流程拆分和高效压缩来提升工具的运行效率。
  5. 最终,更精确的调度模型带来了应用程序性能的提升(在我们的性能核上观测到约 2% 的提升)。

这项工作不仅提高了调度模型的准确性,也增强了 exegesis 工具本身的处理能力和实用性,尤其对于当今常见的 RISC-V 芯片预硅开发具有重要意义。


055:一种识别与评估向量化机会的定量方法

概述

在本节课中,我们将学习如何采用一种定量的方法来评估和识别循环向量化的优化机会。我们将基于LLVM开发者大会的一次演讲内容,探讨当前循环向量化器(Loop Vectorizer)的性能瓶颈、识别方法以及潜在的改进方向。课程将涵盖性能基准测试、问题根因分析、成本模型挑战以及未来优化计划。

动机与问题陈述

上一节我们概述了本课程的目标。本节中,我们来看看启动这项工作的具体动机。我们的目标平台是ARM64 CPU,它支持新的SVE2扩展。高效利用此类硬件的向量单元至关重要,这促使我们提出核心问题:LLVM的循环向量化器性能如何?

为了回答这个问题,我们选取了两个基准测试套件进行量化分析:TSVC和Rodinia。通过使用不同编译器(如GCC和Clang)和不同优化选项(如-O3、-Ofast、禁用向量化)进行测试,我们旨在发现性能异常值、识别优化机会并提出后续问题。

基准测试与初步发现

在完成基准测试后,下一步是识别问题的根本原因并制定解决计划。本演示主要讨论TSVC的结果。TSVC是“向量化编译器测试套件”的缩写,包含152个小型循环内核函数,用于测试向量化器处理不同模式的能力。

我们将Clang(LLVM主干版本)与GCC 13进行性能对比。结果可以归纳为三类:

  1. GCC更快的案例(左侧)。
  2. 性能相似或相同的案例(中间)。
  3. Clang更快的案例(右侧)。

我们专注于改进空间最大的部分,即GCC更快的案例,并将其归类为“有趣的案例”。

主要性能回归问题分类

基于上述数据,我们创建了一个“异常值”短列表,即Clang性能比GCC差至少1.5倍的案例。以下是主要问题分类:

以下是识别出的主要性能回归类别及其影响:

  • 循环交换缺失:GCC可以进行循环交换以启用向量化,而Clang不行,导致16倍性能回归。
  • 外层循环向量化缺失:GCC的另一项技巧,导致4倍回归。
  • 控制流向量代码生成低效:导致3倍回归。
  • 交织因子不足:向量体展开不够,影响性能。
  • SLP向量化器性能不佳:导致2.5倍回归。
  • 循环剥离缺失:一项关键的启用性优化。
  • 标量优化缺失:例如预测化简(predictive commoning)。
  • 不必要的符号扩展:阻碍优化。
  • 成本模型问题:错误地决定是否进行向量化。

如果对这些问题进行聚类,我们可以发现它们主要属于以下几类:

  1. 循环优化问题:如缺少交换、外层循环向量化、剥离。
  2. 向量化器自身调优问题:如交织因子、Epilogue向量化。
  3. 成本建模问题:决策逻辑有待改进。
  4. 标量优化问题

手动优化与性能潜力

与另一个编译器对比很容易,但这并不能告诉我们理论上最好的性能是什么。因此,我们进行了一项手动练习,仅通过源码修改应用两种循环优化:循环分布循环交换

我们发现,通过手动应用这些优化,可以实现显著的性能提升,例如36倍和22倍的改进,并且能启用更多原本无法向量化的案例。这清楚地表明,编译器自动执行这些优化存在巨大的性能提升空间。

已完成的改进工作

在夏季,我们已经开展了一些工作。开始时,有37个案例Clang性能较差。通过提交一系列补丁,我们将这个数字减少到了24个。同时,Clang性能更好的案例数量以及性能持平的案例数量都有所增加,这表明我们的改进是有效的。

这些改进主要属于两类,并带来了约2倍的性能提升:

  1. 成本模型启发式调整
  2. 代码生成改进

深入分析:成本模型问题

上一节我们介绍了已完成的改进。本节中,我们深入探讨识别出的第一个核心问题:成本模型

当前成本模型的工作方式是识别IR代码片段并为其分配成本。它存在几个问题:

  • 模式手工编写:大多数模式是手写的,并非基于低级IR,因此难以精确知晓正在对什么建模。
  • 基于IR,缺乏后端信息:成本模型在IR层面运行,无法利用后端提供的延迟、吞吐量和资源描述等信息。
  • 维护困难:对于新CPU或调优,这是一项无休止的手工练习。
  • 精度不足:由于基于IR,缺乏精确性。

理想的解决方案是建立一个测试用例集,将IR片段映射到生成的代码,并使用工具(如LLVM-MCA)提取性能指标,从而填充一个成本表数据库。这样,当代码生成改变或针对新CPU时,只需重新生成即可更新。

超越2倍改进:循环优化

在解决了基本的代码生成问题后,下一步是实现超越2倍的性能提升,这主要依赖于循环优化

LLVM中已有的循环优化包括:交换、分布、融合、剥离等。我们将重点关注循环交换,因为它是一种通用转换,有利于改善数据缓存局部性,并且当存在依赖阻碍时能启用向量化。

另一个关键优化是循环分布,结合SLP向量化可以实现外层循环向量化。

然而,当前的主要问题是:这些循环优化默认都是关闭的。为什么?因为执行这些转换的合法性检查需要进行数据依赖分析,这需要恢复多维数组的访问模式信息。而这些信息在IR中常常因为扁平化而丢失,需要依赖“delinearization”来恢复,IR简化又加剧了这一过程的难度。

尽管如此,我们认为现有机制足以识别并启用相当多的优化案例。我们计划从启用循环交换开始,因为其转换相对简单直接,可以作为在LLVM中推广循环优化的切入点。

评估挑战:测试套件的局限性

最后一个识别出的问题是如何可靠地评估改进效果。目前依赖LLVM测试套件(如TSVC)进行性能评估可能存在误导。

TSVC的所有内核都在同一个函数中,对某个函数的更改可能会通过代码布局等因素影响其他未更改函数的行为,引入大量噪声。我们发现,TSVC内核中20%-30%的性能变化很可能只是噪声,而非真实改进。

此外,测试套件中的程序非常古老,运行时间极短,可能测量到的I/O开销比实际计算还多。

因此,我们计划:

  1. 修改TSVC,将每个内核放在独立的函数中以减少噪声。
  2. 添加更具代表性的基准测试,如Rodinia。
  3. 已就此启动了一项RFC(征求意见稿)。

总结与未来方向

本节课中,我们一起学习了评估循环向量化性能的定量方法。尽管TSVC是一个人工基准测试,但它对于发现通用的代码生成缺陷极其有用。我们识别了三大类问题:

  1. 循环优化默认禁用:我们将致力于修复依赖分析问题,并推动循环交换等优化默认启用。
  2. 测试套件不可靠:我们将着手添加更具代表性的基准测试并修复现有问题。
  3. 成本模型需要革新:我们期望未来能探索基于实际代码生成和性能指标的成本建模方法。

通过系统性地分析、手动验证和持续改进,我们可以显著提升LLVM循环向量化器的性能,释放硬件的全部潜力。

056:向量化在MLIR中的应用 - 迈向可扩展向量与矩阵(第二部分)🚀

概述

在本节课中,我们将深入探讨MLIR中的向量化技术,特别是针对可扩展向量(Scalable Vectors, SV)和可扩展矩阵扩展(Scalable Matrix Extension, SME)的支持。我们将从向量化驱动方式开始,逐步深入到动态形状处理、访问模式分析以及SME特有的优化技术,最后通过性能数据验证当前进展。


向量化驱动方式 🚗

上一节我们概述了向量化的整体流程,本节中我们来看看如何具体驱动MLIR中的向量化过程。

MLIR的linalg向量化器可以通过两种主要方式驱动:C++ API和Transform Dialect操作。

C++ API驱动

C++ API提供了一个底层的vectorize方法钩子,允许直接对特定的linalg操作进行向量化。由于linalg方言中几乎所有操作都是linalg.generic或其别名(如linalg.matmul),因此vectorizeLinalgGeneric方法至关重要。

以下是使用C++ API进行向量化的示例代码框架:

// 对linalg.generic操作进行向量化
vectorizeLinalgGeneric(op);
// 对卷积等特殊操作进行向量化
vectorizeConvolution(op);

Transform Dialect驱动

为了更快速地进行原型设计和测试,可以使用Transform Dialect提供的更高级操作。

Transform Dialect提供了两个主要操作:

  1. vectorize:直接调用底层的C++ vectorize钩子,适用于精细测试。
  2. vectorize_children_and_apply_patterns:不仅调用向量化钩子,还会应用后续的规范化等模式,输出更清晰、用户友好的IR,并且可以应用于更大的代码块(如整个函数)。

向量化linalg.generic操作 🔧

现在,我们来看看向量化器如何处理一个具体的linalg.generic操作,例如一个矩阵乘法。

当向量化器遇到一个linalg.generic操作时,它会执行以下步骤:

  1. 将操作的输入和输出参数重写为vector.transfer_readvector.transfer_write操作。
  2. 进入操作体,逐个向量化其中的标量操作。
    • 对于乘法操作,直接重写为向量的乘法(vector.mul)。
    • 对于沿归约维度的加法操作,识别后使用专门的vector.multi_reduction操作。

向量化器能够推断出应使用的向量大小。例如,在处理二维数据时,它会生成二维向量。这些向量在后续的 lowering 过程中会被分解为更小的部分。

动态形状的挑战

如果将上述示例中的静态形状替换为动态形状,情况会变得复杂。

向量化器此时无法直接知道应使用的向量大小。这通常源于向量化流程中的第一步——分块(tiling)。为了将问题适配到向量寄存器,分块后的尺寸可能无法被原始问题尺寸整除,从而在循环内产生动态形状和边界检查。

处理动态形状时,必须显式指定向量大小,并且向量化器必须使用掩码(masking)来保证安全性,以应对最坏情况。


掩码(Masking)机制概述 🛡️

为了理解如何处理动态形状下的向量化,我们需要了解向量方言层面的掩码机制。

掩码的基本原理是,将原本无掩码的向量操作(如vector.outerproduct)包装在一个vector.mask操作中,并为其提供掩码参数。

然而,这带来了一个关键问题:需要在每次迭代中计算掩码,在内层循环中这会带来显著开销。理想情况下,应避免这种计算。

通过分析循环(例如矩阵乘法),可以发现通常只需要在最后一次迭代时计算掩码。因此,优化目标是尽可能消除掩码计算。


循环剥离(Loop Peeling)技术 🍌

我们采用循环剥离技术来优化掩码开销。

循环剥离将循环嵌套拆分为一个主循环和一个余数循环。主循环始终在完整的向量寄存器上操作,余数循环则处理剩余部分。

这项技术虽然不能完全消除动态形状,但它创造了一种易于分析的形式。在主循环中,步长(step)是静态的,这使得分析和重写为静态形状的向量操作成为可能。


张量提取(tensor.extract)的向量化 🎯

接下来,我们改变一下主题,讨论tensor.extract操作的向量化,这是一个非常重要的例子。

tensor.extract操作从张量中读取单个元素。向量化该操作时,需要根据访问模式决定使用哪种向量操作。

以从二维矩阵读取到一维向量为例,主要存在四种访问模式场景(其中一种尚未实现):

  • 连续加载(Contiguous Load)
  • 分散加载(Gather Load)

关键点在于,分散加载总是安全的(在大多数情况下正确),但通常是最慢的选项。如果实际是连续加载,则需要识别并生成更高效的连续加载操作。

向量化器通过分析用于访问张量元素的索引,来判断实际处理的是哪种访问模式。


可扩展向量(SV)与矩阵扩展(SME)更新 📈

现在,让我们跳转到向量化器中对可扩展向量扩展(SVE)和可扩展矩阵扩展(SME)支持的最新进展。

SVE 与 SME 简介

  • SVE:核心思想是向量大小是某个基本长度的倍数(例如128位的倍数),称为vscale。这实现了向量长度无关编程,简化了用户视角,但给代码生成和编译器设计带来了独特挑战。
  • SME:在SVE基础上,增加了一个方形的存储阵列,可作为外积操作的累加器。这使得SME成为一个矩阵乘法加速器。该存储阵列在两个维度上都具有可扩展大小,并且被划分为多个虚拟切片(virtual tile),虚拟切片的数量取决于底层元素类型。

与掩码相关的新挑战

在分块(尤其是为SME进行矩阵乘积分块)的上下文中,由于向量和累加器在所有方向都是可扩展的,这导致循环步长也是可扩展的(即未知值)。因此,更难以摆脱动态形状,进而难以避免掩码,这使得分析和代码生成更加复杂。

优化:消除不必要的掩码

即使经过循环剥离,保守的代码生成仍可能产生包含恒定、不必要掩码的操作序列。我们扩展了值范围分析(Value Range Analysis),以理解可扩展大小中的vscale,从而能够在许多情况下消除这些不必要的掩码。

SME特有的 lowering:切片类型分解与虚拟切片分配

在SME的 lowering 流水线中,有两个关键步骤:

  1. 切片类型分解:在高层,我们假设使用整个SME存储阵列。但在硬件层面,它被划分为虚拟切片。因此,我们需要进行类型分解,例如将一个操作整个存储阵列的外积操作,分解为多个对更小向量块和存储阵列切片进行操作的相似操作。
  2. 虚拟切片分配器:当使用SME虚拟切片时,我们通过LLVM IR intrinsics与之交互,这些intrinsics要求指定使用哪个切片。我们实现了一个虚拟切片分配器,它基于MLIR中已有的活跃性分析API,以确保无冲突地使用所有切片,从而获得良好性能。

性能评估 📊

最后,我们通过一些性能数据来评估当前的工作。需要强调的是,这些更多是概念验证,我们尚未投入大量时间进行深度性能优化。

矩阵乘法基准测试

我们比较了三种实现:

  1. 朴素的C++实现
  2. 使用IREE(我们的端到端MLIR编译器)的MLIR生成代码
  3. Arm Compute Library(Arm的性能库)

对于特定示例,我们的MLIR生成代码达到了Arm Compute Library约80%的性能,这是一个令人满意的结果。

为了评估可扩展向量化的效果,我们比较了使用128位宽向量(SVE)和512位宽向量(流式SVE,与SME核心结合)的性能。理想情况下,向量宽度增加4倍应带来接近4倍的加速。我们观察到了约2.6倍的加速,这证明了编译器正确利用了更宽的向量。

SME代码生成评估

我们在内部构建了一个自定义卷积神经网络流程,以测试能否将复杂模型成功lowering到SME。我们取得了成功,这是一个重要的里程碑。

性能评估分为两部分:

  1. 在真实硬件(Neoverse V1芯片)上:比较IREE(MLIR代码生成)与Android生态系统内的XNNPACK库。结果显示性能非常接近,表明IREE做得很好。
  2. 在带有SME加速器的模拟器上:首先在模拟器的主CPU上运行,然后切换到SME加速。由于使用了方形存储阵列且向量更宽,预期应有加速。我们观察到了约3倍的加速,这再次证明代码生成是正确的,且性能具有可扩展性。

总结 🎓

本节课中我们一起学习了:

  1. MLIR中linalg向量化的两种驱动方式:C++ API和Transform Dialect。
  2. 向量化linalg.generic操作的基本过程,以及处理动态形状带来的掩码挑战。
  3. 利用循环剥离技术来优化动态形状下的向量化性能。
  4. tensor.extract操作的向量化策略,关键在于识别连续或分散访问模式。
  5. 对可扩展向量(SVE)和可扩展矩阵扩展(SME)的向量化支持更新,包括掩码消除、切片分解和虚拟切片分配等关键技术。
  6. 通过矩阵乘法和自定义CNN的基准测试,验证了当前向量化及SME代码生成流程的功能正确性和性能潜力。

当前工作为在MLIR中利用现代可扩展向量和矩阵加速器奠定了基础,未来的工作包括集成成本模型、更高级的融合优化以及持续的性能调优。

057:SelectionDAG 入门指南

在本教程中,我们将学习 LLVM 编译器后端中的一个核心框架:SelectionDAG。我们将了解它是什么、它在编译流程中的位置、其内部数据结构,以及它如何将高级的 LLVM IR 转换为目标机器指令。

编译流程中的 SelectionDAG

上一节我们介绍了本教程的目标。本节中,我们来看看 SelectionDAG 在 LLVM 整体编译流程中的位置。

标准的 LLVM 编译流程是:前端解析源代码,生成 LLVM IR;opt 工具对 IR 进行优化;llc 工具生成汇编代码。SelectionDAG 框架位于 llc 内部,具体来说,它介于 LLVM IR 优化阶段和机器指令(Machine IR)优化阶段之间。

SelectionDAG 主要执行以下步骤:

  1. 构建 SelectionDAG 数据结构。
  2. 合法化 类型和操作,确保它们被目标平台支持。
  3. 指令选择,选择将在芯片上运行的特定机器指令。
  4. 调度,将指令的有向无环图转换为线性的机器指令序列。

在构建和合法化阶段之间,还会通过 DAG 合并器 执行窥孔优化,以消除在 lowering 和合法化过程中可能引入的低效代码。

SelectionDAG 数据结构

上一节我们了解了 SelectionDAG 的流程。本节中,我们来深入看看其核心数据结构。

SelectionDAG 既是一个框架,也是一种数据结构。它是一个有向无环图,用于表示程序的含义,是另一种类似于 LLVM IR 或 Machine IR 的中间表示形式。需要记住的是,每个 SelectionDAG 代表一个基本块。在编译过程中,我们会将程序分解为基本块,在每个 DAG 上执行所有指令选择阶段,最后再将它们重新组装起来。

在讨论数据结构之前,我们先简要了解一下 SelectionDAG 中的类型。主要有两种类型需要关注:

  • 机器值类型:这是一个类型集合,是所有架构支持类型的并集。任何特定目标平台不会支持所有 MVT,但集合中的每个元素都被某些架构支持。MVT 包括整数、浮点数、向量等。
  • 扩展值类型:这包括所有 MVT 类型,以及 LLVM IR 支持的非标准大小整数和向量(例如 i3<10 x i8>)。它不包括结构体和数组。

现在,我们通过一个简单的 LLVM IR 基本块来构建 SelectionDAG。该基本块包含三条指令:将一个外部值 %a 加上常量 5,再将结果乘以常量 3,然后跳转到另一个基本块。

以下是构建 SelectionDAG 的核心组件:

SDNode
这是 SelectionDAG 的基本构建块。每个 SDNode 有一个操作码,定义节点的类型。它还可能有许多其他字段。在我们的例子中,会为每条指令(add, mul, br)和常量值(5, 3)以及目标基本块地址创建对应的 SDNode。常量和基本块节点还包含具体的

SDValue
这代表一个 SDNode 的输出。它可以表示为一个产生输出的 SDNode 和一个指向其输出列表的索引。与 LLVM IR 不同,SelectionDAG 中一个节点可以产生多个输出(本例中均为单输出)。每个 SDValue 都有一个关联的 EVT

SDUse
这代表一个 SDNode 的输入。它可以定义为一个 SDValue(被使用的值)、一个使用者节点以及一个指向该节点操作数列表的索引。这些就是图中的。需要注意的是,图中的箭头代表使用关系,而非数据流方向。

每个 SDNode 有一个或多个由 SDValue 表示的结果,以及零个或多个由 SDUse 表示的操作数。

为了表示跨越多个基本块的值(如 %a 和结果 %z),我们引入了 CopyToRegCopyFromReg 节点。它们类似于加载和存储指令,但操作的是寄存器地址而非内存地址。

为了表示非数据依赖的排序关系(例如确保分支指令在计算完成后执行),我们引入了。链是 SDUse 的一种,不代表实际数据流,不会变成寄存器,只表示依赖关系。在调度时,拓扑排序会遵循这些链来保证正确顺序。图中用蓝色虚线箭头表示链。

每个 SelectionDAG 都有一个 EntryToken 节点,代表基本块的开始。通常,终结指令(如分支)节点会成为 DAG 的 节点,某些转换会从这个节点开始。

通过以上步骤,我们就为这个基本块完整构建了 SelectionDAG。

让我们再看一个包含内存操作的例子:存储一个常量,然后加载一个值供其他块使用。这个 DAG 会更大。我们注意到:

  • LoadStore 节点是内存 SDNode,它们有额外的信息(如对齐方式),并且它们的第 0 个操作数是一个链。
  • TokenFactor 节点用于将两条链合并在一起,使得一个节点(如分支)可以依赖于多个事件完成。它本身不会生成任何指令。

SelectionDAG 构建阶段

上一节我们深入了解了 SelectionDAG 的数据结构。本节中,我们开始探讨 SelectionDAG 处理的第一个阶段:构建。

在此阶段,我们将函数中的每个基本块表示为 SelectionDAG。大部分情况下,这基本上是一对一的映射,即将 LLVM IR 值转换为类似的 SelectionDAG 节点,而不进行太多目标特定的 lowering。

但有两个主要的例外情况需要处理:

  1. 结构体类型
    SelectionDAG 不支持结构体类型。因此,需要将它们逐元素降低。例如,加载一个结构体并提取其字段的 IR,会被降低为对每个结构体元素的单独加载操作,并使用 add 节点来偏移地址以访问第二个元素。还会引入 MergeValuesTokenFactor 节点来方便地表示多个值或依赖关系,这些节点最终会被优化掉。

  2. 目标特定的 API
    一些 IR 特性没有通用的 SelectionDAG 表示,需要目标平台自行实现。这包括调用约定相关的 lowering,例如 LowerCallLowerFormalArgumentsLowerReturn。每个目标都需要在 TargetLowering 接口中实现这些,并生成代表这些指令的 SDValue。

类型合法化

上一节我们将 LLVM IR 降低到了 SelectionDAG。本节中,我们需要确保 DAG 中的所有类型和操作都被目标平台支持。第一步是类型合法化

目标平台只支持 LLVM IR 所支持类型的子集。例如,LLVM IR 支持 i24<3 x i8>i128 等类型,而 PTX 目标可能只支持 i32i64floatdouble 等。在类型合法化阶段,我们需要将非法类型降低为合法类型。

那么,什么是“合法”呢?在指令选择之前进行合法化时,我们说一个类型或操作是“合法”的,仅仅意味着我们的指令选择代码能够处理它

目标平台通过调用 addRegisterClass 函数并传递一个特定的 MVT 来告知 SelectionDAG 该 MVT 对该目标是合法的。基于合法的类型集合,SelectionDAG 可以自动为我们合法化所有不支持的类型。它在后台构建一个表格,将每个类型映射到一个合法化该类型的操作。

以下是几种主要的合法化方法:

  • 扩展整数:例如,将不支持的 i128 加法扩展为带有进位处理的 i64 加法序列。注意,此阶段不仅修改类型,也修改 DAG 结构。
  • 提升整数:例如,将不支持的 i24 加法提升为 i32 加法,并通过 and 操作屏蔽掉可能被设置的额外高位。
  • 向量特殊处理:目标可以覆盖 getPreferredVectorAction 函数,为特定向量类型指定合法化操作。例如,Hexagon 目标遇到 <3 x i8> 时,可以要求将其加宽<4 x i8>

操作合法化

上一节我们处理了类型合法化。本节中,我们来处理操作的合法化。

SelectionDAG 支持超过 400 种操作码。目标平台通常不支持所有这些操作码,而且对于支持的操作码,也可能不支持所有合法类型。本阶段的目标是将所有 SelectionDAG 支持的操作降低为目标平台合法的操作。

目标平台通过调用 setOperationAction 函数,为特定的操作码和 MVT 指定应采取的合法化操作。SelectionDAG 在后台构建一个表格,将每个(操作码,合法 MVT)对映射到一个合法化操作。

以下是目标可以指定的几种主要合法化操作:

  • Legal:无需处理,指令选择代码可以直接处理。
  • Promote:提升到更大的类型执行。
  • Expand:用一系列合法的操作来模拟该操作。
  • Custom:需要目标实现自定义的 lowering。

提升示例
NVPTX 支持 f16f32 类型以及 f32 除法,但不支持 f16 除法。解决方案是将 f16 除法提升f32 类型执行,并插入必要的类型扩展和舍入操作以保持原始语义。

扩展示例
MIPS 不支持 i32 的字节交换操作。解决方案是请求 SelectionDAG 用一系列合法的移位和或操作来扩展模拟该操作。

自定义合法化示例
当提升和扩展都不够时,可以使用自定义合法化。目标指定 Custom 操作后,SelectionDAG 遇到该操作码和类型时会调用目标的 LowerOperation 函数。

  • NVPTX 向量洗牌:PTX ISA 有 permute 指令,其语义与向量洗牌操作高度匹配。编译器工程师可以实现自定义 lowering,将通用的 vector_shuffle 节点转换为目标特定的 permute 节点。
  • X86 绝对值:X86 通过自定义 lowering,将绝对值操作转换为目标特定的 sub(设置标志位)和条件移动指令。

DAG 合并器

上一节我们合法化了 DAG 中的操作。但在进行指令选择之前,让我们先看看 DAG 合并器

你可能会问,为什么需要这个?我们不是已经在 LLVM IR 层面做了窥孔优化吗?原因有二:

  1. 清理在 lowering 和合法化过程中可能引入的低效代码。
  2. 生成目标 ISA 提供的、可能比默认操作序列更快的独特操作。

DAG 合并器 为所有使用 SelectionDAG 的目标执行窥孔优化,本质上相当于 SelectionDAG 的 InstCombine。在尝试合并前,它会调用目标降低信息接口来查询特定转换是否对该目标更高效。

如果 DAG 合并器提供的免费优化还不够,目标可以实现自定义 DAG 合并。通过调用 setTargetDAGCombine 并传递一个操作码,SelectionDAG 在遇到该操作码时会调用目标的 PerformDAGCombine 函数。

自定义 DAG 合并示例

  1. NVPTX 的 mul-wide:PTX 非常关注寄存器压力。mul 操作需要 128 位存储操作数。PTX 有 mul.wide 指令,它接受两个 i32 并产生一个 i64。自定义合并器可以检查 i64 乘法,如果发现其输入的高 32 位未被使用(通过零扩展判断),则将其转换为 mul.wide 并插入截断操作。DAG 合并器会迭代运行,进一步消除冗余的扩展/截断,最终得到指令更少、寄存器压力更低的 DAG。
  2. NVPTX 的无符号取余:在 PTX 中,无符号取余是非常昂贵的操作。自定义合并器可以将其降低为利用乘法和减法来模拟取余操作的 DAG 序列,从而提升性能。

指令选择

上一节我们优化了 DAG。本节中,我们进入核心阶段:指令选择

在指令选择阶段,我们将把大多数通用的 SDNode 替换为机器节点。机器节点也是 SDNode,但其操作码是机器指令操作码,直接对应于具体的机器指令。

这个过程过于目标特定,无法由框架通用实现。因此,与之前阶段不同,每个目标都需要重写 Select 方法,并实现大量的模式匹配代码来完成转换。这听起来工作量很大,但解决方案是使用 TableGen。TableGen 允许我们简洁地指定重写模式,然后自动生成繁琐的 C++ 代码。

要使用 TableGen 进行指令选择,我们需要定义三样东西:

  1. SD 模式运算符:描述我们在 DAG 中要寻找的模式。
  2. 指令:我们想要输出的机器指令的表示。
  3. 模式:将上述两者映射起来。

我们以 NVPTX 中降低加法指令为例:

  • 定义模式运算符:首先定义一个类型概要,描述节点产生一个值,有两个相同整数类型的操作数。然后定义一个 SDNode 子类,指定其操作码为 ISD::ADD,使用上述类型概要,并赋予其交换律和结合律属性,以增加 TableGen 匹配的灵活性。
  • 定义机器指令:定义一个机器指令条目,指定其操作码名称(如 ADD_I32rr)、输出定义和输入定义。
  • 定义模式:编写一个模式,指定当遇到一个产生 i32 寄存器、并接受两个 i32 寄存器输入的 ADD 节点时,将其转换为 ADD_I32rr 机器指令。

TableGen 会利用这些信息,在指令选择阶段执行相应的转换。

大多数目标的 Select 方法实现如下:首先执行任何需要的自定义逻辑,然后调用 SelectCode,后者会跳转到 TableGen 生成的匹配器(一个庞大的 switch-case 语句)来执行基于 TableGen 的转换。

关于指令选择算法的一些重要说明:

  • 自底向上遍历:DAG 从后往前遍历,确保一个节点的操作数总是在该节点本身被选择之前就被选择。这允许我们匹配一个节点时,能确保其操作数仍是通用形式。
  • 模式优先级:当多个模式匹配同一个 DAG 时,优先选择更复杂的模式(匹配更大、约束更多的 DAG)。如果复杂度相同,则选择生成指令数更少的模式(作为成本代理)。如果仍相同,则回退到匹配模式的大小,最后选择源代码中出现较晚的模式。

调度

上一节我们完成了指令选择。本节中,我们进入最后一个阶段:调度

指令选择的输入是一个机器节点的 DAG,而输出是这些机器节点的线性序列。最简单的做法是进行拓扑排序。对于 NVPTX 后端,由于其生成的代码是另一个编译器的输入(由该编译器负责最终调度),因此不 heavily 依赖此阶段。关于调度的更多细节,建议参考相关的博客文章和开发者会议演讲。

实践示例:为 NVPTX 实现 MAD 指令优化

现在,让我们通过一个完整的例子,看看如何在实际目标(NVPTX)中为 SelectionDAG 添加一个特性。

我们的目标是:通过窥孔优化,将 mul 后接 add 的模式,匹配并转换为 PTX 的 MAD(乘加)指令。这样做可以降低指令延迟并减少寄存器压力。

首先分析我们需要做什么:

  1. MAD 指令支持的类型在 NVPTX 中已经是合法的,因此无需修改类型合法化。
  2. addmul 节点对于 MAD 支持的类型也已经是合法的,因此无需修改操作合法化。
  3. 我们需要:
    • 自定义 DAG 合并:在合法化后的 DAG 中,将 add(mul(x, y), z) 模式转换为一个目标特定的 SDNode(例如 NVPTXISD::IMAD)。
    • 指令选择逻辑:将这个目标特定的 SDNode 降低为机器节点(MAD_I32 指令)。

步骤一:实现自定义 DAG 合并

  1. 调用 setTargetDAGCombine 指定我们要为 ISD::ADD 实现自定义合并。
  2. 重写 PerformDAGCombine 函数,并添加对 ISD::ADD 操作码的处理,调用我们自定义的合并函数。
  3. 在合并函数中,检查操作数之一是否为 ISD::MUL,检查类型是否为 i32(或其他支持的类型),并检查该 mul 节点是否只有一个使用者(以避免延长其生存期增加寄存器压力)。
  4. 如果所有检查通过,则创建并返回一个新的目标特定 SDNode,其操作码为 NVPTXISD::IMAD,操作数为 mul 的两个操作数和 add 的另一个操作数。

步骤二:实现指令选择

  1. 在 TableGen 中,定义目标特定 SDNode NVPTXISDIMAD 的类型概要(一个输出,三个输入,均为整数)。
  2. 定义机器指令 MAD_I32rrr,指定其操作码、输出和输入。
  3. 编写一个模式,将 NVPTXISDIMAD 节点匹配并转换为 MAD_I32rrr 指令。

通过以上步骤,我们就成功地将 LLVM IR 中的乘加模式,通过 SelectionDAG 的 DAG 合并和指令选择,最终生成了 PTX 的 mad 汇编指令。

总结与资源

在本教程中,我们一起学习了 LLVM 中 SelectionDAG 框架的完整流程。我们从其在编译流程中的位置开始,深入探讨了其有向无环图数据结构,并逐步讲解了构建、类型合法化、操作合法化、DAG 合并优化、指令选择和调度等核心阶段。最后,我们通过一个为 NVPTX 实现 MAD 指令优化的具体例子,将理论知识应用于实践。

希望本教程能帮助你更自信、更从容地使用和贡献于 SelectionDAG 框架及各个后端。

为了帮助你快速上手,以下是一些推荐的学习资源:

  • 官方文档:LLVM 官方关于代码生成的文档。
  • 源码:直接阅读 llvm/lib/CodeGen/SelectionDAG 目录下的源代码。
  • 调试命令:学习使用 -debug-only=isel 等选项来输出 SelectionDAG 各阶段的调试信息,这对理解内部过程非常有帮助。

祝你学习顺利!

058:两个编译器,一门语言,无规范

在本节课程中,我们将探讨HLSL(高级着色器语言)的现状、其编译器生态面临的挑战,以及团队如何通过开发新编译器并制定语言规范来解决这些问题。我们将重点关注HLSL与C/C++的差异、并行架构带来的独特设计考量,以及向现代化工具链迁移的策略。

概述:什么是HLSL? 🤔

HLSL是一种图形编程语言,主要用于实时渲染、DirectX和视频游戏开发。它自2002年开始使用,由Nvidia的Cg语言演变而来,现已成为Windows平台上GPU编程的主要语言,不仅用于图形处理,也用于通用GPU计算应用。

编译器现状与挑战 ⚙️

目前HLSL有两个参考编译器,这带来了维护和兼容性上的挑战。

以下是两个主要编译器的概况:

  • FXC:这是一个完全专有的定制编译器,已停止主动支持,但仍在被使用,因为它是唯一能支持DirectX 9到11目标的编译器,而许多低端设备仍依赖DirectX 11。它支持的HLSL语言版本大约从2002年到2015年。
  • DXC:这是基于LLVM 3.7的一个分支,是当前DirectX 12的主要编译器。它支持2016年至今的HLSL语言版本,目前仅针对“202X”这个开发中的语言版本进行功能开发。

我们面临的核心问题是需要一个现代化的编译器。我们拥有庞大的现有用户群,但不想做一个完全复刻旧bug的编译器。GPU代码的可移植性是一个大问题,而我们缺乏一份成文的语言规范

为什么需要规范? 📜

你可能会问,为什么需要规范?很多编程语言也没有规范。这里有一个例子:对于一段包含inout关键字的代码,C++程序员可能会认为它类似于引用,但在HLSL中并非如此。更糟的是,DXC编译器在针对Vulkan和DirectX不同目标时,会对同一段代码给出不同的编译结果。这清楚地表明我们需要一个标准来消除歧义和实现一致性。

HLSL为什么不是C/C++? 🔄

HLSL不直接采用C或C++有几个原因。简而言之,历史原因和GPU的独特性是关键。GPU的能力组合历来非常奇特,其架构也天生差异巨大,不存在像x86那样的通用GPU架构。此外,GPU本质上是并行的,而C/C++在本质上并行的架构上表现不佳。

语言相似性与差异性 ⚖️

在基础层面,HLSL在很多方面与C++相似,代码看起来也类似,并且工作方式也符合预期——直到出现不符合预期的情况。部分原因是编译器实现不一致,部分原因是语言本身确实不同。

我们一直在努力与用户沟通,让他们理解HLSL工具链需要做出的改变,以及我们过去可能做错的一些事情。我们将会改变这些,但这可能会破坏一些现有代码。

以下是一些来自“HLSL恐怖故事”系列的代码片段,它们看起来你知道其作用,但实际上可能并非如此:

  • func(1, 2);:这是一个模糊的函数调用,按照预期不应编译。
  • func(1.0, 2);:同样模糊,不应编译。
  • int a = 1.0;:为什么这能将double转换为float
  • bool b = 1.0;:为什么这能将double转换为bool
  • bool b = 1;:考虑到前面的例子都转成了float,为什么这个却转成了bool

答案在于我们因缺乏规范、未明确记录规则以及测试不够充分而产生了这些不一致性。

内置函数的现代化处理 🛠️

我们在Clang中做的一项改进是处理所有内置函数。我们不再让它们在编译器中成为特例,而是通过一个隐式包含的头文件来实现,它们现在就是普通的函数。这带来了诸多好处,例如在Clangd中可以获得“转到定义”功能。但这也会破坏现有代码。因此,我们正在与用户沟通,解释我们过去做的这些奇怪事情,并说明现在是时候做出更好的改进了。

GPU并行模型与向量化 🧵

HLSL的一个重大区别源于GPU的并行性。由于GPU常用于图形编程,向量无处不在。在HLSL中,一切都是向量,甚至一个int在底层也是一个向量。

HLSL采用SIMT编程模型。你编写的源代码看起来像是在操作单个变量,但在硬件层面,有多个并行线程同时执行。它们共享一个程序计数器,一起按指令执行。

例如,对于一个四元素输入数组,实际上你有四个四元素数组和四个大小值。当执行遇到控制流时,会有一个掩码决定当前线程是否执行该控制流。所有线程会一直执行控制流,直到所有线程都完成,然后才能退出循环并返回。

这带来了一些有趣的复杂性和隐藏成本,因为每个操作都发生了N次,每个变量都是N个变量(N是4到128之间的2的幂次)。动态控制流总是最坏情况。这些考量深刻影响了HLSL的使用方式和语言自身的表现形式。

类型转换的挑战与解决方案 🔢

一个在C语言中备受争议的特性是“通常算术转换”。它们非常有用,例如int + int得到int。但是short + short呢?在C语言中,short会先提升为int,进行运算,然后再转换回来。在GPU这样的并行架构上,这种转换的数量会严重影响性能。

为了解决这个问题,我们调整了C语言的做法,移除了较小类型提升的概念,但同时也必须添加新的转换规则来处理向量等C语言中不存在的数据类型。

我们还需要为许多其他操作(不仅仅是通常算术转换,还包括函数调用)进行隐式维度转换。因此,我们在隐式转换序列中增加了第四级:向量维度转换,用于将标量扩展为向量,或将向量/矩阵截断为更小的尺寸。

这当然也影响到了我们正在编写的语言规范。左边的表格是C++11的隐式转换序列,右边是HLSL的。我们从3个等级增加到了9个等级。

积极的一面是,与C++保持一致并使用C++的表述方式,使我们能够拥有比参考编译器简单得多的实现。我们的参考编译器使用评分系统进行参数依赖的重载解析,这个系统有缺点,例如当你有128个函数参数时(虽然没人会这么写),计数器会溢出,导致无法解析重载,除非是完全匹配的重载。采用更贴近C++、设计更稳健的方法对我们有很大优势。

但这同样会破坏代码。因此,在我们寻找语言演进方式时,优先考虑的是不将技术债务带到新编译器中。让旧编译器中有效的代码在新编译器中因明确的错误信息而失败,总比让它执行不同操作要好得多。当然,我们也需要平衡,确保用户最终能从旧编译器迁移到新编译器。

指针的缺失与inout/out关键字 🚫

GPU语言的另一个很大区别是,许多语言过去没有指针,HLSL至今也没有指针。这部分是因为GPU以不同方式和不同语义在不同位置存储内存,在基于C的语言中表示所有这些会变得复杂和奇怪。

但这导致了一个大问题:如果一个函数有多个返回值,在没有指针和引用的情况下如何实现?HLSL的答案是inoutout关键字。它们本质上仍然是按值传递的参数,但会在本地线程地址空间中创建为临时变量,以避免地址空间问题。

这又导致了另一个问题:因为这些是临时变量,你可以在调用函数时将参数强制转换为不同类型。函数可以改变该参数的值,然后再转换回来。这是一团乱麻,但它是HLSL语言的基础部分,用户依赖于此,因此在迁移时打破这一点对我们来说不是一个选项。

然而,这给我们带来的一个挑战是,我从未见过有其他语言这样做。我们甚至没有词汇来描述这种行为。因此,我开始创造新词,我称它们为“转换即将消亡的值”。它们有点像C++中的xvalue,但涉及转换,而且很怪异,会消失,然后在被销毁时通过另一个转换写回其值。希望优化器能清理这一切,但也许不能。

我们在Clang中通过一个新的AST节点来表示这一点。我们利用了Objective-C对写回参数的支持,在调用后执行某些操作以将值写回参数。我们有一个AST表示,代表了转换到参数和转换回值的过程。这确保了所有转换都被表示出来,语义检查会介入并警告所有转换。这对我们的用户来说是一个巨大的改进,因为以前所有这些转换都没有在AST中表示,用户的隐式转换从未被报告。

字面量类型与HLSL 202X 🔮

HLSL另一个有趣的差异是“字面量类型”。其起源是GPU经常以奇怪的精度进行计算(如10位、16位浮点值)。如果进行数学运算时只有10位精度,可能会出现严重的舍入错误。因此,HLSL的想法是在编译器中将其视为64位类型,然后在编译期间清理并给出更高精度、更小尺寸的表示。

问题是C++没有多表达式类型推断。因此,当尝试推断应将这个较大的未确定大小的类型解析为什么时,如果遇到像三元运算符这样的表达式,我们不知道该将其转换为什么。最终我们只能生成一个64位值,然后希望IR优化器能清理它。但这并不总是有效,会导致问题。

我们为此提出的解决方案是“HLSL 202X”,这是我们正在开发的语言版本。在Clang中,我们将不支持字面量类型,而是采用更接近C/C++的做法。我们将提供一条路径,通过在旧编译器中实现这些新特性,帮助用户迁移到新的语言版本。然后,一旦新编译器准备就绪,他们再将其作为第二步迁移到新编译器。

模板与概念支持 📐

我们遇到的另一个很酷的问题是,HLSL内置类型的模板在语言拥有模板功能之前就已存在。因此,我们对模板有要求,但当时没有在语言中表达这些约束的方式,例如向量需要是4个或更少的元素。

我们在Clang中决定的做法非常巧妙。由于Clang对C++概念的支持仅在解析层作为版本检查存在,因此我们可以通过编程方式在编译器中生成概念AST节点,从而在一个没有概念的语言中拥有概念。这对我们来说非常有用,因为模板在GPU代码中超级有用(所有内容都会被完全特化)。这是一个非常巧妙的想法,实施起来也很酷。

总结与核心建议 🎯

本节课中,我们一起学习了HLSL的现状、其编译器面临的挑战,以及向现代化迁移的策略。我们探讨了HLSL与C/C++的关键差异,特别是并行架构和向量化带来的独特设计,以及处理类型转换、指针缺失等问题的方案。

最后,我想强调,如果你在设计一门编程语言,请务必将其文档化。请编写规范、编写文档,拥有优秀的文档。因为在某个时间点,你将需要编写第二个或第三个编译器。如果没有描述其预期行为的文档,在不直接复制旧代码的情况下,在新编译器中匹配参考编译器的行为几乎是不可能的。

我们采取了创造性的方法来实现源代码兼容性,而不需要行为完全一致,这对我们非常有用。我们也在努力向前看,例如概念可能对HLSL用户非常有用,因此在当前实现中利用它是很好的。最后,如果你在制作一门编程语言,你的用户是技术人员。请与他们互动,告诉他们你在做什么。他们会理解,并且如果你真正与社区互动和沟通,你可能会收到更少的错误报告和抱怨。

059:通过软件管理虚拟内存实现 Offload ASAN

概述

在本节课中,我们将学习一种为 GPU 等异构计算设备实现地址消毒器的新方法。传统方法在 GPU 上运行时存在显著的性能开销和内存开销。我们将探讨一种基于软件管理虚拟内存的新方案,它通过修改指针表示和引入间接层来高效地检测内存错误,同时大幅降低开销。

背景:现有 ASAN 的挑战

上一节我们介绍了地址消毒器的基本需求。本节中我们来看看为什么在 GPU 上直接使用传统 ASAN 会遇到困难。

CPU 和 GPU 环境存在根本差异。CPU 环境相对封闭,内存资源丰富;而 GPU 拥有多种显存类型,编程时需要显式管理,且内存容量通常有限。

传统 ASAN 存在固定开销。它为程序中的每次内存访问引入了额外的访问。它至少有 12.5% 的内存开销,如果存在大量小内存分配,开销会更高。它还可能产生漏报。当我们在 GPU 上实际运行这些工具时,会观察到一到两个数量级的性能下降。

核心方案:软件管理的虚拟内存

既然传统方法存在瓶颈,本节我们将深入探讨提出的新方案。其核心思想是引入一个软件层面的间接层。

首先,将内存想象为一个指针。一个指针在技术上包含两部分信息:一部分标识所指向的对象,另一部分给出对象内的偏移量。这是一种简化,但有助于理解。

方案的关键是引入一个软件管理的“内存管理单元”。每次进行内存分配时,我们在这个表中添加一个条目,记录该分配的基础指针和大小。每次释放内存时,我们将对应条目的大小标记为无效。

那么,我们的指针现在如何表示?它不再直接指向真实内存,而是指向这个 MMU 表中的一个条目。它包含一个对象ID和一个偏移量。通过这个 MMU 条目,我们可以找到对象实际应该指向的基础指针和大小。偏移量保持不变,因为我们需要允许指针运算和 getelementptr 等操作。

以下是该方案的简化表示:

// 伪代码表示
struct PackedPointer {
    uint32_t object_id;  // 指向 MMU 表中的条目
    uint32_t offset;     // 在对象内的偏移
};

struct MMUEntry {
    void* base_ptr;      // 实际内存的基础地址
    size_t size;         // 分配的大小
};

现在,如果你有一段代码,例如一个循环递增数组中的每个元素,会发生以下情况:

  1. 首先进行一次查找。指针 a 包含对象ID和偏移量。通过查找 MMU 表,我们可以获得基础指针和大小。
  2. 在循环内部访问时,我们进行实际检查。检查公式为:offset + i >= 0 && offset + i < size

这样,除了实际的内存访问外,没有额外的内存访问。这很有效。

处理不同内存类型

上一节我们介绍了核心方案,本节我们来看看如何将其适配到 GPU 上不同的内存类型。

显然,我们需要重写所有的内存访问,因为指针不再是真实的指针。但这是可行的。偏移量保持不变。但如果有人越界访问,覆盖了偏移量,就会破坏对象ID,导致 ASAN 检测崩溃。这并不理想。因此我们引入一个“魔术值”来确保如果偏移量被覆盖,我们可以检测到。

在设备上,每次分配时,我们将其记录到表中。一次查找需要 16 字节的加载。每次访问检查只需要位比较和位操作。这对于 64 位堆指针(分配数量较少)非常有效。

但是,对于共享内存和栈内存,存在大量分配(每个线程都有栈分配),并且这些是 32 位指针。上述方案效果不佳。我们初始实现了该方案,它有效,但对栈指针不够理想。

那么,我们该怎么做呢?首先,我们在“伪指针”中引入一个地址空间标识符,用于指示指针指向哪种内存。它必须始终位于相同的位置,因为如果你获得一个指针但不知道其来源,首先需要检查它以推测这是什么指针。

整个“伪指针”是 64 位。如果我们最初有一个 32 位指针,我们仍然将地址空间标识符放在最前面,将魔术值和偏移量放在最后。然后,我们将对象的大小嵌入到指针中。因为我们是从一个 32 位指针开始,并将其嵌入到一个 64 位指针中。我们把大小放进去,然后把真实的指针放进去。

现在,我们所有的元数据都随着传递的指针一起存在,没有额外存储任何东西。它随指针携带。

对于 32 位指针(共享内存和栈内存),我们没有内存开销。我们只对堆分配进行注册。32 位指针在 everywhere 被替换为 64 位指针。如果某人将 32 位指针存储为 32 位,这会失效,但事实证明几乎没有人这样做,因为这样做很困难且没有好的理由。我们可以检测到这种情况。

方案优势与效果

了解了技术细节后,本节我们通过数据来看看新方案的实际效果。

我们首先在核反应堆科学的代理应用 XBch 上进行了测试。该应用有约 5.5GB 数据移动到 GPU,是一个非平凡的量。我们比较了不同方案:

  • 使用 NVIDIA 计算消毒器工具(非基于插桩),在 V100 上观察到 25 到 130 倍的减速。
  • 使用 AMD 移植到其 GPU 架构的地址消毒器,观察到约 10-11 倍的减速。
  • 使用我们的新方案,仅观察到约 2 倍的减速。这是一个 5 倍的改进,并且我们没有内存开销。

这只是一个代理应用。我们接着运行了完整的 OpenMC 应用程序,并查看了所有内核的时间。基准运行是蓝色的。红色是我们消毒了除共享内存(如 __shared__ 变量)之外的一切。黄色是我们消毒了一切。

我们看到所有内核的开销普遍在 2 倍左右,这很好。如果我们消毒所有内容,端到端有 2.3 倍的减速。如果我们不消毒共享变量,可以收回一些开销。如果只进行堆和全局变量消毒,只有约 80% 的开销。仅插桩本身(按我们目前的方式)就占用了这 80% 中的 30%。我们添加了一些优化,将端到端开销从 2.3 倍降低到了 1.7 倍。

扩展至 CPU 及其他

之前我们主要关注 GPU,但本方案的核心思想并不依赖于 GPU。本节我们将其扩展到 CPU 环境进行验证。

我昨天早上做了一些实验。我采用了一个朴素的矩阵乘法(1024的三次方)。我在一台 AMD 处理器上运行。我运行了这个从 GPU 实现移植并拼凑起来的版本,与基准版本和常规 Clang ASAN 进行比较。

我的拼凑解决方案在该示例上快了约 6%。这大致在正确的范围内,还不错。但有趣的部分是,它实际上有 0% 的内存开销(与 ASAN 的 30% 相比,只有 0.0001 的页被实际触及)。我认为这很巧妙。

我相信还有很多事情可以做,有很大的改进空间。我们已经做了两三个小的优化,其他一些(包括将检查提升出循环等)目前正在研究中。关键是,我们已经去除了原始设计中一直存在的内存开销。现在,如果我们能从热点循环中去除更多一些的加载和检查,我们很可能将两个维度的开销都降低到一个非常理想的水平。

总结

本节课中我们一起学习了一种为异构计算设备实现地址消毒器的新方法。让我们总结一下关键点:

我们能够报告各种错误:

  • 分配过大(受指针配置的自然限制)。
  • 坏指针(例如覆盖了魔术位)。
  • 越界访问(负偏移或偏移大于大小)。
  • 释放后使用(对于 MMU 表中的一切,我们可以在释放后更新大小)。
  • 地址空间不匹配(代码假设了错误的地址空间)。

该方案的设计充分考虑了 GPU 的特性:

  • GPU 内存非常有限,因此应避免内存开销。
  • 我们通常受内存限制,应避免额外的内存访问,尤其是在循环中。
  • 我们有不同的内存类型,因此需要有适用于快速内存的方案、适用于栈内存的方案。对于慢速堆内存,我们可以承受一些开销。

这种方法通过软件管理的虚拟内存和修改的指针表示,在保持强大错误检测能力的同时,显著降低了运行时和内存开销,为在资源受限的加速器上进行高效调试提供了新的可能性。

060:利用参数化分片张量简化GPU编程

在本节课中,我们将学习如何利用Mojo语言中的参数化分片级张量来简化GPU编程。我们将从现代GPU架构的特点出发,探讨传统编程模型的挑战,并介绍一种新的、更高层次的抽象方法。

现代GPU架构概述

上一节我们介绍了课程目标,本节中我们来看看现代GPU的架构特点。GPU是海量并行计算机器。例如,NVIDIA H800 GPU拥有128个流式多处理器,每个SM都配备了大量共享内存和寄存器文件。这种架构可以提供约2048个并行线程来执行任务。

GPU的并行性来源于异构的处理单元集合。并非所有核心都执行相同的指令。例如:

  • CUDA核心:执行加载/存储、浮点运算、分支等操作,可提供高达40 TFLOPs的算力。
  • 张量核心:专门用于执行矩阵乘法这一种指令,可提供约300 TFLOPs的算力。

GPU通过高带宽内存与外部系统交互。随着技术迭代,新一代GPU(如H100)拥有更多SM(144个)、更大的共享内存和寄存器文件,核心速度也更快(CUDA核心快约3倍,张量核心算力相近),总计可达约1 PetaFLOP的算力。此外,还增加了更多专用加速器(如用于在全局内存和共享内存间移动数据的张量内存加速器)以及更高的外部内存带宽。

这听起来都是好消息,但性能却难以移植。如果一个内核在旧架构上达到了峰值性能,你需要做大量工作才能让它在新的架构上达到峰值。原因在于新的指令集和需要应用的新优化技巧。因此,不仅内核,后端编译器也必须努力追赶硬件快速发展的步伐。

GPU编程模型与抽象鸿沟

上一节我们了解了GPU的强大算力,本节中我们来看看如何为它们编程。GPU采用单指令多线程编程模型,所有线程并行执行相同的指令。但硬件会将这32个连续线程分组为一个线程束,并(在理想情况下)让它们同时执行。一个线程束内的所有线程是同时执行的。

然而,由于多种原因,这种情况并不总是发生。此外,还有一部分指令是在线程束级别定义的,例如我们之前提到的矩阵乘累加指令,或者进行规约、线程束内洗牌等操作。在更新一代的架构中,这个概念被拓宽了,出现了需要多个线程束共同执行的指令,例如Hopper架构中的线程束组MMA指令。

因此,虽然单指令多线程模型在表达并行性方面很强大,我们都在使用它来编写快速的内核,但硬件并非真正单独执行每个线程,而是将线程分组为线程束。另一方面,编写内核的程序员也从不关心每个独立线程要做什么,他们总是编写描述一组线程行为的程序。

以密集计算内核(如矩阵乘法)为例,这些内核最终会归结为对分片的数据并行操作。当你分解矩阵乘法或任何类似风格的密集内核时,最终会得到一个分配给线程块的分片操作。然后,在线程块内部,你再次对这个分片进行划分,并将其分配给一个线程束。现在你有了一个线程束,需要分配和组织该线程束内的通道(即线程)来处理你的数据。

在将数据映射到计算层次结构时,你希望在分片上定义操作,例如分配、加载、复制、移动数据等。因此,程序员的思维模型始终处于“有多个线程”和“有不同类型的数据需要移动”的层次。此外,回顾本课开头,我们看到张量核心能提供300 TFLOPs的算力,而CUDA核心只能提供40 TFLOPs。程序员希望使用这些专用的固定功能核心来加速他们的内核,而这些核心的指令是以数据分片的形式定义的。

以下是一个MMA同步指令的例子,它有许多形状和数据类型的变体。我们看的是8x8x16(fp16累加到fp32)这个变体。一个线程束有32个线程。这条指令期望数据以特定布局排列:

  • 对于操作数A:采用行主序布局,线程0按特定顺序访问4个元素。
  • 对于操作数B:采用列主序布局,线程访问一个2x1的列向量。
  • 对于操作数C和D:布局实际上与操作数A相似。

由此可见,即使是指令本身也期望数据以分片和子分片的形式表示。我们认为,这听起来像是一个抽象鸿沟:你作为开发者想要编写的内容,与必须处理的底层细节之间存在差距。单指令多线程只是一种表达并行性的方式,但你表达并行性的方式并不意味着你必须深入底层,将所有内容都写成每个线程应该做什么。否则,你将花费大部分时间仅仅计算索引、进行索引算术和索引簿记,以分解这些层次结构。

Mojo的解决方案:参数化分片张量

上一节我们指出了传统模型的抽象鸿沟,本节中我们来看看Mojo提供的解决方案。我们希望能有一个高级的分片张量,它能自然地契合问题。你可以在这个张量上进行操作:分片、在特定内存区域分配、复制、进行数学运算等。当你将张量分配到计算层次结构时,一旦你有了一个线程束,你可以说:“好的,我有一个线程束,我可以使用这个特定的线程布局。” 这样做之后,你的问题就被参数化了,这非常有利于通过自动调优来优化你的内核,因为现在你有几个可以改变的决策点,例如我的分片大小是多少、我的寄存器分片大小是多少等等。你可以调整你的内核,甚至用于实验。

这实际上不是一个新问题。软件领域一直存在提供高级抽象的库,它们没有额外开销,并允许你表达算法(在我们这里是指内核),而无需担心如何映射到单指令多线程模型的实现细节。但因为它是库,挑战始终在于语言本身。你受限于语言能为你提供多少功能。更具体地说,在这个问题中,你受限于可以进行多少编译时转换,或者该语言的元编程对于编写这个库有多强大。

此外,回到编译器总是在追赶硬件这一点,你并不总是拥有对你所做事情的后端支持。如果你想访问新指令(比如新的张量核心功能),你可能只剩下内联汇编这一种解决方案,而你会尽量避免这样做。幸运的是,Mojo拥有更强大的元编程方法,并且可以直接访问MLIR操作,因此你并非只能依赖内联汇编。

我们的方法就是为了填补这个抽象鸿沟,定义一个张量类型。这个张量类型可以被分片、分配到计算层次结构,并支持更强大的类型转换(例如可以显式地进行向量化)。这个张量类型由一个布局元类型进行参数化,因此你可以指定数据在该张量中如何存储和访问。为了实现这些,你并不一定需要一个编译器或DSL,只要你的语言足够强大来进行这种元编程即可。

Mojo中的元编程

由于元编程是该方法的核心,我们将从Mojo中的元编程是什么样子开始看起。

以下是一个Mojo和C++中几乎相同代码的示例。在右侧,你有Mojo代码,定义了一个由数据类型和秩参数化的张量类型。在C++中,你有一个由类型和一个整型参数化的模板。在Mojo中,我们有参数,它们都是元类型。我们希望在元类型和运行时值之间进行区分。这些参数类似于模板,但它们不是基于替换的元编程方法;它们是编译时常量,Mojo编译器会为你实例化并给出具体的类型、属性或值。

Mojo元编程的另一个强大之处在于,你使用同一种语言进行编程和元编程。例如,在Mojo程序中,有一个函数计算张量中的元素数量。这个函数接收一个列表(可以是堆分配对象或其他任何东西)。你只需要使用 alias 关键字调用 size 函数,该表达式将在编译时被解释和求值。你得到的是一个编译时整数列表属性。但在C++中,元编程几乎是另一种语言,你必须学习所有不同的语言结构来表示相同的东西,这是一个障碍。如果你严重依赖元编程来实现库,这会使事情变得复杂。

在Mojo中,元类型也不仅限于用户定义的类型。你可以有参数化的闭包(即函数),以特定方式定义。例如,你可以有一个参数化多级流水线的MMA,并传递一个函数来实现融合等操作,这对代码生成很有用。

张量类型与布局转换

我们有一个由布局元类型参数化的张量。这些元类型是数据布局,用于指定密集维度如何被访问,以及一个元素布局。当你将张量元素从标量提升为向量时,你转换的是元素布局。布局只是一个将坐标映射到坐标的函数,这就是你获取元素访问方式的方法。如果你熟悉MLIR,我们使用了非常相似的定义来描述布局。

以下是一个行主序和列主序布局的示例。使用元组而不是整数列表的原因是我们需要表示分片布局,如果你熟悉MLIR的memref 4D平铺布局,这很相似,但这是一个非扁平化的、结构化的版本。

让我们看看如何转换这个张量类型。我们有一个像 tile 这样的操作:你有一个张量、一个分片大小参数和坐标,然后你得到另一个张量类型,通过求值这个参数化函数(平铺布局)并进行布局转换来得到。向量化也是如此,你将标量元素提升为向量,然后更新向量化后的元素布局。或者进行布局分发,在这种情况下,分发操作由线程布局参数化。分发操作就是分片并分发:它对数据进行分片,然后根据这个分片将线程分发出去。

现在,你可以在张量上定义操作:分配内存、进行数据移动(将分片从一个地方复制到另一个地方)、进行数学运算。

整合应用:简化张量核心的使用

让我们看看如何将这些整合起来,以简化如何使用张量核心来获得最大性能。

你可以构建一个张量,然后在共享内存中分配分片,在寄存器中分配分片,设置一个规约循环,移动数据并显式同步,然后进行规约。你使用张量核心指令将数据加载到寄存器,然后计算并累加。

GPU原语是作为MLIR操作实现的,这使我们免于仅仅编写内联汇编。当把所有东西放在一起时,你得到的Matmul实现看起来与我们之前看到的数学描述相似。

这个实现使我们获得的性能几乎与cuBLAS相当,在某些情况下甚至更快。需要明确的是,这些形状是动态的,没有静态形状特化。

与Triton的比较及总结

关于与Triton的比较,Triton是一个优秀的编译器基础设施,你可以在高级的线程块级别编写程序,然后编译器会出色地生成高性能代码。我们的方法非常不同,它是一个库。你仍然需要编写内核,但我们让你能够显式控制所有你想要的调度决策,例如分配寄存器分片和同步所有操作,因为我们的首要目标是性能。但我个人相信,你可以从“完全显式,声明并控制一切”过渡到至少“部分隐式”的某种状态。

本节课中,我们一起学习了如何利用Mojo中的参数化分片张量来简化GPU编程。我们从现代GPU架构和传统单指令多线程模型的局限性出发,探讨了存在的抽象鸿沟。接着,我们介绍了Mojo如何通过强大的元编程能力和参数化布局,提供一种高级的张量抽象,让开发者能够更直观地表达并行计算,特别是高效利用张量核心等专用硬件单元。这种方法在提供高性能的同时,也增强了代码的可移植性和可调优性。

061: 一个用于形式化验证MLIR窥孔优化的工具

概述

在本教程中,我们将学习如何使用Lean MLIR,这是一个基于定理证明器Lean的工具,用于形式化验证MLIR(多级中间表示)中的窥孔优化。我们将了解其工作原理、与现有工具(如Alive)的关系,以及如何将其应用于不同复杂度的MLIR方言。


第1章: 背景与动机

1.1: 什么是窥孔优化及其验证?

编译器中的窥孔优化是一种将一小段代码(源程序)替换为另一段功能等价但更高效的代码(目标程序)的转换。验证此类优化的正确性至关重要,即需要证明对于所有可能的输入,目标程序的语义都与源程序的语义完全一致。

1.2: 现有工具:Alive

在深入Lean MLIR之前,我们需要了解一个名为Alive的现有工具,它用于验证LLVM IR中的窥孔优化。

以下是Alive的工作原理:

  1. 输入: 用户提供源程序片段和目标程序片段。
  2. 转换: Alive将这些片段编译成一种名为SMT-Lib的严格规范的中间表示。
  3. 求解: 然后,Alive使用一个名为Z3的SMT求解器来回答一个关键问题:对于所有输入,函数source是否等于函数target
  4. 输出: Z3会返回验证结果。如果优化正确,则通过;如果错误,Z3会提供一个反例,展示导致输出不一致的特定输入值。

Alive的核心挑战在于,它需要定义一个从LLVM IR到SMT-Lib的、能够保持语义的转换。由于LLVM的语义(涉及未定义行为和毒值等概念)非常复杂,这项工作本身极具挑战性。

1.3: 从LLVM到MLIR的挑战

既然Alive对LLVM如此有效,一个自然的想法是将其应用于MLIR。然而,这并非易事。

主要困难在于,MLIR支持定义各种领域特定方言,其中一些方言操作的数学对象(例如,同态加密编译器Hair中使用的多项式环元素)过于复杂,无法直接编码到SMT-Lib中。这意味着像Z3这样的自动化求解器遇到了能力边界。


第2章: 引入定理证明器与Lean MLIR

2.1: 什么是定理证明器?

为了解决上述挑战,Lean MLIR选择基于定理证明器Lean进行构建。定理证明器与传统的编程语言或自动化求解器不同。

定理证明器允许你做两件事:

  1. 定义和计算: 像普通编程语言一样定义函数并进行求值。
    def maximum (a b : Nat) : Nat :=
      if a ≤ b then b else a
    
    #eval maximum 3 4 -- 输出: 4
    
  2. 陈述和证明定理: 你可以陈述一个逻辑命题(定理),并编写一个机器可检查的证明来证实它。
    theorem maximum_commutative (a b : Nat) : maximum a b = maximum b a := by
      -- 这里会包含一个基于情况分析的证明步骤
      ...
    
    Lean会严格检查你提供的证明是否正确。如果证明有效,它就认可这个定理成立。

2.2: Lean MLIR的设计理念

Lean MLIR采用了Alive团队曾探索过的思路:在定理证明器内部重新实现优化验证工具。

其核心思想是:

  • 语义形式化: 使用Lean作为语言,来精确描述各种MLIR方言的语义。
  • 灵活验证: 对于语义简单、可被SMT求解器处理的方言(如类LLVM的算术操作),Lean MLIR会利用Lean中已验证的求解器来自动构建证明,实现“一键式”验证。
  • 复杂对象处理: 对于涉及复杂数学对象的方言(如多项式方言),则直接利用Lean强大的数学库(Mathlib)来编码这些对象的数学定义,然后手动或半自动地编写证明。

这使得Lean MLIR占据了一个理想的位置:在拥有高度自动化的同时,也保持了完备性,确保用户永远不会因为方言太复杂而无法进行形式化推理。


第3章: Lean MLIR实战体验

上一节我们介绍了Lean MLIR的设计理念,本节中我们来看看它的具体使用体验。Lean MLIR主要关注三个目标:形式化MLIR语义、验证窥孔优化,以及为不同复杂度的方言提供相应级别的自动化支持。

3.1: 自动化验证示例(类LLVM方言)

对于语义类似于LLVM的方言,Lean MLIR提供了高度自动化的验证体验。

以下是使用其内嵌DSL定义优化规则并验证的流程:

  1. 定义优化模式: 使用类似MLIR的语法定义要匹配的源模式(左值)和要转换成的目标模式(右值)。
    -- 定义源程序: x + 0
    def lhs : LHS := [mlir| %x = arith.addi %y, 0 : i32 |]
    -- 定义目标程序: x
    def rhs : RHS := [mlir| %x = arith.addi %y, 0 : i32? No, 这里应为 `%x = %y`, 但为展示DSL语法保留原表述,实际验证会处理恒等变换]
    
    注:为忠实原文,此处保留可能的描述性笔误,实际右值应为%x = %y
  2. 陈述优化规则: 声明这是一个从lhsrhs的重写。
  3. 生成正确性证明
    • 框架首先运行自动化程序,消除SSA形式的样板代码。
    • 将正确性条件简化为纯数学表达式(例如,对于32位整数xx + 0 == x)。
    • 调用一系列自动化证明策略(“证明锤”),该策略可以自动完成证明。

用户甚至不需要本地安装Lean,可以通过在线Playground进行体验。当自动化成功时,Lean会显示“No goals”(无待证目标),表示证明已完成。

3.2: 处理错误优化与推广证明

如果优化规则是错误的,自动化程序会像Alive一样提供一个反例。

更强大的是,Lean MLIR可以证明优化对于所有位宽都成立,而不仅仅是具体的位宽(如32位)。这对于确保优化在特殊架构(如使用512位整数)上依然正确至关重要。

3.3: 复杂方言验证示例(多项式方言)

对于多项式方言这类复杂对象,验证无法完全自动化,但仍然是可能的。

Lean MLIR利用Lean庞大的数学库Mathlib,该库包含了定义多项式环等复杂对象所需的基础数学。开发者可以:

  1. 在Lean中形式化多项式方言的精确语义。
  2. 基于这些形式化定义,手动或借助一些辅助工具来构造优化正确性的证明。

虽然这需要更多人力,但它突破了自动化求解器的限制,使得验证复杂方言的优化成为可能。


第4章: Lean MLIR框架剖析

前面我们看到了Lean MLIR的应用,本节我们来深入了解其内部框架是如何构建的。该框架的核心是提供一种模块化的方式来定义任何MLIR方言的语义。

4.1: 方言语义的定义

一个方言的语义主要由两部分构成:

  1. 类型宇宙: 方言中所有类型的集合。需要为每种类型提供其在Lean中的语义。例如,可以将i32类型映射为Lean中的BitVec 32(32位位向量)。
  2. 操作语义: 方言中所有操作的集合。对于每个操作,需要定义:
    • 类型签名: 它接受什么类型的参数,返回什么类型。
    • 语义函数: 一个Lean函数,精确描述该操作如何根据输入值计算输出值。

例如,对于一个极简的整数方言:

  • Constant操作:接受一个整数值,返回一个i32。其语义函数返回对应的位向量。
  • Add操作:接受两个i32,返回一个i32。其语义函数是位向量的加法。

4.2: 框架的工作流程

定义了方言的语义后,Lean MLIR框架会:

  1. 将用户用DSL编写的源程序和目标程序片段,根据操作的类型签名,解析成语法树。
  2. 利用之前定义的语义函数,将这些语法节点“解释”或“编译”成等价的纯Lean数学表达式。
  3. 最终,要证明的优化正确性命题就转化为一个关于这些Lean数学表达式的等式,可以在Lean中加以证明。

4.3: 当前范围与开放问题

目前,Lean MLIR主要专注于基本块级别的、语法驱动的窥孔优化验证。它能够处理许多常见的优化,并已验证了大量来自Hacker‘s Delight、Alive测试用例和LLVM InstCombine的优化。

然而,也存在一些开放挑战:

  • 副作用: 框架虽然支持对副作用建模,但如何自动化地推理MLIR中复杂的副作用交互仍是一个难题。
  • 非组合语义: 如果某个操作的语义依赖于其上下文(例如,某些量化方言中,区域内操作的语义受外部操作影响),则当前的组合语义模型将难以处理。
  • 大规模转换: 目前主要验证局部优化。对于像内联这样更全局的、过程间的转换,验证起来更为复杂。

总结

在本教程中,我们一起学习了Lean MLIR这个工具。我们从窥孔优化验证的需求出发,回顾了现有工具Alive的能力与限制。为了克服MLIR方言多样性带来的挑战,Lean MLIR基于定理证明器Lean构建,它既能对简单方言提供高度自动化的“一键验证”,也能通过形式化复杂数学对象来验证极具挑战性的方言。通过其模块化框架,开发者可以为自己的MLIR方言定义形式化语义,并在此基础上验证优化的正确性,从而在编译器开发的早期就能保证转换的可靠性,让简单的事情变容易,让复杂的事情成为可能。

062:在Vale和Mojo中实现线性与不可破坏类型 🧵

概述

在本教程中,我们将学习什么是线性类型,以及如何在编程语言Vale和Mojo中实现它们。线性类型是一种强大的类型系统特性,它要求程序员必须显式地、以特定方式销毁对象,而不是让对象简单地离开作用域。这有助于编译器在编译时捕获许多常见的编程错误,例如忘记释放资源或处理重要操作。


什么是线性类型? 🤔

上一节我们概述了线性类型的概念,本节中我们来看看它的具体定义。

线性类型的常见定义是:一个线性对象最终必须被恰好“消费”一次。但这个定义可能不够直观。一个更实用的定义是:线性对象不能仅仅离开作用域,你必须最终以特定的方式显式地销毁它

为了理解这个定义,让我们看一个基本示例。

线性类型示例:线程处理 🧵

如果你使用过C++的std::thread,可能会遇到这样的问题:如果你不小心让线程对象离开作用域而没有调用join()detach(),它的析构函数会因不知道该做什么而调用std::terminate,导致程序崩溃。

线性类型可以解决这个问题。在Mojo中,我们可以定义一个Thread结构体:

struct Thread:
    explicit_destroy("must call join or detach")
    # ... 其他字段 ...

    fn join(self: owned Self):
        # ... 执行连接操作 ...
        destroy self

    fn detach(self: owned Self):
        # ... 执行分离操作 ...
        destroy self

这里的关键是explicit_destroy注解,它表示这个类型没有析构函数,其对象不能仅仅离开作用域。destroy是一个特殊的关键字,用于最终销毁对象。类型的使用者(如函数foo)不应直接使用destroy关键字,而应调用像joindetach这样的方法,这些方法在内部使用destroy来结束对象的生命周期。

如果用户尝试让一个Thread对象离开作用域,编译器会报错:“不能删除T,必须调用join或detach”。

以下是用户修复错误的几种方法:

  1. 调用一个接收所有权并销毁它的方法:例如 t^.detach()^是移动语义的语法,表示将所有权转移给方法的owned参数)。
  2. 推迟销毁:将线程对象移动到某个更长寿的数据结构中,如一个线程列表。由于Thread是线性的,List[Thread]也是线性的,编译器会确保最终有人处理这个列表中的所有线程。
  3. 返回它:将线程对象作为返回值移交给调用者,让调用者负责决定如何处理(调用joindetach或继续传递)。

通过这个例子,我们可以看到线性类型的第一个核心作用:编译器确保你最终必须决定对象何时以及如何消失


线性类型的其他应用场景 🔧

上一节我们通过线程的例子了解了线性类型的基本原理,本节中我们来看看线性类型在其他场景下的应用。

线性类型不仅能确保你做出决定,还能确保特定的操作最终会发生。

确保计算并提供一个值:Promise示例 🤝

在多线程编程中,Promise用于向另一个线程传递值。如果你忘记调用set_value,等待的线程就永远得不到值。

使用线性类型的Promise可以解决这个问题:

struct Promise[T]:
    explicit_destroy("must call set_value")
    # ... 其他字段 ...

    fn set_value(self: owned Self, value: T):
        # ... 设置值并通知等待者 ...
        destroy self

如果用户创建了一个Promise但没有调用set_value就让它离开作用域,编译器会报错。这确保了用户最终会计算出一个结果并调用set_value

确保获取一个值:Future示例 📬

Future代表一个将来会包含重要值的对象。这个值可能是一个需要处理的待处理请求的响应,或者是飞机需要正确着陆的当前航班信息。用户绝对不能让它无声无息地消失。

线性类型的Future可以这样定义:

struct Future[T]:
    explicit_destroy("must call get")
    # ... 其他字段 ...

    fn get(self: owned Self) -> T:
        # ... 等待并获取结果 ...
        let result = ...
        destroy self
        return result

用户必须调用fut^.get()来获取结果。编译器不仅确保我们最终调用了get,还确保我们最终取得了这个结果值的所有权。


线性类型的隐藏超能力 🦸

从前面的例子中,我们可以看出一个模式。线性类型不仅仅是关于销毁,它拥有一个隐藏的超能力:通过线性类型,你可以控制未来

你可以设计你的线性类型及其方法,以确保在未来某个时间点,以某种顺序,特定的事情最终一定会发生。你虽然不知道它们具体何时发生,但从你创建那个线性对象的那一刻起,你就可以确信:

  • 某些决定将会被做出(例如,如何处理线程)。
  • 某些数据将会被计算、提供或获取(例如,Promise的值,Future的结果)。
  • 数据将会以正确的方式被销毁。

你可以将这些保证以有趣的方式组合起来,帮助你的用户更正确地使用你的类型。


一个强大的例子:解决缓存不一致性问题 💡

线性类型的组合能力非常强大。下面是一个来自Vale游戏开发中的例子,展示了单个线性类型如何一举解决了三个缓存不一致性错误。

我们有两个主要结构体:

struct LiveEntityList:
    fn add(...) -> LiveEntityHandle:
        # ... 添加实体到列表 ...
        return LiveEntityHandle(index)

    fn remove(self: &mut Self, handle: owned LiveEntityHandle):
        # ... 根据句柄的索引从列表中移除实体 ...
        destroy handle

struct LiveEntityHandle:
    explicit_destroy("must give back to LiveEntityList.remove")
    index: Int
    # ... 其他字段 ...

LiveEntityList是关卡中实体的中央列表。add方法返回一个LiveEntityHandle。注意,这个句柄本身是线性类型(有explicit_destroy注解),唯一销毁它的方式就是把它交还给LiveEntityListremove方法。

这些句柄在实体存在于列表期间,被存放在其他地方,比如:

  • 一个“位置 -> 实体句柄”的缓存映射,用于快速查找某个位置上有谁。
  • 一个“队伍 -> 该队伍中实体列表”的映射。

这带来了两个重要的推论:

  1. 活的实体句柄即证明:如果你持有一个LiveEntityHandle,你就知道对应的实体仍然在中央列表中。这类似于弱指针,但更通用,它证明了关于一个远端对象的任意属性(在这里是它在“真相源”列表中的成员身份)。
  2. 悬垂句柄成为不可能:因为你必须先从所有缓存映射中取出句柄,才能用它来从中央列表中移除实体。所以,映射(缓存)永远不可能与列表(真相源)不同步。

通过巧妙地设计线性类型,我们自动解决了多个集合之间的不一致性问题。这就像一种“线性纠缠”或“编译时线性引用计数”。


在Mojo中的实现 🛠️

上一节我们看到了线性类型强大的应用,本节中我们来看看它在Mojo编译器中的实现机制。

首先,需要了解Mojo中普通(非线性)类型的工作方式。Mojo采用一种称为 ASAP(尽快)销毁 的策略。编译器不会在作用域末尾插入析构函数调用,而是在变量的最后一次使用之后立即插入。这可以提高内存使用效率。

处理普通类型生命周期和插入析构函数调用的逻辑,位于 CheckLifetimes MLIR Pass 中。这个Pass会找出生命周期的开始和结束位置。

它的工作流程大致如下(以逆向扫描代码为例):

  1. 从函数末尾开始逆向扫描。
  2. 遇到一个使用变量的操作(例如print(x)),如果这是逆向扫描中第一次遇到该变量,则标记此处为该变量的生命周期结束点
  3. 对于非线性类型,如果不是移动操作,就在此处插入析构函数调用。
  4. 继续扫描,直到遇到初始化该变量的操作,标记此处为生命周期开始点

线性类型的错误检查 ✅

对于线性类型,CheckLifetimes Pass 的工作流程在“生命周期结束点”有所不同:

  1. 当Pass扫描到线性类型变量的最后一次使用(即生命周期结束点)时,它发现这不是一个移动操作(即所有权没有被转移给一个接收owned参数的方法)。
  2. 于是,Pass检查该类型是否有explicit_destroy注解。
  3. 如果有,Pass不会插入析构函数调用,而是向用户报告一个编译错误,提示用户必须显式销毁该对象(例如,“必须调用join或detach”)。

这种实现方式巧妙地融入了Mojo现有的编译器架构中。


待解决的问题与未来方向 🚀

上一节我们介绍了线性类型在当前Mojo中的基本实现,本节中我们来看看尚未解决的问题和未来的计划。

条件线性类型与泛型容器 📦

一个主要的未决问题是:如何让线性类型与泛型容器(如ListOptionalDictBox等)协同工作?这些容器本身有析构函数,但它们包含的元素类型T可能是未知的。如果T是线性类型(没有析构函数),容器的析构函数就无法调用T的析构函数。

解决方案是使用条件一致性(Conditional Conformance)。例如,Box[T]del析构方法,应该只在T也有del方法时才存在。在Vale中,这类似于:

struct Box[T]:
    fn del(self: owned Self) where T has del:
        self.value.del() # 只有T有del方法时,这行代码才有效
        # ... 释放Box自身内存 ...

在Mojo中,可能会使用特质(Trait)来实现,例如where T: ImplicitlyDestructible。这确保了泛型代码能够同时处理线性类型和非线性类型。

路线图 🗺️

未来的工作主要包括:

  1. 完成条件线性类型的实现:这是支持泛型容器的关键。
  2. 更新标准库:让标准库中的容器和工具支持线性类型。
  3. 逐步发布:最初可能通过一个默认关闭的编译器标志来启用线性类型功能,供社区试用和反馈。
  4. 收集反馈:评估线性类型在实际项目中的效果、易用性以及修复问题的成本。
  5. 最终决策:根据社区反馈,决定是否将线性类型作为默认开启的功能。

总结

在本教程中,我们一起学习了线性类型的核心概念。我们了解到:

  1. 线性类型要求对象必须被显式、以特定方式销毁,不能仅仅离开作用域。
  2. 它通过编译器强制保证,帮助避免了忘记释放资源(如线程)、忘记提供结果(如Promise)、忘记处理重要值(如Future)等常见错误。
  3. 线性类型的隐藏超能力在于允许类型设计者“控制未来”,确保一系列特定操作最终必定以正确的顺序发生。
  4. 我们通过一个游戏开发的例子,看到了线性类型如何巧妙地解决多个缓存之间的不一致性问题。
  5. 在Mojo中,线性类型的错误检查是通过扩展现有的CheckLifetimes MLIR Pass来实现的,当发现线性对象未被正确处理时报告编译错误。
  6. 未来的挑战在于实现条件线性类型,以使其与泛型容器完美配合,这项工作正在进行中。

线性类型是一种强大的工具,它将部分运行时责任转移到了编译时,能够显著提升代码的可靠性和可维护性。

063:手拉手 - LLVM libc 与 libc++ 的代码共享

在本节课程中,我们将探讨一个在 LLVM libc 和 libc++ 之间共享代码的项目。我们将了解其动机、实现方式、面临的挑战以及未来的可能性。

为什么 LLVM 需要 libc?

首先,我们可能会问,为什么 LLVM 需要一个 libc 库?毕竟 LLVM 本身是用 C++ 编写的。答案在于,libc 已经成为许多编程语言的基础库。例如,Lua 和 Python 的解释器是用 C 写的,它们大量调用 libc 的功能。Mojo 和 Rust 等编译型语言的标准库也会调用 libc,因为没有人愿意重新实现复杂的数学函数或字符串到浮点数的转换。C++ 则是最极端的例子,因为 libc++ 标准上包含了 libc 的所有功能。

libc++ 为许多 libc 函数提供了更友好的接口。例如 sin 函数,如果你直接想调用 sinf,它确保在全局命名空间中定义;但如果你只想调用 sin 并传入浮点数,它提供了函数重载,无需指定具体类型。当包含 <cmath> 时,它也会包含 libc 版本的 math.h。许多 libc++ 函数在内部最终会调用 libc 函数来完成核心功能。

LLVM libc 本身是用 C++ 实现的,这使我们能够利用模板等特性来优化内部函数。例如,字符串转整数有 atoistrtolstrtollstrtoulstrtoull 等多个函数,它们功能相似但略有不同。如果用纯 C 实现,可能需要使用宏和代码复制来获得良好的接口。而 C++ 的模板让我们可以在内部获得更清晰的语法抽象和函数接口,尽管对外仍然提供相同的 C 函数接口。

我们将其设计为模块化,部分原因在于这些内部函数需要完全保持内部性,不能暴露给外部。我们希望在内部使用更符合 C++ 习惯的接口,因为这样更方便。

libc++ 的挑战与机遇

libc++ 的接口表面比 libc 大得多。libc++ 长期以来缺少一些 C++17 的功能,部分原因在于浮点数处理非常复杂,需要投入大量精力确保正确性。

C++17 特别增加了一个名为 from_chars 的功能,位于 <charconv> 头文件中,用于将字符串转换为浮点数。标准明确指出,from_chars 的功能与 C 库函数 strtod 几乎相同,只有少数例外。

然而,对比两个接口,我们发现它们差异很大:

  • C++ 的 from_chars 接受由两个指针界定的字符范围。
  • C 的 strtod 接受以空字符结尾的字符串。
  • 返回值方式不同:C++ 通过引用返回浮点数,C 通过返回值返回。
  • from_chars 的返回值结构包含更多信息。
  • C 有格式说明符。
  • 错误返回机制不同:C++ 错误信息是 from_chars 结果的一部分,而 strtod 通过 errno 返回。

因此,虽然功能相同,但接口完全不同。这就引出一个问题:为什么 libc++ 不能自己实现一套?同样,因为浮点数处理极其困难。既然 LLVM libc 已经实现了相关功能,为什么还要重写超过 2300 行代码呢?如果 libc 能提供一个不同的内部接口就好了。

好消息是,LLVM libc 确实已经这样做了。它使用函数模板来处理字符串到浮点数的转换,因此无需为 strtofstrtodstrtold 等分别编写代码。这些函数模板必须在头文件中实现,这很好。而且由于只是字符串到浮点数的转换,没有操作系统特定的依赖,这使得合作变得容易。

那么,核心问题就变成了:libc++ 能否使用 LLVM libc 已经提供的代码,以便用共同的核心来实现 from_chars

合作路径与要求

如果我们想走这条路,就需要分别考虑 libc++ 和 libc 各自需要做什么。

从 libc++ 的角度看:

  1. 对用户透明:这意味着我们需要一个严格的接口来限制“海拉姆定律”。海拉姆定律指出,任何用户可能接触到的东西,无论你是否声明它不稳定,随着用户群增长,最终都会有人依赖它。因此,接口必须设计得让用户甚至不知道底层是不同的。只要行为完全相同,就是可以接受的。
  2. 不稳定的 API:我们需要一个非常明确和狭窄的接口,以避免限制这个库的演化。同时,我们需要为 API 变更制定计划,因为事物总会随时间变化,没有计划会限制我们的发展。
  3. 扩展到 Clang:未来可能考虑在 Clang 中也使用这些共享代码,但必须谨慎和明确。不能随意包含 libc 的内部头文件,否则会造成混乱。

从 LLVM libc 的角度看:

  1. 代码基础:代码已经是独立且用 C++ 编写的,我们有一个与 libc++ 需求相似的 API,只需要进行一些小的调整。
  2. 优化实现:该实现已经是经过良好优化的。
  3. 明确的接口:通过仅头文件(header-only)的实现方式,我们可以使用间接头文件,在特定的命名空间中重新导出我们想要暴露的部分。这样就能明确区分哪些部分可以接触,哪些是 libc 内部更深层的实现细节。

以下是一个示例头文件的片段,展示了如何提供 strto_integer,但不提供像将十六进制字符转换为整数这样的底层辅助函数:

namespace __llvm_libc { // 内部命名空间
// 只暴露给 libc++ 的接口
int strto_integer(const char* __restrict str, char** __restrict str_end, int base);
// 不暴露底层辅助函数
// char to_digit(char c, int base);
}

潜在的陷阱与解决方案

在合作过程中,我们需要避免一些潜在的陷阱:

  1. 耦合公共 API 会严重限制演化:过去 Glibc 和 libstdc++ 曾尝试共享代码(如 io vtable,允许将 C++ 流用作 C 的 FILE*),但最终失败了,因为 API 的紧密耦合使得双方无法独立更新,最终不得不破坏 API。

  1. 如果接口发生分歧怎么办?:如果发生分歧,我们需要做的是回退到共同祖先。我们从现有 API 开始,找出两个新 API 之间的共同功能,将分歧部分移入各自的库中,保持公共接口对两者通用。这只需要一些代码重组,以确保满足各自库的规范。

  2. 如果完全分歧怎么办?:如果某个功能完全分歧,我们只需将该函数完全拆分出来,使其不再属于公共代码。其余部分保持不变。除非我们坚持之前设定的严格、狭窄的接口准则,否则这可能会有些困难。

幸运的是,由于 libc 和 libc++ 位于同一个单体仓库(monorepo)中,我们无需担心 API 版本化问题。如果我们想更改 API,可以在一个原子提交中完成。只要用户从同一个提交构建他们的 libc 和 libc++,就不会有任何问题。API 版本就是那个单一的提交。

项目成果与致谢

基于上述分析和设计,这个项目成功了。LLVM 20 将发布基于 LLVM libc 代码实现的 from_chars 浮点数转换功能。相关的拉取请求已经合并,没有造成严重破坏(除了 PowerPC 上的一些小问题)。这对于 libc++ 和 libc 来说都是一个成功。现在我们有了一个可以共同维护的共享核心。

在此,我们要特别感谢为此项目做出贡献的人们:

  • Louis Dionn:libc++ 的主要维护者,帮助组织了 libc++ 方面的工作并进行了大量代码审查。
  • Mark de Wever:编写了 libc++ 部分的字符串解析代码。
  • 所有参与 RFC 讨论和拉取请求评审的贡献者。

未来展望

对于未来,我们考虑在以下方面进行探索:

  1. 扩展共享范围:我们怀疑 libc++ 可能从其他领域(如数学函数)的代码共享中受益。
  2. 扩展到 LLVM 其他部分:例如,今天主题演讲中提到的数学概念,可能是与其他 LLVM 组件共享的潜在途径。但再次强调,不要随意在其他地方包含这些代码,请先与相关维护者沟通。
  3. 构建统一的运行时库:未来或许可以考虑构建一个统一的库,包含 LLVM libc、libc++、libunwind 等,这样在构建程序时只需链接一个东西,而无需记住需要组合哪些 LLVM 运行时组件。

问答环节摘要

在演讲后的问答环节,讨论了一些关键问题:

  • 关于 C 和 C++ 标准协调:有建议提出,如果 C 标准社区能增加一个具有新功能的公共标准化 C 函数(类似 from_chars),或许可以简化工作。虽然存在错误处理机制(errno 与异常/返回结构)和解析规则(如区域设置)的差异,但这无疑是一个值得探索的未来方向。
  • 测试策略:libc++ 方面像测试其他单元测试一样测试公共功能。libc 方面有自己的单元测试来测试内部代码和公共接口。libc++ 不测试“手拉手”项目的具体细节,只将其视为普通的 libc++ 函数进行测试。
  • libc 内部改动:为实现共享,libc 侧只需要一个微小的改动(在字符串到浮点数解析的某个性能优化环节增加了长度限制器),没有重大变更,目前也未发现会对未来 libc 发展方向造成限制。
  • 版本混合与依赖:共享代码是内部的。如果用户混合使用不同版本的 libc(如 musl)和 LLVM libc++,可能会缺少某些函数。但本质上,共享的 libc 代码是 libc++ 内部的,musl 不需要提供它。
  • 会创建新的中间库吗?:曾考虑过创建一个介于 libc 和 libc++ 之间的公共库,但短期内不会实现。目前代码由 libc 开发者维护,放在 libc 中更合适,且所有共享都是通过头文件完成的,没有独立的运行时库。
  • 对供应商的影响:对于像 Apple 这样的 libc++ 供应商,如果使用 CMake 构建则无需任何更改。如果使用自己的构建系统(如 GN),则需要配置包含路径并在构建 charconv 时定义一个宏,之后即可正常工作。未来类似更改的影响范围取决于具体功能,可能是几十个函数,如果涉及常量表达式数学函数,则可能多达数百个,但这对用户应该是透明的。

本节课中,我们一起学习了 LLVM libc 与 libc++ 之间“手拉手”代码共享项目的背景、设计、实现与未来。核心在于利用 C++ 模板和头文件实现了一个狭窄而明确的内部接口,使得 libc++ 能够复用 libc 中复杂的浮点数解析代码,成功实现了 C++17 的 from_chars 功能,同时避免了紧密耦合带来的维护负担,为两个库的未来合作奠定了良好的基础。

064:为你的GPU构建一个C++工具链 🚀

概述

在本节课中,我们将学习如何构建一个能够直接在GPU上运行的标准C++工具链。我们将探讨其动机、实现方法、现有方案的对比,以及如何通过交叉编译的方式将LLVM的C/C++运行时库移植到GPU上。

从固定功能到通用计算:GPU的演变

GPU已经从最初的固定功能图形硬件发展至今,现在它们承担了大量的通用计算任务。然而,与最简单的嵌入式处理器相比,GPU通常缺乏完整的工具链支持。这引发了一个问题:如果GPU真的是通用处理器,我们能否直接在它们上面运行任意的C/C++代码?这正是本项目的核心动机。尽管其中一些工作可能并不完全实用,但探索过程本身比结果更有意义。更重要的是,这允许你将现有库轻松移植到GPU上,并得益于LLVM和libc++团队的工作,获得许多通用功能的实现。

现有GPU编程方案对比

上一节我们介绍了项目的动机,本节中我们来看看现有的将C/C++库移植到GPU的方案。以下是几种主流方法:

CUDA / HIP

CUDA或其替代品HIP是目前在GPU上编写代码的普遍方案。但当我们审视其本质时,CUDA是一种“卸载语言”。它将主机编译和设备编译(甚至可能是多架构)的异构编译任务合并到一个庞大的Clang作业中,这给构建系统带来了巨大的复杂性。此外,它要求程序员手动为每个函数添加 __global____device__ 等修饰符。

让我们剖析一个简单的AXPY示例(这似乎是所有GPU相关幻灯片的标配):

// CUDA 示例
__global__ void saxpy(float a, float *x, float *y) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    y[i] = a * x[i] + y[i];
}

你使用一些“神奇”的Clang选项编译此代码。编译器实际上是在设置了一些特定语言选项后,将代码传递给Clang前端。运行时则将这些 blockIdx 调用转换为Clang支持的内建函数。虽然我们了解了其内部机制,但目前我们并不想使用CUDA。

OpenMP

OpenMP提供了一种更接近标准C++风格的解决方案,它主要通过编译指导语句实现。它与CUDA存在相同的问题,主要与构建系统相关,并且也需要手动使用 #pragma 来区分主机和GPU代码,因为它是异构的,不能假设所有内容都是共享的。

再次查看相同的SAXPY示例:

// OpenMP 示例
void saxpy(float a, float *x, float *y, int n) {
    #pragma omp target teams distribute parallel for
    for (int i = 0; i < n; ++i) {
        y[i] = a * x[i] + y[i];
    }
}

我们使用 -fopenmp 标志进行编译,本质上也是启动了一个设置了 -mtriple 和一些OpenMP语言选项的Clang前端作业。OpenMP的运行时也使用了相同的Clang内建函数来实现GPU的“魔法”。

OpenCL

OpenCL采用更传统的编译方式,这解决了我对卸载语言在此类应用中的许多顾虑。但OpenCL的问题在于,为了尽可能兼容更多硬件,它有意限制了所提供的功能类型。而我更感兴趣的是利用当今大多数人都能接触到的高端硬件的特性。例如,函数指针或递归在OpenCL标准中是被明确禁止的,但它们存在于许多现有程序中,因此无法直接移植。

直接使用C/C++:交叉编译的视角

综合以上方案,我们发现几乎每一种卸载语言都只是Clang前端的某种封装。那么,为什么我们不能直接使用C/C++呢?事实证明,Clang已经有一个选项可以直接针对不同的架构进行编译,它叫做 -target

如果我们针对AMD GPU,只需执行:

clang -target=amdgcn-amdhsa your_code.cpp

这样,我们就可以直接使用标准的ISO C++代码。由于CUDA等语言本身就基于C++,我们可以轻松地将其通过Clang编译,得到能在设备上运行的功能性代码。

但显然,这样做的问题是性能极差,因为你是在单个线程上运行代码,这无异于将价值1000美元的硬件当作100美元的树莓派来使用。因此,要想真正在GPU上使用C++,你需要打破标准委员会的束缚,进入编译器扩展的奇妙世界。

以下是一个使用编译器扩展的阻塞矩阵乘法示例:

// 使用编译器扩展的GPU代码示例
[[clang::kernel]] void matmul(...) {
    [[clang::address_space(3)]] float *shared_A;
    int tid = __builtin_amdgcn_workitem_id_x();
    int bid = __builtin_amdgcn_workgroup_id_x();
    // ... 计算逻辑
    __builtin_amdgcn_barrier();
}

这段代码看起来与CUDA非常相似:函数上有特殊修饰符使其使用内核调用约定;有地址空间属性(与CUDA的 __shared__ 关键字几乎一一对应);有获取网格ID或线程ID的内建函数;还有与 __syncthreads() 相同的屏障内建函数。

你可能会觉得这看起来很糟糕。但首先,我提交了一份RFC,试图提供一个资源目录头文件,至少为这些东西提供更规范的名称,使其更接近OpenCL的风格。其次,更重要的是,如果我们转变对GPU语言编译方式的看法,我们可以用它做很多有趣的事情。因为,这本质上就是交叉编译,而人们进行交叉编译已经很久了。

构建完整的GPU工具链

上一节我们提到了交叉编译的概念,本节中我们来看看如何构建一个完整的GPU工具链。一个完整的编译器工具链通常包括:

  1. 编译器前端:我们已经有了,就是Clang。
  2. 汇编器:我们也有了,是LLVM的机器码。
  3. 链接器:我们也有,比如LD。
  4. 运行时库:这是我们真正缺乏的部分。

因此,我们的计划就是将现有的LLVM C/C++运行时库移植到GPU上。LLVM已经为交叉编译提供了大量支持。许多项目(如刚刚讨论过的libc++)都使用LLVM的运行时构建系统。

这个构建系统的有趣之处在于:

  • 它允许你使用刚刚构建的Clang,这对GPU至关重要,因为前面提到的所有编译器扩展都依赖于Clang。
  • 它可以为多个目标编译运行时。通过使用 LLVM_RUNTIME_TARGETS CMake选项,你可以启用所有想要的目标。然后,你可以通过类似 -D<runtime>_<triple>_VAR 的语法为每个独立的工具链传递参数。这种语法虽然看起来有些复杂,但一旦习惯就会非常强大。

这种机制利用了multi-libs的处理方式。每个运行时目标在设置 LLVM_ENABLE_PER_TARGET_RUNTIME_DIR(在Linux上默认开启)后,都会获得自己的目录结构。这允许你将所有库命名为相同的东西(如 libc.a, libm.a),而不会与你可能正在使用的主机构建冲突。当你使用 -target=-lm-lc 时,只要这些库在你的工具链路径中,编译器就能自动找到它们。

移植运行时库

有了构建系统的支持,让我们开始实际移植运行时库。

LLVM libc

C库是所有其他有趣应用程序的基础,因此它是一个很好的起点。LLVM libc的一个优点是高度可配置。你可以先启用一个在GPU上实际能工作的函数子集,然后逐步添加更多。系统调用通过远程过程调用处理,简而言之,这是一种客户端-服务器协议,CPU线程和GPU线程通过某种互斥机制进行通信。

一个很酷的地方是,就像普通的libc构建会有启动对象来处理应用程序启动和 _start 函数一样,我们也可以为GPU生成相同的东西。这让我们可以思考在GPU上直接调用 main 函数。显然,对于追求性能的代码你不会这么做,但这对于单元测试非常有帮助,因为它允许你直接复制粘贴现有的单元测试并在GPU上运行。

为了实际运行这些测试,我们可以借鉴交叉编译中常用的模拟器概念。我编写了一个类似的工具,称为“加载器”,它负责在GPU上启动你的可执行文件。

Compiler-RT

接下来是Compiler-RT,因为Clang在某些情况下需要能够发出内建函数。移植它相对直接,因为它本身就是C语言,并且已经支持交叉编译到无数目标。一旦剥离了所有卸载相关的复杂性,GPU目标并没有什么特别之处。我只需要添加几个案例来设置一两个标志,然后它就工作了。现在我有了一个用于libc和所有相关功能的静态库。

libc++

libc++构建在libc之上。一旦我们有了一个基本可用的libc,我们就可以在其上构建libc++。libc++团队已经做了一些工作来在LLVM libc和LLVM之上构建libc++,我直接利用了这些工作,并将其目标定为GPU。因为这些编译任务看起来与任何其他嵌入式工具链所需的任务相同,所以我几乎不需要做任何更改,只是使用了libc++现有的配置文件,并将它们放入一个大的缓存文件中。我只需要在少数几个地方添加 #ifdef 来做一些不同的事情。除此之外,移植绝大部分libc++几乎是自动完成的。我关闭了线程和文件系统等功能,因为目前还没有支持它们的明确计划。

这允许我们做一些非常有趣的事情,例如在GPU上运行libc++的测试套件。因为libc++已经支持自定义测试执行器(嵌入式系统测试常用模拟器),我只需将其与我的AMD加载器结合,就能运行大部分测试套件。

与卸载语言集成

前面我们构建了一个独立的工具链,本节中我们来看看如何将其与用户实际关心的语言(如CUDA或OpenMP)集成。

卸载语言对于典型用户来说非常方便。我们可以将在这里构建的静态库应用到像CUDA或OpenMP这样的程序中,因为我们所做的就是构建一个静态库,而我们的卸载目标有一个能处理静态库的链接器。

你只需要将这些库传递给设备链接阶段,可以通过 -Xoffload-linker 选项实现。这允许你直接用纯C/C++编写代码,然后将其链接到卸载程序中。

例如,你可以从一个OpenMP目标区域调用 std::cout

// 在OpenMP目标区域使用标准库
#pragma omp declare target
#include <iostream>
#pragma omp end declare target

void some_function() {
    #pragma omp target
    {
        std::cout << "Hello from GPU!" << std::endl;
    }
}

这种方式编程非常接近我习惯的方式,并且如果我想要回到卸载语言,也可以轻松集成,因为我仍然可以使用常规的CMake。

总结与展望

本节课中,我们一起学习了如何构建一个面向GPU的功能性C++工具链。

主要成果

  • 构建了一个功能性的C++工具链,可以直接面向GPU。
  • 虽然对许多应用来说并不超级实用,但它包含了一些可能有用的部分,例如在GPU上使用 std::mdspanstd::span
  • 支持在GPU上进行测试(例如,编写 int main() 并在GPU上运行)。
  • 这主要是一系列编译器和CMake技巧的集合。

面临的挑战与未来方向

  1. 并发原语:如何处理互斥锁和线程?GPU的向前进度保证非常有限,其多线程方式与Pthreads的期望完全不同。实现完整的 std::thread 需要不同的模型。
  2. 编译时间:当你开始做这些事情时,编译时间会变得非常糟糕。libc++非常庞大,生成大量LLVM IR,GPU后端需要很长时间处理,因为它们通常针对高度优化的内核进行设计。
  3. 与卸载语言的集成:需要声明哪些libc++函数可用,目前基本上是全部可用,但这需要一些头文件魔法来处理。
  4. 数据拷贝:在卸载语言中,当数据在CPU和GPU之间拷贝时,如果GPU上发生了重新分配,需要仔细处理内存管理。

最终目标是希望有朝一日,发行版能够在其工具链中支持这种每目标运行时的特性,这样用户就不需要自己构建,可以直接使用发行版提供的包。


演示:作为演示,我成功地将《毁灭战士》游戏移植并在此工具链上编译运行。(演示画面略)


问答环节要点

  • 自动向量化/生成内核:目前,像GCC那样从SIMT模型转向自动生成GPU内核的方式在LLVM中可能不支持,需要更多后端工作,或许可以将其视为向量内部函数。
  • 供应商无关的C++源码:可以通过预处理器宏包装不同厂商的内建函数来实现,因为大多数GPU在99%的情况下功能是相似的。
  • 编译到本地镜像与动态链接:可以编译到本地IR,但在AMD上存在一些关于数据包启动的元数据分析问题。使用LTO的主要原因是便于像SPIR-V那样重定向目标,但存在一些注意事项。
  • 性能考量:目前该方案更侧重于功能性和探索,而非高性能计算。

065:LLVM基金会动态与展望

概述

在本节中,我们将了解LLVM基金会的最新动态、核心项目、面临的挑战以及未来的发展规划。LLVM基金会是一个致力于支持编译器与工具领域发展的非营利性公共慈善组织。

LLVM基金会简介

LLVM基金会成立于2014年,是一个501(c)(3)公共慈善组织。这意味着其宗旨是服务公共利益,专注于其使命,而非特定利益。

基金会的使命是:通过教育活动、资助和奖学金支持编译器与工具领域的教育与进步;提升编译器与工具领域的多样性;并直接支持LLVM项目社区及其基础设施。

简而言之,LLVM基金会通过帮助社区成长、促进社区互动、通过基础设施保持LLVM开发的高效性,并努力确保这个已有20多年历史的项目的长期健康发展,来支持LLVM社区。

基金会项目与结构

非营利组织通常将工作划分为不同的“项目”。LLVM基金会目前有四个主要项目。

以下是这些项目的简要介绍:

  • 教育推广:组织如LLVM开发者大会等活动。
  • 社区拓展:旨在提高LLVM及编译器与工具领域多样性和包容性的倡议。
  • 奖学金与资助:目前主要面向学生提供支持。
  • 社区健康与成长:通过项目基础设施、法律事务(如近期接近完成的许可证重新授权工作)及其他跨领域工作来支持社区。

董事会成员更新

LLVM基金会由一个董事会管理。董事会每两年选举一次。目前董事会已从9人扩充至11人。

以下是当前董事会成员名单:

  • Chris Lattner
  • Wei Wu
  • Reed Kleiner
  • Mike Edwards
  • Kristof Beyls
  • Anton Korobeynikov
  • Tanya Lattner

此外,我们很高兴地宣布三位新加入的董事会成员:

  • Anna Zaks
  • Ankita Gupta
  • Allison Randal

这三位新成员在LLVM及其他开源项目方面拥有独特的经验,我们期待在未来两年内借助他们的知识与经验。

各项目进展与挑战

接下来,我们将快速回顾各项目的更新情况。

教育推广项目

我们持续每年组织两次开发者大会。我们正计划在2025年6月于东京举办首届亚洲会议。对于美国的活动,我们看到需求增长,这非常棒。

所有演讲均被录制并发布,这为LLVM构建了宝贵的知识库。我们在YouTube频道上看到了极高的关注度。我们还尽可能为本地社交活动提供支持,Chris在组织在线研讨会和办公时间方面做得非常出色。

面临的挑战:活动成本非常高,即使有赞助补贴,门票价格仍在上涨。我们需要评估如何控制成本,使活动更易于参与。同时,我们也在思考活动的规模问题:是继续扩大规模,还是保持较小规模以维持有效的协作与交流。此外,基金会迫切需要增加人手。

社区拓展项目

去年我们引入了特邀演讲者,并将其与新成员培训相结合,试图触及新受众。然而,这个项目未能获得足够关注,因为我们需要更多人参与其中,无论是付费员工还是志愿者。保持该项目的势头一直很困难。

奖学金与资助项目

该项目非常成功。今年我们向学生提供了约7万美元的资助,帮助他们参加活动。我们不断收到反馈,称参加LLVM开发者大会是一次非常有价值的经历。这有助于培养下一代LLVM开发者。

面临的挑战:申请资助的学生人数超出了我们的资助能力。我们希望与大学教授合作,触及更多从事LLVM研究的学生,并鼓励他们来展示自己的工作。

社区健康与成长项目

这是基金会工作的核心领域。我们保持了基础设施的稳定运行,并完成了向新技术的多年过渡,这离不开众多志愿者的时间和努力。基础设施帮助超过5000名贡献者有效协作。我们在重新授权许可证方面取得了进展,并拥有一个出色的行为准则委员会。

面临的挑战:对基础设施支持的需求依然强烈。基金会需要找到更好的方式来资助这项工作,并雇佣更多员工来支持项目不断增长的基础设施需求。

基金会发展规划

正如您可能听出的主题,我们正在努力扩展LLVM基金会。我们目前正在面试一位项目总监,以专门协助我们的活动和差旅资助项目。我们有更大的目标,即雇佣专门负责基础设施支持和社区管理的员工。

这将有助于平衡工作负载,帮助我们摆脱困境,开始以最佳方式支持项目。

挑战在于找到适合这些职位的人选。因此,我们需要借助大家的力量。如果您认识任何对这些职位感兴趣且合适的人选,请与我们联系。我们正在寻找对活动、非营利组织、开源或基础设施充满热情的人才。

随着基金会的发展,我们将重新评估收入来源,以确保能够合理配备人员并支付薪酬,同时也要平衡,避免过度提高门票价格而影响活动的可及性。

寻求赞助支持

我们明确在寻找更多赞助商。如果您有兴趣赞助LLVM基金会,您的支持将有助于确保5000名贡献者能够继续有效协作,帮助每个人在安全的环境中贡献和协作,帮助我们培养下一代,并最终帮助基金会和项目,使更多公司能够更轻松地做出贡献。

我们相信,赞助LLVM基金会是一项回报丰厚的投资。如果您有兴趣,请联系我或前面幻灯片中提到的任何董事会成员。

总结

本节课中,我们一起学习了LLVM基金会的最新情况。我们了解了基金会的使命、四个核心项目、董事会更新、各项目取得的进展与面临的挑战,以及基金会未来的发展规划,包括扩大团队和寻求赞助支持。LLVM基金会致力于支持LLVM社区的健康与持续发展,确保这个重要的开源项目在未来几十年内继续繁荣。

066:引言与概述

在本节课中,我们将要学习Rust语言中的自动微分技术,并探讨为何安全的Rust代码在性能上可能优于不安全的代码。我们将从基本概念入手,逐步深入到技术细节和性能对比。

概述

Rust是一门现代系统编程语言,以其内存安全和零成本抽象而闻名。然而,关于Rust性能的一个常见误解是,为了获得最佳性能,开发者必须使用不安全的代码。本次课程将分析这一观点,特别是在高性能计算和科学计算领域,通过自动微分的案例来展示安全Rust代码的潜力。

自动微分是计算函数导数的技术,在机器学习、物理模拟和优化问题中至关重要。我们将看到,Rust的类型系统和安全保证不仅不会阻碍性能,反而可能通过为编译器提供更多信息来提升性能。


自动微分与Rust性能:第2章:理解Rust中的unsafe关键字

上一节我们介绍了课程的整体目标,本节中我们来看看Rust中unsafe关键字的具体含义和作用。

unsafe关键字并不改变Rust代码的编译方式,它只是一个非常表面的检查,用于标记代码区域或函数为不安全。在unsafe块中,开发者获得了五种“超能力”。

以下是unsafe允许的五种操作:

  1. 解引用裸指针:这类似于C语言风格的指针操作。通常我们不会直接使用裸指针,它更多用于与其他语言进行FFI交互。
  2. 调用不安全的函数或方法:Rust标准库提供了一些不安全函数,开发者也可以编写自己的不安全函数,通常是在无法证明前置条件一定会被满足时,将正确使用的责任转移给调用者。
  3. 访问或修改可变的静态变量:全局变量通常不建议使用,除非开发者清楚自己在做什么。
  4. 实现不安全的trait
  5. 访问联合体中的字段:联合体类似于枚举,但其类型不是标记的,使用者在某种程度上需要对自己如何使用它们负责。

需要强调的是,一旦编译器检查了代码是否在unsafe块中执行了这五种操作,之后就会丢弃这些信息。从Rust的MIR中间表示开始,所有代码无论安全与否,其编译模型都是一致的。


自动微分与Rust性能:第3章:自动微分简介与性能背景

上一节我们介绍了unsafe在Rust中的角色,本节中我们来看看本课程的核心应用场景——自动微分。

自动微分是一个基础概念,源于微积分。例如,对于函数 f(x) = x²,其导数是 f'(x) = 2x。这个概念衡量了函数在特定点的变化率。

在计算机科学中,我们处理的函数通常有多个输入和输出。一个常见的例子是机器学习中的神经网络,它有巨大的张量输入(如图像和权重)和一个标量输出(如损失函数)。在这种情况下,计算导数变得非常重要,这也是性能基准测试的关键点。

传统的自动微分工具在源代码级别进行转换。流程如下:

  1. 输入源代码。
  2. 应用自动微分工具,生成新的、能计算导数的源代码。
  3. 对新代码进行优化。

然而,基于LLVM的自动微分工具采用了不同的顺序:

  1. 将输入代码(如C++)降低到LLVM中间表示。
  2. 对LLVM IR进行优化。
  3. 在LLVM IR级别进行微分。
  4. 再次优化。

这种改变操作顺序的方法带来了显著的性能提升,平均加速比达到 4.2倍。由于Rust编译器主要在LLVM IR级别进行优化,因此利用基于LLVM的自动微分对Rust社区具有很大吸引力。

除了神经网络,自动微分在科学计算和高性能计算领域也有广泛应用。


自动微分与Rust性能:第4章:安全代码 vs 不安全代码的性能对比

上一节我们了解了自动微分及其性能背景,本节中我们通过具体的基准测试来比较安全与不安全Rust代码的性能。

我们使用了五个基准测试,对比C++和Rust。C++代码来自先前的研究论文,并经过了良好优化。我们测试了多种变体:

  • C++(使用restrict关键字)
  • C++(不使用restrict
  • Rust(安全、符合语言习惯的高级代码)
  • Rust(使用原始指针的类C风格代码)

所有代码都通过自动微分工具处理。如果不考虑微分,它们的原始性能差异很小(在1-2%以内)。性能差异主要体现在计算导数时。

以下是关键发现:

  1. FFT基准测试:两个Rust版本(安全和原始指针)性能相同(0.54秒),而C++版本稍慢(0.64秒)。
  2. 需要边界检查的案例:在某些情况下,Rust的安全版本会进行边界检查。通过调用不安全的 swap_unchecked 函数来绕过检查,可以获得约 40% 的性能提升。这反映了LLVM目前在某些情况下还无法成功消除边界检查。未来LLVM的改进有望解决这个问题。
  3. 最显著的差异:在一个基准测试中,安全的、符合习惯的Rust代码性能最佳。其次是使用了所有restrict限定符的C++代码。而将Rust写成类C风格(使用原始指针)会导致 10倍 的性能下降。同样,C++如果移除所有restrict限定符,也会有 4倍 的性能惩罚。

自动微分与Rust性能:第5章:为何unsafe可能导致性能下降

上一节我们看到了性能对比数据,本节中我们来深入探讨为什么在自动微分场景下,不安全的代码反而可能更慢。

自动微分的工作原理是“镜像”原函数。它首先运行一遍原始代码(前向传播),然后以相反顺序再次运行(反向传播),从而使函数长度大约翻倍。

在这个过程中,工具需要缓存那些在后续计算导数时需要的、会被覆盖的变量。以经典的矩阵乘法C = A * B为例,矩阵C是会被覆盖的。

问题的核心在于指针别名分析。如果没有信息表明指针ABC指向不同的内存区域,自动微分工具就必须做最坏的假设:每次写入C时,也可能写入AB(例如,当三个指针指向同一地址时)。这导致工具需要缓存几乎所有变量,造成巨大的性能开销。

Rust的所有权系统和借用规则在编译时提供了强大的别名保证。安全的、符合习惯的Rust代码天然地向编译器(以及基于LLVM的自动微分工具)传达了“这些引用不会别名”的信息。而使用原始指针的Rust代码或未使用restrict的C++代码则丢失了这些信息,导致性能大幅下降。

C++的restrict关键字和Rust的安全引用都是向工具传递“无别名”信息的方式。因此,安全的Rust代码通过其类型系统,为优化和自动微分提供了宝贵信息,从而可能获得更好的性能


自动微分与Rust性能:第6章:挑战与正确性考量

上一节我们解释了安全代码的性能优势原理,本节中我们来看看在Rust中实现自动微分面临的一些挑战和正确性问题。

首先,编译时间是一个重要考量。自动微分需要精确的类型信息。例如,复制64字节的内容和复制8个double类型(也是64字节)在微分意义上是不同的。如果工具不知道具体类型,就可能计算出错误的导数。

C++有TBAA(基于类型的别名分析)来提供部分信息。Rust目前没有等效的TBAA,因此我们需要通过其他方式(如分析Rust的MIR中间表示)来获取和传递类型信息。

关于正确性,由于自动微分工具直接在LLVM IR级别操作,需要特别注意Rust的一些运行时特性:

  • 动态大小类型:如Vec。自动微分工具生成的代码需要正确处理其长度信息,否则可能导致内存越界。解决方案通常是在函数入口处添加边界检查。
  • 可变性:LLVM级别的工具不总是清楚哪些内存是可变的。Rust的&mut&引用在LLVM IR中布局相同,但含义不同。我们需要确保不会向只读内存写入梯度值。
  • 枚举:枚举的类型在运行时决定。不当的类型转换或处理可能导致未定义行为。

这些挑战正在被积极解决,目标是确保自动微分在Rust中既高效又安全。


自动微分与Rust性能:第7章:总结

本节课中我们一起学习了Rust中自动微分与性能的关系。

我们首先澄清了关于unsafe代码的误解,指出它并不改变编译模型。接着,我们介绍了自动微分的基本概念及其在高性能计算中的重要性。

通过具体的基准测试,我们发现安全的、符合习惯的Rust代码在自动微分场景下通常优于或不逊于使用unsafe或类C风格的代码。这主要归功于Rust类型系统提供的丰富别名信息和保证,使得基于LLVM的优化工具能够进行更有效的分析。

我们探讨了性能差异背后的原因,即指针别名分析在自动微分缓存机制中的关键作用。最后,我们也审视了当前实现中在类型信息、编译时间和运行正确性方面面临的挑战。

核心结论是:充分利用Rust的安全抽象和类型系统,不仅能保证内存安全,还能为编译器优化(包括自动微分)提供关键信息,从而在默认情况下获得出色的性能。开发者应优先编写安全、符合习惯的Rust代码,仅在必要时,并有充分理由时,才谨慎地使用unsafe

067:基于MLIR的对抗性JavaScript检测

概述

在本节课中,我们将学习JSIR,一个基于MLIR的JavaScript中间表示,专门用于检测对抗性代码。我们将探讨其设计目标、核心概念以及如何实现从源代码到IR再完整返回源代码的“往返”过程。


背景:无处不在的JavaScript与恶意代码检测

JavaScript无处不在。它出现在网页的HTML中,出现在使用React Native等跨平台框架构建的移动应用中,也出现在浏览器扩展里。谷歌在这些领域都有业务,因此投入了大量工程精力来检测各种平台上的恶意代码。

例如,面向Android的Google Play商店有一个名为“Play Protect”的功能,它会在你下载应用前对其进行扫描。这些检测系统的很大一部分工作在于分析代码,以发现恶意或可疑行为。这些行为信号随后会被输入到其他系统中,以做出整体决策,例如禁止该应用或显示警告标志。

因此,我们的目标是分析JavaScript代码以发现恶意行为。


恶意行为示例

那么,这些恶意行为具体是什么样的呢?让我们来看两个例子。

示例一:隐写术

隐写术,又称隐蔽书写,意味着将信息隐藏在别的东西里。这里的“别的东西”可以是任何形式,如消息、图像甚至视频。

我们展示的这段代码首先从图像中加载数据。这些数据实际上只是一个代表每个像素颜色的数字数组。代码随后通过多个步骤操作这个数组。我们大致可以看出,它正在逐步构建一个名为message的字符串变量。最后,这个字符串被传递给eval函数,该函数将字符串视为一段代码并执行它。

我们现在明白了,这里展示的代码实际上是一个解码器,用于解码隐藏在图像中的某些恶意代码。攻击者这样做是为了试图规避检测系统。

隐写术实现起来并不难,GitHub上也有工具可以帮你完成。你只需上传一段文本和一张图片,该工具就会生成一张看起来与你上传的图片几乎相同的新图片,但文本就隐藏在其中。该工具还会提供一个库,用于从生成的图像中提取和解码文本。

我们对隐写术的解决方案是污点分析,这是一种数据流分析,旨在发现恶意的信息流。在这个例子中,确实存在从getImageDataeval的信息流。

污点分析的工作原理如下:我们从开始(在本例中是名为getImageData的函数),然后逐步找出代码中所有被“污染”(即受源影响)的变量。最终,我们发现一个被污染的变量被传入了eval函数。

污点分析是一种数据流分析,通常在中间表示上进行。我们在控制流图上运行迭代算法,直到结果收敛。理想情况下,IR应基于SSA,这能使算法更优化。

示例二:代码混淆

混淆是故意使代码变得更复杂的行为。这个例子使用了一种特定的混淆技术,称为字符串分割

我们可以想象,如果攻击者想将用户重定向到恶意网站,他们可以将URL分割成几部分,使其更难被发现。同样,混淆也不难实现,因为也有现成的工具可以做到。

显然,我们的目标是反混淆代码。在这个特定例子中,我们需要执行常量传播来合并这些字符串片段,以恢复原始字符串。

常量传播也是一种数据流分析,在编译器优化中非常常见。然而,与典型编译器在IR上执行这些优化然后将IR转换为机器码等低级表示不同,在我们的用例中,我们实际上希望输出简化后的JavaScript代码。换句话说,我们需要源到源的转换。这是因为混淆很大程度上被逆向工程师使用,他们需要手动阅读代码来识别恶意行为。


设计挑战与JSIR的引入

现在我们看到了一些相互冲突的需求。一方面,我们需要进行分析以提取信号,以便分析结果可用于基于规则的决策引擎和机器学习分类器等。另一方面,我们需要执行源到源转换来简化代码,以协助逆向工程师、分析师和人工审查员。这两种用例都涉及数据流分析,因此我们需要一个IR和一个CFG来执行这些分析。

但源到源转换似乎又表明我们不需要低级表示,也许我们只需要将自己限制在抽象语法树上,因为我们当然可以从AST生成JavaScript代码。

这就引出了JSIR,一个精心设计的JavaScript IR,可以完全提升回AST。JSIR的目标是,我们不仅能执行数据流分析,还能实现从JavaScript源代码到AST,再到IR,然后回到AST,最终回到源代码的完整往返过程。这样,在IR上进行一些转换后,我们可以生成反映这些转换的JavaScript源代码。而对于IR中未更改的部分,我们可以生成原始源代码。


JSIR设计之旅:实现往返的三个关键点

带着这个目标,现在让我们来了解JSIR,特别是讨论我们为实现往返而遇到的三个设计问题。

设计问题一:从线性IR恢复表达式结构

让我们从一个相当简单的例子开始。在左侧,我们有一个表达式:1 + 2 + 3。在中间部分,我们有IR,它以线性方式列出计算,并将每个中间结果分配给一个SSA值。

我们如何将这个IR提升回源代码呢?如果我们将每个SSA值映射到一个变量,那肯定会生成与原始源代码不同的东西。更重要的是,它的可读性更差,这违背了代码简化的目的。

那么,我们在这里怎么做呢?让我们看看IR中的最后一个操作:expression statement。它并没有真正捕获原始代码的语义,但我们在从AST生成IR时仍然保留了它。

这个操作的有趣之处在于,如果我们递归地追踪IR中的使用链,我们会发现它开始看起来像一个AST。我甚至可以更进一步:实际上,每个IR操作与一个AST节点之间存在一一映射关系。

通过以这种方式设计JSIR来保留AST的所有信息,往返过程听起来就合理多了。但有些问题我们无法完全绕过。要让这种递归遍历工作,我们需要确定开始遍历的根操作。在这个例子中,根操作是expression statement

我们设计JSIR时,让这些根操作变得明确且易于查找。例如,我们知道一个JavaScript代码块由一系列语句组成,每个语句包含表达式。因此,在JSIR中,我们定义了一个特质来标记某些操作为语句(例如本例中的expression statement操作),这样它们就成为递归遍历的根操作。

设计问题二:显式表示变量

在之前的例子中,我们展示了IR中的SSA值不会在源代码中被提升为变量。这意味着,如果我们确实想在IR中表示变量,就需要显式地去做。

在这个例子中,我们有 a = a + 1。在IR中,我们使用专用的操作来表示它们:左值用 identifier_ref 表示,右值用 identifier 表示。其余部分与前面的例子相同。现在我们基本上仍然有从IR到AST的一一映射。

不过有一个注意事项。从技术上讲,如果我们只关心往返过程,我们实际上不需要区分 identifieridentifier_ref。我们引入这种区分是为了在IR中更准确地捕获语义,这将使数据流分析更容易进行。

设计问题三:处理控制流结构

当我们有控制流结构时,事情开始变得棘手。在这个例子中,我们有两层if语句。MLIR为我们提供了两种表示控制流结构的方式:要么使用区域,要么使用控制流图

区域准确地捕获了嵌套结构,就像AST一样,所以如果我们使用区域,可以将IR提升回AST,这并不奇怪。另一方面,CFG明确表示了较低层次的分支行为,更适合运行数据流分析算法。但我们似乎丢失了原始的嵌套结构。

然而,CFG的结构确实保留了原始结构的一些痕迹。在这个例子中,为了表示一个if语句,我们从一个条件分支开始,它分裂成两条代码路径,两条路径都以无条件分支合并在一起。内部的if语句以相同的方式表示,我们只是用一个具有相同结构的子图替换了那个块。

通过以这种二维格式布置块,我们可以看到每个块映射到原始代码中控制流结构的一部分。我们实际上可以指向一个块说:“嘿,这个块映射到外部if语句的true分支”,或者“那个块映射到内部if语句的false分支”。

那么,如果我们只是在IR上添加注解,使这种映射变得明确呢?更具体地说,在每个块的开头,我们添加一个描述该块代表什么的注解操作。此外,我们可以用一个令牌将同一控制流结构的注解分组。

在这个例子中,我们有两个令牌:一个用于外部的if语句,一个用于内部的if语句。这些令牌和注解使我们能够递归地遍历CFG,就像遍历AST一样。当我们遇到令牌F1的创建时,我们可以追踪它的使用-定义链,以定位所有将此令牌作为参数的注解操作。从那里,我们定位到映射到if语句各个部分的所有块。

这个设计有效吗?是的,我们已经测试了超过50亿个JavaScript样本,其中绝大多数都成功实现了从源代码到AST到IR,再回到仅有格式差异的相同源代码的往返过程。

另一个证明此设计有效的证据是,我们还有一个JSIR的内部用例,它将React Native字节码反编译为JSIR,然后一路反编译为JavaScript源代码。根据我们上次在字节码文件数据集上的检查,大约70%的文件可以反编译为源代码,失败大多是由于内存不足崩溃。


关于控制流表示的说明

很明显,基于区域的表示更具可读性和直观性。那么,是否有可能直接在这种表示上进行数据流分析,而不是基于CFG的表示呢?

MLIR数据流框架确实能识别区域之间的分支行为,所以我们实际上已经很接近了。唯一剩下的问题是找到一种方法来建模breakcontinue语句,这些是不寻常的分支行为,例如跳出两层区域。

我们看到Mojo已经在一定程度上实现了这一点,并且有一个RF(请求)旨在将这种解决方案的一个版本上游化。我们非常期待这一点,因为这样我们实际上可以完全摆脱基于CFG的表示。


总结与要点

本节课我们一起学习了JSIR的设计与应用。让我们总结一下本次讨论的要点:

  1. 恶意JavaScript是一个普遍存在的问题。谷歌关心这个问题,我们开发了系统并投入人力来解决这个领域的问题。总的来说,编译器与安全的结合是一个非常有趣的领域,我们可以产生很大的影响。

  2. 我们的经验进一步证明,MLIR足够强大,可以表示像JavaScript这样的通用语言。我们希望我们的项目以及类似的项目(如Kang IR)能给大家更多信心,让人们能够发现MLIR的新用例。

  3. 正如我们在本次讨论中描述的,设计一个可以完全转换回AST的IR实际上是可能的


开放性问题

最后,我想以一些开放性问题结束本次讨论。

  1. 我们能看到一个行业趋势,即构建特定于高级语言的IR。那么,未来我们是否真的不再需要AST了?我们可能仍然想在解析期间构建某种解析树,但我们不需要在AST上做任何分析或转换,而只会在高级IR上进行所有这些操作。我们看到一些项目正朝着这个方向发展,例如,Mojo编译器似乎会尽快进入MLIR领域,然后所有分析都在不同的IR上进行。Carbon编译器也尽快将代码转换为高级IR。

  2. 实际上已经存在一些基于AST的JavaScript工具框架,例如Babel和ESLint。它们将AST作为公共API公开,使用户可以相当容易地遍历和转换AST。那么,JSIR是否有可能成为基于IR的JavaScript工具框架的基础?这样,人们编写数据流分析会更容易。

  3. 你可能不知道,JavaScript实际上有一个标准的AST规范,叫做ESTree。流行的JavaScript工具框架基本上都基于ESTree,只有一些微小的变化。那么,是否也有可能提出一个JavaScript IR标准呢?

这些都是我们正在思考的问题。同时,欢迎大家查看我们今天刚刚发布的GitHub仓库,我们仍在向仓库中发布更多组件,敬请关注。


问答环节

:感谢精彩的演讲。我想知道,你们如何处理像循环这样的结构?因为它们不像if语句那样有统一控制流,对吧?因为还有breakcontinue

:是的,实际上,当我们使用基于CFG的表示时,循环的表示方式与if语句类似,我们只是有更多的注解,并且它在代码库中确实有效,你可以实际查看一下。我确实认为带有注解的CFG表示不是很直观,而且相当难用,因为当你在这种表示上进行一些分析后,你希望将分析结果导出到其他表示。所以,如果我们统一到基于区域的表示上,那么所有事情都发生在一个表示上会更容易。至于如何使用基于区域的表示来表示breakcontinue语句,就像我说的,有一个RF(请求)实际上在做这件事,这需要修改MLIR上游,你还需要修改数据流框架来实际捕获这种分支行为。我认为有一个关于Mojo的演讲,其中详细介绍了他们是如何做到的。希望这至少回答了你的部分问题。

068:在 LLVM 中支持新的调用约定 🚀

在本节课中,我们将学习如何在 LLVM 中添加一个新的调用约定。我们将以 RISC-V 向量扩展为例,详细介绍其概念、栈布局以及在 LLVM 后端中的具体实现步骤。

什么是调用约定? 🤔

调用约定是调用者与被调用者之间的一种协议。它定义了一系列规则,使两者能够顺畅地通信。在物理机器中,没有一条核心指令直接包含参数。两个子例程之间传递参数的方式是通过寄存器或内存,而传递参数的规则正是由调用约定定义的。

例如,假设我们有一个执行乘法和加法的函数。在调用乘法子例程时,我们需要将参数 A 和 B 放置在寄存器或内存中的某些位置。然后,乘法子例程从相同的寄存器或内存中读取这些输入以执行计算。这些规则都由调用约定定义。调用加法函数时也是如此。

调用约定还定义了用于返回值的寄存器集合。当返回到调用者时,双方都需要知道使用哪个寄存器或内存来传递返回值。

在过程调用期间,某些寄存器可能会被覆盖。在使用寄存器后,我们需要确保数据的一致性。因此,调用约定还定义了一组需要由调用者或被调用者保存的寄存器。

RISC-V ABI 与栈布局 📚

RISC-V 有三种不同类型的数据寄存器:整数寄存器、浮点寄存器和向量寄存器。

首先,有 32 个整数寄存器,命名为 x0 到 x31。每个寄存器都有一个别名,以便用户更容易识别其用途。我们今天重点关注的寄存器是参数寄存器和调用者保存寄存器。

还有 32 个浮点寄存器,其中 12 个是调用者保存寄存器,8 个是参数寄存器。

在深入栈布局之前,让我们先了解一些关于 RISC-V 向量的背景知识。

RISC-V 向量是一种可伸缩向量,其长度由具体硬件定义。RISC-V 有 32 个向量寄存器,这与 Intel 的 AVX 和 Arm 的 SVE 相同。每个寄存器的宽度是 vlen 位。RISC-V 向量还有一个名为 vlenb 的 CSR,用于保存以字节为单位的向量长度。例如,如果向量长度为 128 位,那么 vlenb 就是 128 除以 8,等于 16 字节。RISC-V 还有一个向量 vtype CSR,用于记录配置信息,如计算策略和向量寄存器组乘数等。

下图说明了标准调用约定和向量调用约定在向量寄存器安排上的差异。

我们需要为向量定义另一个调用约定的原因是,在某些应用程序或库中,它们并不大量使用向量寄存器。另一方面,向量加载和存储指令的延迟很高。因此,最好为其他用途提供另一种选择。

上图是标准调用约定,下图是向量调用约定。这两种调用约定的主要区别在于调用者保存的寄存器。在标准调用约定中,所有向量寄存器都是调用者保存的,这意味着调用者有责任在过程调用期间保持寄存器状态。然而,在向量调用约定中,V1 到 V7 以及 V24 到 V31 是被调用者保存的。只有在需要占用其中任何一个寄存器时,它们才会被保存。

每个函数在调用期间维护自己的内存空间,这被称为调用栈。

以下是 RISC-V 的栈布局。可以看到,总共有五个部分。从上到下,分别是:为可变参数完全分配的区域、可伸缩的被调用者保存寄存器、可伸缩的局部变量、RVV 对象和可变大小对象。我们将在接下来的幻灯片中详细描述它们。

首先,我们有标量被调用者保存寄存器和标量局部变量。假设我们有一个声明了局部变量的简单函数。代码的汇编位于中间块。块中的第一行向下调整栈以保存所有寄存器。然后,我们从 sp-16 开始保存标量被调用者保存寄存器,从 sp-20 开始保存标量局部变量。相对位置显示在最右侧的图片中。

接下来,我们添加 RVV 局部变量。其大小通过读取 vlenb CSR 并乘以 2 来计算。它被放置在标量局部变量正下方。需要提及的一点是,RVV 对象部分不仅包含向量局部变量,还包含向量被调用者保存寄存器。被调用者保存寄存器被放置在向量局部变量的正下方。

然后,我们有可变参数。在这里,无论是否使用,我们都会将所有参数保存在预分配的区域中。这不仅是为了访问简单,也是为了释放这些寄存器供寄存器分配器使用,以降低压力。

下一部分是可变大小对象。某些局部变量的大小在编译时是未知的,例如,大小在其他对象中定义的数组。我们需要从符号表中获取变量来计算确切的大小,并且该大小需要按 128 字节对齐,这在规范中有规定。因此,这里是紫色框中的代码片段。为了在运行时计算确切的大小,首先从符号表中读取变量,然后左移 2 位,因为它是整数类型,即 4 字节。然后加上 15,接着使用 and 指令确保偏移量正确对齐。

以上就是 RISC-V 调用栈布局的五个部分。

实现细节 🔧

要启用调用约定,我们需要一个属性来将信息从前端传递到后端。需要修改一些文件才能使其工作。

第一个文件是 Attributes.td。该文件收集了 RISC-V 向量调用的所有不同方面的属性。我们需要为声明和函数类型定义一个属性。C 或 C++ 中的关键字以及其他前端信息也在此部分描述。

我们还需要在 Specifiers.h 中定义一个枚举。这用于在客户端站点为后端之前的所有后续函数保存信息。

我们还需要一种方法将调用约定属性从前端映射到后端。在 Clang 代码生成中,我们有一个名为 clangCallConvToLLVMCallConv 的函数来执行映射工作。

我们还需要在 CallingConv.h 中定义一个枚举。这用于将信息从 LLVM IR 一直保存到机器代码。

LLToken.h 文件定义了 LLVM IR 中的向量调用约定关键字。该标记将通过属性传递,并在 LLVM 中转换为相应的调用约定枚举。

相反,AsmWriter.cpp 为每个调用约定枚举在 LLVM IR 中生成相应的关键字。

现在,我们知道了如何在 Clang 和 LLVM 中添加调用约定的属性关键字。我们也能够让解析器识别 LLVM IR 中的调用约定关键字。让我们看一个测试用例,看看我们在之前的幻灯片中已经完成了什么。

在上图中,我们有一个带有 riscv-vector-callbar 函数声明。然后我们在 test 函数中调用 bar 函数。通过运行命令 clang -target riscv64 -S -emit-llvm,生成的 LLVM IR 显示在下图中。你会看到 riscv-vector-call 属性已正确地从前端降级到 LLVM IR,稍后将被选择 DAG 用来确定降低调用约定的机制和寄存器集合。

接下来,我们将讨论向量寄存器的 RVV 特定概念。

第一个概念是 LMUL,代表向量组乘数。例如,最左侧的图像说明了 LMUL 等于 1,这意味着数据只占用一个向量寄存器。然而,在中间的图像中,LMUL 等于 2,这意味着数据占用两个向量寄存器。这是 RISC-V 的灵活性之一,用户可以完全控制向量寄存器资源。

第二个概念是 NF,是段中字段数量的缩写。这基本上用于需要多个寄存器组连续的段加载/存储指令中。

在接下来的幻灯片中,我们将使用 RVV 类型来表示仅包含 LMUL 的向量类型,并使用 vector tuple 类型来表示同时包含 LMULNF 概念的向量类型。

对于上一张幻灯片中的这两种向量类型,也存在约束。对于 RVV 类型,我们有一组寄存器。第一个约束是起始寄存器应该是 LMUL 的倍数。第二个约束是组中的所有寄存器应该是连续的。在示例 1 中,我们有 LMUL 等于 2,并且我们有 V2 和 V3 用于此类型。这是有效的,因为起始寄存器 V2 是 2 的倍数,并且 V2 和 V3 也是连续的。然而,在示例 2 中,尽管 V1 和 V2 是连续的,但起始寄存器不是 2 的倍数。相反,示例 3 是无效的,因为组中的寄存器应该是连续的。

vector tuple 类型的约束基于 RVV 类型,并附加了一个约束,即所有组都必须是连续的。换句话说,所有 NF 乘以 LMUL 个寄存器都应该是连续的。在示例 4 中,该类型的 LMUL 等于 2,NF 等于 2,因此起始寄存器应该是 2 的倍数,并且所有四个寄存器都应该是连续的。然而,在示例 5 中,它是无效的,因为它打破了所有组的连续规则。

一个重要的问题是,我们如何建模 RVV 类型和 tuple 类型?对于 RVV 类型,我们已经在 LLVM 中使用 vscale 来建模它。例如,假设我们有 <vscale x 1 x i64> 来表示 LMUL 等于 1 的 i64 RVV 类型。它被建模为 vscale * 1 * i64。由于 RISC-V 中每个块是 64 位,这与我们如何建模 SVE 相同。

然而,目前没有现有的机制来建模 vector tuple 类型。假设我们有一个类型 <vscale x 2 x i64> x 4,其 LMUL 等于 2,NF 等于 4。一种可能的方法是通过与 RVV 相同的方式来建模它,可能类似于 vscale * 8 * i64。然而,这种方法在类型 <vscale x 2 x i64> x 4<vscale x 4 x i64> x 2 之间会产生歧义,因为向量寄存器的总数是相同的。所以我们无法识别它是 LMUL=2, NF=4 还是 LMUL=4, NF=2。SVE 也有 NF 概念,但它没有 LMUL。所以当前的机制对 SVE 来说已经足够了。

最初,我们使用结构体类型来表示 vector tuple 类型,其中 NF 映射到结构体中的元素数量,每个元素是具有指定 LMUL 的可伸缩向量类型。例如,一个 LMUL=2NF=4vector tuple 类型被表示为一个具有四个元素的结构体,每个元素是一个 LMUL=2 的可伸缩向量。然而,这种方法在选择 DAG 中存在潜在问题,结构体类型被扁平化为其元素类型,其中组信息丢失。因此,不可能推断出我们需要的确切类型。我们需要一种新的类型来处理 RISC-V 中的 vector tuple

幸运的是,LLVM 有一个内置类型称为目标扩展类型,它具有类型参数和整数参数。在 vector tuple 类型中,我们使用类型参数来编码 LMUL 信息,使用整数类型来编码 NF 信息。LMUL 信息由可伸缩向量类型表示,其基本元素是整数类型。NF 直接由其值表示。

很明显,RVV 类型可以直接降级为 SVE 引入的可伸缩向量 MVT。然而,在 RISC-V 中,我们为 vector tuple 类型引入了新的 MVT

在指令选择期间,目标扩展类型被转换为其对应的 MVT 类型,其中包含 LMULNF 信息,但消除了对后端来说无关紧要的元素类型信息。每个 MVT 也有其寄存器类,每个寄存器类映射到一组由我们刚刚讨论的规则构造的寄存器。

在接下来的几张幻灯片中,我将向您展示如何在后端处理向量 RISC-V 向量调用约定。

在我们继续之前需要提及的一点是,除了与调用约定相关的寄存器外,所有寄存器都由通用寄存器分配器分配。我们必须在每个后端中实现规则,为输入参数和返回值指定物理寄存器。

因此,我们首先需要定义由被调用者管理的寄存器集合,也称为被调用者保存寄存器。在 RISC-V 向量调用约定中,我们有 V1 到 V7 以及 V24 到 V31 在被调用者寄存器集合中。我们还需要添加超级寄存器,例如 V2M2V4M4V24M8 等。因为不同的寄存器组使用不同的指令进行加载/存储。例如,要仅存储单个 V2 寄存器,我们使用 vs1r,这意味着存储 1 个寄存器。然而,要存储 V2M2(即 LMUL 等于 2 的 V2),我们需要 vs2r 来处理它。如果我们不将 V2M2 添加到寄存器集合中,后端会将 V2M2 分解为 2 个 LMUL 等于 1 的向量寄存器,并使用 2 个 vs1r 来存储它,这不是预期的,也不优化。

然后,我们需要实现目标钩子 getCalleeSavedRegs,以确定函数中的 live-inlive-out 寄存器,告诉调用者是否存储这些寄存器。在这种情况下,我们不需要调用者保存和恢复这些寄存器。只有被调用者在使用其中任何一个寄存器时才需要保存和恢复它们。

这是一个实用函数,用于收集需要在调用中保存的所有被调用者保存信息。在溢出和重加载之前,我们需要为需要在调用栈中保存的所有寄存器分配一个偏移量。我们还需要对齐每个帧。在我们的例子中,对于 RVV 寄存器的字节对齐,它具有分数 LMUL,这意味着数据将只占用向量寄存器的一部分。然而,为了简单起见,我们总是希望对象大小至少为一个向量寄存器,在 RV 建模中是一个字节。需要提及的一点是,我也在致力于一项优化,尝试使向量寄存器溢出只存储有效数据。换句话说,如果它只占用寄存器的一部分,我们不一定需要使用完整的寄存器存储指令,我们可以使用其他开销较低的指令代替。另一个好处是,如果它不使用剩余的空间,我们不需要在调用栈中为这些对象分配完整的向量寄存器大小。

这是发出实际指令以溢出寄存器的目标钩子。同样,我们也有一个用于从栈槽恢复寄存器的重加载/恢复目标钩子。

让我们看看我们做了哪些更改。这是一个简单的测试用例,其中有一个内联汇编调用,带有两个被调用者保存寄存器 V1 和 V8。在标准调用约定中,调用者负责在过程调用期间保持向量寄存器状态,因此 %v 最初放置在 V8 中。当它进入函数 test_vector_standard 后,在内联汇编之后,它需要确保数据一致,所以我们可以看到在调用前后有一个寄存器移动指令,充当溢出和重加载的角色。

如果我们对函数应用调用约定关键字 riscv-vector-call,V1 就变成了一个被调用者保存寄存器,需要由 test_vector_callee 在调用子例程时保存。因此,在红色框中有另外几行代码处理向量寄存器的溢出。

一旦为每个类定义了寄存器集合,我们需要定义另外三个目标钩子:lowerFormalArgumentslowerReturnlowerCall。基本上,我们只需要使用 CCState 来帮助完成这些工作。我将在接下来的幻灯片中向您展示寄存器集合。

这是 RVV 寄存器集合。我们需要定义所有包含从 V0 到 V23 的每个 LMUL 的寄存器。所以我们有 LMUL 1、2、4 和 8。对于 vector tuple 类型,NF 信息使用下划线分隔,并且也在同一个文件中定义。

有时,参数数量大于寄存器数量,这意味着有些参数没有被分配到物理寄存器。对于那些尚未处理的参数,我们需要通过引用来传递它们,这基本上是将数据存储到栈中,并使用通用寄存器传递栈地址。

因此,在第一个测试用例中,我们可以看到 %x%y 分别通过 V8M8V16M8 传递,正如我们所期望的。然而,在第二种情况下,它用完了寄存器,所以 %x1%y1 通过寄存器传递,而 %x2%y2 应该通过引用传递。红色框中的四条寄存器加载指令用于从栈中读回它们。

相同的机制适用于 vector tuple 类型。寄存器的定义已经建模了约束,因此我们可以以相同的方式处理它们。对于 vector tuple 类型用完寄存器的情况也是如此。

总结 📝

今天,我们简要回顾了调用约定,并介绍了 RISC-V 向量 ABI。我们学习了 RISC-V 栈布局,最后深入探讨了实现的细节,包括添加函数属性、处理函数参数以及处理调用者保存和被调用者保存寄存器。

069:使用MLIR创建解析器生成器 🛠️

在本教程中,我们将学习MLIR中的语法方言。我们将探讨其设计动机、核心概念、如何用于词法和语法分析,以及它如何利用传统编译器技术来优化形式文法。本教程旨在让初学者能够理解这一高级主题。

动机:为什么需要语法方言?🤔

上一节我们介绍了本教程的主题。本节中,我们来看看创建语法方言的动机。

在我的论文中,我希望探索更模块化的编译器和语言。这引导我去研究动态解析组合子。

例如,在右侧的代码中,主编译器无需了解代码中的OMP、GPU或MLIR是什么。我需要一种方式,在遇到特定关键字时,能够调用相应的解析组合子。

基本上,这就是我需要此工具的主要动机,因为现有工具无法提供这种灵活性。因此,我最终构建了一个。

语法方言:形式文法的高级描述 📝

上一节我们了解了动机,本节中我们来看看语法方言本身。

语法方言是对形式文法的一种高级描述。如果你看右侧的图表,你会看到传统的元素,如终结符、或运算、与运算等。它能够表示所有传统的表达式。

但它也包含一些高级语法特性,例如语法上的函数。你可以创建类似宏的东西来交织语法。例如,如果你使用LLVM核心库,你会遇到interleave commainterleave。这实现了相同的功能,你可以定义一个通用函数并用它来创建新的表达式。

该方言本身也包含专用于词法和语法分析的运算,我们稍后会看到。

在右侧的列表中,我们还展示了它们在MLIR内联后的变化,这只是简单地实例化了宏函数。这对于实现像interleave这样常见的功能非常有帮助。这意味着你只需定义一个interleave函数就可以开始使用。

设计:纯运算与常量式运算 ⚙️

上一节我们介绍了语法方言的能力,本节中我们深入其设计原则。

在设计方面,大多数运算要么是纯的,要么是常量式的。在MLIR术语中,这意味着它们没有副作用或行为类似常量。这使得我们可以使用CSE(公共子表达式消除)来优化冗余。

例如,在右侧列表的顶部,有一个包含冗余的规则:A B | A B C,这意味着你计算了两次A B。在底部,通过CSE消除了这种冗余。

因此,我们可以使用传统的优化技术来优化形式文法。

词法分析:从高级描述到DFA 🔤

上一节我们讨论了设计优化,本节中我们看看如何实现词法分析。

在词法分析方面,我创建了一个TableGen后端来生成语法分析器。这意味着该项目完全依赖于LLVM和MLIR。

它从一个相当高级的TableGen描述开始,例如定义一个标识符的规则,然后生成完整的MLIR代码。接着,这些MLIR代码被分析和优化,并最终翻译成C++。

在实现上,它们只是实现了传统的确定性有限自动机算法。

以下是词法转换的步骤:

  1. 我们首先进行内联和规范化,并应用CSE,因为我们也进行了一些规范化来消除冗余。
  2. 然后,我们开始将IR转换为DFA。此时的DFA并非最小化,但已包含创建词法分析器所需的所有元素,包括状态和转换。
  3. 接着,我们执行传统的DFA最小化。
  4. 最后,我们用它来生成C++代码。

这个过程虽然直接,但好处在于你可以修改这些降级过程,从而根据需求进一步定制,这也是创建这一切的主要目的。

语法分析:动态解析组合子与灵活性 🔄

上一节我们完成了词法分析,本节我们转向语法分析。

对于语法分析,我们同样创建了一个TableGen后端。右侧列表展示了一个我们编译器需要或想要的功能:动态解析组合子。这在第4行显示,#parse是一个宏,意味着#parse表达式内部的表达式将通过解析宏处理。#parse表达式本质上是调用动态解析组合子,这允许在语法中实现可扩展的语法。

总的来说,在解析方面引入新想法没有限制。这意味着所有这些都非常灵活,可以开始融入新的思想。

从图中也可以看到,它支持指定在某段代码后执行特定代码块。这对于处理传统语法分析器不支持的功能非常有用。

就我而言,我对生成解析表达式文法感兴趣,而不是传统的LR或LALR分析器。如前所述,它原生支持动态解析组合子,因此用它来创建新代码的解析器相当直接。

语法转换:优化与性能提升 🚀

上一节我们介绍了语法分析的灵活性,本节我们看看其转换和优化过程。

在语法转换方面,我们从TableGen代码开始。这是一个相当标准的解析表达式文法,例如A | B | A C | C D。我们首先执行内联、规范化和应用CSE,以消除冗余、摆脱所有宏,并形成一个连贯的语法。

之后,我们分析文法并开始优化。如果你了解解析表达式文法,它们本质上是递归下降解析器。如果使用Packrat解析,它们可能是线性时间,但意味着更大的内存占用。我们发现实际上可以进一步优化,因为在许多情况下,你可以做出局部决策,判断何时不需要回溯到先前状态。

例如,这在IR中通过switchany表达式实现。初始解析规则是A | B | A CC D。分析确定,你可以根据第一个标记完全决定走哪个分支。switch可能转到第15行的any(表示需要回溯),也可能转到第14行的and运算。

这意味着我们可以做出局部决策,移除一些可能不需要的回溯,从而最终提高性能。

未来工作:即时编译与自扩展语言 🔮

上一节我们探讨了性能优化,本节我们展望未来的可能性。

未来工作中,我最感兴趣的部分是能够即时编译这个方言。这意味着我们有一个文法,可以直接将其转换为LLVM IR,然后使用执行引擎立即执行。

在这种情况下,我们可以让LLVM即时优化正则识别器。如果你有一个需要多次执行识别查询的系统,这可能会节省时间。

我特别感兴趣的另一点是自扩展编程语言的可能性。如果你能将语法方言即时编译,那么你只需要在顶部放置一个用于创建新语法的模块,然后就可以在以后重用该函数来解析新的语法,并且语法是在同一语言中描述的。

目前,即时编译的最大障碍是,为此创建的所有运行时都严重依赖C++,例如std::optional。需要将其转换为更友好的C代码,才能真正实现即时编译。

总结 📚

本节课中,我们一起学习了MLIR语法方言。

总结来说,通过语法方言,我们可以创建和分析词法分析器及语法分析器。我们发现,这意味着我们可以应用传统的编译器技术来优化形式文法,这最终证明了MLIR作为基础设施在广义编译中的实用性。

070:一种数据驱动的调试信息质量提升方法

在本节课中,我们将学习一种通过数据驱动的方法来提升编译器优化后代码的调试信息质量。我们将探讨调试优化代码的必要性、当前面临的问题,并详细介绍一种名为“变量丢失统计”的技术,该技术能帮助定位和修复编译器优化过程中丢失调试信息的问题。

为什么需要调试优化后的代码?

上一节我们介绍了课程主题,本节中我们来看看为什么需要调试优化后的代码。主要有三个原因。

以下是具体原因:

  1. 嵌入式编程:我们希望在这些领域停止使用像C语言这样不安全的语言。但未优化的代码对于资源受限的目标设备来说通常过于庞大,因此即使在调试时,也必须使用优化后的构建版本。
  2. 崩溃日志分析:当我们收到生产环境构建版本的崩溃日志时,仍然希望能够调试发生了什么。
  3. 复杂程序:在未优化的编译器中进行调试会变得非常缓慢。因此,我通常更倾向于调试优化后的构建版本来提高速度。

调试优化代码面临的问题

然而,调试优化后的代码并不可靠。单步执行不完美,变量常常不可用。在Swift中,有时变量甚至根本不存在。调试C++时,我经常需要向上或向下移动几个栈帧才能看到变量的值。信息就在编译器的某个地方,但我们为什么无法跟踪它呢?

我相信变量不可用的情况在许多情况下是可以预防的,我们可以修复这些问题。

问题根源:编译器流水线

那么,为什么会发生这种情况?首先,让我们看一下Swift编译器的流水线。

Swift代码经过抽象语法树(AST),宏会在此修改它并可能改变调试信息。然后,代码进入Swift中间语言(SIL)阶段,优化通道会修改它。接着,代码经过LLVM IR及其优化通道,最后经过机器码IR及其优化通道,最终生成目标文件。

调试信息在每个阶段的表示形式都不同。因此,在降级到下一阶段时,我们可能会丢失调试信息。在每个阶段内部的转换过程中(例如优化通道),我们也可能丢失调试信息。

变量可能在从一个阶段转换到下一个阶段时丢失,但大多数情况下,它们被丢弃是因为优化通道的编写目的是优化代码,而没有充分考虑调试信息。在Swift通道中,调试变量经常被完全移除,因此它们在LLDB中甚至不会显示为“不可用”,而是完全消失。

传统问题定位方法及其局限

今天,我们将重点讨论如何改进优化通道。一种简单的方法是阅读代码并尝试理解发生了什么,但这很困难。更好的方法是使用一个已知的、变量被编译器丢弃的示例,然后通过二分查找来定位变量是在哪个通道丢失的。

一种方法是使用llvm-bisect工具,它可以帮助二分查找LLVM通道。但这需要一个存在长期调试问题的代码样本,因为我们必须告诉它预期的结果。此外,它只适用于LLVM IR,而不适用于SIL。运行整个流水线也可能很慢。

解决方案:变量丢失统计

因此,我今天提出的解决方案是变量丢失统计。那么,它是什么?

这些统计发生在优化流水线内部,即在一个阶段内。今天我将以SIL阶段为例,但对于任何具有优化通道的阶段,原理都是相同的。

在SIL优化器中,有许多通道会各自修改SIL代码。变量丢失统计的工作原理是在每个通道运行之间收集统计信息。

收集统计信息时,我们将获取每个函数内定义的变量列表。然后,对于每个通道,我们比较其运行前后的列表。如果存在差异,则意味着变量被该通道丢弃,我们会报告它。如果一个变量被某个通道添加(例如在内联代码时发生),这是正常的。但是,一旦一个变量被添加,后续的通道就不应该再次丢弃它。

统计中的变量匹配规则

什么构成“可用”?两个变量被认为是相同的,如果在“之前”和“之后”的列表中,它们具有相同的名称、相同的函数内作用域(包括它从哪个函数内联而来)以及相同的源代码位置。

两个相同的变量可以同时出现在同一个函数中。当变量被SROA拆分成不同的分配,或者当一个值被修改时,就会发生这种情况。我们不跟踪这些片段,只跟踪变量是否仍然存在。

应用统计:测试套件分析

对于Swift,我们可以在任何代码库上启用这些统计来运行编译器。我们可以在Swift标准库上运行,因为它对我们编写的所有Swift代码都有影响。但问题是它有点特殊,并不能完全代表Swift开发者通常编写的代码。

因此,我们可以在整个源代码兼容性测试套件上运行这些统计,该套件包含超过200个开源Swift项目,这应该能代表开发者通常的做法。

如果我们在源代码兼容性测试套件上运行统计,会得到这些庞大的数字:大约有270万个变量被不同的通道丢弃。

丢弃变量最多的通道是那些具有许多不同启发式方法的通道,例如SimplifyCFGSILCombine。这说得通。但我们也可以看到SROA名列前茅,这是一个相当简单的通道,本不应该丢弃调试信息。

分析统计结果:合理的变量丢弃

有些变量丢弃是合理的。当一个函数变得不可达时,例如通过死函数消除或死代码消除,删除变量是可以的。同样,当函数内的某个作用域变得不可达时(意味着该作用域在可以设置断点的任何指令上都不可见),丢弃变量也是可以的。此外,标准库定义了透明函数,这意味着我们不允许单步进入它们,因此这些变量也可以被删除。在这种情况下,变量不会出现在调试器中。

分析统计结果:不合理的变量丢弃

有些变量值应该在变量未使用时被丢弃,或者当其计算被移动到新作用域时,它在原始作用域中可能不可用。因此,如果我们无法跟踪变量,它不能被移除,而必须在该位置标记为不可用。但这通常没有被SIL通道很好地处理,这就是变量经常消失的原因。SimplifyCFG就是这种情况之一。

如果一个值被折叠并且可以通过可用值的表达式计算,那么我们也应该将其显示为不可用,同时保持其在该处的可用性。

但是,有些通道绝对不应该丢弃变量的调试值。例如,当变量被拆分成多个分配时(如SROA),或者当它从分配移动到寄存器时(如Mem2Reg),或者当它从堆移动到栈时(如AllocBoxToStack),调试值应该跟随变量移动。当一个常量被折叠并且可以在DWARF表达式中表示为常量时,我们应该这样做。

问题的严重性:即使在调试模式下

在上述所有通道中,我们都在SIL阶段丢失调试信息。问题在于,图中橙色的这些通道是SIL优化器中的强制通道,即使在-O0编译时也会运行。所以,这并不好。

如果我们在调试模式下运行编译器并启用变量丢失统计,可以看到常规变量在编译用于调试时也会被丢弃。这是由于一些逻辑错误:有些变量出现在错误的作用域中,它们在原始作用域中不可用,有时它们因为未被处理而被丢弃。

修复问题与验证结果

在修复这些问题之后,现在的情况看起来好多了。大多数通道已完全修复,LoadableByAddress通道中的三分之二案例也已修复。蓝色的部分是来自透明函数的变量,所以这是正常的。

那么,我是如何发现真正的问题的呢?我这里只有数字,没有能帮助我找到错误的示例。统计报告包含的信息不仅仅是每个通道丢弃的变量总数,还包括变量被丢弃时所在的函数名称以及通道编号。

然后,我们可以使用标志重新编译错误发生的部分,在小型函数上查看有问题的通道运行前后的SIL代码。一旦有了这个示例,理解发生了什么实际上就相当容易了。

如果你还记得,这是优化代码的统计报告在调查和修复问题之前的样子。在调查问题并修复之后,现在的情况是这样的。SimplifyCFG现在多丢弃了12%的变量,因为它们没有被更早的通道丢弃,但总体上我们丢弃的变量减少了35%,从总计270万下降到180万。

SROASILCombine这样本不应丢弃变量的通道现在已被修复。

当前状态与未来工作

现在,丢弃调试变量最多的八个通道是这些,我们应该专注于这些通道,以便对调试优化的Swift代码产生最大影响。

如果你感兴趣,变量丢失统计的拉取请求可以在这里找到。有一个针对Swift的PR,并且最近已移植到LLVM IR,因此我们也可以修复LLVM IR优化通道中的问题。

仍然有很多工作要做。LLVM IR的补丁最近才被合并。我们还可以将类似的方法添加到不同的阶段,例如机器码IR。我们还可以在阶段之间添加统计,例如从SIL到LLVM IR,或从LLVM IR到机器码IR。

我们有一些通道做了很多不同的事情,例如LLVM中的InstCombine和SIL中的SILCombine。可能很难知道哪个子部分应对变量丢弃负责。因此,拥有更细粒度的统计会很好。

我们最终还希望确定变量被丢弃是有正当理由还是无正当理由,并在报告中提供单独的数字。

总结

本节课中,我们一起学习了一种数据驱动的方法来提升调试信息质量。我们探讨了调试优化代码的必要性,分析了调试信息在编译器流水线中丢失的根本原因。重点介绍了一种名为“变量丢失统计”的技术,它通过在优化通道之间比较变量集合来定位问题。通过应用这项技术分析大型测试套件,我们能够量化问题、识别出需要修复的关键优化通道,并验证修复效果。这项工作不仅适用于Swift的SIL阶段,其原理也已扩展到LLVM IR,为系统性地改善所有优化级别下的调试体验提供了强大工具。

071:面向可伸缩向量的 GISel —— 拓展视野

在本教程中,我们将学习如何在 LLVM 的全局指令选择框架中支持可伸缩向量。我们将介绍相关核心概念、实现步骤,并通过一个演示来对比 GISel 与 SelectionDAG 的差异。

🧠 核心概念介绍

上一节我们概述了本课程的目标,本节中我们来看看两个核心概念:全局指令选择和可伸缩向量。

全局指令选择

全局指令选择,简称 GISel,是一个为指令选择提供一系列可重用 Pass 和实用程序的框架。它之所以被称为“全局”,是因为它在整个函数上操作,而不是像 SelectionDAG 那样仅针对单个基本块。GISel 旨在替代 SelectionDAG。

GISel 包含四个主要 Pass:

  • IR 转换器:将 LLVM IR 转换为通用机器 IR。
  • 合法化器:将不支持的通用机器 IR 操作替换为支持的。
  • 寄存器组选择器:将通用虚拟寄存器绑定到寄存器组。
  • 指令选择器:将受约束的通用机器 IR 转换为目标特定的指令。

此外,还有一些可选 Pass,如组合器,可以插入在某些主要 Pass 之间。

可伸缩向量

可伸缩向量在固定大小向量的基础上增加了一个称为 vscale 的常量倍数。vscale 在编译时未知,是硬件无关的,但在整个程序中,所有可伸缩向量的 vscale 值是固定的。

以下是一个可伸缩向量的代码示例,其元素数量为 3,元素类型为 i32,vscale 可以为 1, 2, 3 等:

// 例如:<vscale x 3 x i32>

⚙️ 实现工作详解

上一节我们介绍了 GISel 和可伸缩向量的基本概念,本节中我们将深入探讨支持可伸缩向量的具体实现工作。

实现工作始于一些前期准备,包括对机器 IR 解析器和验证器的支持,以及调用约定的处理。

前期准备

首先,需要支持机器 IR 解析器以解析包含可伸缩向量的机器 IR 文件。其次,更新机器验证器,使其允许从固定大小向量复制到可伸缩向量,只要目标可伸缩寄存器的最小尺寸能容纳源固定大小向量即可。这可以用以下逻辑表达:

if (SrcReg.isFixedVector() && DstReg.isScalableVector()) {
    // 检查 DstReg 的最小尺寸 >= SrcReg 的尺寸
}

完成这些支持后,机器 IR 文件仍然是完全通用的,可以在物理寄存器和虚拟寄存器之间相互复制。

调用约定处理

如果目标希望在 GISel 中支持可伸缩向量,也必须支持调用约定。具体来说,需要:

  • 支持将可伸缩向量作为函数参数传递。
  • 支持从函数调用返回可伸缩向量。
  • 根据目标特定的 ABI 启用调用指令。

GISel 四阶段实现

接下来,我们将按照 GISel 的四个主要阶段来介绍实现细节。

1. IR 转换阶段

此阶段主要关注算术逻辑单元操作以及加载/存储操作。由于操作在目标无关的通用机器 IR 上进行,这些转换通常适用于所有目标。如果需要支持更多操作码,可以在 GlobalISel/IRTranslator.cpp 文件中实现相应的 translateOpcode 函数。

对于 IR 转换器,所需的改动可能不大。一个例子是将代码从使用 unsigned 类型显式地迁移到使用 TypeSize 类型,这在转换加载操作码时已经完成。

2. 合法化阶段

合法化可能具有挑战性。与使用机器值类型和扩展值类型的 SelectionDAG 不同,GISel 使用称为低层级类型的概念,它无法区分整数类型和浮点类型。

如果目标希望支持可伸缩向量类型作为合法类型,需要使用标量类型作为构建块,将低层级类型映射到目标特定的向量类型。

以下是构建可伸缩向量的方法:

// 构建可伸缩向量
LLT::scalar(ElSize)            // 标量元素
LLT::pointer(AddressSpace, PtrSize) // 指针
LLT::scalable_vector(NumElts, EltTy) // 可伸缩向量

可以使用 LegalizerInfo 配合合法性谓词和查询来实现合法化逻辑。对于 ALU 操作,逻辑相对简单:如果目标支持向量指令,并且满足一些目标特定的谓词,则操作是合法的。

对于加载和存储操作,则有一些细微差别。例如,需要使用 custom() 方法为特殊情况实现自定义逻辑,比如非标准对齐的加载/存储。如果 custom() 评估为真,最终将调用需要你放置自定义函数实现的 legalizeLoadStore 函数。

在我们的实现中,选择合法化 RISC-V 向量扩展能支持的指令和类型。合法化计算严重依赖于 RVV 中的一些概念和参数,如向量寄存器长度 VLEN、元素长度 ELENSEWLMUL

3. 寄存器组选择阶段

直观上,我们将可伸缩向量映射到向量寄存器组。同时,需要一些目标特定的函数,根据操作值的大小,使用 ValueMapping 类将操作值映射到寄存器组。

在我们的案例中,选择根据 RVV 中不同的 LMUL 大小,将单个向量寄存器组划分为四个部分。

4. 指令选择阶段

对于 ALU 指令,指令选择非常直接,现有的表格驱动机制可以直接工作。

对于加载/存储指针向量,情况可能不那么简单。因为在 SelectionDAG 中,指针类型通常在合法化之前被转换为整数机器值类型或扩展值类型。但在 GISel 中,需要定义一些自定义逻辑来将指针转换为 SXLen 类型。

目前还有一些关于是否以及何时引入目标特定的“通用”操作码的有趣讨论,但这些讨论仍在进行中。

🖥️ 演示与对比

上一节我们详细讲解了实现步骤,本节中我们通过一个具体演示来对比 GISel 和 SelectionDAG。

演示使用一个简单的 SAXPY 示例:a * X + Y,其中涉及可伸缩向量。

在 LLVM IR 中,代码首先设置可伸缩向量的动态长度,并计算剩余的标量迭代次数。接着,将标量 a 拆分为一个可伸缩向量。然后是执行实际计算 a * X + Y 的向量循环体。最后,处理剩余的标量迭代并进行清理工作。

以下是使用 GISel 框架在 RISC-V 上生成的汇编代码与使用 SelectionDAG 生成代码的对比,我们将聚焦于一些有趣的差异点。

主要差异在于向量化部分:

  • 在 SelectionDAG 中,它没有显式地进行标量 a 的拆分操作,而是通过一条向量累加指令直接完成了乘加操作。
  • 在 GISel 中,代码被逐字翻译:它使用 RISC-V 的 VFMV.S.F 操作将标量 a 拆分到向量中,然后分别进行向量乘法和向量加法。

另一个与向量无关但值得注意的点是:

  • SelectionDAG 在计算余数时进行了一些优化。
  • GISel 再次逐字翻译了计算过程。

目前看来,GISel 的优化程度较低,但这主要是因为 GISel 框架仍在开发完善中。至少,我们已经实现了基本功能。

GISel 的一个显著优势是支持渐进式降低测试,你可以创建一个最小示例,并逐步观察每个阶段的 lowering 结果,这在使用 SelectionDAG 时相对困难。

💡 对其他目标的建议与总结

如果您在其他目标后端也希望支持可伸缩向量,这里有一些建议,特别是关于处理类型信息:

以下是处理类型信息的关键点:

  • 尽可能使用低层级类型。
  • 在目标寄存器信息中,从使用 unsigned 迁移到使用 TypeSize
  • 如果需要与零比较,使用 .isZero() 方法。
  • 如果需要大于比较,使用 TypeSize::isKnownGT
  • 如果需要与某些固定大小值比较,使用 getKnownMinValue()
  • 如果需要获取向量中的元素数量,应从 getNumElements() 切换到 getElementCount()

我们的未来工作包括改进 GISel 框架以利用更多的块间优化,当然,还包括支持更多的操作码。

本节课中我们一起学习了在 LLVM GISel 框架中支持可伸缩向量的基本概念、实现路径和注意事项。通过对比演示,我们看到了 GISel 当前的特点和潜力。随着框架的成熟,它有望为向量化代码生成提供更灵活和强大的支持。

posted @ 2026-03-29 09:18  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报