浮点数转换恐怖,还有出路吗?

时间:2013-12-11 14:21:39

标签: delphi unit-testing floating-point type-conversion delphi-2009

背景

最近,我的同事在我们的测试项目中添加了一些新的测试。其中一个没有传递或持续集成系统。由于我们有大约800个测试,并且运行所有这些测试需要一个小时,因此我们经常会犯错误并在我们的开发机器上运行我们当前实施的测试。这种方法有其弱点,因为有时测试是在本地传递但在集成系统上失败。当然,有人可以说“这不是一个错误,测试应该是彼此独立的!”。

在理想世界中......当然,但不是在我的世界里。不是在initialization部分中有很多单例初始化的世界中,Delphi本身引入了很多全局变量,在后台初始化了一个OTL线程池,DevExpress方法连接到控件以进行绘制。以及其他许多我都不知道的事情。因此,在最终结果中,一个测试可以改变其他测试的行为。 (当然这本身就很糟糕,我很高兴它会发生,因为希望我能够修复另一个依赖)。

我已经在我的机器上启动了整个测试包,并且我已经获得了与集成系统相同的结果。到目前为止一直很好,现在我开始关闭测试,直到我缩小了一个干扰最近添加的测试的测试。他们没有任何共同之处。我已经深入挖掘,并将问题缩小到一条线。如果我评论它 - 测试通过,如果没有 - 测试失败。

问题

我们有这样的代码将文本数据转换为经度坐标(仅包含重要部分):

procedure TTerminalNVCParserTest_Unit.TranslateGPS_ValidGPSString_ReturnsValidCoords;
const
  CStrGPS = 'N5145.37936E01511.8029';
var
  LLatitude, LLongitude: Integer;
  LLong: Double;
  LStrLong, LTmpStr: String;
  LFS: TFormatSettings;
begin
  FillChar(LFS, SizeOf(LFS), 0);
  LFS.DecimalSeparator := '.';

  LStrLong := Copy(CStrGPS, Pos('E', CStrGPS)+1, 10);
  LTmpStr := Copy(LStrLong,1,3);
  LLong := StrToFloatDef( LTmpStr, 0, LFS );
  LTmpStr := Copy(LStrLong,4,10);
  LLong := LLong + StrToFloatDef( LTmpStr, 0, LFS)*1/60;
  LLongitude := Round(LLong * 100000);

  CheckEquals(1519671, LLongitude);
end;

问题是LLongitude有时等于1519671,有时它给出1519672.并且它是否给出1519672是否依赖于不同方法中不同测试中其他完全不相关的代码片段:

FormXtrMainImport.JvWizard1.SelectNextPage; 

我检查了SelectNextPage方法的四倍,它不会触发任何可能改变FPU单元工作方式的事件。它不会更改始终在rmNearest上设置的RoundingMode的值。

此外,德尔福不应该在这里使用银行家规则吗? :

LLongitude := Round(LLong * 100000); //LLong * 100000 = 1519671,5

如果使用银行家规则,它应该给我总是1519672而不是1519671.

我猜必须有一些损坏的内存导致问题,而SelectNextPage的行只显示它。但是,在三台不同的机器上会出现同样的问题。

任何人都可以告诉我如何追踪这个问题?或者如何确保始终获得稳定的转换结果?

