.Net8神秘的OSR(堆栈替换)到底是什么?

前言

OSR(On-Stack Replacement),按照英文语义意思是在堆栈上替换。堆存托管对象,栈存局部变量以及其它协程(协助程序)机器值。替换这两个东西,基本上就是替换了整个函数运行的机器码。本篇来看下它的运作模式.

概括

1.示例
看一个官方的例子:

static void Main(){   var sw = new System.Diagnostics.Stopwatch();   while (true)   {       sw.Restart();       for (int trial = 0; trial < 10_000; trial++)       {           int count = 0;           for (int i = 0; i < char.MaxValue; i++)           if (IsAsciiDigit((char)i))               count++;       }       sw.Stop();       Console.WriteLine(sw.Elapsed);   }   static bool IsAsciiDigit(char c) => (uint)(c - '0') <= 9;}

2.条件

触发堆栈替换的条件有两条
其一,需要开启快速

JIT(DOTNET_TC_QuickJitForLoops)

这点主要是针对.Net6而言,因为它默认没有开启。.Net7及其之后,就会默认开启这一项

其二,当一个函数运行次数超过1000(0x3E8)次的时候,会对它进行堆栈替换。比如示例当中的IsAsciiDigit运行次数超过1000次。

满足以上两个条件之后,IsAsciiDigit函数会被重新高度优化性质的编译。示例当中IsAsciiDigit函数的原来没有超过1000次运行的函数头,会被重新编译之后的函数头的进行一个替换。此后.Net8运行的机器码皆以后者为主。

3.原理
那么它实际上的一个运行原理到底是什么样的呢?非常简单,就是个for循环。参考下面的代码。

static void Main(){
  var sw = new System.Diagnostics.Stopwatch();  for (int xinjian = 0; xinjian <= 0x3E8; xinjian++)  {      if (xinjian >= 0)      {         while (true)         {            sw.Restart();            for (int trial = 0; trial < 10_000; trial++)            {               int count = 0;               for (int i = 0; i < char.MaxValue; i++)               {                  if (IsAsciiDigit((char)i))                         count++;                         --xinjian;               }            }            sw.Stop();            Console.WriteLine(sw.Elapsed);          }
      }      else      {         JIT_Patchpoint();      }      static bool IsAsciiDigit(char c) => (uint)(c - '0') <= 9;   }}

当进行JIT编译之后,示例代码被替换成了以上代码.它多了一个for循环和判断,调用一次IsAsciiDigit函数,自减一次.最后如果0x23E8此全部减完.那么则调用JIT_Patchpoint函数重新编译,替换掉旧函数头,此后就运行新的函数头.

稍微深入点,可以看看下面的机器码

00007FF81A573B3E E8 4D 0F DF 5E       call        JIT_TrialAllocSFastMP_InlineGetThread (07FF879364A90h)  00007FF81A573B43 48 89 45 A8          mov         qword ptr [rbp-58h],rax  00007FF81A573B47 48 8B 4D A8          mov         rcx,qword ptr [rbp-58h]  00007FF81A573B4B FF 15 7F A0 68 00    call        qword ptr [7FF81ABFDBD0h]  00007FF81A573B51 48 8B 4D A8          mov         rcx,qword ptr [rbp-58h]  00007FF81A573B55 48 89 4D C0          mov         qword ptr [rbp-40h],rcx  00007FF81A573B59 C7 45 98 E8 03 00 00 mov         dword ptr [rbp-68h],3E8h  00007FF81A573B60 8B 4D 98             mov         ecx,dword ptr [rbp-68h]  00007FF81A573B63 FF C9                dec         ecx  00007FF81A573B65 89 4D 98             mov         dword ptr [rbp-68h],ecx  00007FF81A573B68 83 7D 98 00          cmp         dword ptr [rbp-68h],0  00007FF81A573B6C 7F 0E                jg          00007FF81A573B7C  00007FF81A573B6E 48 8D 4D 98          lea         rcx,[rbp-68h]  00007FF81A573B72 BA 06 00 00 00       mov         edx,6  00007FF81A573B77 E8 94 F0 97 5E       call        JIT_Patchpoint (07FF878EF2C10h)

可以看到OSR是在sw对象实例化之后,进行一个判断的。对0x3E8进行自减,当全部减完,则调用JIT_Patchpoint 。跟上面推测如出一辙。

它的本质code非常复杂,这里只是扼要的结果。

结尾

作者:江湖评谈。

技术交流:QQ群,676817308,也可加入知识星球讨论你没见过的顶级技术。