【问题标题】:How to optimize DivMod for a constant divisor of 10如何优化 DivMod 以获得 10 的常数除数
【发布时间】:2019-04-01 23:29:25
【问题描述】:

在 Delphi ma​​th.pas 单元中有一个过程 DivMod 我想将其转换为内联并优化它以使除数始终为 10 。但我不知道五角大楼 ASM 的细节。波纹管程序的转换是什么

 procedure DivMod(Dividend: Integer; Divisor: Word;
  var Result, Remainder: Word);
asm
        PUSH    EBX
        MOV     EBX,EDX
        MOV     EDX,EAX
        SHR     EDX,16
        DIV     BX
        MOV     EBX,Remainder
        MOV     [ECX],AX
        MOV     [EBX],DX
        POP     EBX
end;

【问题讨论】:

  • “五角大楼 ASM”?您指的是“奔腾”吗?无论如何,对于 10,最好的方法确实是乘以一个常数(如果需要,还可以右移)。 @Peter Cordes:德尔福没有优化很多。它已经变得更好了,但不像 GCC。
  • 很抱歉不是 Pentagon 而是 Pentium CPU 汇编语言指令。我只是想将上述调用变为内联语句以更快地执行。使用内联会产生更大的代码,但会更快,因为不会使用 PUSH 和 POP 指令
  • 就好像您没有阅读任何内容一样。不要使用DIV。它比乘法和移位要慢。
  • @Omid:Delphi 使用寄存器调用约定,不会推送或弹出其参数,只有返回地址。但是如果你内联,生成的代码可能不得不推送或弹出(或在堆栈上存储一些寄存器),以释放内联代码的寄存器。内联可以是一个优势,但不是必须的。不要期望太多。一些代码,例如使用托管类型的代码,因此需要在其周围隐藏 try-finally,如果内联,甚至会使您的代码变慢。

标签: delphi x86 inline-assembly micro-optimization


【解决方案1】:

到目前为止,您可以做的最重要的优化是使用定点乘法逆除以编译时常数:Why does GCC use multiplication by a strange number in implementing integer division?

任何体面的 C 编译器都会为您做到这一点,但显然 Delphi 不会,因此使用 asm 这样做是有充分理由的。

您可以在 EAX 中返回一个值,而不是将商和余数都存储到内存中吗?传递 2 个指针参数并强制调用者从内存中检索值似乎是一种浪费。 (更新,是的,我认为您可以将其设为函数而不是过程;不过,我只是盲目地从其他答案中修改 Delphi 代码。)

无论如何,幸运的是,我们可以使用 C 编译器来完成为我们计算乘法逆和移位计数的艰苦工作。我们甚至可以让它使用与 Delphi 用于内联汇编的相同“调用约定”。 GCC's regparm=3 32-bit calling convention 在 EAX、EDX 和 ECX 中传递 args(按此顺序)。

对于只需要商的情况,您可能需要制作一个单独的版本,因为(与慢速 div 指令不同),如果您使用的是快速乘法,则必须将余数单独计算为 x - (x/y)*y逆。但是,是的,这仍然是现代 x86 的两倍到 4 倍。

或者你可以让余数计算在纯 Delphi 中完成,除非编译器在一般优化方面很糟糕。

#ifdef _MSC_VER
#define CONVENTION  _fastcall   // not the same, but 2 register args are better than none.
#else
#define CONVENTION __attribute__((regparm(3)))
#endif

// use gcc -Os to get it to emit code with actual div.

divmod10(unsigned x, unsigned *quot, unsigned *rem) {
    unsigned tmp = x/10;
    // *quot = tmp;
    *rem = x%10;
    return tmp;
}

From the Godbolt compiler explorer:

# gcc8.2  -O3 -Wall -m32
div10:    # simplified version without the remainder, returns in EAX
        mov     edx, -858993459     # 0xCCCCCCCD
        mul     edx                 # EDX:EAX = dividend * 0xCCCCCCCD
        mov     eax, edx
        shr     eax, 3
        ret
      # quotient in EAX

# returns quotient in EAX, stores remainder to [ECX]
# quotient pointer in EDX is unused (and destroyed).
divmod10:
        mov     edx, -858993459
        push    ebx
        mov     ebx, eax
        mul     edx                      # EDX:EAX = dividend * 0xCCCCCCCD
        mov     eax, edx
        shr     eax, 3
        # quotient in EAX = high_half(product) >> 3 = product >> (32+3)
        lea     edx, [eax+eax*4]         # EDX = quotient*5
        add     edx, edx                 # EDX = quot * 10
        sub     ebx, edx                 # remainder = dividend - quot*10
        mov     DWORD PTR [ecx], ebx     # store remainder
        pop     ebx
        ret
        # quotient in EAX

这是 C 编译器的输出。根据需要适应 Delphi 内联汇编;我认为输入位于正确的 Delphi 寄存器中

如果 Delphi inline-asm 不允许您破坏 EDX,您可以保存/恢复它。或者你想去掉未使用的quotient指针输入,然后你可以调整asm,或者调整Godbolt上的C,看看新的编译器输出。

这比div 的指令多,但div 非常慢(10 微秒,即使在 Skylake 上也有 26 个周期延迟。)

如果你在 Delphi 中有 64 位整数类型,你可以在 Delphi 源代码中这样做,避免内联 asm。或者如 MBo 所示,您可以使用 $CCCD 作为仅使用 32 位整数类型的 0..2^16-1 范围内的输入的乘法逆元。

对于其余部分,存储/重新加载往返(4 到 5 个周期)与最近的英特尔 CPU 上的实际计算具有相似的延迟,使用 mov-elimination(3 + 1 到商,另外 3 用于 lea/add /sub = 7),因此必须为此使用内联 asm 是很垃圾的。但在延迟和吞吐量方面,它仍然比div 指令要好。请参阅https://agner.org/optimize/ 和其他性能链接in the x86 tag wiki


可以复制/粘贴的Delphi版本

(如果我做对了,我不知道 Delphi,只是根据我对调用约定/语法的推断,在 SO 和 this site 上复制+修改示例 )

我不确定我是否获得了 inline-asm 的 arg 传递权。 This RADStudio documentation 说“除了 ESP 和 EBP,asm 语句不能假设任何关于进入语句的寄存器内容。”但我假设 args 在 EAX 和 EDX 中。

将 asm 用于 64 位代码可能很愚蠢,因为在 64 位中,您可以有效地使用纯 Pascal 进行 64 位乘法。 How do I implement an efficient 32 bit DivMod in 64 bit code。所以在{$IFDEF CPUX64} 块中,最好的选择可能是使用UInt64(3435973837)*num; 的纯帕斯卡

function Div10(Num: Cardinal): Cardinal;
{$IFDEF PUREPASCAL}
begin
  Result := Num div 10;
end;
{$ELSE !PUREPASCAL}
{$IFDEF CPUX86}
asm
        MOV     EDX, $CCCCCCCD
        MUL     EDX                   // EDX:EAX = Num * fixed-point inverse
        MOV     EAX,EDX               // mov then overwrite is ideal for Intel mov-elimination
        SHR     EAX,3
end;
{$ENDIF CPUX86}
{$IFDEF CPUX64}
asm
         // TODO: use pure pascal for this; Uint64 is efficient on x86-64
        // Num in ECX, upper bits of RCX possibly contain garbage?
        mov     eax, ecx              // zero extend Num into RAX
        mov     ecx, $CCCCCCCD        // doesn't quite fit in a sign-extended 32-bit immediate for imul
        imul    rax, rcx              // RAX = Num * fixed-point inverse
        shr     rax, 35               // quotient = eax
end;
{$ENDIF CPUX64}
{$ENDIF}

 {Remainder is the function return value}
function DivMod10(Num: Cardinal; var Quotient: Cardinal): Cardinal;
{$IFDEF PUREPASCAL}
begin
  Quotient := Num div 10;
  Result := Num mod 10;
end;
{$ELSE !PUREPASCAL}
{$IFDEF CPUX86}
asm
    // Num in EAX,  @Quotient in EDX
    push    esi
    mov     ecx, edx           // save @quotient
    mov     edx, $CCCCCCCD
    mov     esi, eax           // save dividend for use in remainder calc
    mul     edx                // EDX:EAX = dividend * 0xCCCCCCCD
    shr     edx, 3             // EDX = quotient
    mov     [ecx], edx         // store quotient into @quotient

    lea     edx, [edx + 4*edx] // EDX = quot * 5
    add     edx, edx           // EDX = quot * 10
    mov     eax, esi                  // off the critical path
    sub     eax, edx           // Num - (Num/10)*10
    pop     esi
    // Remainder in EAX = return value
end;
{$ENDIF CPUX86}
{$IFDEF CPUX64}
asm
        // TODO: use pure pascal for this?  Uint64 is efficient on x86-64
    // Num in ECX,   @Quotient in RDX
    mov     r8d, ecx          // zero-extend Num into R8
    mov     eax, $CCCCCCCD
    imul    rax, r8
    shr     rax, 35           // quotient in eax

    lea     ecx, [rax + 4*rax]
    add     ecx, ecx          // ecx = 10*(Num/10)
    mov     [rdx], eax        // store quotient

    mov     eax, r8d          // copy Num again
    sub     eax, ecx          // remainder = Num - 10*(Num/10)
    // we could have saved 1 mov instruction by returning the quotient
    // and storing the remainder.  But this balances latency better.
