【问题标题】:Different optimizations in Math.Sum in Win32/64Win32/64 中 Math.Sum 的不同优化
【发布时间】:2019-07-22 08:18:08
【问题描述】:

我有以下代码

const
  NumIterations = 10000000;
var
  i, j : Integer;
  x : array[1..100] of Double;
  Start : Cardinal;
  S : Double;
begin
  for i := Low(x) to High(x) do x[i] := i;

  Start := GetTickCount;
  for i := 1 to NumIterations do S := System.Math.Sum(x);
  ShowMessage('Math.Sum: ' + IntToStr(GetTickCount - Start));

  Start := GetTickCount;
  for i := 1 to NumIterations do begin
    S := 0;
    for j := Low(x) to High(x) do S := S + x[j];
  end;
  ShowMessage('Simple Sum: ' + IntToStr(GetTickCount - Start));
end;

当为 Win32 编译时,Math.Sum 比简单循环快得多,因为 Math.Sum 是用汇编程序编写的,并且使用四重循环展开。

但是当为 Win64 编译时,Math.Sum 比简单循环要慢得多,因为在 64 位 Math.Sum 中使用 Kahan 求和。这是一种在求和过程中最大限度地减少错误堆积的准确性优化,但比简单的循环要慢得多。

即当为 Win32 编译时,我得到了针对速度优化的代码,当为 Win64 编译相同的代码时,我得到了针对准确性优化的代码。这并不是我天真地期望的那样。

Win32/64 之间的这种差异有什么合理的原因吗?双精度始终是 8 字节,因此在 Win32/64 中精度应该相同。

在当前版本的 Delphi 中,Math.Sum 是否仍以相同的方式实现(Win32 中的汇编程序和循环展开,Win64 中的 Kahan 求和)?我用的是 Delphi-XE5。

【问题讨论】:

  • 可能在 32 位中,朴素是相当准确的,因为算术是使用 80 位 x87 寄存器完成的。在现实世界的设置中,我怀疑您会观察到任何性能差异,因为访问主内存的时间会淹没 Kahan sum 的额外 FP 操作。您的基准测试非常不切实际,因为数据集非常小,无法放入 L0 缓存。由于这个原因,我了解到使用这种微基准来优化代码通常是一种巨大的时间浪费。

标签: delphi delphi-xe5


【解决方案1】:

在当前版本的 Delphi 中,Math.Sum 是否仍以相同的方式实现(Win32 中的汇编程序和循环展开,Win64 中的 Kahan 求和)?我用的是 Delphi-XE5。

是的(德尔福 10.3.2)。

Win32/64 之间的这种差异有什么合理的原因吗?双精度始终是 8 字节,因此在 Win32/64 中精度应该相同。

Win32 的 32 位 Delphi 使用旧的 FPU,而 64 位编译器使用 SSE 指令。在 XE2 中引入 64 位编译器时,许多旧的汇编例程并未移植到 64 位。相反,一些例程移植了与其他现代编译器类似的功能。


您可以通过引入Kahan summation function 来稍微增强 64 位实现:

program TestKahanSum;

{$APPTYPE CONSOLE}

uses
  System.SysUtils,Math,Diagnostics;

function KahanSum(const input : TArray<Double>): Double;
var
  sum,c,y,t : Double;
  i : Integer;         
begin
    sum := 0.0;                 
    c := 0.0;                      
    for i := Low(input) to High(input) do begin
      y := input[i] - c;  
      t := sum + y; 
      c := (t - sum) - y; 
      sum := t;                 
    end;
    Result := sum;
end;

var
  dArr : TArray<Double>;
  res : Double;
  i : Integer;
  sw : TStopWatch;
begin
  SetLength(dArr,100000000);
  for i := 0 to High(dArr) do dArr[i] := Pi;
  sw := TStopWatch.StartNew;
  res := Math.Sum(dArr);
  WriteLn('Math.Sum:',res,' [ms]:',sw.ElapsedMilliseconds);
  sw := TStopWatch.StartNew;
  res := KahanSum(dArr);
  WriteLn('KahanSum:',res,' [ms]:',sw.ElapsedMilliseconds);
  sw := TStopWatch.StartNew;
  res := 0;
  for i := 0 to High(dArr) do res := res + dArr[i];
  WriteLn('NaiveSum:',res,' [ms]:',sw.ElapsedMilliseconds);
  ReadLn;
end.

64 位:

Math.Sum: 3.14159265358979E+0008 [ms]:492
KahanSum: 3.14159265358979E+0008 [ms]:359
NaiveSum: 3.14159265624272E+0008 [ms]:246

32 位:

Math.Sum: 3.14159265358957E+0008 [ms]:67
KahanSum: 3.14159265358979E+0008 [ms]:958
NaiveSum: 3.14159265624272E+0008 [ms]:277

15 位的 Pi 是3.14159265358979

在此示例中,32 位数学汇编例程精确到 13 位,而 64 位数学汇编例程精确到 15 位。


结论:

  • 64 位实现速度较慢(与简单求和相比是两倍),但比 32 位数学例程更准确。

  • 引入增强的 Kahan 求和例程可将性能提高 35%。

【讨论】:

    【解决方案2】:

    在切换编译目标时,相同的 RTL 函数的行为不一样是一个可怕的错误。它不应该改变行为。更糟糕的是,Win64/pascal Sum() 在单精度或双精度上的表现不一样! sum(single) 是简单的求和,而 sum(double) 使用 Kahan... :(

    您最好使用普通的+ 运算符,或者创建您自己的 Kahan 求和函数。

    我可以确认该错误在 Delphi 10.3 中仍然存在。

    【讨论】:

    • 他们的行为真的如此不同吗?我的意思是 80 位精度的天真和可能比 64 位精度的 Kahan 和更准确。您将如何将 x87 80 位精度与 64 位 SSE2 算法相匹配?鉴于底层硬件不同,我认为期望相同的行为是不现实的。
    • 它们的行为肯定会有所不同,例如双重价值。只是在一个巨大的值上加了很多小值,卡汉和天真的加法是不匹配的。使用相同的精度添加应该是相同的 - 使用 x87 和 SSE。 BTW Kahan over double 的精度超过 100 位 - 远远超过扩展。
    • 谢谢。有趣的是,Kahan 如此准确。
    • BTW x87 处理整数时的“扩展”精度(这是 Kahan 的意思)不是 80 位,而是 64 位 - 所谓的“有效位”。 “双”有效数字精度为 53 位。 Kahan sum over double 使用加法“进位”寄存器,因此当涉及两个寄存器时,预计最多可处理 53*2 = 106 位整数精度。
    猜你喜欢
    • 2017-02-05
    • 1970-01-01
    • 1970-01-01
    • 2012-10-04
    • 1970-01-01
    • 1970-01-01
    • 2020-07-06
    • 2012-04-30
    • 1970-01-01
    相关资源
    最近更新 更多