如何优化DivMod的常数除数为10

Omi*_*hed 5 delphi x86 inline-assembly micro-optimization

在Delphi的math.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;
Run Code Online (Sandbox Code Playgroud)

Pet*_*des 6

到目前为止,你可以做的最重要的优化是使用一个定点乘法逆来除以编译时常数: 为什么GCC在实现整数除法时使用乘以一个奇数?.

任何体面的C编译器都会为你做这件事,但显然Delphi不会,所以有一个合理的理由用asm来做.

你能在EAX中返回一个值,而不是将商和余数存储到内存中吗?看起来像是浪费来传递2个指针args,并强制调用者从内存中检索值.(更新,是的,我认为你可以通过使它成为一个函数而不是一个过程;但我只是盲目地从其他答案中修改Delphi代码.)

无论如何,幸运的是我们可以得到一个C编译器来完成为我们计算乘法逆和移位计数的艰苦工作.我们甚至可以使用它看起来像Delphi用于内联asm的相同"调用约定". GCC的regparm=332位调用约定传递了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;
}
Run Code Online (Sandbox Code Playgroud)

来自Godbolt编译器资源管理器:

# 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
Run Code Online (Sandbox Code Playgroud)

这是C编译器输出.根据需要调整Delphi inline asm; 我认为输入在Delphi的正确寄存器中.

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

这比使用更多指令div,但div速度非常慢(即使在Skylake上也有10次uop和26次循环延迟.)

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

对于其余部分,存储/重载往返(4到5个周期)与最近具有mov-elimination的Intel CPU上的实际计算具有相似的延迟(3 + 1到商,+另外3个用于lea/add/sub = 7),所以必须使用内联asm这是相当废话.但它仍然比div延迟和吞吐量指令更好.请参阅x86标记wiki中的https://agner.org/optimize/和其他性能链接.


您可以复制/粘贴Delphi版本

(如果我说得对,我不知道Delphi,只是在SO和本网站上复制+修改过的例子,基于我推断的调用约定/语法)

我不确定我是否获得了inline-asm的arg-passing权利. 这个RADStudio文档说"除了ESP和EBP之外,asm语句在进入语句时不能假定寄存器内容." 但我假设args在EAX和EDX中.

使用64位代码的asm可能很愚蠢,因为在64位中你可以有效地使用纯Pascal进行64位乘法. 如何在64位代码中实现高效的32位DivMod.所以在{$IFDEF CPUX64}块中,最好的选择可能是纯粹的pascal使用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}
Run Code Online (Sandbox Code Playgroud)

存储商并返回余数意味着两者可能在调用者中几乎同时就绪,因为计算商的余数的额外延迟与存储转发重叠.IDK,如果这是好的,或者如果基于商的某些工作开始无序执行通常更好.我猜想如果你打电话给DivMod10,你可能只想要其余的.

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

在这种情况下,你将商数作为EAX中的返回值,并将函数arg重命名为余数.


asm基于此C函数版本的clang输出(https://godbolt.org/z/qu2kvV),针对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;
}
Run Code Online (Sandbox Code Playgroud)

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

  • `除非编译器在优化方面非常糟糕......`是的.是的. (2认同)
  • @Peter我修复了一些编译错误.代码似乎适用于32位版本,但64位版本的"Div10"有点破碎.我会调查一下是什么. (2认同)

MBo*_*MBo 1

增益确实存在,但是对于实际任务来说重要吗?(注意我已经更改了参数类型)

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
Run Code Online (Sandbox Code Playgroud)

有限范围的最快方法- 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)
Run Code Online (Sandbox Code Playgroud)

使用此 SO 答案中的常量可以摆脱轮班,但时间比 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
Run Code Online (Sandbox Code Playgroud)