LLVM IR中的设计问题

2021-06-09 13:28:15

总的来说,LLVM具有精心设计的中间表示(IR),该代表在语言参考中指定。但是,已经有许多设计错误的领域。虽然LLVM项目一般开放待解决此类问题,但核心红外设计中的错误往往牢固地嵌入代码库中,使其难以在实践中进行修复。这篇博文讨论了一些问题。

在中端,我们通常认为变换不是优化,而是作为规范化。有许多不同的方法来编写相同的程序,目标无关的中端变换的目的是将它们减少到单个形式。当然,所选形式通常(但并不总是)与更有效的形式一致。 (这不适用于所有转换,例如运行时展开和矢量化肯定不是Canonicalization Transforms。)

为什么我们关心规范化?主要原因是它减少了其他通行证需要处理的排列数量。如果1 + A可以是A + 1的,则只需处理后一种形式即可。另一个原因是它提高了冗余消除变换的有效性(如常见的子表达消除和全局值编号)。让我们来看看一个例子:

定义I1 @Test(I32%x){%y =子I32%x,1%z =添加I32%x,-1%c = ICMP eq i32%y,%z Ret I1%C}

定义I1 @Test(I32%x){%y =添加I32%x,-1%z =添加I32%x,-1%c = ICMP eq i32%y,%z Ret I1%C}

此时,检测%y和%z实际上是相同的,因此我们可以用%y替换%y:

定义I1 @Test(I32%x){%y =添加I32%x,-1%c = ICMP eq i32%y,%y ret i1%c}

一旦完成,我们也可以看到平等比较是琐碎的:

那么,这与IR设计有什么关系?良好的IR设计可以通过设计产生某些冗余。主要是,这是“只是”一个没有编码任何不必要的信息的问题。

例如,在符号和无符号整数之间区分的早期LLVM版本,而更高版本只有单个整数类型(对于给定的位宽),并且只在少数地方编码的符号仅在少数地方编码:比较,扩展和溢出标志。这意味着,无论x是否签名,x + 1只有一个表示。这是核心IR设计的结果,不需要额外的规范化。

您会注意到以下讨论的问题是令人愤怒的Quire。

目前,LLVM指针类型看起来很像C指针类型:i8 *是一个指向8位整数的指针。让我们在I8 *指针周围复制:

定义void @ copy.i8ptr(i8 **%dst,i8 **%src){%val = load i8 *,i8 **%src store i8 *%val,i8 **%dest ret void}

定义void @ copy.i32ptr(i32 **%dst,i32 **%src){%val = load i32 *,i32 **%src store i32 *%val,i32 **%dest vid}

精明读者会注意到这两条代码完全相同。它们将指针大小从一个位置复制到另一个位置,并且这一事实是指向i8或i32的指针完全无关紧要。如果我们检测到这一事实并希望通过在另一个方面实现一个来合并这些功能,我们必须插入BITCASTS:

定义void @ copy.i32ptr(i32 **%dst,i32 **%src){%dst.i8ptr = bitcact i32 **%dst到i8 *%src.i8ptr = bitct i32 **%src到i8 * call void @cop.i8ptr(i8 **%dst.i8ptr,i8 **%src.i8ptr)ret void}

BITCASTS是表示在不改变其二进制表示的情况下重新解释不同类型的值的演员的LLVM方式。只需要这些指针位卡,因为LLVM对不必要的指针元素类型进行编码。

指针类型转换是普遍存在的,并且在数据通过不同的图层时经常发生。当代表已知尺寸的特定对象时,指针可以启动为[8 x i32] *当被解释为切片时变为[0 x i32] *,然后当作为通用指针传递到memcpy时,i8 *。

值得庆幸的是,LLVM正在删除指针元素类型并切换到不透明指针。通过不透明的指针,上面的代码会如下:

定义void @ copy.ptr(ptr%dst,ptr%src){%val = load ptr,ptr%src store ptr%val,ptr%dest ret void}

这呈现函数等效的函数,并删除任何对指针位卡的任何需要。在此提出的一个有趣的问题是:我们不能进一步走一步,只需将指针视为整数?在具有64位地址空间的平台上说i64:

