Something like this:
if( a == x/y )
assert( a == x/y );
Is allowed to assert (!). It's also allowed to not assert. Worse, the following is a legal "optimization" of the above code: if( a == x/y )
if ( getRandomBooleanFromSomewhere() )
assert( a == x/y );
(I'd use rand, but I don't want to get into matters of state here. Just assume that getRandomBooleanFromSomewhere, well, pulls a random boolean from somewhere. No side effects.)Why, you might ask? It's allowed to use extra precision, but not required to. And it's not required to even be consistent about it. So: if it uses extra precision, and it decides to lose the extra precision (say, by spilling x/y to stack) between the first and second uses, the assert could potentially fire. If it doesn't do both of those, the assert cannot fire. So it's legal to remove the assert, but also legal to keep it. And also legal to randomly decide which to do at runtime.
Now, this is a stupid thing for it to do in this case. But nonetheless, it is allowed. And there are cases where analogous optimizations actually make a plausible amount of sense.
You can work around it to an extent with FPU flags. But that way lies madness. To put it mildly. ("It desyncs when I print a document.")
It's the compiler that's mainly the problem here. Or rather, the language standard.
That most certainly gives the appearance of non-determinism.
And floating point is frequently infamously tricky to deal with, I think is the obvious point without arguing terminology.
if( a == x/y )
assert( a == x/y );
Is legally allowed to trigger (!). Worse, it's also legally allowed to not trigger.That's right. The above can be legally compiled to always trigger, or never trigger, or even to sometimes trigger (although this is rare in practice).
Why? C / C++ allow floats to be stored with excess precision. And they lose that excess precision when spilled to RAM. And GCC isn't deterministic. So x/y may be pulled out as a common subexpression and spilled to a register after the first usage, and it triggers. And then the next time it isn't, and it doesn't. (Effectively, the difference between this:
temp = x/y
if( a == temp) {
spill(temp);
assert( a == temp );
}
and this: temp = x/y
if( a == temp) {
assert( a == temp );
}
Yes, it'll (generally - though the standard doesn't dictate that it will be!) be deterministic in terms of always with a given example in an single binary triggering or not triggering, but that is irrelevant, as binaries aren't cross-platform and you aren't always loading exactly the same binaries of the dynamic libraries you've linked to.You can (most of the time) work around this by manually setting FPU precision (note that you need to change the precision of both the mantissa and exponent, as otherwise you can still end up with issues). (Except that you, in C++ at least, have to work deep magic - you have to set it before main even runs, because you can end up with problems with static initialization otherwise.) And in the process losing compatibility. And finding that random code you call occasionally resets things. Or random code you don't call (a classic example: the printer driver!). And using exactly one of float or double throughout. And hoping that none of the libraries you use ever resets FPU precision for anything, or uses the other of float or double itself. (And hope that your OS properly saves / restores the FPU everywhere, though this has largely become a non-issue).
And, worst of all, you've got to hope that the compiler performs constant folding with the same level of care as you've just taken.
Look at these:
https://gcc.gnu.org/bugzilla/show_bug.cgi?id=323
https://gcc.gnu.org/bugzilla/show_bug.cgi?id=37845
http://yosefk.com/blog/consistency-how-to-defeat-the-purpose...
http://gafferongames.com/networking-for-game-programmers/flo...
http://www.yosoygames.com.ar/wp/2013/07/on-floating-point-de...
If only to have deterministic test cases...
(Relatedly, several ASIC and FPGA tools suffer from nonlinearity so badly that they will let you specify the random seed at the start and/or do multiple runs with different seeds to pick the best result)
if( a <= x/y )
assert( a <= x/y );
Or with any other of the operators you mention (<, <=, >, >=).Main source of multi-player sync loss was a different number of rand() calls between machines e.g. a different animation sequence for own character vs enemy. If own animation calls one rand() too many, the next common rand will return diff values on own vs enemy machines. And you essentially start playing two different games together ;-)
That being said, we fixed all bugs. There were no additional checks to synchronize state other than keyboards. It was way easier than we anticipated!
Good times!
Unfortunately, many of the edge cases that cause non-deterministic behavior have gotten substantially more exploited by compilers since then.
The problems with today's systems does not lie in compilers. It lies in vastly increased complexity of the code itself.
Back in 96 we had a few sprites, with some animation sequence, sound effects. State of an object was kept in a few integers. Collisions were done using really stupid algorithm that would probe special mask in level builder with a circle consisting of 16 points, so we can get a vector pointing away from the wall. Etc.
Today, collisions, physics, presentation - all this is thousands of times more complex. More code, more bugs, more issues.
For that reason, it would be very difficult to write keyboard-state driven multiplayer today.
W.r.t. floating-point, good luck. See https://news.ycombinator.com/item?id=9430958
The only way to correct such error would be to change model to sync the actual state among machines. But that means completely different code.
All in all, I remember we added the multiplayer code at the very end. If we decided to sync state rather than exchange keyboard+mouse, we would not finish on time.
At least for anything that ever passes through C or C++, or uses code that does. (I.e. everything, close enough.)
Games I've worked on have used libraries e.g. http://nicolas.brodu.net/programmation/streflop/index.html
Now, what do you suppose happens during a context switch, when a second process wants to use floating point math?
Where you can get unpredictable (though still repeatable) behaviour is when your compiler spills into 64-bit memory slots, and does it a little differently with each little modification to the code.