MergeFunctions 过程详解

引言

有时代码包含相同的函数,或者即使在 IR 层面上不完全相同但执行相同操作的函数(例如:乘以 2 和 ‘shl 1’)。这可能是由于多种原因造成的:主要是模板的使用和自动代码生成器。不过,有时用户自己也会写两次相同的东西 :-)

此过程的主要目的是识别此类函数并将其合并。

本文档是对过程注释的扩展,并描述了过程逻辑。它描述了用于比较函数的算法,并解释了如何正确组合相等函数以保持模块的有效性。

材料以自上而下的形式呈现,因此读者可以从高级概念开始学习过程,并以低级算法细节结束,从而为阅读源代码做好准备。

主要目标是在这里描述算法和逻辑以及概念。如果您不想阅读源代码,但希望了解过程算法,则本文档适合您。作者尽量避免重复源代码,并且只涵盖常见情况,以避免在任何小的代码更改后都需要更新本文档的情况。

阅读本文档需要具备哪些基础知识?

读者应该熟悉常见的编译工程原理和 LLVM 代码基础。在本文中,我们假设读者熟悉静态单赋值的概念,并了解IR 结构

我们将使用诸如“模块”,“函数”,“基本块”,“用户”,“”,“指令”等术语。

作为良好的起点,可以使用 Kaleidoscope 教程

LLVM 教程:目录

尤其重要的是要理解教程的第 3 章

Kaleidoscope 教程

读者还应该了解 LLVM 中的过程如何工作。他们可以将本文用作参考和起点

编写 LLVM 过程(旧版 PM 版本)

还有什么?也许读者还应该在 LLVM 过程调试和错误修复方面有一些经验。

叙述结构

本文档分为三个部分。第一部分解释了过程在顶层的功能。第二部分描述了比较过程本身。第三部分描述了合并过程。

在每个部分中,作者都尝试以自上而下的形式呈现内容。首先将描述顶级方法,然后在每个部分的末尾描述终端方法。如果读者看到对尚未描述的方法的引用,他们将在下面找到其描述。

基础知识

如何实现?

我们需要合并函数吗?显而易见的答案是:是的,这是一种非常可能的情况。我们通常确实有重复项,并且最好去除它们。但是我们如何检测重复项呢?思路是:我们将函数分解成更小的块或部分,并比较“块”的数量。如果相等,我们比较“块”本身,然后得出关于函数本身的结论。

差异可能是什么?例如,在具有 64 位指针的机器上(假设我们只有一个地址空间),一个函数存储 64 位整数,而另一个函数存储指针。如果目标是上面提到的机器,并且如果函数相同,除了参数类型(我们可以将其视为函数类型的一部分),那么我们可以将uint64_tvoid*视为相等。

这只是一个例子;更多可能的细节将在下面描述。

作为另一个例子,读者可以想象另外两个函数。第一个函数执行乘以 2 的操作,而第二个函数执行向左逻辑移位 1 位的操作。

可能的解决方案

让我们简要考虑一下关于如何以及我们必须实现什么才能创建功能齐全的函数合并的可能选项,以及它对我们的意义。

相等函数检测显然假设要实现一个“检测器”方法,并且后者应该回答“函数是否相等”的问题。此“检测器”方法由微小的“子检测器”组成,每个子检测器都回答完全相同的问题,但针对函数的部分。

作为第二步,我们应该合并相等函数。因此,它应该是一个“合并器”方法。“合并器”接受两个函数F1F2,并生成F1F2函数,即合并的结果。

有了这些例程,我们可以处理整个模块,并合并所有相等函数。

在这种情况下,我们必须将每个函数与每个其他函数进行比较。正如读者可能注意到的,这种方式似乎非常昂贵。当然,我们可以引入哈希和其他辅助工具,但这仍然只是一个优化,因此复杂度级别为 O(N*N)。

我们能达到另一个级别吗?我们可以引入对数搜索或随机访问查找吗?答案是:“可以”。

随机访问

如何做到这一点?只需将每个函数转换为一个数字,并将它们全部收集到一个特殊的哈希表中。具有相同哈希值的函数是相等的。良好的哈希意味着必须考虑每个函数部分。这意味着我们必须将每个函数部分转换为某个数字,然后将其添加到哈希中。查找时间会很短,但这种方法由于哈希例程而增加了一些延迟。

现状

这两种方法(随机访问和对数)都已实现并经过测试,并且都提供了非常好的改进。最令人惊讶的是,对数搜索更快;有时快达 15%。哈希方法需要一些额外的 CPU 时间,这是它运行速度较慢的主要原因;在大多数情况下,总“哈希”时间大于总“对数搜索”时间。

因此,我们更倾向于使用“对数搜索”。

尽管在需要的情况下,对数搜索(读取“总排序”)可以作为我们实现随机访问的里程碑。

每次比较都基于数字或标志比较。在随机访问方法中,我们可以使用相同的比较算法。在比较期间,一旦我们发现差异,我们就退出,但在这里我们可能每次都必须扫描整个函数体(注意,这可能会更慢)。像在“总排序”中一样,我们将跟踪每个数字和标志,但不是比较,而是应该获取数字序列,然后创建哈希数字。因此,再次,总排序可以被视为更快的(理论上)随机访问方法的里程碑。

MergeFunctions,主要字段和 runOnModule

类中有两个主要的重要字段

FnTree – 所有唯一函数的集合。它保存了无法相互合并的项。它被定义为

std::set<FunctionNode> FnTree;

这里FunctionNodellvm::Function类的包装器,在函数集中实现了“<”运算符(下面我们将解释其确切的工作原理;这是快速函数比较的关键点)。

Deferred – 合并过程可能会影响已经在FnTree中的函数体。显然,此类函数应再次重新检查。在这种情况下,我们将其从FnTree中删除,并将其标记为重新扫描,即将其放入Deferred列表中。

runOnModule

算法非常简单

  1. 将模块的所有函数放入工作列表中。

2. 扫描工作列表中的函数两次:首先仅枚举强函数,然后仅枚举弱函数

2.1. 循环体:从工作列表中取出一个函数(称为FCur),并尝试将其插入FnTree:检查FCur是否等于FnTree中某个函数。如果FnTree存在相等函数(称为FExists):将函数FCurFExists合并。否则将来自工作列表的函数添加到FnTree

3. 一旦工作列表扫描和合并操作完成,检查Deferred列表。如果它不为空:用Deferred列表重新填充工作列表内容并重新执行步骤 2,如果Deferred列表为空,则退出方法。

函数比较

首先,让我们定义如何精确地比较复杂对象。

复杂对象比较(函数、基本块等)主要基于其子对象比较结果。它类似于下一个“树”对象比较

  1. 对于两棵树T1T2,我们执行深度优先遍历,并产生两个序列:“T1Items”和“T2Items”。

  2. 然后,我们按最显著项优先的顺序比较链“T1Items”和“T2Items”。项比较的结果将是T1T2本身比较的结果。

FunctionComparator::compare(void)

简单看一下源代码,我们会发现比较从“int FunctionComparator::compare(void)”方法开始。

1. 要比较的第一部分是函数的属性和一些不在“属性”术语范围内的属性,但仍然可能使函数不同而无需更改其主体。这部分比较通常在简单的cmpNumberscmpFlags操作中完成(例如cmpFlags(F1->hasGC(), F2->hasGC()))。以下是此阶段要比较的函数属性的完整列表

  • 属性(这些由Function::getAttributes()方法返回)。

  • GC,为了等价,RHSLHS都应该既没有GC,或者具有相同的GC

  • ,就像GC一样:RHSLHS应该在同一节中定义。

  • 可变参数LHSRHS都应该既有或没有var-args

  • 调用约定应该相同。

2. 函数类型。由FunctionComparator::cmpType(Type*, Type*)方法检查。它检查返回类型和参数类型;该方法本身将在后面描述。

3. 将关联函数形式参数彼此关联。然后比较函数体,如果我们在LHS的主体中看到LHS的第i个参数的使用,那么,我们希望在RHS的主体中的相同位置看到RHS的第i个参数的使用,否则函数将不同。在此阶段,我们优先考虑我们在函数体中稍后遇到的那些(我们首先遇到的值将较小)。这是通过“FunctionComparator::cmpValues(const Value*, const Value*)”方法完成的(将在稍后描述)。

  1. 函数体比较。正如方法注释中所写

“我们进行 CFG 顺序遍历,因为链接列表中块的实际顺序无关紧要。我们的遍历从两个函数的入口块开始,然后按顺序从每个终结器中获取每个块。作为人工制品,这也意味着不可达块将被忽略。”

因此,使用此遍历,我们按相同顺序从左侧右侧获取 BB,并通过“FunctionComparator::compare(const BasicBlock*, const BasicBlock*)”方法进行比较。

我们还将 BB 彼此关联,就像我们对函数形式参数所做的那样(请参阅下面的cmpValues方法)。

FunctionComparator::cmpType

考虑类型比较是如何工作的。

1. 将指针强制转换为整数。如果左侧类型是指针,请尝试将其强制转换为整数类型。如果其地址空间为 0,或者如果根本忽略地址空间,则可以执行此操作。对右侧类型执行相同的操作。

2. 如果左侧和右侧类型相等,则返回 0。否则,我们需要优先考虑其中一个。因此,继续下一步。

3. 如果类型种类不同(类型 ID 不同)。返回类型 ID 比较的结果,将它们视为数字(使用cmpNumbers操作)。

4. 如果类型是向量或整数,则返回其指针比较的结果,将它们作为数字进行比较。

  1. 检查类型 ID 是否属于下一组(称为等价组)

    • 浮点

    • 双精度

    • X86_FP80

    • FP128

    • PPC_FP128

    • 标签

    • 元数据。

    如果 ID 属于上面的组,则返回 0。因为看到类型具有相同的TypeID就足够了。不需要其他信息。

6. 左侧和右侧是指针。返回地址空间比较的结果(数字比较)。

7. 复杂类型(结构体、数组等)。遵循复杂对象比较技术(请参阅本章的第一段)。左侧右侧都将被展开,并且它们的元素类型将以相同的方式进行检查。如果我们在某个阶段得到 -1 或 1,则返回它。否则返回 0。

8. 步骤 1-6 描述了所有可能的情况,如果我们通过了步骤 1-6 并且没有得出任何结论,那么调用llvm_unreachable,因为这是一个非常意外的情况。

cmpValues(const Value*, const Value*)

比较局部值的方法。

此方法为我们提供了一个非常好奇的问题的答案:我们是否可以将局部值视为相等,否则哪个值更大。最好从示例开始

考虑当我们查看左侧函数“FL”和右侧函数“FR”中的相同位置时的情况。左侧位置的每个部分都等于右侧位置的对应部分,并且 (!) 两部分都使用Value实例,例如

instr0 i32 %LV   ; left side, function FL
instr0 i32 %RV   ; right side, function FR

因此,现在我们的结论取决于Value实例的比较。

此方法的主要目的是确定此类值之间的关系。

我们对相等函数有什么期望?在函数“FL”和“FR”中的相同位置,我们期望看到相等的值,或在“FL”和“FR”中的相同位置定义的值。

在此处考虑一个小示例

define void %f(i32 %pf0, i32 %pf1) {
  instr0 i32 %pf0 instr1 i32 %pf1 instr2 i32 123
}
define void %g(i32 %pg0, i32 %pg1) {
  instr0 i32 %pg0 instr1 i32 %pg0 instr2 i32 123
}

在此示例中,pf0pg0关联,pf1pg1关联,我们还声明pf0 < pf1,因此pg0 < pf1

具有操作码“instr0”的指令将相等,因为它们的类型和操作码相等,并且值是关联的。

来自f的操作码为“instr1”的指令大于来自g的操作码为“instr1”的指令;这里我们有相等的类型和操作码,但“pf1大于“pg0”。

具有操作码“instr2”的指令相等,因为它们的指令和类型相等,并且使用相同的常量作为值。

我们在 cmpValues 中关联什么?

  • 函数参数。左侧函数的第i个参数与右侧函数的第i个参数关联。

  • BasicBlock 实例。在基本块枚举循环中,我们将左侧函数的第i个 BasicBlock 与右侧函数的第i个 BasicBlock 关联。

  • 指令。

  • 指令操作数。请注意,我们可能会在这里遇到之前从未见过的Value。在这种情况下,它不是函数参数,也不是BasicBlock,也不是Instruction。它是一个全局值。它是一个常量,因为它是此处唯一假定的全局值。该方法还比较:类型相同的常量,如果右侧常量可以无损地位转换为左侧常量,那么我们也比较它们。

如何实现 cmpValues?

关联对我们来说是一种相等的情况。我们只是将这些值视为相等,但是,总的来说,我们需要实现反对称关系。如上所述,要了解什么是较小,我们可以使用我们遇到值的顺序。如果两个值在函数中具有相同的顺序(同时遇到),那么我们将这些值视为关联的。否则 - 取决于谁先出现。

每次我们运行顶级比较方法时,都会初始化两个相同的映射(一个用于左侧,另一个用于右侧)

map<Value, int> sn_mapL, sn_mapR;

映射的键是Value本身,value是其顺序(称为序列号)。

要添加值V,我们需要执行以下过程

sn_map.insert(std::make_pair(V, sn_map.size()));

对于第一个Value,映射将返回0,对于第二个Value,映射将返回1,以此类推。

然后,我们可以通过简单的比较来检查左右值是否同时出现

cmpNumbers(sn_mapL[Left], sn_mapR[Right]);

当然,我们可以将插入和比较结合起来

std::pair<iterator, bool>
  LeftRes = sn_mapL.insert(std::make_pair(Left, sn_mapL.size())), RightRes
  = sn_mapR.insert(std::make_pair(Right, sn_mapR.size()));
return cmpNumbers(LeftRes.first->second, RightRes.first->second);

让我们看看整个方法是如何实现的。

1. 我们必须从坏消息开始。考虑函数自身和交叉引用案例

// self-reference unsigned fact0(unsigned n) { return n > 1 ? n
* fact0(n-1) : 1; } unsigned fact1(unsigned n) { return n > 1 ? n *
fact1(n-1) : 1; }

// cross-reference unsigned ping(unsigned n) { return n!= 0 ? pong(n-1) : 0;
} unsigned pong(unsigned n) { return n!= 0 ? ping(n-1) : 0; }

此比较已在初始的MergeFunctions传递版本中实现。但是,不幸的是,它不是传递的。这是我们唯一无法转换为小于等于大于比较的情况。这是一种罕见的情况,10000 个函数中的 4-5 个(在测试套件中检查),并且我们希望读者能够原谅我们为了获得 O(log(N)) 的传递时间而做出的这种牺牲。

2. 如果左/右Value是常量,我们必须比较它们。如果它是相同的常量,则返回 0,否则使用cmpConstants方法。

3. 如果左/右是InlineAsm实例。返回Value指针比较的结果。

4. L(左值)和R(右值)的显式关联。我们需要找出值是否同时出现,因此是关联的。或者我们需要制定规则:当我们处理L < R时。现在很容易:我们只需返回数字比较的结果

std::pair<iterator, bool>
  LeftRes = sn_mapL.insert(std::make_pair(Left, sn_mapL.size())),
  RightRes = sn_mapR.insert(std::make_pair(Right, sn_mapR.size()));
if (LeftRes.first->second == RightRes.first->second) return 0;
if (LeftRes.first->second < RightRes.first->second) return -1;
return 1;

现在当cmpValues返回 0 时,我们可以继续比较过程。否则,如果我们得到(-1 或 1),我们需要将此结果传递到顶层,并完成比较过程。

cmpConstants

按如下方式执行常量比较

1. 使用cmpType方法比较常量类型。如果结果是 -1 或 1,则转到步骤 2,否则继续执行步骤 3。

2. 如果类型不同,我们仍然可以检查常量是否可以无损地相互进行位转换。进一步的解释是canLosslesslyBitCastTo方法的修改。

2.1 检查常量是否属于第一类类型(isFirstClassType检查)

2.1.1. 如果两个常量都不属于第一类类型:返回cmpType的结果。

2.1.2. 否则,如果左侧类型不属于第一类,则返回 -1。如果右侧类型不属于第一类,则返回 1。

2.1.3. 如果两种类型都属于第一类类型,则继续执行下一步(2.1.3.1)。

2.1.3.1. 如果类型是向量,则使用cmpNumbers比较它们的位宽。如果结果不为 0,则返回它。

2.1.3.2. 类型不同,但不是向量

  • 如果它们都是指针,对我们来说很好,我们可以继续执行步骤 3。

  • 如果其中一个类型是指针,则返回isPointer标志比较的结果(cmpFlags操作)。

  • 否则,我们没有方法来证明位转换性,因此返回类型比较的结果(-1 或 1)。

以下步骤适用于类型相等的情况,或常量可位转换的情况

3. 其中一个常量是“null”值。返回cmpFlags(L->isNullValue, R->isNullValue)比较的结果。

  1. 比较值 ID,如果它不为 0,则返回结果

if (int Res = cmpNumbers(L->getValueID(), R->getValueID()))
  return Res;

5. 比较常量的内容。比较取决于常量的种类,但在这一阶段它只是一个词法比较。只需查看它在“函数比较”段落的开头是如何描述的。在数学上,它等于以下情况:我们对左常量和右常量进行编码(与bitcode-writer的方式类似)。然后比较左侧代码序列和右侧代码序列。

compare(const BasicBlock*, const BasicBlock*)

比较两个BasicBlock实例。

它枚举来自左侧BB和右侧BB的指令。

1. 它使用cmpValues方法为左侧和右侧指令分配序列号。

2. 如果左或右之一是GEPGetElementPtr),则将GEP视为大于其他指令。如果两个指令都是GEP,则使用cmpGEP方法进行比较。如果结果是 -1 或 1,则将其传递到顶级比较(返回它)。

3.1. 比较操作。调用cmpOperation方法。如果结果是 -1 或 1,则返回它。

3.2. 比较操作数的数量,如果结果是 -1 或 1,则返回它。

3.3. 比较操作数本身,使用cmpValues方法。如果结果是 -1 或 1,则返回结果。

3.4. 比较操作数的类型,使用cmpType方法。如果结果是 -1 或 1,则返回结果。

3.5. 继续执行下一条指令。

  1. 我们可以在三种情况下完成指令枚举

    4.1. 我们到达了左右两个基本块的末尾。我们没有在步骤 1-3 中退出,因此内容相等,返回 0。

    4.2. 我们已经到达了左侧基本块的末尾。返回 -1。

    4.3. 返回 1(我们到达了右侧基本块的末尾)。

cmpGEP

比较两个 GEP(getelementptr指令)。

它与常规操作比较的不同之处在于:可以使用accumulateConstantOffset方法。

因此,如果我们为左右两个GEP都获得了常量偏移量,则将其作为数字进行比较,并返回比较结果。

否则将其视为常规操作(参见上一段)。

cmpOperation

比较指令操作码和一些重要的操作属性。

  1. 比较操作码,如果不同则返回结果。

  2. 比较操作数的数量。如果不同 - 返回结果。

3. 比较操作类型,使用cmpType。同样 - 如果类型不同,则返回结果。

4. 比较subclassOptionalData,使用getRawSubclassOptionalData方法获取它,并将其作为数字进行比较。

  1. 比较操作数类型。

6. 对于某些特定指令,检查某些重要属性的等价性(在我们的案例中为关系)。例如,我们必须比较load指令的对齐方式。

O(log(N))

上面描述的方法实现了顺序关系。然后,可用于二叉树中的节点比较。因此,我们可以将函数集组织成二叉树,并将查找过程的成本从 O(N*N) 降低到 O(log(N))。

合并过程,mergeTwoFunctions

一旦MergeFunctions检测到当前函数(G)等于之前分析过的函数(函数F),它就会调用mergeTwoFunctions(Function*, Function*)

操作以以下方式影响FnTree内容:F将保留在FnTree中。等于FG不会添加到FnTree中。G的调用将被替换为其他内容。它更改了调用者的主体。因此,调用G的函数将被放入Deferred集合中并从FnTree中移除,并再次进行分析。

方法如下

1. 最理想的情况:当我们可以使用别名并且FG都是弱的时。我们使它们都使用到第三个强函数H的别名。实际上,HF。请参见下文是如何实现的(但最好直接查看源代码)。好吧,当我们可以将G替换为F时,我们在这里使用replaceAllUsesWith操作(RAUW)。

