为什么Math.DivRem效率低下?

时间:2009-01-15 15:50:47

标签: .net optimization

在我的电脑中,此代码需要17秒(1000万次):

static void Main(string[] args) {
   var sw = new Stopwatch(); sw.Start();
   int r;
   for (int i = 1; i <= 100000000; i++) {
      for (int j = 1; j <= 10; j++) {
         MyDivRem (i,j, out r);
      }
   }
   Console.WriteLine(sw.ElapsedMilliseconds);
}

static int MyDivRem(int dividend, int divisor, out int remainder) {
   int quotient = dividend / divisor;
   remainder = dividend - divisor * quotient;
   return quotient;
}

而Math.DivRem需要27秒。

.NET Reflector为我提供了Math.DivRem的代码:

public static int DivRem(int a, int b, out int result)
{
    result = a % b;
    return (a / b);
}

CIL

.method public hidebysig static int32 DivRem(int32 a, int32 b, [out] int32& result) cil managed
{
    .maxstack 8
    L_0000: ldarg.2
    L_0001: ldarg.0
    L_0002: ldarg.1
    L_0003: rem
    L_0004: stind.i4
    L_0005: ldarg.0
    L_0006: ldarg.1
    L_0007: div
    L_0008: ret
}

从理论上讲,对于具有多个内核的计算机来说可能会更快,但实际上它不需要首先执行两个操作,因为x86 CPU在返回时会返回商和 使用DIV或IDIV(http://www.arl.wustl.edu/~lockwood/class/cs306/books/artofasm/Chapter_6/CH06-2.html#HEADING2-451)的整数除法!

11 个答案:

答案 0 :(得分:16)

哎呀。这个函数存在的唯一原因是为了利用CPU指令,他们甚至没有这样做!

答案 1 :(得分:12)

哇,真的看起来很蠢,不是吗?

问题在于 - 根据Lidin的微软出版社出版的“.NET IL Assembler” - IL rem和div算术指令就是这样:计算余数和计算除数。

  

除了否定操作之外的所有算术运算都从栈中取出两个操作数并将结果放在栈中。

显然,IL汇编语言的设计方式,不可能有一条产生两个输出的IL指令并将它们推送到eval堆栈上。鉴于此限制,您不能在IL汇编程序中使用除法指令来计算x86 DIV或IDIV指令的方式。

IL旨在提高安全性,可验证性和稳定性, NOT 的性能。任何拥有计算密集型应用程序且主要关注性能的人都将使用本机代码而不是.NET。

我最近参加过Supercomputing '08,在其中一个技术会议上,Microsoft Compute Server的传播者给出了粗略的经验法则,即.NET通常是本机代码速度的一半 - 这就是这里的情况!

答案 2 :(得分:5)

虽然.NET Framework 4.6.2仍然使用次优的模数和除法,但.NET Core(CoreCLR)currently用减法替换除法:

    public static int DivRem(int a, int b, out int result) {
        // TODO https://github.com/dotnet/coreclr/issues/3439:
        // Restore to using % and / when the JIT is able to eliminate one of the idivs.
        // In the meantime, a * and - is measurably faster than an extra /.
        int div = a / b;
        result = a - (div * b);
        return div;
    }

对于improve DivRem specifically(通过内在)或RyuJIT中的detect and optimise the general case,这是一个未解决的问题。

答案 3 :(得分:2)

答案可能是没有人认为这是一个优先事项 - 这已经足够了。事实上,任何新版本的.NET Framework都没有解决这个问题,这表明这种情况很少被使用 - 很可能是没有人抱怨过。

答案 4 :(得分:2)

如果我不得不疯狂猜测,我会说实现Math.DivRem的人不知道x86处理器能够在一条指令中完成它,所以他们把它写成两个操作。如果优化器正常工作,那不一定是坏事,尽管这是另一个指标,即现在大多数程序员都缺乏低级知识。我希望优化器能够折叠模数然后将操作划分为一条指令,编写优化器的人应该知道这些低级别的东西...

答案 5 :(得分:1)

测试时有没有其他人反对?

Math.DivRem = 11.029 sec, 11.780 sec
MyDivRem = 27.330 sec, 27.562 sec
DivRem = 29.689 sec, 30.338 sec

FWIW,我正在运行英特尔酷睿2双核处理器。

上面的数字是调试版本......

发布版本:

Math.DivRem = 10.314
DivRem = 10.324
MyDivRem = 5.380

看起来“rem”IL命令的效率低于MyDivRem中的“mul,sub”组合。

答案 6 :(得分:1)

效率很可能取决于所涉及的数字。您正在测试可用问题空间的TINY部分,并且所有前端都已加载。您正在检查前100万* 10 = 10亿个连续输入组合,但实际问题空间约为42亿平方,或1.8e19组合。

这样的通用库数学运算的性能需要在整个问题空间中摊销。我有兴趣看到更标准化的输入分布的结果。

答案 7 :(得分:0)

以下是我的数字:

15170 MyDivRem
29579 DivRem (same code as below)
29579 Math.DivRem
30031 inlined

测试稍有变化;我在返回值中添加了赋值,并且正在运行发布版本。

Core 2 Duo 2.4

观点:

您似乎找到了一个很好的优化;)

