6

When calculating a 32bit ID from a timestamp (TDateTime), I get a strange error. In certain situations, the value is different on different processors.

The fTimeStamp field is read from a Double field in a SQLite database. The code below calculates a 32bit ID (lIntStamp) from fTimeStamp, but in some (rare) situations the value is different on different computers even though the source database file is exactly the same (i.e. the Double stored on file is the same).

...
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;

The precision ofTDateTime (Double) is 15 digits, but the rounded value in the code uses only 11 digits, so there should be enough information to round correctly.

To mention an example of values: in a specific test run the value of lIntStamp was $74AE699B on a Windows computer and $74AE699A on an iPad (= only last bit is different).

Is the Round function implemented different on each platform?

PS. Our target platforms are currently Windows, MacOS and iOS.

Edit:

I made a small test program based on the comments:

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;

The output on Windows is:

36317325723 36317325722.5 36317325722

On the iPad the output is:

36317325722 36317325722.5 36317325722

The difference is in the first number, which shows the rounding of the intermediate calculation, so the problem happens because x86 has a higher internal precision (80 bit) than the ARM (64 bit).

Hans
  • 2,220
  • 13
  • 33
  • See [Are floating point operations in Delphi deterministic?](http://stackoverflow.com/q/24352053/576719). – LU RD May 06 '15 at 10:44
  • 1
    Can you capture the binary contents of the fTimeStamp variable? Note that rounding may be affected by the rounding mode of the fpu. – LU RD May 06 '15 at 10:55
  • I have captured the 64bit binary value of the fTimeStamp variable (= `$40E4863E234B78FC`) and found that it is the same on both platforms, so the problem is with the rounding. The floating point value of fTimeStamp is `42033.941808449075`, and multiplied by 864000 the result is `36317325722.5000008`. This is the value being rounded. On Windows/Intel it is rounded to ...23 and on iPad/ARM it is rounded to ...22! – Hans May 06 '15 at 12:24
  • @Hans No, that's not what is going on. Floating point data types are binary not decimal for a start. – David Heffernan May 06 '15 at 12:25
  • @DavidHeffernan: it is either the multiplication by 864000 that produces different results or the rounding. – Hans May 06 '15 at 12:27
  • You aren't multiplying by 864000. You are performing 4 separate mults. I'll bet you that the 80 bit intermediates on x86 are the difference from ARM. As per my answer. – David Heffernan May 06 '15 at 12:31
  • @DavidHeffernan I used your answer and replaced the original calculation with the constant 864000, and that was used as the basis for my comment above. – Hans May 06 '15 at 12:33
  • @DavidHeffernan, same result on x86 for both ways. – LU RD May 06 '15 at 12:38
  • @LURD round or trunc? – David Heffernan May 06 '15 at 12:40
  • 1
    `var d: Double; id: int64 absolute d; begin id := $40E4863E234B78FC; WriteLn(Round(d*24*60*60*10)); WriteLn(Round(d*864000)); WriteLn(Trunc(d*24*60*60*10)); WriteLn(Trunc(d*864000)); end.` produces `36317325723 36317325723 36317325722 36317325722 `. x64 compiler gives the same value (ending with 22) for both Round and Trunc. – LU RD May 06 '15 at 12:44
  • @LURD Which points the finger at Round – David Heffernan May 06 '15 at 12:49
  • 1
    @DavidHeffernan, or that an 80 bit intermediate (caused by either multiplied with 864000 or 24*60*60*10) being rounded. – LU RD May 06 '15 at 12:52
  • ST0 contains `4022 874A E699 A800 0122` (36317325722.5) when Round is called in x86. – LU RD May 06 '15 at 13:03
  • That's a different value from that reported by the asker. – David Heffernan May 06 '15 at 13:04
  • 1
    Hans, your last update means that storing the calculation in a Double before rounding is a solution. – LU RD May 06 '15 at 13:30
  • @LURD Yes, then it will work in the same way on all platforms, but unfortunately I have to be backward compatible too, so I must increase the precision of this calculation on ARM to be similar to extended. I plan to simply separate the Integer and fraction part of the timestamp before the calculation. That should approximately give the 4 extra digits precision I need... – Hans May 06 '15 at 13:46
  • 1
    @Hans I don't think that will work. You need to replicate the rounding to 80 bit intermediates exactly. Adding more precision won't always help because 80 bit precision is not exact. You need the **same** precision, not **more** precision. I think you will find it very hard to replicate 80 bit on ARM. I'm afraid that you have quite a tricky task ahead of you. Can't you break free from the backwards compatibility bind? – David Heffernan May 06 '15 at 13:48

2 Answers2

5

Assuming that all the processors are IEEE754 compliant, and that you are using the same rounding mode in all processors, then you will be able to get the same results from all the different processors.

However, there may be compiled code differences, or implementation differences with your code as it stands.

Consider how

fTimeStamp * 24 * 60 * 60 * 10

is evaluated. Some compilers may perform

fTimeStamp * 24

and then store the intermediate result in a FP register. Then multiply that by 60, and store to a FP register. And so on.

Now, under x86 the floating point registers are 80 bit extended and by default, those intermediate registers will hold the results to 80 bits.

On the other hand the ARM processors don't have 80 registers. The intermediate values are held at 64 bit double precision.

So that's a machine implementation difference that would explain your observed behaviour.

Another possibility is that the ARM compiler spots the constant in the expression and evaluates it at compile time, reducing the above to

fTimeStamp * 864000

I've never seen an x86 or x64 compiler that does that, but perhaps the ARM compiler does. That's a difference in the compiled code. I'm not saying that it happens, I don't know the mobile compilers. But there's no reason why it could not happen.

However, here is your salvation. Re-write your expression as above with that single multiplication. That way you get rid of any scope for intermediate values being stored to different precision. Then, so long as Round means the same thing on all processors, the results will be identical.

Personally I'd avoid questions over rounding mode and instead of Round would use Trunc. I know it has a different meaning, but for your purposes it is an arbitrary choice.

You'd then be left with:

lIntStamp := Trunc(fTimeStamp * 864000); //steps of 1/10th second
lIntStamp := lIntStamp and $FFFFFFFF;

If Round is behaving differently on the different platforms then you may need to implement it yourself on ARM. On x86 the default rounding mode is bankers. That only matters when half way between two integers. So check if Frac(...) = 0.5 and round accordingly. That check is safe because 0.5 is exactly representable.

On the other hand you seem to be claiming that

Round(36317325722.5000008) = 36317325722

on ARM. If so that is a bug. I cannot believe what you claim. I believe that the value passed to Round is in fact 36317325722.5 on ARM. That's the only thing that can make sense to me. I cannot believe Round is defective.

David Heffernan
  • 601,492
  • 42
  • 1,072
  • 1,490
  • Good points. I changed it to fTimeStamp * 864000 as suggested, but it did not change the result. Using Trunc is not an easy solution because it is not backward compatible with all our programs and files being out already. – Hans May 06 '15 at 12:32
  • Very hard to see how a simple multiplication could have more than one answer. I guess you need to do some debugging. Backwards compatibility is not going to be possible. Well, not easily. Sadly your original choice of algorithm was ill thought out. Using 80 bit intermediates is not portable. – David Heffernan May 06 '15 at 12:36
  • Of course, you are also hurt by MS/Borland's moronic choice of floating point for a date/time type. – David Heffernan May 06 '15 at 12:37
  • I presume you used Trunc(fTimeStamp * 864000) on both platforms? And got different results? What was the value of fTimeStep? Report it with PInt64(@fTimeStep)^ – David Heffernan May 06 '15 at 12:38
  • Anyway, you can do some debugging to narrow this down. Capture intermediates with trace debugging. Is the same value being passed to Round or Trunc? And so on. – David Heffernan May 06 '15 at 12:42
  • The value 36317325722.5000008 was found with a multiplikation in the WIndows calculator and not in the program, but as I wrote in my comment it was either this calculation that was different on the two platforms or the round. It showed to be the first because as you wrote in your answer the intermediate resolution is higher (80 bit) on x86 than on Arm. – Hans May 06 '15 at 13:34
  • 1
    Sorry for the somewhat confused comment trail and answer edits. Anyway, I'm glad that I helped steer you in the right direction. Thanks also to @LURD – David Heffernan May 06 '15 at 13:47
  • Sorry for misleading you with the result of the extended calculation. I was tricked by the debugger, not showing the full precision of the extended value in the FPU window. – LU RD May 06 '15 at 20:46
2

Just to be complete, here is what is going on:

A call to Round(d*n);, where d is a double and n is a number, will turn the multiplication into an extended value before calling the Round function, on an x86 environment. On a x64 platform or OSX or IOS/Android platform, there is no promotion to an 80 bit extended value.

Analysing the extended values can be tricky, since the RTL has no function to write the full precision of an extended value. John Herbster wrote such a library http://cc.embarcadero.com/Item/19421. (Add FormatSettings in two places to make it compile on a modern Delphi version).

Here is a small test that writes the results of extended and double values in steps of 1 bit change in the input double value.

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

Note that the fTimeStamp value in the question has an exact double representation (ending with .5) when using double precision calculation, while the extended calculation gives a value that is a tiny bit higher. This is the explanation of the different rounding results for the platforms.


As noted in comments, the solution would be to store the calculation in a Double before rounding. This would not solve the backward compatibility problem, which is not easy to accomplish. Perhaps that is a good opportunity to store the time in another format.

LU RD
  • 34,438
  • 5
  • 88
  • 296
  • Thank you. I made some more tests (incrementing a TDateTime by various small steps over several days) and found that the propability of the rounding error seems to be constant at: 3.8E-6 (1 out of 263,000), so it does not happen very often... – Hans May 07 '15 at 07:40