1

I was doing a check on some variables when I realized that the program was not working as expected. I understood that the problem was due to an "if" block. I'm using .Net Framework 4.7.2

Here is a practical example:

Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load

    Dim a As Single = 20
    Dim b As Single = 0.01

    Console.WriteLine("First test")
    If a / b > 2000 Then 'Same behavior with (a/b)>2000
        Console.WriteLine(String.Format("{0} > 2000", a / b))
    Else
        Console.WriteLine(String.Format("{0} <= 2000", a / b))
    End If

    Console.WriteLine("Second test")
    Dim c As Single = a / b
    If c > 2000 Then
        Console.WriteLine(String.Format("{0} > 2000", c))
    Else
        Console.WriteLine(String.Format("{0} <= 2000", c))
    End If

End Sub

Here is the output:

First test
2000 > 2000
Second test
2000 <= 2000

The correct result is obtained also if i wrote

If CSng(a / b) > 2000 Then ...

Why would I need this casting? Why do I get two different results?

Andrew Morton
  • 24,203
  • 9
  • 60
  • 84
Calaf
  • 1,133
  • 2
  • 9
  • 22
  • [Is floating point math broken](https://stackoverflow.com/questions/588004/is-floating-point-math-broken) – KekuSemau Jul 01 '21 at 09:41
  • Thank you, @KekuSemau . But how come this error doesn't show up if I save the result in a new variable? – Calaf Jul 01 '21 at 09:54
  • 1
    Hmm, I am actually not sure. The 'error' occurs with doubles, but not with singles. But `(a/b).GetType()` returns Single. There has to be some implicit cast to double in the expression, maybe someone can explain this. – KekuSemau Jul 01 '21 at 10:25
  • What happens if you use 2000.0 instead of 2000? Or 2000.0F? If the results of these changes are different, it will point to the source of the differences in rounding at the previous comment alluded to. – Craig Jul 01 '21 at 12:54
  • @Craig nothing changes. The output is the same... – Calaf Jul 01 '21 at 13:23

1 Answers1

2

Even though at the source level, everything is Single (at least, once you make the constants 2000.0F instead of 2000), I'm pretty sure the issue is that this is being done in double precision inside the FPU. The code sequences are almost identical, but the difference is crucial:

If a / b > 2000.0F Then
fld     dword ptr [a]
fdiv    dword ptr [b]
fld     dword ptr ds:[const 2000]
fcomip  st, st(1)

(actual addresses swapped out for readability; the locals are coming off the stack, the literal is coming out of the data segment.)

Compare with:

c = a / b
If c > 2000.0F Then
fld     dword ptr [a]
fdiv    dword ptr [b]
fstp    dword ptr [c]
fld     dword ptr [c]
fld     dword ptr ds:[const 2000]
fcomip  st, st(1)

Note the round trip through memory there. One of the fun features of x87 floating point is that (possibly depending on the rounding modes you have set) it will only round to the defined precision when values are stored. It will work with full precision internally up until that point. I think that's what's happening here: the first sequence doesn't have an intervening store, so it takes place in extended precision, while the second sequence forces a rounding when c gets written to memory.

It gets even more fun... this is in a debug build, which keeps the exact code sequence of the second step intact so that there will be an exact correspondence to the original source to aid in debugging. I'd lay very good odds that a release build with optimization turned up would end up with the first sequence, and you'd get the same results for both codes.

Addendum: I confirmed my supposition, in a release build, I get 2000 > 2000 twice.

Second addendum: I missed the part about CSng the first time. That's even more fun: in a release build, the CSng version is the only one that ends up with "2000 <= 2000".

In a debug build, the behavior is clear from the disassembly: the CSng call results in an intermediate fstp instruction, so it's the same as assignment to c (the stack location just doesn't have an association with a variable name).

It's more interesting in a release build. In the first and third tests, the compiler/JITter is quite smart, and does the comparison at compile-time (it doesn't actually write the comparison call into the output, it only writes the branch it knows will be taken). The second test, however, turns out identically to the debug build, apparently due to the CSng call.

Bruce Dawson has written about floating point in some detail. I would highly recommend reading his article about the internal precision in x87 floating point calculations, linked here: https://randomascii.wordpress.com/2012/03/21/intermediate-floating-point-precision/

All of the above is the story for a 32-bit program, which will generally prefer to compile to x87 floating point. In 64-bit, everything goes through SSE/SSE2, which behaves according to IEEE floating point, and you get the results that you might expect with everything identical. The disassembled instruction sequence looks like this for the first two:

vmovss   xmm0, dword ptr [a]
vdivss   xmm0, xmm0, dword ptr [b]
vucomiss xmm0, dword ptr [const 2000]

For the third, it just adds a single vmovss dword ptr [c], xmm0 to the instruction sequence but it's otherwise identical (in particular, unlike the x87 sequence, it doesn't do a store/load pair, it just stores and keeps working with the enregistered value).

Craig
  • 2,248
  • 1
  • 19
  • 23
  • Do you feel like you want to confirm your conclusion with C#? – Andrew Morton Jul 01 '21 at 20:15
  • @AndrewMorton I haven't tried it yet. Is there something interesting that I should expect if/when I do? Offhand, I can't see a reason why this would compile any differently in C#. – Craig Jul 02 '21 at 04:18
  • I was wondering if it was peculiar to VB.NET, or if it was a general .NET thing. An example of the latter being the difference between the result of some calculations on 32-bit vs. 64-bit because the former uses 80-bit floats (extended precision) in the processor and the latter uses 64-bit floats. – Andrew Morton Jul 02 '21 at 07:46
  • 1
    @AndrewMorton There isn't anything in the disassembly that looks like it's special to VB. Just to confirm, though, I did make a scratch C# project to test, and the results are identical (same debug disassembly, same behavior in a release build). I think this is a cousin to the bit about using 80-bit extended precision in the processor. I suspect that there is an incantation I could use to change rounding modes or code generation that would force consistent behavior. – Craig Jul 02 '21 at 13:11
  • @AndrewMorton BTW, behavior for the cast is the same in C# as well, if I use `(float)(a/b) > 2000.0F` it's the same as `Csng(a / b) > 2000.0F`. – Craig Jul 02 '21 at 13:24
  • Very interesting! Thanks a lot, @Craig! – Calaf Jul 02 '21 at 14:11