对那些误解我的问题的人

  1. 我已经检查了RoundingMode并且我之前提到过它:“我检查了SelectNextPage方法的四倍,它不会触发任何可能改变FPU单元工作方式的事件。不改变RoundingMode的值,它始终设置在rmNearest上。“ RoundingMode在上述代码中出现任何runding之前总是rmNearest。

  2. 这不是真正的考验。这只是显示问题发生位置的代码。

  3. 添加了视频说明。

    所以,在努力改进我的问题时,我决定添加显示我的眩晕问题的视频。这是生产代码,我只添加断言来检查RoundingMode。 在视频的第一部分,我将展示原始测试(@Sir Rufo,@ Craig Young),负责转换的方法以及我得到的正确结果。在第二部分中,我将展示当我添加另一个不相关的测试时,我得到的结果不正确。视频可以找到here

    添加了可重现的示例

    这一切归结为以下代码:

    procedure FloatingPointNumberHorror;
    const
      CStrGPS = 'N5145.37936E01511.8029';
    var
      LLongitude: Integer;
      LFloatLon: Double;
      adcConnection: TADOConnection;
      qrySelect: TADOQuery;
      LCSVStringList: TStringList;
    begin
      //Tested on Delphi 2007, 2009, XE 5 -  Windows 7 64 bit
      adcConnection := TADOConnection.Create(nil);
      qrySelect := TADOQuery.Create(adcConnection);
      LCSVStringList := TStringList.Create;
      try
        //Prepare on the fly csv file required by ADOQuery
        LCSVStringList.Add('Col1;Col2;');
        LCSVStringList.Add('aaaa;1234;');
        LCSVStringList.SaveToFile(ExtractFilePath(ParamStr(0)) + 'test.csv');
    
        qrySelect.CursorType := ctStatic;
        qrySelect.Connection := adcConnection;
        adcConnection.ConnectionString := 'Provider=Microsoft.Jet.OLEDB.4.0;Data Source='
          + ExtractFilePath(ParamStr(0)) + ';Extended Properties="text;HDR=yes;FMT=Delimited(;)"';
    
        // Real stuff begins here, above we have only preparation of environment.
        LFloatLon := 15 + 11.8029*1/60;
        LLongitude := Round(LFloatLon * 100000);
        Assert(LLongitude = 1519671, 'Asertion 1'); //Here you will NOT receive error.
    
        //This line changes the FPU control word from $1372 to $1272.
        //This causes the change of Precision Control Field (PC) from 3 which means
        //64bit precision to 2 which means 53 bit precision thus resulting in improper rounding?
        //--> ADODB.TParameters.InternalRefresh->RefreshFromOleDB -> CommandPrepare.Prepare(0)
        qrySelect.SQL.Text := 'select * from [test.csv] WHERE 1=1';
    
        LFloatLon := 15 + 11.8029*1/60;
        LLongitude := Round(LFloatLon * 100000);
        Assert(LLongitude = 1519671, 'Asertion 2'); //Here you will receive error.
    
      finally
        adcConnection.Free;
        LCSVStringList.Free;
      end;
    end;
    

    只需复制并粘贴此过程,然后将ADODB添加到uses子句即可。似乎问题是由Delphi的ADO包装器使用的某些Microsoft COM对象引起的。这个对象正在改变FPU控制字,但它没有改变舍入模式。它正在改变精度控制。

    这是启动与ADO相关的方法之前和之后的FPU屏幕截图。:

    FPU screenshot

    我想到的唯一解决方案是在使用ADO代码之前使用Get8087CW,然后使用Set8087CW来设置之前存储的控制世界。

1 个答案:

答案 0 :(得分:9)

问题很可能是因为代码中的其他内容正在改变浮点舍入模式。看看这个程序:

{$APPTYPE CONSOLE}

{$R *.res}

uses
  SysUtils, Math;

const
  CStrGPS = 'N5145.37936E01511.8029';
var
  LLatitude, LLongitude: Integer;
  LLong: Double;
  LStrLong, LTmpStr: String;
  LFS: TFormatSettings;

