THE LINUX FOUNDATION PROJECTS
GSoCHPX

Making HPX Remote Calls Simpler: C++26 Reflection for Action Dispatch

By September 18, 2026No Comments
Project: C++26 Reflection for HPX Action Dispatch
Contributor: Priyanshi Sharma
Organization: The STE||AR Group
Mentor: Hartmut Kaiser

Before my project, dispatching a function to run on a remote HPX node looked like this:

int add(int x, int y) { return x + y; }

HPX_PLAIN_ACTION(add, add_action)       // define a type for the action
HPX_REGISTER_ACTION(add_action)         // register it with the runtime
hpx::async<add_action>(locality, 1, 2); // now you can dispatch it

After my project, it looks like this:

int add(int x, int y) { return x + y; }

hpx::async<^^add>(locality, 1, 2);

The ^^add is C++26 static reflection. It produces a compile-time value representing the function, from which reflect_action extracts the signature, builds the serialization wrappers, and registers the action automatically. No macros. No registration call. If you forget HPX_REGISTER_ACTION in the old approach, you get a crash at runtime with no helpful error message. With reflection, there is nothing to forget.


Where this started

HPX uses macros heavily because C++ historically had no way to inspect types and functions at compile time. HPX_PLAIN_ACTION(f, name) expands into a struct definition with serialization glue, and HPX_REGISTER_ACTION(name) creates a static object that triggers registration when the program starts. The two macros are always a pair, and nothing enforces that.

C++26 static reflection gives you std::meta::info, a compile-time value you can use to inspect a function's return type, parameter types, qualifiers, and name. The proposal that matters for HPX is P2996, which GCC trunk implements behind -freflection, and which Clang implements in a separate branch with -freflection-latest.

My proposal was to build reflect_action<^^func> on top of this. The reflection operator extracts everything the old macros compute at the preprocessor level, but at compile time, where the compiler can catch mistakes and where no manual step is needed.


The first working version

reflect_action<F> holds a static invocation_count_registrar_ member. When the compiler instantiates the template, that static member gets a definition, and its constructor calls the HPX registration function. This is the same trick HPX_REGISTER_ACTION uses — a static object whose constructor does the registration — but now it happens automatically when the type is first used.

[: std::meta::type_of(F) :] splices the function type back into C++ so you can write using result_type = typename action_traits<[: std::meta::type_of(F) :]>::result_type. That line is what makes the serialization glue work without knowing the function type at the point where you write it.

The first version built and ran. Then I ran it with component actions and discovered parent_of — to build reflect_component_action, you need to know which component owns the member function, and std::meta::parent_of(F) gives you exactly that. Before reflection, that information had to be passed manually as the first argument to HPX_DEFINE_COMPONENT_ACTION. Now it is extracted automatically.

PR #7298 (plain actions) and #7311 (component actions) merged first.

The macro compatibility problem

Once the reflection types worked, the question was whether existing HPX code would have to change. The answer I was aiming for was no.

HPX_PLAIN_ACTION(f, name) defines using name = .... When HPX_HAVE_CXX26_REFLECTION is defined, I redefined it to expand to using name = reflect_action<^^f>. The two argument form stays identical.

Code written before C++26 existed recompiles unchanged and now silently uses the reflection path.

The same applies to HPX_DEFINE_COMPONENT_ACTION and HPX_REGISTER_ACTION. The latter becomes a no-op under reflection, because registration is already handled by the static member.

This is the part of the project I found most satisfying. Code that was written years before C++26 was even close to standardised now uses the reflection path without knowing it.

PR #7349.

The clean API

Once reflect_action worked, adding hpx::async<^^func> was straightforward. Two overloads in async.hpp, constrained with requires(std::meta::is_namespace_member(F) && std::meta::is_function(F)).

The constraint matters: if you pass ^^SomeType::member instead of a free function, you get a compile error rather than a runtime crash.

hpx::async<^^add>(locality, 1, 2).get();
hpx::async<^^add>(hpx::launch::async, locality, 1, 2).get();

That second overload with the launch policy took me longer than expected. The existing hpx::async machinery already has overloads for launch policies, but the template parameter ordering for reflection overloads had to be carefully separated to avoid ambiguity with the existing ones. Hartmut caught one case where the overload would have silently resolved to the wrong thing.

PR #7376.

The client generator

The most complex part of the project was HPX_CLIENT(Server).

In old HPX, writing a typed client for a component server means defining a class that inherits from client_base, declaring each action type as a nested type, and writing an async method for each member function that dispatches the right action. For a server with five member functions, that is 50+ lines. It is the kind of code you write once and never want to touch again.

HPX_CLIENT(compute_server) does all of this in one line.

The implementation uses consteval {} — a block that runs at compile time and can call define_aggregate to create a new type. Inside the block, create_client_data_members<^^Server>() gives you all the server's public member functions, and for each one you call data_member_spec to add a std::function member to the client type with the right async and sync signatures.

This hit several GCC trunk constraints. consteval {} in a function template cannot access template parameters, so the whole thing has to happen at namespace scope via a macro. template for only works in function bodies, not in class bodies or consteval blocks, so the iteration over server members had to be moved into a helper function. static constexpr is required for template for to see a local array — a plain constexpr local does not work.

None of these constraints are documented anywhere. I found each one by writing the obvious code and reading the compiler error.

