.NET 5的JIT编译器优化

前一段时间,我开始了一段奇妙的旅程,进入了JIT编译器世界,以寻找可以亲身体验并加快速度的地方,因为在主要工作过程中,已积累了少量的LLVM及其优化知识。在本文中,我想分享我在JIT方面的改进列表(在.NET中,它是为纪念某些龙或动漫而被称为RyuJIT的-我没有弄清楚),其中大多数已经掌握并且可以在.NET(Core)中使用5我的优化影响JIT的不同阶段,其可以被示出非常示意性地如下:



如可从图中可以看出,JIT是与狭窄的单独模块的JIT接口,通过该对一些事情JIT咨询,例如,是有可能将一个班级推向另一个班级。JIT将方法编译为Tier1的时间越晚,运行时可以提供的信息越多,例如,可以将static readonly字段替换为常量,因为 该类已经被静态初始化。

因此,让我们从列表开始。

PR #1817:模式匹配中的装箱/拆箱优化


阶段:导入器
许多新的C#功能通常会通过插入box / unbox CIL操作码来犯错这是一个非常昂贵的操作,实质上是在堆上分配一个新对象,将值从堆栈中复制到堆栈中,然后最后还加载GC。在这种情况下,JIT中已经有许多优化,但是我发现C#8中缺少模式匹配,例如:

public static int Case1<T>(T o)
{
    if (o is int x)
        return x;
    return 0;
}

public static int Case2<T>(T o) => o is int n ? n : 42;

public static int Case3<T>(T o)
{
    return o switch
    {
        int n => n,
        string str => str.Length,
        _ => 0
    };
}

让我们看看针对所有三种方法进行优化之前的asm-codegen(例如,用于int专业化):



现在,在进行改进之后:



事实是优化发现了IL代码的模式

box !!T
isinst Type1
unbox.any Type2

导入并获得有关类型的信息后,她能够简单地忽略这些操作码,而无需插入boxing-anboxing。顺便说一下,我也在Mono中实现了相同的优化在下文中,到Pull-Request的链接包含在优化描述的标题中。

PR #1157 typeof(T).IsValueType⇨是/否


阶段:导入程序
在这里,我训练了JIT,以便在可能的情况下立即用常量替换Type.IsValueType这是一个挑战,它是将来减少全部条件和分支的能力的一个例子,例如:

void Foo<T>()
{
    if (!typeof(T).IsValueType)
        Console.WriteLine("not a valuetype");
}

让我们看一下改进之前 和之后的Foo <int>专业化的代码生成:



改进之后:



如有必要,可以使用其他Type属性进行相同的处理。

PR #1157 typeof(T1).IsAssignableFrom(typeof(T2)) ⇨ true/false


阶段:导入器
几乎是同一件事-现在,您可以检查通用方法中的层次结构,而不必担心这没有优化,例如:

void Foo<T1, T2>()
{
    if (!typeof(T1).IsAssignableFrom(typeof(T2)))
        Console.WriteLine("T1 is not assignable from T2");
}

同样,它将被常量替换,true/false并且条件可以完全删除。当然,在此类优化中,您应始终牢记一些极端情况:System .__ Canon共享泛型,数组,co(ntr)可变性,可为空,COM对象等。

PR #1378 "Hello".Length ⇨ 5


阶段:导入器
尽管优化过程尽可能的明显和简单,但我不得不花很多精力在JIT-e中实现它。问题是,JIT不了解字符串的内容,他看到了字符串文字(GT_CNS_STR),但是对字符串的具体内容一无所知。我必须通过联系VM来帮助他(以扩展前述的JIT-Interface),并且优化本身实际上是几行代码。除了显而易见的情况外,还有很多用例,例如:str.IndexOf("foo") + "foo".Length对于涉及内联的非显而易见的用例(我提醒您:Roslyn不处理内联,因此这种优化与其他所有方法一样无效),例如:

bool Validate(string str) => str.Length > 0 && str.Length <= 100;

bool Test() => Validate("Hello");

让我们看看代码生成的测试验证的内联):



添加优化后,现在的代码生成:



内联方法,用字符串文字替换变量,用真实的字符串长度替换文字中的.Length,折叠常量,删除无效代码。顺便说一下,由于JIT现在可以检查字符串的内容,因此为与字符串文字相关的其他优化打开了大门。.NET 5.0的第一个预览版的公告中提到了优化本身:devblogs.microsoft.com/dotnet/announcing-net-5-0-preview-1 在RyuJIT中的代码质量改进一节

PR #1644:优化绑定检查。


阶段:边界检查消除
对于很多人来说,每次您通过索引访问数组时,JIT都会为您插入一个检查数组是否超出范围的检查,如果发生这种情况,则抛出异常-这在逻辑上是错误的,您不能读取随机内存,获得一些价值并继续。

