Skip to content

Regression in rounding of Duration::from_secs_f64 in 1.60.0 #96045

Closed
@dtolnay

Description

@dtolnay
Member
use std::time::Duration;

fn main() {
    let dur = Duration::from_secs_f64(0.000042);
    assert_eq!(dur, Duration::new(0, 42000));
}

The above assertion succeeds on Rust standard library versions prior to 1.60.0. On 1.60.0 it fails with:

thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `41.999µs`,
 right: `42µs`', src/main.rs:6:5

The exact mathematical value represented by 0.000042_f64 is:

0.0000419999999999999976759042230600726952616241760551929473876953125

which is about 41999.999999999998ns. This is astronomically closer to 42000ns than to 41999ns. I believe the correct behavior for from_secs_f64 would be to produce 42000ns as before, not truncate to 41999ns.

Mentioning @newpavlov @nagisa since #90247 seems related and is in the right commit range. The PR summary mentions that the PR would be performing truncation instead of rounding but I don't see that this decision was ever discussed by the library API team. (Mentioning @rust-lang/libs-api to comment on desired behavior.)

Activity

added
T-libs-apiRelevant to the library API team, which will review and decide on the PR/issue.
regression-from-stable-to-stablePerformance or correctness regression from one stable version to another.
C-bugCategory: This is a bug.
T-libsRelevant to the library team, which will review and decide on the PR/issue.
on Apr 14, 2022
added
I-prioritizeIssue: Indicates that prioritization has been requested for this issue.
on Apr 14, 2022
nagisa

nagisa commented on Apr 14, 2022

@nagisa
Member
dtolnay

dtolnay commented on Apr 14, 2022

@dtolnay
MemberAuthor

#93535 (comment) says that the decision in favor of truncating was motivated in part because the integer accessors on Duration, such as subsec_micros, do truncation not rounding. For example a duration with 41999 nanos would say it has 41 subsec micros, not 42.

I don't believe this rationale is correct to apply to from_secs_f64. People's expectations are quite different for integer manipulation vs floats. I think it is widely understood that / performs truncating division for integers and rounding for floats (mandated by IEEE 754).

Truncating is especially not a good behavior for from_secs_f64 because every float that is the closest f64 to an exact number of nanoseconds (like 0.000042_f64 is) is going to be in some tiny tiny interval around that number of nanoseconds, "randomly" either slightly smaller or slightly greater. It would be silly to split that interval and send the bottom half of it to a different result than the top half.

  exactly x-1 nanos          x nanos                    x+1 nanos
  |                          |                          |
 [ ]                        [ ]                        [ ]
 ^ ^
   float somewhere in this range

(In reality these intervals are 10000000000x smaller than shown in the ASCII art, which makes the truncating behavior even more absurd.)

None of the integer operations on Duration share a similar characteristic to this.

m-ou-se

m-ou-se commented on Apr 14, 2022

@m-ou-se
Member

Hm, some_int as f64 rounds, while some_float as i64 truncates. This is basically the some_float as i64 situation, which would suggest that truncating is the right option. Except that integers (whole numbers) are exactly representable, which makes the issue disappear:

    let mut x = 0.000042;
    println!("{x:.100}"); // 0.0000419999999999999976759042230600726952616241760551929473876953125000000000000000000000000000000000
    x *= 1e9;
    println!("{x:.100}"); // 42000.0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000

So, while 0.000042 is slightly less than it should be, (0.000042 * 1e9) as i64 still produces exactly 42000. Interestingly, the rounding that makes this work happens in the multiplication, not in the conversion to i64.

m-ou-se

m-ou-se commented on Apr 14, 2022

@m-ou-se
Member

That's not universal though. For example, (0.000000015 * 1e9) as i64 is 14, not 15.

And that already happened in Duration::from_secs_f64 before 1.60:

Latest Rust:

[src/main.rs:4] Duration::from_secs_f64(0.000000015).as_nanos() = 14
[src/main.rs:5] Duration::from_secs_f64(0.000042).as_nanos() = 41999

Rust 1.59.0:

[src/main.rs:4] Duration::from_secs_f64(0.000000015).as_nanos() = 14
[src/main.rs:5] Duration::from_secs_f64(0.000042).as_nanos() = 42000
m-ou-se

m-ou-se commented on Apr 14, 2022

@m-ou-se
Member

It might be reasonable to round to nanoseconds. (Simply add half a nanosecond to the f64 before converting it.) But the previous behaviour was also truncating, not rounding. It's just that rounding errors happen in slightly different places, making this a regression for 0.00042.

m-ou-se

m-ou-se commented on Apr 14, 2022

@m-ou-se
Member

Testing for every whole number of nanoseconds between 0 and 1, we had 'wrong' answers for 1.7% of them before the recent change (like 0.000000015s = 14ns), and for 48% of them after the change (like 0.00042s = 41999ns).

m-ou-se

m-ou-se commented on Apr 14, 2022

@m-ou-se
Member

Here's another argument for doing rounding, that does not involve floating point literals: It doesn't round-trip, even if f64 has more than enough precision:

[src/main.rs:7] Duration::from_secs_f64(Duration::from_nanos(15).as_secs_f64()).as_nanos() = 14

If we simply add 0.5e-9 to the f64 before converting it, to make it round instead of truncate, all these issues disappear.

nagisa

nagisa commented on Apr 14, 2022

@nagisa
Member

Simply add half a nanosecond to the f64 before converting it.

This is not something we can do. Taking the ASCII art given by dtolnay above, if we were to adopt a round-to-nearest scheme, we'd end up with something like this:

  exactly x-1 nanos         x nanos                x+1 nanos
          |                    |                      |
[                   ][                    ][                     ]
  round towards x-1      round towards x      round towards x+1

If we were to add half a nanosecond to the floating point value before truncating, we'd end up rounding wrong at the exact points where rounding… “direction” changes (as denoted by ][).

There are some descriptions on how to correctly implement round-to-nearest in software for example here, but it'll naturally make the algorithm that much more complicated.


It doesn't round-trip, even if f64 has more than enough precision

The roundtrip property does not and cannot hold for the values representable by Duration. Duration can hold 96 bits worth of data.

newpavlov

newpavlov commented on Apr 14, 2022

@newpavlov
Contributor

I've created the experimental PR which implements rounding. I am not 100% sure that rounding is implemented correctly and that I haven't missed any corner cases.

Personally, I think that having truncation in float-duration conversion is fine and that we should prefer it for code simplicity.

17 remaining items

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    C-bugCategory: This is a bug.P-mediumMedium priorityT-libsRelevant to the library team, which will review and decide on the PR/issue.T-libs-apiRelevant to the library API team, which will review and decide on the PR/issue.regression-from-stable-to-stablePerformance or correctness regression from one stable version to another.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      Participants

      @joshtriplett@newpavlov@BurntSushi@nagisa@m-ou-se

      Issue actions

        Regression in rounding of Duration::from_secs_f64 in 1.60.0 · Issue #96045 · rust-lang/rust