在iOS上,TDateTime存在四舍五入误差

6
当从时间戳(TDateTime)计算32位ID时,我遇到了一个奇怪的错误。在某些情况下,即使源数据库文件完全相同(即存储在文件中的Double相同),不同处理器上的值也会不同。 fTimeStamp 字段从 SQLite 数据库中的 Double 字段读取。下面的代码从 fTimeStamp 计算出 32 位 ID(lIntStamp),但是在某些(罕见)情况下,即使源数据库文件相同,不同的计算机上该值也会有所不同。
...
fTimeStamp: TDateTime
...

var
  lIntStamp: Int64;
begin
  lIntStamp := Round(fTimeStamp * 864000); //86400=24*60*60*10=steps of 1/10th second
  lIntStamp := lIntStamp and $FFFFFFFF;
  ...
end;
TDateTime的精度(Double)为15位数字,但代码中的舍入值仅使用11位数字,因此应有足够信息进行正确的舍入。举个例子,对于某个特定的测试运行,在Windows计算机上lIntStamp的值为$ 74AE699B,在iPad上为$ 74AE699A(=仅最后一位不同)。Round函数在每个平台上的实现是否不同?PS.我们的目标平台目前是Windows、MacOS和iOS。编辑:根据评论,我制作了一个小的测试程序:
var d: Double;
    id: int64 absolute d;
    lDouble: Double;
begin
  id := $40E4863E234B78FC;
  lDouble := d*864000;
  Label1.text := inttostr(Round(d*864000))+' '+floattostr(lDouble)+' '+inttostr(Round(lDouble));
end;

在Windows上的输出结果是:
36317325723 36317325722.5 36317325722

在iPad上,输出结果如下:
36317325722 36317325722.5 36317325722

问题在于第一个数字,它显示了中间计算的四舍五入,因此问题发生在x86具有比ARM更高的内部精度(80位比64位)。


