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::asyncat first. hkaiser asked about the other dispatch variants. These followed the same pattern — two overloads, same constraint, forward toreflect_action<F>{}. PR #7462. - dispatch_work<^^func>: The supervision dispatch system has its own
dispatch_workfunction. hkaiser asked for a reflection wrapper there too. This one changed twice because the underlying API changed while I was working on it —shadow_idandjoined_peerwere replaced withdiscovered_peerin 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 buildHPX_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_ofis 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 fromqualified_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_nameused 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 onstd::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_countbecause 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>andhpx::async<^^func>across two localities, verifies serialization round-trips, and checks that actions execute on the right locality usinghpx::find_here(). - PR #7455 is the fibonacci example rewritten with
hpx::async<^^fibonacci>instead ofHPX_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 usesmake_action_texplicitly rather thanHPX_PLAIN_ACTION, because under reflectionHPX_PLAIN_ACTIONitself expands toreflect_actionand 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.