end;
{$ENDIF CPUX64}
{$ENDIF}

存储商并返回余数意味着两者可能在调用者中几乎同时准备好,因为从商计算余数的额外延迟与存储转发重叠。 IDK 如果这很好,或者如果在基于商的某些工作上开始乱序执行通常会更好。我猜如果你调用 DivMod10,你可能只想要剩下的。

但是在重复除以 10 的拆分为十进制数字的循环中,形成关键路径的是商,因此返回商并存储余数的版本会更好在那里选择。

在这种情况下,您可以将商设为 EAX 中的返回值,并将函数 arg 重命名为余数。


asm 基于此版本的 C 函数 (https://godbolt.org/z/qu2kvV) 的 clang 输出,针对 Windows x64 调用约定。但是进行了一些调整以使其更有效,例如将mov 关闭关键路径,并使用不同的寄存器来避免 REX 前缀。并用一个 ADD 替换一个 LEA。

unsigned divmod10(unsigned x, unsigned *quot) {
    unsigned qtmp = x/10;
    unsigned rtmp = x%10;
     *quot = qtmp;
     //*rem = rtmp;
    return rtmp;
}

我使用 clang 的版本而不是 gcc 的版本,因为 imul r64,r64 在 Intel CPU 和 Ryzen 上更快(3 个周期延迟/1 uop)。 mul r32 是 3 uop,在 Sandybridge 系列上每 2 个时钟吞吐量只有 1 个。我认为乘法硬件自然会产生 128 位的结果,而将其中的低 64 位拆分为 edx:eax 需要额外的 uop 或类似的东西。

【讨论】:

  • c 编译器提供了几乎与我相同的代码。我只是在 Delphi 中使用了其他 DivMods 的 Delphi 示例,将 q 和 r 都存储在引用参数中,所以我不得不再洗牌一点。
  • FWIW,您的 Delphi 代码对我来说看起来不错。对于不懂语言的人,赞!嗯...不确定您的 {$IFDEF} {$ELSE} 是否都有自己的 {$ENDIF},但编译器可以轻松检查。参数列表中的逗号应该是分号。我已经编辑过了。
  • unless the compiler is just terrible at optimizing in general... 是的。是的。
  • 结论:对于 64 位,可能更喜欢链接的 pascal 示例。对于 32 位,请选择。
  • @Peter 我修复了几个编译错误。该代码在 32 位版本上似乎可以正常工作,但 Div10 的 64 位版本有点损坏。我会看看发生了什么。
【解决方案2】:

this answer 开始,您可以通过利用硬件 32x32 -> 使用 SSE 的 64 位乘法,在 32 位编译中恢复一些性能:

program Project1;
{$APPTYPE CONSOLE}

uses
  Windows, SysUtils;

procedure DivMod10(num : Cardinal; var q, r : Cardinal);
const
  m : cardinal = 3435973837;
asm
  movd xmm0, m         {move magic number to xmm0}
  movd xmm1, eax       {move num to xmm1}
  pmuludq xmm0, xmm1   {xmm0[0:32] * xmm1[0:32] -> xmm0[0:64] unsigned}
  psrlq xmm0, 35       {right shift xmm0}
  movss [edx], xmm0    {store quotient to q}
  movd edx, xmm0       {recycle edx, store q}
  imul edx, -$A        {edx = q * (-10)}
  add edx, eax         {edx = r}
  mov [ecx], edx       {store r}
end;

var
  q, r, t0, i : cardinal;
begin
  t0 := GetTickCount;
  for I := 1 to 999999999 do DivMod10(i, q, r);
  WriteLn('SSE ASM : ' + IntToStr(GetTickCount - t0));

  t0 := GetTickCount;
  for I := 1 to 999999999 do q := i div 10;
  WriteLn('div : ' + IntToStr(GetTickCount - t0));

  WriteLn('Test correctness...');
  for I := 1 to High(Cardinal) do begin
    DivMod10(i,q,r);
    if (q <> (i div 10)) or (r <> (i mod 10)) then
      WriteLn('Incorrect Result : ' + IntToStr(i));
  end;

  WriteLn('Test complete.');
  Readln;
end.

这会产生:

SSE ASM : 2449
div : 3401
测试正确性...
测试完成。

通常不安全,因为您应该在运行时检查 CPU 是否支持所需的 SSE 指令(并为这种情况提供纯帕斯卡替代方案),但是越来越少找到CPU 已经足够老,至少不支持 SSE2。

对于支持它的系统,这可能比 div 性能更高(例如,我看到在 Haswell 上使用 DivMod10 可以提高 25% 的性能优势),剩下的就交给你了。不如原生 64 位 IMUL 快,但仍然非常有用。


要解决 Peter 的 cmets,请考虑纯 x86 版本:

procedure DivMod10(num : Cardinal; var q, r : Cardinal);
const
  m : cardinal = 3435973837;
asm
  push eax
  push edx
  mul m
  mov eax, edx
  shr eax, 3
  pop edx
  mov [edx], eax
  pop eax
  imul edx, [edx], -$A
  add edx, eax
  mov [ecx], edx
end;

产生(对我来说 - Haswell i7):

x86 ASM:2948
div : 3401
测试正确性...
测试完成。

这比 SSE 版本慢约 18%。


借助 Peter 的一些好主意,我们可以进一步优化纯 x86 版本,通过转换为函数并将直接的 imul 替换为 leaadd 来节省寄存器:

function DivMod10(Num: Cardinal; var Quotient: Cardinal): Cardinal;
const
  m : cardinal = 3435973837;
asm
  mov ecx, eax           {save num to ecx}
  push edx               {save quotient pointer}
  mul m                  {edx:eax = m*Num}
  shr edx, 3             {edx = quotient}
  pop eax                {restore quotient pointer}
  mov [eax], edx         {store quotient}
  mov eax, ecx           {restore num to eax}
  lea ecx, [edx +4*edx]  {ecx = q*5}
  add ecx, ecx           {ecx = q*10}
  sub eax, ecx           {return remainder in eax}
end;

这将执行时间(与上述条件相同)缩短至2637ms,但仍不如 SSE 版本快。 imullea 的优化是次要的,它优化了延迟而不是吞吐量 - 这可以应用于所有算法,具体取决于最终使用环境。

【讨论】:

  • 标量整数 mul 为您提供 EDX 中的高半部分,延迟较低,并且不需要将数据从整数往返往返 XMM regs。 (为什么你在一个方向使用movd,而在另一个方向使用存储/重新加载?如果你是,为什么不使用imul edx, [edx], -10折叠负载?x86 imul-immediate 有一个单独的源和目标(felixcloutier.com/x86/IMUL.html ),不像大多数立即指令使用 3 位 /r 源字段作为额外的操作码位)。
  • 无论如何,在所有 CPU 上,这看起来比我对这个问题的回答中的代码更糟糕。 (欢迎编辑添加或更改其中一个代码块为可复制粘贴的 Delphi 语法。)
  • @PeterCordes 我以前做过这个,记得认为mul 对于 32 位来说更慢。我已经测试过,这似乎是这种情况,但不是很多。由于操作数大小问题,movd 用于移入,movss 用于移出。我不确定你会建议什么?你是对的,imul 立即进行 edx 回收会更快。我很快就会更新这个......
  • mul r32 在 Haswell 上是 3 uop,延迟为 4c。 (agner.org/optimize)。比mul r64 慢得有趣,我猜是因为乘法单元自然会产生 128 位输出,它必须用额外的微指令分割低半部分,以及写入 EAX 和 EDX。 PMULUDQ 是 1 uop,延迟为 5c,但您在数据传输上花费的额外 uop 可以平衡它。无论如何,我会做 movss [edx], xmm0 / movd edx, xmm0 来存储商然后覆盖指针。 (或者movd 两者都是,但是movss 短一个字节,也是一个双字存储)
  • @PeterCordes 是的,32x32->64 imul 在 32 位模式下很慢。 movd edx, xmm0 稍微快一点(~1%)……我会接受的。谢谢。
【解决方案3】:

好的,这是我的尝试:

procedure DivMod10(Num: Cardinal; var Quotient, Remainder: Cardinal);
asm
        PUSH    ESI
        PUSH    EDI
        MOV     EDI,EAX          // Num
        MOV     ESI,EDX          // @Quotient
        MOV     EDX,$CCCCCCCD    
        MUL     EDX              // EDX:EAX = EAX * magic_number
        SHR     EDX,3
        MOV     [ESI],EDX        // --> @Quotient
        LEA     EDX,[EDX+4*EDX]
        ADD     EDX,EDX          // Quotient * 10 
        SUB     EDI,EDX          // Num - Quotient*10
        MOV     [ECX],EDI        // --> @Remainder 
        POP     EDI
        POP     ESI
end;

如果您不需要剩余部分:

function Div10(Num: Cardinal): Cardinal;
asm
        MOV     ECX,$CCCCCCCD    
        MUL     ECX   
        SHR     EDX,3
        MOV     EAX,EDX
end;

【讨论】:

  • 我猜只有 32 位 Windows。 Pascal 实现有什么问题?甚至可以内联。
  • @David:因为 32 位 Pascal 实现要么使用简单的 div 和 mod,这很慢,要么需要 UInt64,这也很慢(很多寄存器改组、推送和弹出、调用 __llmul 等.)。在 Win32 中,因为 EAX:EDX 包含 64 位结果,并且您需要 64 位的顶部,所以在 asm 中可以快得多。在 Win64 中,它会容易得多,即使在 PURPASCAL 中也是如此。
  • 但如果你关心性能,你就不会写 32 位代码
  • @David:视情况而定。我通常编写我的(库)代码,因此它可以在 Win32 和 Win64 中使用,也可以用于 Mac(目前仅 32 位)甚至其他平台,如果这有意义的话。如果这只是为了我的目的,并且如果它必须是高性能的,我确实会使用 64 位。
  • 我关心所有平台的性能。如果整个程序都致力于性能,那么确实,我会选择一个快速的平台。但是库代码也应该尽可能快,无论它在哪个平台上使用。这个 DivMod10 看起来好像可以用来将整数转换为文本,甚至可能是大整数。我对此有一些经验。而且我的代码在所有平台上都尽可能快地运行,无论是在 Pure Pascal 中,还是在汇编器中。
【解决方案4】:

增益确实存在,但它对实际任务有意义吗? (注意我改变了参数类型)

procedure DivMod10(Dividend: DWord; var Result, Remainder: DWord);
asm
        PUSH    EDI
        PUSH    ESI
        MOV     EDI, EDX
        MOV     ESI, 10
        XOR     EDX, EDX
        DIV     ESI
        MOV     [ECX], EDX
        MOV     [EDI], EAX
        POP     ESI
        POP     EDI
end;

  1 000 000 000 iterations
  divmod10: 4539
  math.divmod: 7145

有限范围的最快方法 - Delphi 代码 使用 @Peter Cordes 建议的乘法。 汇编代码较慢 (1777) 可能是由于函数调用和我较弱的汇编经验。

  b := a * $CCCD;
  b := b shr 19;   //result
  c := a - b * 10;  //remainder
  1 000 000 000 iterations: 1200 ms  (560 ms without remainder)

使用常量from this SO answer 可以摆脱轮班,但时间比 Peter 和 J 的版本更糟糕:

function DM10(Dividend: DWord;  var Remainder: DWord): DWord;
asm
   push ebx
   mov ebx, eax
   mov ecx, edx
   mov edx, 1999999Ah
   mul eax, edx
   push edx
   lea eax, [edx+edx*4]
   add eax, eax
   sub ebx, eax
   mov [ecx], ebx
   pop eax
   pop ebx
end;

Timings for my machine (10^9 iterations, haswell i5-4670):
this  DM10               2013
Peter Cordes DivMod10    1755
J... SSE version         1685

【讨论】:

  • 因为未能使用 Why does GCC use multiplication by a strange number in implementing integer division? 优化常数除数而试图投反对票。任何 C 编译器都会做得更好。 godbolt.org/z/9a0mW5
  • 在 Delphi 源代码中使用乘法逆的有趣想法。只要可以将输入范围限制为 16 位就很好,这与除数是 32 位整数的 OP 代码不同。 (但div bx 会在除数/10 溢出 16 位 AX 时出错,所以如果它是有意的,这接近安全。)
  • @Peter: 在 Delphi for Cardinal (= UInt32) 中也是可能的,但是您必须将表达式扩展为 UInt64,这又很慢(在 32 位 Delphi 中)。不过,在 64 位 Delphi 中效果很好。
  • 请注意,您链接的答案不正确。编译器不这样做,因为它不适用于所有可能的输入。请参阅我对该答案Divide by 10 using bit shifts? 的评论。但是,如果有限的范围是可以的,那么是的,这是一个不错的选择。 (适用于最大为 107374183 的正整数,负输入时舍入错误。我没有测试无符号版本)
  • @Peter Cordes 是的,我必须添加此警告。在任何情况下,功能都比最好的慢。
猜你喜欢
  • 2017-12-05
  • 1970-01-01
  • 1970-01-01
  • 2014-11-17
  • 2023-03-08
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多