Avoid losing precision in f64

Hi,
In my crate GitHub - rsalmei/human-repr: Generate beautiful human representations of bytes, durations, and even throughputs!, I convert a number of seconds into a human friendly representation, so I'm used to f64s. But I'm having a hard time with a new, unreleased yet version.

I want this to be true (the p! macro is not important, I only format it with num.human_duration() to get the generated repr):

assert_eq!("19min 20.9s", p!(1160.9));

But unfortunately, only this works:

assert_eq!("19min 20.90000000000009s", p!(1160.9));

I know IEEE754 double-precision floating-point format does allow only 15-17 significant digits, what I'm I'm looking for here is some workaround...
Look, apparently they are precise:

    println!("{}", 0.9f64);
    println!("{}", 1160.9f64);
// 0.9
// 1160.9

But actually:

    println!("{:58.53}", 0.9f64);
    println!("{:58.53}", 1160.9f64);
//    0.90000000000000002220446049250313080847263336181640625
// 1160.90000000000009094947017729282379150390625000000000000

Well, what I want is exactly that previous behaviour, in that Rust somehow detects that the f64 has a lot of zeros, and decides to print a naked "{}" as 0.9 and 1160.9. But, when calculating the minutes/seconds, the larger one shows it has lost precision on the seconds:

    println!("{}", 0.9f64 % 60.0);
    println!("{}", 1160.9f64 % 60.0);
// 0.9
// 20.90000000000009

If I were to "directly" print 20.9, Rust does print it nicely:

    println!("{}", 20.9f64);
// 20.9

I could manually print the original number into a String "1160.9", parse the decimal part as a new f64 "0.9", and use that, but I'd rather not do any heap allocations.
Any suggestions how I could polish the final number of seconds, please?

playground

How much precision do you need? I'd multiply by some power of 10 and round.

That's the thing, I can't really know. An user can send 0.00125 seconds or 183246.188 seconds... In any case, I'll find the best way to convey that information, but in the latter the precision is much worse than in the former, thus my calculation will lead to those artifacts at the end...

How about using a fixed point representation, rather than floats? You represent a number as an integer, along with the number of digits to put after the decimal point.

Another option is to use fractions.

2 Likes

@alice Humm, that's interesting, could you elaborate?
I can keep my internal data private, but how could I acquire that from a f64 input?
An user would use my interface as follows:

use human_repr::HumanDuration;

println!("The operation took: {}", 183246.188.human_duration())

I mean, the number may already be received as f64...

If you don't get to parse the string from the user and have to process an f64, then it's more difficult. You have to pick some sort of heuristic for when to truncate the number.

You don't have to heap allocate to do that. You can write into a byte array on the stack with the write! macro.

1 Like

Wow, I've never thought about that! :thinking: Does a byte array implement fmt::Write?
Well, that would really allow me to fetch the decimal part with full precision, by printing and re-parsing it isolatedly, then adding it to the final result! This seems to work:

    let orig = 1160.9f64;
    let (x, y) = (1160f64, 0.9f64);
    println!("{}min {}s", (orig / 60.).trunc(), orig % 60.);
    println!("{}min {}s", (x / 60.).trunc(), x % 60. + y);
// 19min 20.90000000000009s
// 19min 20.9s

I'm going to try that, thank you @alice!

Err, are you sure @alice?
What am I missing?

error[E0599]: no method named `write_fmt` found for array `[{integer}; 32]` in the current scope
 --> src/main.rs:9:5
  |
9 |     write!(buf, "{}", 0.9f64);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^ method not found in `[{integer}; 32]`
  |
  = note: this error originates in the macro `write` (in Nightly builds, run with -Z macro-backtrace for more info)

Yes, I am sure. The impl you need to use is this one:

impl Write for &mut [u8]

Your problem is that buf has type [u8; 32], but you should pass an &mut [u8] instead.

I still can't make it work:

fn main() {
    let mut buf = [0u8; 32];
    write!(&mut buf, "{}", 0.9f64);
    println!("{:?}", buf);
}

error[E0599]: no method named `write_fmt` found for mutable reference `&mut [u8; 32]` in the current scope
 --> src/main.rs:5:5
  |
5 |     write!(&mut buf, "{}", 0.9f64);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ method not found in `&mut [u8; 32]`
  |
  = note: this error originates in the macro `write` (in Nightly builds, run with -Z macro-backtrace for more info)

Also, I couldn't find it on the docs, the implementors seem to be pretty slim:

  • impl Write for OsString
  • impl Write for String
  • impl Write for Formatter<'_>
  • impl Write for &mut W
    where
    W: Write + ?Sized,

I also tried to use std::fmt::Write;, to no effect.

use std::io::Write; // <---
fn main() {
    let mut buf = [0u8; 32];
    write!(&mut buf[..], "{}", 0.9f64);
    //             ^^^^
    println!("{:?}", buf);
}
1 Like

Yes, you need the type &mut [u8] rather than &mut [u8; 10]. See this for more info on that.

Thank you @quinedot!
Thank you @alice!

I didn't recall that write!() worked with both fmt::Write and io::Write!
I don't really understand their difference, but it seems the io one is lower level... I'll try to implement some detection of the decimal part based on this :+1:

Regarding my original question, do you think this would be the best way to "hide" the lost precision after some calculation, i.e. to try to re-gain that precision and avoid those ugly artifacts at the end?
I'd like this:

        assert_eq!("19min 20.9s", p!(1160.9));
        assert_eq!("50h 54min 6.188s", p!(183246.188));

Instead of this, which is the only way the tests are passing at this moment:

        assert_eq!("19min 20.90000000000008s", p!(1160.9));
        assert_eq!("50h 54min 6.187999999994645s", p!(183246.188));

You cannot regain lost precision. I think your problem is ill-specified. You need to decide what kind of rounding is appropriate.

2 Likes

Honestly I don't think there is a good solution to the constraints you've covered so far, where you're targeting a human audience but they gave you an f64 and you're unwilling to pick some fixed fraction of second as a minimal resolution. For all you know the user has already done some floating point math, in which case you'll still be spitting out "20.90000000000008s". Or phrased differently, your unit tests only test passing an f64 literal; try passing 1160.9 % 60.0, say. It won't perform any nicer under the print-and-parse approach.

The print-and-parse approach is reasonable for the case of "I want an estimate of how many fractional digits were in the textual representation of this f64 that I'm assuming was just parsed from text". But given that assumption, you should probably just parse from text in the first place.

The more general problem of "print this with the shortest number of fractional digits but without losing precision (for round trips)" is what the display implementation does already. If you want less digits in the general case and without giving users a way to specify the number of digits, you'll have to lose precision (round).

3 Likes

You're using as_secs_f64 on a Duration in your linked repository. Duration is a fixed-point representation; it uses 96 bits where the denominator is 1,000,000,000 (the smallest nonzero Duration is a nanosecond). Don't go into floating point in the first place. If you're given a floating point value, rounding to the nearest ns seems reasonable.

1 Like

Yep, I misspoke, we can't "regain lost precision", what I meant was to remove the error introduced by the calculations.

In 20.90000000000008
It is 0.00000000000008

In 6.187999999994645
It is -0.000000000005355

If the goal is to show it to a human, do they really ever care about more than, say, 10 sigfigs? Usually "human" displays of time durations show about one sigfig "about a week ago", not "2 weeks and 3.193 seconds"

1 Like