begin
  FillChar(LFS, SizeOf(LFS), 0);
  LFS.DecimalSeparator := '.';

  LStrLong := Copy(CStrGPS, Pos('E', CStrGPS)+1, 10);
  LTmpStr := Copy(LStrLong,1,3);
  LLong := StrToFloatDef( LTmpStr, 0, LFS );
  LTmpStr := Copy(LStrLong,4,10);
  LLong := LLong + StrToFloatDef( LTmpStr, 0, LFS)*1/60;

  Writeln(FloatToStr(LLong));
  Writeln(FloatToStr(LLong*100000));

  SetRoundMode(rmNearest);
  LLongitude := Round(LLong * 100000);
  Writeln(LLongitude);

  SetRoundMode(rmDown);
  LLongitude := Round(LLong * 100000);
  Writeln(LLongitude);

  SetRoundMode(rmUp);
  LLongitude := Round(LLong * 100000);
  Writeln(LLongitude);

  SetRoundMode(rmTruncate);
  LLongitude := Round(LLong * 100000);
  Writeln(LLongitude);

  Readln;
end.

输出结果为:

15.196715
1519671.5
1519671
1519671
1519672
1519671

显然,您的特定计算取决于浮点舍入模式以及实际输入值和代码。确实,documentation确实证明了这一点:

  

注意:Round的行为可能会受到Set8087CW过程或System.Math.SetRoundMode函数的影响。

因此,首先需要找到程序中正在修改浮点控制字的其他内容。然后,每当执行错误代码时,您必须确保将其设置回所需的值。


祝贺您进一步调试。实际上它实际上是乘法

LLong*100000

受精度控制的影响。

要了解情况,请查看此程序:

{$APPTYPE CONSOLE}
var
  d: Double;
  e1, e2: Extended;
begin
  d := 15.196715;
  Set8087CW($1272);
  e1 := d * 100000;
  Set8087CW($1372);
  e2 := d * 100000;
  Writeln(e1=e2);
  Readln;
end.

<强>输出

FALSE

因此,精度控制会影响乘法的结果,至少在8087单元的80位寄存器中是这样。

编译器不会将该乘法的结果存储到变量中,而是保留在FPU中,因此这种差异会流向Round

Project1.dpr.9: Writeln(Round(LLong*100000));
004060E8 DD05A0AB4000     fld qword ptr [$0040aba0]
004060EE D80D84614000     fmul dword ptr [$00406184]
004060F4 E8BBCDFFFF       call @ROUND
004060F9 52               push edx
004060FA 50               push eax
004060FB A1107A4000       mov eax,[$00407a10]
00406100 E827F0FFFF       call @Write0Int64
00406105 E87ADEFFFF       call @WriteLn
0040610A E851CCFFFF       call @_IOTest

注意乘法的结果如何保留在ST(0)中,因为这正是Round期望其参数的位置。

实际上,如果将乘法拉入单独的语句并将其分配给变量,那么行为将再次变为一致:

tmp := LLong*100000;
LLongitude := Round(tmp);

以上代码为$1272$1372生成相同的输出。

虽然存在基本问题。您已失去对浮点控制状态的控制。要解决这个问题,你需要保持对FP控制状态的控制。每当您调用可以修改它的库时,请在调用之前将其存储起来,然后在调用返回时进行恢复。如果你想拥有可重复,可靠和强大的浮点代码,不幸的是,这种游戏是不可避免的。

这是我的代码:

type
  TFPControlState = record
    _8087CW: Word;
    MXCSR: UInt32;
  end;

function GetFPControlState: TFPControlState;
begin
  Result._8087CW := Get8087CW;
  Result.MXCSR := GetMXCSR;
end;

procedure RestoreFPControlState(const State: TFPControlState);
begin
  Set8087CW(State._8087CW);
  SetMXCSR(State.MXCSR);
end;

var
  FPControlState: TFPControlState;
....
FPControlState := GetFPControlState;
try
  // call into external library that changes FP control state
finally
  RestoreFPControlState(FPControlState);
end;

请注意,此代码处理两个浮点单元,因此适用于使用SSE单元而不是8087单元的64位。


值得一提的是,这是我的SSCCE:

{$APPTYPE CONSOLE}
var
  d: Double;
begin
  d := 15.196715;
  Set8087CW($1272);
  Writeln(Round(d * 100000));
  Set8087CW($1372);
  Writeln(Round(d * 100000));
  Readln;
end.

<强>输出

1519672
1519671