The horror of converting floating point numbers, is there any way out?

Background

Recently, my colleague added several new tests to our test project. One of them failed or did not integrate the integration system. Since we have about 800 tests, and it takes an hour to start all of this, we often make mistakes and run on our machines only the tests that we have currently implemented. This method has its weakness, because from time to time the tests pass locally, and then refuse to integrate. Of course, someone could say: "This is not a mistake, the tests must be independent of each other!".

In an ideal world ... of course, but not in my world. Not in a world in which you have many loners initialized in the initialization section, many global variables entered by Delphi itself, in OTL the thread pool is initialized in the background, DevExpress methods are connected to controls for painting purposes .. and dozens of other things. which I don’t know about. Thus, in the end result, one test can change the behavior of another test. (This, of course, is bad, and I am glad that this will happen, because, I hope, I can fix another dependency).

I started the entire test package on my machine, and I achieved the same results as in the integration system. So far so good, now I started to turn off the tests, until I narrowed down one test that interfered with the recently added one. They have nothing in common. I went deeper and narrowed the problem down to one line. If I comment on this - test passes, if not - the test fails.

Problem

We have such code for converting text data to longitude coordinates (only the important part was included):

 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; 

The problem is that LLongitude sometimes equal to 1519671, and sometimes it gives 1519672. And whether it gives 1519672 or not depends on another completely unrelated part of the code in another method in another test:

 FormXtrMainImport.JvWizard1.SelectNextPage; 

I checked the four-local SelectNextPage method, it does not fire any event that can change the operation of the FPU module. It does not change the value of RoundingMode , it is always set to rmNearest.

Also, shouldn't Delphi use the banker rule here?

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

If a banker's rule is used, it should always give me 1519672 not 1519671.

I assume that there should be some corrupted memory that causes the problem, and the line with SelectNextPage shows it only. However, the same problem occurs on three different machines.

Can anyone give me an idea on how to trace this issue? Or how to always provide a stable conversion result?

Those who misunderstood my question

  • I checked RoundingMode, and I mentioned it before: "I checked the four-local SelectNextPage method, it does not fire any event that can change the operation of the FPU. The value of RoundingMode is always set to rmNearest." RoundingMode is always rmNearest before any runding happens in the code above.

  • This is not a real test. This is just code to show where the problem is.

Video description added.

So, trying to improve my question, I decided to add a video that shows my problem with bizzare. This is production code, I just added assertions to test RoundingMode. In the first part of the video, I show the original test (@Sir Rufo, @Craig Young), the method responsible for the conversion, and the correct result that I get. In the second part, I show that when I add another unrelated test, I get the wrong result. The video can be found here.

Added example of reproducible

It all comes down to the code below:

 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; 

Just copy and paste this procedure and add the ADODB clause to uses. It seems that the problem is caused by some Microsoft COM object used by the Delphi ADO shell. This object changes the control word FPU, but it does not change the rounding mode. It changes precision control.

Here is a screenshot of the FPU before and after activating the ADO-related method:

FPU screenshot

The only solution that comes to my mind is to use Get8087CW before using the ADO code, and then Set8087CW to set up a control world with a previously saved one.

+4
source share
1 answer

The problem is most likely due to the fact that something else in your code changes the floating point rounding mode. Take a look at this program:

 {$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. 

Output:

  15.196715
 1519671.5
 1519671
 1519671
 1519672
 1519671

Obviously, your specific calculation depends on the floating point rounding mode, as well as the actual input value and code. In fact, the documentation does the following:

Note The behavior of Round can be affected by the Set8087CW procedure or System.Math.SetRoundMode.

Therefore, you first need to find what else in your program has changed the floating-point control word. And then you have to make sure that you set it back to the right value when this incorrect code is executed.


Congratulations on debugging. In fact, this is actually multiplication

 LLong*100000 

affected by precision control.

To make sure this is the case, take a look at this program:

 {$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. 

Output

  False

Thus, precision control affects the results of multiplication, at least in the 80-bit registers of block 8087.

The compiler does not save the result of this variable multiplication and remains in the FPU, so this difference applies to 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
 00406 105 E87ADEFFFF call @WriteLn
 0040610A E851CCFFFF call @_IOTest

Notice how the result of the multiplication remains in ST(0) , because it is where Round expects its parameter.

In fact, if you pull the multiplication into a separate statement and assign it to a variable, then the behavior will again become consistent:

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

The above code produces the same output for $1272 and $1372 .

However, the main problem remains. You have lost control of the state of floating point control. To deal with this, you will need to monitor your FP management state. Whenever you call a library that can change it, store it before the call, and then restore it when the call returns. If you want to have something like repeatable, reliable and reliable floating-point code, this game is unfortunately inevitable.

Here is my code for this:

 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; 

Note that this code processes both floating point blocks and is therefore ready to use the 64-bit version, which uses the SSE block, not the 8087 block.


For what it's worth, here is my SSCCE:

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

Output

  1519672
 1519671
+9
source

Source: https://habr.com/ru/post/1485611/


All Articles