Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement value_cast<ToQ> and value_cast<ToQP> #571

Merged
merged 11 commits into from
Jun 14, 2024
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- feat: `fma` for quantity points added
- feat: `quantity_point` support added for `quantity_cast` and `value_cast`
- feat: `value_cast<Unit, Representation>` added
- feat: `value_cast<Quantity>(q)`, `value_cast<Quantity>(qp)` and `value_cast<QuantityPoint>(qp)` added by [@burnpanck](https://github.com/burnpanck)
- feat: `interconvertible(QuantitySpec, QuantitySpec)` added
- feat: `qp.quantity_from_zero()` added
- feat: `value_type` type trait added
Expand Down
14 changes: 14 additions & 0 deletions docs/users_guide/framework_basics/value_conversions.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,17 @@ using namespace unit_symbols;
Price price{12.95 * USD};
Scaled spx = value_cast<USD_s, std::int64_t>(price);
```

As a shortcut, instead of providing a unit and a representation type to `value_cast`, you may also provide a
`Quantity` type directly, from which unit and representation type are taken. However, `value_cast<Quantity>`,
still only allows for changes in unit and representation type, but not changing the type of the quantity.
For that, you will have to use a `quantity_cast` instead.

Overloads are also provided for instances of `quantity_point`. Furthermore, in that case, there is
an overload `value_cast<ToQP>(qp)`, which is roughly equivalent to
`value_cast<typename ToQP::quantity_type>(qp).point_for(ToQP::point_origin)`.
mpusz marked this conversation as resolved.
Show resolved Hide resolved
In contrast to a separate `value_cast` followed by `point_for` (or vice-versa), the combined
`value_cast` tries to choose the order of the individual conversion steps in such a way,
to avoid both overflow and unnecessary loss of precision. Overflow is a risk because the change of origin point
may require an addition of a potentially large offset (the difference between the origin points),
which may well be outside the range of one or both quantity types.
120 changes: 101 additions & 19 deletions src/core/include/mp-units/bits/sudo_cast.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,45 @@ template<typename T, typename Other>
using maybe_common_type = MP_UNITS_TYPENAME std::conditional_t<requires { typename std::common_type_t<T, Other>; },
get_common_type<T, Other>, std::type_identity<T>>::type;

/**
* @brief Details about the conversion from one quantity to another.
*
* This struct calculates the conversion factor that needs to be applied to a number,
* in order to convert from one quantity to another. In addition to that, it also
* helps to determine what representations to use at which step in the conversion process,
* in order to avoid overflow and underflow while not causing excessive computations.
*
* @note This is a low-level facility.
*
* @tparam To a target quantity type to cast to
* @tparam From a source quantity type to cast from
*/
template<Quantity To, Quantity From>
requires(castable(From::quantity_spec, To::quantity_spec))
struct magnitude_conversion_traits {
// scale the number
static constexpr Magnitude auto c_mag = get_canonical_unit(From::unit).mag / get_canonical_unit(To::unit).mag;
static constexpr Magnitude auto num = numerator(c_mag);
static constexpr Magnitude auto den = denominator(c_mag);
static constexpr Magnitude auto irr = c_mag * (den / num);
using c_rep_type = maybe_common_type<typename std::remove_reference_t<From>::rep, typename To::rep>;
using c_mag_type = common_magnitude_type<c_mag>;
using multiplier_type = conditional<
treat_as_floating_point<c_rep_type>,
// ensure that the multiplier is also floating-point
conditional<std::is_arithmetic_v<value_type_t<c_rep_type>>,
// reuse user's type if possible
std::common_type_t<c_mag_type, value_type_t<c_rep_type>>, std::common_type_t<c_mag_type, double>>,
c_mag_type>;
using c_type = maybe_common_type<c_rep_type, multiplier_type>;
static constexpr auto val(Magnitude auto m) { return get_value<multiplier_type>(m); };
static constexpr multiplier_type num_mult = val(num);
static constexpr multiplier_type den_mult = val(den);
static constexpr multiplier_type irr_mult = val(irr);
static constexpr multiplier_type ratio = num_mult / den_mult * irr_mult;
};


/**
* @brief Explicit cast between different quantity types
*
Expand Down Expand Up @@ -64,34 +103,77 @@ template<Quantity To, typename From>
// warnings on conversions
} else {
// scale the number
constexpr Magnitude auto c_mag = get_canonical_unit(q_unit).mag / get_canonical_unit(To::unit).mag;
constexpr Magnitude auto num = numerator(c_mag);
constexpr Magnitude auto den = denominator(c_mag);
constexpr Magnitude auto irr = c_mag * (den / num);
using c_rep_type = maybe_common_type<typename std::remove_reference_t<From>::rep, typename To::rep>;
using c_mag_type = common_magnitude_type<c_mag>;
using multiplier_type = conditional<
treat_as_floating_point<c_rep_type>,
// ensure that the multiplier is also floating-point
conditional<std::is_arithmetic_v<value_type_t<c_rep_type>>,
// reuse user's type if possible
std::common_type_t<c_mag_type, value_type_t<c_rep_type>>, std::common_type_t<c_mag_type, double>>,
c_mag_type>;
using c_type = maybe_common_type<c_rep_type, multiplier_type>;
constexpr auto val = [](Magnitude auto m) { return get_value<multiplier_type>(m); };
if constexpr (std::is_floating_point_v<multiplier_type>) {
using traits = magnitude_conversion_traits<To, std::remove_reference_t<From>>;
if constexpr (std::is_floating_point_v<typename traits::multiplier_type>) {
// this results in great assembly
constexpr auto ratio = val(num) / val(den) * val(irr);
auto res = static_cast<MP_UNITS_TYPENAME To::rep>(
static_cast<c_type>(q.numerical_value_is_an_implementation_detail_) * ratio);
static_cast<traits::c_type>(q.numerical_value_is_an_implementation_detail_) * traits::ratio);
return {res, To::reference};
} else {
// this is slower but allows conversions like 2000 m -> 2 km without loosing data
auto res = static_cast<MP_UNITS_TYPENAME To::rep>(
static_cast<c_type>(q.numerical_value_is_an_implementation_detail_) * val(num) / val(den) * val(irr));
static_cast<traits::c_type>(q.numerical_value_is_an_implementation_detail_) * traits::num_mult /
traits::den_mult * traits::irr_mult);
return {res, To::reference};
}
}
}


/**
* @brief Explicit cast between different quantity_point types
*
* @note This is a low-level facility and is too powerful to be used by the users directly. They should either use
* `value_cast` or `quantity_cast`.
*
* @tparam ToQP a target quantity point type to which to cast to
*/
template<QuantityPoint ToQP, typename FromQP>
requires QuantityPoint<std::remove_cvref_t<FromQP>> &&
(castable(std::remove_reference_t<FromQP>::quantity_spec, ToQP::quantity_spec)) &&
(detail::same_absolute_point_origins(ToQP::point_origin, std::remove_reference_t<FromQP>::point_origin)) &&
((std::remove_reference_t<FromQP>::unit == ToQP::unit &&
std::constructible_from<typename ToQP::rep, typename std::remove_reference_t<FromQP>::rep>) ||
(std::remove_reference_t<FromQP>::unit != ToQP::unit))
[[nodiscard]] constexpr QuantityPoint auto sudo_cast(FromQP&& qp)
{
using qp_type = std::remove_reference_t<FromQP>;
Copy link
Owner

Choose a reason for hiding this comment

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

I think you are assuming here an integral representation type, which often will not be the case as double is the default in the library. In cases of a floating point representation type, we should calculate the entire conversion factor in one step and then do only one multiplication operation on the value to get the result. Otherwise, we will not generate assembly equivalent to operations on double.

Copy link
Owner

Choose a reason for hiding this comment

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

If my previous comment was unclear, I will just note that the relative_point_origin stores an offset, which has its own unit and representation as well. We could factor in this conversion here as well (instead of calling point_for() as a separate step).

However, maybe it would be too complex to do it here anyway and the measurable performance gains would not be that big.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Indeed, those considerations about order of the conversion operations are most relevant for integral types, where both underflow and overflow are likely. It doesn't hurt for double either. However, I'm not sure what you mean by "calculate the entire conversion factor in one step". A change of point-origin requires an addition. A change in unit requires a multiplication. This implementation here does at most one multiplication and at most one addition in all cases. Here is how:

  • The initial check for same point-origin directly delegates to the sudo_cast<Q> implementation to select the "best" way to do that multiplication, and avoids the addition
  • The two code-paths for a change in point origin go as follos:
    • The first path for where the unit gets smaller and thus the number gets larger:
      1. cast to the intermediate representation type using sudo_cast (no-op in the case of floatingpoints). previously, that was a value_cast, but I figured that within a sudo_cast, value_cast is off limit.
      2. add/subtract the change in reference using .point_for; hopefully, the implementation of .point_for is such that this is a single addition and no multiplication
      3. scale the unit/number and
      4. cast the to the final representation type using sudo_cast.
    • The second path for where the unit gets larger and thus the number will get smaller (or stay the same):
      1. cast to the intermediate representation type and
      2. scale the unit/number using sudo_cast. If the intermediate representation type is the same as the source erpresentation and the units remain the same, this is a no_op.
      3. add/subtract the change in reference using .point_for
      4. cast to the final representation type using sudo_cast. No-op for floating-point types.

So I believe this implementation is as efficient as it can be through a careful selection of types. While both code-paths invoke sudo_cast twice, there is always one where the source and target units match and no multiplication is needed. For floating-point, there is also not cast, so that cast is a no-op. Furthermore, the implementation here does never do any arithmetic itself - it delegates the addition to .point_for and the multiplication to sudo_cast<Q>. Personally, I would even prefer to call value_cast instead, because in each of the two code-paths, one of the casts is a pure representation cast, which would be more clear as value_cast<rep>.

Copy link
Contributor Author

@burnpanck burnpanck Jun 3, 2024

Choose a reason for hiding this comment

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

Wait, now I understand you are indicating that .point_for may potentially change the unit and representation type? In that case, you are of course right, we should replace the .point_for call here with a dedicated implementation.

Copy link
Owner

Choose a reason for hiding this comment

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

However, I'm not sure what you mean by "calculate the entire conversion factor in one step".

I mean something like this:

  if constexpr (std::is_floating_point_v<multiplier_type>) {
      // this results in great assembly
      constexpr auto ratio = val(num) / val(den) * val(irr);
     // use precalculated ratio...
  }
  else // ...

Precalculation of ratio as a constexpr variable ensures that the assembly will include only one multiplication by this constant. The else branch typically results in more assembly instructions.

Copy link
Owner

Choose a reason for hiding this comment

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

@burnpanck, are you going to work on this here? Or should we rebase, merge, and then refactor in the next PR?

Copy link
Contributor Author

@burnpanck burnpanck Jun 13, 2024

Choose a reason for hiding this comment

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

Sorry, work interfered. Yeah, I intend to do a replacement of point_for by a direct implementation which avoids all further unit conversions. However, I believe the code as-is would be mergeable too. As mentioned before, there should definitely be no regressions in this PR as of now. All unit conversions are as efficient as they can be (they re-use the existing implementation). The current value_cast<QP> is no worse than what you would reasonably implement using the tools given by this library as a user (namely, using point_for). The converting constructor of quantity_point is untouched and slightly worse than this value_cast<QP> concerning overflow or truncation with integer types. Maybe it would make sense to switch the converting constructor to delegate to value_cast<QP> and then merge. We can then postpone performance-improvement of value_cast<QP> to another PR, and perhaps discuss separately when the current converting constructor of quantity_cast should be explicit or not.

Copy link
Owner

Choose a reason for hiding this comment

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

OK, so let's do it this way. I need a few more days to finish the framework cleanup I am doing right now. It there will be no further changes in this PR when I am done, I will merge it as is and provide a new mp-units 2.2 release. After that we will continue this effort in the following PRs targeting the next release. If you will have some time in the upcoming days to work on it and you care for all of the changes to be provided in mp-units 2.2., please feel encouraged to do so.

if constexpr (is_same_v<std::remove_const_t<decltype(ToQP::point_origin)>,
std::remove_const_t<decltype(qp_type::point_origin)>>) {
return quantity_point{
sudo_cast<typename ToQP::quantity_type>(std::forward<FromQP>(qp).quantity_from(qp_type::point_origin)),
qp_type::point_origin};
} else {
// it's unclear how hard we should try to avoid truncation here. For now, the only corner case we cater for,
// is when the range of the quantity type of at most one of QP or ToQP doesn't cover the offset between the
// point origins. In that case, we need to be careful to ensure we use the quantity type with the larger range
// of the two to perform the point_origin conversion.
// Numerically, we'll potentially need to do three things:
// (a) cast the representation type
// (b) scale the numerical value
// (c) add/subtract the origin difference
// In the following, we carefully select the order of these three operations: each of (a) and (b) is scheduled
// either before or after (c), such that (c) acts on the largest range possible among all combination of source
// and target unit and represenation.
using traits = magnitude_conversion_traits<typename ToQP::quantity_type, typename qp_type::quantity_type>;
using c_rep_type = typename traits::c_rep_type;
if constexpr (traits::num_mult * traits::irr_mult > traits::den_mult) {
// original unit had a larger unit magnitude; if we first convert to the common representation but retain the
// unit, we obtain the largest possible range while not causing truncation of fractional values. This is optimal
// for the offset computation.
return sudo_cast<ToQP>(
sudo_cast<quantity_point<qp_type::reference, qp_type::point_origin, c_rep_type>>(std::forward<FromQP>(qp))
.point_for(ToQP::point_origin));
} else {
// new unit may have a larger unit magnitude; we first need to convert to the new unit (potentially causing
// truncation, but no more than if we did the conversion later), but make sure we keep the larger of the two
// representation types. Then, we can perform the offset computation.
return sudo_cast<ToQP>(sudo_cast<quantity_point<make_reference(qp_type::quantity_spec, ToQP::unit),
qp_type::point_origin, c_rep_type>>(std::forward<FromQP>(qp))
.point_for(ToQP::point_origin));
}
}
}


} // namespace mp_units::detail
2 changes: 1 addition & 1 deletion src/core/include/mp-units/framework/magnitude.h
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ template<auto H1, auto... T1, auto H2, auto... T2>
template<auto... Ms>
[[nodiscard]] consteval auto common_magnitude_type_impl(magnitude<Ms...>)
{
return (... * decltype(get_base_value(Ms)){}) * std::intmax_t{};
return (decltype(get_base_value(Ms)){} * ... * std::intmax_t{});
}

// Returns the most precise type to express the magnitude factor
Expand Down
100 changes: 96 additions & 4 deletions src/core/include/mp-units/framework/value_cast.h
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ template<Representation ToRep, typename Q>
*
* auto q = value_cast<us, int>(1.23 * ms);
*
* @tparam ToRep a representation type to use for a target quantity
* @tparam ToU a unit to use for the target quantity
* @tparam ToRep a representation type to use for the target quantity
*/
template<Unit auto ToU, Representation ToRep, typename Q>
requires Quantity<std::remove_cvref_t<Q>> && (convertible(std::remove_reference_t<Q>::reference, ToU)) &&
Expand All @@ -92,6 +93,30 @@ template<Unit auto ToU, Representation ToRep, typename Q>
return detail::sudo_cast<quantity<detail::make_reference(q_type::quantity_spec, ToU), ToRep>>(std::forward<Q>(q));
}


/**
* @brief Explicit cast of a quantity's representation
*
* Implicit conversions between quantities of different types are allowed only for "safe"
* (e.g. non-truncating) conversion. In truncating cases an explicit cast have to be used.
*
* using ToQ = quantity<us, int>;
* auto q = value_cast<ToQ>(1.23 * ms);
*
* Note that value_cast only changes the "representation aspects" (unit and representation
* type), but not the "meaning" (quantity type).
*
* @tparam ToQ a target quantity type to which to cast the representation
*/
template<Quantity ToQ, typename Q>
requires Quantity<std::remove_cvref_t<Q>> && (convertible(std::remove_reference_t<Q>::reference, ToQ::unit)) &&
(ToQ::quantity_spec == std::remove_reference_t<Q>::quantity_spec) &&
std::constructible_from<typename ToQ::rep, typename std::remove_reference_t<Q>::rep>
[[nodiscard]] constexpr Quantity auto value_cast(Q&& q)
{
return detail::sudo_cast<ToQ>(std::forward<Q>(q));
}

/**
* @brief Explicit cast of a quantity point's unit
*
Expand Down Expand Up @@ -133,14 +158,15 @@ value_cast(QP&& qp)
}

/**
* @brief Explicit cast of a quantity's unit and representation type
* @brief Explicit cast of a quantity point's unit and representation type
*
* Implicit conversions between quantities of different types are allowed only for "safe"
* (e.g. non-truncating) conversion. In truncating cases an explicit cast have to be used.
*
* auto q = value_cast<us, int>(1.23 * ms);
* auto qp = value_cast<us, int>(quantity_point{1.23 * ms});
*
* @tparam ToRep a representation type to use for a target quantity
* @tparam ToU a unit to use for the target quantity
* @tparam ToRep a representation type to use for the target quantity
*/
template<Unit auto ToU, Representation ToRep, typename QP>
requires QuantityPoint<std::remove_cvref_t<QP>> && (convertible(std::remove_reference_t<QP>::reference, ToU)) &&
Expand All @@ -152,4 +178,70 @@ template<Unit auto ToU, Representation ToRep, typename QP>
std::remove_reference_t<QP>::point_origin};
}

/**
* @brief Explicit cast of a quantity point's representation
*
* Implicit conversions between quantities of different types are allowed only for "safe"
* (e.g. non-truncating) conversion. In truncating cases an explicit cast have to be used.
*
* inline constexpr struct A : absolute_point_origin<A, isq::distance> A;
*
* using ToQ = quantity<mm, int>;
* auto qp = value_cast<ToQ>(quantity_point{1.23 * m});
*
* Note that value_cast only changes the "representation aspects" (unit and representation
* type), but not the "meaning" (quantity type or the actual point that is being described).
*
* @tparam ToQ a target quantity type to which to cast the representation of the point
*/
template<Quantity ToQ, typename QP>
requires QuantityPoint<std::remove_cvref_t<QP>> && (convertible(std::remove_reference_t<QP>::reference, ToQ::unit)) &&
(ToQ::quantity_spec == std::remove_reference_t<QP>::quantity_spec) &&
std::constructible_from<typename ToQ::rep, typename std::remove_reference_t<QP>::rep>
[[nodiscard]] constexpr QuantityPoint auto value_cast(QP&& qp)
{
return quantity_point{value_cast<ToQ>(std::forward<QP>(qp).quantity_from_origin_is_an_implementation_detail_),
std::remove_reference_t<QP>::point_origin};
}

/**
* @brief Explicit cast of a quantity point's representation, including potentially the point origin
*
* Implicit conversions between quantities of different types are allowed only for "safe"
* (e.g. non-truncating) conversion. In truncating cases an explicit cast have to be used.
*
* inline constexpr struct A : absolute_point_origin<A, isq::distance> A;
* inline constexpr struct B : relative_point_origin<A + 1*m> B;
*
* using ToQP = quantity_point<mm, B, int>;
* auto qp = value_cast<ToQP>(quantity_point{1.23 * m});
*
* Note that value_cast only changes the "representation aspects" (unit, representation
* type and point origin), but not the "meaning" (quantity type or the actual point that is
* being described).
*
* Note also that changing the point origin bears risks regarding truncation and overflow
* similar to other casts that change representation (which is why we require a `value_cast`
* and disallow implicit conversions). This cast is guaranteed not to cause overflow of
* any intermediate representation type provided that the input quantity point is within
* the range of `ToQP`. Calling `value_cast<ToQP>(qp)` on a `qp` outside of the range of `ToQP`
* is potentially undefined behaviour.
* The implementation further attempts not to cause more than
* rounding error than approximately the sum of the resolution of `qp` as represented in `FromQP`,
* plust the resolution of `qp` as represented in `ToQP`.
*
* @tparam ToQP a target quantity point type to which to cast the representation of the point
*/
template<QuantityPoint ToQP, typename QP>
requires QuantityPoint<std::remove_cvref_t<QP>> &&
(convertible(std::remove_reference_t<QP>::reference, ToQP::unit)) &&
(ToQP::quantity_spec == std::remove_reference_t<QP>::quantity_spec) &&
(detail::same_absolute_point_origins(ToQP::point_origin, std::remove_reference_t<QP>::point_origin)) &&
std::constructible_from<typename ToQP::rep, typename std::remove_reference_t<QP>::rep>
[[nodiscard]] constexpr QuantityPoint auto value_cast(QP&& qp)
{
return detail::sudo_cast<ToQP>(std::forward<QP>(qp));
}


} // namespace mp_units
37 changes: 37 additions & 0 deletions test/static/quantity_point_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1704,4 +1704,41 @@ static_assert(value_cast<float>(lvalue_qp).quantity_from_zero().numerical_value_
static_assert(value_cast<m, float>(lvalue_qp).quantity_from_zero().numerical_value_in(m) == 2000.f);
} // namespace lvalue_tests

static_assert(value_cast<quantity<km, int>>(quantity_point{2000 * m}).quantity_from_zero().numerical_value_in(km) == 2);
static_assert(value_cast<quantity_point<km>>(quantity_point{2000 * m}).quantity_from_zero().numerical_value_in(km) ==
2);

template<typename ToQ, typename FromQ>
constexpr bool value_cast_is_forbidden()
{
// it appears we cannot have the requires clause right inside static_assert
return !requires(FromQ q) { value_cast<ToQ>(q); };
}
static_assert(value_cast_is_forbidden<quantity_point<m>, quantity_point<isq::width[m]>>(),
"value_cast shall not cast between different quantity types");
static_assert(value_cast_is_forbidden<quantity_point<isq::width[m]>, quantity_point<m>>(),
"value_cast shall not cast between different quantity types");
// value_cast which does not touch the point_origin
static_assert(value_cast<quantity_point<isq::height[m]>>(quantity_point{2 * isq::height[km]})
.quantity_from_origin_is_an_implementation_detail_.numerical_value_in(m) == 2000);
static_assert(value_cast<quantity_point<isq::height[km]>>(quantity_point{2000 * isq::height[m]})
.quantity_from_origin_is_an_implementation_detail_.numerical_value_in(km) == 2);
// a value_cast which includes a change to the point origin
static_assert(value_cast<quantity_point<isq::height[m], mean_sea_level>>(quantity_point{2000 * isq::height[m],
ground_level})
.quantity_from_origin_is_an_implementation_detail_.numerical_value_in(m) == 2042);
// a value_cast which includes a change to the point origin as-well as a change in units
static_assert(value_cast<quantity_point<isq::height[m], mean_sea_level>>(quantity_point{2 * isq::height[km],
ground_level})
.quantity_from_origin_is_an_implementation_detail_.numerical_value_in(m) == 2042);
// a value_cast which changes all three of unit, rep, point_origin simultaneously, and the range of either FromQP or
// ToQP does not include the other's point_origin
static_assert(value_cast<quantity_point<isq::height[cm], mean_sea_level, int>>(
quantity_point{std::int8_t{100} * isq::height[mm], ground_level})
.quantity_from_origin_is_an_implementation_detail_.numerical_value_in(cm) == 4210);
static_assert(value_cast<quantity_point<isq::height[mm], ground_level, std::int8_t>>(
quantity_point{4210 * isq::height[cm], mean_sea_level})
.quantity_from_origin_is_an_implementation_detail_.numerical_value_in(mm) == 100);


} // namespace