Skip to content

Implement c++20 std::chrono::duration subsecond formatting - #2623

Merged
vitaut merged 14 commits into
fmtlib:masterfrom
matrackif:cpp20-format-subsecond
Dec 9, 2021
Merged

vitaut merged 14 commits into
fmtlib:masterfrom
matrackif:cpp20-format-subsecond

Conversation

@matrackif

Copy link
Copy Markdown
Contributor

Hello,

I have returned with a solution to #2207, after dealing with licensing issues in PR #2554. This time I am 100% the author of the code. The solution is simplified, and could be adapted to use if-constexpr if C++17 is detected at compile-time. In fact, the solution I have proposed is working better than the current MSVC implementation which leads to template overflow when std::atto is used as a precision. I will open an issue there.

  1. Subsecond values that are not representable in std::intmax_t (the standard type for chrono tick counts) cause program termination with FMT_ASSERT(). The standard requires us to support up to 18 decimal digits, and std::int64_t can store a maximum value of 9,223,372,036,854,775,807 or, 9.2e+18,. Therefore it can represent all 18 digit numbers. However the following assert test had to be removed:
(void)fmt::format("{:%S}",
                    std::chrono::duration<float, std::atto>(1.79400457e+31f));
  1. The decimal point is still not formatted according to the locale but that is a general problem.

* Allow proper Duration::rep type to propagate via template argument deduction
* Allow proper Duration::rep type to propagate via template argument deduction
@matrackif

Copy link
Copy Markdown
Contributor Author

@vitaut Hi could you rerun the CI tests.

Thanks

@matrackif

Copy link
Copy Markdown
Contributor Author

@vitaut I locally used -Wconversion instead of -Wsign-converison so line of code still had to be fixed. If you could run the workflow one more time it would be much appreciated.

Thanks

@vitaut vitaut left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR!

Comment thread test/chrono-test.cc
Comment thread test/chrono-test.cc Outdated
}
using nanoseconds_dbl = std::chrono::duration<double, std::nano>;
EXPECT_EQ(fmt::format("{:%S}", nanoseconds_dbl{-123456789.123456789}),
"-00.123456789");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the integral part 00 here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the spec:

If the number of seconds is less than 10, the result is prefixed with 0

The number of seconds is 0, which is less than 10, hence we write 00.

The MSVC implementation of std::format also interprets the standard this way. The check below passes locally in my instance of Visual Studio 2022

std::format("{:%S}", std::chrono::duration<double, std::nano{-123456789.123456789}) == std::string("-00.123456789")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I misread the test case and thought that the integral part 123456789. is seconds rather than nanoseconds. I suggest changing the fractional part to something different to prevent confusion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread test/chrono-test.cc Outdated

TEST(chrono_test, cpp20_duration_subsecond_support) {
using attoseconds = std::chrono::duration<std::intmax_t, std::atto>;
// Check that 18 digits of subsecond precision are supported

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Please add punctuation (. in the end of the sentence) here and elsewhere.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread test/chrono-test.cc Outdated
EXPECT_EQ(fmt::format("{:%H:%M:%S}", dur), formatted_dur);
}
// Check that durations with precision greater than std::chrono::seconds have
// fixed precision and empty zeros

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"empty zeros"? I suggest rephrasing this as "zero fractional part" or smth like that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread test/chrono-test.cc Outdated
Comment on lines +547 to +548
"40",
fmt::format("{:%S}", std::chrono::duration<double>(1e20)).substr(0, 2));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why change this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the spec, the decimal point should be printed only if the duration "period" type is more precise than seconds:

If the precision of the input cannot be exactly represented with seconds, then the format is a decimal floating-point number with a fixed format and a precision matching that of the precision of the input

This can be determined statically at compile time and does not depend on runtime values of the duration. The test I modified has seconds precision, so I removed the decimal point.

In my local instance of Visual Studio 2022, the following test passes:

std::format("{:%S}", std::chrono::duration<double>{1.234}) == std::string("01")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we check that there is no decimal point then?

Comment thread include/fmt/chrono.h Outdated
}

void write(Rep value, int width) {
template <typename RepType> void write(RepType value, int width) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why introduce a template parameter instead of using Rep?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rep is currently assumed to be an unsigned type. I wanted the proper type to propagate through the code and be deduced in the template. Otherwise there could be an unsigned to signed conversion here which would lead to a -Wconversion error.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rep is currently assumed to be an unsigned type.

I don't think this is correct. Rep is the representation type of a duration and it can be signed (in fact it's normally signed).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, lowercase rep is used to store the internal duration value, and rep is unsigned to avoid overflow. At first I wanted to use the existing logic in the write() function to format a duration value of type lowercase rep. This is irrelevant now as I reverted this function to its previous state