1
你能获取fTimeStamp变量的二进制内容吗?请注意,舍入可能会受到FPU的舍入模式的影响。 - LU RD
1
var d: Double; id: int64 absolute d; begin id := $40E4863E234B78FC; WriteLn(Round(d24606010)); WriteLn(Round(d864000)); WriteLn(Trunc(d24606010)); WriteLn(Trunc(d864000)); end.的输出结果为36317325723 36317325723 36317325722 36317325722 `。x64编译器对于Round和Trunc都会得到相同的值(以22结尾)。 - LU RD
1
@DavidHeffernan,或者是由于与864000相乘或246060*10相乘导致的80位中间值被四舍五入。 - LU RD
1
Hans,你最后的更新意味着在舍入之前将计算结果存储在Double中是一种解决方案。 - LU RD
1
@Hans,我认为那样做行不通。你需要完全复制到80位中间值的四舍五入。增加更多精度并不总是有帮助的,因为80位精度并不是精确的。你需要相同的精度,而不是更多的精度。我认为你会发现在ARM上复制80位非常困难。恐怕你面临着相当棘手的任务。你不能摆脱向后兼容的束缚吗? - David Heffernan
显示剩余12条评论
2个回答

5

假设所有处理器都符合IEEE754标准,而且您在所有处理器上使用相同的舍入模式,那么您将能够从所有不同的处理器中获得相同的结果。

然而,由于已编译代码的差异或当前代码的实现差异,可能会存在一些差异。

请考虑如何进行优化和调整以确保最佳性能和一致性。

fTimeStamp * 24 * 60 * 60 * 10

被评估。有些编译器可能会执行

fTimeStamp * 24

然后将中间结果存储在FP寄存器中,然后将其乘以60,并存储到FP寄存器中。如此反复。

现在,在x86下,浮点寄存器是80位扩展的,并且默认情况下,这些中间寄存器将保存80位的结果。

另一方面,ARM处理器没有80个寄存器。中间值以64位双精度形式保存。

因此,这是一种机器实现上的差异,可以解释您观察到的行为。

另一个可能性是ARM编译器在表达式中发现了常量,并在编译时进行了求值,将上述表达式简化为:

fTimeStamp * 864000

我从未见过能这样做的x86或x64编译器,但也许ARM编译器可以。这会导致编译后的代码不同。我并不是说它一定会发生,因为我不了解移动编译器。但这种情况是有可能发生的。
然而,你可以采用上述方法重新编写表达式,只使用单个乘法。这样就可以消除存储中间值时出现不同精度的可能性。只要在所有处理器上“Round”的意思相同,结果就会相同。
个人建议避免涉及舍入模式的问题,使用“Trunc”代替“Round”。我知道它们的含义不同,但对于你的目的来说,这是一个任意的选择。
然后你只需留下以下内容:
lIntStamp := Trunc(fTimeStamp * 864000); //steps of 1/10th second
lIntStamp := lIntStamp and $FFFFFFFF;

如果在不同的平台上,Round函数表现不同,那么你可能需要在ARM上自己实现它。在x86上,默认的舍入方式是"银行家舍入法"。这只有在两个整数之间正好相隔一半时才会产生影响。因此,检查是否满足Frac(...) = 0.5,并根据情况进行四舍五入即可。这种检查是安全的,因为0.5可以被精确表示。
另一方面,你似乎声称
Round(36317325722.5000008) = 36317325722

在ARM上出现这种情况是一个bug。我不相信你所声称的。我相信传递给Round的值实际上是36317325722.5在ARM上。这是唯一有意义的事情。我不认为Round是有缺陷的。


好的建议。我已经按照建议将它更改为fTimeStamp * 864000,但结果并没有改变。使用Trunc并不是一个容易的解决方案,因为它与我们所有程序和文件的向后兼容性不符。 - Hans
很难想象一个简单的乘法会有多个答案。我猜你需要进行一些调试。向后兼容可能不太可能。嗯,不容易。可悲的是,你最初选择的算法考虑不周。使用80位中间值是不可移植的。 - David Heffernan
当然,微软/博兰德选择使用浮点数作为日期/时间类型也让你感到困扰。 - David Heffernan
无论如何,您可以进行一些调试以缩小范围。使用跟踪调试捕获中间值。传递给Round或Trunc的值是否相同?等等。 - David Heffernan
1
抱歉评论和回答编辑有些混乱。无论如何,我很高兴能帮助您找到正确的方向。也感谢@LURD。 - David Heffernan
显示剩余3条评论

2

为了完整起见,以下是具体情况:

在 x86 环境下,调用 Round(d*n);(其中 d 为双精度浮点数,n 为数字)会将乘法转换成扩展值之后再调用 Round 函数。但在 x64 平台或 OSX 或 IOS/Android 平台上,不会进行到 80 位扩展值的提升。

分析扩展值可能有些棘手,因为 RTL 没有函数来写入扩展值的全部精度。 John Herbster 写了一个库,可以实现这一功能:http://cc.embarcadero.com/Item/19421。(在两个地方添加 FormatSettings 可以使其在现代 Delphi 版本中编译)。

下面是一个小测试,它按输入的双精度浮点数每增加 1 位的步长输出扩展值和双精度浮点值的结果。

program TestRound;

{$APPTYPE CONSOLE}

uses
  System.SysUtils,
  ExactFloatToStr_JH0 in 'ExactFloatToStr_JH0.pas';

var
  // Three consecutive double values (binary representation)
  id1 : Int64 = $40E4863E234B78FB;
  id2 : Int64 = $40E4863E234B78FC; // <-- the fTimeStamp value
  id3 : Int64 = $40E4863E234B78FD;
  // Access the values as double
  d1 : double absolute id1;
  d2 : double absolute id2;
  d3 : double absolute id3;
  e: Extended;
  d: Double;
begin
  WriteLn('Extended precision');
  e := d1*864000;
  WriteLn(e:0:8 , ' ', Round(e), ' ',ExactFloatToStrEx(e,'.',#0));
  e := d2*864000;
  WriteLn(e:0:8 , ' ', Round(e),' ', ExactFloatToStrEx(e,'.',#0));
  e := d3*864000;
  WriteLn(e:0:8 , ' ', Round(e),' ', ExactFloatToStrEx(e,'.',#0));
  WriteLn('Double precision');
  d := d1*864000;
  WriteLn(d:0:8 , ' ', Round(d),' ', ExactFloatToStrEx(d,'.',#0));
  d := d2*864000;
  WriteLn(d:0:8 , ' ', Round(d),' ', ExactFloatToStrEx(d,'.',#0));
  d := d3*864000;
  WriteLn(d:0:8 , ' ', Round(d),' ', ExactFloatToStrEx(d,'.',#0));

  ReadLn;
end.

Extended precision
36317325722.49999480 36317325722 +36317325722.499994792044162750244140625
36317325722.50000110 36317325723 +36317325722.500001080334186553955078125
36317325722.50000740 36317325723 +36317325722.500007368624210357666015625
Double precision
36317325722.49999240 36317325722 +36317325722.49999237060546875
36317325722.50000000 36317325722 +36317325722.5
36317325722.50000760 36317325723 +36317325722.50000762939453125

请注意,在使用双精度计算时,问题中的值具有准确的双重表示(以.5结尾),而扩展计算给出的值略高。这就是不同平台得到不同四舍五入结果的原因。
正如在评论中指出的那样,解决方案是在进行四舍五入之前将计算结果存储在Double中。这并不能解决向后兼容性问题,这不是易于实现的。或许这是一个好机会以其他格式存储时间。

谢谢。我进行了更多的测试(在几天内通过各种小步骤递增TDateTime),发现舍入误差的概率似乎是恒定的:3.8E-6(263,000中的1个),因此它并不经常发生... - Hans

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接