答案 8 :(得分:0)

我猜想增加的大部分成本都是静态方法调用的设置和拆除。

至于它存在的原因,我猜它会部分地用于完整性,部分用于其他语言的好处,这些语言可能没有易于使用的整数除法和模数计算的实现。

答案 9 :(得分:0)

这只是一个评论,但我没有足够的空间。

以下是一些使用Math.DivRem()的C#:

    [Fact]
    public void MathTest()
    {
        for (var i = 1; i <= 10; i++)
        {
            int remainder;
            var result = Math.DivRem(10, i, out remainder);
            // Use the values so they aren't optimized away
            Assert.True(result >= 0);
            Assert.True(remainder >= 0);
        }
    }

这是相应的IL:

.method public hidebysig instance void MathTest() cil managed
{
    .custom instance void [xunit]Xunit.FactAttribute::.ctor()
    .maxstack 3
    .locals init (
        [0] int32 i,
        [1] int32 remainder,
        [2] int32 result)
    L_0000: ldc.i4.1 
    L_0001: stloc.0 
    L_0002: br.s L_002b
    L_0004: ldc.i4.s 10
    L_0006: ldloc.0 
    L_0007: ldloca.s remainder
    L_0009: call int32 [mscorlib]System.Math::DivRem(int32, int32, int32&)
    L_000e: stloc.2 
    L_000f: ldloc.2 
    L_0010: ldc.i4.0 
    L_0011: clt 
    L_0013: ldc.i4.0 
    L_0014: ceq 
    L_0016: call void [xunit]Xunit.Assert::True(bool)
    L_001b: ldloc.1 
    L_001c: ldc.i4.0 
    L_001d: clt 
    L_001f: ldc.i4.0 
    L_0020: ceq 
    L_0022: call void [xunit]Xunit.Assert::True(bool)
    L_0027: ldloc.0 
    L_0028: ldc.i4.1 
    L_0029: add 
    L_002a: stloc.0 
    L_002b: ldloc.0 
    L_002c: ldc.i4.s 10
    L_002e: ble.s L_0004
    L_0030: ret 
}

以下是生成的(相关)优化x86程序集:

       for (var i = 1; i <= 10; i++)
00000000  push        ebp 
00000001  mov         ebp,esp 
00000003  push        esi 
00000004  push        eax 
00000005  xor         eax,eax 
00000007  mov         dword ptr [ebp-8],eax 
0000000a  mov         esi,1 
        {
            int remainder;
            var result = Math.DivRem(10, i, out remainder);
0000000f  mov         eax,0Ah 
00000014  cdq 
00000015  idiv        eax,esi 
00000017  mov         dword ptr [ebp-8],edx 
0000001a  mov         eax,0Ah 
0000001f  cdq 
00000020  idiv        eax,esi 

请注意 2 idiv的调用。第一个将余数(EDX)存储到堆栈上的remainder参数中。第二个是确定商(EAX)。第二次调用并不是真的需要,因为EAX在第一次调用idiv后具有正确的值。

答案 10 :(得分:-4)

部分原因在于野兽的性质。据我所知,没有通用的快速方法来计算分裂的剩余部分。这将需要相应大量的时钟周期,即使使用x亿个晶体管也是如此。