Skip to content

Parallel numeric ranges algorithms: Add identity interface use cases #29

Description

@mhoemmen

Add identity element interface sketch and use cases

Add use cases showing sketches of an interface for specifying an identity element.

Interface sketch

Users specify identity element with a named struct reduce_identity. Its default specialization looks like this.

template<class T>
struct reduce_identity {
  T value{};
};

The default identity is T{}. Users have two ways to use a nondefault value.

  1. Specify the value: reduce_identity{value}
  2. Specialize reduce_identity<T>

Option (1) supports the use case where the same reduction result type might be used with different binary operators. For example, users might have some parts of their code where they use double values with the normal floating-point algebra, and other parts where they use double values with the min-plus ("tropical") algebra. For the former case, the default reduce_identity<double> would suffice. For the latter case, users would specify the identity as reduce_identity{numeric_limits<double>::infinity()}.

Option (2) lets users specify the value at compile time if they want.

namespace impl {
  inline constexpr T some_value = /* value goes here */;
}
template<class T>
struct reduce_identity<T> : constant_wrapper<impl::some_value> {};

Should the default identity value depend on the binary operation?

It would be more user-friendly if the default identity value could depend on the binary operation. For example, the default identity value for std::multiplies<float>{} should be 1.0f. This would imitate OpenACC and OpenMP behavior.

One way to achieve this goal would be to have special cases for known combinations of binary operator type and reduction result type. For example, the identity for std::multiplies<float> would be 1.0f. The problem with this approach is that users often use the Standard Library binary operator types with their default template argument void. std::multiples<void> just means "use binary operator*." This would give incorrect results for user-defined reduction result types. For example, if users define a min-plus algebra number type MinPlus with underlying type float, its overloaded operator* would actually do addition on float values, and thus should have identity value 0.0f.

Another way to achieve this goal would be to template reduce_identity on the binary operation type BinaryOp, instead of (or in addition to) the reduction result type T. However, this would have at least two issues.

  1. Users would not be able to specialize reduce_identity for lambdas, since every lambda declaration declares a unique type.
  2. Binary operations with the same return type can themselves have different types.

Here is an example of (2).

float binary_op_1(const float& x, const float& y) {
    return x + y;
}
float binary_op_2(float x, float y) {
    return x + y;
}
static_assert(! std::is_same_v<
  decltype(binary_op_1),
  decltype(binary_op_2)>);
static_assert(std::is_invocable_r_v<float,
  decltype(binary_op_1), float, float>);
static_assert(std::is_invocable_r_v<float,
  decltype(binary_op_2), float, float>);

A result is that users might try to specialize on a function float(float, float), but then define the actual function as float(const float&, const float&). This would give them the wrong specialization of reduce_identity at the point of use without a compile-time error.

Users might try to solve this by writing a partial specialization of reduce_identity constrained on "any binary function invocable with two arguments U and V and returning T." However, correctly constraining U and V is difficult (range value type, reduction result type, or some intermediate type?). More importantly, the partial specialization would take effect for all binary operations on T, not just for the user's binary operation.

We conclude that it's not worth trying to make reduce_identity depend at compile time on the binary operation. The following decision tree explains how users can specify the identity value.

  1. If T{} is the identity for the binary operation, rely on default reduce_identity<T>{}.value;

  2. otherwise, if T{} is NOT the identity for the binary operation:

    a. If the binary operation is always T operator+(x,y) or std::plus<T>:

     i. specialize `reduce_identity<T>`, or
    
     ii. provide a value `reduce_identity<T>{value}` at the point of use;
    

    b. otherwise, provide a value reduce_identity<T>{value} at the point of use.

If users can specify the identity value, should they also specify an initial value?

The initial value parameter T init of C++17 std::reduce has two purposes.

  1. It specifies the actual initial value, which lets users compute partial reductions.
  2. It determines std::reduce's return type.

If users could specify the identity value, that would also determine std::reduce's result type. In fact, it would do so redundantly, because for C++17 std::reduce, the initial value type is the return type.

The ranges::fold_left algorithm does not need an identity value, for reasons we explain elsewhere in this proposal. However, it requires a (nonoptional) initial value. The initial value of ranges::fold_left determines the return type of ranges::fold_left, but is not necessarily the same as the return type. The actual return type depends on the binary operation, the range value type, and the initial value type.

It's an important feature of both ranges::fold_left and of our ranges::reduce that we propose here, to be able to deduce a default initial value type from the range type.

What should happen if users could specify both an initial value and an identity value, and those types differ? Should that be ill-formed?

Use cases

Ordinary floating-point plus, range of double.

auto id = reduce_identity<double>{};
auto result1 = ranges::reduce(exec_policy, range, plus{}, id);
// Default identity value is reduce_identity<double>{}
auto result2 = ranges::reduce(exec_policy, range, plus{});

Min reduction (e.g., for min-plus (tropical) algebra) on double.

auto binary_op = [] (double y) { return std::fmin(x, y); }
auto id = reduce_identity{numeric_limits<double>::infinity()}
// No initial value, just identity value
ranges::reduce(exec_policy, range, binary_op, id);

Activity

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions