The <chrono> header from the C++ standard library provides applications with various types to abstract clocks, points in time and operations thereon. However, there are dangers lurking beneath some of its convenient features: the C++ Standard has some simple-yet-strong mathematical statements that do not always hold in practice with the limited and variable precision of floats.
Time points are represented as a single number, paired with a fraction that denotes the number of seconds it represents. For example, 1ms could be represented as 1*(1/10^3) or 1000*(1/10^6). The number may be any number (including floats!) and the fraction can consist of any two integers.
The <chrono> header provides users with ways to implicitly convert between their own time representation and the implementation-defined representation used by the <chrono> clocks. A query to “wait for 0.0025s” is converted with little-to-no effort to “2500000ns” to interface with a clock.
The straightforward way in which time representations are converted is to compute them directly. This is inherently a lossy operation, which is well documented by the standard. No issues here.
During comparisons between differently represented points in time, <chrono> tries to ensure more accuracy with an additional step. Instead of converting one side to the other, both are converted to have a common fraction. For example, comparing fractions 1/3 and 1/5 causes both of them to be converted to 1/15:
The comparison 2*(1/3) < 2*(1/5) is transformed to 10*(1/15) < 6*(1/15).
This means that their values can only be multiplied, and we would never lose any precision, right?
For integers, yes, but as the multiplication required to create this common representation results in higher numbers, any floats that represent them start losing precision. Not an uncommon occurrence when most implementations count in nanoseconds. We found that the Standard and implementations overlook this quirk in the floor() function when using floats.
The floor() function converts one point in time from into a time to with a different representation and rounds down. The resulting to must be the latest representable time point not later than from. This is asserted with the operator, which converts operands to a common representation. If the implementation does not account for the potential loss in precision, the next representable time after to may become equal to from as a float, even if it is larger in theory. The post-condition of floor() is now blatantly violated! (see https://godbolt.org/z/Yx6z9oEGa)
This issue, and more besides, were revealed during our coverage of the <chrono> header for SuperGuard. They cause subtle differences in results and appear seemingly arbitrarily in only some use cases and configurations, so it makes sense that they fly under the radar and are not officially documented. Exactly the thing you want to be made aware of when developing safety-critical applications!
Max Blankestijn, Software Engineer