Floats can have a really large exponent without losing their significant precision. Turns out that floats allow really large multiplication without any issues, as such:
>>> 9_000_000_000_000_000_000_000_000_000.0 * 1_000_000_000
9e+36
>>> 9_123_456_789_012_345_678_901_234_567.0 * 1_000_000_000
9.123456789012346e+36
>>> int(9_123_456_789_012_345_678_901_234_567.0 * 1_000_000_000)
9123456789012346228226434616254267392
So basically, the float is keeping as many "significant digits" as it can fit internally, truncating the rest (in the left hand operator in the examples above), and then just scaling the exponent. It's able to roughly represent Unix nanosecond timestamps that are far larger than the age of the universe.
When it's time to convert it to an integer, you can also see that the float keeps as much precision as it could and does a good job with the conversion. All of the significant digits are there. There's a lot of "random float rounding errors/noise" at the end of the output number, but those digits don't matter.
In other words, I've had a fundamental misunderstanding about the size of numbers that a float can store. It's not limited per se. It just stores a fixed amount of significant digits and then it uses an exponent to reach the desired scale. So a float would suffice here!
The answer is that I can just do the multiplication directly, and it will be totally safe. Since my multiplier is a straight 1 billion without any fractions, it will just scale up the exponent by 1 billion, without changing any of the digits at all. Fantastic. :)
Just like this!
>>> int(1687976937.597064 * 1_000_000_000)
1687976937597063936
Although when we use an integer like above, Python actually internally converts it into a float (1_000_000_000 (int) -> 1e9 (float)
), since the other operand is a float.
So it's actually 6% faster to do that multiplication with a float directly (avoiding the need for int -> float
conversion of the multiplier):
>>> int(1687976937.597064 * 1e9)
1687976937597063936
As you can see, the result is identical, since both cases are doing float * float
math. The integer just required an extra conversion step first, which the latter method avoids.
Let's recap:
1_687_976_937_597_064_018
was the result of my "split" algorithm earlier (in my original question).
1_687_976_937_597_063_936
is the result given by the suggestion to "just trust the float and do the multiply directly".
1_687_976_937_597_064_000
is the mathematically correct answer given by Wolfram Alpha's calculator.
So my "split" technique had a smaller rounding error. The reason why my method was more accurate is because I had "split" my number into "whole" (int) and "decimals/fractions" (float). Which means that my method has full devotion of all significant digits to the decimals, since I had removed "the whole number" before the decimals/fractions. This means that my "decimals" float was able to devote all significant digits to properly representing the decimals with much greater precision.
But these are UNIX timestamps represented as nanoseconds, and nobody really cares about the "fractions of a second" precision that much. What matters are the first few, important digits of the fraction, and those are all correct. That's all that matters in the end. I'll be using this result to set timestamps on disk via the utimensat API, and all that really matters is that I get roughly the correct fractions of a second. :)
I use the Python os.utime() wrapper for that API, which takes the nanoseconds as a signed integer: "If ns is specified, it must be a 2-tuple of the form (atime_ns, mtime_ns) where each member is an int expressing nanoseconds."
I'm going to do the straight multiplication and then convert the result to an int. That does the math in one simple step, gets sufficient precision for the decimals (fractions of a second), and solves the issue in a satisfactory way!
Here's the Python code I'll be using. It preserves the current "access time" as nanoseconds by fetching that value from disk, and takes the self.unix_mtime
float (a UNIX timestamp with fractions of a second as decimals) and converts that to a signed 64-bit integer nanosecond representation, and then applies the change to the target file/directory:
# Good enough precision for practically anybody. Fast.
file_meta = target_path.lstat()
st_mtime_ns = int(self.unix_mtime * 1e9)
os.utime(
target_path, ns=(file_meta.st_atime_ns, st_mtime_ns), follow_symlinks=False
)
If anyone else wants to do this, beware that I am using lstat()
to get the status of symlinks rather than their target, and using follow_symlinks=False
to ensure that if the final target_path
component is a symlink then I affect the link itself rather than the target. Other people may want to change these calls to stat()
and follow_symlinks=True
if you prefer affecting the target rather than the symlink itself. But I would guess that most people prefer my method of affecting the symlink itself if the target_path
points at a symlink.
If you care about doing this "seconds-float to nanoseconds int" conversion with the highest achievable precision (by devoting maximum float precision to all the decimal digits to minimize rounding errors), then you can do my "split" variant as follows instead (I added type hints for clarity):
# Great conversion precision. Slower.
file_meta = target_path.lstat()
whole: int = int(self.unix_mtime)
frac: float = self.unix_mtime - whole
st_mtime_ns: int = whole * 1_000_000_000 + int(frac * 1e9)
os.utime(
target_path, ns=(file_meta.st_atime_ns, st_mtime_ns), follow_symlinks=False
)
As you can see, it uses int * int
math for the "whole seconds" and uses float * float
math for the "fractions of a second". And then combines the result into an integer. This gives the best of both worlds in terms of accuracy and speed.
I did some benchmarks:
- 50 million iterations on a Ryzen 3900x CPU.
- The "simplified, less accurate" version took 11.728529000014532 seconds.
- The more accurate version took 26.941824199981056 seconds. That's 2.3x the time.
- Considering that I did 50 million iterations, you can be sure that you can safely use the more accurate version without having to worry about the performance. So if you want more accurate timestamps, feel free to use the last method. :)
- As a bonus, I benchmarked @dawg's answer, which is the exact same idea as "the more accurate method", but is done via two calls to
math.modf()
instead of directly calculating the whole/fraction manually. Their answer is the slowest at 33.54755139999557 seconds. I wouldn't recommend it. Besides, the primary idea behind their technique was just to discard everything after the first three float decimals, which doesn't even matter for any practical purposes, and if their removal is truly desired then it can be achieved without slow math.modf()
calls by simply changing my "more accurate" variant's final line to say whole * 1_000_000_000 + (int(frac * 1e3) * 1_000_000)
instead, which achieves that decimal truncation technique in 27.95227960000746 seconds instead.
There's also a third method via the discussed decimal
library which would have perfect mathematical precision (it doesn't use floats), but it's very slow, so I didn't include it.