Comment thread include/fmt/chrono.h Outdated
Comment on lines +1594 to +1595
uint32_or_64_or_128_t<std::intmax_t> n =
to_unsigned(to_nonnegative_int(value, max_value<std::intmax_t>()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use long long instead of intmax_t because it's a built-in type and can also handle 18 digits of precision.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you wish, std::chrono::duration types use std::intmax_t internally that's why I went along with it.

Comment thread include/fmt/chrono.h Outdated
@@ -1319,20 +1319,20 @@ inline bool isfinite(T) {
}

// Converts value to int and checks that it's in the range [0, upper).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

int -> Int

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread include/fmt/chrono.h Outdated
Comment thread include/fmt/chrono.h Outdated
1000);
return std::chrono::duration<Rep, std::milli>(static_cast<Rep>(ms));
}
template <class Duration> class subsecond_helper {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having a utility struct seems unnecessary. I suggest converting it into a function (e.g. write_fractional_seconds) or merging the logic into write where it is used.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

matrackif and others added 5 commits December 4, 2021 19:41
Co-authored-by: Victor Zverovich <victor.zverovich@gmail.com>
…th name write_fractional_seconds()

* Revert write(Rep value, int width) function to previous state
@matrackif

Copy link
Copy Markdown
Contributor Author

@vitaut I have implemented the suggestions you have requested. If you have any further remarks I would gladly hear them. The workflows still need to be run :)

Comment thread test/chrono-test.cc Outdated
Comment on lines +547 to +549
// No decimal point is printed so size() is 2.
EXPECT_EQ(value.size(), 2);
EXPECT_EQ("40", value.substr(0, 2));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's simplify this:

EXPECT_EQ(value, "40");

Then we don't need a comment because it's clear that we don't emit a decimal point.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course. I recall the size of this string being platform dependent but after the changes in this PR it should always be consistent. Done

Comment thread include/fmt/chrono.h

@vitaut vitaut left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more, mostly minor comments.