PR #7385 took several redesigns and a lot of back-and-forth with hkaiser before reaching its final form.

The first version used a different API shape. The second version had the right shape but a subtle type error. The third added hpx::launch::sync support via a dual_fn wrapper. Each round caught something real.

The final result:

auto c = hpx::components::make_client<compute_server>(locality);
auto f = c.add(10, 32);                    // returns future<int>
int r = c.add(hpx::launch::sync, 10, 32); // returns int directly

What got added along the way

  • Sync, post, async_cb, post_cb: The reflection API only covered hpx::async at first. hkaiser asked about the other dispatch variants. These followed the same pattern — two overloads, same constraint, forward to reflect_action<F>{}. PR #7462.
  • dispatch_work<^^func>: The supervision dispatch system has its own dispatch_work function. hkaiser asked for a reflection wrapper there too. This one changed twice because the underlying API changed while I was working on it — shadow_id and joined_peer were replaced with discovered_peer in PR #7438, and I had to rebase and update the signatures. PR #7432.
  • Annotation-driven registration: GCC trunk has annotations_of(), which lets you attach metadata to a function with [[=SomeType{}]] and then discover all functions in a namespace that have that annotation. I used this to build HPX_REGISTER_ANNOTATED_ACTIONS(my_namespace): mark each remotely callable function once with [[=hpx::actions::detail::remote_function{}]], call the macro once at the end of the namespace, and all of them are registered. No per-function macro calls. annotations_of is GCC-specific and not in Clang P2996 yet, so this is behind a separate feature check. PR #7418.
  • get_component_name default: HPX_REGISTER_COMPONENT(Type, name) takes two arguments, the component type and a string name used by AGAS. With reflection, the name can be derived from qualified_name_of<Type::type_holder>, so the two-argument form becomes optional. PR #7448.

Things that did not work

  • Function template partial specializations: My first attempt at providing a reflection-based default for get_component_name used a partial specialization of the function template. C++ does not allow partial specialization of function templates. The compiler error is clear enough, but I had convinced myself the SFINAE approach would work as a specialization. It doesn't. The fix was a helper class template with a partial specialization on std::void_t<typename Component::type_holder>, called from the primary template.
  • hpx/iostream.hpp in a non-distributed build: The runtime benchmark included hpx/iostream.hpp, which is not available in the actions_base test module. It compiled locally because my build had the full distributed runtime. The CI caught it. One include swap to <iostream> fixed it.
  • HPX_REGISTER_ANNOTATED_ACTIONS in a NOLIBS test: I added the macro call to the annotation unit test to exercise registration. The linker could not find register_remote_action_invocation_count because the NOLIBS test does not link the full HPX runtime. The registration path only works in a full runtime build. Removed it and left the compile-time discovery checks, which do not need the runtime.
  • Em-dashes in source files: The HPX inspect tool checks for non-ASCII characters in source files. I used -- in my code comments throughout, but at some point an em-dash crept in through copy-pasting from a document. hkaiser caught it. Now I run a Python check before every push.

Testing

  • PR #7332 benchmarks compile time for 10, 50, and 100 action definitions using both the old macro path and reflect_action. No regression.
  • PR #7352 adds CI workflows for GCC trunk and Clang P2996, so the reflection code is checked on both compilers on every PR.
  • PR #7436 is a distributed integration test that runs reflect_action<^^func> and hpx::async<^^func> across two localities, verifies serialization round-trips, and checks that actions execute on the right locality using hpx::find_here().
  • PR #7455 is the fibonacci example rewritten with hpx::async<^^fibonacci> instead of HPX_PLAIN_ACTION. It ships with HPX as a concrete before/after comparison.
  • PR #7459 is a runtime benchmark measuring dispatch latency of the old action type approach versus hpx::async<^^func>. The baseline uses make_action_t explicitly rather than HPX_PLAIN_ACTION, because under reflection HPX_PLAIN_ACTION itself expands to reflect_action and the comparison would be measuring the same thing twice.

Where things ended up

Feature Before After
Remote function dispatch 3 macros hpx::async<^^func>(loc, args...)
Component client 50+ lines HPX_CLIENT(Server)
Component name HPX_REGISTER_COMPONENT(T, name) HPX_REGISTER_COMPONENT(T)
Existing code changes -- none

16 PRs merged.

What comes next

template for in class template bodies is not supported on current GCC trunk, which is why HPX_CLIENT is a macro rather than a pure template. When that constraint lifts, the macro can be replaced.

annotations_of is not in Clang P2996 yet. When it is, the cmake feature guard in PR #7418 can be removed.

The runtime benchmark (PR #7459) needs to run on a real multi-locality setup to produce meaningful latency numbers. The current version runs locally.

I plan to keep contributing after GSoC.

Thanks

Thank you to Hartmut Kaiser for reviewing every PR, often the same day, and for catching the things I missed — the struct layout issue, the overload ambiguity, the NOLIBS linker error, the non-ASCII characters. The code is substantially better for each of those.

Thank you to the STE||AR Group and Google for the opportunity.


GSoC 2026 with the STE||AR Group. Contributor: Priyanshi Sharma (@Priyanshi507). Mentor: Hartmut Kaiser (@hkaiser). Repository: TheHPXProject/hpx.

Emilios Tassios