Skip to content

Split future<T> into future_base + future<T> - #107

Merged
kammce merged 1 commit into
mainfrom
split-future-base
Aug 25, 2026
Merged

Split future<T> into future_base + future<T>#107
kammce merged 1 commit into
mainfrom
split-future-base

Conversation

@kammce

@kammce kammce commented Aug 25, 2026

Copy link
Copy Markdown
Member

Summary

  • Splits async::future<T> into a non-template future_base (completion-state tag, pending coroutine handle, exception_ptr storage) and a thin future<T> derived layer (only T's own value storage) - mirroring the existing promise_base/promise<T> split in this same file.
  • ~future_base(), await_suspend(), await_resume()'s exception/cancelled/terminate cold path, done(), is_cancelled(), has_value(), resume(), and cancel() all become single, non-template, shared functions instead of being re-instantiated once per T.
  • future<T>::cancelled moves to future_base::cancelled (inherited, so future<T>::cancelled still resolves for existing call sites) - it was already fully T-independent in content, just needlessly duplicated per T for a vtable + what().
  • await_suspend() also drops its template<typename U> parameter (the calling coroutine's promise type) - the body only ever calls .promise().get_context(), which lives on promise_base, so U was never load-bearing. This explains why so many distinct await_suspend<...> symbols existed before ICF: it was a T x U cross product, not just a T axis.
  • promise_return_base<T>/promise_return_base<void> gain export: their out-of-line members (which need future<T> to be a complete type, so can't be defined in-class) are reached through the exported promise<T> template from other translation units, and need real cross-TU linkage rather than module-internal visibility. Without this, consumers instantiating promise<void> locally hit undefined symbol: promise_return_base<void>::return_void() at link time.
  • benchmarks/benchmark.cpp's virtual-call-returning-a-variant microbenchmark used the internal future_state<T> alias directly; decoupled it with a locally-defined equivalent variant, since it was never actually testing future<T>'s specific representation.

Motivation

Investigating ROM usage on a coroutine-heavy USB CDC demo (libhal-arm-mcu v2, stm32f103zg, Clang 20, MinSizeRel) found future<T>'s per-await machinery fully duplicated per distinct T, even though most of it - "am I still pending", "rethrow the exception", "throw cancelled" - never touches T. Linker ICF (libhal-cmake-util 5.0.11) already collapses byte-identical duplicates (e.g. several strong_ptr<X> futures with the same layout regardless of X), but can't help with genuinely different-sized Ts. On that demo, 9 distinct ~future<T>() bodies and ~8 distinct await_resume() bodies existed for void, bool, unsigned int, unsigned long long, and others - this PR collapses that down to one shared implementation each.

Fixes #106

Test plan

  • All 12 of this repo's own test suites pass (basics, cancel, blocked_by, mutex, proxy, sync_wait, context_listener, simple_scheduler, clock_adapter, run_until_done, async_stacking, cross_context_await) via conan build . -pr:a hal/tc/llvm-20 - test_cancel and test_cross_context_await specifically exercise the cancel-on-drop and cross-context-await logic that moved into future_base.
  • Builds clean under this repo's strict clang-tidy (WarningsAsErrors: "*").
  • Verified end-to-end against real downstream consumers: cross-compiled this branch for stm32f103zg and rebuilt libhal (v5 core), libhal-util, libhal-usb, and libhal-arm-mcu's usb/usb_cdc_raw/can/adc/uart/blinker/blank demos from source against it. All link and produce correct binaries.
  • Measured real size impact on the usb demo (enumerator-based USB CDC-ACM): .text went from 74,893 -> 66,813 bytes, on top of the already-shipped ICF change. Combined with a follow-up one-line double->float fix in an unrelated demo clock adapter, the full demo now fits in a 64KB-flash target (stm32f103c8) that it did not fit in before this investigation started.

Moves everything in future<T> that never depended on T - the
completion-state tag, the pending coroutine handle, exception_ptr
storage, cancel-on-drop, cross-context await bookkeeping, and
await_resume()'s exception/cancelled/terminate cold path - into a
non-template future_base, mirroring the existing promise_base/promise<T>
split. Only T's own value storage and the "extract T" fast path stay
templated. Collapses what were 9+ distinct per-T instantiations of this
machinery down to one shared copy each. Measured on a coroutine-heavy
USB demo: 74,893 -> 66,813 bytes of .text, on top of the already-shipped
linker ICF change.

promise_return_base<T>/<void> also gain `export`, since their out-of-line
members (needed once future<T> is a complete type) are now reached
through the exported promise<T> template from other translation units
and need real cross-TU linkage, not just module-internal visibility.

benchmarks/benchmark.cpp's variant-return-overhead microbenchmark used
the internal future_state<T> alias directly; decoupled it with a locally
-defined equivalent variant, since it was never actually testing
future<T>'s specific representation.

Fixes #106
@kammce
kammce force-pushed the split-future-base branch from 577fe1e to f11607b Compare August 25, 2026 02:40
@kammce
kammce merged commit 257ced3 into main Aug 25, 2026
8 checks passed
@kammce
kammce deleted the split-future-base branch August 25, 2026 02:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Split future<T> into future_base + future<T> to reduce per-T code duplication

1 participant