Comment thread include/fmt/chrono.h Outdated
template <typename Rep, typename Period,
FMT_ENABLE_IF(std::is_floating_point<Rep>::value)>
inline std::chrono::duration<Rep, std::milli> get_milliseconds(
// Returns the amount of digits according to the c++ 20 spec

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: amount -> number

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok

Comment thread include/fmt/chrono.h Outdated
// Returns the amount of digits according to the c++ 20 spec
// In the range [0, 18], if more than 18 fractional digits are required,
// then we return 6 for microseconds precision.
static constexpr int num_digits(long long num, long long den, int n = 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think static is needed here (and similarly in a functions below).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, static made sense when it was a member function, now it is no longer is needed.

Comment thread include/fmt/chrono.h
inline std::chrono::duration<Rep, std::milli> get_milliseconds(
// Returns the amount of digits according to the c++ 20 spec
// In the range [0, 18], if more than 18 fractional digits are required,
// then we return 6 for microseconds precision.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is returning 6 actually required by the standard? Can we turn it into a compile-time error instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the standard:

...and a precision matching that of the precision of the input (or to a microseconds precision if the conversion to floating-point decimal seconds cannot be made within 18 fractional digits)

Microseconds precision is 6 digits. The MSVC implementation of std::format also behaves this way.

Comment thread include/fmt/chrono.h Outdated
}

template <class Duration> void write_fractional_seconds(Duration d) {
static constexpr auto fractional_width =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't need to be static because it's just an int.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, lightweight integral types probably do not need to be static, even if constexpr

* Remove static from detail functions, they are no longer member functions of a class and static is unnecessary.
* Change comment from "amount" to "number"
Comment thread include/fmt/chrono.h

template <class Rep, class Period,
FMT_ENABLE_IF(!std::numeric_limits<Rep>::is_signed)>
static constexpr std::chrono::duration<Rep, Period> abs(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more static to remove here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in a follow-up commit.

@vitaut
vitaut merged commit 0bbc970 into fmtlib:master Dec 9, 2021
@vitaut

vitaut commented Dec 9, 2021

Copy link
Copy Markdown
Contributor

Merged, thanks!

@vitaut

vitaut commented Dec 22, 2021

Copy link
Copy Markdown
Contributor

A fuzzer has discovered a UB which seems to be introduced by this PR:

fmt::print("{:%S}", std::chrono::duration<long double, std::ratio<1, 1000000> >(-1.7591538808701083784E+4865L));
% c++ test.cc -std=c++11 -I include src/format.cc -fsanitize=undefined
% ./a.out
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/chrono:906:28: runtime error: 1.75915e+4859 is outside the range of representable values of type 'long long'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior 

@matrackif

Copy link
Copy Markdown
Contributor Author

Subseconds are always an integral value no longer than 18 digits. The implementation will cast all values to a long long. If the value is not representable by a long long like this huge double UBsan will report this error.

It is not clear how to handle values not representable by intmax_t/long long, the spec doesn't seem to mention what to do. I guess it's up to the implementation how to truncate such values.

@vitaut

vitaut commented Dec 23, 2021

Copy link
Copy Markdown
Contributor

I think we should add a range check when converting from an FP number.

@matrackif

Copy link
Copy Markdown
Contributor Author

And throw an exception if the value is out of range? What method do you propose for range checking floats. I assume such checks exist already in the codebase

@vitaut

vitaut commented Dec 24, 2021

Copy link
Copy Markdown
Contributor

I implemented a fix for the UB in 784e2a7.

phprus referenced this pull request Dec 26, 2021
@matrackif

matrackif commented Jan 7, 2022

Copy link
Copy Markdown
Contributor Author

I implemented a fix for the UB in 784e2a7.

This commit does not work. I believe it can be safely reverted because currently the above example throws an exception "invalid value". If exceptions are disabled then we need an alternative approach.

fmt::print("{:%S}\n", std::chrono::duration<double, std::milli >(1.0)); correctly prints 00.001

Whereas if we change the type to long double then it incorrectly prints 00.000

The offending line is here

detail::abs(d) - std::chrono::duration_cast<sec>(d) where sec is std::chrono::duration<long double> will subtract away the subsecond part which we want to keep. The integer cast to std::chrono::seconds was intentional in the implementation exactly for this reason.

Link to a godbolt example

Also the commit addresses only long double, but doesn't the undefined behavior problem exist with other floating point types such as float, and double?

@vitaut

vitaut commented Jan 7, 2022

Copy link
Copy Markdown
Contributor

Could you provide a PR with a better workaround for the UB?

@matrackif

matrackif commented Jan 7, 2022

Copy link
Copy Markdown
Contributor Author

At first I thought of the following solution:

  1. Check if the duration type is floating_point and if it is greater than max_value<long long>

a. If yes, then cast it to its seconds representation, and truncate it. This way we don't subtract away the valuable subsecond part of the number. Something like this:

detail::abs(d) - Duration{std::trunc(std::chrono::duration_cast<std::chrono::duration<typename Duration::rep>>(d).count())}

b. If not, then cast to std::chrono::seconds as before and call it a day.

But then I realized that in practice such code will never be reached because

uint32_or_64_or_128_t<long long> n = to_unsigned(to_nonnegative_int(subseconds, max_value<long long>()));

Will thrown an exception if the value is larger than max_value<long long> anyways. The simple code suddenly becomes much more complicated if we want to handle floating point values in the range: (max_value<long long>, max_value<long double>]

A possible workaround would be to avoid using detail::count_digits() to count the number of digits for floating point types and simply delegate the work to the fmt floating point formatter and specifying precision to be equal to num_fractional_digits:

Any thoughts?

@vitaut

vitaut commented Jan 7, 2022

Copy link
Copy Markdown
Contributor

A possible workaround would be to avoid using detail::count_digits() to count the number of digits for floating point types and simply delegate the work to the fmt floating point formatter and specifying precision to be equal to num_fractional_digits

This sounds reasonable. We can always optimize this path later and correctness is more important.

@matrackif

matrackif commented Jan 8, 2022

Copy link
Copy Markdown
Contributor Author

Ok, I am trying to format floating point subsecond values using the floating point formatter, but as always there are issues :).

If the duration type is floating point, then we do our calculations using floating point arithmetic. Let's say we calculate the subseconds remaining to be std::chrono::duration<double, std::micro>(123.456). We call:

format_to(out, "{:{}.{}f}", subseconds, num_fractional_digits, num_fractional_digits);, but this yields 123.456000 which is not what we want. We want the result to be simply 123, because we don't care about the fractional part of the subseconds themselves. After calling std::trunc() on the value we obtain 123.000000, still doesn't help because all std::trunc() did was zero out the fractional part.

So we could cast to an integral type to remove the fractional part, but then we end up with the same problem that we started with, since some floating point values are not representable by integral types and we get UB.

In other words, how can we remove the fractional part of floating point numbers without casting to an integral type? Or how can we format a FP number without its fractional part (and decimal point), using existing facilities?

@matrackif

Copy link
Copy Markdown
Contributor Author

The easy way out would be to simply throw an exception if the formatted value is larger than max_value<long long>. Then the code would technically be correct and there would be no UB, the downside is that floating point values larger than max_value<long long> would not be supported by the facility.

@vitaut

vitaut commented Jan 9, 2022

Copy link
Copy Markdown
Contributor

I think we should format integral and fractional parts together. I took a stab at this as part of fixing another UB in d9f045f but improvements are welcome.

@matrackif

Copy link
Copy Markdown
Contributor Author

I am a fan of this approach. We simply format the duration using the floating point formatting facility, otherwise we use the integral implementation.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants