LLVMCon-EU-2025-笔记-全-
LLVMCon EU 2025 笔记(全)
001:构建安全的桥梁 🌉

在本课程中,我们将探讨如何通过添加注解来提升C++代码的内存安全性,特别是生命周期安全性,并实现C++与内存安全语言(如Swift)之间更安全、更高效的互操作。我们将了解现有工具、新增的注解及其设计理念,以及如何以渐进式的方式在现有代码库中应用这些改进。
概述
内存安全至关重要,因为历史上大部分严重的安全漏洞都源于内存安全问题。我们的目标是提升应用程序的整体安全性。由于存在大量用非内存安全语言编写的遗留代码,完全重写通常不现实。最具性价比的策略是开始在新的模块中使用内存安全语言编写代码,从而在“不安全的海洋”中创建“安全的小岛”,并逐步扩大这些安全区域。为了实现这一目标,我们需要在两种语言之间构建更好的“桥梁”。
上一节我们介绍了构建安全桥梁的必要性,本节中我们来看看实现这一目标的具体挑战和设计考量。
生命周期安全的挑战 ⚠️
生命周期安全是指我们只在对象的生命周期内访问它。在C++中,确保生命周期安全颇具挑战。
- 临时对象规则复杂:C++中临时对象的生命周期规则有时会延长,有时不会,这些规则并不总是直观的。
- 内存管理技术多样:C++使用了大量内存管理技术(如手动释放、引用计数、内存池、静态内存等)。仅从API上,我们通常无法得知某个指针所指对象应采用何种管理方式。
- 存在非平凡不变式:例如,向
vector添加元素可能导致其缓冲区重新分配,从而使之前获取的迭代器失效。如果相关代码分散在大型代码库的不同模块中,这类错误将很难发现。
当我们开始在不同语言间进行互操作时,会引入新的生命周期错误可能性。例如,在Swift中使用C++的vector和迭代器时,由于两种语言对对象生命周期的管理方式不同(Swift可能在最后一次使用对象时就结束其生命周期),且API中未包含生命周期依赖信息,Swift编译器可能无法得知vector和迭代器之间的关联,从而导致悬垂迭代器。
设计目标与权衡 ⚖️
那么,如何改善这种状况?一个直接的思路是为API添加缺失的生命周期信息。但问题在于,这里存在一个广阔的设计空间,涉及不同的权衡。根据我们的目标和优先级,最终方案可能位于这个设计空间的不同位置。
我们的主要目标是尽可能降低采用门槛,鼓励用户尽快开始利用内存安全语言的优势。以下是设计时考虑的一些关键方面:
- 渐进式注解:用户可能使用无法修改的第三方代码。我们希望用户能够从仅注解重要的API或语言边界开始,而不是要求先为整个代码库添加注解才能获得收益。
- 可选的病毒式传播:病毒式传播(即要求相关类型都必须注解)有助于推广注解以获得更多安全性,但会提高初始门槛。最佳方式是提供可选的病毒式传播,让用户可以根据诊断信息,按照自己的节奏逐步采用注解,并立即看到价值。
- 避免过度推断:过度推断会使API更难理解。更重要的是,我们希望添加注解能带来回报,而不是惩罚。
- 寻找“甜点”:C++可以表达任何内存管理技术,但不太可能有一种注解语言或类型系统能表达所有这些。我们的目标是找到一个易于采用、易于理解、对C++开发者来说不显突兀的“甜点”,能够表达我们关心的常见模式即可。
现有的Clang注解 🛠️
目前,Clang编译器已经通过一些注解来帮助诊断生命周期问题。请看以下两段非常相似的代码:
// 代码段 1: 存在问题
const char *p = std::string("hello").c_str();
// 代码段 2: 安全的
std::string s = "hello";
const char *p = s.c_str();
如果编译它们,Clang会对第一段代码发出警告,但不会对第二段发出警告。Clang能判断第一段代码有问题,是因为std::string::c_str()方法上使用了名为 [[clang::lifetimebound]] 的注解。这个注解指明,返回指针的生命周期与隐式的this对象(即调用c_str()的字符串对象)的生命周期绑定。因此,Clang可以推断出临时std::string对象在完整表达式结束时生命周期结束,那么指针p所指向的缓冲区生命周期也随之结束,从而生成诊断信息。这个属性在Clang中已存在七到八年。
此外,近期由Google的贡献者添加的一个新注解有助于诊断另一种情况:当我们尝试将一个临时字符串添加到set中时。这个新注解可以描述一个值的生命周期被另一个值所“捕获”。
这些注解和诊断功能已经包含在最新发布的Clang版本中。
为互操作新增的注解 🌉
上一节我们了解了现有的工具,现在来看看为了改善与内存安全语言的互操作性,以及让C++自身受益,我们新增了哪些注解。
设想一段C++代码,其中string_view是一个引用类型。我们有一个返回string_view的API,但从其签名中,我们无法得知这个返回的string_view对象的生命周期。如果能有一个注解,强制编译器检查该类型的所有使用,并警告“我们正在返回一个引用类型,因此应该添加生命周期注解”,那就太好了。这正是我们所说的可选的病毒式传播,可以用来驱动这些注解的采用。
事实上,我们确实添加了这样一个注解。这个注解的动机源于互操作性需求,但正如你所见,即使不考虑互操作性,它对C++也相当有用。
这个注解在我们(演讲者团队)的Clang分支中是新的。让我们看看在尝试从Swift使用C++代码时,这个注解如何发挥作用。
我们引入了Swift中新的可选语言模式:严格内存安全模式。在此模式下,当使用像string_view这样的外部类型时,Swift编译器会给出诊断,指出我们正在使用不安全的代码。Swift编译器认为该代码不安全,是因为它无法得知这是一个可能具有生命周期依赖的引用类型,还是一个自包含的值类型。
另一方面,如果我们想安全地从Swift使用这个类型,我们可以指定它是一个引用类型(在Swift术语中称为“non-escapable”)。但如果我们添加了这个注解,在C++端编译时,就会收到之前提到的警告:该API缺少生命周期注解。
如果我们补上这个缺失的注解,然后重新编译那段Swift代码,Swift编译器现在将能够诊断出一个实际的生命周期错误。在这个例子中,normalized_path是一个局部变量,其生命周期在函数返回时结束。而我们返回的string_view依赖于这个局部变量,因此会导致“释放后使用”错误。在C++端添加这个注解,使得Swift能够进行完整的借用检查,这是在编译时、零开销就能获得的好处。
对于像vector这样的模板类型,vector<string_view>应该被视为引用类型,因为它具有生命周期依赖;而vector<string>则是值类型,因为它是完全自包含的。为了注解这些泛型类型,我们引入了条件注解,使得一个类型是否为引用类型取决于其模板参数。
此外,现在我们将string_view注解为引用类型后,当编译器看到一个函数返回string_view时,它会要求我们添加生命周期注解。但有些情况下,我们并不需要生命周期注解,例如返回的string_view具有静态生命周期,或者传入的string_view不会逃逸出当前函数。为此,我们泛化了[[clang::noescape]]注解(以前它只能应用于指针,现在可以应用于任意类型),并添加了另一个注解来标记返回值具有静态生命周期。这些也是我们分支中的新功能。
渐进式采用的优势 📈
这些注解对于渐进式采用特别有用,原因如下:
- 非病毒式传播:可以选择只注解重要的API或语言边界处的API,而不必强制将其传播到整个代码库。无需在获得收益之前进行大量的采用工作。
- 无需完备性:注解可以是部分的。例如,一个函数可能返回对两个参数之一的引用,但只注解了其中一个依赖关系。这仍然会被视为不安全(因为存在未注解生命周期的引用),但至少有了部分注解。允许部分注解非常重要,因为有些API的生命周期约定可能无法用这些渐进式注解来表达。
当然,目前的方案也有局限性。例如,我们无法表达“返回指针的生命周期与某个pair的第一个元素相同”这样的约定,这可能未来需要像“命名生命周期”这样的功能。我们的目标不是表达一切,而是找到一个对用户有效且实用的“甜点”。同时,我们希望保持相对简单,因为C++可能需要与多种语言互操作,我们可能需要在C++中采用一种“最小公分母”的方法。
非常重要的一点是,目前在C++端,我们并未获得全部益处。即使C++编译器拥有了检测生命周期问题所需的所有信息,它目前也没有进行完整的数据流分析来做到这一点。我们希望未来能够实现。但当前,如果使用内存安全语言的门槛足够低,当人们需要这种完整的契约检查时,他们可以直接使用内存安全语言来编写代码,因此C++端可能不需要完整的分析。
总结与问答环节 💎
本节课中我们一起学习了如何通过注解来构建C++与内存安全语言之间的安全桥梁。
总结来说,通过这些注解,我们可以表达一些C++类型系统中原本缺失的信息。利用Clang的现有功能,我们发现只需添加很少的扩展,就能让互操作运行得出奇的好,尤其是在语言边界的API往往比内部API更简单的情况下。C++和内存安全语言都能从这些渐进式注解中受益。易于采用是这一切背后的主要目标和驱动力,因为我们希望人们能够尽快、尽可能轻松地开始扩大那些“安全的小岛”。我们鼓励用户在需要生命周期契约强制执行时,依赖内存安全语言。
Q&A 摘要

- 问:能否自动为现有代码中可轻松推断的部分添加注解?
- 答:目前没有,但技术上对于大部分代码是可行的,未来可能实现自动化推断简单情况。
- 问:如何知道还需要添加哪些注解?如何确保安全?
- 答:要获得完全的安全保证,需要进行完整的契约检查,这可能会增加采用难度。目前这是一个固有的权衡:要么易于渐进式采用,要么获得完整安全保证。最佳位置可能是提供渐进式版本,并允许需要时选择加入完整契约检查。
- 问:是否考虑过使用“命名生命周期”而非属性?这些属性是仅用于边界,还是也能为C++代码库带来更多安全性?
- 答:有团队正在积极研究“命名生命周期”。这些注解对C++本身也非常有用,在Google内部的应用已经发现了C++代码中的错误。
- 问:对于
vector<static指针>和vector<局部生命周期指针>这种相同类型但不同生命周期需求的场景,如何注解?- 答:在这种情况下,注解可能不属于类型层面。
vector<string_view>始终是引用类型。描述“返回的是静态生命周期”的注解应属于API层面。对于更复杂的用例,需要进一步讨论。
- 答:在这种情况下,注解可能不属于类型层面。
- 问:是否有将这些注解纳入C++标准的努力?
- 答:目前没有,我们尚处于早期内部推广阶段,希望积累更多经验后再向标准委员会提案。
- 问:处理这些注解的编译时代价如何?
- 答:Clang中现有的诊断功能开销很低,默认开启。新增的标记类型为引用/值类型的注解开销也很低。本课程展示的所有内容在C++端的编译时成本都应该很低,预计可以作为默认诊断开启。
002:在实践中采用 Fbound Safety


概述
在本教程中,我们将学习 Apple 的 C 语言扩展 Fbound Safety。这是一种旨在检测和防止内存越界访问的技术,能够在不重写现有大型 C 代码库的前提下,显著提升其安全性。我们将了解其核心概念、工作原理、如何在实际项目中逐步采用它,以及如何调试运行时错误。
第一部分:Fbound Safety 简介
1.1 背景与目标
我的名字是 Henrik,这是 Patrick,我们是 Apple 的 Se 语言扩展团队的编译工程师。我们主要致力于 Fbound Safety,这是我们为 C 语言设计的边界安全扩展。
我们于 2023 年的 URLLLVM 大会上宣布了此扩展并发布了 RFC。去年我们开始了上游化工作,目前仍在进行中。我们的最终目标是完全标准化其语法和语义,以实现跨工具链的兼容性。
由于完整的上游化是一个漫长的过程,今年我们已在 Swift 的 Kang 分支中完全开源了我们的实现,作为一个起点。你可以克隆该仓库并自行构建 Kleline,或者访问 Sw.org 下载适用于 Mac、Windows 和 Linux 的快照版开发工具链。
目前,此扩展仅支持 C 语言,我们未来希望增加对 Objective-C 和 C++ 的支持。
1.2 为什么需要 Fbound Safety?
内存安全漏洞是整个行业安全漏洞的主要来源,约占所有安全漏洞的三分之二。历史上,它们被用于许多高调的攻击中,以远程控制受害者的设备。我们日常依赖的许多大型、关键的安全代码库都是用 C 语言编写的,而 C 语言提供的内存安全性非常有限。
当然,最安全的选择是使用提供完全内存安全的语言,但如此大规模的代码重写是一项艰巨的任务,并且我们需要的是当下的安全,而不是十年后的安全。这就是 Fbound Safety 的用武之地。
与 C 语言的其他边界安全缓解措施(如 45 source)不同,Fbound Safety 提供了一个全面的模型,用于防止攻击者利用越界访问。它通过迫使攻击者寻找其他更难利用的漏洞类型来提升安全性。
需要明确的是,虽然 Fbound Safety 可以帮助你发现漏洞,但这并非其主要目的。它旨在用于生产环境,以防止正在发生的攻击。
第二部分:Fbound Safety 的工作原理
上一节我们介绍了 Fbound Safety 的目标,本节中我们来看看它是如何实现这些目标的。
2.1 核心策略
Fbound Safety 在语言层面强制执行边界安全规则。以下是实现此目标的策略:
- 自动边界检查:编译器通过自动边界检查在运行时防止越界内存访问。启用 Fbound Safety 后,这不是可选项。
- 编译时错误:如果编译器没有足够的信息来发出运行时检查,或者它明确知道访问是不安全的,它将在编译时通过错误告知你。
- 边界信息可靠性:编译器还会拒绝那些不能保证边界注释正确性的表达式,以确保在遇到边界检查时,这些信息是可靠的。
2.2 边界注释与运行时检查
Fbound Safety 扩展为程序员提供了用于函数类型、结构体字段和全局变量的边界注释。例如,counted_by。
以下是使用 counted_by 注释的示例:
void process_buffer(int *buffer counted_by(count), int count) {
for (int i = 0; i <= count; i++) { // Bug: 循环条件错误,将越界
buffer[i] = 0;
}
}
利用 counted_by 注释提供的边界信息,编译器可以插入边界检查。如你所见,这个有缺陷的循环条件最终会越过缓冲区的末尾,但插入的边界检查将使程序陷入陷阱,而不是访问越界内存。
程序员必须提供足够的边界信息,否则编译器将拒绝代码并要求程序员添加边界注释。如果我们再次使用相同的例子,但没有 counted_by 注释,缓冲区就没有必要的边界信息,因此当我们尝试进行数组索引时,编译器会报告错误。为了使代码正常工作,程序员需要添加边界注释以消除错误。
2.3 边界信息的同步与正确性
这种方式有效地默认保护了所有指针,但仅仅添加所需的边界信息是不够的,它还必须是准确的。例如,程序员还必须确保每当指针更新时,计数也相应更新,反之亦然。否则,一个最初具有正确边界信息的指针,在其中一项更新后,可能会得到过时的边界信息,从而导致边界检查失败。
在这个例子中,buff 和 count 暂时不同步,因为计数在指针递增之前被递减,但 Fbound Safety 要求它们始终保持同步。因此,编译器将此报告为错误。为了使代码正常工作,程序员必须移动赋值语句,使它们彼此相邻。这样,扩展就能始终保持边界注释的正确性。
幸运的是,并非所有指针都需要边界注释。对于局部变量,编译器可以自动跟踪边界,这加快了采用速度,因为它不需要手动添加注释,同时也因为更灵活的语义不需要像刚才展示的那样移动赋值语句。由于没有外部边界会失去同步,让我们再次使用相同的例子,但这次我们将重命名参数,并引入具有原始参数名称的局部变量。
现在,buff 的类型不再依赖于 count,因此它们不必再保持同步,并且内存访问实际上并没有越界,所以代码现在没问题。仍然会有一个边界检查,但边界信息保存在 buff 指针内部,因此我们不必移动赋值语句。这种方式扩展性更好,因为我们只需要在函数开头进行一次更改,而不管函数中有多少次重新赋值。如果你正在对开源项目进行下游采用,这也减少了与上游的差异。
第三部分:Fbound Safety 的采用与优势
上一节我们探讨了 Fbound Safety 如何工作,本节我们来看看采用它的实际影响和好处。
3.1 采用难度与兼容性
Fbound Safety 确实对指针的使用方式施加了严格的规则,但尽管如此,采用起来仍然相当容易。大多数指针根本不需要注释,因此需要手动编辑的地方数量很少。根据我们的经验,完全采用 Fbound Safety 大约需要每 2000 行代码花费 1 个工程师小时。作为参考,对于一百万行代码,这大约是 12.5 个工程师周。
因为它保持了 API 兼容性,你仍然可以使用不使用 Fbound Safety 的库,或者如果你的代码是一个库,你可以在不破坏用户构建的情况下采用 Fbound Safety。这也支持增量采用,因此你不需要打开一个单独的分支来进行完全采用,然后再一次性合并回来。你可以在几个版本的过程中逐步保护代码,这对于保护大型代码库至关重要。
3.2 生产环境应用与性能
这是 Apple 已经在做的事情。尽管实现最近才开源,但它已经在 Apple 内部的生产环境中使用了多年。Fbound Safety 已在数百万行关键软件中被采用,运行在所有 Apple 平台上的消费者设备中。虽然其中大部分代码不是开源的,但 XNU 内核中的采用是开源的,如果你想看看的话。开发仍在进行中,但 Fbound Safety 的当前状态是有效的,并且有助于保护我们的设备安全。
所有这些都是在我们认为合理的开销下实现的。我不想过多关注性能,但如果不展示数字,就很难提及它。让我们看几个基准测试。在展示这些数字之前,我想提一下这些数字来自 2023 年,所以如果你今天尝试重复它们,可能会略有不同,但它们应该在大致范围内。
我们发现平均代码大小增加了 9.1%,运行时开销增加了 5.1%。但从范围可以看出,开销因应用程序而异。通常,在已经具有手动边界检查的应用程序中,影响往往更小,因为优化器可以移除冗余的边界检查。然而,在优化器中仍有一些工作可以改进,以移除更多冗余的边界检查。
但在实际的采用者中,端到端的开销比这些数字要小。例如,我们测量了音频编解码器中的运行时开销大约为 1%,我们很乐意为了安全而付出这个代价。二进制大小开销也比这些数字小,因为这也只测量了二进制文件的文本部分。
第四部分:边界注释类型详解
上一节我们了解了采用 Fbound Safety 的宏观情况,本节我们将深入探讨具体的边界注释类型。
我们有一系列具有不同优缺点、适用于不同场景的边界注释。以下是主要的几种:
4.1 counted_by 系列
counted_by 允许你指定一个表达式,表示缓冲区中可以容纳的元素数量。通常这只是一个参数或结构体字段,但它也可以包含常量或简单的算术表达式。该表达式在每次边界检查时被求值,这就是为什么它需要与指针保持同步。
counted_by 非常适合在局部上下文中传递信息,因为它使用已经存在的信息并保持 ABI 兼容性。结构体字段、函数参数、返回值是它大放异彩的地方。
它不仅仅是一个单一的注释,counted_by 是一个包含其他三个类似注释的家族的一部分,每个都有细微的语义差异。
以下是 counted_by 家族的成员:
counted_by:用于指定元素数量。sized_by:当缓冲区类型为void*或谈论元素数量没有意义时,用于以字节数表示缓冲区大小。counted_by_nullable/sized_by_nullable:用于指针可能为NULL的情况(如malloc失败时)。边界检查首先检查指针是否为NULL,如果是,则忽略大小参数。
经验法则是:尽可能使用 counted_by,在需要时使用其他变体。
counted_by 家族的指针在每次内存访问和每次赋值时都会进行边界检查。对于函数参数,这意味着调用者将在调用前检查边界。这意味着函数可以完全信任计数是正确的,而无需知道指针来自哪里。
4.2 single
single 表示指向单个元素或空指针的指针。因此,如果我们尝试对其进行指针运算,总是会得到编译错误,所以我们不能索引到该指针,除非索引是 0。我们可以为动态索引发出运行时检查,但我们不允许这样做,因为大多数时候,如果你尝试对 single 指针执行动态索引,实际上只是缺少边界信息,我们希望在编译时捕获该错误,而不是在运行时。如果你确实想用索引 0 进行索引,只需使用常量值而不是动态值。
4.3 bidirectional_indexable
与 counted_by 不同,bidirectional_indexable 允许你在正向和负向进行索引,因此得名。这是通过将指针的表示形式更改为“宽指针”来实现的,该指针携带上下界以及指针本身。
在这个例子中,编译器会将指针转换为宽指针,并在指针被解引用时在运行时发出边界检查。然而,它不会在重新赋值期间发出边界检查,因为如果指针越界,该信息仍然可以存储在指针中,以便在你稍后访问指针时,错误将在那时被捕获。这与 counted_by 不同,在 counted_by 中,仅仅构造一个越界的指针就是未定义行为,但在这里这完全没问题。
因此,bidirectional_indexable 非常灵活,编译器将允许几乎任何操作,因为它有大量信息可用于运行时检查。然而,由于它改变了指针表示形式,它与普通指针的 ABI 不兼容,因此调用者必须知道函数期望一个宽指针。这对于向后兼容性和增量采用来说是一个问题,因为即使调用者和被调用者在同一个头文件中共享相同的定义,如果调用者是在未启用 Fbound Safety 的情况下编译的翻译单元中,它甚至不知道 bidirectional_indexable 是什么,从而导致 ABI 不匹配。
因此,在公共头文件中使用 bidirectional_indexable 注释函数至少是一个问题,但其灵活性使得 bidirectional_indexable 非常适合 ABI 不是真正因素的局部变量。
4.4 unsafe_indexable
我们有一个称为 unsafe_indexable 的逃生舱口。这只是一个普通的 C 指针,你可以用它做任何通常用 C 指针做的事情,没有编译时限制,也没有边界检查。这对于与不使用 Fbound Safety 的库交互很有帮助,因为尽管它们没有边界安全注释,我们仍然可以调用它们的函数。当然,库可以用我们的指针做不安全的事情,但我们已经尽力保护了我们控制的代码。
第五部分:指针转换与默认规则
上一节我们介绍了各种注释类型,本节我们来看看它们之间如何转换以及默认的注释规则。
5.1 指针转换模型
这个过程相当顺利,因为指针可以在不同的指针种类之间隐式转换。让我们看看这是如何工作的,以及何时需要显式转换。
以下是 Fbound Safety 指针转换的核心模型:
Bidirectional_indexable 作为最灵活的类别,充当其他类别之间的公共中间地带。转换为 bidirectional_indexable 只是转移边界的问题,因此不需要边界检查。
另一方面,安全地转换为 counted_by 指针需要在索引处插入边界检查,以确保传入指针中的元素数量等于或大于计数表达式。
类似地,安全地转换 single 需要检查指针是否实际指向某个东西,因为 bidirectional_indexable 指针可能越界。
最后,转换为 unsafe_indexable 只是移除边界注释的问题。
这些转换都是自动发生的,因此你不需要插入任何显式强制转换,因为编译器知道该做什么。但是,没有从 unsafe_indexable 的隐式转换。要从 unsafe_indexable 伪造一个安全指针,你需要使用一个不安全的伪造内置函数。这告知编译器边界信息,但也有助于审计,因为你是明确地不安全地伪造一个安全指针。
5.2 默认注释规则
函数参数、结构体和局部变量中的很大一部分指针不需要任何显式注释,因为默认值已经是正确的。这些默认值使采用过程更容易。
以下是默认规则:
- 函数签名、结构体和全局变量中的指针:隐式为
single。 - 外部系统头文件中的指针:隐式为
unsafe_indexable,以便你仍然可以调用你无法控制的库中的函数。 - 局部变量中的指针:全部隐式为
bidirectional_indexable。
因此,我们建议将所有外部头文件包含为系统头文件,除非你手动注释这些头文件,否则当你启用 Fbound Safety 时,编译器可能会发出源自这些头文件的边界错误。
如果需要,也可以使用编译指示更改 ABI 可见指针的默认值。编译器通常能够优化掉不必要的边界,如果你不使用 bidirectional_indexable 的全部功能,那么开销就不会像在 ABI 表面上那么大。当然,你总是可以用显式注释覆盖这些默认值,但如果没有任何注释,这些就是默认值。
以上就是对 Fbound Safety 的简要概述。还有一些额外的边界注释和语义边缘情况我没有涵盖,所以如果你有兴趣深入了解,我建议观看 2023 年 URLLVM 上的初始公告,当然也要阅读可用的文档。上游 K 文档反映了我们上游化工作的目标,而不是当前的实现。
第六部分:实践采用指南
现在,我将把讲解交给 Patrick,他将展示如何在实践中采用 Fbound Safety。
大家好,我是 Patrick,我在 Apple 的 Se 语言扩展团队与 Hendrich 一起工作,今天我将向大家展示如何在实践中采用 Fbound Safety。
6.1 增量采用流程
Fbound Safety 允许我们进行增量采用。我们的建议是在单个文件中启用 Fbound Safety。
这样做可能会导致编译器发出一堆编译错误和其他诊断信息。这些诊断信息将指导你如何注释代码。
当你修复了编译错误后,你应该运行测试以检查是否存在任何运行时问题。在这里,拥有良好的运行时测试覆盖率极其重要。
当测试成功且代码编译通过后,你可以重复第一步,直到在所有地方都启用了 Fbound Safety。
当你在所有地方都启用了 Fbound Safety 后,你可以对性能(如运行时和代码大小)进行基准测试。
如果你对性能不满意,你可以优化代码。我们专门为 Fbound Safety 提供了优化备注,这些备注将指导你如何优化代码,并显示边界检查在何处发出。
6.2 在库中采用
你也可以在库中采用 Fbound Safety,但对于公共头文件有一个注意事项。
对于系统头文件,默认属性是 unsafe_indexable。然而,对于非系统头文件,ABI 可见指针的默认属性是 single。如果你将已采用 Fbound Safety 的库的头文件作为系统头文件包含,这可能会导致不匹配,因为默认属性将是 unsafe_indexable,但如果你已经采用了,属性应该是 single。
为了解决这个问题,我们有一个名为 #pragma pointer_check assume_safe 的编译指示,你可以将其添加到文件的开头,这将把整个文件的默认 ABI 属性更改为 single。这将改变该属性,无论该文件是否作为系统头文件包含。
因此,你的库中的公共头文件应始终使用此编译指示,以避免这种不匹配,并表明它们已采用 Fbound Safety。
此外,库还有另一个更轻量级的选项,而不是完全采用,我们称之为“仅头文件采用”。这对于那些不想承担完全采用成本(无论是时间投入还是运行时开销)的库很有用。你可以通过仅注释公共头文件或公共接口来实现这一点,但不在你的实现中注释和启用 Fbound Safety。因此,实现并未受到 Fbound Safety 的保护。然而,正在采用 Fbound Safety 的客户端将看到这些注释并获得安全的接口。未采用 Fbound Safety 的其他客户端将看不到这些注释,也不会支付任何成本。
一个很好的例子是 memcpy。例如,你可以用 sized_by 注释来注释 memcpy,这将确保在调用端发出边界检查,以验证你传递了具有正确大小的缓冲区。但你不必注释,也不必在 memcpy 的实现中启用 Fbound Safety。这对于其他语言中更安全的互操作也很有用,因为这些边界注释为编译器提供了更多信息。
但如果你想进行仅头文件采用,请记住添加一个测试用例,创建一个包含所有头文件的单个 C 文件,这将检查该头文件中的那些注释是否编译。
第七部分:现场演示与调试
上一节我们介绍了采用流程,现在我将通过一个简单的演示项目来展示如何实际操作。
7.1 演示项目介绍
为了说明 Fbound Safety 的采用,我们开发了一个简单的程序。这个程序叫做 B64,是一个简单的命令行工具,可以让你编码和解码 base64。这个程序使用 makefile 来构建项目。
这个项目包含三个 C 文件。你可以构建 B64 实用程序,并使用它来编码和解码 base64。我们还有一个测试脚本,可以让我们运行一些测试并检查一切是否正常。
我们将以增量方式进行采用,这意味着我们将在一个文件中启用 Fbound Safety,然后为每个文件重复此过程。我们将启用它,可能会得到一些编译错误,我们将解决它们,并在每次为单个文件启用 Fbound Safety 后运行测试。我们将重复此过程,直到整个项目都被采用。
7.2 逐步采用示例
让我们从 dbuf.c 开始。这是一个动态缓冲区的简单实现,可以随时间增长。我们有一个结构体和几个允许我们操作动态缓冲区的函数。
要启用 Fbound Safety,我们必须传递 -fbounds-safety 标志作为编译标志。现在,编译器将使用 Fbound Safety 标志编译此项目或此文件。我们将得到一些编译错误。
以下是解决错误的示例步骤:

- 错误:无法索引到 single 指针。函数参数
data默认是single。我们需要用counted_by(size)注释它,以告知编译器该指针必须指向至少size个元素。 - 错误:结构体字段的索引问题。结构体中的
data指针默认也是single。我们使用counted_by(capacity)注释它,因为capacity表示data指针中分配的字节数。 - 错误:赋值同步问题。
counted_by要求指针和其计数必须一起赋值。在代码中,data和capacity的赋值被memset调用隔开。我们引入一个局部变量来保存新数据,然后在最后一起赋值给data和capacity。 - 错误:静态函数中的指针。静态函数不在 ABI 层,我们可以将其指针表示更改为宽指针,使用
bidirectional_indexable注释,或者如果大小可用,也可以使用counted_by。推荐使用counted_by,因为它与普通 C 指针 ABI 兼容且通常优化得更好。 - 错误:系统头文件指针。
stdin和stdout定义在系统头文件stdio.h中,默认是unsafe_indexable。我们需要使用__unsafe_forge_single内置函数来告知编译器这些指针指向单个文件指针。
修复所有错误后,编译项目并运行测试。所有测试都成功,说明我们成功地在文件中采用了 Fbound Safety。对其他文件重复此过程。

第八部分:调试运行时错误
现在编译器满意了,测试也通过了,但这并不意味着程序没有错误,因为我们只有六个测试用例,测试覆盖率不高。我们将调试此代码中的运行时陷阱。你可以想象这发生在一个具有更广泛覆盖率的更好的测试套件中,或者这是对某个被阻止的攻击的重放。
8.1 触发并分析陷阱
我们创建一个精心设计的输入文件来触发越界访问。运行程序后,我们遇到了一个陷阱,并收到一条很好的错误消息:“边界检查失败,解引用越界”。
这是使用去年新增的 Kang 中的详细陷阱功能,它也存在于上游 Kang 和 LDB 中。它通过将消息编码在一个伪造的内联堆栈帧中来工作。在正常的 LDB 中,它会知道跳过这个堆栈帧并直接转到真实的堆栈帧,但 BS 代码扩展知道这一点,所以让我们手动转到那里,然后我们可以看到陷阱实际发生的位置。
我们正在索引到 b64_table,这个索引实际上是越界的。对于这个指针,我们可以看到边界信息(在下游 LDB 版本中会显示)。它显示 data 是 counted_by(capacity),capacity 是 8,而索引 j 是 9,所以我们越界了,这就是陷阱的原因。
8.2 问题根源与修复
这里的问题是,我们有一个动态缓冲区,当我们向其中添加内容时它会增长,但这个函数实际上只是直接访问内部数据指针,而不是使用公共 API。
修复方法是:不直接访问值,而是使用官方的 dbuf_push API,这样当我们添加新元素时,它实际上会增长缓冲区。
这是一个快速演示,展示了 Fbound Safety 如何防止越界访问,如何识别 Fbound Safety 陷阱,以及 LDB 如何帮助你找到这些问题。
8.3 关于优化构建的说明
在这个函数中,我们可以看到许多不同的 brk 指令,每条都有自己独立的消息。但如果这是在优化构建中(这只是一个完全未优化的调试构建),它们都会被合并到一个 brk 指令中,分支都指向同一条指令。这样我们会丢失源代码信息。因此,如果你想查看陷阱实际发生的位置和原因,你需要进行未优化的构建,或者如果那不可行,你可以使用 -funique-traps 作为编译器标志,然后它将阻止这些陷阱的合并。
第九部分:总结与资源
让我们回到演示文稿。
9.1 总结
这只是我们为适应本次演示时间而创建的一个小演示项目,但如果你想要一个更大的项目,我们已经在开源项目 GIFlib 中采用了 Fbound Safety,这是一个用于处理 Gi 图像的成熟 C 代码库,可以在我们的 GitHub 上找到。如前所述,我们在 XNU 中也有采用。
总之,Fbound Safety 支持大型和小型代码库的增量采用,并帮助你在生产环境中捕获边界安全问题,在攻击者能够利用它们之前。如果你对此感兴趣,明天我们同事的 Dev 主题演讲中会有更多类似的内容,以及一个关于在 C++ 中消除整个内存安全漏洞类的方案,所以一定要去看看。是的,去尝试一下,给我们反馈。你可以在这里的会议上或随时在 Discord 的 Fbound Safety 频道与我们交谈。感谢大家的关注。
9.2 问答环节摘要
counted_by表达式的限制:不能调用任意函数,只能调用被注释为pure的函数,且表达式必须在编译时可求值。可以进行一些算术运算。全局变量如果是编译时常量可以使用,但如果是可变变量,则很难检查与指针的同步。- 可变长度数组和灵活数组成员:
counted_by支持灵活数组成员,只要结构体中有关于其长度的信息,就可以用counted_by注释。 - 设计是否基于现实攻击修改:演讲者未意识到因现实攻击而修改设计的情况,但发现过未覆盖某些情况的问题,不过未同时发现这些情况被用于攻击。
__unsafe_forge_single为何需要显式指定类型:因为该内置函数也可用于从整型伪造指针,所以源类型和目标类型不一定相同。- 性能优化建议:使用优化备注查看边界检查发出位置。可以重构代码(如改变循环条件、循环顺序)以优化边界检查。对于复杂表达式(如除法),可以尝试找到解决方法。也可以在循环前手动进行边界检查,使循环内的检查变得冗余。
- C++ 支持的挑战:C++ 的构造要多得多。最大的挑战是覆盖整个语言需要时间。最近增加了对 C++ 模板函数中语法上使用表达式的支持,但运行时还不支持。可以只为 C++ 的 C 子集提供支持,但需要决定如何处理模板等情况。

003:什么是LLDB DAP?


在本节课中,我们将要学习LLDB DAP。这是一个连接开发工具与调试器的协议,旨在为不同编程语言提供统一的调试体验。
什么是DAP?🤔
DAP代表调试适配器协议。它是一个由微软开发的开源标准,用于在IDE、编辑器等开发工具与调试器之间进行通信。其目标是实现跨语言的统一调试体验,避免各种工具和调试器之间需要为彼此实现定制化支持。
该协议基于JSON格式,定义了三种类型的消息:请求、事件和响应。
- 请求:从客户端发送到调试适配器的消息。
- 事件:从调试适配器主动发送到客户端的非请求消息。
- 响应:对请求的回复。
深入一个协议请求示例 🔍
上一节我们介绍了DAP的基本概念,本节中我们来看看一个具体的协议消息示例。我们以breakpointsLocations请求为例,因为它相对简单。
这个请求的目的是获取一个源代码断点的所有可能位置。以下是请求和响应的结构:
请求示例:
{
"seq": 3,
"type": "request",
"command": "breakpointsLocations",
"arguments": {
"source": {
"path": "/path/to/driver.cpp"
},
"breakpoints": [
{
"line": 780
}
]
}
}
协议定义了此请求的参数是固定的。在这个例子中,我们提供了文件路径和行号。客户端将此请求发送给调试适配器,适配器会将其传递给底层的调试器实现。
响应示例:
{
"seq": 3,
"type": "response",
"command": "breakpointsLocations",
"success": true,
"body": {
"breakpoints": [
{
"line": 780,
"column": 19
}
]
}
}
如你所见,响应只包含了一个断点位置(第780行,第19列)。需要注意的是,响应省略了请求中的许多信息(如文件路径),因为它是对特定请求(序列号3)的回复,这种方式更加高效。
LLDB中的DAP实现 🛠️
了解了协议本身后,我们来看看它在LLDB中的具体实现。需要强调的是,LLDB DAP是一个社区共同努力的成果,目前是LLDB中最活跃的部分之一,得到了来自不同个人和公司的贡献。
首先,我们需要区分两个概念:
lldb-dap(全小写):这是调试适配器服务器本身。LLDB(全大写):这是Visual Studio Code的扩展插件。
接下来,我们将分别详细介绍它们。
调试适配器服务器:lldb-dap
lldb-dap是一个独立的二进制程序,它实现了调试适配器协议。它的作用是接收DAP请求,将其翻译成LLDB的稳定API调用,然后利用这些调用将响应返回给客户端。
本质上,lldb-dap充当了DAP协议与LLDB之间的适配器,因此得名。
- 该二进制程序从LLVM 19版本开始,成为LLVM发行版的一部分。
- 从Xcode 16.0开始,它也随Xcode命令行工具一同提供。
- 一个值得注意的特点是,
lldb-dap构建在LLDB稳定API之上。这意味着你可以更换底层的LLDB版本,这对于拥有自定义语言支持的下游分支非常有用。
任何需要与LLDB通信的工具,都可以通过这个服务器来进行。
Visual Studio Code扩展:LLDB
如果你使用Visual Studio Code,则需要下载LLDB扩展(全大写)。这个扩展使用TypeScript编写,它主要告诉VS Code如何使用lldb-dap服务器,并提供了一些额外的便利功能:
以下是该扩展提供的主要功能:
- 设置UI:你可以在其中指定
lldb-dap二进制文件的路径。 - 启用选项:可以配置一些调试选项。
- 启动配置模板:指导VS Code如何启动或附加到调试目标。
需要说明的是,该扩展不包含lldb-dap二进制文件。如果lldb-dap在你的系统路径中,扩展会自动找到它;否则,你需要手动指定路径。未来可能的解决方案是从最新的LLVM版本中自动下载该二进制文件。
实际演示 🎬
最后,让我们看看实际效果。下图展示了在Visual Studio Code中使用LLDB调试LLVM项目的情景。

所有你看到的UI元素(如变量查看、调用栈、断点)都是标准的。这一切的背后都是由lldb-dap驱动的。你还可以在底部的调试控制台中直接输入LLDB命令,获得对调试器的完全访问权限。
如果你还没有尝试过,请务必试试看。

总结 📝
本节课中我们一起学习了LLDB DAP。我们了解了DAP协议的目标和基本消息类型,通过一个示例观察了请求与响应的交互过程。接着,我们区分了lldb-dap调试适配器服务器和VS Code的LLDB扩展,并了解了它们各自的作用和获取方式。最后,我们看到了它在实际开发环境中的强大应用。LLDB DAP为开发者提供了一个标准化、跨平台的强大调试界面。
004:全新状态行功能详解

在本节课中,我们将学习LLDB调试器引入的全新状态行功能。我们将了解它的外观、配置方法、设计动机以及背后的实现原理。这个功能旨在提升调试体验,提供更清晰、更可定制的信息展示。
🎨 状态行外观与默认配置
上一节我们介绍了课程概述,本节中我们来看看状态行的具体外观。
状态行是位于终端屏幕底部的一个专用区域。它使用LLDB现有的格式字符串进行完全配置,这是一个你可能已经用来自定义帧或线程显示格式的概念。
以下是状态行默认包含的几个核心组件:
- 反色显示区域:状态行整体使用反色(或称负片)效果,即前景色与背景色互换。这利用了终端配色方案中已有的颜色,能确保在任何配色下都有良好的视觉效果。
- 目标名称:显示当前正在调试的目标程序名称。默认显示完整路径,但为了节省空间,可以配置为仅显示基础名称。
- 源代码位置:显示当前程序停止处的源代码文件和行号,格式为
{文件}:{行号}。 - 停止原因:显示程序停止的原因,例如遇到断点。
- 进度报告:当LLDB执行耗时操作(如加载符号)时,会在此处显示进度信息。如果当前没有进行中的操作,则该部分不显示。
状态行各组件之间使用竖线 | 分隔。一个重要的概念是作用域,它由花括号 {} 表示。作用域内的所有格式变量都必须能成功解析,该作用域(包括其分隔符)才会被打印出来。这避免了在信息缺失时显示多余的分隔符。
⚙️ 如何自定义状态行
了解了默认配置后,我们来看看如何根据个人喜好定制状态行。
自定义通过修改LLDB的 status-format 配置实现。格式字符串中可以使用变量来引用不同信息,例如 %T 代表目标,%S 代表源代码位置。
以下是一个自定义示例,它将状态行背景改为黑色,并添加了一个表情符号:
settings set status-format "🐛 \033[48;5;0m %T | %S | %B \033[0m"
在这个例子中:
\033[48;5;0m是设置背景色为黑色的ANSI转义码。%T是目标变量。%S是源代码位置变量。%B是停止原因变量。\033[0m用于重置颜色。- 作用域规则依然适用,确保信息缺失时布局整洁。
LLDB官网提供了完整的格式变量列表及其说明,你可以根据需要组合它们。
💡 功能设计动机
在深入技术实现之前,让我们探讨一下引入状态行功能的两大主要动机。
首先是为了改进进度事件的显示。此前,进度信息以内联方式显示在输出中,这种方式很脆弱。需要复杂的簿记来跟踪屏幕上显示的是哪个进度事件,以便在操作完成后清除它。当调试器自身和被测程序同时产生输出时,这变得非常棘手,有时甚至导致进度事件无法显示,给用户造成困惑。
其次是为了满足用户的个性化定制需求。许多用户一直希望能在LLDB提示符中使用格式字符串进行定制。而状态行提供了一个更合适、更强大的区域来实现个性化信息展示,不会干扰主要的命令和输出区域。
🛠️ 技术实现简介
状态行的实现没有使用成熟的终端UI库(如Ncurses),因为这类库通常会接管并清空整个屏幕,这与LLDB当前的工作方式差异太大。
相反,该功能直接使用了ANSI转义码,这与LLDB为多行编辑等功能所做的类似。其核心依赖于一个特定的转义序列:\033[?1049h 和 \033[?1049l(此处原文描述为“减少终端滚动窗口”,实质是使用替代屏幕缓冲区或类似技术来保留主屏幕内容)。通过将终端滚动区域减少一行,可以将最底部的一行“固定”下来,专供状态行使用。而其他所有输出则正常显示在上方的滚动区域内,互不干扰。
🚀 未来展望与总结
本节课中我们一起学习了LLDB状态行功能。目前该功能已在LLDB的主干代码中可用。
展望未来,主要工作将围绕格式字符串的扩展展开。随着用户开始使用和定制状态行,预计会出现暴露更多LLDB内部信息作为格式变量的需求。此外,计划支持默认值功能,以便在未加载目标时显示“无目标”等友好提示,而非空白。在功能设计征求反馈期间,许多用户也提出了对对齐和填充功能的支持需求,这将允许创建更复杂、更精美的状态行布局。
我们鼓励你尝试这个新功能。如果你遇到任何问题,或者有很酷的定制想法,请向LLDB项目提交问题报告或直接与开发者交流。


总结:LLDB的新状态行是一个可高度定制的信息显示区域,它通过格式字符串进行配置,利用作用域规则保持布局整洁,并直接使用ANSI转义码实现,以兼容LLDB现有的交互模式。它解决了进度显示不稳定的问题,并为用户提供了强大的个性化界面能力。
005:消除C/C++中整类内存安全漏洞的配方


在本教程中,我们将学习如何通过一系列技术和编程模型,来消除或大幅缓解C和C++语言中整类别的内存安全漏洞。我们将从内存安全的重要性谈起,逐步深入到具体的技术方案,包括初始化保证、边界安全、生命周期安全、类型安全和线程安全,并探讨如何将这些技术整合到异构代码库中。
概述:内存安全的挑战与策略
如今,个人计算设备无处不在,它们承载着我们生活中大量私密且关键的信息。这些设备互联互通,使得其中的安全漏洞可能被恶意攻击者利用,造成严重后果。内存安全问题处于这些安全挑战的最前沿。
世界上存在大量用内存不安全语言(如C/C++)编写的安全敏感代码。我们的内存安全策略是:完全的内存安全需要使用内存安全的语言,例如Swift。然而,有太多的C/C++/Objective-C代码无法全部重写。
因此,我们采取的策略是:在新代码中采用内存安全语言,并将高价值代码库重写为内存安全语言。这个策略很有效,但也带来了挑战。我们将其比喻为“内存安全岛屿”:绿色的安全代码岛屿,分布在广阔的、蓝色的不安全C/C++代码海洋中。
我们正在努力扩大这些岛屿,但也需要保护那片广阔的海洋。我们坚信无法使C/C++变得完全内存安全,但可以致力于消除整类别的漏洞。如果无法完全消除,也应尝试强力缓解,确保攻击者难以利用。
内存安全的五个维度
我们通常将内存安全视为五个不同的维度:初始化保证、边界安全、生命周期安全、类型安全(主要指转换安全)和线程安全。接下来,我们将逐一探讨这些方面,介绍我们为消除相关漏洞所做的工作,以及我们认为需要进一步努力的领域。
初始化保证:确保内存在使用前被初始化
初始化保证是指所有内存在被读取之前都已被初始化。我们开发了Clang编译器扩展 -ftrivial-auto-var-init 来保护栈内存,它保证将内存初始化为0,从而防止信息泄露攻击和许多栈修饰攻击。
我们也可以使用 -fzero-initialize-in-poniter 来保护堆内存。这种方法已在数亿行代码上部署,非常成功。然而,它并非完美。需要指出的是,0并不总是程序员的预期值。在我们的研究中,大约有20%的情况,零初始化不是正确的值,这代表了一个逻辑错误。尽管如此,它在防止因使用未初始化内存而导致的一些最严重安全问题上非常有效。
关于零初始化的更多信息,可以参考我们六年前在LLVM论坛上发布的RFC。
边界安全:确保访问不越界
边界安全确保程序员在访问内存区域时,只在该区域内操作,不会越界。我们为C和C++分别采取了两种不同的边界安全方法。
C语言的边界安全:-fbounds-safety 扩展
我们为C语言开发了 -fbounds-safety 语言扩展。用户通过代码注解为缓冲区和它们的边界建立关系。这些注解足以让编译器插入运行时边界检查,在越界内存访问时触发陷阱。
这不是纯粹的静态分析,而是确保编译器在编译时有足够的信息,以便在运行时生成适当的检查。为了降低采用成本,我们做了一些关键设计选择,使得采用该扩展的时间大约为每2000行代码一小时。苹果公司已在数百万行代码上采用了此扩展,包括我们操作系统内核中的数十万行代码。
以下是一个简单的C函数示例,它存在一个差一错误(off-by-one)溢出:
void fill_buffer(int *buffer, size_t count) {
for (size_t i = 0; i <= count; ++i) {
buffer[i] = 0;
}
}
程序员可以通过修改代码来告知编译器,count 参数表示 buffer 拥有的元素数量:
void fill_buffer(int *buffer __counted_by(count), size_t count) {
for (size_t i = 0; i <= count; ++i) {
buffer[i] = 0;
}
}
这就在 buffer 参数和 count 参数之间建立了关系。编译器会据此添加边界检查,确保访问不会越界。这种方法的好处是,注解的使用保留了函数的二进制接口和签名,允许增量采用。
为了减少注解负担,我们还开发了隐式宽指针技术。对于局部变量,编译器会隐式地将其转换为包含指针及其边界的“宽指针”。这意味着程序员只需要在ABI接口上注解边界,从而减少了代码修改和工作量。
关于此方法的更多信息,请参阅我们发布的Clang RFC。我的同事Yoll Na在EuroLLVM 2023的主题演讲中介绍了此方法,我的同事Herik和Patrick昨天也做了一个相关教程。
C++语言的边界安全:安全缓冲区编程模型
C++是一种比C更丰富的语言,拥有更丰富的库生态系统和语言特性。因此,我们为C++的边界安全采取了不同的方法。
我们构建了 -Wunsafe-buffer-usage 编译器警告和强化版Libc++库,并将这两者的组合称为 C++安全缓冲区编程模型。它保证了C++的边界安全。在这种方法中,编译器拒绝原始的指针运算。如果C++代码开启了此警告,编译器会发出警告。大多数用户将此警告视为错误,从而避免使用任何原始指针运算。
相反,程序员使用经过边界检查的标准库抽象,如 std::span 和 std::vector。这种方法已在数千万行代码上采用。就采用速度而言,我们发现它是双峰的。对于大量使用原始指针和 malloc 的“C风格C++”代码,采用成本大约是C语言 -fbounds-safety 扩展的两倍(即每小时约1000行代码)。但对于已经使用大量库抽象而非原始指针的现代C++代码库,采用速度非常快。
让我们看看它是如何工作的。这是之前同样的函数:
void fill_buffer(int *buffer, size_t count) {
for (size_t i = 0; i <= count; ++i) {
buffer[i] = 0;
}
}
为了使其边界安全,程序员会开启拒绝原始指针运算的警告。编译器会报错,指出在原始缓冲区上进行数组访问(本质是指针运算)。然后,程序员将 buffer 的类型改为 span,以表明它是一个连续的整数范围:
void fill_buffer(std::span<int> buffer) {
for (size_t i = 0; i <= buffer.size(); ++i) {
buffer[i] = 0;
}
}
在底层,通过使用强化版Libc++,span 的实现通过运算符重载来检查数组索引操作的边界。这就是为什么我们为C和C++选择不同方法的原因:在C++中,我们可以利用运算符重载和丰富的标准库,而不需要一个完整的语言扩展。
关于此方法的更多信息,请参阅Clang RFC,我们在LLVM 2023会议上也分别就强化版Libc++和不安全缓冲区使用警告做了演讲。
生命周期安全:防止释放后使用
生命周期安全是一个更棘手的问题。我将描述我们采取的两种不同技术,一种基于静态分析,另一种基于语言扩展。
基于静态分析的引用计数智能指针模型
过去几年,我们在Clang静态分析器中开发了一组检查器,用于围绕引用计数智能指针强制执行严格的编程模型。这不是传统的漏洞查找,而是试图强制执行编程模型,如果程序员遵守该模型,就能保证不会出现这类错误。这种方法已在数百万行代码上采用,相当成功。
以下是一个可能有错误的代码示例:
class Container {
std::shared_ptr<Resource> resource;
public:
Resource* getResource() { return resource.get(); }
void someMethod();
};
void example(Container& c) {
Resource* rawPtr = c.getResource(); // 获取原始指针
c.someMethod(); // 可能释放资源
rawPtr->use(); // 潜在的释放后使用
}
静态分析器会报错,指出在 rawPtr 的使用和可能释放资源的调用之间,无法保证资源不会被释放。程序员可以通过将 rawPtr 的类型改为 RefPtr(一种保证底层资源在作用域结束前存活的智能指针类)来修复此问题。
此实现在LLVM上游。我们已将其用于WebKit的智能指针,但我们认为它可以推广到各种其他引用计数的共享指针规范。
通过类型化分配器缓解释放后使用
对于不使用引用计数规范(如使用 malloc/free 的C代码)的情况,我们需要不同的方法。我们为C和C++开发了一个语言扩展,通过限制数据指针的类型混淆来缓解(而非消除)释放后使用漏洞。这意味着即使发生释放后使用,攻击者也很难利用它。
这种方法已部署在数亿行用户空间代码上,我们在XNU内核中也采用了类似的方法。
编译器部分的工作原理如下:它由一个新的类型化内存分配属性驱动,提供分配API的库供应商可以将此属性放在其分配函数上。该属性表明这是一个分配函数,并且分配大小通过第一个参数传递。库供应商还会提供另一个分配入口点(如 malloc_typed),它包含一个用于传递类型信息的第二个参数。
当编译器看到对标准分配函数(如 malloc)的调用时,它会透明地将其重写为对类型化配对函数(如 malloc_typed)的调用,并尝试推断被分配对象的类型,将类型描述符传递给分配器。
对于C++,我们有一个提案P2719,它扩展了语言,允许程序员提供模板化的 operator new 和 operator delete。该提案改变了查找规则,如果存在模板化的 operator new/delete,编译器将优先选择它们。由于它是模板化的,其实现可以使用模板参数将类型信息传递给分配器。
我们为此提交了Clang RFC,我的同事Oliver Hunt在LLVM 2024上就此做了演讲。我们正在与C++标准委员会合作将其标准化,预计它将成为C++26的一部分。
类型安全:安全的类型转换
这里所说的类型安全主要指类型转换安全。我们认为在这方面还没有非常完善的解决方案,尽管我们有一些进展。
我们开发了另一种静态分析来强制执行编程模型,它与WebKit的API配合,拒绝未经检查的转换,除非编译器能证明它们是安全的。这对于拥有运行时类型信息表示的C++代码库非常有效。然而,对于C代码库,或者没有运行时类型信息可供依赖的C++代码库,我们还没有可行的方案。这是一个需要社区进一步研究的领域。
线程安全:尚未解决的挑战
线程安全是最令人担忧的一个方面。我们目前对于如何处理线程安全还没有强有力的想法。我们知道它在许多情况下非常重要,线程安全问题可能导致释放后使用和内存泄漏,攻击者绝对可以利用这些漏洞。
这是一个需要更多研究的领域。Clang有 -Wthread-safety 警告,这很有用,但它并不能消除整类别的线程安全数据竞争错误。因此,我们的一般建议是:如果需要线程安全,就应该使用线程安全的语言,例如Swift并发性的Actor模型可以提供数据竞争自由。
但我们认为对于C和C++还可以做更多工作,只是目前还不确定具体是什么。随着我们在边界安全和生命周期安全方面堵住漏洞,攻击者将越来越多地转向关注线程安全,这将变得越来越重要。
异构代码库中的内存安全
让我们回到“内存安全岛屿”的比喻。我们的代码库越来越多地混合了用内存安全语言编写的代码和部分安全的C/C++代码。我们希望,当C/C++与内存安全语言交互时,这种互操作是安全的,并能保留我们在C/C++中辛苦提供的部分内存安全性。
为了使用这个比喻,我们需要保护海滩。仅仅建造绿色岛屿和部分保护海洋是不够的,我们还需要在海滩上采取强有力的措施。
在苹果,我们混合使用多种内存安全技术,代码库通常包含所有这些技术。我们需要确保这些技术之间的互操作是安全的。例如,我们可以将带有边界安全注解的C类型作为Swift的 Span 类型导入到Swift中。当Swift调用C++时,我们可以利用生命周期注解使其更安全。我们还需要保护边界安全的C和强化版C++之间的接口。
我们已经在这方面做了很多工作,例如开发了一系列注解,以使C/C++与Swift等内存安全语言的接口更安全。
严格编程模型的配方

回顾以上方法,其底层是一个我们开发的“配方”:强制执行严格的编程模型。一个编程模型通常包含三个要素,以消除整类漏洞:
- 编译器可检查的局部规则:这些规则通常将程序员限制在语言的一个子集内,通过限制可以做出强有力的保证。我们应该将其视为一种编程语言特性,而非漏洞查找工具。
- 开发者提供的注解:编译器通常需要更多信息,这些是程序员头脑中期望成立的关键不变量。注解使编译器能够对程序进行推理,实现模块化推理。
- 运行时支持:编译时检查通常不够,因为工具无法精确推理程序行为。我们需要设计运行时抽象来处理困难情况,并在运行时进行检查。
让我们看看几个方法如何符合这个配方:
- C边界安全扩展:
- 局部规则:程序员只能解引用已知边界的指针。
- 开发者注解:以边界属性的形式提供。
- 运行时支持:编译器生成解引用时的越界陷阱。
- C++安全缓冲区模型:
- 局部规则:禁止原始指针运算。
- 开发者注解:采用
span工具类来表示缓冲区。 - 运行时支持:强化标准库中
span实现的边界检查。
- WebKit智能指针分析:
- 局部规则:原始指针不得在未知调用期间保留在栈上。
- 开发者注解:采用
RefPtr工具类。 - 运行时支持:
RefPtr实现本身处理保持原始指针存活的簿记工作。
这是一个相当通用的配方。我向社区提出的一个挑战是:思考是否还有其他安全问题可以应用这种配方。我的直觉是,我们还可以做更多,这或许能启发许多不同的方法来消除整类安全漏洞。
采用成本与自动化推断工具
我们发现,采用这些严格编程模型的最大限制因素是程序员的工作量。例如,零初始化或类型化分配器需要采用者做的工作相对较少,因此容易部署到数亿行代码。而边界安全方法虽然功能强大,但需要程序员做更多的采用工作,因此目前的采用范围是数百万到数千万行代码。
这表明,为了让这些编程模型在工业界广泛采用,我们必须投资于工具链以降低采用成本。社区需要在这方面共同努力。最终,我们需要构建注解推断工具。
主要瓶颈在于让程序员实际写下表达其不变量的注解。构建此类工具的问题在于,尽管注解可以局部检查,但它们通常必须全局推断。一个函数的适当注解可能取决于调用者的情况,反之亦然。
我的一个建议是,在LLVM/Clang中扩展基础设施,实现基于摘要的推断工具。这类似于应用于前端的“瘦LTO”思想。编译器在编译每个文件时,可以发出一个关于每个函数从安全角度所做之事的摘要。然后,在构建的一个阶段合并这些摘要,迭代至固定点以推断注解,并自动将这些注解应用到每个文件。
这种基础设施具有广泛的适用性。它可以用于Clang静态分析器,以实现跨翻译单元分析,从而扩展到更大的代码库并降低维护成本,同时减少误报和漏报。它对于与Swift等内存安全语言的安全互操作也极为有用。
总结
本节课我们一起学习了如何应对C/C++中的内存安全挑战。
内存安全对我们社区来说是一个非常重要的问题。我坚信,完全的内存安全只能通过像Swift、Rust这样的内存安全语言来实现。然而,我们拥有大量现有的C/C++代码,因此需要尽最大努力保护它们。同时,我们还需要确保现有C/C++代码与用安全语言编写的新代码之间具有安全、符合人体工程学的互操作性。
实现这一目标的最佳方法之一是使用严格的编程模型。这为消除C/C++中整类内存安全漏洞提供了一个配方。同样,它不会使这些语言完全内存安全,但可以迫使攻击者寻找完全不同类型的漏洞来利用。
一个典型的编程模型包含三个组成部分:一组局部规则、开发者提供的注解和运行时支持。根据我们的经验,开发者的注解成本是采用的主要限制因素。因此,我们作为社区,绝对应该投资于自动推断工具。
通过结合使用内存安全语言、对遗留代码应用严格编程模型,以及投资于降低采用成本的工具,我们可以显著提高攻击者的攻击成本,从而更好地保护我们的系统和用户。


006:将 XRay 集成到 HPC 工具生态系统中


概述
在本教程中,我们将学习 LLVM XRay 的基本概念、它在高性能计算(HPC)性能分析工具中的应用,以及如何将其作为第三方工具进行集成。我们将探讨 XRay 的混合插桩技术、其优势、当前工具的局限性,以及未来的改进方向。
XRay 简介 🧐
XRay 是 LLVM 编译器工具链中的一个集成追踪器。它采用了一种混合插桩方法,在编译时插入静态的、可运行时修补的探针点,从而实现选择性追踪。
XRay 包含几个核心组件:插桩机制本身、一个带有内置追踪器的运行时库,以及用于转换和分析收集数据的工具。它最初由 Google 开发,用于生产环境中的性能调试。
插桩机制详解 ⚙️
上一节我们介绍了 XRay 的基本概念,本节中我们来看看其核心的插桩机制是如何工作的。
XRay 采用了一种有趣的混合静态-动态方法。它在编译时插入称为“雪橇”的指令序列,这些序列由空操作组成。在运行时,可以通过将这些“雪橇”替换为对性能分析处理器的调用来动态激活追踪。
以下是一个代码示例,展示了 main 函数入口处被插入的“雪橇”:
main:
nop
nop
nop
... ; 其他指令
当运行时进行修补后,XRay 运行时会插入一个调用,传递唯一的函数标识符给追踪函数,从而调用运行时库。
这种机制带来了几个关键优势:
- 近乎零开销:当“雪橇”未被激活时,对性能几乎没有影响。
- 非侵入性:与完全动态的二进制插桩相比,它不需要重新排序或重新编译二进制文件。
- 线程安全与快速:修补操作是线程安全的且速度很快,可以在程序运行的任何时刻进行。
HPC 性能分析工具的现状 🛠️
了解了 XRay 的原理后,我们来看看当前 HPC 领域主流性能分析工具是如何处理插桩的。
以下是几个代表性工具及其特点:
- Score-P 和 TAU:广泛使用的性能分析和追踪工具。
- HPCToolkit:用于收集高层级性能指标的自然工具。
- 这些工具都支持 MPI、OpenMP 等并行编程模型。
对于 MPI 和 OpenMP,存在 PMPI 和 OMPT 等标准化或半标准化的插桩接口。然而,对于通用区域的插桩,情况则更为复杂。
许多工具历史上(甚至现在)依赖于编译器的 -finstrument-functions 标志。该标志会在每个函数的入口和出口点插入性能分析探针。虽然被大多数编译器支持,但它存在显著缺点:
- 控制有限:通常会插桩所有函数。
- 高开销:即使进行动态过滤,调用处理器本身的开销依然存在。
现有解决方案与 XRay 的潜力 💡
面对 -finstrument-functions 的局限性,不同工具提出了自己的解决方案。
例如,Score-P 为 GCC 和 LLVM 开发了自定义编译器插件,在调用点进行快速动态过滤,显著降低了开销。也有一些工具使用完全的动态二进制插桩,但这可能有些“杀鸡用牛刀”,且容易出错。
此外,这些工具通常也提供手动插桩 API。
那么,编译器能否提供更好的基础设施呢?LLVM 已经为内部工具提供了 XRay,并且它也可以被第三方工具使用。因此,我们可以思考:能否用 XRay 作为 -finstrument-functions 的更强大替代品?
重申 XRay 的优势:快速的动态调整、未激活时的低开销,以及无需巨大集成努力即可获得这些好处。本质上,XRay 可以看作是“-finstrument-functions 的增强版”。
XRay 的挑战与改进方向 🚀
既然 XRay 如此优秀,为何目前几乎没有工具使用它呢?
原因主要有两方面:
- 许多工具在 XRay 出现之前就已开发,依赖于基础接口或自建方案。
- XRay 作为第三方工具存在一些需要解决的缺陷和限制。
我个人认为 XRay 潜力巨大,并正在从两个方面推动改进:
- 增强核心能力:例如,最近我们实现了对共享库的 XRay 插桩支持。
- 促进集成应用:通过将其集成到现有工具中,展示其益处。
接下来,我们将更详细地探讨共享库支持和 Score-P 集成这两个具体方面。
共享库插桩支持 📚
我们最近将共享库插桩支持功能合并到了上游 LLVM,并在 LLVM 20 中可用。只需传递 -fxray-instrument-shared 标志即可。
其技术细节如下:在可执行文件中,XRay 运行时维护一个关于可修补函数的列表。在共享库中,一个小的运行时组件会在库加载时向主运行时注册,告知其可用的“雪橇”和地址。
基于加载顺序,每个动态库对象获得一个动态 ID。我们使用这个动态对象 ID 和静态函数 ID 组合成一个“打包 ID”,以唯一标识整个应用程序中的每个函数。
修补过程类似,主要区别在于主运行时会遍历所有已注册的对象并进行修补。目前该功能支持 X86 和 RISC-V 架构。
Score-P 中的 XRay 后端集成 🔌
我们已将 XRay 后端集成到 Score-P 中,代码已在 GitHub 上可用。使用起来很简单,只需传递一个标志即可自动启用。
我们通过评估来回答一个问题:这个 XRay 后端与高度优化的、工具特定的静态解决方案相比如何?
我们将其与 Score-P 的 Clang/GCC 插件进行了比较。这些插件是经过打磨的解决方案,嵌入了区域信息,并在调用点进行快速动态过滤。
我们观察了 SPEC 和 LULESH 等多个基准测试。在一个动态过滤的配置中,XRay 至少与静态插件性能相当,有时更优。在完全过滤(即无任何追踪)的配置下,XRay 没有可测量的开销,而静态插件仍有高达 6% 的开销。这 0% 的开销允许使用同一个二进制文件进行性能分析和生产运行,极大地简化了工作流程。
未来工作:提升插桩灵活性 🔮
为了使 XRay 成为第三方工具的通用后端,需要提升其灵活性。目前,XRay 仅支持在函数级别插入调用点。
可能的扩展方向包括:
- 允许通用区域插桩,例如通过
-fxray-instrument-loops标志支持循环插桩。 - 建立一个更好的系统,允许通过内部函数在 LLVM IR 中放置测量点,然后将其转换为工具可以测量的区域。
总结
本节课中,我们一起学习了 LLVM XRay 的核心概念及其混合插桩机制。我们探讨了当前 HPC 性能分析工具在插桩方面面临的挑战,以及 XRay 如何作为一个高性能、低开销的替代方案。我们详细介绍了新加入的共享库插桩支持,以及 XRay 在 Score-P 工具中的集成效果和性能表现。最后,我们展望了未来通过提升插桩灵活性,使 XRay 成为更强大通用后端的可能性。




007:支持多线程代码 🧵

概述
在本节课中,我们将要学习如何扩展Clang静态分析器,使其能够分析多线程代码。我们将探讨当前分析器在处理多线程代码时的局限性,并介绍一种通过内联线程函数来合并程序状态图的方法,从而让现有的检查器能够跨线程边界发现错误。
当前面临的挑战 🚧
上一节我们概述了目标,本节中我们来看看当前静态分析器在处理多线程代码时遇到的主要障碍。
静态分析器会遍历代码,并为程序的所有可能状态(包括变量和内存)构建一个状态图。当遇到多线程代码时,分析器会分别访问父线程和子线程的代码。但问题在于,它会为每个线程创建独立的状态图。每个线程的状态被隔离在自己的图中。如果我们只有完全分离的线程图且无法连接它们,就无法开始进行跨线程分析。
我们的解决方案:合并状态图 🔗
既然我们知道了问题所在,本节中我们将探讨如何连接这些分离的线程状态图。
我们的目标很简单:合并两个独立的图。我们有一个强大的工具:我们可以将线程执行的函数内联到调用点。这样,我们就合并了状态图,现在所有的程序状态都统一了,所有现有的检查器无需修改就能正常工作。
以下是实现这一目标的核心思路:
// 伪代码:在线程创建点,内联线程函数
inlineThreadFunction(thread_func, user_data) {
// 创建代表内联函数调用的表达式
CallExpr *CE = createCallExpr(thread_func, user_data);
// 在分析器中执行内联,合并控制流图
analyzer.inlineCall(CE);
}
实现尝试与演进 🛠️
上一节我们提出了解决方案,本节中我们来看看具体的实现尝试和遇到的挑战。
我们的第一次尝试是将其实现为一个“建模检查器”。我们尝试封装所有平台特定的状态并对其进行建模。这适用于像Pthreads这样的常见线程库。我们提取所有参数,然后尝试内联函数。但我们遇到了障碍:内联功能并不真正对检查器可用,它被限制在分析器核心部分。更重要的是,检查器的设计初衷并不是用来影响符号执行的控制流的。
因此,我们需要重新思考。我们的第二次尝试是直接将代码放入分析器核心。我们添加了一个配置标志让用户选择启用,然后基本上复制了之前的代码。问题在于代码应该放在哪里?我们将其放在了检查器原本会进行评估的地方。我们有一个默认的函数调用评估步骤,在那里进行内联,然后尝试分派给各个检查器。我们就把代码放在那里。
测试与结果 📊
在实现了核心机制之后,我们需要验证它是否有效。以下是我们的测试方法:
我们使用LLVM的集成测试框架来检查分析器的内部状态。我们可以通过一个简单的例子来展示我们确实内联了代码。
在初步测试之后,我们开始尝试更复杂的例子。我们添加了一些小错误,并展示我们能够使现有的检查器跨线程边界检测到这些错误。
我们测试了以下类型的检查器:
- 释放后使用
- 空指针解引用
- 污点分析
- 大多数核心的未初始化值检查
完成了这些小规模测试后,我们需要在真实代码上进行测试。首先,我们尝试在MC上测试,它导致了一次崩溃,但不幸的是,崩溃发生在我们的代码中,而不是MC中。修复了这个bug之后,在真实世界代码的测试中,我们没有发现更多错误。
发现与未来方向 🧭
经过测试,我们得到了以下发现:
我们没有看到显著的差异。这是一个好迹象,意味着我们没有丢失分析结果。我们也没有看到有意义的性能开销,尽管这里有一个重要的警告:我没有时间对此进行适当的性能分析,所以在某些极端情况下可能会遇到性能瓶颈,但在常见情况下,差异可以忽略不计。

我们确实证明了我们可以发现真实的错误。例如,我们以LZ4为例,在其线程池代码中,我们移除了一个空指针检查,并在线程初始化时插入了一个空指针,分析器成功发现了它。
但总的来说,在开源代码中,这不会发现太多有趣的错误,因为现实情况下,你主要看到的是初始化代码,而这部分通常很容易测试,问题也容易被发现。不过,这是一个很好的起点,在某些特定情况和平台上,你肯定能够发现很多有趣的问题。
从这里出发,我们的目标是将此功能上游化到主代码库,以此作为进行多线程分析工作的起点。之后,我们可以实现更好的分析和更复杂的检查。一旦上游化完成,我们希望开始编写更多的多线程检查,建模更多内容,并以此驱动未来的发展方向,而不是一开始就试图实现所有高级功能。
总结
本节课中我们一起学习了如何扩展Clang静态分析器以支持多线程代码分析。我们了解了当前分析器因线程状态图分离而无法进行跨线程分析的局限性。我们提出的解决方案是在线程创建点内联线程函数,从而合并父线程和子线程的状态图。我们探讨了将该功能实现为检查器或集成到分析器核心的尝试,并展示了该方法能够使现有检查器跨线程检测错误。虽然在实际代码中发现的重大错误有限,但这为未来更强大的多线程静态分析奠定了坚实的基础。
008:指令缓存预取


概述
在本节课中,我们将要学习一种名为“指令缓存预取”的编译器优化技术。这项技术旨在通过软件方式,减少CPU在执行程序时因指令缓存未命中而导致的性能损失,从而提升程序运行效率。我们将从指令缓存的基本概念开始,逐步深入到预取指令的原理、实现时用到的LLVM分析工具,以及最终的性能收益。
指令缓存基础 🧠
上一节我们概述了课程目标,本节中我们来看看指令缓存是什么。
指令缓存是CPU内部一个快速但容量较小的存储部件。与数据缓存不同,它专门用于存储即将被CPU执行的指令。
让我们通过一个例子来总结其工作流程:
- 在指令获取阶段,CPU需要找到下一条要执行的指令。
- 它首先会查询指令缓存。
- 如果指令已经在缓存中(缓存命中),CPU可以快速获取它并进入解码阶段。
- 如果指令不在缓存中(缓存未命中),CPU就必须访问更慢的主内存来获取指令,这会浪费大量时钟周期,之后才会更新指令缓存并继续执行。
这项优化的核心目标就是尽可能减少缓存未命中的发生。
硬件方案与软件方案 ⚙️
了解了缓存未命中的代价后,我们自然会问:如何让缓存保持更新?
在通用CPU中,通常有专用的硬件单元来负责预取指令,在CPU实际需要之前就将指令加载到缓存中,效果很好。然而,这种硬件方案成本高昂,需要更多的芯片面积和功耗。
因此,许多定制化或嵌入式CPU无法负担这样的硬件。它们往往在遇到缓存未命中时才去处理,导致性能损失。我们面临的正是这个问题,因此需要寻找一个软件解决方案。
这个解决方案就是插入预取指令。
预取指令详解 🎯
上一节我们提到了软件解决方案的方向,本节中我们来看看核心的预取指令。
首先,预取指令只是一个提示,它不会改变程序的正确性。它的作用是提示处理器将特定地址的指令提前加载到指令缓存中。
以下是该指令的关键点:
- 语法:非常简单,类似于跳转指令,例如
prefetch label。 - 语义:它指示处理器将与
label标签关联的指令块(缓存行)取到指令缓存中。 - 关键要求:它必须是一个非阻塞指令。这意味着CPU执行它之后会立刻继续执行下一条指令,而预取操作则在后台进行。这正是性能提升的来源。
如果还不清楚,请看这个简单例子:
start:
prefetch label2 ; 提示预取 label2 的代码
... (执行 cache line N 的代码) ...
jmp label2
label2:
... (执行 cache line N+1 的代码) ...
我们在代码开头插入了预取指令。当CPU执行它时,会开始将label2处的代码(缓存行N+1)在后台加载到指令缓存。与此同时,CPU继续执行当前的代码。最后,当程序跳转到label2时,所需的指令很可能已经在缓存中了,从而避免了缓存未命中。
因此,这项优化的核心就是在合适的位置插入预取指令。
实用的LLVM分析工具 🔧
知道了要插入预取指令,但如何找到“合适的位置”呢?本节中我们来看看在实现中非常有用的几种LLVM分析。
以下是几种需要考虑的情况及对应的分析方法:
情况一:循环中的基本块
假设我们有一个基本块,我们已经为它的下一个缓存行插入了预取指令。这在第一次进入该基本块时是有效的。但如果这个基本块位于一个循环中,从第二次迭代开始,目标指令早已在缓存里了,此时预取指令就是在浪费周期。我们只想在预取确实有益时才插入它,否则可能导致“缓存污染”,踢出仍有用的数据。
- 解决方法:使用
MachineLoopInfo分析。它可以告诉我们一个基本块是否属于循环,从而帮助我们做出更明智的插入决策。
情况二:控制流与支配关系
假设我们想在基本块BB1中插入指令,预取基本块BB2的代码。这看起来合理。但如果有另一个基本块BB3也跳转到BB2呢?程序可能先执行BB3再执行BB2,使得BB2的代码已被缓存。这时再在BB1中执行预取就是浪费的。
- 解决方法:使用
MachineDominatorTree分析。它可以判断BB1是否支配BB2。支配意味着所有执行流在到达BB2之前都必须经过BB1。如果是这种情况,在BB1中插入预取指令就更安全。
情况三:条件分支的概率
假设BB1通过条件跳转指令跳转到BB2。我们并不确定执行完BB1后一定会执行BB2。这时是否插入预取指令就不那么明确了。
- 解决方法:如果使用了配置文件引导优化,可以利用
MachineBranchProbabilityInfo分析。它能提供从BB1跳转到BB2的估计概率。例如,如果概率是95%,我们可能就认为值得插入预取指令。
性能收益 📈
在应用了上述分析并谨慎地插入预取指令后,我们来看最终的优化效果。
在指令缓存性能方面,我们成功将由指令缓存未命中引起的停顿周期减少了45%。值得注意的是,我们的插入策略并不激进,仍有提升空间。
在整体程序性能上,我们获得了约3%到4%的周期数减少,即总性能提升。需要说明的是,优化效果取决于程序本身:受指令缓存未命中影响越严重的程序,这项优化的潜力就越大。
总结
本节课中我们一起学习了指令缓存预取优化技术。
我们了解到,先进CPU通常用硬件高效管理指令缓存,而简单CPU则缺乏这种能力,需要软件解决方案。我们看到,可以利用LLVM框架的分析能力,相对便捷地实现此类优化。最后,实践表明,通过编译器插入预取指令的软件方案是一个可行的选择,能够带来可观的性能提升。


注:本教程省略了部分实现细节和假设。如需深入了解,请参考相关文献或联系原作者。
009:基于轨迹的运行时性能精确估计


在本教程中,我们将学习如何利用机器学习模型替代LLVM寄存器分配器中的启发式算法,并重点探讨用于模型训练的核心环节——运行时性能的精确估计方法。
概述
传统的编译器优化(如寄存器分配)依赖于人工设计的启发式规则。本教程介绍一种新方法:使用机器学习模型替代这些启发式规则,并通过强化学习进行训练。训练的关键在于需要一个能够快速、准确评估代码性能变化的成本模型,而不是直接运行耗时的宏观基准测试。
上一节我们介绍了用机器学习模型替代启发式算法的整体构想,本节中我们来看看如何构建用于训练反馈的运行时性能估计模型。
为何需要成本模型?
在强化学习训练循环中,模型需要即时反馈(奖励)来判断其决策(如寄存器分配方案)的优劣。直接运行完整的基准测试不切实际,原因如下:
- 奖励信号至关重要:不准确的奖励会导致训练出无效的模型。
- 宏观基准测试不具代表性:微型基准测试无法反映真实应用的复杂行为。
- 真实应用过于庞大:在紧密的训练循环中反复编译和运行大型应用效率极低。
因此,构建一个高效的成本模型来预测代码性能变化是必由之路。
基于轨迹的成本建模
我们采用基于轨迹的成本建模方法。与静态分析可执行文件不同,我们分析动态的指令流轨迹。
- 先前工作:有研究尝试用机器学习模型替代启发式算法,并使用性能导向优化(PGO)数据。其奖励函数是指令类型的线性组合,并混合了基本块频率信息。
- 我们的方法:我们采用纯粹的轨迹分析方法。捕获程序运行时的指令序列(轨迹),将其输入到一个分析型CPU流水线模型或基于机器学习的CPU模型中,最终输出预测的周期数。这个周期数即可用于判断模型决策使代码变快还是变慢。
轨迹数据来源与基本块追踪模型
我们使用Dyna Oo工具来收集运行时轨迹数据,这得益于其良好的内部支持。
我们需要一种能力:针对一个具有不同寄存器分配方案的新二进制文件,能够重构出其对应的指令流轨迹,而无需重新运行程序。以下是实现方法:
我们首先将原始的指令流轨迹转换成一个基本块序列。然后,我们可以针对新的二进制文件“回放”这个基本块序列,从而得到新二进制文件在该执行路径下的指令流。
以下是实现过程中的关键步骤和假设:
- 识别基本块:分析指令流,识别基本块的起始位置,从而创建基本块流。
- 处理特殊情况:在某些位置需要分割基本块,例如:
- 调用指令周围:控制流在基本块层面无法充分体现。
- 内联汇编:编译器将其视为单个基本块,但其内部可能包含控制流。
- 回放基本块序列:获得新二进制文件的指令流。
然而,这里存在一个核心挑战:如果优化过程(不仅仅是寄存器分配)改变了控制流图,那么新的指令流将与原始轨迹无法对应。
以下是控制流图发生变化的例子,其中大部分在仅改变寄存器分配器时是可协调的:
原始控制流: A -> B -> C
新控制流: A -> B -> D -> C
但有些编译器优化(例如基本块布局优化)会导致难以协调的复杂变化。
我们的解决方案是暂时规避此问题:在训练阶段,禁用那些在寄存器分配之后会引发控制流图变化的优化通道。我们禁用了三个通道(包括部分功能,如Shrink Wrap和区域分割)。我们认为这些优化与寄存器分配器的性能是解耦的。因此,理论上在此设置下训练的模型,能够推广到启用这些优化通道的二进制文件上。
模型验证与关联性研究
在建立方法后,我们进行了验证,评估从基本块轨迹重构指令流的准确度。结果显示约有0.5%的指令缺失,主要源于:
- 汇编文件中定义的符号(无基本块信息)。
- 不可重定位的指令序列。
由于无法改变内联汇编或外部汇编文件的寄存器分配,这些问题优先级不高。
接着,我们进行了关联性研究,以验证成本模型的预测能力:
- 生成一系列具有不同寄存器分配方案的二进制文件。
- 实际运行这些文件并进行基准测试,记录真实性能数据。
- 使用我们的成本模型预测每个二进制文件的性能变化。
- 比较预测值与真实值的关联程度。
我们在一个高度优化的二进制文件(使用PGO、CSPGO和ThinLTO)上进行了测试,获得了良好的R平方值,表明预测模型是有效的。在非PGO场景下,预测效果更佳。
我们还将此方法应用于一个真实的大型工作负载——LLVM优化器本身(包含约1000万条退休指令),验证了其处理非玩具级应用的能力。
集成到强化学习训练循环
拥有一个具备预测能力的成本模型后,即可将其集成到强化学习循环中,用于训练机器学习模型。
以下是训练流程的步骤:
- 构建代码语料库:收集目标应用程序中所有需要优化的翻译单元(Translation Units)及其对应的指令轨迹。在ThinLTO背景下,我们使用导入后、优化前的比特码文件。
- 语料库子集化:为了评估一个寄存器分配策略,需要重新编译二进制文件中的所有目标文件,这很耗时。我们发现,实际上只需要处理轨迹中出现的函数所属的模块(约占总模块的10%)。此步骤大幅提升了效率。
- 应用机器学习算法:我们使用进化策略作为训练算法。ES擅长处理长轨迹和延迟奖励的场景,即模型做出数百万个分配决策后,才得到一个总体奖励信号。这比需要快速反馈的某些基于梯度的强化学习算法更合适。
- 训练与结果:我们在之前用于关联性研究的
opt二进制文件上进行了初步实验。经过训练,我们获得了约0.5% 的真实性能提升。随后,在一个内部服务器应用程序上,通过调整强化学习超参数,我们训练出了一个更有效的模型,在多个内部服务器应用基准测试中取得了约0.5%至0.7% 的性能提升。虽然百分比看似不大,但在超大规模计算的背景下意义显著。
未来优化方向
当前工作仅是初步尝试,我们相信还有提升空间。未来的优化方向包括:
- 加速训练过程:
- 不仅对语料库进行子集化,还可以只编译轨迹中实际出现的函数。
- 只编译那些包含寄存器分配决策的函数。
- 降低建模成本:目前使用的LLVM MCA分析工具开销较大,需要寻找更高效的替代方案。
- 推广到其他优化:这是未来的主要挑战。控制流图协调问题是将其推广到其他可能根本性改变控制流图的优化(而不像寄存器分配主要插入零长度基本块)的关键。我们需要研究更通用的解决方案。
- 改进成本建模技术:探索更精确、更快速的性能预测模型。
总结



本节课中我们一起学习了如何为机器学习驱动的编译器优化构建运行时性能估计模型。我们重点介绍了基于轨迹的成本建模方法,包括从动态指令流构建基本块序列、处理控制流变化挑战、验证模型预测准确性,以及最终将其集成到进化策略训练循环中以训练有效的寄存器分配模型。这套方法为使用机器学习替代传统编译器启发式规则提供了可行的技术路径,并在实际应用中展示了初步的性能收益。未来的工作将集中于提高效率、降低开销,并将此方法泛化到更多的编译器优化场景中。
010:新预合并系统详解



在本节课程中,我们将详细介绍LLVM项目正在开发的新预合并(pre-merge)系统。该系统旨在解决现有系统在队列时间、资源利用率和稳定性方面的问题,并引入一系列改进措施。
🏗️ 系统现状与并行测试
目前,新系统正处于测试阶段。为了确保稳定性,新旧两套系统正在并行运行。
- 旧系统:基于 Buildkite 平台运行。
- 新系统:基于 GitHub Actions 平台运行,目前处于测试状态。
如果你最近提交过拉取请求(PR),可能会看到 Buildkite 的检查项,其下方还会有标记为“测试专用,请忽略结果”的 GitHub Actions 检查项。这种并行运行模式主要用于测试新基础设施的稳定性。目前,新系统将所有任务标记为“通过”,以避免在系统尚不稳定时因基础设施故障向开发者发送大量失败通知邮件。
🎯 新系统的核心特性与改进
上一节我们介绍了新系统的测试现状,本节中我们来看看新系统旨在实现的核心特性和改进。
新版本基础设施有几个我们认为特别重要的特性:
- 自动扩缩容:这是最重要的改进。新系统可以根据需求动态调整可用机器数量。在固定预算内,我们可以将资源集中在高峰时段(通常是欧洲和太平洋工作日的开始时间以及太平洋时间的大部分工作时间)使用。这能显著降低任务延迟,并更有效地利用资源。相比之下,当前的预合并系统全天候运行大量机器,资源利用率不高。
- 组织架构支持:新系统在组织层面也有两大改进。
- 建立待命轮值制度:设立待命(on-call)轮值,在工作时间提供支持。
- 配备专职工程人员:安排专职工程人员进行系统维护和改进。这是当前系统所缺乏的,过去一两年基本处于无人维护状态,导致了一些问题。专职的维护流程将有助于未来系统的平稳运行。
📊 监控、告警与脚本优化
为了保障新系统的运行,我们建立了监控体系并对核心脚本进行了优化。
我们有一个公开的 Grafana 仪表板,用于展示关键指标。你可以在 LLVM 的 pre-merge 目录下找到链接。这些指标包括过去社区非常关心的队列时间、运行时间,未来还可能包括失败率(高失败率通常意味着基础设施问题)以及主线构建失败情况。这些指标与告警系统联动,如果队列时间激增或出现其他基础设施问题,告警会触发并在工作时间内通知相关人员进行调查。
此外,大部分改进同时影响了新旧系统,因为它们运行着相同的 Shell 脚本。以下是已完成的一项主要改进:
- 任务计算脚本重构:预合并系统有一个复杂的任务计算脚本,用于根据更改的文件决定需要测试哪些项目(基本上是测试被改动的项目及其依赖项)。该脚本原是一个难以理解的 Shell 脚本,近期已被重写,现在它经过了单元测试,可读性和可维护性都更好了。围绕它还进行了许多其他杂项改进。
🔮 未来探索方向
除了已实现的改进,团队还在探索以下几个方向以进一步提升系统性能:
- 优化节点与容器启动时间:在使用自动扩缩容时,如果需要启动新的 Windows 节点来运行工作流,可能会有大约15分钟的延迟。我们正在研究如何改善这一点,这在非高峰时段需要启动新节点时尤为重要。
- 提供复现指南:过去的一个痛点是难以在本地复现预合并检查的失败以进行调试。由于所有任务都在容器中运行,我们可以编写非常精确的指南,指导如何在大多数机器上复现这些问题。
- 加速 Windows 工具链:旨在降低 Windows 预合并检查的延迟,特别是在编译器缓存未充分预热的情况下。
🚀 正式启动计划与社区决策
关于新系统的正式启动,其含义是使新系统具有权威性。具体来说,我们将不再忽略新系统的失败结果,而是会将其作为真正的失败状态报告给 GitHub,并同时关闭旧的预合并基础设施。
此外,近期出现了一个关于预合并延迟与测试覆盖率的讨论。随着项目增加,需要在两者之间做出权衡。更多的测试覆盖率固然好,但会导致预合并任务延迟增加。谷歌的目标是提供一个高可靠性、高性能的系统,但关于延迟与测试彻底性之间的权衡,我们希望最终由社区决定。目前的处理方式是,通常通过 PR 讨论达成共识。如果出现争议,我们将把覆盖范围的决策权交给 LLVM 治理机构,即基础设施领域的负责人。
🤝 贡献者致谢
这个项目的推进离不开许多贡献者的努力。在谷歌内部,Caroline、Lucil 和 Nathan 都做出了重要贡献。在开源社区方面,David Biot 和 Tom Stard 在评审大量补丁等方面提供了极大帮助。当然,还有许多其他社区成员也参与了贡献。我们希望继续推进这项工作,使其成为对所有 LLVM 贡献者都高效可用的系统。




本节课中我们一起学习了LLVM新预合并系统的设计目标、当前状态、核心改进、监控手段、未来规划以及启动策略。新系统通过自动扩缩容、强化组织支持和改进工具链,致力于为开发者提供更快速、更稳定的代码合并前检查体验。
011:连接LLVM与SPIR-V以实现异构计算


概述
在本教程中,我们将学习LLVM SPIR-V后端项目。SPIR-V后端已成为一个官方目标,用于生成SPIR-V的工具。我们计划将其扩展到实际应用中,包括深度学习工作负载,并希望看到其他实现,如使用该后端的Adaptive C++。我们的总体目标是提升质量、性能和功能。本教程将涵盖SPIR-V后端的技术和社区方面,讨论项目面临的挑战、解决方案和集成工作。
项目背景与挑战
上一节我们介绍了项目的总体目标。本节中,我们来看看项目所处的背景和面临的核心挑战。
异构计算领域,编译过程复杂,需要一个统一的代码表示形式,以便在各种后端API和硬件上实现计算内核。一个关键挑战是决定在编译流程的哪个阶段进行何种转换,因为代码会从后端无关的中间表示(IR)转换为设备特定的二进制文件。
SPIR-V作为一种抽象指令集,隐藏了特定供应商指令集和语言的细节,为此提供了解决方案。SPIR-V 是一种用于计算和图形的跨供应商、可移植的中间表示。LLVM SPIR-V后端 则是LLVM工具链中此前缺失的一环。
我们曾使用Khronos Translator来生成SPIR-V,但这增加了复杂性和外部依赖。我们的原生后端消除了这一障碍,提供了紧密的集成和更好的可维护性。
支持SYCL一直是开发的主要驱动力,但OpenCL和Vulkan同样受益于计算能力的改进。随着项目日益成熟,它正变得更适合现实世界的工作负载。
SPIR-V后端的作用与复杂性
上一节我们了解了SPIR-V如何解决异构编译的挑战。本节中,我们深入探讨后端在连接不同生态时所扮演的角色及其复杂性。
SPIR-V后端是不同利益交汇点,其实现需要协调众多参与方和用例。计算领域对语义有很高的期望,SPIR-V指令定义明确,从源代码到目标代码的含义流必须被精确保留。考虑到SPIR-V作为可移植IR有许多不同用户(运行时环境、前端、后端),这是一项挑战。
例如,作为硬件驱动程序一部分的、从SPIR-V反向翻译回LLVM IR的过程(由Khronos Translator实现),可能会难以处理意外的、特定于后端的模式。同时,仅靠LLVM工具和概念也不足以完全捕获丰富的SPIR-V语义。
如果你将所有涉及的参与方和用例连接起来,会看到一个复杂的交叉网络。OpenCL和SYCL中的着色器(Shader)和内核(Kernel)在功能上可能重叠,这造成了来自运行时的隐式依赖和限制,这些运行时需要防范不支持的SPIR-V功能。后端需要管理这种二分法,实现内核和着色器的共享核心,并依赖环境检查来在这些模型之间分支,以确保跨不同领域的正确性。
澄清规范与处理类型
上一节我们讨论了后端协调不同模型的复杂性。本节中,我们来看看后端在澄清规范模糊性和处理类型系统方面的工作。
后端的工作之一是澄清SPIR-V规范中的模糊之处。一个最近的例子是关于计算风格(Compute flavor)如何使用图像读写指令。这些指令依赖于着色器能力,但可能在计算中使用未知的图像格式。这种不必要的耦合导致了使用图像的计算机工作负载中的错误。SPIR-V后端帮助规范演进,在内核上下文中记录了此行为。
另一个主要问题是运行时与指针类型的对抗。关键词是脆弱性。运行时依赖SPIR-V类型,而前端和优化器可能生成破坏类型跟踪的模式。类型推断至关重要,但并非总是可行。规范中没有指导前端如何创建IR或内部函数(intrinsics)以最终映射到SPIR-V。前端自行解决问题可能导致不一致。
最近的例子是前端将按位布尔值打包成32位向量,这违反了SPIR-V将布尔视为没有任何位模式的规定。因此,虽然语义一致性是目标,但实现路径可能不明确且脆弱。
类型推断机制
上一节我们看到了类型系统面临的挑战。本节中,我们详细探讨后端如何进行类型推断。
SPIR-V具有分层类型系统,大多数指令都是类型化的。后端运行一个模型(Module)Pass来扫描IR中的模式,发现依赖关系并推断类型,避免依赖名称修饰(name mangling)。为了编码类型和关系,类型推断会发出内部内部函数。
类型推断很复杂,以下是后端推断类型的步骤草案:
- 分析函数体,维护一个不完整类型的工作列表,允许延迟推断直到整个模型被分析。
- 尽早修复
getelementptr指令。 - 根据函数签名和调用点处理函数参数。
- 进行前向遍历,使用操作数推断结果类型。
- 进行后向反转,调整或细化操作数类型。
除了规则,还有基于经验猜测的空间,例如处理具有不同类型的值的phi节点。Pass中还编码了一些关于已知函数的先验知识,例如地址空间之间的指针类型转换,意味着参数和结果类型等价。
后向反转从结果推断操作数类型,确保在整个模型中生成一致的类型。我们构建一个指针类型转换,并将更改进一步传播到受影响的操作用户。除了对IR进行推理,我们还使用已知内置操作码的语义。最后,我们检查函数调用点并最终确定不完整类型,这也是从IR转换器清理中保存函数指针扩展的时刻。
连续辅助函数传播更改,最终在需要时为指令插入指针类型转换,并修补所有LLVM类型。目前,唯一的特殊情况是单元素向量(one element vector)的情况。
类型推断的局限性与解决方案
上一节我们介绍了类型推断的步骤。本节中,我们看看当类型推断不可行时的解决方案及其局限性。
当前端没有生成足够的含义时,类型推断并不总是能以格式良好的方式进行。一些SPIR-V指令没有对应的LLVM指令或内部函数,例如OpGroupNonUniform的复制操作。前端使用内置函数来编码在指针之间复制的代码,给定元素数量。如果我们无法猜测指针类型,问题在于SPIR-V中字节数比元素数更重要,操作码将无法工作。
想象一下,内置函数调用被插入到IR中,但指针和IR之间没有任何有用的关系来帮助推断类型。这正是我们通常希望避免的情况,因为依赖名称修饰是危险的。但在这种情况下,我们只能使用编码在名称修饰中的指针类型。当然,最好是根据字节数重新表述操作码本身,但现在改变为时已晚。可行的选项要么是无类型指针扩展,要么是一个假设的新内置函数来正确传达含义。
模型范围指令与性能优化
上一节我们讨论了类型推断的特定挑战。本节中,我们转向后端在处理模型范围指令和性能优化方面的努力。
另一个问题是逻辑上的不匹配。形式上,模型(Module)是指令的线性列表。然而,实际上存在模型范围和函数范围,模型范围按照预定义顺序存放类型定义,我们必须为相同的类型重用相同的类型ID。我们也希望保持其他定义(如常量)的唯一性,以避免输出膨胀。
想象一下,LLVM IR中的函数指令引用函数范围内的定义,但我们必须记住,这些定义最终必须属于模型范围并被重用。这是另一个概念上的不匹配。对于常量,我们甚至没有选择,它们从一开始就在后端之外被创建和复制,从转换器开始。
我们演讲的标题中使用“桥接”(bridging)一词并非偶然,后端充当了SPIR-V和LLVM概念之间的桥梁。定义的去重和模型指令的高效收集至关重要。后端不能成为异构计算的瓶颈,关键词是高效。我们希望在后端中替换计算工具中的转换器,但直接比较绝对时间不公平。LLC运行许多Pass,转换和优化IR和MIR,后端在其他Pass之间将IR降低为SPIR-V。而Khronos工具则在没有LLVM开销的情况下进行翻译,因此转换器更快。这不是零开销,如果比较,它大约快5到60倍。
第一个性能问题的根源正是对模型范围指令的低效处理。去重曾用于构建ID的依赖图,但数据结构扩展性不好。我们完全移除了图,翻译时间获得了5倍加速,这是缩小性能差距的坚实一步。下一步是进一步改进类型和值的去重和跟踪,通过更智能的类型管理,减少了内部内部函数对MIR的膨胀和发出的机器代码。总效果是从原始基线获得了25倍加速,中间状态和IR/MIR转储更清晰,代码更简单。
未来,我们计划分析内存使用情况,并最终解决类型推断逻辑。它目前与IR转换纠缠在一起,使维护复杂化。因此,我们希望从类型推断中创建一个独立的、可重用的Pass,并可能在后端和其他项目之间共享。
缓存与一致性管理
上一节我们探讨了性能优化。本节中,我们来看看确保缓存一致性的挑战和解决方案。
解决性能问题有一个预期且有意为之的副作用:去重的跟踪并非完全简单,因为IR在翻译过程中会发生变化。有些变化是失控的,是后端无关的Pass的一部分。虽然这些变化本可以被监控,但之前并没有。
以前的方法不能保证实体的正确缓存,可能会由于指令的移除及其在指令选择中的修改而随机返回陈旧和不正确的记录。我们通过为跟踪和最终定义去重创建正确的缓存来解决这个问题。
无法完全控制IR的变化意味着缓存比双向映射更复杂一些,尽管新的实现无论如何都更简单。其思想是通过基于类型和内容从LLVM IR实体创建唯一键,并存储相应的指令及冗余信息来监控IR变化、验证记录和识别陈旧项,这确保了最终一致性。
一致性是最终的,因为IR的变化发生在外。冗余和自定义哈希有助于区分有效记录和陈旧记录。我们将定义存储为机器指令、其定义虚拟寄存器和哈希的元组,结合双向映射,这足以提供一致的查找和擦除操作。当我们看到机器函数中此虚拟寄存器的定义,并且该定义与存储的指令相同时,我们就使用该记录。
过去在某些负载中存在不稳定的行为,在这个修复之后,我们不再观察到它。
指令选择与社区生态
上一节我们讨论了后端内部的缓存机制。本节中,我们来看看指令选择的改进空间和更广泛的社区影响。
TableGen的使用必须在指令选择中变得更智能,这是后端持续关注的一部分,也是可能需要进行重大重构的部分。我们计划将所有内容同步到一种方法,并尝试将手动编码的语义检查和全局指令选择(GlobalISel)模式匹配转移到TableGen模式。目前指令选择代码通常看起来重复,可以由TableGen模式处理,因此有改进空间。
最近的成就已经包括从机器验证器(Machine Verifier)角度的有效性、TableGen手动记录规则的简化、中间表示和底层假设。在最近的美国LLVM开发者会议上讨论了与全局指令选择的一些有问题的交互,让我们从实用角度简要回顾一下。
第一个是SPIR-V中的OpPhi,它是一个PHI节点,但未被识别为PHI。我们讨论过修补LLVM代码生成的想法,在机器指令isPHI中进行硬编码检查,而不是修补SPIR-V后端并推迟OpPhi的生成。支持指令选择阶段后的特定目标代码行为不是更好吗?答案是否定的。覆盖硬编码检查是一个优雅的解决方案,因为选定的指令不应需要任何进一步的选择。目标指令isPHI将帮助后端在ISA中报告PHI指令,但问题是为时已晚,优化Pass正在使用硬编码检查,大约有300个地方需要重构,因此优雅的方案看起来不可行。我们与代码库保持一致,使用通用的PHI操作码直到模型最终化,这为我们启用了优化Pass。
类似的问题但相反的解决方案是保持指针类型一致。后端在指针之间使用位转换(bitcast),我们希望使用通用的G_BITCAST,但解决方案是生成SPIR-V的OpBitcast。理由是低级类型(LLT)没有带来足够的信息来将G_BITCAST的使用与SPIR-V对齐,因此我们不能发出通用操作码,其语义与OpBitcast不同。
新应用与数据类型支持
上一节我们探讨了后端与LLVM基础设施的集成。本节中,我们来看看后端如何支持新的应用和数据类型。
回到社区方面,后端的显著进展和非实验状态启用了新的应用。SYCL仍然是变化的主要驱动力,新的AI工作负载和扩展正在进行中。
新的用例提出了缺少BF16支持的问题。BF16很重要,但全局指令选择不区分语义差异,将16位浮点仅视为IEEE半精度(half)。这种模糊性是一个问题,尤其是在AI工作负载中。我们需要确保正确的位模式被保留,并且指令能正确解释位,在全局指令选择中准确表示非标准负载类型,并为高效的代码生成和大量出现提供坚实的基础。
缺乏BF16意识导致在全局指令选择中表示BF16时采用变通方法。项目使用半精度浮点或整数进行编码,然后依赖内部函数、元数据或添加位转换。单独的寄存器组可能使情况复杂化,后端需要根据上下文猜测一个16位值到底是浮点数还是整数。所有这些都分裂了生态系统,增加了出错的机会。
LLVM社区提出了几个选项来正确表示BF16这种非标准浮点类型。第一个是扩展低级类型以区分变体;另一个是重新定义LLT种类,添加格式说明符以指定格式,包括未来的扩展如FP8和TF32,缺点是这需要大量重构;下一个是在浮点指令中嵌入类型作为操作数,这会使MIR变得臃肿,Pass必须注意额外的操作数;最后,我们可以进入推理领域,附加元数据并依赖分析Pass。总之,我们确实需要BF16的第一类表示,以避免猜测带来的陷阱。
测试与质量保证
上一节我们讨论了新数据类型带来的挑战。本节中,我们来看看项目如何通过测试和QA来保证质量。

我们的测试套件正在迅速扩展,反映了不断变化的优先级。测试超越了文件检查(FileCheck),我们使用Spirv-Tools验证代码在语法和语义上是否有效。展望未来,我们计划识别覆盖范围的薄弱点,并自动化非功能性测试。
后端在Mobos和持续集成环境中的推广,迅速发现了一些需要修复的地方。许多问题是次要的,但仍需要紧急修复。作为一个教训,在像LLVM这样的大型代码库中,即使对子项目的质量非常谨慎,也可能让难以检测的bug隐藏起来,导致在新环境中出现意外。来自构建机器人(buildbot)、消毒器(sanitizers)和外部环境的扩展覆盖确实有帮助。
性能评估与未来展望
上一节我们了解了项目的测试和质量保证措施。本节中,我们评估后端生成的代码性能,并对整个教程进行总结。
后端在异构计算领域是稳定的。我们将更好地支持OpenCL,并解决SYCL中所有已知问题。对于Triton和SYCL Native(N2)的测试套件,通过率也很高,其中一些功能有意不被支持,其他功能正在进行中。添加扩展可能需要更多时间和精力,例如联合矩阵(joint matrix)的例子。
在QA中有很多活动部分:下游Intel LLVM、上游LLVM、Khronos Translator、运行时驱动程序有时会给出分支,组件在不同时间表上更新,总是存在滞后。因此,在环境更新到一致状态之前,C工作流中的通过率可能看起来比实际低。在LLVM之外进行持续测试仍然有帮助。
我们检查了后端生成的SPIR-V代码的运行时性能与Khronos Translator生成的代码相比如何。目前处于初始探测阶段,采用自动化的方法。第一个结果是好消息:我们代码的运行时性能与转换器相当,没有显著差异。
在本演示开始时,我告诉过你这是一系列小课程,而不是一个要传达的信息。所以,如果我现在只挑选一条信息,那就是:LLVM SPIR-V后端是一个重要且稳定的项目,它已准备好替换工具链中的Khronos Translator。

总结
在本教程中,我们一起学习了LLVM SPIR-V后端项目。我们从项目背景和异构计算的挑战开始,了解了SPIR-V作为可移植IR的价值以及原生后端的重要性。我们深入探讨了后端在桥接LLVM与SPIR-V生态时面临的技术复杂性,包括协调不同计算模型、澄清规范模糊性以及处理脆弱的类型系统。
我们详细介绍了后端强大的类型推断机制及其局限性,并看到了通过性能优化(如高效的模型指令去重和缓存管理)如何显著提升编译速度。我们还探讨了后端在指令选择、支持新数据类型(如BF16)以及通过严格测试和质量保证来确保稳定性方面的持续改进。
最后,我们了解到后端生成的代码在运行时性能上已与成熟工具相当,标志着它已准备好广泛应用于实际的异构计算工具链中。LLVM SPIR-V后端项目展示了通过开源协作解决复杂工程挑战的强大能力。
012:符号查找错误与 ABI 破坏检测


在本教程中,我们将学习如何理解共享库更新时出现的“未定义符号”错误,并探讨一种基于源代码分析来检测破坏应用程序二进制接口(ABI)兼容性变更的方法。
重现一个典型的符号查找错误
上一节我们介绍了课程目标,本节中我们来看看如何重现一个典型的符号查找错误。
首先,我们从一个函数开始。这个函数的名字经过C++名称修饰(mangling)后,看起来可能有些复杂,但它是一个完全合法的修饰名。
// 一个具有复杂修饰名的函数
void ABI_breaking_function(int param1);
这不是日常编程中常见的函数,但为了演示,我们将其放入一个库中。为了让这个库有实际功能,我们为这个函数提供一个定义。
// 函数定义
void ABI_breaking_function(int param1) {
// 执行一些操作
}
现在,我们可以将这个源文件编译成一个共享库。
clang++ -shared -fPIC my_talk.cpp -o libmytalk.so
编译完成后,我们得到了一个共享库 libmytalk.so。假设有另一个名为 libeurollvm.so 的库使用了 libmytalk.so 中的代码,因此它必须链接到 libmytalk.so。此外,还有一个名为 berlin 的可执行文件使用了 libeurollvm.so。
目前,如果我们运行 berlin,它会成功执行并输出 libmytalk.so 中定义的消息。一切看起来都很正常。
引入一个看似兼容的变更
上一节我们创建了一个可以正常工作的库,本节中我们来看看如何通过一个看似微小的变更来破坏其兼容性。
现在,我们觉得 ABI_breaking_function 函数的参数有点少,决定为其添加一个新的参数。
// 修改函数声明,添加一个新参数
void ABI_breaking_function(int param1, int param2);
这个改动带来了一个问题:任何调用这个函数的源代码将因为参数数量不匹配而无法编译。为了保持源代码兼容性(API兼容性),我们为新参数提供一个默认值。
// 为新增参数提供默认值
void ABI_breaking_function(int param1, int param2 = 0);
我们同样在函数的定义中添加这个新参数(和默认值),然后重新编译 libmytalk.so 库。这就像是发布了一个包含此变更的库的新版本。
由于这是一个共享库,理论上,任何链接到它的应用程序无需重新编译,应该仍然能够正常工作,从而实现向后二进制兼容。因此,再次运行 berlin 应该会成功,对吗?
实际上,运行 berlin 会导致失败,并出现我们在课程开头提到的“未定义符号”错误。
理解源代码兼容性与二进制兼容性
上一节我们看到了一个破坏二进制兼容性的变更,本节中我们来深入理解兼容性的两种类型。
当应用程序链接到一个共享库时,库的内容并不直接成为应用程序的一部分。应用程序中只包含对库的引用。在运行时,动态加载器会根据这个引用去磁盘上查找并加载库文件。如果文件被新版本覆盖(例如通过安装更新),那么加载和执行的就是这个最新安装的版本。
有两种主要的兼容性需要关注:
- 源代码兼容性(API兼容性):这意味着任何使用库旧版本的应用程序,无需修改就能针对新版本重新编译成功。我们为参数添加默认值的变更就保持了源代码兼容性。
- 二进制兼容性(ABI兼容性):这意味着这些应用程序无需重新编译,就能直接使用库的新版本。
如果我们对比 libmytalk.so 旧版本和新版本的接口:
- 源代码层面:由于添加了默认参数,函数可以像以前一样被调用,因此变更保持了源代码兼容。
- 二进制层面:函数的修饰名(mangled name)包含了其参数类型信息。添加新参数(即使是默认参数)改变了函数的修饰名。这意味着函数在二进制文件中定义的标签(即其修饰名)发生了变化。未重新编译的应用程序仍然试图用旧的标签(旧的修饰名)去调用它,但新库中已不存在这个标签,因此动态加载器在运行时找不到该符号,导致了“未定义符号”错误。
现有检测工具的局限性
上一节我们明确了问题的根源,本节中我们来看看现有的解决方案及其局限性。
在维护遗留系统时,如果因为库的变更导致系统故障,若能有一个工具自动检测这些破坏性变更将会非常有帮助。
目前存在一些检测工具,但它们大多分析二进制文件。这似乎是解决此问题的直接方法,因为二进制接口本就存在于二进制文件中。然而,不同平台使用不同的二进制格式(如ELF、Mach-O、PE),因此没有一种通用的、跨平台的二进制分析解决方案。
另一些工具则利用DWARF调试信息来获取跨平台支持。但DWARF并非唯一的调试信息格式,例如Windows使用PDB,这又是完全不同的东西。
那么,如果我们转而分析源代码呢?这里面临另一个问题:对于C++语言,目前不存在专门用于分析库兼容性的成熟工具。
基于 LLVM/Clang 构建新的分析工具
上一节我们讨论了现有工具的不足,本节中我们来看看如何利用 LLVM 生态构建一个新的解决方案。
LLVM 项目包含许多可以以某种方式推理源代码的工具,但没有一个专门针对库兼容性分析。另一方面,LLVM 提供了一套 LibTooling 库,允许我们根据需要创建独立的 Clang 工具。我们还拥有 ASTMatchers 库,它允许我们从 Clang 的抽象语法树(AST)中提取信息。
以下是核心思路:利用这些库,创建一个新的独立 Clang 工具。这个工具可以获取同一库不同版本的源代码,并运用静态代码分析来检测实际破坏兼容性的变更。
然而,直接比较来自不同翻译单元(源文件)的 AST 节点并非易事。Clang 为每个编译的源文件创建一个独立的 AST 上下文,节点通过指针访问。在同一个 AST 上下文中,通过比较指针地址即可判断是否为同一节点。但在不同的 AST 上下文中,即使两个节点代表相同的声明,它们的物理内存地址也绝不可能相同。
为了解决这个问题,Clang 提供了 ODR Hash 机制。ODR Hash 以 AST 节点作为输入,生成一个哈希值作为输出。由于这是一个哈希函数,只要节点代表相同的声明,无论其内存地址如何,都会生成相同的哈希值。这样,我们就可以利用 ODR Hash 来跨不同的 AST 上下文进行比较。
这个想法的实现已经可以在 GitHub 上的一个仓库中找到。这是一个严肃的工具,尽管它的名字可能看起来有些创意。
工具演示:检测 ABI 破坏
上一节我们介绍了新工具的原理,本节中我们通过演示来看看它的实际效果。
首先,回顾一下我们对 libmytalk.so 所做的那个破坏了 ABI 的变更。让我们看看这个工具会如何报告它。
要使用这个工具,我们需要指定要分析的库旧版本的源文件,以及新版本的源文件。
# 假设工具名为 abi-compliance-checker
abi-compliance-checker -old libmytalk_v1.cpp -new libmytalk_v2.cpp
运行后,工具会显示一条警告,指出 ABI_breaking_function 的一个重载在新库中缺失。这正是导致 ABI 破坏的原因。
这看起来像是一个精心构造的简单例子。那么,在真实项目上效果如何呢?有人曾要求用 LLVM 本身来评估这个工具。我们就以 LLVM 15 的符号化器(symbolizer)组件为例。
我们将使用 LLVM 15.0.0 版本符号化器的头文件,并指定其构建目录(包含编译数据库 compile_commands.json),以便工具能获取正确的编译选项来构建 AST。对于新版本,我们使用 LLVM 15.0.1 的相同源文件。
LLVM 的次要版本发布通常旨在保持二进制和源代码兼容性,因此我们预计不会看到警告。但工具实际上报告了多个警告。这些警告源于两个变更:
- 在 15.0.1 中,一个名为
MarkupFilter的类增加了一个新字段。这改变了类在内存中的大小和大多数字段的偏移量。 - 该类的构造函数增加了一个新参数,这改变了其修饰名(标签)。

如果我们有一段简单的代码,它只是实例化一个 MarkupFilter 然后退出。在安装 LLVM 15.0.0 后编译这段代码并运行,一切正常。如果我们决定升级到 LLVM 15.0.1,然后运行同一个(未重新编译的)应用程序,我们最终会得到与本教程开头完全相同的“未定义符号”警告。
问答与总结
本节课中我们一起学习了共享库 ABI 兼容性的重要性、破坏兼容性的常见原因,以及一种基于 Clang AST 和 ODR Hash 来检测此类破坏性变更的静态分析工具。
以下是演讲结束后的一些问答摘要:
- 问:如果函数被标记为
extern "C",修改参数还会产生未定义符号错误吗?- 答:对于
extern "C",函数没有名称修饰,因此参数变化通常不会影响其链接符号名。但具体行为可能更复杂,不过一般来说,这避免了因修饰名改变导致的问题。
- 答:对于
- 问:基于源代码的工具如何处理预处理器指令(如
#ifdef)?为了检测所有潜在的 ABI 不兼容,是否需要为所有目标平台编译?- 答:是的,这是一个挑战。工具需要针对特定的目标平台进行配置(通过传递对应的编译器目标三元组参数),这样源代码才会被正确地预处理,生成包含该平台特定信息的 AST。理论上,这能提供针对该平台的准确分析。二进制分析工具同样面临需要跨平台支持的问题。将此类检查集成到针对不同平台的 CI(持续集成)系统中会很有用。
通过本教程,你应该对共享库更新时的 ABI 兼容性问题有了清晰的认识,并了解了一种利用 LLVM/Clang 基础设施来主动检测此类问题的前沿方法。


013:为LLVM和MLIR引入NVIDIA Blackwell支持

概述
在本节课中,我们将学习如何为上游LLVM和MLIR编译器框架添加对NVIDIA Blackwell GPU架构的支持。我们将介绍Blackwell的新特性,探讨在NVPTX后端和NVVM方言中的实现方法,并通过NVDSL展示具体的GEMM(通用矩阵乘法)示例。
Blackwell GPU架构简介
Blackwell是NVIDIA最新的GPU架构,它引入了多项旨在加速AI计算的新特性。其中,矩阵乘累加单元和Tensor内存加速器是与我们今天讨论最相关且最有趣的部分。
在矩阵乘累加单元方面,从Ampere架构开始,我们让线程束内的线程协作执行一个矩阵乘累加块。在Hopper架构中,这扩展到了线程束组协作。而Blackwell进一步扩展,现在允许来自最多一对计算线程数组的线程协作计算一个矩阵乘累加块。
同时,Blackwell还支持新的Tensor内存。它拥有专用的分配、释放指令以及加载/存储指令。这意味着它由程序员管理,并且与之前的架构不同,Blackwell中矩阵乘累加操作的累加器和部分操作数可以来自Tensor内存。
此外,Blackwell的矩阵乘累加单元还支持块缩放类型,即FP6和FP4数据类型。所有这些特性都通过ISA中的tc_gen5系列指令集暴露出来。
在Tensor内存加速器方面,它主要负责在共享内存和全局内存之间进行数据拷贝。它理解最多五维数据,支持平铺模式、多播模式等。在Blackwell中,它在多播模式中获得了对几种专用模式的支持,以及分散-聚集支持。
编译器流程概览
为了让大家了解这些特性如何融入编译器流程,我们来看一个整体图景。MLIR中的NVVM方言将这些特性建模为操作,它也可以将它们降级为LLVM IR,作为内部函数或内联PTX。
然后,LLVM IR通过NVPTX后端生成PTX代码。
对于Blackwell,我们预计需要在NVVM方言中添加几十个操作。同时,NVPTX后端已经为Tensor内存添加了地址空间6。我们估计还需要在后端添加大约一千个内部函数来支持这些特性。
内部函数数量与可扩展性
现在我们来谈谈内部函数。我们看到一个简单的Tensor内存加速器指令示例,仅其一个变体就可能降级为数百个内部函数。同样,建模Blackwell矩阵乘累加单元的tc_gen5矩阵乘累加系列指令,单独就可能降级为大约700个内部函数。
观察当前Blackwell之前基线版本的内部函数分布,我们大约有3000个内部函数,其中40%已经来自矩阵乘累加和Tensor内存加速器指令。现在,我们仅为了支持Blackwell的Tensor内存加速器和矩阵乘累加单元,就要再增加一千个内部函数。
这具有可扩展性吗?为了回答这个问题,我们尝试了一个实验:运行一组测试,覆盖1千、2千直至1万个内部函数,观察在后端添加这些内部函数对LLVM构建时间、二进制大小以及用户内核编译时间的影响。
这个实验的基础设施建立在当前已有的一个简单Tensor内存加速器内部函数之上,也包括前端内部函数和NVPTX代码生成,所有这些都是通过TableGen生成的。我们构建了多达1万个内部函数的版本。
在二进制大小方面,例如opt、llc等工具,我们看到有高达3%的边际增长。对我们来说,有条件地编译这些包含文件或TableGen生成的前端包含文件以及后端文件非常重要。在这些方面,我们看到与添加的内部函数数量成比例的增长。这些表格中的时间单位都是毫秒,因此在LLVM的完整发布版本构建中,我们没有看到任何影响。
但是,当我们考虑用户内核编译时间时,情况有所不同。我们在上百个Blackwell内核上运行了相同的测试集,这些都是为GEMM实现的实际内核,使用了我们迄今为止添加的内部函数。
编译时间测量使用-time-passes选项完成,这些是大约20次运行的平均值,并分别测量了opt阶段和llc阶段。在opt阶段,我们没有看到这些内核编译时间有任何影响,这是可以理解的,因为目前大多数内部函数对于优化通道来说只是简单的传递。
在llc阶段,它承担了为这些内部函数进行实际选择和代码生成的重任。对于1千和2千个内部函数,我们看到边际增长高达约1%和4%。但当我们增加到5千或1万个内部函数时,编译时间可能增加高达15%。
内部函数整合策略
显然,我们无法承受这样的开销。因此,我们开始研究以某种形式整合这些内部函数的方案。
我们早期采用的一种方法是将一组修饰符建模为标志位,我们称之为“标志位”,但它实际上是一个整数常量。
这里展示了一个例子,例如平铺模式、多播模式等,将它们打包在一起,说“好的,这是一个常量”。只有后端会查看这个常量,并将其分层映射到它拥有的适当变体。这种方法在可维护性和兼容性方面效果很好。
但是,我们遇到了一些实际问题。其中之一是,我们将某个字段或修饰符建模为,比如说,一个2位字段。然后ISA扩展器将其变成了一个3位字段。现在我们如何用这些标志位来管理?我们尝试了一些变通方法。另一个问题是解析标志位本身。随着ISA的演进,内部函数变得更加复杂,例如它们可能变成多个32位或更宽的类型,处理这些标志常量本身变得更加困难。基本上,我们必须解包,然后将它们映射到它们对应的枚举值。
因此,我们考虑的另一种方法是:与其将所有内容打包到一个i32中,不如将一组修饰符单独拆分到一个标志字段中。这在某种意义上更容易处理,因为如果你在调试或修改IR,我们可以直接将标志值映射到枚举值,并且我们实际上永远不会用完位宽,我们可以使用i8、i16或i32。
但问题是,将一个打包的i32值扩展到多组i32对位码大小有什么影响?我们再次回到之前测量的同一组内核,几何平均值大致接近1,但对于少数内核,我们看到.bc文件大小增加了高达3%。
经验总结与指导原则
我们从中学习到的是:是的,为了可读性,将所有内容放在内部函数的名称中很重要,但我们也需要牢记内部函数的数量,以及何时选择哪种方法,哪些修饰符集合可以作为标志位,哪些效果好或不好。
我们已经为NVPTX制定了一套指导原则,并在这个PR中进行了汇总。
浮点类型支持
接下来是浮点类型的添加。Blackwell支持这些窄浮点类型,即FP6和FP4类型,我们已经在LLVM的APFloat基础设施中添加了对它们的支持。
FP6和FP4类型相对简单,建立在现有的基础设施之上。但具有精度0的缩放格式类型带来了相当大的挑战。经过与维护者的几轮讨论和审查,我们发现关键是要让APFloat理解某些特殊类型的精度可以为0。一旦我们向APFloat阐明了这一点,一切就都顺利就位了。
APFloat通常有一个称为“穷举对测试”的概念。它获取一种类型,然后遍历该类型中的所有位模式,并测试所有算术运算。使用这类测试有助于揭示这些类型特有的许多边界情况。
当然,所有这些类型在MLIR中也都有类型定义支持。

MLIR与NVVM方言支持
转到MLIR方面,我认为大多数tc_gen5操作现在都在NVVM方言中得到支持。tc_gen类型集几乎都通过内部函数进行了分层。作为准备工作,我们还在添加一些Blackwell之前的功能,主要是在Tensor内存加速器方面。

该方言本身可以支持内联PTX和内部函数降级。更重要的是,NVVM方言中的一个特定操作可以部分降级为内联PTX(针对某些修饰符),然后为另一部分降级为内部函数。我们发现在开发早期和调试阶段,这个功能非常方便。
是的,我们也在积极将一些现有的内联汇编迁移到基于内部函数的方式。
未来展望
展望内部函数的未来,我们认为能够漂亮地打印一些这些标志参数,而不仅仅是说一个巨大的值或简单的枚举值,这将很有好处。例如,能够说明这代表舍入模式、归约模式或平铺模式等。关于这个主题有相关的讨论,欢迎大家查看并告诉我们您的想法。
在启用tc_gen5期间出现的另一个项目是,我们无法指定特定的读写属性。目前我们正在IR端使用更保守的属性来解决这个问题,但拥有这些属性会更好,我们计划在某个时候研究这个问题。
在NVVM方言方面,随着所有这些添加,我们意识到我们没有方法来检查这些操作所需的SM版本。因此,它们会一直传递到IR,然后在后端失败。我们正在添加对此的支持,这有助于我们在开发流程的更早阶段捕获这些SM版本检查。
当然,我们也将添加对剩余指令作为内部函数的支持。
通过NVDSL的GEMM示例
现在,让我们通过NVDSL来了解几个GEMM示例。
Doga的团队已经在MLIR中实现了LLVM IR和NVVM方言。因此,我们在那里有基本的支持。但如果我告诉你,“去实现一个非常快速的内核”,你能做到吗?我认为这需要时间,因为并行编程很难,使用所有这些Tensor特性也很难,编写快速内核真的很难。
因此,我们决定同时提供GEMM示例的基本构建块,以及这些PTX指令和NVVM方言。
为此,我们使用了NVDSL,这是我去年介绍的。我不会讲太多细节,但你可以观看相关视频。它基本上是一个Python中的测试DSL,你可以生成NV GPU代码或NVVM方言。
它能做什么?让我快速介绍一下:它可以自动生成MLIR函数,我们有装饰器,它可以即时编译并执行。它处理所有的样板代码,进行运算符重载,并将NumPy类型转换为MLIR类型。所以,它只是给你一个小小的Python DSL,让我们可以专注于性能或在MLIR中进行测试。我们编写一个内核,然后它可以生成许多变体,这样我们就可以测试更大的场景。
让我们深入了解一下。这是去年关于Hopper GPU的幻灯片,展示了Hopper GPU的工作原理。在Hopper中,我们有一个Tensor核心,Tensor核心指令的大小是64x1028x16。所以,如果你想做1028x1028x64的GEMM,你需要执行8条指令。这就是Hopper,结果存储在寄存器中。这意味着如果你想进行计算,想使用这些寄存器的结果,你必须持有这些值才能继续。
在NVIDIA,我们进行线程束专业化。一些线程束执行Tensor内存加速器加载,一些执行矩阵乘累加,另一些执行算术逻辑。这真的很难管理线程束专业化,因为你必须持有整个线程束,然后为了下一次计算而保持它们移动。所以这很复杂。
Blackwell的变革:Tensor内存
那么在Blackwell中发生了什么变化?在Blackwell中,我们引入了Tensor内存。这在核心生成方面是一个游戏规则改变者,因为它真正简化了线程束专业化。
这里发生了什么?我们的Tensor核心尺寸变大了,但这并不那么重要。重要的是,现在Tensor核心的结果存储在内存中。这个内存非常快,并且可以被其他寄存器或其他线程束访问。
所以你不必持有你的线程束组或线程束来继续计算。你基本上可以取一个线程束,实际上取一个线程,然后提交线程束到Tensor核心,然后你可以继续你的工作,你可以使用其他线程束继续线程束专业化。
另一件事是同步方式发生了变化。我们使用M屏障。这有点细节,但更容易管理屏障,因为现在一切都是异步的。我确定这对你来说还不清楚,所以我们将通过示例逐步讲解。
我们将从一个非常基础的Tensor核心开始。这是我去年教程中的第3章。我取了同一章并将其应用于Blackwell GPU。
我们内核的顺序版本是:0:2 和 8x1028x64。我们使用F16类型,在F32上累加,对吗?如果你用NVDSL编写,右边就是代码。我们将逐步完成所有这些步骤,这将是接下来的许多幻灯片。
在左边,你看到的是执行时间线,右边你看到的是代码。如果你看执行时间线,你会看到四个线程束,对吗?你会看到四个线程束。我使用第一个线程束。我们有一个“领导者线程”的概念,领导者线程是我的线程束中最快的线程。我可以动态地获取它,这是一条指令。
内核执行步骤详解
好的,让我们启动内核。这是一个GPU内核,我们启动内核。
我有两个来自主机的Tensor内存加速器描述符,我需要引入一个栅栏。我取我的第一个线程束,我取我最快的线程。所以它们为这个Tensor内存加速器引入栅栏,因为它们位于全局内存中,我需要确保它们对所有人可见。
同时,我初始化我的M屏障。它们就像是分离的数组,如果你不熟悉这个,我将使用两个M屏障,一个用于数据加载,另一个用于矩阵乘累加。这就是引导过程。
步骤2:在这个步骤2中,由于我们有了Tensor内存,我需要分配这个内存,对吗?我将使用线程束0来分配它。我分配Tensor核心,12x8列,我有1028个线程。所以我的结果矩阵将完美地放在那里。这就是步骤3,我们分配它。
现在我们已经完成了引导。我们可以对M屏障进行栅栏操作,这意味着这些M屏障栅栏对其他线程可见。我也在使用屏障,所以我想确保我所有的线程都在同一个位置。这样我就可以开始计算了,我将开始做一些有趣的事情。
步骤4:好的,让我们开始做一些有趣的事情,这就是步骤4。在这里,我将使用Tensor内存加速器加载数据。Tensor内存加速器也是一个异步的内存加载/存储单元。你可以取最快的线程,告诉它们“加载这么多数据”。你可以启动许多指令。我们使用M屏障。
步骤5:我们将等待这些数据。所有来自Tensor内存加速器的数据都是异步到达的。
步骤6:终于,我的数据在我的共享内存中,因为我使用Tensor内存加速器将数据从全局内存加载到共享内存。我的数据在那里。现在我知道我可以相乘这些矩阵了。在Hopper之后,正如你现在所知或正在学习,矩阵乘累加指令可以直接与共享内存一起工作。所以如果你不想,你不必手动将它们加载到寄存器中,它们可以直接在共享内存上工作,你只需要构建寄存器描述符。
所以这是步骤6。在步骤6中,我取我的线程束0,我取我最快的线程,我在这里使用矩阵乘累加指令或mma API。我们有一个for循环。正如我在上一张幻灯片中展示的,我需要执行大约四条这样的矩阵乘累加指令,因为我在K维度上跨步。所以我想完成GEMM。对于结果,累加器是这里的tm,你可以看到tm符号。我们在同一个东西上累加。所以这不是跨步,唯一跨步的部分是矩阵A和矩阵B的描述符。
然后,在步骤6结束时,我们提交这个屏障。我们说“嘿,我完成了矩阵乘累加”。我们提交它,这很好。
步骤7:现在,让我们进入步骤7。在这个步骤7中,我使用第二个M屏障。我等待第二个M屏障。这个M屏障与矩阵乘累加相连。所以,正如你在这里看到的,我的内核中的所有线程都在等待这个矩阵乘累加。因为我这样做,我想使用我所有的线程,因为我想将tm存储到全局内存。你可以用多种方式做到这一点。但正如我所说,这是一种做GEMM的方式,这就是我想做的。所以现在,在步骤7之后,我的矩阵乘法完成了。我的数据在tm中,对吗?它不在我的寄存器中。所以我需要复制它们。

步骤8:这是步骤8。所以为了复制,我们有一个加载指令。你可以加载Tensor内存中的任何内容到你的寄存器中。在这里,我有128个线程,我每个线程加载128个元素。所以我基本上将我所有的矩阵加载到我的寄存器中。
步骤9:我们有一个for循环,就像这个SF4循环。因为我有我的寄存器,我直接将这些寄存器存储到全局内存。这不是最好的方法,因为它不是合并访问,但你知道,你也可以存储到共享内存,然后返回到全局内存,或者你可以直接使用Tensor内存加速器将共享内存中的所有内容存储到全局内存。有很多方法。但这是其中一种方法。如果你能理解并学会它,你基本上可以在这个想法之上构建你的编译器。正如你所见,即使是基础的东西,即使是基础的GEMM仍然需要线程束专业化,因为我们使用这个线程束做这个,我们使用另一个线程束做那个。
无论如何,让我们结束内核,我的时间快到了。这是最后一步。在最后一步,我只需要释放我的tm,我还需要放弃我的分配许可,这意味着我完成了这个tm,我的SM中的另一个CTA或另一个CTA可以分配它。
基本上就是这样。这就是GEMM。
总结
让我们总结一下我们在NVIDIA正在做的事情。我们支持MLIR,我们正在上游MLIR上工作,我们正在为Blackwell带来支持。Hopper基线已经存在。在NVPTX中,我们可以作为内部函数,也在NVVM方言中,我们正在改进它。
此外,我们将提供可运行的示例。我们将在mlir-nvidia仓库中提供相同的示例。

感谢聆听。
014:利用NSW属性提升Flang向量化能力

概述
在本节课程中,我们将学习如何通过在Flang生成的LLVM IR中添加“无符号溢出”(NSW)属性,来提升后端循环向量化(Vectorization)的能力。课程内容基于2025年LLVM欧洲开发者大会的相关演讲,我们将解析其核心思路、实施方法以及面临的挑战。
背景:Flang与向量化
上一节我们介绍了优化背景,本节中我们来看看具体的技术路径。
Flang是LLVM项目中的Fortran前端编译器。它本身不包含向量化优化通道,而是依赖LLVM后端中的LoopVectorize等通道进行优化。向量化在性能优化中扮演着重要角色,因此提升后端的向量化能力是关键。
为了评估向量化能力,我们使用了一个名为TSVC的基准测试套件。该套件包含用Fortran和C编写的测试循环,有助于区分前端生成问题与后端优化问题。
尽管Flang依赖后端优化,但前端生成的LLVM IR质量会直接影响后端向量化的难易程度。如果IR过于复杂或信息不足,后端优化器可能无法有效进行向量化。
问题分析:阻碍向量化的循环类别
以下是我们在TSVC测试中发现的、Flang 19版本无法向量化而Clang 19可以向量化的循环类别。这些类别指出了前端IR生成可以改进的方向:
- 数组下标分析的复杂性:Fortran中数组下标计算涉及不同位宽(32位到64位)的转换和偏移量计算,导致IR复杂。
- 通过引用传递的归约变量:作为参数传递的归约变量增加了分析难度。
- 编译时大小未知的数组:动态数组形状影响了向量化的确定性。
- 仅需类型提升的循环:这些循环本应更容易向量化。
我们认为,类别A和B的问题可以通过在前端生成LLVM IR时添加更多信息来解决。本教程将重点讨论如何利用NSW属性解决类别A的问题。
核心概念:NSW属性
上一节我们明确了问题所在,本节中我们来看看解决问题的核心工具——NSW属性。
NSW是LLVM IR中应用于整数运算指令(如add, sub, mul)的一个属性。它是“No Signed Wrap”的缩写,表示该指令的结果不会发生有符号整数溢出。
NSW的作用
NSW的一个关键用途是简化涉及不同位宽整数的计算,并为优化器提供关键信息。
考虑以下表达式是否恒等:
(i + 1) - 1 == i
实际上,在数学上这并不总是成立。在C语言中,如果i是int类型且等于INT_MAX,那么左边表达式会因溢出而得到INT_MAX - 1,右边则是INT_MAX,两者不等。
然而,如果加法操作具有NSW属性,即编译器能确定i <= INT_MAX - 1,那么该等式就成立。优化器可以利用这个信息进行化简。
在LLVM IR中,带有NSW的加法指令如下所示:
%sum = add nsw i32 %i, 1
NSW在Fortran中的适用性
那么,我们能否安全地为Fortran运算添加NSW属性呢?
关键在于语言规范。Fortran标准并未定义整数溢出的行为,这意味着如何处理溢出由编译器实现决定。因此,主流Fortran编译器(如gfortran, ifort)通常假设循环变量、数组下标和循环边界计算不会溢出。
基于此,我们可以做出相同假设,并为这些计算添加NSW属性,以帮助优化。当然,我们也提供了一个编译选项(-fno-signed-integer-overflow)来禁用此行为,以满足需要严格溢出检查的用户。
实施方案:在Flang中集成NSW
了解了NSW的原理和适用性后,现在我们来看看在Flang中的具体实现方法。
我们在Flang的IR生成器(Fortran::lower::ExprBuilder)中引入了一个新的标志。这个生成器负责递归地将抽象语法树(AST)节点降低为LLVM IR指令。
该标志控制是否为目标整数运算指令添加NSW属性。其逻辑如下:
- 默认启用:为循环变量增量、数组下标计算和循环边界计算添加
NSW。 - 特殊情况禁用:对于Fortran 2008及以后版本中引入的、用于位运算比较的内部函数(如
POPCNT,LEADZ),不添加NSW,因为它们可能有不同的溢出语义。
代码生成示例
考虑一个简单的Fortran DO循环:
DO i = 1, N
A(i) = B(i) + C(i)
END DO
在优化前的LLVM IR中,循环边界和下标计算是普通的算术运算。经过我们的修改后,生成的IR会包含NSW属性:
; 循环上界计算 (可能涉及转换)
%ub = add nsw i64 %N, 0
; 数组下标计算 (从32位扩展到64位)
%idx.ext = sext nsw i32 %i to i64
%idx = sub nsw i64 %idx.ext, 1
; 循环变量递增
%i.next = add nsw i32 %i, 1
这些NSW属性向后端的LoopVectorize通道提供了关键的不溢出保证,使其能够更准确地分析循环,并成功将更多循环类别A中的代码向量化。
挑战与未解决的问题
在实施过程中,我们也遇到了一些挑战和尚未解决的问题。
- 与现有优化的交互:添加
NSW属性意外触发了LoopStrengthReduce(循环强度削减)通道中的一个性能回归。该通道在考虑NSW时进行了不同的转换,虽然转换本身合理,但却阻碍了后续某些优化分析。这体现了优化通道之间复杂的相互作用。 - 剩余问题:TSVC中类别B(通过引用传递的归约变量)和类别C(编译时大小未知的数组)的问题根源已经找到,但目前尚无简便的解决方案。解决它们可能需要更复杂的前端变换或新的分析机制。
总结
本节课中我们一起学习了如何利用LLVM IR的NSW属性来提升Flang编译器的向量化能力。
我们首先分析了Flang在向量化方面面临的主要问题,特别是数组下标计算复杂化导致的向量化失败。然后,我们深入探讨了NSW属性的含义和作用,明确了在Fortran语境下安全添加该属性的依据。接着,我们介绍了在Flang前端IR生成器中集成NSW属性的具体实现策略。最后,我们了解了当前方案遇到的挑战以及尚未解决的其他向量化障碍。


这项改进使得Flang 20能够向量化此前无法处理的三个TSVC测试循环,是提升Fortran编译器性能的一次有效实践。它强调了前端高质量IR生成对后端优化的重要性。
015:为Propeller优化代码添加LLDB支持

概述
在本节课中,我们将学习如何使LLDB调试器支持由Propeller优化框架编译的二进制文件。我们将从Propeller的基本原理入手,探讨其在调试信息表示上带来的挑战,并详细介绍为解决这些问题而对LLDB内部API和逻辑所做的关键修改。
Propeller优化简介 🌀
上一节我们概述了课程目标,本节中我们来看看Propeller优化是什么。
Propeller是一种后链接优化框架,它通过重新排列代码来提升性能。一个普通的二进制文件包含多个函数,每个函数内部可能包含频繁执行的热代码和不常执行的冷代码。
Propeller的工作方式是:收集所有函数的热代码块,将它们集中放置在一起;对冷代码也进行同样的处理。
这种优化能带来更好的指令缓存局部性、更低的缓存未命中率,从而提升程序运行速度。
然而,这种代码布局的改变给调试带来了新的挑战。
调试信息表示的变化 📊
了解了Propeller的基本原理后,本节我们来看看它对调试信息表示的具体影响。
在未启用Propeller优化时,调试信息对函数的表示非常简单直接:它只记录每个函数的起始和结束地址。
伪代码表示:
DWARF Info for Function A: [Address_A_Start, Address_A_End]
DWARF Info for Function B: [Address_B_Start, Address_B_End]
启用Propeller后,情况变得复杂。因为一个函数的热代码和冷代码被分离到不同的内存区域,所以该函数在调试信息中会由多个不连续的范围来描述。
伪代码表示:
DWARF Info for Function A:
Range 1 (Hot): [Address_A_Hot_Start, Address_A_Hot_End]
Range 2 (Cold): [Address_A_Cold_Start, Address_A_Cold_End]
这种“一个函数对应多个地址范围”的表示方式本身并不新鲜,内联函数和变量早已采用类似方式。因此,你可能会认为LLDB能直接处理这种情况。
LLDB内部表示的问题 ⚠️
虽然DWARF格式支持多范围函数,但LLDB的内部实现却存在假设。在开始本项目前,LLDB的函数对象(如 Function)内部使用单一的地址范围(AddressRange)来描述整个函数。
问题代码示例(旧API):
AddressRange Function::GetAddressRange(); // 只返回一个范围
LLDB的做法是,为每个函数创建一个地址范围,这个范围试图覆盖该函数所有分散的代码块以及它们之间的内存区域。这显然会导致错误。
此外,一个更深层的问题是:LLDB缺乏直接获取函数入口点地址的API。当时的假设是,通过获取函数的地址范围,然后取该范围的基地址,就能得到函数入口点。
这种“函数是连续的”假设渗透在整个代码库中。即使某些API(如下所示)在设计中似乎支持返回多个范围,但所有调用它们的代码都未使用这个特性,而是默认函数只有一个范围。
旧API示例:
bool GetAddressRange (uint32_t scope, uint32_t range_idx, AddressRange &range);
// 尽管有range_idx参数,但调用者通常假设range_idx=0是唯一有效的。
解决方案:API重构 🛠️
面对这些问题,本节我们探讨如何通过重构API来解决。
从概念上讲,解决方案很简单:需要一套新的API。
- 添加获取范围列表的API:取代原先只返回单个范围的API。
- 添加单独获取函数入口点的API:明确区分“函数范围”和“函数入口”。
新API示例:
// 获取函数的所有地址范围
std::vector<AddressRange> Function::GetAddressRanges();
// 明确获取函数的入口点地址
Address Function::GetEntryPoint();
真正的难点在于,需要更新代码库中所有依赖旧有假设的代码,让它们能正确处理多范围函数的情况。这是一个庞大且细致的工作。
经验教训与最佳实践 📝
在更新代码的过程中,我总结了一些重要的经验教训。以下是开发者在处理函数信息时应避免的假设:
不要假设函数代码是连续的
函数可能由多个分散的部分组成。
不要假设函数的所有部分都在同一个节(Section)内
像Propeller这样的工具会将函数的不同部分放到不同的节中,以便链接器重新排列。这意味着你不应该基于节内偏移进行算术计算。
错误做法示例:
// 假设函数在单一节内,计算节内偏移
offset = current_address - section_base_address;
正确做法示例:
// 使用文件地址(在ELF中称为虚拟地址)进行重定位计算
offset = current_file_address - function_entry_file_address;
不要假设一个函数仅由一个EH帧记录或一个行号表序列描述
每个可独立重定位的部分都需要自己对应的记录,你必须准备好处理这种情况。
不要假设函数的入口点就是其最低的地址
链接器可以自由排列,函数可能从中间甚至任何地址开始。
不要假设从入口点开始的偏移总是正值
当你计算在函数中的位置时,必须准备好处理偏移值为负的情况。
项目现状与未来工作 🚧
最后,我们来了解一下本项目的当前状态和已知问题。
目前,LLDB内部的大部分API都已更新,能够处理多范围函数。主要例外是栈展开(unwinding)代码,修复工作仍在进行中,因为它几乎受到了前面提到的所有问题的影响。
此外,LLDB的某些行为可能看起来令人困惑。例如,反汇编一个经过Propeller优化的函数时,输出可能看起来不连续。这虽然不是错误,但用户很容易误以为是LLDB的bug。
未来可能需要改进用户界面,更清晰地展示函数由多个部分组成的这一信息。这需要进一步的开发工作。
总结


本节课我们一起学习了如何使LLDB支持Propeller优化代码。我们探讨了Propeller优化导致的函数多范围表示问题,分析了LLDB原有API中“函数连续”的假设带来的限制,并介绍了通过添加新API(如GetAddressRanges和GetEntryPoint)以及更新相关代码来解决这些问题的方案。最后,我们总结了在处理函数信息时应避免的关键假设,并了解了项目的当前进展。这些经验对于构建能够适应现代代码优化技术的健壮调试器至关重要。
016:交互式差分调试

概述
在本节课中,我们将学习如何使用交互式差分调试技术来定位复杂软件系统中的回归问题。我们将介绍差分调试的核心概念,并通过一个实际演示来展示如何利用工具快速缩小问题范围。
问题领域:复杂的软件系统
现代软件系统通常由数百万行代码构成,并由全球各地的开发者共同维护。在这样的系统中,开发新功能或进行维护工作很容易引发意外的行为,导致系统其他部分出现代码回归。这些问题通常难以隔离和复现。
随着系统复杂度的增加,调试成为开发周期中一项耗时且困难的任务,这对软件质量和添加新功能的能力产生了负面影响。
差分调试:一种强大的方法
差分调试是一种强大的方法,有助于缓解上述问题。其核心思想是使用一个先前稳定的版本作为参考点。通过将当前版本与基准版本进行比较,我们可以轻松地缩小问题可能被引入的范围,从而显著减少搜索空间。
然而,现有的工具通常将两个调试实例完全分开处理,它们之间无法通信或同步,这错失了一个重要的机会。
介绍交互式差分调试器
我们的解决方案是交互式差分调试器。它自动化了过滤参考版本和回归软件系统之间无关执行路径的过程。
让我们看看它的实际工作原理。首先,它同时持有同一系统的两个版本:一个我们认为正确的基准版本,以及一个我们怀疑引入了错误的回归版本。
接下来,我们使用像GDB或LLDB这样的调试器,在交互式差分调试器的控制下并行运行这两个版本。这些是广为人知的调试器,我们主要关注它们。
使用它们的强大之处在于,我们利用了深度视图。交互式差分调试器会自动比较内部状态,例如变量值、调用栈、内存布局,并仅高亮显示不同的部分。这意味着我们可以忽略所有噪声,忽略行为一致的系统部分,只专注于出现分歧的执行路径。
最终,我们得到一个聚焦的调试视图,它只显示差异,这极大地加快了根本原因分析的速度。
系统设计与实现
交互式差分调试器被设计为像LLDB和GDB这类调试器的前端。它利用了LLDB强大的Python API,从两个目标进程获取详细信息,提供了超越传统调试器输出的丰富洞察。
虽然交互式差分调试器也能在GDB上运行,但GDB缺乏LLDB提供的专用API,这使得LLDB成为高级功能的首选后端。
该系统的工作原理是允许每个LLDB实例调试同一程序的不同版本。交互式差分调试器同步它们的执行,并过滤掉相同部分,仅高亮显示差异。
用户界面
通过高亮显示差异,我们可以看到程序的界面。它是基于终端的。界面分为两个并排的视图:左侧显示基准版本,右侧显示回归版本。
我们可以向两个调试器实例同时发送单个命令。如果需要,也可以向特定目标实例单独发送并执行命令。
界面上有面板显示栈帧之间的差异,例如局部变量或子程序的参数,当然还有实际的执行状态。
演示:调试一个Clang回归问题
现在到了演示时间。在演示中,我们将尝试解决GitHub上的一个问题。这个问题与类模板参数推导有关。看起来在Clang版本20中存在一个回归。
我有一个可复现的案例。我们有一个结构体S,它有一个嵌套的M构造函数。我们断言其值类型等于int。如果我使用LLVM版本19执行此代码,不会引发任何问题。而如果我使用LLVM版本20,它会报告一个静态断言错误,但这并非预期行为。
我将启动交互式差分调试器。我指定LLDB作为我的基准调试器,使用版本19作为基准目标,版本20作为回归目标,然后启动它。
这需要一点时间,因为它需要读取所有符号,而这些二进制文件相当大。
因为我们要调试与模板推导相关的内容,所以我将在这里设置一个断点并启动程序。
现场演示总是有挑战性的。我们在“从初始化中推导模板特化”处停止了执行。这就是视图,你可以看到栈帧、局部变量、参数以及调试视图本身。为了简化,我修改了交互式差分调试器以在此处显示Clang版本号,这样阅读起来稍微容易一些。
目前,我们在基准版本和回归版本之间没有看到任何差异。让我们逐步执行,直到找到一些分歧。
我们有一些“候选”和“最佳”结果,这可能是我们需要查看的地方。还有一个“尝试解析重载”的lambda函数,可能也值得关注。
我们正在执行“尝试解析重载”lambda函数。执行后,让我们输出它解析出的“最佳”结果。

现在你可以看到,在基准版本中,我们构建了一个int类型,而在回归版本中,我们得到了记录类型,即结构体本身。我们在这里看到了分歧。差异视图让你更容易在基准版本和回归版本之间进行比较,颜色使其非常容易识别这些差异。
让我终止进程并重新启动,我们可以进入那个lambda函数看看。
我将步入这个函数。再次,看起来这里没有任何分歧。让我们继续执行。
我们位于一个for循环内部,正在添加潜在的推导候选。让我们步入这个函数,也许这里很有趣。“最佳可行函数”。再次,基准版本和回归版本看起来没有分歧。
但是,如果我们步入这里然后继续执行,我们看到了一个明显的分歧。基准版本在执行其他内容,而回归版本在执行完全不同的东西。在这种情况下,你可能更希望独立地查看基准版本或回归版本,而不需要交叉通信。在这种场景下,你可以使用中间的输入字段向回归版本或基准版本单独发送特定命令。
我将执行到下一个执行点。看起来我们在执行同一行代码,因此我可以再次同步执行并继续下一步。
让我们看看是否有其他分歧。这里似乎有一个分歧。如果我们向上滚动查看,我们位于一个if语句中,即“获取更特化的模板”。看起来在我们的基准版本中,我们进入了if块,而控制流退出了。所以在基准版本中,if语句结果为真,而在回归版本中,if语句结果为假。
因此,我们现在确切地知道了基准版本和回归版本在何处产生分歧。这有助于缩小搜索范围并精确定位错误出现的位置。如果需要,我们可以进一步调试,但我会在这里停止并继续幻灯片内容。
未来工作
未来的工作方向包括改进语义调试。在演示中,你可以看到指针值不同,但底层数据可能相同。在这种情况下,调试器声称基准版本和回归版本之间存在差异是没有意义的。我们希望关闭地址空间布局随机化能有所帮助,但不确定如何消除指针地址不同但底层值相同的情况下的差异。它应该能够识别出这是相同的东西。
下一个方向是自动在分歧的栈帧处中断。假设我们有两个版本,在基准版本中函数A调用函数B,而在回归版本中函数A调用函数C,那么栈帧就会产生分歧。在我们的示例中没有展示这种情况,但在这种栈帧分歧的情况下,也许可以引入一个新命令,例如“运行直到栈帧分歧”,一旦两个版本的栈帧产生分歧就停止执行,并将控制权交给用户,以便我们检查发生了什么。
观察分歧的变量。我们已经有了观察点,可以在特定变量值改变时暂停程序。但在我们的案例中,这可能不是很有趣。更有趣的场景是变量值发生了变化,但在基准版本和回归版本中的变化不一致。例如,基准版本中的某个整数被赋值为5,而回归版本中为25。在这种情况下,我们希望停止执行并检查发生了什么,而不是仅仅在变量上设置观察点并在所有场景下查看。
更好的GDB支持。GDB目前没有为我们提供可构建的API。我们目前在内部启动GDB命令行界面,然后使用通用输入/输出管道与GDB接口通信,这是一个非常糟糕的设计,使得同步执行变得困难。因此,我们可能需要添加API,或者找出其他解决方案,但GDB确实相当难以合作。
总结
在本节课中,我们一起学习了交互式差分调试技术。我们了解了如何利用基准版本来快速定位回归问题,通过自动化比较和过滤,将注意力集中在产生分歧的执行路径上。演示部分展示了如何使用该工具解决一个实际的Clang模板推导回归问题。我们还探讨了该工具的未来改进方向,包括语义调试增强和更好的调试器后端支持。对于处理复杂软件系统中的回归问题,交互式差分调试是一个强大且高效的辅助工具。
017:Pass 插件的过去、现在与未来 🚀

在本教程中,我们将学习 LLVM Pass 插件的演变历程、当前状态以及未来的潜在发展方向。我们将探讨其架构变化、使用方式、现有挑战,并构想如何使其支持更丰富的编译器扩展。
概述 📖
Pass 插件是扩展 LLVM 编译器功能的一种方式,允许开发者在编译器优化管道中插入自定义的中间表示(IR)转换过程。本节课将回顾其历史实现,分析现代架构,并重点讨论如何扩展其能力以支持更复杂的、领域特定的编译器扩展。
过去:传统 Pass 插件 ⏳
上一节我们概述了Pass插件。本节中,我们来看看其最初的传统实现。
传统 Pass 插件与旧版 Pass 管理器协同工作。我们使用 -load 选项动态加载它们。每个插件包含一个静态初始化器,该初始化器接收 Pass 注册表并添加自己的 Pass。
以下是 Polly 项目作为参考的实现方式:
// 传统插件通过静态初始化向全局Pass注册表添加Pass
static void registerMyPass(const llvm::PassManagerBuilder &Builder,
llvm::legacy::PassManagerBase &PM) {
PM.add(new MyLegacyPass());
}
static llvm::RegisterStandardPasses RegisterMyPass(
llvm::PassManagerBuilder::EP_EarlyAsPossible,
registerMyPass);
这种方式简单,但存在两个主要缺点:
- Pass 注册表必须是全局静态实例。
- 插件在没有与工具实际协作的情况下“潜入”其 Pass,这会导致各种问题。
现在:现代 Pass 插件 ⚙️
上一节我们介绍了传统插件的局限性。本节中,我们来看看为解决这些问题而引入的现代Pass插件。
现代 Pass 插件随着新版 Pass 管理器的引入而诞生,其核心变化是控制反转。现在由工具控制并发起交互。此外,Bye 示例成为了标准入口点。
在插件侧,我们实现一个定义的接口。它接收一个带有 Pass 构建器引用的回调函数。
以下是 Bye 示例中的插件实现:
// 插件侧:实现标准入口点
extern "C" ::llvm::PassPluginLibraryInfo LLVM_ATTRIBUTE_WEAK
llvmGetPassPluginInfo() {
return {
.APIVersion = LLVM_PLUGIN_API_VERSION,
.PluginName = "MyPlugin",
.PluginVersion = "v1.0",
.RegisterPassBuilderCallbacks = [](llvm::PassBuilder &PB) {
// 使用与内置工具相同的回调函数注册Pass
PB.registerPipelineStartEPCallback(
[](llvm::ModulePassManager &MPM, llvm::OptimizationLevel Level) {
MPM.addPass(MyModulePass());
});
}
};
}
在工具侧,我们枚举要加载的插件,动态打开它们,并调用上述函数,传入实际的 Pass 构建器实例。
这种方式非常棒,因为其代码路径对于树内工具和树外工具是相同的。我们可以在 MLIR 中使用相同的概念,只是入口点前缀不同。我们保留了现有的优势,例如用于插件注册的 C 接口,以及针对 LLVM 发布版本的快速构建。
但现代插件也存在一个未改进的缺点:Pass 基类是一个 C++ 模板类,Pass 构建器也是 C++ 类。这意味着我们将 C++ ABI 拉入了静态库接口,这导致了一些问题,主要是使得插件难以正确构建。有许多因素可能破坏你的 ABI 兼容性。
我们必须承认,大多数插件无论如何都会使用 C++ ABI,因为 C API 通常不够灵活。但兼容性问题确实存在。我们基本上需要为每个目标平台、每个 SDK 版本的每个编译器构建一个独立的插件,这并不理想。
支持的工具与现有插件 🛠️
上一节我们探讨了现代插件的接口。本节中,我们来看看哪些工具支持它以及社区中有哪些插件。
支持 Pass 插件的主要工具是 opt。我们甚至可以在其中选择要运行的 Pass,这对于调试和开发非常有用。然后,我们可以将 -fpass-plugin 选项用于 clang、flang 和 clang-repl。Swift 在主线上支持 LLVM Pass 插件。在 Rust 中,有一个不稳定的选项允许使用 LLVM Pass,但必须在 Rust 的构建配置中启用 llvm-plugins 选项,常规的 nightly 版本并不支持所有 Pass 插件。
令人惊讶的是,LLD 也支持 LLVM IR Pass。这适用于启用了 LTO 的构建,优化管道在链接时运行,并且它像编译器一样使用新版 Pass 管理器。
社区中现有的插件包括树内的 Polly、处理 VMIR 的替代插件 EnSom,以及来自 Bill 38 的、执行原生二进制混淆的 O-MVLL 插件的开源实现。除此之外,还有一些内部项目使用插件,但总体而言,这种方式仍然比较少见。
动机:对更丰富扩展的需求 💡
上一节我们看到实际使用的插件并不多。本节中,我们来探讨是否存在对更丰富编译器扩展的潜在需求。
一个观点是,对领域特定编译器扩展的需求比我们今天通过 Pass 插件看到的要大。这可能是因为人们找到了其他方式来实现他们的需求。
为了寻找证据,我们可以看看消毒器(Sanitizers)的发展。在 2020 年代初期,内置消毒器的数量似乎趋于稳定。这很合理,因为消毒器是帮助我们应对 C/C++ 不便之处的工具。然而,去年情况发生了逆转,我们新增了三个消毒器:TypeSanitizer、NumericalStabilitySanitizer 和 RealtimeSanitizer。
后两者显然是跨语言的、领域特定的问题。这引出了一个观察:它们更偏向领域特定,而非特定于 C/C++。如果未来有更多此类提案出现,我们是否应该将所有功能都构建到上游并分发给全世界,而实际上只有一小部分用户需要它们?或者,我们是否应该提出一种替代方案,通过某些基础设施来支持丰富的树外扩展,比如类似消毒器的功能?
我们能否用今天的 Pass 插件来实现?传统上,插件功能较窄,只进行静态局部转换,似乎不行。让我们看看消毒器需要什么:
- 前端部分:需要属性来控制其应用方式和位置。
- IR Pass:进行插桩,添加影子内存标记,调用运行时库等。
- 微小的驱动编辑:在链接行中添加运行时库。
插桩 Pass 显然可以用插件完成。但其他部分呢?以 RealtimeSanitizer 为例,如果它没有内置到 LLVM 中:
- 前端属性:可以用 Clang 前端插件来定义 Clang 属性。
- LLVM 属性:可以发射注解(annotation)属性,IR Pass 仍能检测到它们;或者,也许可以教会 Pass 插件定义 LLVM 属性。
- 链接运行时库:或许可以利用 LLVM 已有的自动链接功能(如
llvm.used、llvm.linker.options、llvm.dependent-libraries),虽然这有点非常规,但并非不可能。
如果这些都能实现,那么从功能角度,我们已经可以树外实现类似消毒器的扩展了。
未来展望与提议 🔮
上一节我们探讨了用插件实现丰富扩展的可能性。本节中,我们来看看如何促进这种生态的发展,并提出一些具体构想。
如果有人问我如何促进树外扩展,我会说:
- 提供可重用的基础设施。
- 使其成为用户测试想法的“游乐场”。
- 激励供应商支持并可能分发插件。
针对第一点,我们目前只有一个带有单元测试的插件接口和一个树内示例(有 Lit 测试)。但我们的构建机器人不太构建示例,因此测试几乎不运行,示例在许多 LLVM 配置下也有问题。供应商也不太分发示例。构想是:我们应该创建一个默认构建和部署的参考插件。
这个参考插件可以比“对函数挥手”更复杂,更像一个真实世界的例子。它可以同时支持 LLVM IR 和 MLIR,做一些有用的事情供人们实验。一个更有趣的构想是:创建一个能加载 Python 脚本并在管道中将其作为 Pass 运行的插件。Python 非常流行,这将成为一个真实世界的扩展。我们可以展示如何动态链接 libpython。这样,用户无需重建插件即可编写 IR 转换。
实际上,已经有两个开源仓库提供了概念验证。此外,为了说服供应商支持插件,我们需要认真对待他们的顾虑(例如安全漏洞)。我们可以通过代码签名来控制:工具只加载具有适当签名的插件。供应商可以审核允许在其平台上运行的插件。

对于 Rust(它对 C++ 有些问题),我们可以将 Pass 注册包装在 C 接口中,并使参考插件成为纯 C 接口插件,因为 Pass 注册已经是 C 语言了,Python 绑定也使用 C 接口。
总结 🎯
本节课中,我们一起学习了 LLVM Pass 插件的演变:
- 过去:传统插件使用全局注册表,存在协作问题。
- 现在:现代插件采用控制反转,与工具集成更好,但仍受 C++ ABI 兼容性困扰。
- 未来:存在对更丰富、领域特定扩展的需求。通过增强插件能力(如前/后端协作、链接支持)、提供强大的参考实现(如支持 Python),并解决供应商的顾虑(如代码签名),Pass 插件有望成为构建模块化、可扩展编译器生态系统的强大基石。

梦想未来的样子是健康的。希望这些讨论能激发更多关于编译器扩展性的思考与实践。
018:使用模式匹配、变换与代码替换

概述
在本节课中,我们将学习如何使用 LLVM 的 Polly 库进行多维模式匹配、循环变换和代码替换。这项技术旨在自动识别程序中符合特定模式的循环结构,将其转换为可被高度优化的目标实现(如特定库函数或硬件指令)调用的形式,从而提升程序性能。
多维向量化的挑战
上一节我们概述了课程目标。本节中,我们来看看多维向量化面临的挑战。在 LLVM 中,对于单维循环,我们已经拥有强大的工具,例如循环向量化器或 SLP 向量化器。然而,多维循环的优化仍然是一个开放性问题。
优化的目标实现通常以优化库函数调用的形式存在,例如 OpenBLAS 中的矩阵乘法或 Intel AMX 扩展中的二维点积。程序员通常通过显式调用这些库或内部函数来使用它们。但这种方法可移植性差且容易出错。
因此,MMTR(多维匹配、变换与替换)的目标是:
- 识别可被替换的循环。
- 对循环进行变换,使其能够被替换。
- 用优化的目标实现替换这些循环。
MMTR 的工作原理与限制
我们观察到,许多优化的目标实现在语义上等同于一个具有以下特征的完美嵌套循环:
- 稠密且矩形的迭代域。
- 常量步长。
- 循环不变量迭代次数。
- 循环体较小,控制流不太复杂。
这些是相当强的限制条件,也可以看作是 MMTR 未来需要改进的方向。这类循环适合在多面体模型中进行分析,也适合进行模式匹配。
MMTR 作为 Polly 库的扩展实现,Polly 是随 LLVM 发行的多面体优化库。它目前实现在 LLVM 19 中,工作在 LLVM 的中端。有两种模式:
- 参数化维度:维度大小作为参数传递给优化目标实现,通常用于库函数调用。
- 固定维度:维度大小限制为固定值,通常见于操作固定大小向量或矩阵寄存器的专用指令。
多面体模型简介
为了理解后续内容,我们需要简要了解多面体模型。它是一种用于表示控制流和内存访问的紧凑模型。一个限制是,所有的数组索引和分支条件都必须是仿射函数(类似于线性函数)。
以下是核心概念:
- 多面体语句:一段线性代码,可以近似理解为基本块。
- 多面体语句实例:特定迭代变量取值下的一次语句执行。
- 实例集合:所有实例的集合。
- 数组访问映射:一个从多面体语句实例到数组索引的映射。
- 多面体调度:一个从实例到多维时间点的映射。
在 MMTR 中,我们利用这些映射来分析内存访问和执行强大的循环变换,例如循环分裂、循环融合、条带挖掘或分块。
MMTR 的输入:M 模式
MMTR 需要为每个优化的目标实现定义一个 M 模式 作为输入。这个模式用于匹配目标循环。
一个 M 模式包含以下部分:
- 指令模式:匹配目标循环体的指令序列。
- 维度信息:需要匹配的循环维度信息。
- 内存访问信息:所有内存访问的描述。
- 替换块信息:匹配成功后用于替换的代码块信息。
实例解析:矩阵乘法
为了展示 MMTR 如何工作,我们来看一个简短的例子:一个典型的矩阵乘法实现。
以下是其核心循环结构:
for (i = 0; i < N; i++) {
for (j = 0; j < M; j++) {
C[i][j] = 0; // 初始化块
for (k = 0; k < P; k++) {
C[i][j] += A[i][k] * B[k][j]; // 核心计算
}
}
}
注意其中包含一个初始化块 C[i][j] = 0;,这一点稍后会很重要。
定义 M 模式
假设我们有一个用于向量矩阵乘法的优化目标实现。我们需要在 TableGen 中为其指定 M 模式。
以下是定义步骤:
- 指定指令模式:直接映射核心计算部分的指令树(乘法和加法)。
- 指定访问:我们有三个读访问(A[i][k], B[k][j], C[i][j])和一个写访问(C[i][j])。其中,对 C 的访问是二维矩阵访问。
- 指定维度:例如,固定维度,外层大小为 6,内层大小为 4。
- 指定替换调用:最终指定优化目标实现的函数名及其所需参数。
匹配与变换过程
MMTR 按以下步骤工作:
1. 匹配循环体
首先,MMTR 使用指令模式检查循环体中的所有指令是否等价于该模式。同时,它会捕获后续计算可能需要的值,例如需要作为参数传递的指针。
2. 检查实例集合的形状和大小
目前,这被限制为矩形形状。MMTR 会检查循环的迭代域是否符合要求。
3. 检查内存访问映射
对于每个内存访问,检查其是否映射到指定的模式。例如,对于二维矩阵访问,它需要投影到最内层的两个维度。这涉及到模重命名等操作。
4. 获取步长信息
通过 Polly 的 ArrayInfo 功能获取访问矩阵所需的步长信息。
5. 执行必要变换
匹配成功后,可能需要进行变换以使循环可替换。例如,上面例子中的初始化块 C[i][j] = 0; 无法被目标实现替换。因此,我们需要通过操作 Polly 的调度树,使用循环分裂将其移出主循环体。
此外,对于固定维度模式,我们还需要进行循环分块,这会产生序幕、主体和尾声。最终,留在中心的主体部分就是一个与我们的 M 模式完全等价的嵌套循环。
这些变换通常不是无条件安全的,因此我们必须检查变换后的调度是否仍然有效。
6. 代码替换
在 Polly 的代码生成阶段,MMTR 会移除这个嵌套循环,并用对优化目标实现的调用来替换它,并提供之前捕获的参数。
评估与结果
我们对 MMTR 进行了评估,使用 OpenBLAS 作为目标实现。我们提供了四种模式:
- 三维模式:矩阵乘法。
- 二维模式:向量矩阵乘法(如示例所示)。
- 两种一维模式:点积和向量加法。
在 PolyBench 测试集中,30 个基准测试里有 7 个成功匹配。三维和二维案例获得了显著的加速。一维案例的性能与常规 LLVM 向量化器相当。这些好结果主要归功于 OpenBLAS 的高效实现以及基准测试本身非常适合 OpenBLAS。

此外,我们在 MLPerf Tiny 基准套件的 Visual Wake Words 模型上进行了测试。使用 TFLM 从模型文件生成 C 代码后,我们在热点函数中获得了 4 次匹配,将推理运行时间减少了 66%。
未来工作
MMTR 的未来工作方向包括:
- 泛化访问模式。
- 泛化迭代域的大小和形状。
- 提供更好的成本模型。
总结
本节课中,我们一起学习了如何使用 LLVM Polly 库的 MMTR 扩展进行多维模式匹配、变换和代码替换。我们了解了其工作原理、如何定义 M 模式、完整的匹配与变换流程,并通过实例和评估结果看到了该技术的潜力。MMTR 为自动利用高度优化的底层库或硬件指令提供了一条途径,是提升程序性能的有力工具。
019:现状与问题


在本教程中,我们将介绍一个名为AutoCheck的静态分析工具。该工具主要针对MISRA和AUTOSAR编码规范,用于检查C++代码的安全性。我们将探讨其实现原理、功能特点、当前的开源状态以及面临的挑战。
概述
AutoCheck是一个基于Clang的静态代码分析工具,旨在帮助开发者遵循汽车行业的关键安全编码规范,如MISRA和AUTOSAR。它既可以作为持续集成(CI)流程的一部分,也可以作为代码编辑器的插件使用。本教程将详细解析其技术架构和独特功能。
MISRA规范简介
首先,我们需要理解AutoCheck的主要目标之一:MISRA规范。MISRA是一套为编写安全C++代码而制定的编码指南,在汽车软件等安全关键型行业中至关重要。该规范支持多个C++标准,而AutoCheck工具支持其中大部分标准。
工具实现原理
上一节我们介绍了MISRA规范,本节中我们来看看AutoCheck是如何实现的。该工具本质上是围绕Clang库的一个封装器,其实现方式与Clang Tidy类似。
以下是实现规则所基于的几个前端阶段:
- 预处理器(Preprocessor)
- 词法分析(Lexer)
- 抽象语法树访问者(AST Visitors)
- 语法树匹配器(ST Matches)
一个有趣的特点是,部分规则是在LLVM IR(中间表示)级别实现的。这是因为在进行某些数据流分析时,使用LLVM IR比使用AST更有优势。例如,有一条MISRA规则对某些算术运算的值范围提出了约束,这条规则就是在LLVM IR层面实现的。
语言服务器协议(LSP)集成
正如之前提到的,我们实现了语言服务器协议(LSP)。这使得工具能够轻松集成到各种集成开发环境(IDE)中,而不仅仅是VS Code。以下是一个示例,可以清晰地展示工具如何报告规则违规。
人工智能集成
我们还在有意义的地方集成了人工智能(AI)。具体来说,AI被用于那些编译器技术难以处理的场景。例如,静态分析器可能建议优化结构体(struct)的字段布局。在进行字段重组时,我们使用AI来保持数据的局部性(cache locality)。为此,我们利用了ama-cpp API。
项目现状与挑战
这是本次介绍中最重要的部分:项目的当前状态。我们在一年前宣布了该项目并将其公开,但后来由于不确定是否违反了某些许可证(即法律问题),不得不再次将其转为私有。因此,我们确实需要帮助。
如果您或您所在的公司有相关经验,请通过提供的联系方式与我们联系。我们非常希望将此项目开源。

由于时间关系,我们无法进行演示。感谢观看。



总结


本节课中我们一起学习了AutoCheck工具。它是一个基于Clang、用于检查MISRA和AUTOSAR编码规范的静态分析工具。我们了解了其通过多个编译阶段(包括LLVM IR)实现规则的方式,以及它如何通过LSP支持多种IDE,并在特定场景下集成AI以提供更优的代码建议。最后,我们也了解到该项目目前因许可证问题处于私有状态,并正在寻求社区帮助以实现开源。
020:利用标量扩展增强自动向量化的循环分布


在本教程中,我们将学习如何通过改进循环分布(Loop Distribution)来增强LLVM编译器的自动向量化能力。我们将探讨当前循环分布算法的局限性,并介绍一种基于数据依赖图(DDG)和标量扩展(Scalar Expansion)的新方法。
背景与动机 🎯
循环分布是一种编译器变换,它将一个循环分割成多个具有相同迭代空间的循环,每个新循环只执行原循环体的一部分。然而,这种变换并非总是有益的,因为它不仅会重新调度计算,还可能复制某些计算(例如循环头)。
循环分布如何实现部分循环向量化?对于包含依赖环(dependent cycles)的循环,它们通常是不可向量化的。循环分布可以通过将这些环拆分到独立的循环中,从而实现对循环其他部分的向量化。
LLVM目前有一个循环分布优化通道(pass),其目标也是实现部分向量化。它的实现使用了一种快速、轻量级的算法,不构建依赖图,仅依赖循环简单别名分析(LAA)。但这种方法存在挑战和局限性,主要是无法对内存操作进行重排序。因此,该通道目前虽在流水线中,但默认未启用。
我们这项工作的重点是尝试利用数据依赖图来改进其分区能力,并希望未来能为“总是有益的分布”建立更好的成本模型。
当前算法的局限性案例研究 🔍
以下是三个展示当前分布通道局限性的案例。
案例一:优化后分区边界不可分
此案例取自TSVC S2121。在CSE和GVN等其他通道进行向量化前优化时,这段代码是可分布的,可以将一个语句分离到一个循环中。但优化后,冗余的加载被消除,导致分区边界变得不可分割。
案例二:内存操作顺序敏感
此案例展示两个具有相同计算但内存操作顺序不同的循环。只有一种模式可以被分布。这是因为当前的循环分布通道依赖于内存操作的词法顺序。
案例三:由Phi和SSA值引起的依赖环
此案例中的依赖环仅由Phi节点和SSA值引起。当前的分布通道可以检测由内存操作引起的依赖环,但无法检测由Phi和SSA值引起的环,因此不会分布此案例,而此案例是不可向量化的。
造成这些局限性的原因是当前算法仅依赖LAA,这使得重排内存操作变得不安全。因此,它只能分布某些特定模式,即分区边界由不安全的内存操作决定,并且如果它们共享相同的内存操作,还会导致一些合并。
相关工作与我们的计划 📋
社区之前对此问题已有许多努力,例如该通道原作者在其首次演讲中提到了未来基于依赖图进行分布的工作,其他演讲也提到了向基于DDG的分布迈进。值得注意的是,目前DDG补丁已经合入LLVM上游。
然而,为什么基于DDG的循环分布仍未成为现实?我们发现,人们主要担心其可扩展性,因为构建整个依赖图会带来预期的时间复杂度问题。
在我们的工作中,我们计划尝试将可用的DDG分析集成到循环分布通道中。我们试图覆盖当前算法已处理的所有情况,并通过运行一些基准测试来检查是否存在性能回归。对于未来的工作,我们希望借助此分析启用更多案例,并开发更好的成本模型用于合并和调度。
基于依赖图的经典分布算法 📚
经典的教科书式分布算法基于依赖图,主要包含三个步骤:
- 将源代码转换为计算依赖图。每个节点代表一个语句,每条边代表一条内存依赖边。
- 计算强连通分量(SCC)。同一SCC内的语句应放在同一个循环中。
- 循环的顺序应遵循SCC依赖图的拓扑排序顺序。
将算法适配到LLVM IR 🔧


将此算法适配到LLVM IR最具挑战的部分是第一步。因为在原始算法中,一个语句映射到一个DDG节点,但在LLVM IR中,我们不能将单个指令放到不同的循环中,我们需要将所有依赖的指令放在同一个循环中。
首先,我们看看当前的DDG图是怎样的。其节点是指令级别的,可能包含单指令节点、多指令节点或DDG中的SCC(会形成一个管道块)。其边只有两种:一种是使用-定义链(Use-Def),另一种是内存依赖边。它目前不支持控制依赖,并且支持为单个函数或单个循环构建依赖图,无需为整个模块构建所有依赖图。
回到算法的第一步。为了适配LLVM IR,我们只需要找到“种子指令”,然后沿着使用-定义链向上遍历,获取所有依赖的指令。
但是,即使有了DDG,有些情况仍无法通过此方法启用,例如我们刚才提到的案例研究一,其中存在的冗余加载会将所有分区合并在一起。因此,我们希望引入标量扩展技术来解决这个问题。
以下是动机示例。在这个例子中,我们想将蓝色指令和绿色指令分开,但这两个种子指令依赖于同一个SSA值。这里的直觉是,我们可能希望通过加载指令重新加载这个SSA值,这样两者就变得可分离了。
这个过程类似于向量化中的标量扩展。扩展通常用于打破依赖,但通常涉及新的内存分配。例如,在右侧,你会看到每次迭代都会赋值到一个新的T[i]位置,从而打破由标量t创建的循环依赖。
我们在分布通道中想做的是检测可进行无分配扩展的标量。其要求是:存储(store)必须在每次迭代中写入不同的地址,并且新的加载(load)必须支配我们分区中的所有使用。基本上,它逆转了存储-加载转发(store-load forwarding)。我们的启发式方法是:检查存储指针的标量演化(scalar evolution)是否可计算,以验证其符合第一个要求;第二个要求是,我们使用存储的位置来验证支配性,因为目前我们在存储之后立即插入新的加载。
实验与结果 📊
我们的目标是覆盖原始循环分布处理的所有情况。我们主要关注被分布的循环数量,同时也会展示编译时间开销的结果。
实验设置:使用Intel核心CPU和LLVM 20编译TSVC和Spec CPU 2017基准测试。试验中有三种配置:
- LLVM 20,不启用循环分布。
- 启用原始版本的循环分布。
- 启用基于DDG版本的循环分布。
结果显示,在大多数基准测试中,我们版本分布的循环数量超过了原始版本,尽管仍有一些案例出现性能回归。
在向量化循环数量方面,约一半的基准测试表现优于原始版本,而另一半的向量化数量有所下降。我们稍后会解释这个结果。
关于编译时间回归,所有编译时间以秒为单位。最右侧一列是我们DDG版本的编译时间。可以看到,考虑到编译时间,变化并不大,而且在一些基准测试中,编译时间甚至有所减少。这是因为在我们的DDG版本中,当我们发现没有可获利的循环分布候选时,能够提前退出。
此外,从图表中可以看到,DDG分析所消耗的时间(R列)只占整个循环分布通道执行时间的一小部分。
结果分析与案例分类 📈
我们的DDG版本并未在所有基准测试中都表现更优。为了解释这一结果,我们将成功的分布案例分类为以下六种情况。
情况一:冗余计算分布
在这类案例中,原始循环分布能够进行分布,但我们可以看到,分布后分区1中的所有指令都包含在分区0中。这种分布被视为冗余计算。在我们的DDG版本中,我们会消除此类分布,并且分区1有可能被向量化。这种冗余分布也会影响向量化循环的数量。
情况二:运行时检查案例
在这类案例中,我们为C和D添加了restrict关键字,因此只需考虑A和B是否与其他内存重叠。在原始循环分布中,它会先忽略这些别名边,然后尝试分布。而在我们的DDG版本中,由于DDG不提供运行时检查功能所需的信息,我们无法处理此类案例。

情况三:仅包含反向依赖的案例
这类案例无法被分布,在原始版本中也无法被向量化。但我们可以看到中间的简化DDG图。如果我们交换语句顺序,将第二个语句放在前面,我们就能在不分布的情况下向量化这个测试案例。在原始循环分布中,我们无法知道这个final指令的内存依赖。而在我们的DDG版本中,这个final指令会被插入到管道块中,我们将其视为非循环分区,因此能够分布此类测试案例。
情况四:可识别为循环分区的案例
在这个测试案例中,我们假设第二个语句中的所有操作都是独立加载的。左侧是原始版本,能够被分布;右侧是我们的DDG版本。我们能够识别出第二个语句是一个循环分区,并且能够被我们的DDG版本分布。


情况五:我们的工作能做而原始版本不能的案例
我们能够使用标量扩展技术来增强循环分布。我们能够复制仅加载的指令。在这个测试案例中,语句1和语句2中都有D[i],但实际上,在分区0和分区1中,它们是相同的加载D[i]。在原始版本中,它们会被合并到一个分区,从而导致分布失败。而在我们的DDG版本中,如果我们分析发现一个加载指令有多个使用,并且该加载指令与任何内存依赖无关,我们就能够复制这个加载,从而分布此类循环。
总结与展望 🌟
本节课中,我们一起学习了如何通过结合数据依赖图和标量扩展技术来改进LLVM中的循环分布,以增强自动向量化。
我们比较了两种不同版本分布的支持情况。原始版本需要保持内存操作顺序,而我们的DDG版本能够重排内存操作以增强循环分布。

然而,我们DDG版本循环分布的主要局限性在于无法提供运行时检查功能,这是因为我们的DDG不提供别名信息。此外,我们目前的方法只处理包含循环依赖的案例,对于仅包含反向依赖的案例,如果我们能如前所述重排语句顺序,则能够向量化此类案例。
关于一半基准测试在DDG版本中向量化数量下降的原因:最后一个基准测试是因为原始分布存在冗余分布,这也会影响向量化数量。另外三个基准测试则是因为我们的DDG版本不支持运行时检查,因此无法匹配原始版本分布的性能。


未来的工作可以集中在开发更精确的成本模型,以评估不同代码生成策略(如重新计算与重加载)的代价,从而做出更优的分布决策。
021:LLVM IR 的过去、现在与未来


在本节课程中,我们将回顾 LLVM IR 的发展历程,探讨其近期的重要变化,并展望未来的演进方向。我们将重点关注三个核心主题:类型的简化、分析结果的显式表达以及指针来源(provenance)的精确追踪。
遥远的过去:LLVM 1.0 时代
上一节我们介绍了课程概述,本节中我们来看看 LLVM IR 的起点。在 LLVM 1.0 时代,IR 的语法与今天大不相同。
它包含了一些如今已不常见的元素:
- 实现定义的类型:例如
implementation begin。 - C 风格整数类型:例如
long和U end。 - 一等公民的
memfree指令:用于内存释放。
随着时间的推移,这些元素逐渐被移除。LLVM 2.0 引入了更熟悉的语法和无符号整数类型。LLVM 7.0 移除了 memfree 指令,改用普通的函数调用。最终,在 LLVM 15.0 切换到不透明指针(opaque pointer)后,IR 才呈现出我们今天熟悉的样子。
类型简化:一个持续的主题
上述演变过程的一个共同主线是“去类型化”,即从 IR 中移除不必要的类型信息。
以下是两个关键例子及其动机:
- 无符号整数类型:用单一的
integer类型替代独立的signed和unsigned类型。 - 不透明指针类型:用单一的
ptr类型替代多种具体的指针类型(如i32*)。
这样做的动机始终一致:避免引入不必要的类型转换。虽然这并非严格启用了新的优化,但它引导编译器走向更通用的方向。例如,当有符号加法和无符号加法是同一个操作时,对它们进行去重就变得非常简单。此外,这也有正确性方面的考虑:如果保留一个带有元素类型的指针,却告诉开发者这个类型没有语义,那么开发者很可能会误用。彻底移除它是防止误用的唯一方法。
当前进行中的变化:从 GEP 到指针加法
延续“去类型化”的方向,一个正在进行的重要变化是从 getelementptr (GEP) 指令迁移到 ptr.add 指令。
目前,GEP 是基于类型的。它需要一个基类型和指向该类型的索引。以下三个 GEP 指令实际上执行完全相同的操作:将一个指针增加 4 个字节。
%p1 = getelementptr i32, ptr %base, i64 1
%p2 = getelementptr [4 x i8], ptr %base, i64 0, i64 4
%p3 = getelementptr {i16, i16}, ptr %base, i64 0, i32 1
我们的目标是将其转变为单一的 ptr.add 指令,该指令只做一件事:给指针增加一个偏移量。
这项迁移已部分完成。目前,在可能的情况下,我们使用 getelementptr i8,这基本上等同于 ptr.add。尚未完成的是对可变偏移量的处理。这种情况更为复杂,因为它通常涉及一个缩放因子。这个缩放因子可以表示为独立的移位或乘法指令,也可以作为原生缩放支持内嵌到 ptr.add 指令中。关于采用哪种方向的决定尚未做出,这也是该项目目前停滞的地方。
其他潜在的简化方向
“去类型化”的思路可以应用到更多地方:
load/store指令:目前它们接受一个类型参数,但实际只使用该类型的大小和对齐信息。理想情况下,应直接表示大小和对齐,这可以防止开发者做出错误的假设(例如,忽略结构体中的填充字节)。- 各种属性:如
byval、sret。 - 全局变量:这更棘手,因为它们还涉及初始化器。
指令标志的激增与价值
现在,让我们切换到一个完全不同的主题。在过去的几个 LLVM 版本中,我们添加了大量新的指令标志。
以下是新增标志的例子:
or disjointzext的nneg(non-negative)icmp的us(unsigned signed) 等
许多新标志的目标之一是能够撤销规范化转换。例如,一个 or disjoint 指令可以转换回 add;带有 nneg 标志的 zext 可以转换回 sext;带有 us 标志的比较可以透明地在无符号和有符号谓词之间切换。
这样做的动机包括:
- 撤销转换:某些优化(如地址模式匹配)可能更偏好原始的指令形式(如
add而非or)。标志允许我们在更复杂的场景中可靠地撤销之前的转换。 - 显式分析结果:将一个过程(如复杂的过程间分析)推断出的信息,通过标志传递给另一个只进行局部变换的过程。
- 传达前端保证:基于语言语义,前端可以附加一些 LLVM 自身可能无法推断出的保证。
IR 中的值约束:一个核心挑战
将分析结果或值约束显式化到 IR 中,是优化编译器的核心挑战之一。LLVM 提供了多种机制,但没有一种是完全令人满意的。
以下是现有机制及其问题:
- 属性与元数据:允许表达精确的信息(如精确的值范围),但主要作用于调用或加载边界,一旦内联就很容易丢失。
- 指令标志:精度较低(如
nneg只提供一个比特的信息),且只适用于特定指令。 - 假设(
assume):完全通用的机制,可以编码任何信息,但额外的指令和使用会阻碍优化。
我们当前的策略是继续添加更多的属性和标志,希望情况能有所改善。
一个前瞻性构想:操作数标志
一个思考方向是,某些新标志实际上与周围的指令无关,纯粹是关于值的陈述。因此,更合理的做法可能不是创建像 zext nneg 这样的指令,而是让一个普通的 zext 指令的某个操作数具有 nneg 标志。
这种“操作数标志”的概念可以泛化,涵盖许多最近添加的指令标志(如 ui2fp 的 nneg、icmp 的 us),甚至包括一些我们目前没有但很有用的标志(如 sub nsw 的 nneg)。进一步的泛化是在特定使用点或操作数上附加范围信息。
然而,这个构想面临巨大挑战:
- 维护负担:每次添加新标志都会带来大量的更新工作,因为许多变换会就地修改指令,可能无法维持新标志的不变性。
- 存储开销:为每个用户存储信息会带来开销。不过可以进行一些权衡,例如只存储前导零比特数,这能以更低的成本获得大部分优化收益。
这个方向目前仍非常具有推测性,其可行性有待验证。
指针来源与捕获属性
让我们回到更实际的议题。除了新指令标志,我们也添加了许多新属性,动机类似:传达前端约束和显式化分析结果。这里我们重点讨论最新的 captures 属性。
captures 属性取代了旧的 nocapture(即 captures none)。它的新意在于允许指定指针的哪些组件可能被捕获,主要组件是地址(address)和来源(provenance)。
我们可以这样声明:
- 这个指针参数只捕获指针的地址,但不捕获其来源。
- 或者,它捕获两者,但仅通过函数返回值。
这里的核心区别是:
- 地址:指针的整数值或指针身份。
- 来源:通过该指针进行内存访问的实际权限。

了解一个内存对象的地址并不意味着你被允许使用它,你必须拥有相应的来源。这个区别对别名分析和内存优化至关重要,它们只关心来源,不关心地址。例如,仅比较地址但不暴露来源的指针比较,将不再干扰别名分析和内存优化。
减少来源暴露的更大目标
强调这个话题,是因为它是一个更大目标的一部分。考虑 ptrtoint 指令:它将指针转换为整数,然后你可能想将整数转换回指针。ptrtoint 转换会暴露指针的来源,然后在 inttoptr 时恢复。问题在于,这从技术上使 ptrtoint 产生了副作用,意味着我们不应该消除它。但为了优化,我们有时还是会这样做,尽管这可能导致错误的编译结果。
部分问题在于,LLVM 目前没有任何机制可以让你获取指针地址而不同时暴露其来源。因此,我们需要这样的机制,例如为 ptrtoint 添加一个标志,或者一个单独的 ptrtoaddr 指令。
与此密切相关的是,IR 中 ptrtoint 的一个最大贡献者是指针减法,因为 LLVM 没有原生操作。你必须先将指针转换为整数,然后再进行整数减法。这意味着每次指针减法都会暴露来源,导致指针“逃逸”,从而使大多数内存优化停止工作。
因此,我认为我们应该有一个专门的 ptr.sub 指令来镜像 ptr.add,它应该只捕获地址。如果结合常见的标志(例如,要求相减的指针属于同一内存对象),那么减法甚至不会泄露任何关于对象基地址的信息。
总结与展望
本节课中我们一起学习了 LLVM IR 演进的三个核心方向:
- 去类型化议程:持续简化 IR 中的类型信息,例如从不透明指针到
ptr.add指令的迁移,旨在减少不必要的复杂性并防止误用。 - 分析结果与约束的显式化:通过指令标志、属性等机制,将分析信息和前端保证编码到 IR 中,以促进优化过程间的协作。
- 减少指针来源暴露:更严肃地对待指针来源,通过新属性(如
captures)和潜在的新指令(如ptr.sub),目标是减少因ptrtoint等操作导致的错误编译,同时在此过程中改进优化。
当然,IR 的变化远不止这些。例如,最近有出色的工作将调试信息表示从内部函数转换为记录格式,未来还有更多改进调试位置处理的变更。此外,即将有重大变化来改进受约束浮点运算的表示方式(从专用内部函数转换为通用内部函数上的操作数绑定包)。由于时间关系,这些内容无法在此详述。

LLVM IR 的演进是一个持续的过程,需要在引入新功能、改进优化能力和维护生态系统稳定性之间不断权衡。
022:深入探讨 MLIR 到 LLVM IR 的翻译机制



在本教程中,我们将学习 MLIR 与 LLVM IR 之间的连接机制。我们将探讨它们如何历史性地建立联系,以及未来可能的发展方向。课程将包含约 10-15 分钟的技术深入探讨,并扩展到关于 MLIR 生态系统及其应用的更广泛讨论。
MLIR 与 LLVM 的关系
MLIR 是更广泛的 LLVM 项目的一部分,就像 LLVM 中的许多其他子项目或库一样。
我们可以将 MLIR 视为 LLVM 的精神继承者。LLVM 本身可以被看作是一种带有有用指令和类型的静态单赋值形式。它非常有用,已经存在了大约 20 多年,许多人都在愉快地使用它。
MLIR 借鉴了它,并试图将其泛化,有时甚至过度泛化。但它的核心是带有区域的 SSA,并提供了一种引入自定义指令类型的方法。因此,与 LLVM 提供固定操作集不同,MLIR 允许你直接添加自己的类型、操作和属性。
本教程不是 MLIR 的介绍,我们假设大多数人对 MLIR 有一定了解。
最后,本教程更技术性的部分,即我最初打算讨论的内容,是 MLIR 表示与 LLVM IR 之间的双向翻译。这是一个技术演示,我们将深入探讨。这基本上是将一个项目连接到另一个项目,形成一个完整编译流程的方法。
MLIR 的起源故事
在讨论连接机制之前,为了证明这种连接方式为何有效,我需要讲述 MLIR 的起源故事。
MLIR 的诞生源于一个认识:当今计算机科学的许多工作都投入到机器学习或人们称之为人工智能的领域。早在 2018 年,TensorFlow 是最流行的机器学习框架之一,无论是在谷歌内部还是外部。
谷歌的一个团队观察后发现,如果你打开 TensorFlow 的黑盒,它的工作方式几乎就是编译器。它充满了许多小的“龙”。我知道 LLVM 喜欢龙,所以 TensorFlow 也是一个编译器,只是它没有使用合适的编译器设计,或者它没有使用 LLVM 风格的设计。
因此,由 Chris Lattner 领导的谷歌团队着手解决这个问题,提供一个编译基础设施,既能支持像 LLVM IR 这样的东西,也能支持 TensorFlow 生态系统内部使用的抽象。
MLIR 项目始于 2018 年初。它首先被推送到 TensorFlow 仓库,并在那里停留了几个月,然后我们提议将其上游到 LLVM 项目中。经过几个月的谈判,它最终在 2019 年 12 月左右合并。
我在 2018 年底加入,见证了项目的大部分发展。许多人认为我一直都在,其实并非如此。
2018 年底的项目状态
当时,项目有一个 IR 文件夹,里面有很多东西。例如,IR 有操作,但当时还有 7 或 8 个不同的概念。它有一些分析,比如普通的 SSA 支配性。它对多面体有原生支持,我来自多面体编译领域,对此感到非常高兴。
它有 17 个操作,其中大部分是从 LLVM 借鉴的,实际上还有更多来自 TensorFlow。MLIR 项目本身只有 17 个操作,并且有一些有用的转换,比如规范化器、循环转换等。
但你知道它缺少什么吗?任何执行这段代码的方法。你可以转换它,得到 IR 输出,但你无法仅用 IR 做任何事情。因此,我着手通过确保 MLIR 连接到 LLVM 来解决这个问题,这样我们就可以使用 LLVM 编译器来生成可执行的二进制文件,并验证我们的转换在保持正确性的同时确实使程序更快。
早期的连接工作
这就是我在那一年里主要做的事情,大部分在私有仓库中。我们将 TensorFlow 转换到这个漂亮的多面体表示,然后转换到正常的控制流图。但 MLIR 中没有分支,所以我必须添加它们。
然后我们将其转换为 LLVM IR。我们将 MLIR 操作翻译成 LLVM 中的等价物。但 MLIR 操作透明地处理张量,这些是像大型多元素对象一样的东西。因此,我们不得不从张量回到精细循环和 CFG,然后再返回,在 lowering 过程中形成了一种循环,这有点丑陋。
然后我们可以调用,例如,如果你从 TensorFlow 运行,或者只是发出 LLVM IR 并调用 clang 或 llc。
但我们有一个叫做 memref 的类型,用于成员引用,而测试没有 API,所以我们必须定义它。因此,有很多工作,其中很多是编写 LLVM 编译器时通常会做的事情。你需要编写大量使用 LLVM IR 构建器来生成 LLVM IR 的代码,然后才能调用 pass 和其他有用的东西。
我们并不完全想这样做,因为 MLIR 代表多级 IR。直接从循环到 LLVM IR 是 Clang 做的事情,中间没有多少“多级”可言。所以在某个时刻,我们决定,是的,我们可能应该做这个 LLVM 方言。我们讨论方言已经好几个月了。
LLVM 方言的引入
LLVM 方言是第一个被添加的方言之一,作为所有其他 MLIR 指令与 LLVM IR 世界之间的中间层。你转换到那个东西,然后就可以转到 LLVM。
许多这些东西仍然存在,它们为今天 MLIR 的基础设施奠定了基础。例如,多面体到 CFG 的转换现在称为 convert-scf-to-cfg。CFG 到 LLVM 方言的转换现在称为 convert-*(我们现在有几十种方言)到 LLVM。
这两者共同导致了所谓的数据转换基础设施。LLVM 方言到 LLVM IR 的转换产生了一个叫做模块翻译的东西。MLIR 中有一个执行引擎,它抽象了 LLVM 执行引擎的工作方式。多面体和 LLVM 一起导致了操作的泛化,使其可以带有区域,就像我们现在做的那样。
LLVM 方言是第一个在 MLIR 中引入自定义类型的方言。所以你现在可以引入自定义类型。如果你使用 MLIR,你会认出这些东西。这些东西仍然存在。我们在 2018 年底、2019 年编写的很多代码仍然在那里。它可能是 MLIR 中经过最充分测试的代码之一。
我们处于这样一种情况:所有的 MLIR 基础设施都依赖于几个初级工程师编写的代码。作为一个附带项目,我告诉你一个秘密:我从未在官方的 MLIR 团队中。我只是谷歌 Brain 的一个随机研究员,觉得编译器很酷就加入了。所以我的故事是从贡献开始的。现在,我算是这个东西的非官方维护者。
LLVM 方言的设计目标
为了给你更多关于 LLVM 方言的细节,它的设计目标是在 MLIR 抽象中尽可能紧密地镜像 LLVM IR。
因此,从 LLVM 方言到 LLVM IR 的翻译应该尽可能直接。
一些例子:如果你在 LLVM 中有一个操作,在 MLIR 中会有相同的操作,只是前缀是 llvm. 以标识方言,并且语法略有不同。但基本上,如果你知道一个,你几乎可以轻松地阅读另一个。闭包也是如此。
然后有趣的事情开始了。在某个时刻,MLIR 团队认为,我们有一些人已经在 LLVM 上工作了很多年,我们觉得 LLVM 中有一些设计错误。也许不是,但这是 MLIR 中的子设计。那么我们如何修复它呢?其中之一是 phi 节点。
Phi 节点是在 SSA 形式中沿着控制流边表示数据流的典型方式。但有更好的方式。有一种叫做函数式的方式,你可以让基本块带有参数。这就是 MLIR 所具有的。
但当你拥有这些时,你实际上可以用不同的值分支到同一个块,这在带有 phi 节点的典型 SSA 形式中是无法做到的。所以翻译变得有点丑陋。现在,一个条件分支需要创建一个新块。
另一个例子是,在 MLIR 中,我们非常关心编译器的多线程。因此,像进入全局上下文或全局寄存器这样的事情被认为是糟糕的。例如,我们将常量作为 IR 中的操作,而不是特殊类别的值。我们通常希望泛化。所以我们没有任何东西有特殊类别的值,只有值。所以基本块不是值。
我们还将元数据泛化为我们称之为属性的东西。这是每个操作上结构化的、定义良好的、经过验证的元数据。例如,一个 noinline 元数据可以放在函数上,我们有验证规则说这个元数据存在或不存在。但你不能说 noinline=42,这没有意义。
现在我们将其泛化为将函数作为操作,而不是一等实体。但我们需要做的是将 LLVM IR 镜像到 MLIR 中,并尽可能接近。然后翻译就简单了。我们不想再写一个庞大的 LLVM IR 发射器。
翻译过程
翻译过程非常简单。用伪 Python 表示,但基本上就是英文描述。
对于每个函数,我们首先翻译签名。然后对于每个全局变量,我们做同样的事情。然后我们对某些类型的元数据做同样的事情。我们理解属性。然后对于每个函数,对于该函数中的每个块,我们将创建一个新块,转换除终结指令外的所有操作,然后使用 phi 节点连接终结指令。
这非常直接。如果你理解这两个项目,你就会知道怎么写。最初贡献时可能只有 200 行代码。
这很好,所以我们可以接受几乎任何形式的 MLIR,将其翻译成大量的 LLVM IR,然后将其提供给 clang 或 llc,或者任何 LLVM 能用的东西,然后运行它。我们可以运行、执行,然后从此幸福地生活下去。这是一个关于龙的故事。
支持 GPU 和其他硬件
直到我们意识到这些东西存在,它们叫做 GPU,人们非常喜欢它们,尤其是在 AI 世界。他们想在 GPU 上运行东西。不仅是那个特定的 GPU,还有这个、那个。还有一些叫做加速器和 TPU 的东西。我们必须以某种方式支持它们。
LLVM 处理它的方式是 LLVM 有平台特定的内部函数。我们认为将指令与内部函数分开是一个错误。所以我们决定推进方言的想法。现在我们有一个专门用于 NVIDIA GPU 的方言,另一个专门用于 AMD GPU 的方言。所有这些都借鉴了内部函数,而内部函数本身又借鉴了相应硬件的 ISA。
我们有很多这样的方言。我没有做统计,只计算了最大方言中的操作。AMD 和 NVIDIA GPU 方言本身就贡献了 300 个我们需要处理的操作。CPU 稍微合理一些。在我看的两个方言中大约有 50 个。如果你看所有的,可能超过 1000 个操作,我们需要考虑和翻译。
我们看着这个,觉得我真的不想写一个 10,000 或 20,000 行代码的文件,里面有一个巨大的 switch 语句,说如果我看到这个内部函数,就发出那个内部函数。
到那时,我们有了 MLIR 的这个光荣特性,叫做接口。如果你不知道接口是什么,但知道面向对象编程,那么你就知道 MLIR 中的接口是什么。它们在面向对象编程中正是其名称所暗示的。
这意味着你可以在 MLIR 中说,嘿操作,你能翻译成 LLVM 吗?如果能,请翻译你自己。这就是它的工作方式。
翻译接口
这个接口,我将向你展示一些代码。相信我,之后我会回到可爱的龙。
它确实如我所说。这是一种方式,让一个方言(在这种情况下)告诉如何将 MLIR 中的 LLVM 方言或任何其他方言的操作转换为 LLVM IR。它接受一个操作。它接受一个 LLVM IR 构建器(如果你曾经使用 LLVM IR 编写代码,你可能知道那个对象),并且它有一个模块翻译,这基本上是一种在不同接口实现之间存储信息的方式。
它还有另一个叫做 amendOperation 的东西,这源于我们意识到 GPU 使用了很多奇怪的元数据。它可能属于一个不同的方言。我们有一个带有 NVVM(NVIDIA 东西)注解的 LLVM 操作,说,哦,这是一个内核,或者我知道这个操作是产生你线程索引的那个,所以它永远不会超过 1024 之类的东西。所以有时我们需要在翻译后根据不同的接口修改一个操作。
差不多就是这样。如果你想在 LLVM 翻译中支持一个新方言,你基本上需要在你的方言上实现那个接口。就是这样。有两个函数,实际上是三个,但这就是你需要做的所有工作。
它接受一个操作。它接受一组指令来产生,然后你可以修改它们。有一组映射我们可以使用。
最后一个是 convertParameterAttribute,这就像是函数参数上的元数据。它有点特殊。我们会研究它,而且拥有它并没有太大意义,但我们确实有。
支持 OpenMP
现在我们可以接受 MLIR,将其转换为大量的 LLVM IR,输入到 clang 并在 GPU 上运行。我们看着这个,决定,是的,我们可以从此幸福地生活下去。
直到我们意识到 OpenMP 也是一样的,而且我们在 LLVM 上游的第一个主要采用者是 Fortran 编译器 flang。显然,在 Fortran 世界里,他们非常非常关心 OpenMP。
此外,我们团队中一些从事多面体编译的人也很喜欢 OpenMP 和自动并行化。所以我们研究了它。是的,MLIR 喜欢方言。一切都是方言。我们可以有一个 OpenMP 方言。
但当我们想将其翻译成 LLVM 时,就变得丑陋了。
如果你没有见过 LLVM 内部的 OpenMP,那基本上就是位置信息,我们可以忽略它。但在你的代码中不要真的忽略它,它很有用。为了演示,你不必在意。OpenMP 所做的是,它有一个运行时库,然后进行大量对该运行时库的调用。所以你的代码从左边来,实际上被 outline 成一个被运行时函数调用的函数。
这是一个不错的设计。但当你编写编译器时,处理起来有点丑陋,因为对于 MLIR,你必须考虑区域 outline、全局变量,你必须知道 MLIR 运行时库如何工作。
幸运的是,有一个叫做 OpenMP IR 构建器的东西。它现在叫做 OpenMP 前端,有大约 22K 行代码。我们真的不想重写它。所以我们决定,我们何不重用那个呢?
因此,我们修改了模块翻译对象,使其返回一个 OpenMP IR 构建器的实例,然后调用它。我们还必须暴露在 LLVM 有函数、函数中有块之前转换单个块的可能性。现在,突然之间,在我们的模型中,我们需要为 OpenMP 块和普通方言块进行不同的转换。
我们还需要在那之后重新连接 phi 节点,因为我们看到了差异。
因为 OpenMP 是这种嵌套的东西。MLIR 也是如此,它用这种嵌套方式建模。所以我们可以有嵌套循环。嗯,这很正常。我们也可以有嵌套的并行结构。如果你用任何语言编写 OpenMP 代码,你可以在其他并行结构内部有 #pragma omp parallel。我们可以有多个工作共享,我们只是用这种无限嵌套的区域结构在 MLIR 中镜像它们。
所以我们不得不向翻译添加一个堆栈对象。所以我们有一个虚拟堆栈,翻译代码可以使用。所以我们有堆栈帧。如果你见过 LLVM 代码,它大致遵循 LLVM 用这种 C++ 模式所做的。所以有一个堆栈帧,你可以派生。例如,我们有一个 OpenMP 循环信息堆栈帧,我们可以在其中放入 OpenMP 构建器构建循环所需的任何信息。
这只是代码。它有相当好的文档。
如果我们这样做了,我们就可以使用我们构建的基础设施运行这个翻译,将 OpenMP 转换为 MLIR。
模块翻译对象
在模块翻译对象中,我们有一种方法来保存翻译状态,将其推入堆栈,然后我们可以遍历该堆栈,专门寻找特定类型的操作。所以如果你记得幻灯片,我们可以混合多种区域,每种区域都可以有自己的堆栈帧类型。然后我们可能只想遍历循环堆栈帧,或者因为 MLIR 允许你混合方言,你可以有 OpenMP 区域与普通循环、lambda 函数或其他东西混合。你可能不想处理你不属于的方言的堆栈帧。
有了这个,我们可以接受 MLIR。我们可以将其翻译成大量的 LLVM IR。我们可以通过带有 libomp 的 clang 运行它,并在多个 CPU 上运行。
我们很高兴,并决定我们可以从此幸福地生活下去。
支持 OpenACC
是的,你明白我要说什么了。有一个叫做 OpenACC 的东西,我甚至找不到一个徽标可以用,所以我只放了一些文字。我们研究了它。
他们实际上不需要做任何事情。它已经由我们已有的功能支持了。我们很高兴。
技术部分总结
总结一下关于翻译的技术部分,如果你想从任何 MLIR 方言转到 LLVM IR,你需要做的是实现这个叫做 LLVMTranslationDialectInterface 的小接口。它非常一致。你翻译到 LLVM。它是一个方言。你有一个接口。
任何实现都可以使用一个叫做 ModuleTranslation 的对象。该对象包含中间映射。所以如果你从 MLIR 值转到 LLVM 值,你可以查询,反之亦然。如果你从 MLIR 函数转到 LLVM 函数,你可以查询。所有东西都存储在那里。它还给你一些可能性来翻译单个块、类型、属性等等,你可以递归地触发翻译,因为 MLIR 本身具有递归结构。
有一件事你不能做:你不能有自定义类型。嗯,这是一种设计选择,因为 LLVM IR 本身有一个固定的类型系统。MLIR 没有,但为此也构建一个类型转换基础设施没有意义。
这差不多就是技术深入探讨。
反思与挑战
现在,我们可以退一步反思一下。我们有这段代码。它真的很简单。它足够通用吗?在 MLIR 中,一切都是操作,我们可以转换操作,所以它某种程度上是通用的。
另一个问题是,为什么这些东西存在?为什么我们分别翻译全局变量和函数?在 LLVM IR 中,这有意义。全局变量是它自己的东西。函数是它自己的东西。在 MLIR 中,它们都是操作。为什么我的翻译操作不能处理那个?
同样,在接口中,我谈到了这个 convertParameterAttribute,为什么我们不能只在函数操作上使用 amendOperation?这是一个好问题。
所以我开始深入研究,发现了一个引入这个 convertFunctions 逻辑和整个流程的旧提交。它不大。幻灯片上的日期实际上是错的。那是我们内部所有提交被推送到 TensorFlow 仓库的日期,不知怎么分成了 3 月 29 日和 3 月 30 日,但所有这些都发生在更早的时候,大概是 2018 年 12 月或 11 月。它说“将 CG 函数部分降低到 LLVM IR”,只是为了让你了解这一切。观众中有人知道 MLIR 中的 CG 函数是什么,或者见过 CG 函数和 M 函数之间的区别吗?
没有。这意味着即使是我写的理由,而且我把它剪短了,那个理由实际上是提交描述中写的三分之一,除非你有 MLIR 如何演变的历史背景,否则没有意义。
所以这个添加了 convertFunctions,这在当时是有意义的。
然后五年后的这个提交,由 NVIDIA 的一个人添加,因为他们需要支持一个叫做 grid_constant 的东西,他们决定添加这个 convertParameterAttribute,因为函数有参数,你可以在它们上面放属性。这是一种元数据,当我们转到 NVIDIA 时,我们真的想转换。
是的,那个人实际上检查了,那是他们第一个也是唯一一个提交到 LLVM。他们不可能知道我所知道的关于 CG 函数以及为什么存在 convertFunctions 逻辑的事情。
你知道谁应该知道吗?可能是审查和批准那个补丁的高级人员。如你所见,是我,我批准了它,因为我在那一刻忘记了。
我在准备这个主题演讲时思考了这个演示,我想,好吧,我要向你们证明,LLVM 方言以及 MLIR 和 LLVM 之间翻译的很多代码是旧的,我们应该重新审视它。所以我做了一些统计。代码实际上并不旧。大部分代码在去年或两年前被修改过,而且我们正打算修改更多。
这是一种天真的看法。我尝试了几种不那么天真的方法。我查看了提交,试图过滤掉 NFC(无功能变化)的提交,试图过滤掉文档和类型修复,所有这些仍然显示翻译中的大部分代码相对较新。
不新的是设计。
猴子梯子实验的启示
所以我想在这里说的是,MLIR 中的许多设计决策,尤其是项目早期添加的部分,可能在我们做出决策时有一定意义,但可能不再有意义。
然而,我们继续遵守这些决策。我们继续使用 convertFunction。我们有一个叫做 translateModuleToLLVMIR 的函数。你知道为什么吗?因为模块是一个单独的东西。我们想翻译一个模块。我们也有反向的,叫做 translateLLVMIRToModule,为什么这样叫?只是为了对称。
这不是一个观察。这不是关于 MLIR 或 LLVM 或任何编程社区特有的。
互联网上有一个故事叫做“猴子梯子实验”。这个故事假装是基于科学的。我实际上没有找到任何证据表明确实进行过这样的实验。根据描述,它看起来不像一个科学实验。我甚至更进一步,找到了一个朋友的朋友的朋友,他是一名实验心理学家。在他们的训练中,他们实际上和猴子一起工作。她告诉我,没有猴子梯子实验这种东西。但我认为这是一个有用的隐喻。
如果你不知道,这个实验据说是这样的:五只猴子被放进一个房间,它们被喂养和照顾,每个人都喜欢它们。房间里有一个梯子。梯子顶上有一串香蕉。在某个时刻,其中一只猴子会试图爬梯子,抓住香蕉。如果它这样做,就会触发冷水淋浴。所以每个人都被泼了冷水。
在某个时刻,猴子们意识到,我们在这里很好。我们有食物。谁在乎那些香蕉。我们只要不爬梯子,以避免被泼冷水。
然后其中一只猴子被一只新猴子取代。剩下的猴子会阻止新猴子爬梯子,因为他们不想被泼冷水。这个过程一直持续到所有的猴子都被取代。所以在某个时刻,没有一只猴子经历过冷水,但它们都以某种方式知道不要爬梯子去拿香蕉。
这表明了人们可能称之为传统或制度知识的东西。实际上有一些关于类似事物的论文,叫做“cargo cult”。MLIR 早期设计中有很多这样的东西。
对社区的呼吁
所以我认为我们不应该像那些猴子一样。我们不应该只是遵循既定的东西,因为它们是既定的,而不试图理解。
因此,我希望每个人,在 MLIR 和更广泛的 LLVM 中,通常在整个软件生态系统中,多花 5% 的时间来尝试理解某些设计决策背后的理由。也许甚至没有做出有意识的决定,而是在当时更容易做的事情。
这真的不需要很多时间。做一个 git blame。做一个 git log。我做这些幻灯片可能只花了五分钟。我花了更多时间浏览代码和进行代码审查。你可以查看论坛。你可以查看邮件列表。你可以四处问问。你可能会找到一个在决策做出时在场的人,他可以给你一些历史背景。
有一件事很重要,我见过太多次了,但要假设良好的意图。如果你不知道,假设人们实际上知道他们在做什么,不要过来说,哦,你知道,这都是错的。我们应该修复一切。
这是一个简单的建议。如果你想要一个更难的建议,你知道,我们可能应该更好地记录设计决策。MLIR 代表什么,LLVM 代表什么,几乎一切。
我们有一个很棒的社区,很棒的工具。我们发布 RFC。我喜欢 RFC 讨论。我知道有些人讨厌它们,尤其是当它们变得很长的时候。有时我们会得到几百条评论,你需要通读才能理解发生了什么。
我们通常得不到的是某种总结。所以要理解为什么采用了某个设计,你需要通读一页又一页的精彩技术论证。
但最终不清楚做出了什么决定以及为什么。那么我们何不写下来呢?这也会花你 10 分钟,就像补丁一样,说,好吧,讨论参考了,总结一下讨论,说,好吧,我们决定这样。
也许你还应该考虑将接受的 RFC 放入 Git,或者有某种单一的持久性。论坛很棒。但有时很难在论坛、旧的邮件列表、提交信息、文档之间跳转。
MLIR 曾经有这个原理文档,它最后一次真正更新是在 2020 年。我们应该修复它。
然后是一个更难的建议。有时我们需要挑战不再有意义的遗留设计决策。首先,我再说一遍,我们必须承认,这个决策在做出时很可能是有意义的。但然后我们可以想,也许它不再有意义了。提出一个更好的。
我也理解,而且我知道,我看不到我眼里的光。但我知道有些人,尤其是在工业界,对 LLVM 和 MLIR 倾向于快速变化的方式不太满意。
但这是进化的一部分。我们不应该将 LLVM 的稳定性方面与不变性混淆。我们希望能够改变。我们希望能够改进。我们希望能够学习并在我们的项目中使用这些学习成果。
为了缓解这一点,我们可以设计一个清晰的迁移策略。有几个很好的努力。我不记得引入不透明指针花了多少年。但最终,我们在 LLVM 中实现了不透明指针。这是一个好的设计决策。有很多讨论和一些工具来帮助将所有基础设施、测试等从旧表示迁移到新表示。我们可以考虑它。我们不应该因为我们认为不再有意义而停止修订设计决策。
从我做起:重新思考翻译的简单性
我站在这里要求你们每个人都做额外的工作可能很容易,所以我要从我自己开始。
这是我。是的,如果你不认识我,也许这会有帮助。
我做了很多论坛交流和聊天等等。我认为这是我在 MLIR 讨论中第二常见的短语:如果你想从 MLIR 翻译到 LLVM IR,你必须经过 LLVM IR 方言,并且我们必须保持翻译简单。
我在思考这个。我说了很多。有些人对我这么说不太满意。他们实际上……有道理。
所以翻译的简单性是 LLVM 方言原始设计目标的一个必然结果:反映 LLVM IR。翻译应该简单。
但现在我们不仅仅是在翻译 LLVM 方言。我们正在翻译几十个相关的方言,其中一些是建模内部函数,一些是建模完全不同的编程模型,比如 OpenMP 或 OpenACC。
我们有一个技术翻译系统,它使用递归块、堆栈、接口、动态分派和许多东西。它真的还简单吗?
所以也许我们应该重新表述,说方言到 LLVM IR 的翻译应该只为你使用的东西付费。是的,这意味着我们应该移除对 OpenMP 前端的依赖,或者使其成为可选的。
回到整体 MLIR 设计
从这个回到整体的 MLIR 设计,也许需要一个新的推论。如果我们打算用它做点什么,我们可能需要一个新的方言,仅仅因为它是一个方言而引入一个方言。
虽然这对推动 MLIR 向前发展很有好处。今天,我们还想增加这个东西的复杂性吗?也许不。
所以当思考这个时,我思考这个问题的一种方式是思考转换。我们打算用这个 IR 做些什么吗?然后我们可以做一个权衡。
我们从两年前我和 Jeff 的一个主题演讲中知道,MLIR 操作平均比 LLVM IR 的操作慢 3-4 倍,向 MLIR 添加更多方言会增加每个人的成本。有编译时成本,也有一些运行时成本,因为有时你需要遍历所有的操作、所有的类型。
然后还有认知成本。在 MLIR 基础设施中定位自己是很困难的。我以前在幻灯片上做过这个大的图表,你可能见过,关于 MLIR 中的方言如何关联。我停止了,因为我跟不上了。我们现在有大约 50 种方言。
所以添加更多方言是有成本的。然而,如果你有方言,你就有像普通指令一样的显式操作。你可以推理它们。你可以查询它们。你可以转换它们。这 arguably 是一个更好的 API,不仅仅是转换。
MLIR 中的转换和分析,大多数设计为通过接口工作。所以当你添加一个新方言时,你可以说,好吧,我有一个操作,它没有副作用,没有其他东西,它是可推测的。所以,你知道,你的公共子表达式消除只需写两行就能开箱即用。你不必改变 pass。不必改变分析。
当我们设计 MLIR 时,我们真正引以为豪的一点是 MLIR 上下文和整个生态系统支持编译器中的多线程。所以 MLIR 上下文对象是线程安全的。这意味着我们可以通过使操作并发来抵消那 3-4 倍慢的操作。
所以我们可以在那里用一些东西来换取简单性。
我们过去推动更多方言,作为 MLIR 发展的强制因素。已经六年了,我认为推动 MLIR 发展已经足够了。我们不应该仅仅因为我们认为这是一个好的贡献而添加方言。我们需要添加它们,因为我们可以为更广泛的生态系统、下游项目、其他项目如 clang 或 flang 添加一些有用的东西。
所以在添加一个新方言之前,尤其是可翻译到 LLVM IR 的方言,我们可能需要等待,看看添加方言是否值得。如果不值得,那完全没问题。我知道我说过相反的话很多次。但现在我认为,完全可以将你拥有的任何方言,如果那是你停止转换的地方,你可以接受它,并使用我们已经有的基础设施转到 LLVM IR。
我要从舞台上说一个有争议的事情,因为我可以。我们有这个 arith 方言和 math 方言以及其他几个。它们基本上是 LLVM IR 的副本,只是它们也支持算术张量。
也许根据这个标准,我们应该摆脱那些。也许我们应该只有一个 LLVM 方言并使用它,或者有一个 ISA 方言而没有 LLVM 方言。我不知道。有人,请开始一个 RFC。
结论
我可能有点偏离主题了。我将以此结束。

我从哪里开始,MLIR 是更广泛的 LLVM 项目的一部分,与许多其他好项目一起。
我认为在 MLIR 和 LLVM 中,我们应该更加意识到我们的设计决策。我们需要努力理解它们。我们需要付出更好的努力来记录它们。
实际上,我站在这里的部分原因是我意识到我没有记录我做出的许多决定。所以我试图通过来这里展示它们来修复它,并让后面的人记录我这样做。所以这个记录。
然后一些决策可以而且必须受到挑战。
我展示了方法,由其他人来做,但请保持友好。我们不希望讨论变成争吵。我们可以挑战决策,但只是假设做出决策的每个人都思考过它。通过这样做,我认为 LLVM 和 MLIR 的集成将比我今天可能展示的任何技术内容都要好。

谢谢。有什么问题吗?有麦克风,我想你可以到麦克风那里。
嗨,那么从一种 IR,从一种方言转换到另一种方言,是否会导致性能损失?如果这是真的,你的估计是什么?运行时性能损失还是编译时性能损失?在运行时,执行时间。
我不认为会。我认为恰恰相反。
有两件事。有方言之间的转换。一般来说,转换的想法,它们不是优化。它们只是从一个抽象到另一个抽象。但转到另一个抽象是启用某些转换的一种方式。例如,你从结构化循环转到控制流,然后你可以做 CFG 简化,这会产生一个无法归约为循环的 CFG。
所以通常,转换本身在运行时方面应该几乎不花费什么,就像一个旋钮。
然后你可以做进一步的优化。如果你不能,那么你可能不应该转换。
我有一个关于 RFC 的具体问题,你知道,回顾一个 RFC,它是一个有数百条评论的讨论线程。可能耗时且令人沮丧。
社区中是否有兴趣将 RFC 过程正式化,使其成为一种一等公民,就像代码一样,一个 RFC 经过审查并被提交?是的,所以我不是代表社区发言。在新成立的 LLVM 项目委员会中有一些讨论,比如我们已经开始考虑这个问题。我们不知道答案是什么。他们认为有一个过程来提供一些结构是很好的,但他们也不想在其中增加不必要的摩擦。我知道很多人抱怨 RFC 花费很长时间。所以需要找到一个平衡,但我想说,在某种程度上构建这些讨论并确保我们跟踪它们方面有普遍的兴趣。例如,对于 LLVM 项目,我们现在将接受的关于治理的 RFC 提交到一个 Git 仓库。我们最终可能对 LLVM 做同样的事情,但这需要作为一个 RFC 来讨论。
嘿,Alex,你谈到的关于 git 日志的数字,翻译方面变化的增加,它们只包括从 MLIR 到 LLVM 这边,还是也包括导入那边?是的,只包括导出。好的,导入那边更新。所以,谢谢,谢谢,Alex。
我认为这是一个好时机,因为一旦越来越多的代码库建立在假设之上,就更难改变,因为所需的更改量将是巨大的。即使你心中有更好的设计,也会很困难。我的意思是,人们的时间有限。
我真正有的第二部分问题是,我们可以与 LLVM 画一些平行线,它有更长的历史,在你看来,LLVM 的设计在五年、七年内改变了多少?可能 LLVM 设计得如此之好,以至于不需要改变。是的,当然,它是完美的。从第一天起就是完美的,只是我们不断添加操作并不断改变类型系统。
所以一些设计决策可以在以后修订,也应该在以后修订。有时新事物在初始设计之后出现。我没有看过 LLVM 在前五年的演变。我当时可能不在,我可能当时还在学校。如果这里有人知道答案。我的意思是,我能想到的一件事是可伸缩向量部分,那是很久以后才出现的。
而且,还有一些优化,比如,你知道,有人在后来使中间优化器变得更好。但那些真的像是附加组件。它们并没有真正从根本上改变内在设计、架构。是的,我通常认为 LLVM 和 MLIR 是这些深度堆栈系统。有一些东西是系统的核心,极难改变。比如,我们应该用别的东西替换 SSA 吗?也许在某个时刻,我们会有一个好的论点,但这样做将极其困难。
还有一些东西更容易改变,比如添加一个新类型,比如添加新的浮点类型是最近的添加,或者可伸缩向量。所以有一些东西是设计的基础。还有一些其他东西可能更容易改变,我们应该权衡更改的复杂性以及对生态系统中其他项目的成本与更改的好处。
好的,谢谢。
另一件让一些新玩家对 MLIR 望而却步的事情是方言的极端多样性和它们的波动性,它们一直在变化。新的被添加。嗯。没有标准方言集的概念。我认为这也被讨论过几次。据我了解,我们不想要那个。但你能评论一下拥有某种稳定标准的概念(甚至可能是版本化的)与 Jeff 所说的“拥抱混乱”之间的平衡吗?
是的,所以,只是为了历史背景,MLIR 曾经有一个标准方言。嗯,我删除了它,因为我不想坐在标准委员会上。
但真正的原因是,将某些东西添加到标准方言中是非常高风险的讨论,因为它是标准。
所以我认为我们应该做的是更好地记录方言的状态。所以当你开始某个东西时,你可以记录,好吧,我刚开始。我需要一些时间来正确弄清楚。请来贡献。这是一个极端。另一个极端是像 LLVM 方言非常稳定,或者像 LLVM IR 一样稳定。所以请不要改变这个,除非你有一个非常非常好的理由,这意味着 LLVM IR 改变了。在这之间有一个谱系。
问题是我们只是不知道每种方言在这个谱系上的位置。这是一个文档问题。很容易修复。比如,让我们打开笔记本电脑现在就修复它。
我想这是最后一个问题。谢谢你的演讲。首先,很棒。所以,跟进一个较早的问题,就像你做了一个关于 MLIR 到 LLVM IR 翻译的演讲,深入探讨了这个。你认为 LLVM IR 应该从 MLIR 采用哪些部分,以使翻译更容易,或者仅仅因为它是 MLIR 中的好设计,我们应该采用?
我认为有两件事我觉得有趣。一个是对内部函数的某种 API 支持,比我们目前拥有的更好,比如有一个函数和一个函数调用,通过名称解析,并且神奇地知道这个特定的函数调用意味着什么。嗯,我认为我们之前讨论过,你可以用内部函数构建出大部分 MLIR。
另一部分是区域。我不完全知道如何在 LLVM IR 中建模区域。有一个主题演讲,我想我忘了是几届以前,关于收敛基础设施有一些想法。我们在 LLVM 中关于循环区域和循环 pass 的讨论已经很久了。所以某种区域的概念,就像一个多块的 IR 集合,是子函数。我认为这对于推理、隔离和转换可能有用。谢谢,这就是我所有的。谢谢。

总结

在本节课中,我们一起学习了 MLIR 与 LLVM IR 之间的翻译机制。我们从 MLIR 的起源和历史背景开始,了解了 LLVM 方言的设计目标及其作为中间层的重要性。我们深入探讨了翻译接口如何工作,以及如何通过实现 LLVMTranslationDialectInterface 来支持新的硬件方言,如 GPU 和 OpenMP。我们还讨论了社区应如何反思和挑战遗留的设计决策,并强调了文档和理解设计决策背后的理由的重要性。最后,我们呼吁社区在添加新方言时进行权衡,并共同努力改进 MLIR 与 LLVM 的集成。
023:函数多版本化——编译器辅助的运行时函数特化

概述
在本节课中,我们将要学习LLVM中的函数多版本化技术。这是一种允许编译器为同一个函数生成多个优化版本,并在运行时根据目标平台的硬件特性自动选择最佳版本执行的功能。这对于确保软件在不同架构的CPU上都能高效运行至关重要。
什么是函数多版本化?
函数多版本化是一项功能,它允许编译器生成同一函数的多个版本,并在它们之间自动进行调度分发。
为什么需要这个功能?因为软件需要部署在各种不同的设备上。我们有时希望为基线架构编译,然后将二进制文件用于其他目标平台。然而,在大多数CPU上,都存在可选的指令集扩展,这些扩展可能在我们编译的目标平台上不可用。例如,在ARM架构上,某些产品指令可能不可用。为了利用这些扩展指令,我们需要进行运行时检查。
函数多版本化并非新技术。它最早于2014年为x86架构引入,使用了target和target_clones属性。通过这些属性,可以指定一个字符串,该字符串对应于你希望函数为其优化的架构扩展。
在ARM64架构上,target属性主要用作优化提示。正如你在这个来自ARM C语言扩展头文件的例子中看到的,target属性用于保护一个内置函数。因此,我们引入了一个新的属性,称之为target_version。据我所知,这也已被Clang 5采纳。
关于函数多版本化的规范目前仍在草案状态,但正在不断改进。
实现机制
首先,我们为一个函数生成多个版本。为了能够区分它们,我们希望每个版本所使用的特性能够编码在其版本的符号名中。
如果你看下面的例子,我们有一个使用了四个架构扩展的版本:CRC、BTI、AES和BF16。在绿色框中,你可以看到这个函数版本的符号名是什么样子。
每个版本都与元数据相关联。我们需要元数据的原因是将信息从源代码传播到LLVM IR。
可以想象,当你有一个函数的多个版本时,需要确定在运行时执行哪一个。为了做到这一点,我们需要检测主机上可用的特性。然后,从该主机上可用的版本中,我们仍然需要选择更适合执行的最佳版本。为此,我们使用一个优先级方案,这是规范的一部分,ACLE中的每个架构扩展都与某个优先级相关联。
解析工作由一个名为resolver的函数执行,这个解析是动态的,意味着它在加载时执行,并且在进程的整个生命周期内是永久性的。
__aarch64_cpu_features是一个全局变量,用于存储主机上检测到的特性状态。
为什么需要运行时?
我们需要运行时来检测我们请求的哪些特性在主机上可用,以便我们可以相应地初始化全局状态。
这在不同平台上都得到了支持。在这个例子中,左边的代码展示了在Linux上是如何完成的:我们通过getauxval向内核查询哪些特性可用,然后初始化全局变量__aarch64_cpu_features。
使用示例
现在,我将通过一个例子来讲解如何使用函数多版本化来优化你的代码。
在屏幕的中间和左侧,我们可以看到源代码,我将带你过一遍。然后在右侧,我们将看到生成的LLVM IR是什么样子,我也会带你过一遍。
我们有一个程序,它接受两个字符串参数,将它们连接起来,并写入一个缓冲区。然后这个缓冲区被传递给一个名为skip_word的函数,该函数尝试识别空白字符,然后返回空白字符之后剩余的字符串。
我们有两个版本的skip_word。屏幕中间的是默认版本。屏幕左侧是使用内联汇编为SVE2优化的skip_word版本。
此外,在屏幕顶部,你可以看到一个名为copy_word的辅助函数,它也有两个版本。这两个版本共享相同的函数体,因此我们对它们使用了target_clones属性。一个是默认版本,另一个是为mops优化的版本,mops是内存操作的扩展。
让我们开始查看IR。
这是运行时由resolver函数解析的符号的代码。你可以看到,在行末,有一个用于copy_word,一个用于skip_word,因为两者都是多版本函数。
这是copy_word的mops版本的符号名及其关联的元数据。如你所见,我们有两种类型的元数据,我稍后会详细讨论。一种是fmv_features元数据,专用于多版本化;另一种是我们传统的target_features。
这是copy_word的默认版本及其元数据。这是skip_word的SVE2版本和默认版本。
现在到了关键部分,即解析器函数。
这是skip_word的解析器。它做了几件事。首先,它初始化运行时,这是你在第一行看到的调用,我们必须在那里声明。这个初始化基本上设置了全局状态,即全局变量__aarch64_cpu_features。
在第二行,我们读取那个全局变量,并在下一行将其与一个位掩码进行比较。这个位掩码对应于skip_word的SVE2版本。根据这个比较,我们将在skip_word的SVE2版本和默认版本之间进行选择。
设计选择
现在,让我们谈谈在实现规范时不得不做出的一些设计选择。
第一个与解析器的生成有关。我们希望支持跨多个翻译单元的函数多版本化。挑战在于,解析器无法看到其当前翻译单元之外存在的版本。如果你在每次调用函数时都生成一个解析器,那么在不同的翻译单元中可能会有多个解析器,这些解析器可能彼此不同。为什么它们会不同?因为可能在不同的翻译单元中声明了不同的版本。因此,版本的选择将不是确定性的,并且将取决于链接顺序。我认为链接器遇到的第一个对象将被选中。如果你有两个解析器,第一个将被选中。为了解决这个问题,我们决定只在默认版本所在的翻译单元中生成唯一的解析器。
另一个设计选择与特性检测有关。具体来说,我们希望根据传递性来检测特性。例如,如果你有一个使用SVE2的版本,你也希望检测SVE、FP16和FP的存在。另一种情况是,我们希望不从运行时检测中排除命令行中隐含的特性。因为在我展示的BTI例子中,你可能在命令行上指定了一个扩展。但如果主机上不可用,主机在执行指令时不会陷入陷阱,而是会作为空操作执行。如果在运行时没有检测到它,你就不知道它是否存在。有些用例中,人们希望能够通过多版本化来检测特性。
编译器内部表示
现在,我将大致谈谈我们如何在编译器中为FMV表示信息。
首先,我们需要一个扩展的名称,基本上是属性中写的东西。然后我们需要一个特性位,用于运行时检测,这是API的一部分。
另一件我们需要的事情是能够表达FMV的优先级,我们通过另一个条目来实现。检测不能与优先级相同,我想强调这一点。因为,正如我之前所说,检测是API的一部分。你可以想象,在这些系统中,如果你删除了特性,你会改变值,这将破坏API。这就是为什么这些条目必须不同:特性位与优先级位。
最后,我们希望存储与多版本化特性相对应的架构扩展信息及其关联的依赖项。这些依赖项用于两个原因:一是用于运行时检测,二是为代码生成启用必要的子特性。
所有这些信息只是为了说明,最初这些信息分散在编译器的各个地方,你必须保持同步。通过我列出的那个提交,我们使用TableGen自动生成这些信息,这使得维护更容易。
元数据
现在,让我们谈谈元数据。为什么我们需要它们?它们是什么样子?为什么我们不使用非常相似的target_features?
首先,它们是什么样子?它们看起来像代码中第三行的红色矩形。它被称为fmv_features,然后是一个由逗号分隔、按字典序排序且唯一的架构扩展列表。它基本上与target_features或多或少相同。
但我将给你一个例子,说明为什么我们需要它们,以及为什么我们没有使用target_features。
想象一下,我们有两个版本:第一个使用i8mm和dotprod,第二个使用fma。这些是ARM64的扩展,如果你不知道的话。根据规范,i8mm的优先级高于fma,dotprod最后。
现在,我们将使用-march=armv8.2-a+i8mm编译我们的程序。这个i8mm最终会传播到target_features,但对两个版本都是如此。在这一点上,我们已经失去了关于这个i8mm来自哪里的信息:它是来自命令行还是来自target_version属性?
因此,如果我们使用target_features来推导版本的优先级,我们会认为第二个版本的优先级高于第一个,这是错误的。
优化:静态解析
在接下来的几张幻灯片中,我将讨论LLVM中的一个优化,具体是在全局优化器中。这是一个允许我们静态解析对多版本函数的调用的优化,意味着我们不再需要执行运行时调度和动态解析。相反,我们可以在编译时决定应该执行哪个版本。
当然,这只能在特定条件下发生,并非对所有调用都适用。我们通过比较调用者和被调用者之间的LLVM IR元数据来实现这一点。
我们支持两种特定场景。第一种是当调用者和被调用者都是函数多版本化函数时,在这种情况下,我们比较调用者和被调用者之间的fmv_features。第二种场景是当调用者是非FMV函数而被调用者是FMV函数时,在这种情况下,我们将target_features与fmv_features进行比较。
首先,如你在左侧所见,我们需要做的是选择我们将使用哪种元数据。在右侧,一旦我们选择了元数据,我们就使用这些元数据构建一个位掩码。在接下来的几张幻灯片中,我将向你展示我们如何在算法中使用这些位掩码。
这个优化的主要好处在于链接时。由于全局优化器在模块级别运行,我们首先遍历模块中的ifunc符号。ifunc符号是,如果你还记得我在第二张幻灯片中展示的IR代码,它是为运行时调度生成的符号。
我们遍历模块中的所有ifunc函数。我们查看其ifunc的关联解析器函数,并检查其基本块。我们从返回指令开始检查其基本块,并向后遍历使用链。在我们遍历的过程中,我们可以识别select指令和phi节点。在任何其他情况下,我们直接退出。
一旦我们发现了被调用者版本,我们就使用上一张幻灯片中描述的掩码,按优先级递减的顺序对候选被调用者进行排序。
接下来,我们做的是发现调用者版本。同样,我们按优先级递增的顺序对它们进行排序。现在,我们最终得到了两组函数:按优先级递增排序的调用者和被调用者。
算法示例
这里,我将通过一个例子来讲解算法。
这个例子取自LLVM回归测试套件。在屏幕底部,你可以看到我刚才提到的两个函数列表。调用者在左边,被调用者在右边。
每个版本旁边的大括号对应于位掩码。在注释中,我添加了说明,在右边你可以看到这些位掩码对应于特性:mops、SVE2、SVE、FP16和FP。这是经过依赖传播简化后的掩码。
另一件需要注意的事情是,在屏幕顶部,你可以看到红色的矩形,里面有Lambda函数W,它描述了两个位掩码之间的关系。当我说一个位掩码蕴含另一个位掩码时,意味着第二个位掩码的所有位都存在于第一个位掩码中。
你还可以看到我在这里添加的两个箭头:红色箭头是调用者的迭代器,绿色箭头是被调用者的迭代器。
我们可以通过比较mops+SVE2与mops来开始算法。由于mops+SVE2蕴含mops,我们可以静态解析这个调用。我们推进调用者迭代器。
现在,我们比较mops与mops。mops蕴含mops,所以我们也可以静态解析这个调用。由于位掩码相等,我们推进调用者迭代器。在这一点上,我们推进被调用者迭代器,因为我们知道,既然我们正在执行SVE调用者,我们知道主机上不存在优先级更高的mops调用者,所以我们不再需要与它进行比较。
现在,我们比较SVE与SVE2。SVE不蕴含SVE2,所以我们不能静态解析这个调用。然而,因为SVE2蕴含SVE,我们推进被调用者迭代器。
我们继续遍历被调用者候选者,因为正如我在上一步所说,SVE调用者不能被静态解析。由于SVE蕴含SVE,我们再次推进调用者迭代器。
现在,我们比较两个默认版本。由于这些特性在主机上都不可用,我们可以静态解析这个调用。
我希望这不太复杂。
未来工作
在这张幻灯片上,你可以看到一些未来的工作。
其中一件事是让用户能够控制特性优先级,我们在LLVM上有一个相关的拉取请求。另一件事是让用户能够引用特定的函数版本,我们还没有决定在源代码级别上它会是什么样子。
还有一个开放的问题是关于支持带有ifunc解析器的指针。
在撰写本演示文稿时,有一个请求是支持更多特性,比如CSSC,这是用于人口计数指令的一些扩展。这实际上已经在post-LLVM 20版本中合并了。

我们希望你们使用这个功能,因为它仍处于草案状态,我们需要你们的反馈。我们想看看缺少什么,我们希望看到什么。
最后,我要感谢所有为此工作并帮助我进行良好评审的同事们。

总结
在本节课中,我们一起学习了LLVM中的函数多版本化技术。我们了解了它的基本概念、实现机制、设计选择、编译器内部表示、元数据的作用以及一个重要的静态解析优化。这项技术使得为不同硬件特性生成优化代码并在运行时智能选择成为可能,是提升跨平台软件性能的有力工具。
024:解决编译器难题


在本教程中,我们将学习在 MLIR 框架中调试 AI 模型编译问题的实用工具和方法论。我们将重点介绍如何应对非源语言输入(如 ONNX 等交换格式)的大型模型编译挑战,并探索一系列从基础到进阶的调试技巧。
概述:MLIR 调试的挑战与工具
调试 ML 编译器时,我们常面临几个核心挑战:输入文件巨大(可能包含数 GB 的常量数据)、需要理解多层抽象、以及可能遇到诸如过程序错误、形状推断失败或模式匹配失败等问题。为了高效定位问题根源,我们需要一套系统化的调试方法。
接下来,我们将逐一探讨五个主要的调试主题,从最基本的 IR 查看开始。
1️⃣ 打印机制:查看 IR 状态
调试的第一步通常是查看中间表示(IR)的当前状态。MLIR 的 mlir-opt 工具提供了一系列打印参数,可以帮助我们观察编译过程中 IR 的变化。
以下是几个常用的打印参数:
-print-ir-after-all:在每个过程后打印 IR。-print-ir-before-all:在每个过程前打印 IR。-print-ir-after-failure:在过程失败后打印 IR。
这些参数会输出类似 // -----// IR Dump After SomePass (some-pipeline) // ----- // 的头部信息,清晰地标记 IR 所处的阶段。
局限性:这些方法较为基础。如果过程是参数化的,仅看 IR 可能难以理解其具体行为。此外,对于段错误或断言失败等硬性故障,-print-ir-after-failure 可能无法生效。最重要的是,当处理像 Llama 3.2 这样的大型模型时,向终端输出数十 GB 的文本是不现实的。
2️⃣ 处理大型文件:省略常量与结构化输出
上一节我们提到了打印大型 IR 的困难。MLIR 提供了 -mlir-elide-elementsattrs 和 -mlir-elide-resource-strings 选项,可以省略元素属性和资源字符串的打印,这对于阅读包含大量常量的 IR 非常有帮助。
除了省略内容,我们还可以将输出结构化保存到文件,便于在不同编译状态间跳转分析。
以下是两个有用的参数:
-mlir-print-ir-after-all-to=/path/to/dir:将每个过程后的 IR 转储到一个目录结构中,该结构模仿了过程管道的层次。-mlir-print-ir-module-scope:当过程在函数上操作,但调试需要查看模块级别的属性时,此参数会打印整个模块范围的 IR。-mlir-print-ir-local-scope:如果 IR 中包含嵌套的、具有IsolatedFromAbove特性的操作,此参数只打印到第一个此类操作为止,避免输出过多内容。
3️⃣ 深入过程内部:调试模式与模式匹配
当我们通过打印将问题范围缩小到某个特定过程后,需要深入该过程内部,了解其模式匹配的细节。这时可以使用 -debug 和 -debug-only 参数。
重要提示:这些参数仅在 Debug 模式 构建的 MLIR 中可用。它们会打印每个模式匹配成功或失败的信息。如果一个过程包含大量模式,输出会非常冗长。幸运的是,一些模式会通过 notifyRewriteFailure 提供友好的失败信息。
对于开发者而言,在创建新过程时,使用 LLVM_DEBUG 宏添加调试语句是帮助未来自己和其他开发者的好习惯。当然,这种调试方式同样只适用于较小的 IR 片段。
4️⃣ 生成与使用重现器(Reproducer)
为了稳定地复现和报告问题,MLIR 提供了重现器功能。它能在编译器失败时,生成一个包含重放失败所需参数的自包含 IR 文件。
使用方式如下:
mlir-opt my_input.mlir -pass-pipeline=“...” -mlir-generate-reproducer=reproducer.mlir
生成的 reproducer.mlir 文件末尾会附加一个 _mlir_reproducer 外部资源,其中序列化了运行的管道字符串和其他标志(如是否禁用线程)。
注意事项:
- 确保你的过程中所有参数都是可序列化的。如果过程接受一个自定义结构体作为参数,但该结构体没有实现从字符串的序列化/反序列化,重放时会导致奇怪且令人困惑的失败。
- 深层嵌套的过程管理器有时可能导致异常行为。
- 要生成一个仅重放失败前最后一个过程的“本地重现器”,需要确保在 MLIR 上下文中禁用了线程(
-mlir-disable-threading)。
5️⃣ 交互式调试:LLDB 集成
对于更复杂的交互式调试,可以集成 LLDB。LLVM 和 MLIR 子项目提供了优秀的“漂亮打印”功能,可以将复杂的内部数据结构转换为更易读的格式。
例如,在调试器中,一个 OpOperand 的 producer 和 consumer 字段可能被漂亮地打印为具体的操作名称(如 linalg.generic),而非原始的内存地址。
配置示例(VS Code launch.json):
“miDebuggerArgs”: “-q -ex ‘script sys.path.insert(1, \“/path/to/llvm-project/llvm/utils/lldb\”)’ -ex ‘script sys.path.insert(1, \“/path/to/llvm-project/mlir/utils/lldb\”)’ -ex ‘command script import lldb_utils’ -ex ‘command script import mlir_lldb’”
对于尚不支持漂亮打印的复杂类型(如 Value),在 LLDB 终端中直接调用 op->dump()、attr.dump()、type.dump() 等方法通常是有效的。
MLIR 还支持操作调试,允许在更高粒度(如特定过程或模式)上设置断点。当调试器停在断点时,可以使用 MLIR 的游标功能来检查 IR,并跳转到父操作或子操作。这需要启用相应的调试钩子以确保断点被正确注册。
6️⃣ 自动化问题简化:MLIR-Reduce
最后,我们介绍用于自动化缩小问题范围的工具:MLIR-Reduce。给定一个触发错误的 IR 和一个用于检测错误是否仍然存在的测试脚本,MLIR-Reduce 会尝试不断简化 IR,直到得到一个最小的、仍能触发错误的复现用例。
公式:MLIR-Reduce(Input_IR, Test_Script) -> Minimal_Reproducer_IR
这在理论上非常适合自动化错误报告,甚至可以集成到 CI 中自动生成工单。MLIR 官方文档详细记录了如何使用它以及如何为自定义方言或操作实现简化模式。
实践经验:然而,在实际使用中,MLIR-Reduce 有时无法充分简化问题,且需要一定的进阶知识来配置。很多时候,手动将 IR 输出为文本格式并删除无关行,直到剩下几个能触发问题的操作为止,可能速度更快。
此外,还有一些下游项目提供了定制的简化工具,如 MLIR-EIE-Reduce 和 Circuit-Reduce,它们可能包含比原生 MLIR-Reduce 更多的功能。
总结与额外资源
本节课我们一起学习了在 MLIR 中调试编译器问题的多种方法。我们从基础的 IR 打印开始,探讨了处理大型文件、深入过程调试、生成重现器、使用 LLDB 进行交互式调试,以及利用 MLIR-Reduce 自动化简化问题。
总的来说,MLIR 已经提供了相当丰富的工具集来应对大多数调试场景,当然仍有改进空间以提升开发效率。除了这些工具,还有两个宝贵的资源:
- Lit 测试:对于理解某个过程的预期行为非常有帮助。相比阅读复杂的 C++ 代码,查看输入 IR 和预期输出 IR 的测试用例通常更直观。
- 阅读源代码:最终,深入阅读相关过程和工具的代码是理解其工作原理和调试方法的最根本途径。
希望本教程能帮助你更高效地解决 MLIR 编译器中遇到的难题。



问答环节摘要
问:关于重现器,你提到选项必须可序列化。有一种机制是在 TD 文件中定义过程并提供选项,这是否满足了要求?
答:使用 TableGen 定义的过程可以满足要求。问题通常出现在纯 C++ 实现且未使用 TableGen 的过程中。在一个大型编译器管道中,定位是哪个过程导致的问题可能比较困难,因为错误信息可能比较隐晦。
问:我希望能在过程之间对 IR 进行差异比较。SSA 值编号会变化,导致微小的改动引发后续大量变更,使得常规 diff 工具不太有用。是否有专门工具?
答:我目前不太清楚。但今天早些时候有一个关于 diffing LLVM IR 的精彩演讲,其技术或许可以应用到 MLIR 上。

问:LLVM 有一套丰富的 -print-changed 功能,可以显示过程间精简的差异,而不是打印全部 IR。MLIR 有计划引入类似功能吗?
答:我认为 MLIR 可能已经有了类似功能,只是我没有在幻灯片中列出。需要核实一下。




025:如何为易出错的量子计算机编写可扩展的编译器


在本教程中,我们将学习如何为易出错的量子计算机设计一个可扩展的编译器。我们将从量子计算的基础概念开始,逐步深入到编译器设计中的具体挑战和解决方案,包括中间表示、错误校正、硬件控制以及优化策略。
量子计算基础 🧠
首先,我们需要了解什么是量子计算机。量子计算机使用量子比特(qubit)作为基本计算单元,与经典比特不同,量子比特可以同时处于0和1的叠加态。
量子编程栈与硬件抽象 🔗
上一节我们介绍了量子计算的基本概念,本节中我们来看看量子编程的软件栈和硬件抽象。
目前存在多种量子编程语言和多种物理实现量子比特的技术(如超导、离子阱等)。这些不同的硬件平台需要统一的抽象层。
量子硬件通常由一个极低温的冰箱(用于维持量子态)和内部的物理量子比特阵列组成。我们需要一个控制系统来与这些物理量子比特进行交互。
以下是描述与物理量子比特交互的基本操作:
- 初始化:将一个物理量子比特(例如
Q1)初始化为|0>态。 - 单量子比特门操作:对单个量子比特施加一个操作(门),这会改变测量时得到0或1的概率。
- 双量子比特门操作:对两个量子比特施加相互作用。
- 测量:在计算结束时,测量一个量子比特,得到一个可用于经典计算的比特结果。
物理约束与挑战 ⚙️
了解了基本操作后,我们必须认识到物理世界带来的约束。
- 不可克隆定理:量子信息不能被完美复制。这意味着我们不能将同一个量子比特同时传递给一个需要两个输入的门,但可以通过交换操作来移动信息。
- 物理连通性:如果两个量子比特需要相互作用,它们必须在物理位置上相邻。这直接影响我们的寄存器分配策略。
- 内存与计算空间统一:在量子计算机中,存储量子信息的内存空间本身就是计算发生的空间,这与经典计算机中从内存获取指令的模式不同。
- 测量的破坏性:测量通常是破坏性的。如果在不进行量子交互的情况下对同一个量子比特测量两次,会得到相同的结果。
为了优化,我们使用类似静态单赋值(SSA)的形式,称为QSSA。当我们与一个量子比特交互后,该操作会返回一个新的SSA值,供后续操作使用。
硬件控制系统详述 🖥️
上一节我们讨论了物理约束,本节我们具体看看控制量子比特的硬件系统。
实际的硬件控制由现场可编程门阵列(FPGA)板卡完成。每块FPGA板卡连接着一些导线,这些导线控制着不同的设备(如激光器)。通过向特定导线发送特定时长的脉冲,可以控制激光照射量子比特,从而引发相互作用。
由于导线体积庞大,我们通常需要多个控制盒,每个控制盒管理大约4到6个量子比特。这意味着这些控制盒之间需要同步。如果一个操作涉及分属两个不同控制盒的量子比特,那么这两个控制盒的操作必须精确同步。
此外,我们还需要一个“转换”步骤。虽然像CNOT门这样的操作对经典计算机科学家来说很直观,但它可能不是硬件原生支持的。我们必须将其转换为硬件可以执行的基本操作序列。这些底层操作被称为“脉冲方言”,它们描述了向特定导线发送多长时间的脉冲来控制激光。
量子错误校正的必要性 🛡️
量子错误校正对于量子计算机至关重要,因为大约每1000次操作就会发生一次错误(如比特翻转)。任何一个错误都可能毁掉整个计算。特别是,量子比特可能会“退相干”,即从量子态退化为经典态,这通常每几微秒就可能发生。
错误校正的基本思想是将一个逻辑量子比特的数据编码到多个物理量子比特上。以下是错误校正的基本步骤:
- 数据编码:将一个逻辑量子比特的数据编码到多个物理数据量子比特上。
- 添加辅助量子比特:引入一些初始为空的辅助量子比特。
- 交互与测量:让数据量子比特与辅助量子比特进行交互,然后测量辅助量子比特(而不直接测量数据量子比特,以免破坏其状态)。
- 重复与比对:多次重复上述过程(通常需要应对两种类型的错误,故用两种颜色表示)。通过比较多轮测量的结果,如果发现结果在不应变化时发生了变化,就能检测到错误并进行纠正。
可扩展性挑战与系统架构 📈
错误校正改变了我们的计算栈。每次测量都需要将数据送出进行处理,因此需要一个独立的解码系统。解码芯片通常不能与量子比特放在一起,因为它们会产生热量,而量子比特需要极低温环境。解码系统还需要与控制系统协同工作,进行数据交换。
当我们谈论“可扩展性”时,通常认为需要数百万个量子比特及其上的操作才能实现所谓的“量子优势”。如果错误率约为十分之一,我们就必须对它们进行校正,并在大约10微秒内完成校正和重复操作。这意味着我们需要处理的数据量可能达到每秒太字节级别。
目前,栈中不同部分的代码生成和集成是手工完成的。我们的目标是将其整合,并利用硬件中存在的巨大并行性。本教程将主要关注代码生成部分。
编译器实现策略 🛠️
上一节我们概述了规模挑战,本节我们深入编译器为实现错误校正和可扩展性所采取的具体策略。
首先,我们需要显式地表示“空操作”。因为在错误校正中,我们需要知道何时系统处于空闲状态,以确保“恒等操作”不会意外引入错误。我们会在所有间隙处插入这些恒等操作。
接下来,我们采用物理学家设计的错误校正码,将所有操作和量子比特在其下进行编码。这可以通过实现接口来完成,并且我们可能需要将操作转换到一个新的门集合,因为在这些编码后的量子比特上可执行的操作可能与原始物理量子比特上的不同。
这引入了新的抽象,我们称之为“补丁”,它们看起来像小方块。基本操作包括将两个补丁合并,或者将其拆分(逆操作),以及其他实现实际门操作所需的各种操作。
然后,我们需要一种方法来集成这些操作,生成小型的电路和操作序列,以供解码器校准使用,其中一些校准需要硬件的实际信息。
为此,我们给操作添加“噪声属性”。当我们将一个逻辑量子比特分配给一个具有特定噪声特性的物理量子比特时,这些属性可以与现有软件结合,分析硬件与代码的匹配情况,从而在运行时配置解码器。
有时我们还需要添加“节拍”标记,以明确时间步进,确保后续操作在下一个周期发生。
协调与中间表示设计 🔄
前面提到的各个部分最初是分离的,现在我们需要将它们协调起来。
我们将它们整合到一个MLIR(多级中间表示)管道中。这样,我们可以从称为STM的包中获取配置,并将其发送给解码器。同时,我们在底层使用另一种方言来表示可以发送给控制系统、并与解码系统交互的操作。
我们添加了自己的发送和接收操作(因为目前使用物理电缆,不需要像MPI那样的复杂功能),并将指令收集到显式的并行和同步区域中。明确知道哪些操作块必须同时执行对我们至关重要。
优化策略:寄存器分配与映射 🗺️
最后,我们来讨论一些优化策略。如前所述,寄存器分配变得与物理内存中的相邻性相关。而当我们使用编码后的量子比特时,情况变得更加复杂。

原因之一是,现在执行操作有多种方式。例如,我可以让相邻的补丁交互,也可以通过测量中间区域来实现跨区域的交互,甚至可以通过之前提到的交换操作来移动补丁,从而实现更远距离的交互。
因此,我们必须思考如何将这些数学上构建的操作集,映射到实际的硬件布局上,并将它们转换为可以大规模运行的形式。
总结与展望 🌟
本节课中,我们一起学习了为易出错的量子计算机设计可扩展编译器的核心内容。
这是一个非常新的领域。目前的工作主要是整合不同部分:我们构建了MLIR方言并将它们管道化,现在正逐步应用于实际的控制系统和小规模的错误校正实验(例如谷歌约400个物理量子比特的实验)。
随着规模扩大,控制芯片和解码芯片都将变得更大、更并行。我们需要更好地理解这一点,并为量子程序提出更优的抽象,而不仅仅是“一次操作一个物理量子比特”。最好的编译器优化来自于了解硬件使用方式和算法结构。
从顶层到底层,所有这些都是经典硬件,都需要由常规编译器和编译器工程师来解决。物理学的挑战存在于最底层,我们需要找到更好的表示方法,以便将顶层的需求一直贯通到底层实现。

问答环节精选 ❓
问: 您提到谷歌有约400个量子比特,并假设它们排列成某种二维网格。随着量子比特数量进一步增加,是否会遇到类似“平方-立方”的问题?即量子比特数量按平方增长,而来自边缘的控制线数量增长较慢?您是否从其他领域(如布局布线)获得灵感?
答: 人们已经开始思考这些问题。目前大部分精力集中在让量子比特能够工作并保持超过纳秒的相干时间。有一些思考方向,例如将芯片堆叠起来,或者使用不同类型的量子比特(如可移动量子比特,它们具有全连接性但操作速度较慢)。目前的理解是,我们将需要大量并行芯片,可能由顶层的“超级计算机”来协调所有这些小方块(每个方块管理其下的4-6个量子比特)。芯片之间也需要侧向通信以实现同步。与机器学习社区的交流很有益,因为解码问题本质上涉及大量的矩阵乘法运算。我们肯定可以从其他计算领域(如并行计算)学习经验。
问: 关于寄存器分配,在连接性(将寄存器放在一起)与操作移动次数之间存在巨大权衡,特别是当连接图可以是任意结构时。目前是否存在无需组合爆炸就能有效探索这个设计空间的方法?
答: 据我所知,目前我们还没有大规模进行此类优化。一些最先进的技术使用非常密集的图算法将电路映射到硬件上,这非常耗时。可以使用启发式方法。近年来的研究表明,可以优先使用芯片上性能更好的量子比特(例如,边缘的量子比特错误率可能更高,而中间的一些则很好)。如果只在中间这些好的量子比特上运行较小的计算,可以得到更准确的结果。这仍然是一个新领域,关于如何为编码后的量子比特进行优化,是近几年才开始发展的想法。如果有人想尝试为此做优化,将会非常受欢迎。


026:使用约束定义和验证MLIR操作


概述
在本节中,我们将探讨一种在MLIR中定义操作的新方法。这种方法采用更具声明性的方式,可以看作是Ivan在其IDL演讲中介绍的约束系统的扩展。我们将在XDSL(一个类似Python的MLIR克隆)的上下文中介绍该系统的工作原理、当前进展以及未来的计划。
操作定义的作用
首先,让我们退一步思考:操作定义究竟有什么作用?在MLIR中定义了一个操作,但这意味着什么?不仅仅是给它一个名字。
操作通常附带相关的验证逻辑,用于检查其输入类型是否正确,以及输入和输出的数量是否匹配。它还可能期望始终拥有各种属性。所有这些都内置于操作定义中。
第二类作用可以称为“推断”。有时我们希望用不完整的数据来指定一个操作,并能够从给定的不完整数据中推断出其余的数据。这方面的两个主要例子是构建器和自定义格式。构建器可能只需要一个非常简洁的接口来构建操作,但仍需要填充所有必要信息。更常见的是自定义格式,它不必包含大量额外的不必要数据。
一个简单示例:加法操作
我们从一个最简单的例子开始:两个数字的加法操作。定义这个操作需要做什么?
首先关注验证:我们所有的输入(两个输入和一个输出)都需要是整数类型(例如si32)。但仅仅对每个部分施加约束是不够的,因为这些约束是相互关联的,需要彼此通信。我们不能独立地定义每个约束。
观察下面的通用格式,它满足每个单独的条件(两个输入是整数,输出是整数),但显然是错误的,因为我们实际上需要所有这些类型都相同。
我们还看到了推断问题:上面的自定义格式只指定了一个类型(结果类型)。操作定义需要知道应该将输入类型设置为与结果类型相同。
如何编码这些约束?
我们如何在XDSL中编码所有这些信息呢?以下是一种类似Python伪代码的方式:
# 伪代码示例
Var(‘T’) # 定义一个类型变量 T
Constraint(‘operand_lhs’, ‘T’) # 左操作数类型为 T
Constraint(‘operand_rhs’, ‘T’) # 右操作数类型为 T
Constraint(‘result’, ‘T’) # 结果类型为 T
关键点在于我们使用了Var约束。Var约束的含义是,无论在哪里遇到这个约束,它都应该是同一个东西。这里我们指定左操作数和右操作数需要满足同一个Var约束,结果也需要满足同一个Var约束。
然后我们可以指定汇编格式。如您所见,只给出了结果类型。因此,左操作数和右操作数的类型没有给出,系统可以从这个Var约束中推断出它们需要与结果类型相同。
扩展约束系统
这是一个简单的例子。那么,我们想在此基础上如何发展呢?基本上,所有这些变量约束都可以嵌套在系统已有的其他约束中。
虽然没有足够的时间探讨所有可用的验证功能,但这里有一个例子。假设我们有一个向量插入操作vector.insert。这个操作允许你将一个元素插入向量,或者将一个向量本身插入到多维向量中。
有趣的一行是这里的_V。我们声明了一个向量类型,但约束该向量类型的元素是另一个约束。这允许我们讨论所有这些关系。
未来方向:形状验证
我们未来希望发展的方向是讨论形状验证,因为目前的系统尚未处理任何形状验证。


总结


在本节中,我们一起学习了一种使用声明性约束来定义和验证MLIR操作的新方法。我们了解了操作定义在验证和类型推断中的作用,并通过加法操作的例子看到了如何使用Var约束来确保类型一致性。我们还简要探讨了更复杂的嵌套约束示例,并指出了未来将形状验证纳入该系统的发展方向。这种方法旨在使操作定义更加简洁、健壮,并减少样板代码。
027:基于SSA的IR交互式图可视化工具


在本教程中,我们将学习一个用于可视化多种SSA形式中间表示的程序工具。该工具并非专为LLVM或MIR设计,但它们是展示其可视化能力的良好示例。
工具目标 🎯
上一节我们介绍了工具的基本背景,本节中我们来看看它的核心目标。
该工具旨在获取程序的中间表示,并以多种方式将程序中的数据流动和运行状态展示给用户。其最终目标是生成图形化视图,帮助用户直观地理解程序内部发生的情况。
以下是该工具当前重点实现的两个主要可视化方面:
- 数据流路径建模:在左侧的图表中,黑色线条代表数据流。例如,可以看到第一个加法操作的输出,成为了第二个加法操作的输入。
- 控制流图集成:除了数据流,该工具还在同一图表中表示控制流图。图表中的绿色部分即代表控制流。
面临的挑战与解决方案 ⚙️
上一节我们了解了工具的目标,本节中我们来看看实现这些可视化时遇到的具体挑战及其解决方案。
LLVM和MIR等IR具有一些特性,使得绘制其图形变得复杂。主要挑战在于它们包含基本块和区域,这些结构对图形布局施加了许多约束。例如,需要将某些操作分组,并且数据流被允许从一个基本块流出并进入另一个基本块。
这导致图形中会出现被视为大节点的块,而块内部又包含许多进出的连接,容易显得混乱。
该工具旨在自动处理所有这些布局问题,用户无需进行任何手动调整。
另一个需要原生支持的特性是SSA值可能被多次使用或完全不被使用。在可视化程序时,用户不应被要求手动插入显式的节点来复制值,这一切都应该是自动发生的。
正是这两点——自动处理块/区域约束和隐式值复制——使得本工具区别于使用Graphviz等通用图形可视化程序来处理此类问题。
可视化流程 🔄
上一节我们探讨了工具解决的挑战,本节中我们来看看实现可视化的具体流程。
以下是该工具实现可视化的步骤:
- 从IR开始:工具目前支持将LLVM IR、MIR以及一个用于展示工具特性的玩具语言(SD语言)进行转换。
- 映射为层次图结构:所有IR都被映射到一个层次图结构。这个结构是可视化的基础,描述了可视化中将要发生的一切。
- 定义转换规则:如果用户想要可视化一种新的IR,或者想以不同方式显示某种IR,他们需要做的就是定义如何将这种IR转换到上述的层次图结构。
- 执行绘图流水线:一旦转换完成,工具会经过一个复杂的绘图流水线,自动决定所有节点的垂直和水平位置,处理值的复制,将节点分组到块中,并进行布局优化。
- 生成输出:最终,工具会生成一系列图形形状。这些形状可以直接绘制成SVG图像,也可以使用我们提供的交互式前端进行查看。
交互式前端功能 🖱️
上一节我们介绍了可视化的生成流程,本节中我们来看看交互式前端提供的具体功能,这些功能能帮助用户更好地探索图形。
交互式前端提供了以下便利功能来探索图形:
- 连线高亮:将鼠标悬停在任意连线上,会高亮显示整条边,包括它进入或离开块的所有路径。
- 块折叠:可以将块和区域折叠成一个单一点。当用户暂时不想关注某个区域时,这个功能非常有用。
- 节点搜索:可以使用查找功能搜索特定的节点。
总结 📝
本节课中我们一起学习了一个用于可视化基于SSA的中间表示的工具。我们了解了它的设计目标:同时展示数据流和控制流。我们探讨了它如何解决IR中基本块和隐式值复制带来的可视化挑战。我们还梳理了从IR转换到最终图形输出的完整流程,并介绍了交互式前端提供的高亮、折叠和搜索等实用功能。该工具旨在通过自动化的图形布局和交互式探索,降低理解复杂程序结构的难度。




028:为何为llvm-debuginfo-analyzer工具添加IR阅读器

在本节课中,我们将学习如何通过为LLVM调试信息分析器工具添加IR阅读器功能,来简化调试信息与LLVM中间表示之间的比较过程。我们将从基本概念入手,逐步了解现有工具的局限性以及新功能带来的改进。

基本概念:LLVM与调试信息
上一节我们介绍了课程目标,本节中我们来看看需要理解的一些基本概念。
LLVM及其调试信息处理系统具有以下特点:
- 它接受不同的输入源。
- 它支持不同的工具链。
- 它能处理不同的二进制文件格式。
- 它支持不同的调试信息格式。
- 它提供了用于打印调试信息的工具。
关于调试信息,开发者常遇到的一些问题包括:
- 调试信息是否准确代表了原始源代码?
- 哪些变量因优化而被丢弃?
- 为何无法在特定代码行设置断点?
此外,还存在语义差异问题:
- 即使在同一平台上使用不同的工具链,或在不同平台上编译相同的源代码,生成的调试信息也可能存在差异。
优化器IR输出的挑战
了解了调试信息的基本问题后,我们来看看优化器IR输出的特点。
LLVM IR的输出信息非常丰富,但同时也非常“嘈杂”。使用普通的比较工具很难清晰地看出IR在优化过程中的变化。此外,元数据标识符在不同编译阶段之间会发生变化。
通过“二分法”调试,我们可以定位是哪个编译阶段导致了变化。虽然LLVM提供了丰富的后端打印选项,但观察变化依然困难。
具体案例:SLP向量化后的调试信息丢失
为了具体说明问题,本次演示将分析一个在SLP向量化通道后出现调试信息丢失的案例输出。
左侧是测试用例的源代码。中间是经过simplifycfg(简化控制流图)通道后的IR元数据。右侧是经过SLP向量化通道后的IR元数据。
如果使用普通的比较工具来查看这两份IR之间的变化,几乎无法直观地导出调试信息丢失的具体位置。
现有工具:LLVM调试信息分析器
面对上述挑战,我们来看看现有的解决方案:llvm-debuginfo-analyzer工具。
llvm-debuginfo-analyzer是一个命令行工具,专门用于处理调试信息。它支持不同的二进制文件格式和调试信息格式。最重要的是,无论输入格式如何,它都能生成一个规范的、逻辑化的视图。这个逻辑视图可以被打印、筛选或比较。
例如,左侧是由dwarfdump打印的DWARF调试信息,右侧是由pahole打印的调试信息,而中间则是本工具为这两种输入生成的规范逻辑视图。
以下是该工具当前的组件架构,以及本次提议新增的模块:
- 该模块将允许我们为LLVM IR中的调试信息也创建一个逻辑视图。
新功能:生成IR的逻辑视图
现在,让我们深入了解如何为IR生成逻辑视图。
以下是处理IR时常用的命令选项。这是经过特定编译通道(simplifycfg和SLP向量化)后的测试用例及对应的IR文件名。
完整的命令行如下:
llvm-debuginfo-analyzer --ir-input simplify_cfg.ll --logical-view
llvm-debuginfo-analyzer --ir-input slp_vectorizer.ll --logical-view
执行结果对比如下:
- 左侧是
simplifycfg后的标准IR元数据输出。 - 中间是
simplifycfg后IR的逻辑视图。 - 右侧是
SLP向量化后IR的逻辑视图。
可以看到,逻辑视图比原始IR元数据更清晰、更有条理。
进阶功能:内置比较
生成逻辑视图后,该工具还支持内置的比较功能。
以下是进行比较时常用的选项。这是要比较的两个测试用例文件。
完整的比较命令行如下:
llvm-debuginfo-analyzer --ir-input simplify_cfg.ll slp_vectorizer.ll --compare
现在,使用这个工具进行比较,我们可以清晰地看到两个IR逻辑视图之间的差异。而如果使用普通的比较工具,则很难做到这一点。
通过工具的视图模式,我们可以轻松识别出:
- 缺失的符号。
- 缺失的代码行。
- 同时查看共同部分和缺失部分。
总结

本节课中我们一起学习了为llvm-debuginfo-analyzer工具添加IR阅读器的重要性。总结如下:

通过这一新增功能,我们能够显著降低比较LLVM IR中调试信息时的“噪音”。它通过将杂乱的IR元数据转换为规范的逻辑视图,并提供了强大的内置比较功能,使得开发者能够快速、准确地定位调试信息在优化过程中的变化与丢失,从而更高效地解决调试信息相关的问题。
029:全局函数合并与安全符号化


概述
在本节中,我们将学习LLVM编译器中两项关键的代码优化技术:全局函数合并与安全符号化。这些技术旨在减少移动应用二进制文件的大小,同时确保在函数被合并后,调试信息(如崩溃堆栈)依然准确可用。我们将从全局函数合并的原理开始,然后探讨链接器层面的安全合并,最后解决合并函数带来的符号化挑战。
全局函数合并:超越模块边界
上一节我们介绍了传统函数合并的局限性。传统方法(如LLVM的MergeFunctions)通常只能在单个模块内合并相同或相似的函数。当使用链接时优化时,虽然可以将所有代码合并到一个模块,但这在分布式编译(如ThinLTO)环境中并不高效。
本节中,我们来看看全局函数合并如何解决这个问题。它允许在独立的编译模块之间合并函数,即使在使用ThinLTO进行分布式编译时也能工作。
其核心思想基于代码生成数据框架,该框架最初为全局函数外联而设计。整个过程分为两步:
- 写入阶段:在编译时分析函数,生成一个“摘要”,描述函数的稳定形态。
- 读取阶段:在后续编译(或链接时)消费这些摘要,乐观地创建合并后的函数。
以下是该过程的一个简化示例。假设有两个模块,包含相似函数:
// 模块1
int func1() {
return 100; // 常量 F1
}
// 模块2
int func2() {
return 200; // 常量 F2
}
全局函数合并会分析这些函数,生成一个不依赖于具体中间表示的“稳定函数形式”摘要。在链接时,它识别出这些函数的相似性(仅常量不同),并创建一个参数化的合并函数:
// 合并后的函数
int merged_func(int param_x) {
return param_x;
}
// 原函数变为跳板
int func1() { return merged_func(100); }
int func2() { return merged_func(200); }
需要注意的是,由于ThinLTO后端编译是并行且独立的,我们无法在编译时直接合并。因此,编译时的转换只是将函数“具体化”为跳板形式,最终的合并与重复项消除在链接时完成。
通过启用全局函数合并,我们在ThinLTO构建中观察到了显著的二进制大小缩减。然而,合并摘要对源代码变更比外联摘要更敏感,可能导致一定的二进制大小波动(通常在1%以内),这需要在构建资源与收益之间进行权衡。


链接器中的安全函数合并
上一节我们介绍了编译器实现的全局函数合并。本节中,我们来看看链接器如何通过相同代码折叠技术进一步合并函数,并确保程序行为正确。
链接器可以执行ICF,但它只能合并二进制完全相同的函数。直接合并会带来一个问题:如果两个函数的地址被获取并比较,合并它们会改变程序语义。
例如,有以下源代码:
void A() {}
void B() {} // 二进制代码与A完全相同
int main() {
return (&A == &B) ? 1 : 0; // 应始终返回0(false)
}
如果链接器将B合并到A,那么main中比较的就是A的地址和A的地址,结果变为1(true),导致程序错误。
为了解决这个问题,我们引入了安全跳板模式。在此模式下,链接器只保留一个函数的完整本体,其他相同函数则被替换为一个包含单一跳转指令的跳板。
以下是启用安全ICF后的行为:
- 保留函数
A的完整本体。 - 函数
B被替换为一个跳板B_thunk,其内容仅仅是跳转到A。 - 在
main中,&A获取的是A的地址,&B获取的是B_thunk的地址,两者不同,程序行为得以保持正确。
这种安全的ICF在真实应用中带来了约0.45%的二进制大小节省,并且已在LLVM的ld64链接器(用于macOS arm64)中实现,可通过一个标志启用。
合并函数的符号化挑战与解决方案
之前的优化带来了代码大小的减少,但也引入了调试信息方面的挑战。当函数被合并后,我们可能会丢失其中一部分函数的调试信息,导致崩溃报告中的堆栈信息错误。
考虑以下场景:函数A和B二进制相同但源代码不同,它们被ICF合并。合并后,调试信息可能只保留了函数A的信息。如果程序在函数B中崩溃,符号化工具只能看到地址属于合并后的函数,并错误地将其报告为函数A,这会给开发者调试带来极大困惑。
为了解决这个问题,我们需要在最终的二进制文件和调试信息中保留所有被合并函数的符号和调试信息,即使它们指向相同的地址。同时,我们还需要保留所有调用点的信息(即从哪里调用了哪个函数)。
以下是实现正确符号化的新流程,它采用自底向上的符号化方式,并利用调用点信息作为上下文过滤器:
- 客户端提供原始的堆栈地址序列。
- 符号化工具从最底层(最后一个调用)的帧开始。
- 在调试信息中查找该地址。除了找到对应的函数(如
main),还会找到此地址是一个调用点,例如“调用了函数A”。 - 正确符号化当前帧为
main,并将“调用了A”这个信息作为上下文过滤器保留。 - 处理上一帧地址时,应用保留的上下文过滤器。在调试信息中查找该地址时,可能会匹配到多个合并函数(如
A和B),但过滤器能帮助我们筛选出正确的那个(即A)。 - 重复此过程,直至完成整个堆栈的符号化。
通过这种方法,即使函数A和B被合并,我们也能正确区分出崩溃究竟发生在A还是B中,从而为开发者提供准确的堆栈信息。
此实现目前已集成到上游LLVM中。要使用它,需要向Clang和LLD传递特定的标志以生成和保留必要的额外调试信息,并在使用llvm-symbolizer等工具进行符号化时,采用支持自底向上解析的新方式。

总结
本节课中我们一起学习了LLVM生态中为减少应用体积并保持可调试性所做出的两项重要进展。
首先,全局函数合并允许在分布式编译环境下跨模块合并相似函数,有效减少了代码体积。其次,链接器的安全ICF技术通过引入跳板,在合并相同函数的同时保证了程序语义的正确性。最后,针对合并函数导致的调试信息混乱问题,我们介绍了一种创新的符号化方案。该方案通过保留所有合并函数的符号和调用点信息,并采用自底向上的上下文过滤解析,确保了崩溃堆栈能够被准确无误地还原。

这些技术的结合,使得开发者在追求极致应用性能与体积的同时,无需牺牲可观察性与调试体验。
030: 高性能机器学习符号化Python DSL与编译器

概述
在本教程中,我们将学习 Wave,一个用于高性能机器学习的符号化领域特定语言及其编译器。我们将了解其设计动机、核心概念、架构以及如何简化高性能GPU内核的编写。
Wave: 1: 动机与简介
现代机器学习工作负载需要利用GPU来获得良好性能。然而,直接使用CUDA或HIP等语言进行GPU编程非常复杂且耗时。这些编程模型涉及底层硬件指令、跨线程协作、非标准数据布局以及寄存器/内存调度,这些都不符合常规的C++编程模型。
因此,我们需要一种更便捷的方式来编写高性能内核。虽然存在其他语言(如Triton)致力于此,但Wave旨在提供一种新颖且更便利的方法。
Wave是一个针对高性能机器学习的符号化领域特定语言。它目前主要面向AMD GPU,但其设计也支持扩展到其他硬件供应商。Wave使用Python语法和简单的符号表达式来描述内核,并明确分离了高级内核逻辑与数据分布策略。它利用MLIR进行代码生成,主要使用上游方言,并借助IREE作为“最后一英里”优化器和运行时来启动内核。
Wave: 2: 核心设计理念
上一节我们介绍了Wave的动机,本节中我们来看看其核心设计理念。
Wave的设计基于几个关键原则:
- 逻辑与策略分离: 高级内核描述操作于整个张量级别,而分块(Tiling)和分布策略则与之分离。这意味着可以更改分布策略而无需修改核心内核逻辑。
- 分层执行模型: 语言在GPU的线程块(Block)和线程组(Wave)级别描述计算和分布,随后编译器会透明地决定块和线程的内存访问模式。
- 符号化数据类型: Wave使用符号化数据类型来表示张量形状、分布模式等。这允许编译器进行高级优化。
- 通用硬件支持: 通过使用掩码(Masking)和向量化操作来处理非规整的形状,Wave能够灵活地针对2026年及以后的各种新硬件进行测试和优化。
Wave: 3: 一个简单示例:逐元素复制
让我们通过一个简单的逐元素复制内核来了解Wave的基本结构。
一个Wave内核通常包含三个基本部分:
- 分布描述(约束): 指定如何将整个工作负载划分为块(Blocks)和波(Waves)。
- 内核主体: 描述实际的计算逻辑。
- 符号值: 提供具体的符号值,然后编译并运行内核。
以下是一个简单的逐元素复制内核的示意性代码框架:
# 1. 分布约束 (示意)
constraints = Tile(block=(128, 128), wave=(32, 32))
# 2. 内核主体 (示意)
@wave.kernel
def copy_kernel(input: Tensor[(M, N), f32], output: Tensor[(M, N), f32]):
output = input
# 3. 编译与运行 (示意)
compiled_kernel = compile(copy_kernel, constraints)
result = compiled_kernel.run(input_tensor)
在这个例子中,内核读取一个M x N的输入张量,并写入到另一个相同形状的输出张量中。
Wave: 4: 深入内核组件
上一节我们看了一个简单示例,本节我们来深入看看内核中更重要的组成部分。
以下是一个类GEMM(通用矩阵乘法)内核的关键组件描述:
- 硬件特性(Hardware Traits): 描述每个Wave(波前)的线程数(Wave Size)以及用于矩阵乘累加(MMA)操作的指令。可以全局设置MMA操作,有时这比逐个操作设置更方便。
- 假设(Assumptions): 对于动态维度,可以指定一些属性(如可整除性),编译器可以利用这些属性进行优化。
- 张量描述(Tensor Descriptions): 指定张量的形状、数据类型和内存空间(例如,控制是否应将其提升到共享内存)。
- 临时存储: 可以使用寄存器分配临时存储。这是虚拟寄存器,可以具有完整的
M x N形状。 - 归约循环: 支持跨K维度的归约循环,这是标准的GEMM操作模式。内核读取两个参数,调用MMA操作(该操作将连接到实际的硬件指令),然后写入结果。
Wave: 5: 更复杂的示例:卷积与注意力
现在,我们来看两个更复杂的示例,以展示Wave处理复杂操作的能力。
卷积(通过隐式GEMM实现)
卷积内核本身看起来几乎与普通的GEMM内核完全相同,只是输入和输出是4维张量。它使用一个称为“映射(Mapping)”的特殊组件。
映射(Mapping): 描述如何将卷积输入自动映射到MMA操作所期望的2D输入。这类似于
linalg.generic中的迭代器映射,将输出映射到(i, j),输入映射到(k, l),并指定如何从输入和输出位置转换元素索引。
注意力(Attention)内核
注意力内核更为复杂,无法完全放入一页幻灯片中。它包含以下高级特性:
- 在归约循环内有两个MMA操作。
- 跨线程组(Workgroups)进行归约。
- 包含全局掩码和全局求和操作。
- 支持直接内存访问和一些更高级的功能。
Wave: 6: 编译器架构
上一节我们看到了Wave能表达的内核类型,本节我们来看看其编译器如何工作。
Wave编译器遵循典型的三段式架构:前端、中端和后端。
- 前端: 使用Torch FX进行跟踪(Tracing)。跟踪只需使用特殊的代理对象调用内核函数体,即可在内部构建计算流。这使得快速原型设计和内核组合成为可能,但缺点是需要为控制流编写特殊的处理逻辑。
- 中端: 使用TOSA(Tensor Operator Set Architecture)作为中间表示。在此阶段进行重要的转换,包括:
- 索引同步与形状传播分析: MMA指令需要非标准的数据布局。编译器从执行图中的MMA节点开始,向前向后传播形状信息,以解决来自不同路径(如多个MMA操作)的布局冲突。
- 扩展(Expansion): Wave允许设置任意分块大小,但硬件指令(如MMA)通常有固定大小(如16x16x16)。因此,编译器可能需要展开循环并生成一系列硬件指令调用。
- 其他优化: 共享内存布局优化、屏障插入、非连续读写分区、公共子表达式消除等。
- 后端: 使用上游MLIR方言(如
vector、scf、gpu、amd)进行 lowering。关键步骤包括:- 使用
affine.apply和affine表达式将符号类型和计算 lowering。 - 生成向量化索引计算(当前
affine方言对此支持有限,有改进提案)。 - 调用表达式简化函数。
- 将操作 lowering 到具体的硬件指令(如
gpu方言的加载/存储、amd方言的MMA和shuffle指令)。 - 使用IREE作为“最后一英里”优化器和运行时来启动内核。最终输出是GPU代码(如
gpu.func),IREE提供了必要的主机端基础设施来运行内核,并与PyTorch等框架交互。
- 使用
Wave: 7: 关键优化与经验总结
在编译器后端(或更准确地说,在MLIR lowering 阶段),Wave应用了一系列关键优化:

- 使用标准MLIR优化: 如向量化、循环展开、常量传播等。
- 利用IREE的整数范围分析: 初始生成的索引计算通常是64位(i64)。通过整数范围分析,可以将其降级为更小的整数类型(如i32),如果值域允许的话。
- 可除性分析: 例如,如果知道线程数是64,并且某个表达式是
thread_id % 64,那么这个取模操作可以被完全消除。
在开发Wave过程中,我们积累了一些重要经验:
- 语言与编译器实现: Wave语言本身完全用Python编写,用户用Python写内核。编译器本身也主要用Python编写(利用MLIR的Python绑定和TOSA),这带来了开发便捷性,但编译速度会受到影响。通过缓存编译结果(内存或磁盘)可以部分缓解此问题。
- 中间表示的选择: TOSA作为最小化的IR,有利于快速实现前端和优化流程。但我们不得不在TOSA之上实现一些通用工具(如公共子表达式消除),理想情况下更希望直接使用MLIR。
- 符号类型的挑战: Wave严重依赖符号表达式,但MLIR中缺乏良好的符号表达式和符号张量类型。
affine表达式覆盖了一些情况,但还不够。如何在MLIR中高效地设计和使用符号类型是一个开放性问题。 - IREE的集成价值: IREE极大地简化了端到端流程的启用。只需几行自定义的流程(flow)和流(stream)方言代码,以及一些API调用,就能实现从PyTorch张量传递到内核运行的全过程。在MLIR上游提供类似的支持将非常有益。
Wave: 8: 总结与资源
在本教程中,我们一起学习了Wave符号化Python DSL及其编译器。我们了解了其设计动机:为高性能机器学习内核提供一种比直接CUDA/HIP编程更便捷的方法。我们探讨了其核心设计理念,如逻辑与策略分离、符号化类型。通过简单和复杂的示例,我们看到了Wave代码的结构。我们还深入了解了其三层编译器架构,以及在后端进行的关键优化。最后,我们分享了在实现过程中获得的经验教训。
项目资源:
- Wave是一个开源项目,代码托管在GitHub上。
- 在IREE的Discord服务器上有相关的讨论频道,可以加入并提问。
- 项目团队目前正在招聘,欢迎感兴趣的同学加入。
与Triton的对比: Wave旨在提供比Triton更彻底的抽象分离(内核逻辑与分布策略),让用户无需手动处理指针、掩码等底层细节。在性能上,Wave目前与AMD的rocBLAS和Triton性能相当,有时略快或略慢。


未来方向: 包括改进符号类型在MLIR中的支持、提升编译性能、扩展对更多硬件的支持等。
031:如何将你的神经网络导入上游MLIR方言


在本教程中,我们将学习如何将不同框架的神经网络模型导入到上游的MLIR方言中。我们将探讨TensorFlow Lite、ONNX、PyTorch和JAX这四种主流框架,并了解如何将它们分别转换为Linalg和TOSA这两种核心的MLIR方言。教程将提供具体的项目依赖、转换步骤以及实践经验,旨在帮助初学者克服模型导入的初始障碍。
问题定义与学习目标
首先,我们来明确本次教程要解决的问题以及你将学到什么。
许多MLIR初学者在尝试将神经网络模型导入上游MLIR方言时感到困难。MLIR本身不是一个编译器,但其张量编译器组件是上游MLIR的重要组成部分。新用户希望使用MLIR进行平铺、尝试不同编译过程等实验,但将模型导入上游方言的工具并不包含在MLIR核心库中,而是分散在不同的代码仓库和项目中。这种碎片化给新手带来了挑战。
此外,在使用MLIR的典型编译器流程图中,从PyTorch等框架到编译器的箭头常常被隐藏,显得很神秘。但实际上,这部分工作非常复杂,且经常导致依赖关系混乱。
因此,在本教程中,你将学习:
- 如何将模型导入到Linalg或TOSA方言。
- 针对不同框架需要使用的具体项目。
- 如何找到相关代码和示例。
- 一些实用的技巧和注意事项。
需要说明的是,本教程基于2025年4月的情况,未来相关工具链可能会发生变化。
目标方言:Linalg与TOSA
在深入各个框架之前,我们先简要了解两个目标上游方言:Linalg和TOSA。
Linalg方言是MLIR中主要的、也可能是最常用的张量级方言。它有两种形式:命名操作(Named Ops)和通用操作(Generic Ops)。命名操作是特定通用操作(如linalg.matmul)的语法糖,能保留更多信息。大多数框架的导入器会尝试先转换为命名操作变体。
一个简单的Linalg示例如下:
%0 = linalg.matmul ins(%arg0, %arg1: tensor<128x256xf32>, tensor<256x64xf32>)
outs(%init: tensor<128x64xf32>) -> tensor<128x64xf32>
TOSA(Tensor Operator Set Architecture)是上游MLIR中唯一的ML图级别方言。它致力于定义一组最小且稳定的张量运算符,并关联到一个规范。TOSA既可作为输入方言,也可作为输出方言,因为一些后端编译器会使用TOSA字节码。
框架一:TensorFlow Lite (TFLite)
现在,我们来看第一个框架:TensorFlow Lite(现称LiteRT)。它是一个为移动和物联网设备优化的轻量级ML运行时,使用稳定的FlatBuffer文件格式(通常以.tflite结尾),支持量化和模型优化,非常适合全整型网络。它最初为CNN和RNN设计,但也能编译Transformer模型。
以下是TFLite的导入流程:
- 反序列化:首先,你需要一个
.tflite文件。在TensorFlow项目中,有一个反序列化步骤,可以将FlatBuffer文件几乎一对一地转换为tflite方言。 - 转换为TOSA:同样在TensorFlow项目中,存在从
tflite方言到TOSA方言的 lowering 过程。 - 工具链:目前,你可能需要两个二进制工具:
flatbuffer_translate(用于将FlatBuffer文件转换为MLIR方言)和tf-opt(用于将TFLite转换为TOSA)。之前存在Python API,但已被移除。
转换后的TOSA IR示例如下,它支持全整型(如i32)和一些动态形状:
func.func @main(%arg0: tensor<1x224x224x3xi32>) -> tensor<1x1000xi32> {
%0 = "tosa.conv2d"(%arg0, %cst0, %cst1) {...} : (...)
// ... 更多操作
return %result : tensor<1x1000xi32>
}
实践经验:
- 该流程相对稳定。
- 需要注意所使用的TensorFlow包与编译的MLIR版本保持同步,因为方言可能会变化。
- 移除Python API带来了一些不便,目前需要从源码编译TensorFlow才能使用此流程。
- 依赖整个TensorFlow框架有些繁琐,因为你只使用了其中很小一部分功能(反序列化和 lowering)。
- 对于想要尝试全整型网络的用户,这可能是最简单的方式,因为TFLite量化器会处理好函数参数和返回值的类型。
框架二:ONNX
接下来是ONNX(开放神经网络交换格式)。它使用协议缓冲区文件格式(通常以.onnx结尾),代表静态计算图,旨在实现框架间的互操作性。你也可以导出训练图,因此在用于推理时需要确保没有训练节点。
ONNX有两种导入到上游MLIR的方式:
1. 通过 ONNX-MLIR:
这是一个较老的MLIR项目,最初由IBM启动。它包含一个到TOSA的 lowering 过程,但目前不完整。此外,还有一条通过StableHLO到Linalg的路径,看起来更完整一些。使用此方法需要先反序列化ONNX文件。
ONNX-MLIR使用的中间表示(IR)几乎与ONNX节点一一对应:
%0 = "onnx.Conv"(%arg0, %arg1) {auto_pad = "NOTSET", dilations = [1, 1], ...} : (...)
2. 通过 Torch-MLIR(新方式):
这是较新的方法。有两个主要原因:一是ONNX和ATen(PyTorch核心)操作非常相似;二是人们希望减少对大型MLIR项目的依赖。
- 流程:首先将ONNX模型导入到一个使用Torch自定义操作(Custom Ops)表示的“ONNX方言”中(这并非真正的方言,而是一一映射)。
- 然后,将这个表示转换为
torch方言。 - 最后,
torch方言可以被转换为TOSA。这相当于先横向转换,再向下转换。
实践经验:
- 两种方法都不完美。ONNX-MLIR的TOSA lowering不完整;Torch-MLIR有时会遇到失败,但正在快速改进。
- 使用Torch-MLIR的好处是,通过依赖一个项目,你同时获得了ONNX和PyTorch的支持。
- ONNX在Torch-MLIR中的表示(使用Custom Ops)有些非常规,但这是出于实用性的选择,避免为ONNX单独维护一个完整的C++方言定义。
- Torch-MLIR中的 lowering 代码组织很有趣,它是按操作名字母顺序排列的多个文件,本质上是一个巨大的switch语句。
框架三:PyTorch
现在我们来讨论最复杂的框架:PyTorch。它是一个Python优先的框架,非常流行,但导出路径没有稳定的序列化格式(Torch Export目前是实验性的,变化频繁)。
PyTorch的生态现在分为两部分:一部分依赖于PyTorch基础设施,另一部分存在于Torch-MLIR项目中。
PyTorch导出与导入流程:
- Torch Export:这是默认的模型导出方式,是一种追踪导出格式。你提供模型和一些示例输入(fake arguments),模型会在这些伪张量上执行,从而追踪计算图。它不能处理张量元素级别的控制流(会导致“图中断”)。
- Torch-MLIR导入:Torch-MLIR中的
torchscript导入器接收导出的图,并输出torch方言。它支持一些高级特性,如可变缓冲区(mutable buffers)。 - 转换:得到
torch方言后,你可以将其 lowering 到TOSA或Linalg。这涉及类型转换和确保函数参数/返回值为张量类型的Pass管道,并非单个Pass。
一个示例如下,展示了动态形状和约束:
# Python端:定义模型和动态形状约束
class SimpleModel(torch.nn.Module):
def forward(self, x):
return torch.nn.functional.relu(x)
model = SimpleModel()
# 约束:batch维度在2到10之间,且是16的倍数;channel维度固定为3
dynamic_shapes = {“x”: {0: torch.export.Dim(“batch”, min=2, max=10, multiple_of=16), 1: 3, 2: 224, 3: 224}}
exported_program = torch.export.export(model, args=(torch.randn(2, 3, 224, 224),), dynamic_shapes=dynamic_shapes)
# 使用 torch-mlir 的 `torchscript_export_import` 函数导入到 MLIR
在生成的MLIR中,你会看到代表这些约束的符号形状(Symbolic Shapes)。
实践经验:
- Torch Export功能强大,能捕获符号形状、约束、混合精度等信息,这意味着PyTorch前端设计上就比TFLite前端更复杂。
- “图中断”问题需要模型开发者来解决。
torchscript导入器设计良好,其钩子(hooks)可以像插件一样方便地用于自定义编译器或实现。- 目前,从
torch到Linalg的覆盖度比到TOSA更好,部分原因是TOSA受规范限制,允许的操作变体更少。 - 代码质量在某些部分存在问题,修复bug或添加新特性可能比较困难。
- 需要注意的是,如果目标方言是TOSA,由于卷积和池化操作的数据布局不同,你会在IR中看到大量转置操作,需要设法优化它们。
框架四:JAX
最后是JAX。它通过XLA进行加速,支持自动微分,在研究和大型Transformer模型中广泛使用,与MLIR有非常原生的集成。
JAX的导入流程相对直接:
- 从JAX模型导出到StableHLO。
- 依赖StableHLO项目(可能需要从源码构建)。
- 从StableHLO可以 lowering 到TOSA或Linalg,其中到Linalg的路径相当完整。
一个简单的JAX导出示例代码如下:
import jax
import jax.numpy as jnp
import numpy as np
from transformers import FlaxResNetModel
# 加载模型
model = FlaxResNetModel.from_pretrained(“microsoft/resnet-50”)
params = model.params
# 定义前向函数
def forward(inputs):
return model(inputs, params=params)
# 将前向函数编译为StableHLO
compiled = jax.jit(forward).lower(jnp.ones((1, 3, 224, 224))).compile()
# compiled 中包含 StableHLO 表示
实践经验:
- 对于基本模型,流程顺畅,开箱即用。
- 对动态形状等高级特性体验较少。
- 整体印象良好,未遇到重大问题。
总结与对比
本节课我们一起学习了如何将四种主流机器学习框架的模型导入到上游MLIR方言。

总结如下:
- 选择正确的导入项目至关重要:TFLite用TensorFlow项目;ONNX可选Torch-MLIR或ONNX-MLIR;PyTorch用Torch-MLIR;JAX用StableHLO。
- 简单模型通常在各框架下都能工作。
- 复杂情况,尤其是PyTorch赋予开发者的强大表达能力(动态形状、约束等),会带来更多细节上的挑战。
- 你通常可以在TOSA和Linalg之间选择。总体来看,Linalg的操作覆盖度更好,而TOSA因其精简、稳定的操作集也有其适用场景。
- 一个不便之处是,为了导入模型,你经常需要从源码构建多个基于MLIR的项目,这对初学者不够友好。Torch-MLIR提供了PyPI包,但TensorFlow移除Python API带来了不便。
希望本教程能帮助你更顺利地将神经网络模型带入MLIR的世界进行探索和实验。

Q&A环节摘要
Q1: PyTorch流程中,对于像Torch Compile或Triton这样的即时编译组件,导出器和导入器是否支持?
A1: Torch Compile使用了许多相同的基础设施,但它能处理图中断,代价是可能有效能损耗。据我所知,Triton有自己的流程,如果遇到图中断,目前没有很好的自动处理方式,可能需要手动修复模型代码。
Q2: 不同导入器如何处理权重?是作为常量还是函数参数?
A2: 在ONNX-MLIR和TFLite导入器中,权重是常量操作(constant ops)。在Torch-MLIR中,权重可以被放在IR的资源部分,甚至可以保存到单独的文件中,它们不是函数的参数,而是通过类似全局加载的操作来获取。
Q3: Torch Export 会展开所有NN模块,导致IR非常大。是否有计划支持循环结构?
A3: 你可以指定Torch Export使用哪些分解规则。默认的分解集可能过于激进,导致循环被展开。这可能是一个系统性问题,PyTorch团队正在努力改进,例如ExecuTorch也依赖Torch Export并试图解决此类问题。


032:一个意外的二次方复杂度问题

概述
在本节课中,我们将要学习一个在LLVM编译器运行时库(compiler-rt)的地址消毒器(AddressSanitizer,简称Asan)中发现的、有趣的性能问题。这个问题会导致程序在启动时产生意外的严重延迟。我们将详细探讨问题的现象、根本原因、调试过程以及最终的解决方案。
问题现象:启动延迟之谜
上一节我们介绍了课程概述,本节中我们来看看具体的问题表现。
我们有一个非常简单的程序,它仅仅打印“hello URL LVM”。然而,这个程序的启动时间却严重依赖于它所链接的动态共享库(shared libraries)的数量。
每个共享库都包含一些唯一的符号(unique symbols)和一些在不同库中重复定义的共享符号(shared symbols)。虽然这些符号大小相同,并不构成严格意义上的ODR(单一定义规则)违规,但程序启动却异常缓慢。
在修复之前,这个简单的打印程序需要超过5秒钟才能将信息输出到屏幕。而在修复之后,启动几乎是瞬间完成的。这是一个简化后的例子,但它真实地反映了我们在生产环境中观察到的情况:开发者的构建过程会在实际工作开始前,无谓地消耗2到20秒的时间。
调试之旅:定位性能热点
了解了问题现象后,我们来看看如何定位这个性能问题的根源。由于这是我第一次深入接触compiler-rt代码库,我将分享一些调试和排查的经验。
查看性能分析(Perf)报告时,可以清楚地看出问题与Asan和全局变量(globals)的处理有关,但除此之外,调用堆栈信息并不十分明确。当开发者遇到此类问题时,一个很自然的反应是考虑关闭消毒器(sanitizer)。事实上,我们也观察到一些项目开始因为这个性能问题而切换到未消毒的构建模式。
然而,在开发基础设施团队中,我们非常看重Asan带来的安全性。我们希望更多地使用Asan来保障代码安全,因此解决这个性能问题、确保开发者能够顺畅地使用安全工具至关重要。
继续分析性能数据,如果我们对热点函数进行标注,就能获得更多信息,比如一些函数名。但我们面对的是经过优化的代码,直接从汇编跳转回原始源代码并不直观。
不过,如果仔细观察,可以发现热点循环看起来像是在迭代一个链表(linked list)。代码从第二个字(word)加载数据,然后进行测试等操作。这些线索足以让我们将性能热点追溯到源代码中 checkODRViolationByIndicator 函数内的一个 for 循环。
根本原因:链表遍历的代价
上一节我们通过性能分析定位到了热点函数,本节中我们来深入分析其根本原因。
问题的核心在于:当Asan检测到一个潜在的ODR违规时,它会触发一个遍历程序中所有全局变量的链表的操作。
以下是导致性能问题的核心代码逻辑的简化表示:
// 伪代码:修复前的遍历逻辑
for (Global *G = AllGlobalsListHead; G != nullptr; G = G->Next) {
if (G->ODRIndicator == CurrentGlobal->ODRIndicator) {
// ... 检查其他条件,可能报告ODR违规
}
}
如果程序中有数百万个全局变量,这种线性查找(O(n) 复杂度)会变得极其昂贵,从而导致了2到20秒的启动延迟。代码会比较ODR指示器(ODR indicator),检查是否为同一个全局变量,并在报告ODR违规前验证其他一些条件。
解决方案:用映射替换链表
找到了问题的根本原因,解决方案就变得非常直接。我们只需要将低效的链表数据结构替换为高效的映射(map)数据结构。
修复方法非常简单,其核心思想可以用以下公式描述:
将查找复杂度从 O(n) 降低为 O(log n) 或 O(1)。
具体实现是,我们创建了一个以ODR指示器为键(key)的映射(例如 std::map 或 std::unordered_map)来索引全局变量,从而避免了每次检查都进行全量遍历。
以下是修复思路的代码对比:
// 修复前:线性扫描链表
Global* FindGlobalByIndicator(IndicatorType ind) {
for (Global* G = ListHead; G; G = G->next) {
if (G->odr_indicator == ind) return G;
}
return nullptr;
}
// 修复后:通过映射快速查找
std::map<IndicatorType, Global*> GlobalMap;
Global* FindGlobalByIndicator(IndicatorType ind) {
auto it = GlobalMap.find(ind);
return (it != GlobalMap.end()) ? it->second : nullptr;
}
这个修复代码量很小,几乎可以完整地显示在屏幕上。虽然理论上仍可以构造出导致变慢的极端用例,但根据我们的实践经验,这个修复完全解决了之前遇到的性能问题。
思考与启示
问题虽然解决了,但它给我们带来了一些更深层次的思考。
首先,开发者的感知非常重要。对于不深入参与LLVM或工具链开发的普通开发者来说,“消毒器太慢了”可能是最直接的反应。他们通常没有足够的时间去细致分析,因此即使不想关闭所有消毒功能,也可能不知道应该调整哪部分来规避此类性能问题。这就引出一个问题:我们能否提供更好的性能上限保证,或者运行时诊断信息,来引导开发者得出正确的结论?
其次,关于基准测试(Benchmarks)。我们这次修复了一个极端情况,但如何防止未来出现类似的性能回归(regression)呢?作为一个compiler-rt代码库的新手,我当时并不清楚(现在也未必完全清楚)其性能是如何被追踪以及回归是如何被发现的。这或许涉及到文档完善或基准测试套件的改进,我认为这一点非常重要。
总结


本节课中我们一起学习了LLVM compiler-rt中Asan运行时的一个性能问题。我们从程序启动异常缓慢的现象出发,通过性能分析工具定位到热点代码,发现其根本原因是ODR违规检查时对全局变量链表进行了低效的线性遍历。最终的解决方案是将链表数据结构替换为以ODR指示器为键的映射,从而将查找复杂度从 O(n) 降为 O(log n),彻底解决了启动延迟问题。这个案例提醒我们,即使在底层系统工具中,数据结构的正确选择对性能也至关重要,同时也凸显了持续的性能监控和基准测试的重要性。
033:将方言作为方言——为 IRDL 带来原生 C++ 注册


在本节课程中,我们将学习一种名为 IRDL 的创新方法,它允许我们使用 MLIR 方言本身来定义新的方言。我们将探讨其简洁性、动态加载能力,以及如何通过新工具 irdl-to-cpp 将其与 MLIR 的 C++ 基础设施集成。
大家好,我是来自剑桥的 I。今天我们将讨论方言。
虽然我接触 MLIR 的时间不长,但有一个核心理念被反复强调:我们使用方言来处理一切。
我们使用方言进行算术运算。我们使用方言处理控制流。我们使用方言定义函数。我们甚至将变换操作也作为方言。
因此,当有人提出“为何不将方言定义本身也作为一种方言”时,也就不足为奇了。这就是 IRDL,即 IR 定义语言。
你可能会想,为什么要用方言来定义 IR,而不是其他方式?
与其展示一堆要点,不如直接看看 IRDL 的样子。这是一个 IRDL 文件,只有 11 行代码。
// 示例 IRDL 代码
irdl.dialect "complex" {
irdl.type<"Complex"> = !irdl.any_type
irdl.op<"norm">(%arg: !complex.Complex) -> (!irdl.any_type)
}
它可以直接工作。这意味着,通过单一操作,你可以定义一个方言。你可以定义一个类型。你也可以定义一个操作。所有这些功能都已就绪,只需 11 行代码。
那么,它到底有多“就绪”呢?在当前的 MLIR 上游代码库中,你可以将这个 IRDL 文件作为标志传递给 mlir-opt 工具。无需重新编译 mlir-opt,IRDL 方言会被动态加载,然后你就可以立即在任何 MLIR 文件中使用它。
再次强调,只需很少的代码就能运行。此外,请注意我们定义了一个基于某种浮点类型的复数模板。我们期望当你计算该复数的模时,会输出相同的浮点类型。IRDL 能够验证这一点,并在你错误地输出 f64 类型时报错。这一切都基于同样的 11 行代码。
这就是 IRDL 的核心理念:它是一个用于定义方言的方言,具有简洁、可内省、动态和可生成的特点。它开箱即用。
但事实果真如此吗?我的意思是,编译器工程师们,我有一个问题:仅仅验证程序的语义就足够了吗?
如果你昨天听了 Alex Denenko 的演讲,就会知道这还不够。你仍然需要执行它。执行的方式是将这个高级方言降低到硬件层级。在 MLIR 中,这是通过重写驱动器完成的。
重写驱动器的工作方式是遍历整个操作树,尝试匹配一个操作模式,并将其发送给重写器。这通常通过一些 C++ 代码实现,你无需完全理解其细节,只需关注其中用红色高亮的部分,那些是静态的 C++ 类型。
现在问题来了:IRDL 是动态加载的,我们并不重新编译 MLIR。这意味着 IRDL 无法与现有的 MLIR C++ 基础设施协同工作。
直到两周前,情况确实如此。经过大量的努力,我们让一条命令得以实现:irdl-to-cpp。正如其名,它接收一个 IRDL 文件并输出 C++ 代码。
那么,这个 C++ 代码能给你带来什么呢?没错,就是静态 C++ 类型。现在,你可以使用你的 IRDL 方言,并正常地进行重写操作了。
但我们并非凭空实现这一切。我们投入了大量精力来构建底层基础设施。我们创建了 CMake 函数来生成构建目标,这样当你的依赖于 IRDL 方言的 mlir-opt 需要重新编译时,它会在你更改方言定义时检测到变化。我们也投入了大量时间建立完善的单元测试,因为我们希望 IRDL 是稳定可靠的。
所有这些工作已经完成。事实上,相关的拉取请求已基本准备就绪,预计在一周左右就能完全合并。不过需要重点说明的是,目前仅支持 IRDL 功能的一个子集。我们完成了基础性工作,仍有大量任务有待完成。
这意味着,如果你觉得这个想法很酷——11 行代码、简易的验证、所有这些特性——并且它引起了你的共鸣,我诚挚地邀请你来帮助我们。IRDL 开发团队的成员都非常友善且易于接近,他们在我了解 MLIR 生态系统的过程中提供了巨大帮助。Matthew 和 Theo 是我的好朋友,我希望能将这份热情传递下去。
以下是我们的联系方式。如果你想提供帮助,请务必给我们发送消息。我们非常欢迎你的加入。我的演示到此结束。
如果你有兴趣尝试 IRDL,扫描这个二维码将带你到一个 Godbolt 在线编译器链接,在那里你可以验证我刚才所说的一切。就是这样。谢谢。


本节总结


在本节课中,我们一起学习了 IRDL 这一创新工具。我们了解到 IRDL 允许使用简洁的 MLIR 方言语法来定义新的方言,并能动态加载。更重要的是,我们探讨了通过 irdl-to-cpp 工具,可以将动态定义的 IRDL 方言转换为静态的 C++ 类型,从而使其能够无缝集成到 MLIR 现有的 C++ 重写基础设施中。虽然目前功能尚在完善中,但这为快速原型设计和方言开发开辟了新的可能性。
034:超越基于模式的优化——LLM如何重塑自动向量化

概述
在本节课程中,我们将探讨如何将大型语言模型集成到LLVM编译器中,以增强其自动向量化的能力。自动向量化对于高性能计算和移动应用至关重要,但现有方案在面对复杂代码时仍存在局限。我们将介绍一个名为“Veterans”的模型编译代理框架,它通过引导源代码转换来突破这些限制。
自动向量化的重要性与挑战
自动向量化对于许多工业应用,例如高性能计算以及一些移动应用程序,都相当重要。例如,华为开发的基于LLVM的编译器就高度优化了CPU,特别是针对Neon和SVE指令集。
然而,即便是这类工业级的LLVM编译器,其自动向量化能力也可能不够充分。以一个非常简单的基准测试TSVC2为例,我们发现仍有约34%的案例无法被很好地向量化。
如果我们利用分析工具来寻找原因,会发现问题主要源于对内存依赖性和规约操作的分析能力有限。对于现实中的工业应用,例如HPC应用,它们有很大可能无法在自动向量化方面表现良好。
引入Veterans框架
因此,我们尝试引入Veterans。这是一个模型编译代理框架,旨在大规模增强自动向量化的能力。
该框架具有一些基本特性。例如,它支持引导源代码转换,因此适用于C、C++或Fortran语言。它采用解释与范式,并由于源代码转换而天然支持交叉验证。
从以下结果可以看出,它能够生成有效的优化代码。
Veterans框架工作流程
以下是Veterans框架的一个粗略工作流程。
想象一下,你是一名性能工程师。你给出一个提示:“我想向量化这段源代码”,而这段代码很难被传统的LLVM编译器(如Bi)向量化。
接着,框架会利用深度模型,结合多种来源的反馈,生成经过优化的源代码。这段新代码将更容易被下游的编译器(如Bi)向量化。
效果评估与对比
以下是初步的结果和快速对比。
与之前的工作(如原生Bi编译)以及原生的Arm向量化方案相比,我们的方法在成功率上取得了良好的成果。这也证明了编译器反馈信息的重要性。
应对现实应用的复杂性
然而,仅处理简单代码是不够的。现实中的应用通常非常复杂且相互关联。仅从LLVM获取的备注信息可能不足以应对。
因此,我们尝试进一步开放调试信息。目前,Veterans框架也将在开源社区中开放。
总结


本节课中,我们一起学习了如何利用大型语言模型来增强LLVM编译器的自动向量化。我们了解到现有工业级编译器在分析复杂内存依赖和规约操作时的局限性,并介绍了一个名为Veterans的模型编译代理框架。该框架通过引导源代码转换,接收多源反馈,生成更易于向量化的代码,从而显著提升了向量化的成功率。对于处理现实世界中的复杂应用,开放更详细的调试信息是未来的关键方向。
035:MLIR 中的一對多方言轉換框架

在本節課中,我們將學習 MLIR 中一對多方言轉換框架的核心概念、使用方法及其重要性。我們將通過具體的 memref 到 LLVM 降級示例,來理解如何從舊的轉換模式遷移到新的、更高效的一對多轉換。
概述
MLIR 中的方言轉換框架是兩個主要的模式驅動器之一,它比模式重寫器更為強大。最近,該框架新增了對一對多轉換的完整支持,這意味著一個操作結果現在可以被替換為多個 SSA 值,從而顯著簡化降級過程並減少生成的中間表示數量。
為什麼需要一對多轉換?
在深入細節之前,我們先了解為什麼一對多轉換如此重要。在現有的框架中,當需要將一個複雜類型(如多維數組描述符)降級為多個基本類型時,開發者不得不使用變通方法。這些方法通常效率低下,會生成大量僅用於打包和解包數據的冗餘操作,增加了編譯開銷並使 IR 難以調試。
例如,在 memref 到 LLVM 的降級中,一個 memref 值目前必須被封裝到一個 LLVM 結構體中,然後在後續的每個操作中再解包出來進行計算,最後又重新打包。一對多轉換允許我們直接將一個 memref 值替換為多個 LLVM SSA 值,從而消除這些冗餘操作。
如何使用一對多轉換框架
接下來,我們來看看如何具體使用這個新框架。遷移過程主要涉及兩個組件的更新:類型轉換器和操作適配器。
1. 更新類型轉換器
在類型轉換器中,你需要使用 addConversion API 來指定如何將源類型轉換為多個目標類型。這通過一個回調函數實現,該函數返回一個 SmallVector<Type>。
代碼示例:
typeConverter.addConversion([](MemRefType type) -> SmallVector<Type> {
// 例如,將一個 memref 轉換為 7 個 LLVM 類型
return SmallVector<Type>(7, llvmPointerType);
});
2. 使用一對多操作適配器
對於你的轉換模式,不能再使用普通的 OpAdaptor。你必須使用新的 OneToNOpAdaptor 類。這個適配器的關鍵區別在於,當你查詢操作數或結果時,你得到的是一個 ValueRange(可能包含多個值),而不是單個 Value。
代碼示例:
struct MyLoweringPattern : public OpConversionPattern<MyOp> {
using OpConversionPattern::OpConversionPattern;
LogicalResult matchAndRewrite(MyOp op,
OneToNOpAdaptor adaptor,
ConversionPatternRewriter &rewriter) const override {
// adaptor.getOperands() 返回的是 ValueRange
ValueRange operands = adaptor.getOperands();
// ... 你的轉換邏輯
}
};
3. 替換操作
在重寫邏輯的最後,當你需要替換原始操作時,使用新的 replaceOpWithMultiple 方法。這個方法允許你為原始操作的每個結果指定多個替換值。
代碼示例:
// 假設原始操作有 1 個結果,我們用 3 個新值來替換它
SmallVector<Value> replacementValues = {val1, val2, val3};
rewriter.replaceOpWithMultiple(op, replacementValues);
遷移注意事項與常見錯誤
從舊的轉換模式遷移時,有幾個關鍵點需要注意。
如果你在類型轉換器中定義了一對多轉換,但某個模式忘記更新,仍然使用了舊的 OpAdaptor,MLIR 將會報錯。這實際上是一個有用的調試工具,可以幫助你快速定位哪些模式尚未遷移。
錯誤信息示例:
LLVM FATAL ERROR: Pattern expects a single replacement value, but multiple values are available.
此外,之前存在的一個獨立的、不兼容的一對多轉換框架(位於 one-to-n-type-conversions)現已被棄用,並將在不久後刪除。如果你在使用它,必須遷移到新的統一框架。遷移要點包括:
- 將
OneToNConversionPattern替換為常規的ConversionPattern。 - 使用
applyPartialConversion代替applyPartialOneToNConversion。 - 必須顯式定義一個
ConversionTarget。 - 使用
replaceOpWithMultiple並通過SignatureConversion對象來管理類型映射。
遷移後,由於新的框架是純粹的方言轉換(而非貪婪模式重寫),它不會自動執行公共子表達式消除或折疊優化。這可能導致測試用例失敗。一個快速的解決辦法是在轉換後運行規範化傳遞。
示例:memref 降級對比
讓我們通過一個具體的例子來直觀感受一對多轉換帶來的好處。考慮一個 memref.subview 及其後續的 memref.load 操作。
在當前的降級流程中,首先需要一個 expand-strided-metadata 預處理傳遞來分解操作,然後再應用降級模式。這個過程會生成大量僅用於打包和解包 LLVM 結構體的操作,導致 IR 急劇膨脹(例如從 5 個操作變成 34 個操作)。
當前流程的冗餘操作:
// 函數邊界:一對多轉換(已支持)
// 函數內部:
%packed = llvm.mlir.undef : !llvm.struct<...> // 目標物化:打包
%elem1 = llvm.extractvalue %packed[0] // 解包
%elem2 = llvm.extractvalue %packed[1] // 解包
// ... 實際計算 ...
%new_packed = llvm.insertvalue %elem1, %new_packed[0] // 重新打包
%new_packed = llvm.insertvalue %elem2, %new_packed[1] // 重新打包
// 下一個模式又會解包 %new_packed ...
使用一對多轉換後,memref 值在整個函數體內部都保持為多個獨立的 SSA 值。降級模式直接對這些值進行操作,完全消除了中間的打包和解包步驟。生成的 IR 簡潔明了,與運行規範化器優化後的效果相當,但避免了額外的編譯開銷。
總結


在本節課中,我們一起學習了 MLIR 中一對多方言轉換框架的核心內容。我們了解了它通過允許一個操作結果被多個 SSA 值替換,從而簡化降級流程、減少冗餘 IR 生成並提升編譯效率的重要性。我們詳細介紹了如何使用新的類型轉換器 API 和 OneToNOpAdaptor,並指出了從舊框架遷移時的關鍵步驟和常見陷阱。最後,通過 memref 降級的實例,我們直觀地看到了新框架所帶來的顯著改進。掌握這一框架將有助於你編寫更高效、更簡潔的 MLIR 降級通道。
036:使用OpenVADL自动生成LLVM编译器后端


在本教程中,我们将学习如何使用名为OpenVADL的工具自动生成LLVM编译器后端。我们将了解VADL语言的基本元素,探讨编译器生成器中最核心的指令选择器生成原理,并分析当前实现的性能与未来的工作方向。
概述
OpenVADL代表维也纳架构描述语言,由维也纳技术大学开发。其目标是拥有一份规范,并自动生成模拟器、硬件描述语言和编译器。本课程重点介绍编译器部分。
语言基础
上一节我们介绍了OpenVADL的目标,本节中我们来看看VADL语言本身。我们将以RISC-V 32位版本为例,展示几个核心语言元素。
初始声明如下所示:
register_file GPR[32] constraint (GPR[0] == 0);
program_counter PC;
memory MEM;
这里定义了一个寄存器文件,并带有约束:地址为0的寄存器(Rega[0])值必须为0。此外还定义了程序计数器PC和内存MEM。
可以定义指令格式,例如R型和I型:
format RType {
bits<7> funct7;
bits<5> rs2;
bits<5> rs1;
bits<3> funct3;
bits<5> rd;
bits<7> opcode;
}
format IType {
bits<12> imm;
bits<5> rs1;
bits<3> funct3;
bits<5> rd;
bits<7> opcode;
}
这些格式包含用于二进制表示的字段,以及称为字段访问函数(field access function)的组件。可以将字段访问函数视为解码函数,可以在指令行为中引用它们,因为它们本身不属于指令行为逻辑。
指令定义
以下是定义指令的方式:
instruction ADD of RType {
behavior {
GPR[rd] = GPR[rs1] + GPR[rs2];
}
encoding {
funct7 = 0b0000000;
funct3 = 0b000;
opcode = 0b0110011;
}
assembly { "add"; }
}
在behavior部分定义了指令行为:读取rs1和rs2字段的值,相加后写入rd。还可以定义编码和汇编打印函数(注意是打印,而非解析)。
以下是定义立即数加法的示例,其中引用了字段访问函数:
instruction ADDI of IType {
behavior {
GPR[rd] = GPR[rs1] + imm;
}
encoding {
funct3 = 0b000;
opcode = 0b0010011;
}
assembly { "addi"; }
}
这里的imm字段可能只存储高位,并在行为中进行移位,这是字段访问函数的典型用例。
以下是从内存加载的示例:
instruction LW of IType {
behavior {
GPR[rd] = MEM[GPR[rs1] + imm];
}
encoding {
funct3 = 0b010;
opcode = 0b0000011;
}
assembly { "lw"; }
}
注意这里使用了之前声明的MEM变量。如果要存储值,则需要将MEM函数调用放在赋值左侧。
最后是跳转指令的示例:
instruction JAL of JType { // 假设已定义JType格式
behavior {
GPR[rd] = PC + 4; // 存储返回地址
PC = PC + imm; // 跳转
}
assembly { "jal"; }
}
这里通过写入PC来改变程序计数器,并将返回地址存入rd寄存器。
指令选择器生成
在考虑本演讲内容时,我认为编译器生成器最有趣的部分是指令选择器的生成。但首先,我们需要讨论中间表示。
我们的中间表示称为VM,基于CDFG(控制数据流图)节点的思想。它是一种图数据结构,同时结合了控制流和数据流。图中白色节点表示控制流,蓝色节点表示数据流,绿色节点是用于字段、字段访问函数和常量的叶节点。
我们想要的是类似以下模式匹配表的东西:
def : Pat<(add GPR:$rs1, GPR:$rs2), (ADD GPR:$rs1, GPR:$rs2)>;
括号的第一部分表示要在程序中匹配的模式,第二部分是要作为机器指令发射的内容。这里我们只关注指令选择器。
如果我们查看程序的数据流,例如查看副作用节点(如写入寄存器文件),并递归向下遍历,我们基本上可以看到匹配模式。我们称之为朴素方法。这种方法对于算术、逻辑和比较指令效果尚可。
但对于无条件跳转、条件跳转和更复杂的指令,效果就不太理想。让我们考虑一个例子,回到之前的JAL(跳转并链接)指令。
在LLVM中,我们可能希望有这样的模式:
def : Pat<(br bb:$dest), (J bb:$dest)>;
但如果我们查看VM表示,情况就变得复杂了。现在我们有两个副作用节点(写入PC和写入返回地址)。朴素方法在这里遇到了问题:我们不知道从哪个节点开始匹配。如果只选择一个,得到的只是无用的匹配。因为LLVM生成的SelectionDAG与指令的原始行为并不相同(例如,它可能包含位掩码操作),存在语义鸿沟。
我们的方法
我们知道自己需要一种分支指令,也了解跳转并链接指令的属性。其属性是:写入PC,并将旧PC值写入寄存器。如果这两个条件都满足,我们可以将这个未知的指令标记为JAL。
但这还不够。对于LLVM的情况,它不喜欢返回地址有输出,因此必须与某些“吸收”指令结合。我们的方法是查看这些吸收指令,因为它们会标记其中的所有机器指令。这样我们就知道了指令的作用。
因为我们知道吸收指令的作用,就知道如何发射这种特殊模式(唯一变化的是指令名称)。让我们总结一下这个过程:
- 你编写了一份规范,包含许多指令及其行为。
- 编译器生成器使用一组静态属性进行遍历。
- 如果找到匹配(如跳转链接情况),我们知道模式并可以直接发射指令。
- 如果没有找到,则检查是否存在“红色标志”。例如,存在多个针对特定寄存器(如
PC)的副作用。如果存在,我们尝试过滤,但若不够好,则无法生成模式。 - 如果没问题,就使用朴素方法。
当然,细节决定成败。朴素方法有时也会失效。例如,考虑一个结合加法和除法的例子:我们想处理除数为0的边缘情况,此时希望存储0值。我们尝试查看这个条件,如果发现它限制为一个值或可能引发异常,我们可以直接“修剪”掉这个条件分支。通过修剪,我们能够再次应用朴素方法。这就是我们尝试生成更好模式的方式。
性能评估
目前,我们用于评估编译器和编译器生成器的基准测试套件仅限于汇编打印。我们的评估方法是:打印汇编代码,使用上游工具进行汇编和链接,然后用Spike模拟器运行二进制文件,统计执行的机器指令数,最后与上游LLVM的结果进行对比。
阅读下图的方式是:平均而言,我们的编译器生成的代码多执行了19.4%的指令。在个别案例中,甚至高达近50%。这个结果目前并不理想。

为什么会出现这种情况?原因之一是我们大量使用了复杂结构。例如,在ISelLowering类中,有一个将地址转换为SelectionDAG值的方法。编译器生成器生成的版本只是发射一条地址指令,而上游的手动优化版本则更早地完成了相同的工作。当我们应用这个优化后,平均开销降至13.6%,那个巨大的异常值也降到了37.5%。
常量序列与未来优化
在规范中,可以指定常量序列。常量序列是一种仅供编译器生成器内部使用的指令,它告诉我们如何实现常量。生成的代码会检查常量是否匹配,然后发射相应的指令。同样,上游有一个优化版本更早地完成这个工作。应用此优化后,平均开销进一步降至9.9%,那个异常值也几乎降至零。
异常值消失的原因是,有一个名为“机器循环归纳代码移动”的优化遍。基准测试使用了大量全局变量,而该优化遍不擅长将它们移出循环,因此生成了大量代码。目前,我们的编译器生成器生成的是蓝色条(优化前)的性能,但我们正努力达到灰色条(优化后)的水平。
未来工作
未来的工作方向包括:
- 性能提升:如前所述,继续优化。
- 支持RISC-V 64:正在开展相关工作。
- 支持更复杂指令集:如AArch64。目前提出的解决方案尚不完善,需要进一步验证。
- 编译器部分:有两个重要的补丁正在进行中。
- ABI相关问题:即使汇编器和链接器补丁完成,与上游的协作仍存在问题。
- 重要功能:浮点数和向量支持目前仍然缺失,这是重要的功能需求。
总结

本节课中,我们一起学习了使用OpenVADL自动生成LLVM编译器后端的基本流程。我们从VADL语言的基础元素讲起,了解了如何定义寄存器、内存和指令格式。我们深入探讨了编译器生成的核心挑战——指令选择器的生成,分析了朴素方法的局限性以及我们采用的基于属性匹配和模式修剪的改进方法。最后,我们评估了当前原型的性能,并展望了包括性能优化、对新指令集架构(如AArch64)的支持以及添加浮点与向量功能在内的未来工作方向。通过本课程,你应该对如何利用架构描述语言自动生成编译器后端有了初步的认识。
037:意外的数据流分析——扩展 RISC-V VL 优化器



概述
在本教程中,我们将探讨如何扩展 LLVM 中的 RISC-V VL 优化器,以支持尾循环折叠特性。我们将从问题背景出发,逐步介绍优化器的设计、遇到的挑战、解决方案,并最终揭示其背后的数据流分析原理。整个过程旨在让初学者理解编译器后端优化的一个实际案例。
动机:启用 RISC-V 尾循环折叠
我们希望为 RISC-V 后端(更准确地说,是在中端)启用 EVL 尾循环折叠特性。尾循环折叠是指将循环向量化器生成的尾部循环合并到主向量化循环体中,从而只生成一个向量化循环。
循环向量化器最初生成两个循环的原因在于,向量指令每次处理固定数量的元素(例如四个,称为向量化因子)。如果循环的迭代次数不能被向量化因子整除,就需要一个标量循环来处理剩余的元素。
尾循环折叠的思想是,像 RISC-V 这样的向量架构支持通过掩码来屏蔽向量指令中不需要的通道。利用这个特性,就可以消除尾部的标量循环,在向量循环中通过掩码处理最后一次迭代中不需要的元素。
RISC-V 的独特之处在于,它有一个专用的标量寄存器,称为向量长度寄存器。这意味着我们不需要计算掩码。对该特性的支持已于去年合入主线,这是一个历时很久、工作量巨大的补丁。目前,该特性需要通过一个标志手动启用,因为其中涉及一些显著的代码生成问题,仍需评估和完善。
我们的目标是找到方法,使其能够默认启用。
评估与问题发现
评估一个特性是否就绪的最佳方法是观察它在真实代码上的运行情况。LLVM 测试套件非常适合这项工作。
我们首先构建并运行测试,发现了错误编译的问题。由于这是新特性引入的错误,无法简单地通过二分查找定位,只能通过对比二进制文件来排查。
我们使用了一个名为 bisect 的脚本工具。它允许我们提供一个能复现错误编译的脚本,并在已知的正确构建和错误构建之间进行二分查找,最终定位到导致错误编译的最小对象文件集。通过这种方法,我们成功修复了错误编译。
修复后,我们进行了性能测试,看到了显著的性能提升。但我们仍需仔细检查生成的代码,确保没有隐藏的性能回归。
发现性能回归与 VP 内部函数
我们使用 t-diff 脚本对比启用和禁用尾循环折叠的构建,发现了一些性能回归。例如,优化器无法匹配一个扩展乘法指令。
通过检查 LLVM IR,我们发现差异在于:启用了尾循环折叠的循环向量化器会发射一个带有许多复杂参数的 llvm.vp.* 内部函数调用,而不是原来的乘法指令。这些是向量谓词内部函数,循环向量化器使用它们来控制尾循环折叠和其他循环折叠中的掩码和谓词。
这些内部函数的问题是,它们会阻碍许多优化和指令组合。后端需要为这些 VP 内部函数提供正确的模式匹配,这正是问题的根源:RISC-V 后端没有为这个扩展乘法在 VP 内部函数级别提供模式。
循环向量化器必须为可能陷入的指令(如加载和存储)发射 VP 内部函数,以确保不会在屏蔽的通道上执行。但对于不会陷入的指令,为什么也需要呢?原因在于,在某些 RISC-V 微架构上,即使对于不会陷入的指令,减少 VL 也能带来优化,减少执行周期。
RISC-V VL 优化器的引入
Michael Milan 在 SciFi 也在研究 EVL 尾循环折叠,但他遇到了另一个问题:没有用于 getelementptr 指令的 VP 内部函数。这意味着对于任何向量化的聚集加载循环,我们都在不必要地计算地址,没有减少这些通道的 VL,做了额外的工作。
为了解决这个问题,他向上游提交了 RISC-V VL 优化器补丁。这个优化器在 RISC-V 后端运行,其工作方式非常优雅:它从基本块的底部开始向上遍历(类似于逆后序),对于每条指令,查看其使用者,然后取所有使用者所要求的最大 VL,并尽可能地减少该指令的 VL。这个过程会重复进行,VL 减少的效果会向上“冒泡”,从而能够优化整个计算树。
这个算法的关键在于它在机器指令级别工作。虽然它减少 VL 的方式与 VP 内部函数类似,但因为它作用于 MIR 级别,所以能够同时优化常规的 LLVM IR 指令和 VP 内部函数,因为它们最终都会被降低为相同的机器指令。
这意味着,在这个优化器生效后,循环向量化器不再需要发射 VP 内部函数,只需发射常规的 LLVM IR 指令即可。我们同时获得了良好的代码生成和减少的 VL,几乎是无成本的。
扩展 VL 优化器:处理 Phi 节点
我们再次考虑默认启用尾循环折叠,并检查回归。这次发现的问题更多是错失优化机会,而非性能倒退。VL 优化器在大多数情况下表现良好,但在某些涉及 Phi 节点的场景中无法优化。
例如,一个 Phi 节点的使用者是 A 和 B 两个值,而它们最终被一个 VL 为 2 的存储指令使用。理论上,我们应该能将 A 和 B 的 VL 减少到 2。但存储指令和加法指令是 RISC-V 特定的机器指令,带有 VL 操作数;而 Phi 指令是通用的机器指令,没有 VL 操作数。当 VL 优化器遇到 Phi 指令时,就无法继续向上传播 VL 减少的信息。
为了解决这个问题,我们首先将优化器拆分为两个部分:
- 创建一个映射,将指令映射到它们所需求的 VL。
- 先进行分析阶段,计算每条指令从其使用者那里所需的最大 VL。
- 在单独的阶段,根据映射实际进行指令修改。
这样,当我们遇到 Phi 节点时,就能在需求映射中减少其需求 VL,并将这个信息传播到其操作数 A 和 B,从而在后续的修改阶段完成优化。
扩展 VL 优化器:处理循环依赖
我们遇到的第二个例子是循环依赖:一个 Phi 节点使用了定义在其下方的值 B。显然,之前那种自底向上的单遍扫描方法无法处理这种情况。
我们切换为使用工作列表算法:
- 将指令加入工作列表。
- 在循环中弹出指令,处理它,然后将其输入指令重新加入工作列表。
- 关键在于,只有当指令的需求 VL 实际发生变化时,才将其上游指令加入工作列表,以确保算法能收敛到不动点,避免无限循环。
然而,即使这样,问题仍未解决。因为 Phi 节点的需求 VL 初始值被设为最大值,而它又是 B 的一个使用者,这阻止了 B 的 VL 被减少。
我们需要修正初始需求映射过于保守的问题。现在,我们不再将每条指令的初始需求 VL 设为其 VL 操作数的值,而是假设初始时没有任何需求。这显然不完全正确,但我们可以先在此基础上运行。
算法变为:每条指令计算自身需要什么,然后将其需求传播给它的输入。例如,存储指令会声明它需要 VL=2,并将其传播给输入 B,B 再传播给 Phi 节点。由于算法是单调的,最终会达到一个稳定的不动点并终止。
理论洞察:数据流分析
至此,我们意识到这个问题具有编译器理论中数据流分析的特征。我们退一步审视已有的设计:
- 状态:一个将指令映射到需求 VL 的“需求映射”。
- 初始状态:所有指令的需求 VL 初始化为 0(“底部”)。
- 转换函数:在循环中,根据指令使用者的需求,更新指令自身及其输入的需求 VL。
我们意识到,这本质上就是一个数据流分析。我们定义了转换函数和“底部”元素。这是一个稀疏的数据流分析,因为我们不在基本块边界或程序每个点计算状态,只沿使用链传播。同时,它是一个乐观的、前向的分析,因为它从“无需求”的乐观假设开始,必须运行到完成才能保证正确性。
状态形成了一个半格。分析从底部开始,随着处理指令和发现需求,状态沿着格向上移动,直到最坏情况下所有需求都被满足(“顶部”)。格中的偏序关系定义为:如果一个状态中所有定义的需求 VL 都大于等于另一个状态,则前者序高于后者。
算法终止性证明
利用这个理论框架,我们可以回答一个关键问题:这个工作列表算法是否会终止?
我们可以用定义的半格来证明。分析总是从底部(无需求)开始。黄色的转换函数部分,每次读取指令并传播需求 VL 时,我们都在沿着格向上移动。关键在于,我们只会向上移动,永远不会向下。因此,我们不可能进入循环。算法要么达到一个稳定状态(不动点)并终止,要么一直上升到顶部(所有需求都被满足)并终止。
更形式化的推理:
- 格是有限的:因为从底部到顶部的最长链长度,由程序中定义的数量和 VL 操作数可能取值的唯一数量决定,这两者都是有限的。
- 转换函数是单调的:根据定义,新的需求 VL 是旧值与其使用者需求最大值的最大值,因此应用转换函数永远不会减少需求 VL。
因此,该算法保证终止。
总结与未来工作
本节课我们一起学习了如何扩展 RISC-V VL 优化器以支持尾循环折叠。我们从实际问题出发,经历了发现问题、引入优化器、处理 Phi 节点和循环依赖的挑战,最终将解决方案抽象为数据流分析理论,并证明了算法的正确性。
目前,仍有一些补丁需要合入上游,并进行完整的评估。一个有趣的未来工作是形式化验证。值得一提的是,在本次演讲当天,Saar Baten 已经使用 Lean 定理证明器为这个算法完成了机械化验证,证明了其终止性。这确保了 RISC-V VL 优化器永远不会导致无限循环。


038:更快的编译——LLVM 20及未来的改进


在本教程中,我们将探讨如何提升LLVM后端的编译速度。我们将回顾LLVM 20中实现的性能改进,分析其背后的原理,并展望未来的优化方向。内容将涵盖哈希表优化、内存分配、指令选择、寄存器分配等多个方面,旨在帮助初学者理解编译器后端性能优化的核心思路。
为什么需要关注编译速度?
编译速度主要影响两类用户。第一类用户是即时编译器。例如,某些数据库和WebAssembly运行时会使用LLVM作为其优化编译器框架。当系统已经有一个编译器后端时,将其同时用作基线编译器是合理的,这样可以避免维护独立的基线编译器后端。
第二类用户是开发者。更快的编译速度意味着更快的开发、测试和启动周期,从而提升开发效率。这对于关注编译错误或测试结果反馈延迟的开发者体验至关重要。需要注意的是,开发者体验也涉及编译器前端,但本教程将主要聚焦于后端。
性能优化的一般性原则
上一节我们介绍了关注编译速度的原因,本节中我们来看看进行性能优化时的一些通用准则。
哈希表的优化
哈希表虽然具有O(1)的平均时间复杂度,但常数因子不容忽视。频繁访问哈希表会带来显著的运行时开销。因此,应尽可能避免使用哈希表。
在LLVM中,一种常见的哈希表类型是从指针映射到其他值。对于这种映射,CPU有一个非常高效的内置特性:解引用指针。一个优化实例是SelectionDAG的Combiner。它管理着一个待合并节点的工作列表,并使用一个哈希表将节点映射到工作列表中的索引,以便删除或修改列表项。这个哈希表被频繁使用。通过将工作列表索引直接存储在节点本身,而不是使用哈希表,我们获得了约3%的端到端编译速度提升。
当然,并非所有情况都能这样优化。例如,当需要将LLVM基本块映射到其他值时,并不总是能在基本块中添加额外字段并让所有用户承担开销。对于这种情况,更好的替代方案是使用密集编号和数组。这催生了“块编号”的引入,它显著提升了支配树计算的性能,该计算之前被哈希表查找所主导。
另一个优化点是避免在同一个或不同哈希表中对同一键进行冗余查找。例如,在MC汇编器中,创建一个符号时,它曾被插入到四个不同的哈希表中。我们成功将需要插入的哈希表数量减少到一个,这也带来了显著的性能影响。
需要说明的是,LLVM使用的DenseMap和StringMap数据结构本身是相当高效的。但哈希表固有的开销是难以完全消除的。
内存分配的考量
内存分配是有成本的,尤其是大量的小内存分配。其开销取决于分配器,例如mimalloc可能更快,而像malloc这样被广泛使用的分配器性能则稍差。因此,减少内存分配次数通常是个好主意。
一种方法是对于内存使用单调递增的情况使用指针碰撞分配器。这使得分配更廉价,并改善了空间局部性。我们在MC汇编器的MCFragment中应用了此方法。MCFragment是机器代码或数据的片段,其大小可以是固定的或可增长的。但这种方法可能导致内存使用总量增加,因此并非总是最佳选择。
未来,拥有一个支持指针碰撞分配器后备存储的小向量将非常有用。这对于MCFragment尤其相关,因为MCFragment为其内容和需要应用的“fixups”预留了小的内联存储空间。这里存在一个权衡:过大的内联缓冲区常常被浪费,而频繁使用外联存储则开销较大。一个具有更高效外联存储的小向量可能是一个好主意。
其他次要考量
间接调用和虚函数调用有一定开销,特别是当这些虚函数默认什么都不做时。在这种情况下,应尽量避免此类调用,尤其是在调用非常频繁时。例如,在LLVM MC层,每条编码指令会进行半打虚函数调用。如果其中一些调用是无操作的,避免这些调用也能带来可观的性能提升。
raw_svector_ostream是另一个问题案例,目前每次写入都经过慢速路径,即一个虚函数调用。我们尝试让慢速路径更快一些,但这仍然不够理想。理想情况下,我们能够使用小向量本身作为raw_svector_ostream快速路径及其缓冲逻辑的缓冲区。不幸的是,这实现起来并不容易,因为有一些用户依赖raw_svector_ostream保持小向量即时更新。这可能需要添加一个类似buffered_ostream的新类型。
计时器即使被禁用也相当昂贵。拥有太多计时器并不总是好事。仅仅检查是否需要计时也不是零开销操作。
LLVM后端各阶段性能分析
上一节我们介绍了一些通用的性能优化原则,本节中我们将具体分析LLVM后端各个阶段的性能表现和改进。
下图展示了LLVM 18在X86和AArch64架构上后端各阶段的大致耗时分布,我们将在后续详细查看各个阶段。对于LLVM 20,我们看到在X86上几乎所有部分都有改进,最终在CTMark基准测试上实现了总计18%的提速。
最大的改进来自X86的汇编打印器和寄存器分配器。在AArch64上,指令选择器稍慢一些,因为全局指令选择在O0优化级别是默认开启的。我们在汇编打印器和指令选择器上也看到了显著的改进。
寄存器分配器的更改主要影响X86和其他一些平台。具体来说,一些目标特定的过程变慢了一些。这是在LLVM 18时期的情况。
IR处理阶段
在开始阶段,有15到20个遍处理与降低内部函数和一些复杂操作相关的工作。但观察发现,对于许多函数,尤其是在O0优化级别,它们并没有这些内部函数或复杂操作,但这些遍仍然会处理它们,即它们什么都不做。
我们开始通过将其中一些遍合并到大的预指令选择内部函数降低遍中来修复这个问题,以避免过于频繁地迭代LLVM IR,因为迭代本身不是零开销操作。我认为进一步合并这些遍是一个好的方向,最终可能只保留一个或两个预指令选择合法化遍,例如一个用于内部函数,另一个用于复杂操作。
在这些遍之后,后端主要操作机器IR。机器IR是一个非常功能丰富的中间表示,可以为我们支持的各种目标架构呈现机器代码。
机器IR的效率问题
机器IR本身在修改方面并不十分高效。性能分析中经常出现的一个问题是MachineInstr::addOperand。
机器指令操作数是有序的:首先是显式操作数,然后是隐式操作数;首先是定义操作数,然后是使用操作数。addOperand会迭代或查找正确的位置来插入操作数,并查找一些标志位,这相当耗时。曾考虑添加一个不检查或快速添加操作数的方法,以避免一些搜索和标志更改操作,但目前还没有人完成这项工作。
另一个问题是管理寄存器的使用-定义列表也不是零开销的,但LLVM IR也存在类似的考量。我认为这不会改变,因为我们可能希望保持急切更新的使用-定义列表。
此外,机器IR支持为特定且很少使用的功能内联或外联存储额外信息,但检查某些额外信息是否可用及其位置经常会导致一些分支预测失败,我认为这可以相对容易地避免。
指令选择器
我们如何得到机器IR?这正是指令选择器的工作。目前LLVM中有三种指令选择器。FastISel可能是我们拥有的最快的指令选择器,因为它在一个步骤中完成所有工作。而SelectionDAG和较新的GlobalISel则是增量式地降低IR,直到得到机器IR。
总的来说,有两个观察结果是开销较大且难以避免的。第一是调用约定降低,这在性能分析中经常出现。但由于需要考虑各种属性和ABI细节,我认为那里没有太大的改进空间。
第二是SelectionDAG回退对于FastISel来说仍然且将继续是昂贵的。在这一点上,可能建议前端改变生成LLVM IR的方式,生成对FastISel友好的IR。我们可以启用优化备注来找出FastISel在何处回退到SelectionDAG。
GlobalISel的改进
GlobalISel在最近两个版本中有了相当大的改进。它采用多遍方法进行翻译、合法化和指令选择,中间运行组合器进行一些优化。
第一个观察是,定点迭代通常并不真正有益,尤其是在O0级别。因此添加了一个可选标志,只执行单遍全局指令组合,而不是定点迭代。这与早期对SelectionDAG组合器的类似更改一致,当时也放弃了定点迭代。
另一个观察是,死代码消除并不廉价,也不总是必需的。合法化器已经执行了死代码消除,因此组合器不需要再次执行。
另一个更改是让合法化器生成更少的糟糕IR,例如针对像i1算术这样不合法的操作。使用非位操作可以避免生成此类伪指令。尽管非位操作并不完全廉价,但它总是有益的。即使在O0级别,它也使用减少的递归深度。
尽管有这些明显的改进,GlobalISel仍然比FastISel慢47%。曾有过添加一个更快的、直接生成目标机器IR的翻译器的想法,但这需要更多的工作。虽然这会是有益的,但我不认为它会在未来几个版本中出现。
寄存器分配器
寄存器分配器相当复杂。我认为主要的启示是,针对常见情况的快速路径很重要,同样,使用快速数据结构也很重要。例如,通过直接使用向量来拓宽干涉位图的内部表示。
我认为在处理寄存器单元的方式上也有改进的空间。寄存器单元存储为由TableGen生成的密集编号,当迭代一个寄存器的寄存器单元时,这会导致很多数据依赖。我测量过,并怀疑这些依赖也会导致一些性能开销。
目标特定遍
同样,这里有一个类似的观察:许多这些遍在典型的O0输入上什么都不做。
一个更改是,O0编译X86不再需要支配树。我们更改了使用它的遍,使其首先检测是否真的需要支配树,然后按需计算它。我希望随着新遍管理器的引入,实现此类更改会变得更容易。
此外,针对某些ISA特性(如MMX)的特定遍,如果这些特性未被使用,应该什么都不做。对于AMX,我们在指令选择和降低期间跟踪AMX指令是否实际出现。如果它们没有出现,我们就在这些遍中添加提前退出,以便在非常常见的、未使用该ISA扩展的情况下,它们什么都不做。
汇编打印与MC层
上一节我们分析了后端核心阶段的优化,本节我们来看看编译的最后一步:汇编打印和MC层。
汇编打印器和MC层将机器IR首先降低到MCInst,然后将其编码到目标文件中。它非常灵活且高度可定制,支持各种目标文件格式,为各种功能钩入指令,内置了完整的汇编器。为了这种灵活性,其大部分功能都围绕虚函数调用构建。但也很明显,它的设计初衷是功能,而非性能。
在最近的版本中,Fwisson做了大量工作来重构MC以提高性能。其中一些更改与减少虚函数调用或减少数据和指令被复制的次数有关。但我认为,当聚焦于常见路径时,仍然存在优化潜力。我认为在典型的O0输入不需要的路径上仍然花费了大量时间。
关于LLVM后端性能的总体思考
我认为LLVM有一个根本性的性能问题,不幸的是,这也是它最大的优势:即增量的IR重写方法。这对可组合性非常有利,但也非常昂贵。
另一个问题是,编译时间通常不是首要关注点。其他关注点,例如生成代码的质量、生成代码的大小、可维护性、内存使用等也同样重要。因此总是需要做出权衡。
另一个考量是关于前端的。前端有时会生成相当糟糕的IR。随着后端变得更快,我们也发现Clang在C++编译时间中的占比越来越大,并且Clang随着时间的推移有变慢的趋势,消耗了我们实现的一些性能改进。我认为Clang确实需要一些优化。
关于后端,我认为一个独立的、专注于常见情况的O0后端,正如我们一直在努力并希望很快开源的那个,其速度可能是LLVM O0后端的10倍。与试图从LLVM O0后端再挤出10%或15%的性能相比,投资于这样的后端将是时间上更明智的投资。
总结
在本教程中,我们一起学习了LLVM后端编译速度优化的多方面知识。

我们了解到,LLVM后端性能在过去一年中得到了显著改善,许多小的改进累积起来效果显著。优化常见路径非常重要。但我不认为LLVM会有根本性的改变或2倍的性能提升即将到来。
最后,感谢所有为这些努力做出贡献的人以及花费时间和精力进行代码审查的评审者们。

问答环节
问: 你提到前端时间主导了编译时间。你是指从解析到IR生成的整个前端,还是仅仅指IR生成很慢?根据我的记忆,IR生成并不是最大的部分,最大的部分确实是解析和语义分析。对于C++,模板实例化通常也是个问题。
答: 对于C++,解析和到达可以发出LLVM IR的阶段确实是主导时间。在我们的数据库中,仅仅发出LLVM IR就花费了大约5%到10%的编译时间。但对于Clang,我认为解析和到达可以发出LLVM IR的阶段确实是主导时间。
问: 你列出的许多改进与O0关系不大。你是否有关于它们如何影响,比如说,O2编译时间的数据?
答: 在X86上,O2编译时间改进了9%。在AArch64上,没有改进。
问: 你是否考虑过为指令选择或类似功能设置持久化缓存?
答: 我们没有在缓存方面工作。我知道有其他人在研究缓存方案,但我不清楚其中是否有任何方案目前已经可以使用或有明确的上游路径。我对此不了解。
问: 你提到计时器即使不活动也可能非常昂贵,但我觉得编译时间的一个大问题是程序员无法了解时间花在了哪里。我发现像timetrace这样的工具真的很有帮助。如果计时器很贵,你对于如何为程序员提供更多关于编译器正在做什么的可见性有什么想法,以便他们能参与到优化自己构建的过程中?
答: 我认为问题是如果你以过于细的粒度放置计时器,例如每条指令一个计时器,这些计时器也并没有真正的帮助,因为此时测量开销也会使你的结果价值降低。我认为对于像遍或分析这样的更高粒度,拥有计时器是很好的,并且TimePasses的开销也相当低,大约只有2%到3%。对于其他情况,我结合使用带帧指针的LLVM和分析工具取得了一些成功。这通常是我处理那些在计时器中不显示的问题的方法。
问: 非常有趣的演讲。你提到前端占主导地位,这也符合我们的经验。我好奇你在前端方面认为最重要的两个热点是什么?你有什么想法吗?
答: 我认为我从未向Clang提交过代码,并且我对代码库并不十分熟悉。直观地说,我认为AST并不像它应有的那样好,而且我认为它是一个相当昂贵的数据结构。我认为至少对于C语言,有可能写出更快的、拥有更快方法的编译器。对于C++,我不知道,我从未写过自己的C++编译器。
问: 你是否考虑过让每个函数在自己的线程中进行代码生成?
答: 在LLVM中实现并行性非常困难,因为所有东西基本上都塞在同一个上下文中。我们考虑过一点。但正如我简要提到的,我们选择编写自己的LLVM后端,它处理常见情况的速度要快10到20倍。如果快20倍,那么我认为并行性就不那么重要了,尤其是在翻译单元级别仍然有并行性的情况下。对于即时编译,可能无关紧要,因为可能只有一个函数。对于大型项目,像make这样的工具并行运行任务,这也不重要。也许有一个最佳点,但我没有做过任何分析来指出在哪个点影响编译器会是一个好主意。


039:MLIR 中张量分块简介


概述
在本教程中,我们将学习如何在 MLIR 中为张量操作实现分块和融合。我们将从一个现有的、使用 Transform Dialect 构建的编译流水线开始,理解其核心构建模块。然后,我们将利用这些模块,构建一个仅用约 400 行代码的、能够处理任意卷积融合的简单编译器。最后,我们会探讨如何扩展这个编译器以支持新的操作,并简要介绍一些更高级的构建模块。
第一部分:现有分块流水线分析
首先,我们来看一个由他人构建的现有分块流水线,理解他们使用了哪些构建模块。
上一节我们介绍了本教程的目标,本节中我们来看看一个具体的例子。Alex 在两年前做了一个关于 Transform Dialect 的精彩演讲。在他的演讲末尾,他展示了如何利用 Transform Dialect 和 MLIR 操作构建一个调度器,该调度器能够击败 cuDNN 的卷积实现。这似乎是展示上游 MLIR 已有可用工具的绝佳案例。
我们将从一个简单的计算图开始:一个卷积操作,后面跟着一个 ReLU 激活函数,以及一个偏置的广播加法。如果直接降低这个图,其循环结构会先进行广播,然后是卷积,最后是 ReLU,没有进行任何融合。为了获得良好的性能,我们需要进行融合,目标循环结构应该在一个循环内完成所有计算,使用合适的瓦片大小,避免中间缓冲区,并尽量使用寄存器。
Transform Dialect 简介
Transform Dialect 是一种元 IR,允许你指定如何转换你的操作。例如,你可以指定“使用瓦片大小 [1, 1, 5, 64] 通过 forall 循环来分块这个卷积操作”,这个转换就会被应用到 IR 上。
以下是该教程中使用的调度步骤:
- 并行维度分块:使用
forall循环对并行维度进行分块。 - 融合到循环:使用
fuse_into_containing_op将其他操作融合到这个循环中。 - 归约维度分块:对卷积的归约维度进行分块。
- 向量化:将循环内的计算转换为向量操作。
- 缓冲区化:将张量转换为内存缓冲区。
通过这个流程,我们得到了期望的融合循环结构。这个流水线在性能上可以超越 cuDNN 约 10%。
核心观察
从这个例子中,我们得到的关键观察是:
- 真正可变的部分是分块和融合策略,即如何决定代码的循环结构。
- 其他部分(如向量化、缓冲区化)基本上是固定的,可以直接应用。
因此,本教程将主要关注分块和融合部分。实现分块和融合主要依赖于三个核心方法:
tile_using_scf:使用 SCF 方言(结构化控制流)对操作进行分块。producer_fusion:将生产者操作(通过extract_slice)融合到循环中。consumer_fusion:将消费者操作(通过insert_slice或forall的yield)融合到循环中。
第二部分:构建自己的分块编译器
理解了核心构建模块后,我们现在尝试用它们构建自己的编译器。
上一节我们分析了现有流水线,本节我们将动手构建一个简单的编译器。目标是复制之前看到的卷积融合功能,但将其实现为一个通用的、可处理任意融合的 Pass 流水线。
分块实现
分块的核心是 tile_using_scf 方法。我们可以创建一个 TilingOptions 结构体,设置好瓦片大小,然后对目标操作调用此方法。它会自动生成分块后的计算。
TilingOptions tilingOptions;
tilingOptions.setTileSizes({tileSizeX, tileSizeY, ...});
FailureOr<TilingResult> tilingResult = tileUsingSCF(rewriter, targetOp, tilingOptions);
融合策略
融合策略可以设计得很简单:贪心融合。我们维护一个工作列表,不断尝试将操作融合到已分块的循环中。
以下是融合算法的核心逻辑:
- 初始化工作列表,包含最初分块操作产生的
extract_slice和insert_slice操作。 - 循环处理工作列表:
- 如果遇到
extract_slice,尝试进行生产者融合 (producer_fusion)。 - 如果遇到
insert_slice或parallel_insert_slice,尝试进行消费者融合 (consumer_fusion)。
- 如果遇到
- 新生成的分片操作会自动加入工作列表。
为了跟踪新生成的操作,我们可以使用 MLIR 的 Listener 机制。每当有新的操作被插入到 IR 中时,监听器会将其加入到我们的工作队列。
指定瓦片大小
一个简单的方法是为操作附加一个可丢弃的属性(discardable attribute),例如 lowering_config,在其中指定并行和归约的瓦片大小。在分块时,查询这个属性即可。
// 示例:为操作设置瓦片大小属性
op->setAttr("lowering_config", ...);
完整的编译流水线
结合以上部分,我们可以构建一个两阶段的编译流水线:
- 并行层分块 Pass:对并行维度进行分块。
- 归约层分块 Pass:对归约维度进行分块,并运行贪心融合算法。
之后,可以附加固定的后续处理:
- 向量化 Pass
- 缓冲区化 Pass
这个流水线可以自动处理类似“卷积 + 偏置广播 + ReLU + 其他逐元素操作”的融合,并且性能优异。
处理特殊情况
为了让编译器更健壮,我们可以增加一个机制:如果函数上附加了特定的属性(如 use_transform_spec),则直接运行对应的 Transform Dialect 脚本,否则使用我们默认的贪心融合流水线。
第三部分:扩展编译器以支持新操作
现在我们已经有了一个可工作的编译器,但如何让它支持新的张量操作呢?
上一节我们构建了一个基础编译器,本节我们来看看如何扩展它。关键在于实现 MLIR 的 TilingInterface。
假设我们定义了一个新的操作 tutorial.dequant(一个简化的反量化操作)。编译器目前不知道如何分块或融合它。我们需要教编译器,方法就是为这个操作实现 TilingInterface。
TilingInterface 关键方法
要实现分块和融合,主要需要实现以下方法:
1. 获取迭代域 (getIterationDomain)
这个方法返回包围该操作的循环边界。对于一个逐元素操作,迭代域就是输入张量的每个维度从 0 到对应大小。
2. 获取循环迭代类型 (getLoopIteratorTypes)
这个方法指明每个循环是并行的还是归约的。对于逐元素操作,所有维度都是并行的。
3. 获取分块实现 (getTiledImplementation)
这是核心方法。给定一个“瓦片”的偏移量和大小(即要计算输出张量的哪一部分),生成计算这个瓦片的代码。对于逐元素操作,只需提取输入张量的对应瓦片,克隆原操作,并计算即可。
4. 获取结果瓦片位置 (getResultTilePosition)
给定输入瓦片的位置,告诉我输出的结果瓦片在结果张量中的位置。对于逐元素操作,输入和输出位置相同。

5. 生产者融合相关方法 (如 getIterationDomainTileFromResultTile)
这些方法用于实现生产者融合。例如,getIterationDomainTileFromResultTile 告诉我们在已知输出瓦片位置时,需要读取输入张量的哪个瓦片。
6. 消费者融合相关方法 (如 getIterationDomainTileFromOperandTile)
这些是生产者融合的逆过程,用于消费者融合。
对于大多数逐元素操作,这些方法的实现都非常直接。一旦你的操作实现了这些接口(通常主要是前4个),它就能无缝接入我们之前构建的贪心融合编译器流水线。
通过这种方式,你可以不断向编译器中添加新的操作(如新的激活函数、新的规约操作等),而无需重写整个分块和融合逻辑。
第四部分:高级构建模块与总结
最后,我们简要探讨一些更高级的构建模块,它们可以将简单的分块编译器提升到新的水平。
分布与映射
scf.forall 循环不仅用于分块,还可用于分布。你可以为其设置 mapping 属性,将循环迭代映射到 GPU 的线程块(block)或线程束(warp)上。通过这种方式,你可以在分块的同时完成计算在加速器硬件上的分布,从而构建 GPU 编译器。
高级归约分块策略
对归约操作进行分块时,有多种策略:
- 普通归约:在循环内累加。
- Split-K (部分归约-外层并行):将归约维度拆分,各部分并行计算部分和,最后合并。这对矩阵乘法等操作非常有用。
- GPU 风格归约:采用交错步长加法等优化模式。
MLIR 的 TilingOptions 允许你设置 reductionTilingStrategy 来选择不同的归约分块策略。
总结
在本教程中,我们一起学习了:
- 分析现有工具:我们首先剖析了一个使用 Transform Dialect 构建的高性能分块融合流水线。
- 理解核心模块:我们识别出三个核心方法 (
tile_using_scf,producer_fusion,consumer_fusion) 是构建分块编译器的基石。 - 动手构建编译器:利用这些模块,我们构建了一个约 400 行代码的贪心融合编译器,能够自动处理复杂的操作融合。
- 扩展编译器:我们学习了如何通过实现
TilingInterface来让编译器支持新的张量操作,使其易于扩展。 - 展望高级功能:我们简要介绍了利用
scf.forall进行分布以及高级归约分块策略,展示了这些基础概念的强大扩展能力。
总而言之,MLIR 提供了一套强大而灵活的抽象和接口,使得构建高性能、可扩展的张量编译器不再是遥不可及的任务。希望本教程为你开启了探索 MLIR 编译技术的大门。


040:优化FDTD求解器


在本教程中,我们将学习如何利用MLIR编译器框架,通过高级张量抽象来优化用于电磁学仿真的时域有限差分求解器。我们将从算法背景开始,逐步深入到具体的编译器优化方法,并最终评估其性能。
背景:FDTD算法简介 🧮
时域有限差分方法是求解麦克斯韦方程组最常用的数值仿真方法之一。其核心是以下两个旋度方程,分别源于法拉第定律和安培定律:
∂H/∂t = -1/μ ∇ × E
∂E/∂t = 1/ε ∇ × H
在三维笛卡尔坐标系中,电场 E 和磁场 H 各有三个方向的分量,因此实际上需要处理六个方程。
FDTD算法对计算和内存访问要求很高。为了获得精确的电磁仿真,需要非常高的空间和时间分辨率,这意味着需要处理大规模输入数据并进行大量迭代。该问题本质上是内存瓶颈问题,因为每次迭代都需要更新网格中的每个点,而计算本身相对并不复杂。
传统的FDTD实现通常基于手写、针对特定平台和应用的代码。它们可能为不同的硬件和场景编写不同的内核,这导致代码无法跨硬件移植,并且在某些未优化的情况下性能不佳。
方法论:端到端编译器框架 ⚙️
我们的工作旨在为FDTD程序构建一个端到端的编译器。整体框架概述如下:
输入部分,我们使用MLIR的Python绑定直接生成FDTD算法的MLIR表示。对于所需的算子,我们在线性代数方言中扩展了FDTD特定的操作。
利用张量表示,我们应用了算子融合和平铺等变换。之后,我们实现了原地缓冲化,并针对不同硬件设置了不同的代码生成流水线:为X86 CPU使用固定大小的向量化,为ARM SVE扩展使用可扩展向量化。最终,我们使用LLVM进行代码生成。
以下是我们的应用简化后的Python版本。左边是不同的函数,用于计算如何更新H场分量、边界条件以及如何更新电场。
基本上,H和E的主要内核处理3D输入,而边界条件处理类似2D的输入。右边是一个内核的朴素Python实现示例。
我们可以看到,计算不同网格点时是相互独立的,没有数据依赖,因此我们可以将计算张量化。此外,不同分量的计算会读取相同的输入缓冲区,这为使用算子融合或循环融合以实现更好的数据复用提供了潜力。
实现:张量抽象与特定操作 🔧
我们在张量抽象中的一个实现是扩展了线性代数方言,添加了特定的curl_step操作。该操作同时用于H场和E场的更新,但使用不同的张量和配置。它使用线性代数DSL实现。
代码采用单赋值静态形式。与操作缓冲区的Python代码不同,如果我们想更新张量,需要生成一个全新的张量。在后续的缓冲化过程中,我们会设法消除这些冗余的张量。
优化:变换与代码生成 🚀
以下是我们优化中使用的简化变换IR代码。我们匹配特定的curl_step操作,然后执行平铺、循环融合和向量化。
首先是平铺。在平铺之前,我们有一些自定义的curl_step操作。平铺后,会生成一个外部的全并行循环,内部在更小的数据输入上执行操作,最内层维度被设置为向量化宽度。
对于向量化,我们有两个流水线:一个用于X86的固定大小向量化;另一个用于ARM平台。固定大小向量化在ARM上也能工作,但只会生成NEON指令而非SVE指令。SVE指令有更好的潜力,因此我们尝试了不同的流水线以启用可扩展向量化。
右边是向量化后的虚拟向量代码示例,在后续降低到特定X86或ARM代码时会具体化。
性能评估与总结 📊
我们将优化后的FDTD应用与初始展示的NumPy实现进行了性能对比。我们测试了从64到1024的不同输入数据大小。
我们在左侧比较了单精度和双精度数据下的性能,所有性能都以相对于归一化的NumPy实现的加速比来展示。
总体而言,单精度实现通常比双精度实现快约两倍。我们的实现相比基线有最高达10倍的加速。我们在Intel平台上测试生成了AVX-512代码,在AMD平台上生成了AVX2/256代码,两者都表现良好。
我们也在ARM A64 FX平台上进行了测试。由于我们的应用会使用大量内存,而所用的ARM系统内存有限,因此最大输入尺寸为512。在ARM上有一个有趣的现象:单精度相比双精度的加速并非总是两倍。我们发现,在ARM代码生成流水线中,虽然大部分代码已向量化并生成了SVE代码,但仍有一小部分未向量化。对于这些标量代码部分,单精度不会带来两倍的加速。
右边的图表展示了不同优化组合的效果,以显示我们所应用的各种优化的影响。底部是没有任何前述MLIR优化、但包含一些LLVM底层优化的代码。上方是经过平铺、向量化和循环融合后的代码。可以看到,大部分性能提升来自于向量化。对于循环融合,在小尺寸下帮助不大,但在大尺寸下效果更好,这也是预期的,因为小尺寸数据可以完全容纳在缓存中。
我们还对Intel平台上NumPy实现和我们的MLIR实现进行了性能计数器分析。所有计数器的总和已归一化到NumPy为1倍。
分析显示,在平铺后,L1缓存加载和末级缓存访问显著减少。融合后,L1缓存未命中率也大幅下降。此外,我们发现NumPy实现的汇编热点中会生成一些非常昂贵的向量插入和提取操作,而在我们的实现中没有这样的热点,我们使用了连续的向量加载和存储操作。
总结
本节课中我们一起学习了如何为FDTD内核开发高级张量抽象,并在此抽象上应用平铺和融合等变换。我们实现了硬件特定并行性的自动提取,为Intel、AMD和ARM CPU启用了向量化和架构感知的代码生成。最后,我们对比了优化实现与NumPy基线的性能,并进行了分析,获得了显著的加速。
核心概念与公式/代码摘要:

- FDTD核心方程:
∂H/∂t = -1/μ ∇ × E,∂E/∂t = 1/ε ∇ × H - 张量化计算:将独立网格点计算转换为张量操作。
- 编译器优化流水线:MLIR IR -> 算子融合/平铺 -> 缓冲化 -> 硬件特定向量化 -> LLVM代码生成。
- 关键MLIR变换:匹配
curl_step操作,进行平铺和循环融合。 - 性能评估:对比基线(NumPy),展示不同优化阶段(向量化、融合)带来的加速比和缓存行为改善。
042:提升 RISC-V LLVM 测试的经验教训 🚀

概述
在本节课中,我们将学习如何为 RISC-V 架构改进 LLVM 的持续集成(CI)流程。课程内容基于实际经验,涵盖了从硬件限制、仿真方案选择到构建脚本优化和测试策略等多个挑战。虽然以 RISC-V 为例,但其中大部分经验也适用于其他 LLVM 后端目标。
挑战一:硬件限制与仿真方案 🖥️
上一节我们介绍了课程背景,本节中我们来看看第一个具体挑战。
RISC-V 架构目前拥有相当数量的开发板,但缺乏可放入机架、能快速完成 Clang 和 LLVM 引导构建的服务器级系统。
解决方案是使用在快速商用 x86-64 主机上运行的 QEMU 进行仿真。虽然牺牲了速度,但获得了灵活性。初始设置此基础设施的主要成本是工程时间,硬件本身相对便宜。例如,可以每月花费数百元租用高性能服务器。在 QEMU 下运行还提供了更多灵活性,可以测试尚未在硬件中可用的指令扩展或扩展组合,甚至测试真实硬件中可能不存在的扩展选项,这有助于发现错误。
挑战二:仿真速度与测试运行 🐌
我们建立了基于 QEMU 的仿真环境,但在全系统模式下运行速度较慢。这种模式模拟完整的 RISC-V 系统,包括运行编译为 RISC-V 的 Linux 内核,完成完整的两阶段引导构建,并运行单元测试,整个过程大约需要 16 小时。
以下是几种应对方案:
- 交叉编译:可以在 x86 上尽可能快地编译所有内容。
- QEMU 用户模式:这是 QEMU 的另一种使用模式,它在用户级别进行翻译,比全系统模式稍快,可扩展性更好。在全系统模式下,跨多核的可扩展性尚可,而用户模式在独立的进程构建设置中表现更佳。
然而,用户模式在仿真保真度方面存在问题。这意味着,当你发现一个错误时,很难判断它是测试环境本身的问题,还是真正的问题。
挑战三:最终的解决方案:交叉编译与虚拟机 🔄
我最终采用的解决方案,可能对其他人也很有帮助。相关脚本可以在 LLVM 的 Zorg 仓库中找到。
具体流程如下:
- 进行交叉编译。
- 启动一个基于 RISC-V 的“设备”虚拟机。
- 将生成的构建产物作为文件系统挂载到该虚拟机上。
- 切换到该环境运行测试。
我们有一个围绕 llvm-lit 的包装脚本,使其对 LLVM 构建系统透明。这个方案运行良好,并且没有 QEMU 用户模式在支持 ptrace 或处理消毒剂(sanitizers)等方面的限制。
挑战四:扩展测试覆盖范围 📊
编译 Clang 和 LLVM 并运行单元测试是一个具有挑战性的工作负载,但它并不能代表用户使用编译器的所有场景。为了扩大测试池,至少应该加入独立的 LLVM 测试套件仓库,构建并运行其中的测试。我实际上使用 QEMU 用户模式来运行这部分测试,因为其要求相对较低,并且运行良好。总的来说,我为 LLVM 在 QEMU 用户模式下的使用上游化了一些修复,使其在基础单元测试中能较好地工作。
挑战五:构建脚本的迭代与本地测试 🔧
这个挑战可能有点令人惊讶,除非你是比我优秀得多的工程师。前面描述的概念虽然合理,但你不太可能第一次就写出完美运行的构建脚本。LLVM Buildbot 长期存在的一个问题是,很难在下游测试更改,而无需将其提交到 llvm-zorg 仓库并等待部署到暂存构建主控机。
解决方案是上游化对本地测试模式的支持,并编写文档。现在,设置新构建器、在本地测试或修改现有构建器并推送上游变得非常容易。这对我的工作流程是一个巨大的改进。此外,我们还为 CI 添加了一些简单的 GitHub Actions,用于检查对核心 llvm-zorg Python 脚本的修改是否仍能通过一些简单测试。
挑战六:构建器配置的复用与定制 ⚙️
llvm-zorg 基础设施有一系列可复用的“配方”,用于构建例如 clang-stage1。你可以通过几个选项来参数化它。这很棒,如果你只想做一个与现有构建器类似但略有不同的构建器。但当你开始改变一件事,接着改变另一件事时,你可能需要向该函数添加新参数,并将其传递下去,这可能会以意想不到的方式影响其他人的使用。
这主要是对注解构建器基础设施的一个推广。它允许你提交一个执行所需构建步骤的 Shell 脚本,非常易于迭代和更改。并且,与下一个挑战相关,它使其他人可以轻松地直接检出该 Shell 脚本。如果你以允许的方式编写它,他们可以在不关心 Buildbot 基础设施其余部分的情况下直接运行它。
挑战七:交叉编译的文档化 📖
我们遇到的另一个问题是交叉编译缺乏良好的文档。现在已经有上游文档提供了现代的工作流程,指导如何以有效的方式进行交叉编译。
挑战八:部署新型构建器:快速“考验” 🏃
一个更近期的变化是部署了一种新型构建器。我们从 16 小时(全系统仿真)减少到大约 1.5 到 2 小时(交叉编译后运行 QEMU 系统)。但这仍然意味着单批提交通常包含多个提交。我们能否做得更好?
我的观察是,当我发现构建器标记的回归时,我倾向于遵循一套相似的调试步骤。为什么不设置一个自动执行这些步骤的构建器呢?因此,我设置了所谓的“考验”构建器。它交叉编译一个新的 Clang 和 LLVM,然后以多种配置编译和运行 LLVM 测试套件,并将测试限制在相对较短时间内可以运行的部分。这为大约五种不同的 RISC-V 配置提供了约 25 到 30 分钟的运行时。这个想法是增加一些额外的测试。
挑战九:组合测试策略:没有万能方案 🧩
显然,没有一种放之四海而皆准的方法。我谈到了从 6 小时到 1.5 小时再到 25 分钟的运行时间。这些都是在测试不同的事物,在覆盖范围和保真度方面有不同的权衡。我发现,组合使用这些方法效果非常好。
挑战十:构建器界面的可视化 📈
Buildbot 界面功能强大,但对于浏览来说并不理想,特别是如果你只关心少数几个构建器。因此,设置一个漂亮的仪表板来查看是很有用的。就我而言,我主要关心 RISC-V 相关的构建器,以查看它们的运行时间和状态。
总结与致谢 🙏
本节课中我们一起学习了为 RISC-V 改进 LLVM CI 所面临的十大挑战及其解决方案,涵盖了从硬件仿真、构建优化到测试策略和工具链改进等多个方面。


最后,感谢所有帮助完成这项工作的人,特别是审阅者和维护 Buildbot 基础设施的贡献者。这是一项相当吃力不讨好的任务。同时,感谢 RISC-V 国际基金会对这项工作的支持。如果你有任何问题,或者想尝试应用我谈到的任何内容,请在课后与我交谈或给我发邮件。
043:衡量LLVM社区的健康状况

概述

在本节课中,我们将学习如何通过分析代码仓库、贡献者数据和社区互动等指标,来量化评估一个开源社区(以LLVM为例)的健康状况。我们将介绍一系列简单实用的技术,帮助你理解社区的活跃度、多样性和包容性。
社区健康度的重要性 📊
LLVM社区长期以来致力于构建一个健康、多元且包容的社区环境。德鲁克定律指出:“无法衡量,就无法管理”。因此,衡量社区健康度是进行有效管理的第一步。
从代码仓库开始分析
上一节我们介绍了社区健康度衡量的重要性,本节中我们来看看如何从最基础的代码仓库数据入手。
我们可以从Git仓库开始分析。一个简单的方法是统计提交次数。我们可以按版本发布周期统计,也可以按时间周期统计。
以下是我们可以进行的分析:
- 按版本统计提交数:观察每个LLVM版本包含的提交数量。
- 按时间统计提交数:观察每年或每月的提交数量变化趋势。
通过分析近25年的数据,可以看到随着项目规模扩大和复杂度增加,每年的提交数量稳步增长,这表明项目非常健康。
深入分析子项目活动
我们不仅可以从整体上分析,还可以将同样的命令应用于项目的不同部分,以观察前端和子项目的活动情况。
以下是LLVM子项目的发展历程:
- 初期:只有一个核心项目。
- 随后:加入了Clang,并一直是一个大型子项目。
- 之后:LLDB和LLD加入。
- 近期:随着Flang的加入和持续开发,出现了一波活跃度高峰。
我们也可以在后端进行同样的分析。可以看到X86后端一直很活跃,随后是32位ARM后端,接着是64位ARM后端。最近几年,RISC-V后端加入,其总活跃度看起来与ARM后端相当。通过这种方式,我们可以了解不同子项目的发展状况。
统计贡献者与公司参与度
我们可以进行更详细的分析,统计贡献者数量。这有两种方式:一是查看贡献者姓名,这对于人员更换公司但仍是同一贡献者的情况很有用;二是查看提交作者的电子邮件地址,从中提取域名。
如果我们过滤掉Gmail等个人邮箱域名,这些域名可以很好地代表为LLVM做出贡献的不同公司的数量。
以下是贡献者分析的关键指标:
- 每版本作者数:可以看到稳步增长,最新LLVM版本有近1600位作者。
- 用户域名数:多年来稳步上升,最近几个版本稳定在约70个不同的、可能代表公司的多用户域名。
我们可以针对每个后端进行这种分析。从这里开始,你会得到一些有趣的信息。将所有ARM后端(包括64位和32位)与所有RISC-V后端进行比较,结果有些令人担忧。ARM主要来自一家公司,在Naro有一个软件组织(算两个域名),可能还有像高通这样的几家大公司贡献编译器人员。RISC-V有4000名成员,但贡献的公司数量只有ARM的一半左右。这或许是对RISC-V社区的一个呼吁:开始加大贡献力度。
识别核心贡献者
我们可以深入查看细节,因为不必只看宏观数字,实际上可以列出所有贡献者。例如,列出1515位贡献者、70家公司、以及63位每周贡献超过一次的个体贡献者。
如果你查看数据,会发现有一个人在20版本中贡献了767次提交,这是非凡的投入。回顾历史版本,你会看到同样的名字反复出现,他们是真正的核心贡献者,是我们应该庆祝的“超级贡献者”。
为了增加趣味性,我回顾了1.0版本:17位个体贡献者,两个公司域名(实际上是伊利诺伊大学香槟分校和伊利诺伊大学香槟分校计算机科学系),7位个体贡献者每周贡献超过一次。我留给你们猜猜那位贡献了6500次提交的个体是谁。
跨项目比较:LLVM vs. GCC
我们可以应用这些方法进行不同编译器之间的比较。例如,比较GCC和LLVM。
可以看到GCC发展起来,已有近40年历史,提交数达到约1万到1.5万次。这里我包括了GDB、LLDB和LD,因为它们也是各自项目生态的一部分。但可以看到,到2007年,LLVM的提交活跃度超过了GCC,现在大约是GCC的三倍。这表明LLVM是一个更大的社区。我在两个社区都工作,这不是对它们的评判,我也做GCC方面的工作。
我按年而不是按版本统计,因为两个编译器的发布周期不同。同样,我们可以深入细节,再次看到相同的情况。如果我们观察LLVM和GCC在ARM架构上的活动,可以看到ARM公司确实致力于LLVM,在LLVM上的ARM相关活动比在GCC上多得多。但RISC-V的情况并非如此,实际上在GCC上的活动更多,但优势不大。这或许告诉我们这些不同架构的社区现状。
利用GitHub数据评估协作
让我们转向GitHub本身。有一个命令行工具叫gh。由于我已经展示了通用原则,这里只展示一个例子。
以下是一个小脚本示例,用于提取已关闭状态的拉取请求:
gh pr list --state closed --json number,title,author,comments,mergedAt --limit 100 | jq '.[] | {num: .number, title: .title, author: .author.login, comments: .comments.totalCount, merged: .mergedAt}'
我们可以得到像这样的图表:可以看到90%的PR最终被评论并合并。然后我们可以开始查看每个已合并PR的评论数统计。我按月统计,因为我们认真使用GitHub才大约一年半时间。但可以看到,每个已合并的PR大约有3.5条评论,这与我们多年来收集的统计数据一致。
提醒一下:如果你希望别人在你的PR上工作,请确保你评论并帮助处理大约3.5个其他PR作为回馈。
评估多样性与包容性
这是最后一部分。由于时间不够,我不详细展开,但如何开始触及更多方面?如何审视多样性和包容性?
我要向你们介绍别人的工作,这是Andrea Capaccioli的工作,他现在在格罗宁根大学。我第一次见到他时他在布鲁内尔大学。这是他的相关链接。他提出了一个问题:社区对不同性别的照顾程度如何?他使用了诸如通过名字推断性别、查看Git头像图片推断等方法。
我喜欢他的工作之处在于它纯粹是分析性的。他进行了严谨的统计分析。我给了你们链接,那是我第一次听他演讲,是在英国计算机学会。还有一个链接指向他关于此主题的一篇关键论文。
你可以发现很多事情:例如,与男性相比,有多少女性通过贡献参与社区?有多少女性回答问题?有多少女性提出问题?她们在社区中保持参与的时间有多长?利用现代大语言模型,你可以深入分析:当回复一个看似女性名字或男性名字的人时,使用的语言有何不同?这里面内容非常丰富。Andrea进行了非常详细的研究。在进行这类分析时,有很多可能出错的地方,他非常仔细地审视了这些问题。
我留给你们的最后一点是:看看他的工作,看看我们是否能做类似的分析。我没有时间,今年也没时间做。我很乐意看到对LLVM进行同样的分析。根据我的主观了解,当我们与其他社区比较时,我们可能会感到惊喜。但这是一套很好的技术,可以用来“检查我们的作业”。
我希望这激发了你们的兴趣。你们有了一些想法,我期待能更深入地了解我们社区的运行状况,并希望我们能持续成长,保持健康、包容和多元。


非常感谢。
045:llvmlite - 一个用于 LLVM 的 Python 训练场


在本教程中,我们将学习如何使用 llvmlite,这是一个连接 Python 与 LLVM 编译器的桥梁库。我们将了解它的核心组件,并通过实际例子演示如何用它来构建 LLVM IR、运行优化、可视化分析以及直接执行 IR。本教程旨在让初学者能够轻松上手,利用 Python 的简洁性来探索 LLVM 的强大功能。
概述:什么是 llvmlite 和 Numba?
llvmlite 是连接 Numba(一个基于 LLVM 的 Python 编译器)前端与 LLVM 后端的桥梁。Numba 被广泛用于加速 Python 代码,其原理是绕过 Python 解释器,将 Python 代码转换为 LLVM IR,经过优化后,通过 LLVM 的即时编译引擎在机器上直接执行。
其工作流程可以概括为:
- Python 代码被转换为 Numba IR。
- Numba IR 被进一步转换为 LLVM IR。
- 使用 LLVM C++ API(通过 llvmlite)对 IR 进行优化。
- 生成机器码并执行。
以下是一个使用 Numba 加速的简单示例,它展示了装饰器 @jit 如何将函数编译执行,从而获得远超解释执行的性能。
import numpy as np
from numba import jit
@jit(nopython=True)
def sum_array_jit(arr):
result = 0
for i in arr:
result += i
return result
def sum_array_py(arr):
result = 0
for i in arr:
result += i
return result
# 执行速度对比:jit 编译版本远快于纯 Python 解释版本
第一部分:使用 IRBuilder 构建 LLVM IR
上一节我们介绍了 llvmlite 的基本概念。本节中,我们来看看它的核心组件之一:IRBuilder。IRBuilder 充当了编译器前端角色,允许你将任何编程语言降级(lower)为 LLVM IR。我们将通过构建一个简单的加法函数来学习其基本用法。
首先,我们需要导入必要的模块并创建一个空的 LLVM 模块。
from llvmlite import ir
# 创建一个名为 “my_module” 的空模块
module = ir.Module(name="my_module")
接下来,我们定义一个函数类型,并将其添加到模块中。我们的函数将接收两个 32 位整数参数,并返回一个 32 位整数。
# 定义函数类型:返回类型为 i32,参数为两个 i32
func_type = ir.FunctionType(ir.IntType(32), [ir.IntType(32), ir.IntType(32)])
# 将函数添加到模块中,命名为 “add2”
func = ir.Function(module, func_type, name="add2")
现在,我们需要为函数添加基本块(Basic Block)和指令。IRBuilder 在三个抽象层级上工作:builder.block(基本块级)、builder.function(函数级)和 builder.module(模块级)。
# 在函数中创建一个名为 “entry” 的基本块
entry_block = func.append_basic_block(name="entry")
builder = ir.IRBuilder(entry_block)
# 获取函数的参数
a, b = func.args
# 创建加法指令:将参数 a 和 b 相加,结果存入变量 c
c = builder.add(a, b, name="c")
# 创建返回指令:返回变量 c 的值
builder.ret(c)
以下是完整的代码及其生成的 LLVM IR 输出:
from llvmlite import ir
module = ir.Module(name="my_module")
func_type = ir.FunctionType(ir.IntType(32), [ir.IntType(32), ir.IntType(32)])
func = ir.Function(module, func_type, name="add2")
entry_block = func.append_basic_block(name="entry")
builder = ir.IRBuilder(entry_block)
a, b = func.args
c = builder.add(a, b, name="c")
builder.ret(c)
print(module)
输出 IR 示例:
; ModuleID = "my_module"
target triple = "unknown"
target datalayout = ""
define i32 @add2(i32 %".1", i32 %".2") {
entry:
%"c" = add i32 %".1", %".2"
ret i32 %"c"
}
你还可以进一步设置模块属性,例如目标平台和函数属性。
module.name = "test_module"
module.triple = "x86_64"
func.attributes.add("noinline")
此外,一个模块可以包含多个函数。以下是添加第二个函数 add3 的示例:
# 定义接收三个参数的 add3 函数
func_type3 = ir.FunctionType(ir.IntType(32), [ir.IntType(32), ir.IntType(32), ir.IntType(32)])
func3 = ir.Function(module, func_type3, name="add3")
entry_block3 = func3.append_basic_block(name="entry")
builder3 = ir.IRBuilder(entry_block3)
x, y, z = func3.args
sum1 = builder3.add(x, y, name="sum1")
sum2 = builder3.add(sum1, z, name="sum2")
builder3.ret(sum2)
第二部分:使用 llvmlite 进行日常编译器工作
我们已经学会了如何构建 IR。本节中,我们来看看如何利用 llvmlite 简化日常的编译器开发和实验工作,特别是在需要快速原型设计时,它可以避免直接使用 C++ 的复杂性。
首先,我们可以从字符串直接解析 LLVM IR 到一个模块对象中。
from llvmlite import binding as llvm
ir_string = """
define i32 @count_zeros(i32 %n) {
entry:
%cmp = icmp eq i32 %n, 0
br i1 %cmp, label %if.then, label %if.end
if.then:
ret i32 1
if.end:
ret i32 0
}
"""
# 将字符串解析为 LLVM 模块
module = llvm.parse_assembly(ir_string)
在操作模块之前,需要进行一些初始化,例如设置目标和 Pass 管理器。
# 初始化 LLVM 目标等
llvm.initialize()
llvm.initialize_native_target()
llvm.initialize_native_asmprinter()
# 创建目标机器对象(用于当前主机)
target = llvm.Target.from_default_triple()
target_machine = target.create_target_machine()
# 创建 PassBuilder 和模块 Pass 管理器
pass_builder = llvm.PassBuilder()
mpm = pass_builder.build_module_optimization_pipeline(llvm.OptimizationLevel.O3)
运行优化 Pass
以下是使用 llvmlite 运行单个优化 Pass(如 simplifycfg)的步骤:
# 创建模块 Pass 管理器
pm = llvm.ModulePassManager()
# 添加 simplifycfg pass
pm.add_pass(llvm.PassManagerBuilder().populate_module_pass_manager())
# 对模块运行该 pass
pm.run(module)
print(module)
你也可以方便地运行标准的优化管道,例如 O3:
# 获取针对 O3 优化级别配置好的 Pass 管理器
pm_o3 = pass_builder.build_module_optimization_pipeline(llvm.OptimizationLevel.O3)
# 运行优化
pm_o3.run(module)
print(module)
可视化分析
LLVM 提供了丰富的可视化 Pass,用于生成控制流图、支配树等图表。我们可以用 llvmlite 轻松调用它们。以下是生成控制流图 PNG 图像的函数示例:
import subprocess
def render_module_to_png(module, filename):
"""将模块的控制流图渲染为 PNG 图像。"""
# 创建 Pass 管理器并添加 CFG 打印机 Pass
pm = llvm.ModulePassManager()
# 注意:此处需要 llvmlite 支持对应的 Pass 包装
# pm.add_pass(“cfg-printer”) # 示例,实际 API 可能不同
pm.run(module)
# 假设 Pass 生成了 .dot 文件,将其转换为 PNG
# subprocess.run([“dot”, “-Tpng”, f”{filename}.dot”, “-o”, f”{filename}.png”])
print(f”控制流图已生成到 {filename}.png”)
# 调用函数
render_module_to_png(module, “count_zeros”)
类似地,你可以创建函数来生成支配树或后支配树图。
构建自定义优化管道
llvmlite 允许你灵活地组合各种 Pass,构建自定义的优化管道,这对于研究特定优化序列的效果非常有用。
# 示例:创建一个自定义的 Pass 序列
custom_pm = llvm.ModulePassManager()
# 添加一系列 Pass(此处为示例,实际 Pass 名称需参考 llvmlite 支持列表)
# custom_pm.add_pass(“loop-rotate”)
# custom_pm.add_pass(“licm”)
# custom_pm.add_pass(“simplifycfg”)
custom_pm.run(module)
print(module)
查看汇编代码
你还可以使用 llvmlite 为不同的目标平台生成汇编代码。
# 为不同目标创建目标机器
triple_riscv = “riscv64”
triple_x86 = “x86_64”
# 注意:需要 LLVM 已编译对应后端的支持
# target_riscv = llvm.Target.from_triple(triple_riscv)
# target_machine_riscv = target_riscv.create_target_machine()
# 生成本机目标的汇编
asm_native = target_machine.emit_assembly(module)
print(“本机平台汇编代码:”)
print(asm_native[:200]) # 打印前200个字符作为示例
第三部分:直接执行 LLVM IR
最后,我们将学习如何使用 llvmlite 和 LLVM 的即时编译引擎来直接执行我们构建或解析得到的 LLVM IR 函数。
首先,我们从一个包含 add2 函数的 IR 字符串创建模块。
ir_string_add2 = “””
define i32 @add2(i32 %a, i32 %b) {
entry:
%sum = add i32 %a, %b
ret i32 %sum
}
“””
module_to_execute = llvm.parse_assembly(ir_string_add2)
接着,创建目标机器和即时编译器实例。llvmlite 目前主要依赖 LLVM 的 MCJIT 引擎。
# 创建目标机器(用于当前主机)
target = llvm.Target.from_default_triple()
target_machine = target.create_target_machine()
# 创建 MCJIT 编译器实例
compiler = llvm.create_mcjit_compiler(module_to_execute, target_machine)
编译模块后,我们可以获取函数的地址,并通过函数指针调用它。
# 获取编译后函数 add2 的地址
add2_ptr = compiler.get_function_address(“add2”)
# 将地址转换为可调用的 Python 函数(需使用 ctypes)
import ctypes
add2_cfunc = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_int, ctypes.c_int)(add2_ptr)
# 执行函数
result1 = add2_cfunc(1, 2) # 应返回 3
result2 = add2_cfunc(-1, 5) # 应返回 4
print(f”add2(1, 2) = {result1}”)
print(f”add2(-1, 5) = {result2}”)
总结与展望
本节课中,我们一起学习了 llvmlite 这个强大的工具。我们从构建 LLVM IR 开始,探索了如何将其用于日常的编译器开发任务,例如运行优化 Pass、进行可视化分析以及直接执行 IR。llvmlite 通过 Python 绑定,大大降低了使用 LLVM 进行实验和原型设计的门槛。
llvmlite 的未来发展方向包括更新以支持 LLVM 最新的稳定版本,以及吸引更多项目使用。它不仅在支持 Numba 方面至关重要,也为在 Python 生态中构建新的编译器前端、开发调试和模糊测试工具提供了极大的便利。
希望本教程能帮助你开始使用 llvmlite 探索 LLVM 的世界。


046:提升循环访问分析精度 🔍


在本节课中,我们将学习 LLVM 中的循环访问分析。我们将了解它的基本概念、工作原理、主要用户以及当前存在的局限性。通过本次学习,你将理解该分析在循环向量化等优化中的关键作用。
什么是循环访问分析? 🤔
上一节我们介绍了课程概述,本节中我们来看看循环访问分析的具体定义。
循环访问分析最初是为循环向量化而构建的。这是构建该分析的主要原因。在其他用户中,例如 SPV 也会使用它,但并未使用其全部功能。SPV 并非循环访问分析的重度用户。
本质上,它是一种基于标量演化的依赖关系分析,旨在证明某些操作是否可以安全地进行向量化,或者是否需要在运行时进行检查。如果分析发现依赖关系不安全,则不会进行向量化。
循环访问分析的主要用户 🛠️
了解了基本定义后,我们来看看哪些编译器优化会使用循环访问分析。
以下是循环访问分析的主要用户:
- 循环向量化:这是其主要服务对象,用于判断循环是否可向量化及是否需要运行时检查。
- 循环版本化:该优化会为循环创建多个版本,我们稍后会简要讨论。
- 循环负载消除:该优化直接使用循环版本化后的循环本身。
- 循环分布:该优化在 LLVM 中默认未开启,但其在 GCC 中的等效优化(
loop split)功能强大。我们应当思考为何 LLVM 的循环分布不如 GCC,以及它是否有助于向量化更多场景。
循环访问分析的核心功能:运行时检查 ⚙️
上一节我们介绍了分析的用户,本节中我们来看看它的一个核心功能——生成运行时检查。
循环访问分析构建的主要原因是为了能够发出运行时检查。例如,在代码 const float* x; const float* y; 中,编译器不知道这两个数组是否别名。如果它们别名,则向量化会存在问题。
循环访问分析的主要功能是沟通需要哪些运行时检查。循环向量化器会基于此合成这些约束条件。这些约束本质上是标量演化表达式。分析会指出,如果这些运行时检查不满足,则不进行向量化;如果满足,则进行向量化。
当然,如果使用 restrict 关键字(如 float* restrict x)指明指针不别名,那么分析会直接判定为绝对安全,这是最简单的情况。
但分析也能执行非平凡的分析。以下是一个非平凡分析的例子:
for (int i = 0; i < n; ++i) {
y[2*i] = x[i];
}
分析可以判定此循环是安全的,因为它识别出访问模式。原演讲中使用了 i 和 i-1 来模拟 memcpy,此处改为 2*i 以便于理解示例。
理解依赖关系 🔗
在深入更多功能前,我们需要明确循环访问分析中“依赖关系”的含义。
依赖关系存在于存储与加载或存储与存储之间。如果只是从内存读取(即只读循环),则不存在依赖关系。只有当向内存的冲突部分进行存储,或者加载与存储以某种方式(在循环术语中,指存在循环携带依赖或间接的不安全依赖)相互影响时,才会产生依赖问题。
依赖类型与步长分析 📊
现在,让我们看看循环访问分析能识别哪些具体的依赖模式。
以下是两种重要的依赖类型:
- 前向依赖:例如
A[i] = A[i-1] + ...。这意味着当前迭代的值依赖于前一次迭代的值,这种模式通常是可归约的。 - 后向循环携带依赖:例如
A[i] = A[i + stride] + ...。这里的“步长”是一个关键概念。
步长是指与归纳变量相乘的某个常量或符号值。在实际程序中经常见到步长,例如归纳变量乘以某个因子。在之前的非平凡分析例子中,2 就是一个常量步长。而在后向依赖的例子中,stride 是一个符号步长,意味着它是一个变量(例如函数参数),分析时并不知道其具体值。
对于符号步长,循环访问分析还有另一个功能:版本化。它会生成循环的两个版本:一个假设步长为 1 的版本,另一个保持符号步长的版本。这正是循环版本化优化所利用的机制,它在某些情况下(如循环负载消除)非常有用。
循环访问分析的局限性 🚧
上一节我们看到了分析的能力,本节中我们必须正视它的局限性。
以下是分析无法处理或处理不佳的几种变体(这些循环实际上不可向量化,但分析本身作为独立的依赖分析,理论上应能报告依赖关系):
- 复杂的跨迭代访问模式。
- 涉及多个数组或非线性索引的情况。
这些例子说明了循环访问分析并非一个完整的依赖关系分析。它不会报告循环中所有的依赖关系。它的效用在于:对于大多数实际案例,基于简单标量表达式进行推理是足够有效的。它虽非绝对完美,但对向量化而言通常“足够好”。
内部数据结构与约束 🧱
为了理解分析如何工作,我们需要窥探其内部用于沟通信息的结构。
分析内部使用一种结构来传达其分析得出的信息,主要包含以下字段:
- 依赖距离:例如
(IV - (IV-1)) * typeByteSize。如果指针是int*,则typeByteSize是sizeof(int),代表指针每次加一实际移动的字节数。 - 最大步长
- 公共步长
- 是否需要运行时检查
一个值得注意的限制是:对于循环访问分析,每个依赖只关联一个步长。即使存在多级索引,它也只识别一个与归纳变量相乘的常量或符号。这反映了其设计的简洁性:它通过标量表达式相减,来沟通是否需要运行时检查、最大向量化宽度是多少、依赖距离是多少,从而判断是否可归约。
标量演化的能力与挑战 ⚖️
循环访问分析建立在标量演化之上,因此其能力受限于标量演化。
标量演化本身有其限制。它不使用任意精度整数,有一定的位宽限制。当你向它查询循环次数等信息时,它会返回一个有符号或无符号的扩展表达式,可能附带一些条件和最大回跳次数,这中间存在计算成本。
更重要的是,当处理复杂的表达式时(例如包含嵌套加法、乘法或更复杂结构的表达式),标量演化的处理能力是有限的。虽然有一些论文描述了标量演化的理论基础,但循环访问分析在从标量演化表达式中恢复索引信息方面显得较为简单。
步长版本化也依赖于从标量演化中获取“步长”(getStep)。如果无法获取,分析就不知道步长。
另一个问题是,它主要只分析最内层循环。这意味着任何需要推理外层循环、循环嵌套或嵌套循环间携带的依赖关系的情况,它都无法有效处理。
与经典依赖分析的对比 🆚
为了更全面地认识循环访问分析,我们可以将其与 LLVM 中另一个依赖分析工具进行对比。
LLVM 中存在一个经典依赖分析(默认未开启),它基于 1991 年的论文《Practical Dependence Testing》,更侧重于理论。它使用 GCD 测试、Banerjee 不等式测试等,理论上能在更多情况下工作。但它是一个“完整”的分析,旨在报告所有依赖关系,而不生成运行时检查。它的用户是循环控制与压紧和循环交换等优化(这些优化同样因依赖分析默认关闭而未被默认启用)。要进行循环交换或压紧,本质上需要分析外层循环,这正是经典依赖分析所擅长的。
循环访问分析的成功案例 ✅

尽管存在诸多限制,循环访问分析在许多实际场景中表现良好。
以下是一个它表现出色的例子,该例子来自循环访问分析的测试集:
for (int i = 32; i < 36; ++i) {
for (int j = 0; j < 56; ++j) {
A[i][j] = B[i][j] + 1;
}
}
在这个嵌套循环中,分析能感知向量化宽度。它可以生成运行时检查,或者声明对于某个最大向量化宽度是安全的。这里巧妙地将外层归纳变量起始值设为 32,使其范围较小,内层循环范围也有限,从而在满足一定条件下可直接向量化。
我们可以通过命令 opt -passes=loop-accesses -analyze 来查看循环访问分析的调试输出。对于简单循环,它会打印出依赖方向等信息。对于更复杂的、需要合成运行时检查的循环,其输出会包含标量演化表达式、访问的低/高边界范围、用于步长版本化的相等谓词,以及基于谓词标量演化假设重写后的表达式。
总结与未来方向 🎯
本节课中,我们一起学习了 LLVM 循环访问分析的方方面面。
我们来总结一下循环访问分析的主要问题:
- 无法推理外层循环。
- 处理多索引访问时能力有限,需要像经典依赖分析那样处理更复杂的标量演化表达式,但这也不完美。
- 另一种思路是使用完整的线性规划求解器,但这会显著增加编译时间。
目前看来,没有完美的解决方案能处理所有情况。但我们可以在以下方面进行改进:
- 减少错误的运行时检查和虚假的依赖关系:我们可以通过工程改进,避免分析发出不必要的运行时检查和错误的依赖判定。
- 增加贡献者:该分析目前贡献者很少,需要更多开发者关注和投入。
总而言之,循环访问分析是我们当前拥有的分析工具,但我们或许应该思考:这是我们应得的分析工具吗?是否有办法让它变得更好?
附:问答环节摘要
- 问:标量演化是否类似于一个值范围分析或求解器?
- 答:标量演化不是一个完整的求解器。它在实践中足够强大,能很好地处理循环次数等问题,但在判断表达式是否非负、非正或为零等方面并不完美。它是一个在编译时不爆炸且在实践中非常有用的折中方案。
- 问:如果数组没有使用
restrict修饰符怎么办?- 答:这不是问题。循环访问分析会为可能别名的情况发出运行时检查。向量化器会在循环前导块中插入这些检查。只要支付这个小的运行时开销,循环就能完美向量化。
- 问:是否有考虑将循环访问分析与经典依赖分析结合?
- 答:主要难点在于如何将运行时检查机制“嫁接”到经典依赖分析上。此外,经典依赖分析代码复杂、庞大,且默认关闭导致缺乏贡献者动力。目前两者完全不兼容,结合是一个难题。

本节课中,我们一起学习了 LLVM 循环访问分析的目的、工作原理、能力与局限。理解这些是进行循环优化和编译器开发的重要基础。
047:使用MCLink并行化LLVM编译流水线


在本教程中,我们将学习如何利用MCLink来并行化LLVM编译流水线,以显著提升编译速度,同时保证生成代码的性能,并最小化副作用。我们将从模块化编译的背景出发,深入探讨实现细节,最后从系统设计的角度进行总结。
概述
本次分享基于模块化计算的背景,这是去年LLVM开发者大会上讨论过的主题。今天,我们将重点聚焦于LLVM部分,详细讲解如何借助MCLink并行化LLVM流水线,从而实现快速的编译时间和高性能的生成代码,同时将副作用降至最低。最后,我们会将一切放回模块化编译的上下文中,展示一些数据并从系统设计角度进行讨论。
模块化编译回顾
模块化编译器构建在MLIR和LLVM之上。我们使用MLIR构建前端和中端,即解析器和部分中端优化。然后,我们使用LLVM进行代码生成,以生成目标代码。我们以一种非传统的方式使用LLVM,即并行化LLVM流水线以获得快速的编译时间,同时确保生成的代码性能。这极大地帮助我们缩短了整个模块化编译时间中的LLVM流水线耗时。
使用LLVM的一个原因是其功能强大,非常适合生成特定目标的结果。同时,我们也不想从头构建所有优化。然而,LLVM也有一些限制,最大的限制是其框架并非为并行化设计,通常不是线程安全的,这成为基于LLVM的系统在编译时间上的一个瓶颈。
此外,一些优化行为难以预测,包含大量启发式方法,就像编译器魔法一样,其效果很难预测。因此,在构建流水线时,我们遵循两个原则:一是尽可能简化流水线,目前模块流水线只包含大部分函数级Pass和一个IPO Pass(内联器);二是主要部分,即尝试并行化流水线。
并行化策略:模块拆分
我们并行化流水线的方式是:将一个模块作为输入,将其拆分为多个子模块,每个子模块运行自己的流水线,从而实现两级并行。
第一级拆分是基于锚点函数(即外部可见函数)将模块拆分为子图。拆分图时,我们会将函数调用栈上的所有函数保留在同一个子图中,这样我们就可以运行包括IPO Pass在内的转换Pass,并获得几乎相同的效果。
在转换完成后,我们会进一步将子图按函数拆分开,并行地为每个函数进行代码生成。
如果将其放入流水线上下文中(如右侧所示),主要包括四个部分:前端、优化Pass、转换Pass、代码生成和发射。我们不太关心前端,因为我们有自己的前端。因此,子图级拆分主要应用于优化Pass部分,而代码生成部分则按函数级进行并行化。
拆分示例
这是一个来自去年演讲的快速示例,用于展示这种拆分在具体例子中的样子。
假设我们有一个包含两个外部可见函数(foo和bar)和两个内部函数(g和h)的模块。g和h被foo和bar调用。在第一级子图拆分中,我们将模块拆分为两个子图:一个用于foo,一个用于bar。我们会在每个子图中复制g和h,以便看到完整的函数调用栈。
进一步地,在完成所有转换后,我们将子图按函数拆分为独立的子模块。你可能还会注意到,我们必须将这些内部函数的链接类型从internal改为weak。因为在代码生成时,如果它们在自己的独立模块中且无人使用,同时又是internal的,它们就会被优化掉,我们不希望这种情况发生。
合并挑战与MCLink方案
拆分后我们可以并行运行任务,但最终需要将它们合并起来。最简单的方法是运行整个流水线直到代码发射结束,得到目标文件,然后将它们全部打包成一个归档文件。如果我们只想为模块程序生成二进制目标输出,或者只想为即时执行发送代码,这种方法基本可行。
但如果我们想生成汇编输出(例如用于调试或生成PTX代码),将多个汇编文件合并成一个就比较困难。另一个大问题是,如果我们只是归档已生成的代码发射结果,我们可以修复符号链接类型,但这会导致一些内部函数暴露为外部符号,这并不理想,同时也存在重复函数的问题。
理想情况是:我们有一个LLVM模块作为输入,最终只有一个输出,就像中间什么都没发生一样。并行化应该只是一个实现细节,是一种瞬态,我们希望最小化副作用,特别是符号链接类型的改变。我们需要确保这只是内部发生,不会反映在输出中。
本质上,这是一个针对拆分的Map-Reduce问题。我们将所有内容映射到一些模块中以实现并行运行,并且必须复制一些上下文,以确保即使它们不是线程安全的,每个拆分副本也能安全运行。但最终,我们需要将每个拆分的结果归约成一个输出。
因此,流水线看起来更像左侧所示。我们需要将代码生成流水线分成两部分:代码生成和代码发射。这样我们可以并行运行代码生成,以优化的M模块作为代码生成流水线的输入。在代码生成流水线结束时,我们得到机器码结果,这些结果大多存在于内存数据结构中。在调用汇编打印器发射代码之前,我们需要进行一些归约操作。
有多种方法可以实现这一点。逻辑上更直接、更容易理解的方法是使用MIR,因为MIR是代码生成结果的序列化格式,我们可以使用链接器来链接MIR。但我们的实验和原型表明,这对于当前用例来说不够稳定。同时,它是基于YAML的文本格式,性能不高。
因此,我们引入了名为MCLink的新组件。我们直接管理和操作内存数据结构中的生成代码以进行归约。幸运的是,我们需要管理的归约操作并不多,主要是在将每个拆分合并时,需要对那些全局性的东西进行去重。例如,常量需要去重。对于x86,我们需要对基于PC的符号进行去重(这是位置无关代码符号),外部符号也需要去重。基本上只需要做这三件事。
然后,我们还需要一个链接后的M模块来指导最终的汇编打印器进行打印。因此,我们仍然使用链接器将子模块链接成一个链接版本,并可以在链接版本中修复符号链接类型,从而解决符号链接类型改变的问题。最终,我们可以使用这个链接后的模块来指导汇编打印器进行代码发射。
实现细节
我们引入了两个Pass来进行去重操作,主要是为了操作函数机器函数ID以对常量等进行去重。我们还可以尝试对M符号(即外部符号)进行去重。
除此之外,我们实际上必须将代码生成结果中的私有数据结构移动到集中位置,以便进行最终的代码发射。具体来说,对于每个拆分,在代码生成后,模块在机器模块信息中包含了所有代码生成信息,每个函数都有一个机器函数,一切都位于MC上下文中。这些是针对每个拆分的。
但对于代码发射,我们希望将这些机器函数全部移动到一个属于汇编打印器的机器模块信息中,并且汇编打印器只有一个MC上下文。然后,我们使用这里的链接版本来指导汇编打印器进行代码发射。
内存优化
另一个实现细节上的意外发现是峰值内存的跳跃。左侧(使用MCLink)和右侧(不使用MCLink)展示了一个示例的内存占用情况。你可以看到左侧的峰值内存持续增加并在某个点下降。主要原因是,由于使用了MCLink,我们必须在内存屏障处持有所有代码生成结果。而不使用MCLink时,我们可以运行代码生成、进行代码发射,然后释放所有这些数据结构,只需要持有目标文件缓冲区到内存屏障,这比内存数据结构中的代码结果要小得多。
幸运的是,我们找出了原因。从插装结果来看,我们发现目标机器是一个可变的数据结构,这意味着我们必须让每个拆分拥有自己的副本。但它们共享一个名为SubtargetMap的数据结构,该结构对于每个拆分都包含完全相同的信息,因为我们是为一个初始模块和特定目标进行编译。每个拆分的代码生成都需要这些信息,但一旦代码生成完成,就不再需要了。我们只需要一个副本来进行最终的代码发射。因此,我们实际上可以在每个拆分完成代码生成后立即释放这个数据结构。
通过这样做,我们也尝试减少重复函数,因为我们最终只需要一份重复函数的副本,特别是对于那些内部函数。通过这些修复,我们可以将峰值内存占用降低约三分之一(对于这个测试用例,从2.5GB降至1.7GB)。
放回模块化编译上下文
现在,让我们将所有内容放回模块化编译的上下文中。这里展示了两个测试用例的编译时间:一个是Mamo,一个是Con。这是我们内部运行的两种基准测试,编译时间曾是开发迭代的痛点,因此我们努力改进这些测试用例的编译时间。
这里展示三组数字:没有任何并行化、仅使用子图级并行化、同时使用子图和函数级并行化。
好消息是,通过并行化,我们可以改进编译时间。对于Mamo,我们获得了约15%的加速;对于Con,获得了约30%的加速。同时,我们没有牺牲生成代码的质量,执行时间与没有任何并行化时相同。
第二行数据有点意思。这里我们进行了较低级别的并行化,但编译时间更快,而执行时间却更慢。我的猜测是,这与完全编译不完全相同,因为这里有重复的符号。为了让MCLink工作,我必须更改符号链接类型。我猜这影响了转换Pass的质量,导致我们实际运行的优化更少,所以编译时间更快,但生成的代码质量更差。
你可能还会注意到,编译时间的加速可能不那么令人印象深刻,主要是因为现在我们的LLVM流水线相对于整个模块编译时间来说要小得多。因为与此同时,我们也努力减少了IR的大小。所以现在,前端部分在整个模块编译时间中占据了更大的比重。
经验总结与讨论
现在,让我们退一步看看从这次实践中我们学到了什么。
首先,我们了解到并行化编译确实有助于提高整体模块编译时间。这主要与模块编译模型有关,因为它不同于C++。通常一个C++项目有多个CPP文件,我们将它们编译成目标文件然后链接。但对于模块,我们在不同的模块文件中编写不同的内容,并相互导入。在解析完成后,整个模块项目位于一个编译单元中,因此我们通常在一个编译单元中拥有整个世界,这就是为什么并行化确实有助于加速编译时间。
借助MCLink,我们可以并行化流水线,同时将副作用降至最低:没有符号链接类型改变,没有生成代码性能损失。虽然我不能说它与没有并行化时100%相同,但已经非常接近,副作用非常小。
关于编译器内部如何实现线程和并行化,编译器本身并没有处理太多实现细节。我们实际上依赖异步运行时来处理线程。编译器只是创建不同的任务,设置依赖关系,然后将所有内容发送到运行时。由运行时决定如何分派工作负载。从这个意义上说,如果我们想控制并行化级别,我们可以通过控制运行时的线程池大小来控制单个模块程序编译的并行化级别。
但还有一个问题:如果在构建系统中我们想要并行编译多个模块程序(例如在Bazel系统中),该怎么办?这是一个正在进行的内部讨论话题,也许我们可以使用编译器服务器或其他方案。但根本上,我们需要某种全局监管者,能够看到整体情况,并尝试决定如何平衡资源和如何进行线程调度。
需要说明的是,这种方法有局限性。我们只在x86和AArch64后端上进行了测试。如果代码生成流水线是过程间的(例如NVPTX后端),函数级并行化就不适用。我们确实将相同的基础设施用于GPU编译,但主要是在子图拆分级别,以便将不同的内核拆分成不同的子模块并行编译。目前,我们还没有为GPU编译解决归约问题。
此外,我还必须使用一些C++ Pass的技巧来在树外工作,因为当我移动那些代码生成结果的数据结构时,这些数据结构大多是私有或受保护的成员。为了将它们移出树,我们必须使用C++友元来实现。
我们很乐意将这项工作上游化。我创建了两个上游PR:一个用于LLVM模块拆分,一个用于MCLink。我希望有人能审查它们,看看是否对社区有益。我可能应该为此写一个RFC,以便如果大家喜欢,我们可以通过正式流程将其上游化。
这就是我们尝试在不做大手术的情况下解决这个问题的方法。它对我们有效。这是最好的主意吗?我希望得到更多讨论和反馈。
总结
本节课中,我们一起学习了如何利用MCLink并行化LLVM编译流水线。我们从模块化编译的背景和挑战出发,详细探讨了通过两级拆分(子图级和函数级)实现并行的策略,并深入介绍了使用MCLink进行内存中归约以最小化副作用的实现细节。我们还讨论了相关的内存优化技巧,并将结果放回实际编译上下文中进行了性能评估。最后,我们总结了从这次实践中获得的经验,并探讨了该方法的适用范围和未来上游化的可能性。通过并行化,我们能够在保证代码质量的同时,有效提升大型模块项目的编译效率。

问: 在幻灯片17中,你比较了编译时间。我好奇峰值内存开销(如果有的话)会是什么样子。
答: 这是一个很好的问题。肯定存在内存开销,因为我们必须复制很多东西。例如,在没有并行化的情况下,我们只有一个模块和一个LLVM上下文。当我们尝试并行化时,我们必须复制上下文。这些都是我们必须承受的额外内存压力。对于代码生成,我们实际上还必须复制MC上下文。在第一级子图拆分中,我们也有重复的函数。所以这些都是潜在的内存压力,具体取决于模块的大小以及我们需要复制多少内容。


问: 是的,我想对于一些编译时间的例子,如果也能有一些大的内存比较数据就更好了。
答: 明白了,同意。谢谢。

问: 你能评论一下仅限于后端本身的加速情况吗?根据我的理解,你在这里展示的数字是端到端的编译时间。那么,你能评论一下仅针对LLVM部分的加速吗?
答: 是的,这是端到端的加速。根据我目前的记忆,对我们来说,Pass大约占整个编译时间的30%。解释器(是elaborator的一部分)约占30%。所以后端约占不到40%。因此,如果我们看这里28%的整体时间改善,这意味着后端可能通过并行化实现了大约2倍的加速。


答: 好的,谢谢你。
048:Instrumentor - 易于定制的代码插桩工具

概述
在本节课中,我们将学习 LLVM 中的一个新工具——Instrumentor。这是一个易于定制的代码插桩工具,旨在简化在用户程序中插入额外代码以跟踪其运行时行为的过程。我们将了解它的工作原理、如何配置以及它如何帮助开发者更高效地创建新的插桩工具。
什么是代码插桩?
代码插桩在过去几十年中被广泛用于跟踪应用程序和用户程序的运行时行为。它被用于调试、代码净化、事件日志记录、资源使用监控以及性能分析。通过性能分析,我们可以找到性能瓶颈并尝试优化相关部分。
插桩首先从用户应用程序的原始代码开始。以下是一个 LLVM IR 中的函数示例,这是未插桩的原始代码:
define i32 @example_func(i32* %ptr) {
%val = load i32, i32* %ptr
store i32 42, i32* %ptr
ret i32 %val
}
这个函数接收一个指针参数,从该指针指向的内存位置加载一个值,然后将值 42 存储到同一内存位置,最后返回加载的值。
假设我们想要对加载操作进行插桩,并将有关该操作的一些信息转发给一个运行时组件(库),该库稍后将处理这些信息。我们可以生成插桩后的代码,在加载操作之前插入对运行时组件的调用,并传递有关加载操作的信息,例如指针和访问大小(在本例中为 4 字节)。
插桩支持的主要参与者
插桩支持涉及两个主要阶段:编译阶段和执行阶段。
在编译阶段,编译器需要用一些额外的代码来增强用户的原始代码以进行插桩。正如我们之前看到的,它必须插入一些插桩调用。
在运行时阶段,运行时组件(可以是一个库)将接收所有这些信息,在应用程序执行期间收集它们,并在线处理数据或将其存储在文件中以便稍后处理。
不同插桩工具(例如调试工具或净化工具)的运行时库实现会有所不同。但问题是,我们是否需要在编译器端重复实现相同的插桩过程?每当有新的插桩工具时,答案很可能是肯定的。这是因为编译器缺乏通用的代码插桩机制。虽然我们可以手动在 LLVM 中插入一些插桩调用,但这是一种低级机制,我们希望有更通用、更高级的东西。
实际上,在 LLVM 编译器基础设施中,我们有多个实现自定义插桩逻辑的 Pass。每个 Pass 针对不同的插桩工具或库。虽然它们的目标大体相似,但相似度不足以复用同一个 Pass 来服务所有库。这导致了几个问题:代码可维护性差、代码重复,并且在构建新的插桩库时,开发者必须到编译器端复制现有的插桩 Pass 并使其适应新工具的需求,这增加了新工具的开发难度。
引入通用插桩 Pass
为什么不使用一个通用的、可定制的插桩 Pass 呢?与其像现在这样拥有多个 Pass,不如只用一个 Pass 来服务多个插桩工具或库。这就是 LLVM 中新的 Instrumentor Pass 的任务。
它在一定程度上是通用的,并且可以定制。用户可以决定插桩什么、在运行时向运行时组件提供什么信息。同时,它也对 LLVM 开发者是可扩展的。如果存在其他值得纳入 Instrumentor Pass 的插桩机会,开发者可以扩展它。这样,多个用户就可以使用同一个插桩 Pass,而不是多个。这改善了之前的情况:代码可维护性更好,代码重复更少。现在,当构建新的插桩工具时,你只需要为 Instrumentor Pass 生成一个配置,指定你想要插桩的内容,而无需从头开始创建一个新的 Pass 来适配你的工具。
Instrumentor 如何工作?
Instrumentor 是一个 LLVM Pass,它基于用户可以提供的一个 JSON 配置文件工作。我们有一个默认的 JSON 文件,描述了所有可以插桩的机会,但你可以复制该文件并根据你的需求进行调整。
在这个文件中,你指定你想要插桩什么以及如何插桩。JSON 文件的第一个部分是配置部分。其中最重要的选项是 runtime_prefix,这将是插入到用户代码中的插桩函数的前缀。在这里,你可以设置你的插桩工具或库的名称。
然后,你可以指定其他部分来说明你希望通过这个 Pass 插桩什么。例如,回到我们之前的代码,如果我们想在加载操作发生之前对其进行插桩,我们需要在这里创建一个部分,说明我们想在指令发生之前插桩指令,并列出我们想要插桩的指令列表。这里我们只对加载操作进行插桩。我们启用该指令的插桩,其他选项用于指定我们想要转发给运行时组件的信息,例如指针、访问大小、对齐方式以及是否是易失性访问。
如果你运行 Instrumentor Pass 并传递这个 JSON 文件作为命令行参数,并且输入原始的 LLVM IR,你将得到以下代码:它将在加载之前插入一个插桩函数调用,其前缀为你指定的 pre_load,并转发我们指定的所有信息(指针、大小、对齐方式、是否易失性)。
高级功能:替换操作数
还有一些其他有趣的功能。例如,假设你的插桩工具想要替换你正在插桩的指令的某些操作数。比如,我们想更改正在插桩的加载操作的指针。你必须在 JSON 配置中指定 pointer.replace 为 true(之前是 false)。现在,它会生成相同的插桩代码,只是插桩函数现在返回一个由运行时组件提供的指针,然后我们将其转发并用作加载操作的指针操作数。
我们在某些情况下发现这些模式很有用,例如在我们正在开发的一个净化器中。
在编译流水线中的工作流程
工作流程如下:你有一个 C++ 示例,可以使用 Clang 将其转换为 LLVM IR。然后,如果启用了 Instrumentor,它将在内部执行。它将从 JSON 配置文件中读取你想要插桩的内容。如果你没有提供任何配置文件,它将读取默认文件。然后,它将生成插桩后的版本。最后,你将能够编译它并将其链接到你的插桩运行时库,该库将收集所有信息并在稍后处理。
当前支持的插桩点
以下是我们目前允许插桩的一些机会点(即插桩点):
- 加载和存储指令:类似于前面的例子,可以插桩加载和存储指令,并转发相关信息。
- 函数调用:例如,原始代码调用
fprintf。Instrumentor Pass 将插桩该函数调用,在调用发生之前通知运行时组件,并且你可以指定有关该调用的多种信息,例如调用地址、调用名称、如果是内部函数则提供内部函数 ID、参数数量、指向参数的指针等。这样,你实际上可以在fprintf被调用之前访问并检查其参数。还有替换这些参数的选项,例如,如果你想替换fprintf函数调用的某些操作数,你可以通过parameters.replace选项来实现,从而能够更改任何参数。 - 原子操作:传递地址、大小、对齐方式等信息。
- 分支和比较指令:以及其他一些未提及的指令。
- 函数进入和退出点:在函数刚进入之后和退出之前进行插桩。我们还允许检查函数的参数。
- 全局变量和模块构造/析构:也可以对这些进行插桩。
所有这些机会点都可以在两个位置进行插桩:在它们发生之前或之后。我们使用 pre 和 post 关键字来区分这两种情况。
用于优化插桩的其他机会
还有一些用于优化插桩的其他机会。例如,范围信息。假设用户代码中有一个循环,你希望在循环之前执行一些检查,而不是在每次循环迭代内部执行。你可以利用这个机会在循环之前插入一个插桩函数调用。
使用案例
这里展示了一些使用案例。
1. 性能分析器
我们实现了一个非常简单的性能分析器,用于检查执行时间以及用户代码在每个函数中花费的时间,使用了 Chrome 追踪工具。重要的是,我们只需要两个文件来实现这个分析器:
profiler.cpp:这是我们的运行时组件,只有 56 行代码。它实现了进入和退出函数时启动和停止计时器的代码。profiler.json:一个大约 26 行代码的 JSON 配置文件。
我们不需要对编译器端进行任何更改,插桩由 Instrumentor Pass 自动完成,我们只需要提供这个 JSON 配置文件。配置中,我们在函数进入点以及函数调用指令发生之前和之后进行插桩。
2. 简单的数据竞争和冗余存储检测器
另一个使用案例是一个简单的数据竞争和冗余存储检测器。在第一个案例中,代码正常,没有检测到任何问题。然后,我们可以用这个小型检测器检测到一种情况:我们向同一个全局变量写入不同的值,并且在这两次写入之间没有读取它。插桩会显示旧值和新值。还有一种情况是读取后存储相同的值,这也会被这个简单的插桩工具检测到。
同样重要的是,只需几行代码,我们就实现了一个运行时组件,并且不需要对编译器端进行任何更改。我们只需提供 JSON 文件,在其中对加载和存储进行插桩,提供诸如指针和被加载/存储的值等信息。
额外功能
1. 编程式使用
我一直在解释我们使用 JSON 文件来提供用户想要插桩的信息。但这是当你作为外部用户使用 Instrumentor 而不想接触 LLVM 端任何东西时的情况。然而,有些情况下你可能拥有自己的插桩 Pass,并希望从你的 LLVM Pass 中使用 Instrumentor 的功能。因此,我们允许以编程方式使用 Instrumentor,而无需使用 JSON 文件,具有相同的功能,但具有扩展特性。
例如,从你的 LLVM Pass 中,你可以创建一个 Instrumentor 的实例。然后,你可以指定你想要插桩的机会点,比如加载机会。你可以在这里指定你想要转发的信息。你可以指定你希望在加载发生之前进行插桩。你还可以指定回调函数来过滤你想要插桩的加载指令,从而提供对插桩内容的细粒度控制。最后,如果某些信息是你的插桩工具特有的,你可以向插桩函数传递自定义数据。
2. 自动生成运行时存根
另一个额外功能是,当你开发新的插桩工具时,你会从定义 JSON 文件开始,说明你想要插桩什么以及想要转发什么信息。当你已经有了配置文件,就是构建运行时组件的时候了。但是,你必须实现运行时组件,并指定具有参数类型、参数名称、顺序等的函数原型。问题是这很繁琐。
因此,你可以在 JSON 中指定 runtime_stubs_file 选项。你可以指定一个文件名,当 Instrumentor 运行时将创建这个文件。然后,你可以运行 Instrumentor 并传递 JSON 文件,它将自动为你生成一个存根运行时文件,其中包含所有具有正确类型和返回类型的插桩函数原型。这将使新插桩类型的开发者的生活更轻松。

3. 内联运行时实现以优化性能
最后一个额外功能是,我们允许优化插桩。我们允许通过 runtime_bitcode 选项提供一个包含你的运行时组件实现的位码文件的名称,而不是调用插桩库。当 Instrumentor 运行时,它将执行插桩,并在 Instrumentor 的最后阶段,它将从这个文件中读取,并将插桩函数的实现导入到用户代码中。这样,函数调用就会消失,从而避免了调用运行时库的开销。
总结与结论
本节课我们一起学习了 Instrumentor,这是一个基于 LLVM 的可定制插桩工具。它是一种统一的程序插桩方式,我们希望它对用户来说易于使用、易于定制,同时对于 LLVM 开发者来说,如果有一些尚未实现的功能,也易于扩展。
Instrumentor 应该尝试为未来的插桩工具铺平道路。我们已经展示了一些使用案例,其中大多数都非常简单,但还有其他完整的使用案例,我们证明了 Instrumentor Pass 有能力提供所需的所有功能。例如,来自 IUOV 的 InputGen 已经移植到了这个 Instrumentor Pass,此外,我们正在实现的一个针对 C 和 Go 代码的新对象净化器也依赖于这个 Instrumentor Pass。

总而言之,Instrumentor 通过提供一个通用、可配置的框架,显著简化了在 LLVM 中创建和执行代码插桩的过程,提高了开发效率并减少了代码重复。
049:集成概述

在本节课中,我们将学习SAP HANA数据库如何将其基于LLVM的JIT编译器与自定义解释器以及手动准备的机器码片段进行集成,以在查询执行的编译时间和运行时间之间取得最佳平衡。
背景与挑战 🏢
上一节我们介绍了课程背景,本节中我们来看看SAP HANA数据库面临的具体挑战。
SAP HANA是SAP的旗舰内存数据库。它通过一个复杂的多阶段管道处理SQL查询,该管道的最后一步是生成L程序并执行它们。L语言是SAP为HANA数据库专门设计的编程语言,专注于性能并针对数据库用例进行了定制。
为了执行这些SQL查询和程序,我们采用了分层编译解释器的方法。
以下是该方法的核心组成部分:
- L编译器:使用LLVM作为编译器后端。目前使用MCJIT进行机器码的即时编译,但正在讨论转向ORC JIT,因为它是当前维护的版本。
- 编译管道:拥有默认的O0管道、用于生产环境的自定义O1管道(禁用了一些在大程序中扩展性不佳的优化,如循环优化),以及默认的O2和O3管道。
- 编译服务器:IR在主进程(称为索引服务器)中构建,但在进行中端和后端编译时,会将IR序列化为位码并发送到另一个称为编译服务器的进程。这样做是因为我们无法保证中端和后端编译是内存安全或异常安全的。如果编译器崩溃,只会影响编译服务器,可以快速重启并继续编译,从而避免因索引服务器崩溃而导致数据丢失和长时间的数据重载风险。
- L解释器:这是一个定制的C++实现,专为内存数据库用例设计。它采用顺序的、逐块的执行方式,类似于IR解释。在L编译器不适用时(例如程序非常大超过25万行,或要处理的数据记录非常少时),会使用L解释器作为后备方案。
我们的核心目标是:从端到端(即用户)的角度,尽可能快地执行所有查询。为了实现这一目标,我们希望尽早开始执行以保持编译时间较低,同时也希望快速完成执行以保持运行时间较低。平衡延迟和吞吐量是关键。
另一个绝对重要的前提条件是:在内存数据库的上下文中,我们绝不能在任何情况下崩溃。这不仅适用于编译时(通过编译服务器隔离实现),也适用于运行时,因为代码最终在拥有内存数据记录的索引服务器上执行。
初始性能基准 📊
在介绍优化方案之前,我们先了解一下初始的性能状况。
我们在一个128核、256线程的x86_64机器上进行了基准测试,使用了TPC-H查询集(一个知名的数据库分析查询基准)。我们批量运行了全部22个查询,并收集了编译时间和运行时间。
编译时间对比:我们测量了四种执行策略的编译时间。
- 编译(O1):完全使用编译器(O1优化级别)。
- 编译(O0):完全使用编译器(O0优化级别)。
- 仅解释:完全使用解释器,关闭编译器。
- 混合执行:使用解释器开始执行,当编译结果就绪后,切换到编译后的代码。
结果显示,与O0编译相比,仅使用解释器带来了约93%的编译时间改善。但同时,仅解释模式的运行时间比O0编译模式慢了约20倍。
让我们再深入分析一下编译时间的构成。以一个约4.8万行代码的示例程序(包含许多跳转、循环和数学表达式)为例:
- 在O0编译中,指令选择、寄存器分配和活跃区间分析占据了编译时间的主导地位。
- 在O1编译中,应用一些中端优化可以减少送入后端的IR数量,从而显著降低后端编译时间。
- 原始解释器的编译时间极短(在图表中几乎看不见)。
解释器的性能瓶颈 🔍
那么,为什么我们的解释器在运行时如此之慢呢?
以下是解释器主循环的简化表示:
while (has_next_instruction) {
Instruction* instr = get_next_instruction();
instr->execute(); // 虚函数调用
}
解释器通过指令指针获取下一条指令,并调用其虚函数execute来执行。在我们的案例中,大多数指令(如加法、乘法、成员选择)都非常简单。
我们注意到,虚函数调用的开销主导了运行时间,这对性能非常不利。
另一个问题是存在大量的冗余加载和存储。解释器使用称为LValue的通用容器在内存中存储值。指令之间是隔离的,因此一条指令不知道前一条指令做了什么。例如,一个加法指令后跟一个使用加法结果的乘法指令,会先加载加法的操作数,执行加法,将结果存储到LValue,然后乘法指令立即再次加载同一个LValue来执行乘法。这显然不是高效的,因为本可以将中间结果保存在寄存器中并直接重用。
因此,我们面临两个核心问题:
- 如何消除这种执行开销?
- 能否以增量化的方式实现这一点,还是必须采用“全有或全无”的方法?
解决方案:汇编片段(ASM Snippets)💡
我们对这两个问题的答案是:汇编片段。
基本思想是:对于那些足够简单的指令,我们可以为其生成简单的机器码,然后将这些机器码集成到现有的解释器执行机制中。这允许增量集成,因为我们可以简单地重新定义什么是“简单指令”以及哪些指令序列是简单的,并可以按节点类型启用或禁用代码生成。我们专注于语言中最基本的方面(历史上是算术表达式和赋值等),这有助于保持低编译时间和低复杂度,从而避免错误。
集成工作原理如下:假设我们有一个包含一系列解释器节点的基本块,这些节点代表一个算术表达式。我们有一个机制来检测这个简单指令序列,并将其转换为一个单独的节点。
这个ASM片段节点可以很好地与现有执行机制集成,因为它本身就是一个普通的节点,也实现了虚函数execute。当调用这个函数时,它会转而调用我们预先准备好的机器码片段。这个片段实际上是一个可以从C++调用的函数,它封装了被替换节点(左侧)的逻辑。
代码生成机制 ⚙️
上一节我们介绍了ASM片段的概念,本节中我们来看看其背后的两种主要代码生成机制。
1. 编译化
这包括所有显式的机器码翻译(例如,为加法、减法、成员选择等准备的代码),以及一个代码生成器状态,该状态跟踪节点之间的数据依赖关系和寄存器内容,以优化加载和存储操作。
以前面的例子来说,原本需要6次加载和3次存储。通过代码生成器状态的分析,我们可以减少到仅需4次加载和1次存储。因为一旦第一个操作完成,中间值就保存在一个寄存器中,后续操作只需加载额外的操作数即可,直到不再需要该中间值时才将其存储。
2. 去虚拟化
假设我们有一个本可以成为一个片段的序列,但中间夹杂着一些不可翻译的节点(我们没有为其准备显式的机器码翻译)。我们如何将它们也纳入片段中呢?如果不处理,我们仍然需要对这五个节点进行虚函数调用,这对性能不利。
解决方案是:我们将这些不可翻译的节点解析为其execute函数的函数地址,然后获取这个地址并进行普通的函数调用,并将其“烘焙”到片段中。这样我们就消除了碎片化。当然,显式的机器码翻译可能更好,但这是一个后备机制。任何不可翻译的节点仍然可以在这些片段中表示为函数调用,从而避免了虚函数调用。
目前,我们的实现涵盖了大约30条x86和AArch64指令,覆盖了相当广泛的语言特性,包括一元/二元操作、成员选择、赋值、逻辑跳转等。
代码生成的具体规则 📝
现在您已经了解了片段的工作原理,我们可以更深入地看看代码生成的具体细节。
代码生成器状态负责跟踪寄存器内容。我们首先需要定义实际使用哪些寄存器。我们决定仅使用两个主寄存器来存放二元操作的操作数(操作数1和操作数2),一个额外的辅助寄存器,以及两个用于函数参数的寄存器。所有这些寄存器都是调用者保存的,这意味着我们不必担心之前的寄存器内容,可以直接使用它们。
这是一组简化代码生成的简单规则中的第一条。其他规则包括:
- 左操作数始终在第一个寄存器(操作数1)中。
- 右操作数始终在第二个寄存器(操作数2)中。
- 如果二元操作产生结果,结果始终放在第一个操作数寄存器中,这样代码生成器就知道值在哪里。
- 如果下一个操作需要该值作为右操作数,我们只需在寄存器之间移动它。
- 我们采用惰性数据移动策略,只在绝对必要时才进行加载和存储,这有助于减少加载/存储操作,也是将单个机器码片段串联成连贯的机器码片段背后的驱动机制。
去虚拟化的整个代码生成实现非常简洁,几行代码就能为一次函数调用生成右侧所示的机器码:准备execute函数的参数,将其存入寄存器,将节点解析为函数地址并写入寄存器,最后执行调用。
片段管理与诊断 🔧
使用动态生成代码的一个问题是,你几乎得不到编译器的帮助,会缺失大量信息。例如,没有用于正确C++异常处理的展开表,也没有像函数名、位置这样的基本元数据。
我们需要发挥创造性。例如,在堆栈跟踪中,动态生成的函数会被标记为“dynamic”,并附加其所在的L函数名,以便于识别。
此外,在开发或崩溃时,我们可以查看生成的机器码的反汇编。我们诊断工具的一个优点是,可以将机器码指令映射回生成它的原始操作,这为调试提供了更好的概览。
优化后的性能结果 📈
让我们看看引入汇编片段优化后获得的性能结果。
在我们的两个条形图中,新增了一个柱状条:“仅解释 + 汇编片段”(浅绿色)。可以看到:
- 与O0编译相比,我们仍然在编译时间上有非常可观的改进(86%)。
- 同时,我们将运行时间降低了3到4倍。现在,“仅解释 + 汇编片段”模式与O0编译模式之间的运行时间差距只有约6倍(之前是20倍)。这帮助我们能够以解释器快速启动查询执行。
再次查看示例程序的编译时间,“解释器+汇编片段”的编译时间确实比纯解释器要长,但仍然远优于O0的编译时间。
结论与对LLVM的启示 🎯
我们开发了一个简单的机制,通过以极小的代价在运行时生成汇编片段,显著改善了查询延迟。这是一个我们自主开发的、稳健且量身定制的解决方案。它略微增加了编译时间,但带来了巨大的性能收益。
虽然我们仍然比完整的编译器慢一个数量级,但对于TPC-H查询,我们看到了大约7倍的性能提升,这绝对是可接受的,足以让我们快速启动查询执行。对我们来说,最重要的是它支持增量翻译——我们可以简单地按指令级别启用或禁用AST节点/指令的编译化或去虚拟化。

从LLVM的角度,我们得到以下启示:
- 对于我们的延迟敏感型应用,LLVM O0的编译时间(至少在LLVM 20时)是不够的。O0的大部分编译时间花在了寄存器分配、指令选择和活跃区间分析上。
- 我们曾希望LLVM能提供一种超低延迟的编译模式,这在当时和现在都不可用。
- 我们本可以想象LLVM后端提供更多的配置选项,例如允许实现自定义的寄存器分配策略(就像我们在解释器中只使用两个寄存器那样),或者限制可使用的指令子集来简化指令选择。我们知道有“fast-isel”,但尝试后发现帮助不大。
我们也对上游定制的O0后端版本充满期待。
问答环节 💬
问: 采用自定义解决方案会失去围绕LLVM的所有工具支持(如调试信息、性能剖析)。你们是如何应对的?需要替换这些工具吗?
答: 我们引入了一些自有工具,例如Lucas展示的、可以将反汇编映射回指令的开发用反汇编转储工具。展开表生成器目前由于其他问题尚未使用。我们目前仅对可能抛出C++异常的指令禁用编译化和去虚拟化。是的,我们需要扩展我们的工具链。
问: 你们是否评估过其他代码生成器,比如Rust中常用的Cranelift?
答: 是的,我们考虑过,也考虑过AsmJit这样的JIT汇编库。但在SAP这样的大公司,使用无法保证长期维护的依赖项是个问题。我们需要保证内存安全和异常安全。因此我们决定自己实现,因为相对容易。
问: 你们提到的O0、O1是指从LLVM IR到机器码的 lowering 吗?是否考虑过使用LLVM的KLEE作为初始解释方案?
答: O0/O1指的是后端编译(IR到机器码),前端编译时间未包含在测量中。我们不熟悉KLEE,没有考虑过它。关于ORC JIT,我们正在讨论从MCJIT迁移过去,ORC支持代码模型small/medium,这对我们有益。感谢关于ORC后台编译和热交换的建议。
问: 虚函数调用开销是你们自定义解释器的问题。你们有很深的继承链吗?是否尝试过CRTP模式或C++20的新特性来避免虚表?
答: 我们没有尝试过,但这可能是我们可以研究的方向。
问: 是否探索过在O0和O1之间创建自定义的LLVM Pass管道,例如运行像mem2reg这样的廉价Pass来大幅减少IR数量,从而让后端再次变快,而无需在中端花费大量时间?
答: 是的,我们尝试过创建类似的自定义中端管道,但这并没有带来显著的性能提升。O0和O1的编译时间仍然不够快。
本节课中,我们一起学习了SAP HANA如何通过集成LLVM JIT编译器、自定义解释器和手动准备的机器码片段,在数据库查询执行的编译延迟和运行性能之间取得了有效平衡。关键点在于增量式的汇编片段生成机制,它显著提升了解释器的执行速度,同时保持了极低的启动开销。
050:规范化 (Canonicalization) - 唯一性与等价性

概述
在本节课中,我们将学习 MLIR 中的规范化概念。我们将探讨什么是规范化形式,它在 MLIR 中如何实现,以及当前面临的主要问题和挑战。通过理解规范化的核心思想及其在实践中的应用,我们可以更好地利用这一工具来简化和优化中间表示。
什么是规范化形式与规范化
规范化形式是在与原始 IR 相同的抽象级别上,将 IR 重写为更简单形式的过程。
例如,你有一个向量广播操作,将一个值从 1 x f32 广播到 8 x 1。然后,这个值被转置,从 8 x 1 变为 1 x 8。你可以很快看出,也许更简单的方式是直接将单个值广播到 1 x 8。如果你运行规范化器,它就会这样做。
那么,如果你想为你的操作实现类似的功能,你需要做什么呢?
如何实现规范化
在你的操作(Op)的 TableGen 描述中,你可以指定它具有规范化器。
例如,在转置操作的定义中,你可以设置 hasCanonicalizer = 1。然后,TableGen 后端会生成 C++ 类描述,并填充 getCanonicalizationPatterns 方法,该方法接收一个重写模式集作为参数。
在你的实现中,你需要添加这些模式。对于之前的例子,你可以在 vector.transpose 操作的 getCanonicalizationPatterns 方法中添加一个模式。这个模式会匹配一个转置操作,并检查其输入是否是一个广播操作。如果是,它将用一个新的广播操作替换原来的转置操作,从而得到更简单的代码。
另一种方法是使用声明式模式匹配。
假设你有表达式 (-x) / (-y)。根据基本算术,我们知道这等价于 x / y。你可以编写一个声明式重写模式,其中灰色的代码是你匹配的模式,蓝色的代码是重写后的结果。这样,你可以用直接的除法操作替换两个操作数的取负操作。
你编写这个声明式模式,TableGen 后端(在这种情况下是 rewrite_gen 或 cpp)会自动为你生成之前看到的 matchAndRewrite 代码。这减少了需要编写的样板代码。
对于许多算术和数学操作,折叠(Folding)是更受青睐的方式。声明式模式匹配不仅仅是关于如何编写规范化模式,还有相关的文档说明。这里只是为了让每个人都理解我们讨论的内容。
你可能在你构建的许多 Pass 中大量使用了它。
例如,有一段代码为了特定目的包含了很多内容,因为我即将展示它如何被清理。在这个例子中,有一个 linalg.generic 操作接收一堆输入(这些是动态形状)。它需要创建一个 tensor.empty,因此需要知道大小。所以,它探测并获取张量维度等信息。然而,generic 内部真正做的是以相反的顺序使用它得到的输入。如果你运行规范化器,你会得到更简洁、更好的代码。
规范化的问题
如果你一直在关注 RFC 和讨论,你会发现这是一个非常有争议的话题。
为什么?因为简单的形式很容易达成一致。你重写它,得到更小的代码,大多数人在某些事情上能达成共识。
但是看看左边的例子。你有一个转置操作,将 1 x 5 的张量转置为 5 x 1。你也可以通过 tensor.collapse_shape 操作达到相同的效果。
右边的例子更复杂。你正在执行一个 insert_slice 操作,将某些内容插入到目标张量中,这本质上是覆盖了目标张量的一部分,从而得到一个新的张量。但你可以通过扩展原始切片(expand_shape)来达到相同的效果。
哪一个才是更规范的(Canonical)形式?关于这一点有很多争论,因为它取决于上下文。假设你正在寻找某种 extract_slice 模式,你可能更喜欢第一种形式。如果你从不同的角度看,你可能会说,expand_shape 才是你希望的形式。所以这是一个来回争论的问题。
正如底部所示,Matthias 指出,这仅仅是两个例子,实际上还有更多。我们一直在反复争论哪个才是规范形式。
所以,我们打开了一个“蠕虫罐”。我没有打开它,因为我们刚吃完午饭,不想弄得一团糟。
定义规范化形式的问题
房间这边的人对特定情况有一种定义,那边的人有另一种定义。什么是简单的规范形式?大多数人会说,哦,根本没有规范形式。
它是规范化还是优化?哪个更好?为什么你的 Pass 要依赖规范化器,而它本不应该这样?它很好,但耗时太长,因为所有东西都被塞进了规范化器。
也许我们可以退一步说,让我们采取一些行动。
消除操作是一个好主意。例如,x + 0 就是 x。你可以把它看作一个 linalg.generic 操作,它只是接收输入并转发到输出,基本上是一个无操作(no-op)。你可以把它们折叠起来。
在不同的实例级别,你可以开始思考,什么是恒等元?什么是逆元?x 和 x 的逆元可以折叠。或者,排列操作:如果你做了一个转置接着另一个转置,我可以折叠它们。
或者,规范化以减少值的数量。这里变得棘手了,更少的值并不总是意味着更好。但如果你从某个角度想,是的,如果你传递 x 和 x,只传递 x 就行了。
引入新的或不同的操作来减少代码的多样性。是的,你真正希望在规范化器中做的一件事就是减少多样性。因为这样,你编写的 Pass 只需要处理特定风格的代码。
我们是否在快速走向一个方向,即我们希望代码变成非常紧凑的形式?就像压缩图复杂度一样,我们希望一个非常紧凑的形式。
不,因为如果你想想 LLVM,循环的规范形式有前导块和循环退出块,你可能不是在压缩它。实际上,你是在将其重写为一种更适合其他操作(如代码提升)的形式。所以,紧凑不等于简单。
那么它到底是什么呢?
重新审视规范化的定义
让我们退一步。什么是规范形式?我们不是发明这个词的人,它来自数学和计算机科学。也许我们只是用词不当,或者用对了词但定义很松散。
规范形式是一个抽象对象的唯一表示。抽象对象可以是一个操作或一个实例等。唯一性很重要,这种唯一性来自某些数学属性。
你有了这个唯一的规范形式,但所有东西都需要能转换到它,否则就没有意义。这是闭合性部分。然后,如果两个东西的规范形式相同,我们必须同意它们是相同的。
所有这些都有助于构建转换、优化和查询。一旦我们同意这是规范形式,我们就可以进行这些操作。
所以,有两个属性。我们甚至从操作或整数等具体事物中抽象出来。
规范化意味着,一旦你得到了规范形式,如果你再次运行规范化器,你不会得到别的东西,这体现了收敛性。另一个属性是等价性:如果两个东西的规范表示相同,那么我们必须同意它们在本质上是相同的。
本质上发生的是,你有一个 IR,你可以用 10 种不同的方式重写它,你试图找到那个唯一的东西,那就是它的规范形式。这取决于上下文,因为表示方式取决于上下文。
举个例子。我们知道,任何大于 1 的整数都可以用一种方式表示为素数的乘积,这是算术基本定理。上下文是你想用乘法来表示它。
同样的数字 10000,你可以表示为 1 + 1 + 1 + ...(一万次),这也是唯一的规范形式,但那是不同的上下文。所以,上下文很重要。
实际上,我们在许多不同的学科中都见过规范形式。所以这在 MLIR 中并不新鲜。我认为我们在这方面做得很好。
那么,我们在这里遗漏了什么?为什么当涉及到操作和 IR 时,它似乎对我们没有帮助?
因为如果你仔细想想,我们寻找的是什么?我们寻找的是正交性。意思是,如果你有一个操作做某件事,这个操作不应该能用 10 种不同的方式做同一件事。
对于单个操作来说,这很容易。但当你有一堆操作,一堆 IR 时,如果你能用 10 种不同的方式重写,那么你就没有规范形式。你拥有的是相互竞争的简单 IR 形式。

问题的根源
为了更深入地探讨这一点。对于一个操作来说,根据定义很容易。只有一种方式来表示 add 操作。它在构造时就是规范的。
如果你有一个 generic 操作(或者不是 generic),那么它可能有未使用的参数可以丢弃,或者默认的映射实际上是恒等映射,你也可以丢弃。在构造时,你可以创建一些可以称为规范的东西。
但是,当涉及到你的方言中的一堆操作时,如果你有两个操作在本质上做相同的事情,那么你就会遇到这个问题。这就是这个问题的根源。
因此,关于这个问题有很多讨论。我只是想向听众介绍不同的观点。
操作有一个规范形式,要么是通用的(这很难),要么是针对每个 Pass 或每个阶段的。没有上下文时,你制定规范形式,会有更多共识。这是我的规范表示。
在更高的抽象层次上,比如一个 linalg.generic,很难就规范形式达成一致。在更低的抽象层次上,因为它只做一件小事,可能更容易。
有些人认为规范化实际上是一种预处理。基本上是你为你的 Pass 或一组 Pass 所做的事情。一个全局的规范化器并不现实。
规范化不应是正确性所必需的,不应改变语义等。
规范化经常运行,因此不应计算量太大。这些是一些共识点。人们同意规范化不应是正确性所必需的(除了某些情况),规范化应该具有高效性(意味着不是任何东西都放进去),并且应避免循环(即,如果我们设计的整个 IR 使得有许多操作在做本质上相同的事情,你就会遇到这些问题)。
总结与观点
这是我的观点。追求一个规范形式是不现实的。重叠的操作确实存在,这是现实。
我们可以将规范化器视为一个简化器。它帮助我。我运行它,看到代码变小了。它使用折叠等方式清理代码,或者为不同的上下文重构代码。
我不是在向你强加我的观点。让我们做个投票。
第一个问题:规范化器应该被移除吗?(请举手)应该保持现状吗?(请举手)应该改进吗?(当然,我们都喜欢改进)
现在我们有了一些论点。应该如何改进?它应该有像优化那样的级别吗?或者为了更符合上下文而重构?这是一个想法。实际上,是我的同事给我的建议:为了更符合上下文而重构它。我们需要弄清楚具体如何实施。但我认为大多数人会喜欢这个想法。在问答环节,我们可以更多地讨论它。
本次演讲的目的并不是真正呈现一个关于规范化器的教程。我们想要的是改进,就像 Alex 今天早上说的那样。不要只是接受一切现状。
这是一件好事。我们希望规范化器做得更好。我们发现它很有用,但有时我们发现它非常烦人,因为它会破坏东西。那么我们如何改进 MLIR 呢?这就是本次演讲的重点。
问答环节总结
在问答环节中,讨论进一步深入。主要观点包括:
- 合流性:在重写系统中,一个理想的属性是合流性,即无论以何种顺序应用规范化模式,最终都应到达相同的规范形式。目前 MLIR 的规范化模式没有这个要求,实现和测试它都很困难,但这被认为是一个有用的属性。
- 定制化需求:普遍认为当前的规范化器需要改进而非移除。一个强烈的共识是,MLIR 是“混沌”的,单一的规范化器无法满足所有需求。规范化器需要是可定制、可扩展的,允许下游用户或特定 Pass 选择或添加不同的“风味”的模式,而不是全有或全无。
- 当前实现的问题:
- 非确定性停止:当前的规范化器基于贪婪重写,如果迭代次数太多,它会突然停止,并且 Pass 报告成功,但 IR 可能并未达到真正的规范形式,用户无法知晓。
- 隐式依赖:当其他方言添加了新的规范化模式时,你的编译器行为可能会意外改变,即使你没有使用那些方言。这使得编译行为非确定且不可控。
- 改进方向:需要使规范化器更加灵活和明确。例如,Pass 可以声明其需要的特定规范化前提条件。同时,需要解决非确定性停止和隐式依赖的问题,使规范化过程更加可控和可预测。
本节课总结
在本节课中,我们一起学习了 MLIR 中规范化的基本概念、实现方式以及当前面临的核心挑战。我们了解到规范化旨在为 IR 提供一种更简单或更唯一的表示形式,但在实践中,由于操作语义的重叠和上下文的多样性,定义一个全局的“规范形式”非常困难。
当前的规范化器作为一个实用的简化工具被广泛使用,但它存在合流性难以保证、行为不可预测、缺乏定制性等问题。社区共识是保留并改进它,改进方向集中在提高其可定制性、可扩展性和可控性上,例如允许分层的、上下文相关的规范化,以及解决其非确定性行为。

通过理解这些讨论和挑战,我们可以更明智地使用规范化器,并参与到使其变得更强大、更灵活的工作中。
051:小改动,大影响 - 为 LLVM 项目编写 GitHub 工作流 🚀


在本教程中,我们将学习如何为 LLVM 项目编写和贡献 GitHub Actions 工作流。我们将从基本概念入手,逐步介绍工作流的构成、编写方法、测试策略以及实际应用场景。即使你是初学者,也能通过本教程理解如何利用自动化工具提升项目协作效率。
什么是 GitHub Actions 与工作流?🤔
GitHub Actions 是一项高层次功能,它在你的 GitHub 仓库后台添加了一个自动化服务器。工作流则是你定义在这些自动化任务中执行哪些操作的方式。
工作流运行在 GitHub 提供的远程云机器上,这些机器被称为 runner。工作流使用 YAML 格式定义,并存储在仓库根目录下一个名为 .github/workflows 的特殊文件夹中。
即使你从未查看过工作流内部,你也可能已经见过它的运行效果。例如,当你创建 Issue 或 Pull Request 时,通常就会有工作流与之交互。
工作流的结构 📝
工作流采用声明式语法,你需要回答一系列问题来定义它:
- 在什么事件触发时运行?
- 它需要访问什么资源?
- 它应该做什么?
- 在哪里运行(使用哪个操作系统)?
- 如何通过一系列步骤完成工作?
一个基本的工作流结构如下所示:
name: Example Workflow
on: [push] # 触发事件
jobs:
build:
runs-on: ubuntu-latest # 运行环境
steps:
- uses: actions/checkout@v2 # 步骤1:检出代码
- name: Run a script
run: echo "Hello, World!" # 步骤2:运行命令
本教程的核心信息是:每个人都可以编写工作流。这虽然是 CI/CD 的一部分,但你不需要特殊账户、管理员权限,也无需向 GitHub 支付额外费用或拥有专业版账户。你只需要一些耐心,以及我们将要介绍的一些技巧。
如何开始编写工作流 🛠️
大多数开发者可能已经 Fork 了 LLVM 仓库。如果你还没有,请先完成这一步。
通常,你不需要手动启用 Actions。在 LLVM 项目中,大多数工作流默认不会运行,因为它们受特定条件触发。少数会运行的工作流也有额外检查,导致它们在 Fork 的仓库中默认跳过。我们稍后会介绍如何让它们运行。
YAML 语法以敏感著称,加上 GitHub 自定义的 YAML 架构,情况可能更复杂,并且缺乏良好的验证。因此,即使你要编写全新的工作流,也建议从复制一个现有的工作流开始修改。
在分析现有工作流时,重点关注三个方面:
- 触发条件:什么事件启动了工作流?
- 操作对象:工作流查看什么内容?例如,是迭代评论、Issue 还是 PR?
- 运行结果:根据在评论或 PR 中发现的内容,仓库将如何被改变?
实战案例:新建 PR 工作流 📬
我们将以每个贡献者都见过的“新建 PR 工作流”为例。每当一个 PR 被创建或从草稿状态转为评审状态时,这个工作流就会启动。它最显著的功能是为每个 PR 添加标签。
例如,一个 PR 修改了某些特定文件夹,工作流会将这些修改路径翻译成对应的标签。这些标签随后用于通知相关团队,从而为你找到合适的代码评审者。
另一个不那么常见但部分贡献者会遇到的功能是:如果你是 LLVM 的新贡献者,你会收到一条评论,内容包含“感谢提交 PR”,并说明后续流程以及如何获取帮助。
需要指出的是,用于此功能的脚本是 LLVM 项目特有的,位于 monorepo 中。但它本质上只是 GitHub API 的一个封装。你并不一定要使用特殊脚本才能实现类似功能。
启用与测试工作流 ✅
之前提到,在 Fork 的仓库中,工作流可能被跳过。你需要在仓库的 Actions 标签页中启用它们。启用后,你可能会看到一些任务显示“此任务被跳过”。用户界面通常不会显示跳过原因,但这通常是由于一些指向上游 LLVM 仓库的检查条件导致的。
为了让工作流在你的 Fork 中运行,你可以将这些检查条件中的仓库引用改为你的用户名。修改后,工作流就会开始执行。
在工作流中,你能做的事情几乎是无限的。但由于时间有限,这里提供一些顶层建议:
- 如果能找到理解 YAML 的编辑器,请使用它。
- 进行小幅度编辑并频繁提交。这样,如果出现问题,你或许不知道原因,但能知道是哪个改动导致了问题。
- 如果需要实现复杂逻辑(多个“与”/“或”条件、大量括号),建议将这些逻辑写在脚本步骤或单独的脚本文件中,而不是直接写在 YAML 里。
- GitHub 官方文档非常有用。尽管存在一些重叠(因为访问 API 有不同方式),但文档通常能提供帮助。
- 由于所有这些都是 GitHub 特定的代码,如果你在使用一个非常小众的 API,可以直接使用 GitHub 的代码搜索功能。从数十亿行代码中,你很可能会找到一些好的示例。
工作流的测试策略与挑战 🧪
接下来,你需要确保工作流的行为符合预期。这时我们会遇到一个主要障碍:我们不控制远程的 runner,也不控制 GitHub 的基础设施。目前,你无法在本地运行工作流,甚至没有一个合适的集成测试框架来描述你的测试。
另一个问题是,虽然工作流查看的某些内容(如打开一个模拟 PR、添加模拟标签)很容易设置,但创建模拟用户账户会涉及灰色地带(需要购买新账户或违反服务条款)。因此,我们需要折中方案。
目前的折中方案(虽然希望有更好的方法)是手动测试,并意识到:虽然无法模拟所有输入,但可以通过临时微调代码来模拟输入变化,并以此进行测试。
理论上,测试流程如下:
- 制定一个手动测试计划,列出工作流可能经过的所有路径。
- 思考能否通过一组更改来模拟每条路径。
- 不断分解,直到得到可以通过一组更改进行测试的代码段。
- 测试该代码段,使其正常工作,然后重置仅为测试所做的更改。
- 重复此过程。
测试案例:新建 PR 欢迎语 🔄
以“新建 PR 欢迎语”功能为例,有两条基本路径:收到欢迎评论,或收不到。
在测试时,我的用户账户对我的 LLVM Fork 来说并非“新账户”。在不创建新账户的前提下,我无法模拟新用户。因此,我直接删除了检查用户是否为新账户的代码,运行工作流,确保收到了评论且格式正确。然后,我恢复检查代码,确保它不会尝试发布评论。
这样,我们测试的并非最终上线的版本,但在许多场景下,这已经是能做到的最好测试了。
贡献工作流 📤
完成所有测试并认为覆盖充分后,保存你进行测试的那个分支副本。这个分支可能会很混乱,但在这类开发中是正常现象。
然后,移除你的测试性修改,将仓库检查指回上游 LLVM,压缩提交历史。最后,贡献一个工作流就像贡献任何其他 LLVM 更改一样:提交 PR。事实上,你提交的 PR 也会触发“新建 PR 工作流”,并为你添加标签以找到评审者。
工作流的应用与展望 💡
以下是一些现有或潜在的工作流应用场景:
已在运行的功能:
- PR 自动标签:如前所述。
- 自动化反向移植:使用工作流处理。
- 代码格式化检查:如
clang-format和 Python 代码风格检查。 - 提交权限管理:通过工作流自动化请求和释放提交权限。
正在开发的功能:
- 预合并测试:目前 Buildkite 上的预合并测试正在迁移到 GitHub Actions。
- 清理上游仓库的用户分支:相关工具正在开发中。
- 协助无提交权限者合并 PR:作者正在开发一些工具来帮助这类贡献者。
尚未构建的想法:
- 维护者备注:当 PR 修改特定文件时,自动显示维护者留下的注意事项。
- 按需特殊构建:许多贡献者希望在提交前,能为可能破坏特殊配置的更改请求一次特殊构建。
- 推荐测试:评审者可以请求作者运行某些特定的测试配置。
- 领域特定检查清单:目前有一个针对
libc++的清单,但其他领域也有扩展空间。
总结 🎯
编写 GitHub 工作流可能会有些许挫折感,但本质上,你不需要任何额外的特殊权限。请记住,你所付出的小小挫折感和努力,会乘以每位贡献者、每个 Issue、每个 PR 实例。因此,自动化带来的效率提升回报巨大。

教程到此结束,感谢阅读。





052:RISC-V位运算取反指令优化


概述
在本节课中,我们将学习针对RISC-V架构中五种位运算取反指令的优化方法。这些指令包括 orn(或非)、andn(与非)等,它们不包含异或非指令,也不处理向量与向量、或向量与标量的操作。我们将通过三个具体的优化案例,了解如何在LLVM编译器中实现这些优化,以生成更高效的机器码。
核心概念与初始模式
首先,我们介绍这五种位运算取反指令。对于位宽为 W 的位运算,当其操作数之一是取反的,编译器会直接生成对应的取反指令。例如,对于 A & (~B) 这样的操作,会直接生成 andn 指令。
代码示例:
// 原始操作
result = A & (~B);
// 优化后生成的RISC-V指令
andn rd, rs1, rs2
当然,实际指令选择决策比这个简单的映射表要复杂得多。
优化一:针对常量的优化 🎯
上一节我们介绍了基本的指令生成模式,本节中我们来看看第一个优化点:针对与常量进行位运算的场景。
RISC-V指令的立即数字段是12位,这不足以加载任意32位常量。因此,根据常量的具体值,编译器可能需要使用 lui(加载高位立即数)指令,或 lui 后接 addi 指令的组合来加载常量。
如果取反后的常量需要更少的指令来加载,那么生成取反指令并使用取反后的常量就是更优的选择。
一个典型的32位常量例子是 0xFFFFFF00。我们避免将低8位设置为1。优化后,我们不再需要三条指令来加载原始常量 0x000000FF 再取反,而是直接用更少的指令加载取反后的常量 0xFFFFFF00 并执行 and 操作。
公式示例:
优化前:A & (~0x000000FF) -> 需加载 0x000000FF 再取反
优化后:A & 0xFFFFFF00 -> 直接加载 0xFFFFFF00
对于64位常量,情况更为复杂。LLVM中有一个专门的代价模型,包含超过500行代码,用于评估加载一个常量到寄存器所需的一系列不同指令的代价。
为了实现这个优化,我让编译器分别评估加载原始常量和加载取反后常量的代价。只有当加载取反后常量的指令数更少时,才应用这个转换。
优化二:循环内的优化 🔄
接下来,我们探讨第二个优化,它针对循环结构。
如果在循环之前有一个取反操作,而循环内部有一个位运算,我们可以将取反操作“沉入”循环内部,从而在循环内直接生成取反指令,避免在每次迭代前重复计算取反值。
我目前仅为RISC-V架构实现了这个优化。如果你在X86或ARM等其他平台上工作,这个优化思路同样值得考虑。
优化三:启用现有转换 ⚙️
我的最后一个改动是启用一些现有的、但未完全支持的转换。
我在代码中发现了一条 FIXME 注释,指出因为没有测试用例,所以某个转换未被实现。我为此添加了测试,并为向量类型实现了相应的处理函数。
给LLVM新开发者的建议 💡
如果你刚接触LLVM开发,不要花太多时间进行前期理论学习。相反,应该从一个具体的修改点开始实践。
以下是几种可行的入门方式:
- 分析汇编输出:查看编译器生成的汇编代码,寻找可以改进的低效模式。
- 处理Github工单:在LLVM的issue列表中挑选一个感兴趣的任务。
- 解决
TODO或FIXME注释:就像我做的第三个优化一样,修复代码中标注的问题。
决定修改方向后,中间表示(IR)的知识会对你很有帮助。你可以寻找相关的通用转换(DAG combines 或 IR transforms)作为参考。
最后一步是如何实现你的修改。一旦明确了要改什么以及在哪里改,实现方法通常就显而易见了。在这个过程中,你可以随时向其他贡献者寻求帮助。

总结

本节课中我们一起学习了针对RISC-V位运算取反指令的三项优化:1)通过评估加载代价,智能选择是否对常量取反以生成更优代码;2)将循环外的取反操作移至循环内,减少重复计算;3)通过补全测试和实现,启用编译器中已有的潜在优化转换。这些优化共同提升了RISC-V目标代码的生成效率。


053:使用BOLT优化AArch64平台的Clang/LLD工具链性能

概述
在本节课中,我们将学习如何通过BOLT(Binary Optimization and Layout Tool)工具来优化AArch64(ARM64)平台上的Clang/LLD编译器工具链。我们将探讨BOLT带来的性能提升,分析不同代码库(C++与C)对优化效果的影响,并研究通过扩展性能分析数据(Profiles)来进一步提升性能的可能性。
性能提升的初步探索
上一节我们介绍了课程目标,本节中我们来看看最初的性能测试结果。我们的目标是构建一个更快的编译器,使其在更短的时间内完成相同的工作量。
我们首先对Clang 18和19进行了实验,当Clang 20发布后,我们也将其纳入了测量范围。
下图展示了性能对比结果:
- 蓝线代表原始的Stage 1 Clang构建。
- 橙线代表经过LTO(链接时优化)、PGO(基于性能分析的优化)和BOLT流水线优化后的Clang构建。
从图表中可以看出,对于Clang 18和19,性能提升大约在12-15%之间。而对于最新发布的Clang 20版本,速度提升达到了27%。
这些结果令人印象深刻,但问题是:这种优化对所有工作负载都有效吗?
事实证明,并非如此。在实践中,我们发现经过BOLT优化的Clang在编译SQLite(一个C语言代码库)时,出现了4%的编译时间倒退。
我们推测出现倒退的原因可能有几个,我们的假设是:经过BOLT优化的Clang主要使用C++代码库进行分析训练,这对于SQLite这样的C代码库可能不够充分。
深入调查:C代码库的回归现象
我们想知道这仅是个例,还是能发现更多类似情况。为了验证这一点,我们决定运行C-Ray基准测试。
C-Ray是一个用于测量编译时性能的应用程序集合,其中超过一半的基准测试基于C代码。
从这张图表(基于Clang 19)可以看到,所有基于C代码的基准测试实际上都出现了性能倒退,最高可达6%。
然而,当我们将实验扩展到Clang 20时,我们得到了一个惊喜。性能倒退消失了,取而代之的是高达20%的性能提升。C-Ray基准测试也显示了相同趋势,基于C代码的基准测试不再出现倒退,反而有高达20%的改进。
这并非我们开始工作时所预期的结果,但问题依然存在:我们能否做得更好?
提出新假设:扩展性能分析数据
我们在最新Clang版本上看到的显著改进可能来自不同源头:可能有人更改了CMake配置、传递了不同的选项、BOLT学习了新的优化方式,或者添加了更多更好的性能分析数据。
考虑到所有这些因素,我们的新假设是:通过扩展性能分析阶段,我们可以进一步提高性能。
当前BOLT优化构建仅使用C++代码库的性能分析数据。也许我们可以通过加入C代码库来扩展分析阶段,收集不同的性能分析数据,将它们全部合并,并与已有的原始分析数据融合,然后输入给BOLT,从而进一步改进Clang。
实验验证:扩展性能分析数据的效果
我使用BOLT插桩来为C-Ray基准测试收集性能分析数据。
从图表中可以看出,这对于Clang 19版本确实有帮助。
- 绿色柱状图代表原始Stage 1构建的Clang 19与经过BOLT优化的Clang 19的对比。
- 灰色柱状图代表经过BOLT优化的Clang 19与使用了扩展性能分析数据的BOLT优化Clang 19的对比。
结果显示,性能倒退被修复了,并且我们能看到高达6%的额外提升。
但是,当我们查看Clang 20时,结果并不那么令人印象深刻。平均而言,我们只有1%的改进。同样,灰色柱状图代表使用了扩展性能分析数据的Clang 20。
让我们再看看Clang构建自身的编译时间变化。下图展示了在AArch64 Graviton CPU上编译LLVM所需的时间(秒)。
- 蓝线代表标准构建。
- 橙线代表BOLT优化构建。
- 绿线代表使用了扩展性能分析数据的BOLT优化Clang构建。
对于Clang 19版本,我们获得了额外的6%提升。但不幸的是,对于Clang 20,我们没有看到差异。
结论与总结
基于所有这些数据,我们可以得出以下结论:
-
Clang 20和BOLT 20表现卓越:在AArch64平台上,经过BOLT优化的Clang 20比原始的Clang 20 Stage 1构建快了近30%。它在Clang自身构建和C-Ray构建上都提供了相同水平的性能提升,并修复了我们在Clang 19及更早版本中遇到的问题。
-
是否应该对Clang发布版本使用BOLT? 答案是肯定的。据我所知,目前上游社区已经开始在最新的提交中这么做了。
-
扩展性能分析数据的实验也证明了其有效性。Clang 20经过BOLT优化后性能显著提升的主要原因是,它当前使用了LLVM测试套件的性能分析数据进行分析训练,这带来了巨大改进。当前被性能分析数据覆盖的函数百分比从6%增加到了15%。不幸的是,用更多数据(如C-Ray基准测试)进行训练几乎没有什么区别,这使我们得出结论:当前的Clang BOLT构建流程和配置已经足够。
未来工作方向
关于未来还能做些什么:
- C-Ray基准测试规模相对较小,因此可能值得研究更多、更大的应用程序,以覆盖更多函数。
- 另一个方向是,当前我们仅在BOLT阶段使用性能分析数据。也许我们也可以将这些数据用于PGO阶段,以获取更大的性能提升。


本节课中,我们一起学习了BOLT在优化AArch64平台Clang/LLD工具链中的应用、其带来的性能收益、不同代码库的影响,以及通过扩展性能分析数据进一步优化的尝试和结论。
embed 指令教程:P54:深入解析 Clang 中的 #embed


在本教程中,我们将学习 Clang 编译器中的 #embed 指令。#embed 是一种将数据(特别是二进制数据)嵌入到 C 和 C++ 源代码中的可移植方法。我们将探讨它的工作原理、性能优势、实现细节以及当前状态。
什么是 #embed 指令?
#embed 指令是一种将数据嵌入 C/C++ 代码的可移植方式。在它出现之前,虽然存在一些解决方案,但 #embed 是第一个标准化的、不依赖于特定编译器或链接器的实现。
该指令允许指定参数。例如,limit 参数可以控制从文件中导入多少数据。
#embed 指令类似于 #include 指令,由文件系统解析。开发者只需指定文件名,编译器会负责解析文件路径。
工作原理与性能优势
理解 #embed 的一个心智模型是:它将文件数据作为一个整数列表嵌入到代码中。但这并非 Clang 中的实际实现方式,原因在于性能。
为了说明性能差异,我们可以比较两种方案:
- 使用 Clang 实现的
#embed指令。 - 使用
xxd等工具将二进制数据转换为十六进制字面量并包含到程序中的“朴素”实现。
以下是性能对比数据(以图表形式展示,此处为描述):
- 编译时间:朴素方案的编译时间随数据量增长急剧上升(例如,包含 20MB 数据时,从约 5 秒增至 25 秒)。而 Clang 的
#embed实现编译时间极短,且增长平缓。 - 内存使用:朴素方案在包含 20MB 数据时,编译器内存使用超过 2GB。Clang 的
#embed实现内存使用增长远小于此。
因此,优化的实现至关重要。
Clang 中的实现机制
Clang 如何实现高性能的 #embed 呢?
在简单情况下,例如嵌入到 unsigned char 数组时,#embed 在抽象语法树中生成一个字符串字面量。这是最高效的实现方式。
然而,当目标数组类型不是 unsigned char 时,无法使用字符串字面量。此时,实现会使用一个称为 EmbedExpr 的额外 AST 节点来表达嵌入的数据。
EmbedExpr 将多个字节的数据表示为单个表达式,而不是像朴素实现那样为列表中的每个项生成一个表达式。这大大减少了 AST 的复杂度。
需要注意的是,EmbedExpr 只能出现在初始化列表表达式内部。
EmbedExpr 由 AST 消费者处理,其方式类似于数组填充。
通用情况与优化情况对比
我们可以比较通用情况(使用 EmbedExpr)和优化情况(使用字符串字面量)的性能。
以下是性能对比描述:
- 编译时间:在嵌入到
int数组的通用情况下,编译时间比使用unsigned char数组的优化情况稍慢。 - 但是,如果将通用情况与朴素实现对比,其性能优势依然非常明显。朴素实现的编译时间和内存占用仍然高出数个数量级。
关键点在于:如果 #embed 被用在“野生”环境(如直接用于初始化一个 int 变量),它将回退到类似整数字面量的形式,这会丧失性能优势。
当前状态与未来发展
#embed 指令在 Clang 中的状态如下:
- 自 Clang 19 起可用。
- 在 C23 标准中受支持。
- 在旧模式中作为 Clang 扩展 提供。
- 该指令有望被纳入 C++26 标准,目前作为 Clang 扩展提供。
实现中仍有一些问题需要解决。开发者 Maria 已创建相关标签来跟踪这些问题。如果你希望跟进开发或参与贡献,可以关注这些进展。
总结
本节课我们一起学习了 Clang 中的 #embed 指令。我们了解了它作为一种可移植数据嵌入方式的基本概念,通过性能对比看到了其相对于传统“朴素”方法的巨大优势。我们深入探讨了其实现机制,包括在 unsigned char 数组下的字符串字面量优化,以及在通用情况下使用的 EmbedExpr AST 节点。最后,我们回顾了该指令在 Clang 和 C/C++ 标准中的当前状态与未来展望。#embed 指令为在代码中嵌入资源提供了一种高效、标准化的解决方案。




055:理解 LLVM_ENABLE_PROJECTS 与 LLVM_ENABLE_RUNTIMES 🏗️

在本节课中,我们将要学习LLVM构建系统中两个核心配置选项的区别:LLVM_ENABLE_PROJECTS 和 LLVM_ENABLE_RUNTIMES。理解它们的差异是正确构建编译器及其运行时库的基础。
LLVM_ENABLE_PROJECTS 的工作方式
LLVM_ENABLE_PROJECTS 使用CMake在配置阶段检测到的编译器。这个编译器通常是系统默认的GCC。它主要用于为主机架构构建编译器本身(如Clang)。理论上,你也可以指定一个交叉编译器来构建编译器。
公式:-DLLVM_ENABLE_PROJECTS="clang"
它的工作流程是标准的CMake递归构建,会进入源码的子目录进行构建。
LLVM_ENABLE_RUNTIMES 的工作方式
与 LLVM_ENABLE_PROJECTS 不同,LLVM_ENABLE_RUNTIMES 使用之前通过 LLVM_ENABLE_PROJECTS 构建好的Clang编译器,来构建运行时库本身。这些运行时库是供编译出的代码(例如由你构建的Clang编译的程序)使用的,目标平台可能与构建Clang的主机平台不同。
公式:-DLLVM_ENABLE_RUNTIMES="compiler-rt" -DLLVM_ENABLE_PROJECTS="clang"
它的实现机制更特殊:CMake会创建一些“虚拟”目标,并为每个目标调用 LLVMExternalProject_Add。这会在你的构建目录(通常是 build/runtimes/runtimes-bins)内部启动一个全新的CMake构建过程。这意味着运行时库的构建发生在CMake的“构建”步骤,而非“配置”步骤,并且会确保所有依赖(如Clang、FileCheck等工具)先被构建完成。
上一节我们介绍了两个核心构建选项的基本概念,本节中我们来看看在使用 LLVM_ENABLE_RUNTIMES 时的一些高级配置选项。
运行时构建的配置选项
有一些不太被宣传的选项可以传递给运行时库的构建过程。这通过 -DCMAKE_C_FLAGS_FOR_RUNTIMES 等变量实现。
以下是几个关键选项:
- 传递编译器标志:例如,使用
-DCMAKE_CXX_FLAGS_FOR_RUNTIMES="-ferror-limit=5"。注意,用于构建运行时的Clang可能不接受与构建编译器时完全相同的标志(例如,-fmax-errors对应-ferror-limit)。 - 指定交叉编译目标:使用
-DRUNTIMES_TARGETS="aarch64-unknown-linux-gnu"。你还可以通过-DCMAKE_C_FLAGS_TARGET等为特定目标指定优化选项。 - 独立构建:你可以进行独立构建,此时可以使用任何编译器(如GCC)来编译运行时库。这需要你将源码顶层目录指定为
runtimes,并设置-DCMAKE_C_COMPILER=gcc。
理解了通用运行时构建机制后,本节我们来看看Flang运行时(flang-rt)原有的构建方式及其存在的问题。
Flang运行时的原有构建方式与挑战
Flang运行时之前有两种构建方式:
- “内联”构建:进入源码子目录,使用与构建Flang编译器相同的编译器进行构建。
- 独立构建:使用不同的顶层目录,并指定已构建好的LLVM/Clang路径和所需的编译器。
原有方式存在一个显著问题:十进制库(decimal library)同时被Flang运行时和编译器本身共享使用。这导致如果运行时为了支持CUDA而用NVCC编译,那么Flang编译器可执行文件也会依赖CUDA库。这是需要改变的一个原因,以使Flang运行时与其他LLVM运行时保持一致。
此外,还有以下挑战:
- 多目标支持:例如为AMD GPU编译时,需要为每个主机架构(x86, AArch64等)分别构建运行时,过程繁琐。
- 共享代码的依赖:共享的
libdecimal代码带来了问题,因为编译运行时和编译编译器本身可能对编译环境有不同要求(例如,LTO支持)。 - 环境依赖:某些功能(如128位浮点支持)的测试依赖于构建Flang的主机平台环境,而非最终运行代码的目标平台,这可能导致可移植性问题。
上一节我们分析了原有构建方式的问题,本节中我们来看看如何将Flang运行时集成到统一的 LLVM_ENABLE_RUNTIMES 框架中。
集成到 LLVM_ENABLE_RUNTIMES 的改动
核心改动是让Flang运行时能够使用LLVM现有的运行时构建系统。
主要构建方式:
使用 -DLLVM_ENABLE_RUNTIMES="flang-rt"。这种方式会使用已构建的Clang来编译Flang运行时。
代码示例:
cmake -DLLVM_ENABLE_PROJECTS="clang;flang" -DLLVM_ENABLE_RUNTIMES="flang-rt" ...
独立构建的支持:
独立构建仍然被支持。你需要将顶层目录设为 runtimes,并指定LLVM的路径。一个特殊之处在于,由于此时还没有运行时库,CMake检测编译器能力时会失败。因此,我们需要像Clang自举构建一样,告诉CMake忽略链接检查,假设编译器可以工作。
在集成过程中,需要进行一系列重构来解决问题并保持一致性。本节我们将介绍这些具体的重构工作。
重构与待解决的问题
为了成功集成,我们进行了以下关键重构:
- 库重命名:遵循LLVM运行时库的命名约定(如
libclang_rt.component),将Flang运行时库进行了重命名。十进制库(libdecimal)被集成到运行时中,用户无需关心编译器与运行时的内部代码结构。 - 源码与头文件分离:我们清晰地分离了仅用于运行时的代码和仅用于编译器的代码,特别是处理了那些混合两者的头文件。
- 共享库支持:使Flang运行时的共享库构建方式与其他LLVM运行时保持一致。
尽管取得了进展,仍有一些复杂问题待解决:
- Fortran模块支持:让Fortran模块也能使用运行时构建系统。
- 多目标构建的复杂性:同时为多个目标环境构建运行时非常复杂,目前尚未实现。
- Windows支持:Windows平台总有特殊之处需要处理。
- 实验性CUDA支持:使用NVCC编译运行时的实验性功能。


本节课中我们一起学习了LLVM构建系统中 LLVM_ENABLE_PROJECTS 与 LLVM_ENABLE_RUNTIMES 的核心区别,深入探讨了Flang运行时从原有特殊构建方式集成到统一运行时构建框架的过程、所做的重构以及面临的剩余挑战。掌握这些知识有助于你更灵活、正确地构建LLVM工具链及其组件。

浙公网安备 33010602011771号