int Foo(int[] array, int index)
{
    // if ((uint) array.Length <= (uint) index)
    //     throw new IndexOutOfRangeException();
    return array[index];
}

这样的检查很有用,但是会极大地影响性能:首先,它添加了一个比较操作并使代码无分支,其次,它向方法中添加了一个异常调用代码,并带来了所有后果。但是,在许多情况下,如果JIT可以证明自己的索引永远不会超出该范围,或者已经存在其他检查,并且不再需要添加其他内容,则JIT可以删除这些检查-边界(范围)检查消除。我发现了一些他无法应付的情况,并进行了纠正(以后,我计划在此阶段进行更多改进)。

var item = array[index & mask];

在这段代码中,我告诉JIT从& mask本质上将索引限制为一个值mask,即 如果JIT知道mask数组的值和长度,则不能插入绑定检查。对于%(&x >> y)操作也是如此。aspnetcore中使用此优化的示例
同样,如果我们知道例如数组中有256个或更多的元素,那么如果我们未知的索引器是字节类型的,无论尝试多努力,它都永远不会超出范围。公关:github.com/dotnet/coreclr/pull/25912

PR #24584: x / 2 ⇨ x * 0.5


阶段:
此PR的Morph C,开始了我对JIT优化领域的惊人了解。“除”运算比“乘法”运算慢(如果是整数,则通常为一个数量级)。适用于仅等于2的幂的常数,例如:

static float DivideBy2(float x) => x / 2; // = x * 0.5; 

优化之前



和之后的代码生成:



如果我们将Haswell的这两条指令进行比较,那么一切将变得清晰:

vdivss (Latency: 10-20,  R.Throughput: 7-14)
vmulss (Latency:     5,  R.Throughput:  0.5)

随后将进行优化,这些优化仍处于代码审查阶段,而不是被接受的事实。

PR #31978: Math.Pow(x, 2) ⇨ x * x


阶段:导入
器这里的一切都很简单:在度数为2的情况下(而不是调用pow(f)),当度数为2时(对1,-1,0也免费),您可以将其扩展为简单的x * x。您可以扩展任何其他程度,但是为此,您需要等待.NET中“快速数学”模式的实现,在该模式中出于性能考虑可以忽略IEEE-754规范。例:

static float Pow2(float x) => MathF.Pow(x, 2);

优化之前



和之后的代码生成:



PR #33024: x * 2 ⇨ x + x


阶段:降低
同样非常简单的微观(纳米)油耗优化,允许您乘以2,而无需将常数加载到寄存器中。

static float MultiplyBy2(float x) => x * 2;

优化之前的代码生成:



之后:



通常,该指令的mul(ss/sd/ps/pd)延迟和吞吐量相同add(ss/sd/ps/pd),但是需要加载常量“ 2”会稍微减慢工作速度。在上面的代码生成示例中,我vaddss在一个寄存器的框架内进行了所有操作。

PR #32368: Array.Length / c(或%s)的优化


阶段: Morph
碰巧的是,Array的Length字段是一个有符号的类型,并且用一个常数进行除法和余数可以更有效地执行无符号类型(而不仅仅是2的幂),只需比较一下此代码源:



我的PR只是提醒JIT Array.Length尽管很重要,但实际上,数组的长度NEVER(除非您是无政府主义者)可以小于零,这意味着您可以将其视为无符号数,并进行一些优化(例如uint)。

PR #32716:优化无分支代码中的简单比较


阶段:流程分析
这是另一类优化,使用基本块而不是其中的表达式进行操作。这里的JIT有点保守,还有改进的空间,例如在可能的情况下插入cmove。我从这种情况的简单优化开始:

x = condition ? A : B;

例如,如果A和B是常量,并且它们之间的差是1,condition ? 1 : 2那么我们知道比较操作本身返回0或1,就可以用add代替jump。就RyuJIT而言,它看起来像这样:我



建议看一下PR本身描述,希望在那里有清楚的描述。

并非所有优化都同样有用。


优化需要相当高的费用:
*增加=支持和阅读的现有代码的复杂性
*潜在的错误:测试编译器的优化异常困难,容易遗漏某些东西并从用户那里获得某种错误。
*编译缓慢
*增加JIT Binar的大小

正如您已经了解的那样,并非所有优化的思想和原型都被接受,因此有必要证明它们具有生命权。在.NET中证明这一点的一种公认方法是运行jit-utils实用程序,该实用程序将AOT编译一组库(所有BCL和corelib),并在优化前后比较所有方法的汇编代码,这就是此报告寻找优化的方式"str".Length除了该报告之外,还有一些人(例如jkotas)一眼就能评估其实用性并从他们的经验高度了解所有内容,并了解.NET中的哪些地方可能成为瓶颈,哪些没有。还有一件事:不要用“没人写”这样的参数来判断优化,“最好在Roslyn中显示警告会更好” –您永远不知道代码在JIT内联后会如何处理并填充常量。

All Articles