Search code examples
c++c++17c++-chrono

How to convert an arbitrarily large duration to a nanosecond precision duration without overflows?


Suppose I have my own clock with same epoch as system_clock:

using namespace std;

struct myclock
{
    using rep = int64_t;
    using period = nano;
    using duration = chrono::duration<rep, period>;
    using time_point = chrono::time_point<myclock>;

    static constexpr bool is_steady = chrono::system_clock::is_steady;

    static time_point now() noexcept;
};

I need to convert any tp (of type system_clock::time_point) into myclock::time_point like this:

  1. (if need be) truncate tp to discard "past-nanosecond" precision

  2. if tp.time_since_epoch() is in myclock::time_points valid range -- return myclock::time_point() + tp.time_since_epoch()

  3. otherwise, throw an exception

But, without knowing system_clocks period and rep I am running into integer overflows:

    constexpr auto x = chrono::duration<int64_t, milli>(numeric_limits<int64_t>::max());
    constexpr auto y = chrono::duration<int8_t, nano>(1);

    constexpr auto z1 = x / nanoseconds(1) * nanoseconds(1);   // naive way to discard post-nanosecond part
    constexpr auto z2 = y / nanoseconds(1) * nanoseconds(1);
    static_assert( x > y );

How to write this logic in such way that it works reliably for any tp and arbitrary system_clock::period/rep?

P.S. I checked MSVC's implementation of duration_cast/time_point_cast, but it seems that they have same problem (or require same clock type).


Solution

  • Edit: code update to deal with some if constexpr-related quirks.

    Here is what I came up with (godbolt):

    template<class Dx, class Dy>
    constexpr Dx conv_limit()   // noexcept //C++20: change this into consteval
    {
        // Notes:
        //  - pretty sure this works only for non-negative x (for example because of integer promotions in expressions used here)
    
        using X  = typename Dx::rep;
        using Rx = typename Dx::period;
        using Y  = typename Dy::rep;
        using Ry = typename Dy::period;
        using Rxy = ratio_divide<Rx, Ry>;               // y = x * Rxy, Rxy = Rx / Ry
    
        constexpr X xmax = numeric_limits<X>::max();
        constexpr Y ymax = numeric_limits<Y>::max();
    
        static_assert(numeric_limits<X>::is_integer);
        static_assert(numeric_limits<Y>::is_integer);
    
        static_assert(xmax > 0);                        // sanity checks
        static_assert(ymax > 0);
        static_assert(Rxy::num > 0);
        static_assert(Rxy::den > 0);
    
        if constexpr (Rxy::num == 1)                    // y = x / Rxy::den
        {
            static_assert(Rxy::den <= xmax);            // ensure Rxy::den fits into X
    
            // largest x such that x / Rxy::den <= ymax
            constexpr X lim = [&]() -> X {             // have to use lambda to avoid compiler complaining about overflow when this branch is unused
                if (xmax / Rxy::den <= ymax)
                    return xmax;
                else
                    return ymax * Rxy::den + (Rxy::den - 1);
            }();
    
            // if (x <= lim) --> y = static_cast<Y>(x / static_cast<X>(Rxy::den));
            return Dx(lim);
        }
        else if constexpr (Rxy::den == 1)               // y = x * Rxy::num
        {
            static_assert(Rxy::num <= ymax);            // ensure Rxy::num fits into Y
    
            // largest x such that x * Rxy::num <= Ymax
            constexpr X lim = (xmax < ymax ? xmax : ymax) / Rxy::num;
    
            // if (x <= lim) --> y = static_cast<Y>(x) * static_cast<Y>(Rxy::num);
            return Dx(lim);
        }
        else
            static_assert(!sizeof(Dy*), "not implemented");
    }
    

    this function returns maximum value of duration Dx that can be safely converted to duration Dy, if following conditions hold:

    • Dx::rep and Dy::rep are integers
    • Dx value is non-negative
    • conversion ratio is trivial (either num or den is 1)

    Using this now it is possible to write a safe conversion function:

    template<class Dy, class Dx>
    constexpr Dy safe_duration_cast(Dx dx)
    {
        if (dx.count() >= 0 && dx <= conv_limit<Dx, Dy>())
        {
            using X  = typename Dx::rep;
            using Rx = typename Dx::period;
            using Y  = typename Dy::rep;
            using Ry = typename Dy::period;
            using Rxy = ratio_divide<Rx, Ry>;
    
            if constexpr (Rxy::num == 1)
                return Dy( static_cast<Y>(dx.count() / static_cast<X>(Rxy::den)) );
            else if constexpr (Rxy::den == 1)
                return Dy( static_cast<Y>(dx.count()) * static_cast<Y>(Rxy::num) );
        }
    
        throw out_of_range("can't convert");
    }
    

    Notes:

    • pretty sure everything under the if can be replaced with simple duration_cast<Dy>(dx), but after checking MSVC's implementation I prefer mine.

    • now it is trivial to write a safe conversion between clocks (if they share same epoch):

      c1::time_point() + safe_duration_cast<c1::duration>(c2::now().time_since_epoch())

    • ... and if epochs differ all that is needed is an offset and additional check (to avoid wraparounds)

    This is already enough for me -- on all platforms (that I care about) system_clock satisfies my requirements.

    But it is curious to analyze case of non-trivial Rxy -- in this case (mathematically):

    y = x * Rxy = x * n // d, where:

    • // means "whole-number division" (i.e. 7 // 2 = 3)

    • n and d belong to N (natural numbers, i.e. 1,2,3,...)

    • gcd(n,d) == 1 (to help with overflows in calculations)

    The trick would be to write a generic code that will perform said calculation on any platform for maximum range of values. Optionally one can elect to fail on certain class of values for the sake of performance (e.g. if given platform has bignum, we could choose to ignore it and perform calcs using primitive types).

    There are multiple aspects here to consider:

    • for sufficiently small x you can simply run this calc (potentially using comon_type_t<X,Y> or intmax_t for intermediate calcs)

    • calc can be rewritten as: y = x // d * n + (x % d) * n // d, in which case figuring out if given x is safe to convert becomes non-trivial (e.g. if X is int8_t and Rxy = 99/100 then only 0,1,100,101 can be safely converted without using wider integer type (which may not exist)). Note that using unsigned type can widen our safe class of values (doing this in uint8_t adds 2 and 102 to the list)

    • some implementation (instead of pre-calculating safe values) may use hardware flags (i.e. if given multiplication overflows -- it will set the some CPU flag, which will lead to out_of_range being thrown)

    • I bet doing this with floating point rep type could be interesting

    • dropping x has to be non-negative requirement could be interesting too...

    Additional notes

    • it would be nice if C++ provided similar tools to ensure safe conversions

    • using std::ratio is cool, but it hides overflows -- normally I rely on compiler to warn me about possible issues, std::ratio breaks this. You can easily run into very surprising behaviour and you wouldn't know it until your program encounters such value long after you forgot about it... Especially, if values are retrieved from outside (file time stamps/etc)