定义void @ copy.i64(i64%dst,i64%src){%val = load i64,i64%src store i64%val,i64%dest void}

这是不可能的两个主要原因。首先是指针携带出处,这对于别名分析很重要。指针具有“底层对象”,它基于,并取消引用它仅在它指向该对象时才有效。整数不跟踪出处,因此整数和指针之间的这种转换不被视为无操作(并且没有表示为BitCasts)。

第二种是存在不同类型的指针:即使使用不透明指针,LLVM仍然区分不同地址空间中的指针。虽然这些在von neumann架构上是相同的,但是某些目标可能具有用于数据和指令的单独地址空间,其可能表示为ptr和ptr addrspace(1)。指针甚至可能是非积分的,在这种情况下,它们根本没有整数表示。

多年来,迁移到不透明指针已经“正在进行”,尽管这一进展相当慢,最近才再次拾取。 LLVM本身的不透明指针的状态实际上是相当不错的,因为存在一般性意识,并且尝试使用指针元素类型的任何优化都不会通过审查。然而,铿cl的国家是外交的,少于恒星。我特别喜欢这个可爱的评论 - “简单”的确。

LLVM使用GetElementPtr指令执行地址计算,该指令接受基本指针和许多索引:

%struct = type {i32,{i32,{[2 x i32]}}定义i32 * @test(%struct *%base){%ptr = getElementptr%struct,%struct *%base,i64 1,i32 1 ,I32 1,I32 0,I64 1 RET I32 *%PTR}

第一个I64意味着我们将第二个元素从指针,类似于C的&基础[1]。以下索引向下深入到嵌套结构和阵列结构中的特定元素。假设I32是4字节对齐,如果添加所有偏移,则此GetElementPtr相当于以下内容:

%struct = type {i32,{i32,{[2 x i32]}}定义i32 * @test(%struct *%base){%base.i8 = bitcast%struct *%base to i8 *%ptr = getElementptr i8,i8 *%base.i8,i64 11 ret i32 *%ptr}

这两个GEPS以两种不同的方式计算相同的地址,这增加了另一个规范化问题。这还意味着分析通常需要将基于类型的GEP分解为基于偏移的计算。这令人惊讶地昂贵,因为它需要从数据布局信息计算所有涉及类型的商店大小,包括潜在的对齐约束。除了GEP偏移量计算中,占总编译时的总编译时间占总编译的百分比并不明确。

更好的替代方案是使GEPS直接偏移基于,它与不透明指针一起使用:

定义PTR @Test(PTR%基础){%PTR = GetElentemptr Ptr%Base.i8,I64 11 Ret Ptr%PTR}

什么并不清楚是如何编码变量指数。必要的规模可以是GEP指令的一部分:

定义PTR @Test(PTR%基础,I64%索引){%PTR = GetElementPtr Ptr%Base.i8,4 *%Index Ret Ptr%PTR}

定义PTR @Test(PTR%基础,I64%索引){%Offset = SHL I64%偏移量,2%PTR = GetElementPtr Ptr%Base.i8,I64%Offset Ret Ptr%PTR}

后一变种再次有益于规范化的角度,因为在GEP中嵌入了显式偏移计算和偏移计算之间没有选择。然而,入境导致对偏移量计算的额外约束,这将丢失这种编码。

GEP指令不是唯一的类型的类型的信息。例如,用于保留堆栈空间的AlloCA指令接受类型,而实际只需要知道要保留的字节数:

%PTR = AlloCA [8 x I32],对齐4;应该是%ptr = Alloca I64 32,对齐4

然而,在实践中,Canonicality对Allocas并不是特别重要的,因为它们通常不受冗余消除或结构平等检查。

关于恒定表达的一件好事是它们在建造时最大折叠和圆角。所以这个例子实际上将被转换为

解析文件时。这对规范化不是很好吗?通过施工,我们不能具有相同常量表达的不同表示,因为它总是完全折叠!

不幸的是,事情并不简单。每个LLVM上下文都是唯一的表达式,这不是数据布局意识。这意味着在施工时,只能执行不依赖于数据布局的折叠。

define i64 @ sizeof_int64(){ret i64 ptropoint(i64 * getelementptr(i64,i64 * null,i64 1)到i64)}

该表达式是I64的存储大小的编码,并且需要了解由数据布局提供的I64的ABI对齐,而不是由常量表达式本身所知。

这意味着存在两级常量折叠:一个目标无关并且在结构上发生的级别,以及由某些通过的目标依赖于指令组合所依赖的。第二折叠需要递归地走路所有引用的恒定表达,并尝试在各个层面折叠它们,基本上击败“总是折叠”表示的好处。

然而,它超出了这一点。指令和常量表达式通常具有相同的约束来允许均匀处理,这意味着我们无法强制执行依赖于数据布局的IR有效性约束。例如,PTRTOINT指令/表达式允许整数类型具有与指针类型不同的大小,并且如果它们不匹配,则执行隐式截断或零扩展。在实践中不会出现这种IR,因为它将被规范化为明确的Trunc或Zext,但所有通过仍然需要处理这种可能性。我们实际上无法强制执行大小是相同的,因为常量表达式没有数据布局。

但是,数据布局只是问题的一部分。另一个问题是简单的事实,即可以使用指令或常量表达式表示操作。这意味着代码通常需要能够处理两个表示,以避免如果发生常量折叠(进入表达式),则避免倾向于优化。由于许多通用模式匹配者透明地处理指令和恒定表达式的许多通用模式匹配者,LLVM通常很好。

一个显着的并发症是很多代码假定执行常量的操作是“免费”。例如,通过指令序列推出否定的优化可能假设将否定释放到常量操作数是免费的,因为它将仅转1到-1或类似。当然,对于常量表达来说,这不是真的,其中否定可能无法折叠,您将留下sub(i64 0,i64 ...)。

这是优化器中最常见的无限环路源之一。变换将操作推入常量,假设它将折叠。由于常量表达,它没有。不同的变换可能会看到新的常量表达式并执行反向变化,从而产生无限的组合循环。

定义I64 @Test(I64%x1){%x2 = mul i64%x1,%x1%x3 = mul i64%x2,%x2%x4 = mul i64%x3,%x3%x5 = mul i64%x4,%x4 RET I64%X5}

写入说明,这是一个漂亮的线性链,其中每个指令都使用两次。使用常量表达式,同样也适用于内存中的表示。但是,打印出作为文本IR,无法重用常数的子表达式,这些常数呈现多次,因此根值%x1将最终重复2 ^ 4次。易于构建IR不能在任何合理的时间内打印。

最后,随着在恒定表达式内部发生的差异,恒定的实现可以触发未定义的行为。这具有奇怪的含义:例如,由于“常量”操作数,可以呈现指令。这将基本上从未发生过实践,但仍然需要考虑和占据。

那么替代方案是什么?在施工期间具有数据布局(即使产生的常数是数据布局而是独立的)将解决问题的一部分。其余部分需要删除常量表达的概念,或者更改其范围。

常量表达式确实存在:Global Initializers,它只接受常量。但是,虽然这些初始化器可以在LLVM IR级别保持任意常量表达式,但在降低期间只接受仅接受有限的“可重定位”表达式。该可重定位表达式仅需要常量表达机制。 LLVM的错误是将这些可重定位表达式的表示与恒定的折叠机械结合在一起。

这里讨论的设计问题是明显的,因为后视。当然,往往有明智的历史原因,为什么这些选择是首先制造的。例如,具有指针元素类型和基于类型的GEP,可以方便地实现直接降低到LLVM IR的前端 - 换句话说,克朗。这意味着将类型跟踪类型的任务委派给LLVM。同样,我相信常量表达式设计日期返回到LLVM仍然支持目标无关模块的概念的时间,而没有相关的数据布局。

这篇文章所涵盖的问题与IR规范相关有关。与正确性有关的单独问题。其中最大的是undef值的概念,它表示所有可能的位模式的量子叠加状态。是的,这与听起来一样糟糕。值得庆幸的是,undef值在被毒药值替换的过程中。但这一切都是另一个时间的讨论。

如果您喜欢本文,您可能想要浏览其他文章或在Twitter上关注我。 博客评论由Disqus提供动力