2. F不能被覆盖,而G可以。最好执行以下操作:合并可覆盖函数使用的位置后,仍然使用可覆盖存根。因此,尝试使G成为F的别名,或在F周围创建可覆盖尾调用包装器,并用该调用替换G

3. FG都不能被覆盖。我们不能使用RAUW。我们只能更改调用者:调用F而不是G。这就是replaceDirectCallers的作用。

下面是详细的正文描述。

如果“F”可能被覆盖

mayBeOverridden注释中可以看出:“此全局定义是否可能在链接时被非等效的内容替换”。如果是这样,那很好:我们可以使用F的别名代替G或更改调用指令本身。

HasGlobalAliases, removeUsers

首先考虑当我们有一个函数名称到另一个函数的全局别名的情况。我们的目的是使它们都使用到第三个强函数的别名。尽管如果我们保持F存活并且没有重大更改,我们可以将其保留在FnTree中。尝试结合这两个目标。

使用指向F的别名替换F本身的存根。

1. 创建存根函数H,其名称和属性与函数F相同。它采用FG的最大对齐方式。

2. 将函数F的所有使用替换为函数H的使用。这是一个两步过程。首先,我们必须考虑到,所有调用F的函数都将被更改:因为我们更改了调用参数(从FH)。如果是这样,我们必须在此过程之后重新检查这些调用者函数。我们从FnTree中删除调用者,名为removeUsers(F)的方法执行此操作(不要与replaceAllUsesWith混淆)

2.1. 在 removeUsers(Value* V) 函数中,我们遍历所有使用值 V(在我们的上下文中为 F)的指令。如果值是指令,我们跳转到持有该指令的函数,并将其标记为需要重新分析(放入 Deferred 集合中),同时我们也从 FnTree 中移除调用者。

2.2. 现在我们可以进行替换:调用 F->replaceAllUsesWith(H)

3. H(现在“正式”扮演了 F 的角色)被替换为 F 的别名。对 G 也进行同样的操作:将其替换为 F 的别名。因此,最终在所有使用 F 的地方,我们都使用 H,它是 F 的别名,在所有使用 G 的地方,我们也使用 F 的别名。

  1. F 的链接设置为私有。使其变得强大 :-)

无全局别名,replaceDirectCallers

如果全局别名不被支持。我们调用 replaceDirectCallers。只需遍历 G 的所有调用,并将其替换为 F 的调用。如果你查看该方法,你会发现它也会扫描 G 的所有使用情况,如果使用是调用者(如果用户是调用指令并且 G 用作被调用者),我们将它替换为 F 的使用。

如果“F”无法被覆盖,修复它!

我们调用 writeThunkOrAlias(Function *F, Function *G)。在这里,我们首先尝试将 G 替换为 F 的别名。以下条件至关重要

  • 目标应该支持全局别名,

  • G 本身的地址不应该具有特殊含义,不应命名且不应在任何地方被引用,

  • 函数应该带有外部、本地或弱链接。

否则,我们编写 thunk:一个具有 G 接口并调用 F 的包装器,以便 G 可以被此包装器替换。

writeAlias

根据 llvm 参考

“别名充当被别名化的值的 第二个名称”。因此,我们只想为 F 创建一个第二个名称,并用它代替 G

  1. 创建全局别名本身(GA),

  2. 调整 F 的对齐方式,使其必须是当前对齐方式和 G 对齐方式的最大值;

  3. 替换 G 的使用

    3.1. 首先使用 removeUsers 方法(参见上一章)将 G 的所有调用者标记为需要重新分析,

    3.2. 调用 G->replaceAllUsesWith(GA)

  4. 删除 G

writeThunk

正如方法注释中所写

“用对 bitcast(F) 的简单尾调用替换 G。还用 bitcast(F) 替换 G 的直接使用。删除 G。”

总的来说,当我们想要替换被调用者时,它执行的操作与往常一样,除了第一点

1. 我们在 F 周围生成尾调用包装器,但具有允许用它代替 G 的接口。

  1. “照常”:然后是 removeUsersreplaceAllUsesWith

  2. 删除 G