Skip to content

perf(channel): reduce send coroutine frame storage - #1051

Merged
Coldwings merged 2 commits into
mainfrom
perf/channel-send-frame-core
Aug 13, 2026
Merged

perf(channel): reduce send coroutine frame storage#1051
Coldwings merged 2 commits into
mainfrom
perf/channel-send-frame-core

Conversation

@Coldwings

Copy link
Copy Markdown
Owner

Description

Reduce the coroutine-frame footprint of channel<T>::send(T) and send(T, cancel_token) by removing duplicate payload storage.

The public send coroutines already own their by-value T parameter for the lifetime of the frame. Their internal send waiter now borrows that parameter through a private queue core instead of moving it into a second T subobject. Public directly constructed send awaiters remain owning wrappers and retain their existing inheritance, lifetime, result, cancellation, and exception behavior.

For a 256-byte inline payload, the measured send-frame request fell by 29.8% without a token and 46.7% with a token. For a 1024-byte payload, it fell by 42.9% and 60.3%, respectively. Paired Release measurements found no significant regression in non-token ready sends and measured improvements in active-token ready sends and all forced-handoff cases.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Performance improvement (optimization that improves speed/memory usage)
  • Documentation (changes to documentation, comments, or examples)
  • Refactoring (code changes that neither fix bugs nor add features)
  • Tests (adding or modifying tests)
  • Build/CI (changes to build system, CI configuration, or dependencies)

Related Issues

Closes #1047
Related to #762

Changes Made

Core Changes

  • Added a private intrusive send_waiter_core that owns the existing queue node, wake state, and arbitration flags while referring to a payload owned elsewhere.
  • Changed channel-owned send coroutine waiters to borrow their by-value frame parameter, eliminating the second payload-sized storage region.
  • Kept public send_awaitable and cancellable_send_awaitable as owning wrappers, including the existing public inheritance relationship and directly constructed awaiter behavior.
  • Preserved claim-before-move ordering, cancellation precedence, close draining, sender refill, direct receiver handoff, queue unlinking, and dequeue-to-schedule shared wake-state lifetime.
  • Added move-only payload tests for bounded, unbounded, rendezvous, close, active-token, parked-token, cancellation-versus-refill, and cancellation-versus-close paths.
  • Added a separate Release-oriented frame benchmark target. It uses a cross-translation-unit non-inlined factory and target-local allocation recorder so coroutine allocation elision cannot invalidate frame-size measurements or contaminate the existing channel benchmark.
  • Updated the changelog, API reference, and performance guide.

API Changes (if applicable)

No public signature, result, cancellation, or exception-specification changes.

channel<T>::send_awaitable remains an owning public awaiter and retains its public intrusive_list_node<send_awaitable> base for source compatibility. The new borrowed core is private and is used only by channel-owned coroutine wrappers.

Migration Guide (if breaking change)

Not applicable.

Testing

Unit Tests

  • Added new tests for the changes
  • Updated existing tests if needed
  • All tests pass locally

Integration Tests

  • Tested with existing examples
  • Tested in real-world scenarios (if applicable)

Sanitizer Testing

  • Tested with ASAN (AddressSanitizer)
  • Tested with TSAN (ThreadSanitizer)
  • No new warnings or errors

Test Results

Post-rebase focused normal:
  New move-only/frame-origin tests: 906 assertions in 3 cases, all passed
  Complete [channel] suite:         3,310 assertions in 37 cases, all passed
  bench_channel_send_frame --smoke: all 8 frame rows and 7 timing paths passed

Full normal:
  13,306 assertions in 820 cases, all passed

Full ASAN:
  13,305 assertions in 820 cases, all passed
  fork-boundary ASAN target passed
  no AddressSanitizer, LeakSanitizer, or runtime-error diagnostics

Full TSAN:
  13,304 assertions passed
  820 cases: 819 passed, 1 expected existing fork-under-TSAN skip
  no ThreadSanitizer or data-race diagnostics

All builds were out of source with explicit --parallel 2. The dedicated benchmark also builds cleanly in Release with the project's warnings-as-errors configuration.

Checklist

Code Quality

  • My code follows the project's code style
  • I have added/updated comments for complex logic
  • I have removed any debug code, TODOs, or commented-out code
  • My changes generate no new warnings

Documentation

  • I have updated documentation (wiki, README, code comments)
  • I have added examples for new features (if applicable)
  • I have updated API documentation (if applicable)

Testing

  • I have added tests that prove my fix is effective or my feature works
  • New and existing unit tests pass locally with my changes
  • I have tested with ASAN and TSAN

Compatibility

  • My changes are backward compatible (or I've documented breaking changes)
  • I have considered the impact on existing users
  • I have updated CHANGELOG.md (if applicable)

Performance (if applicable)

  • I have considered the performance impact
  • I have added benchmarks for performance-critical changes

Screenshots / Diagrams

Not applicable.

Additional Notes

The frame benchmark was run as 20 pinned, serial, interleaved baseline/candidate pairs. Both variants used the same final benchmark source and compiler flags; only the Elio include root differed.

Frame allocation request results were deterministic across all samples:

Payload Variant Before After Reduction
64 B no token 448 B 392 B 12.5%
64 B token 504 B 384 B 23.8%
256 B no token 832 B 584 B 29.8%
256 B token 1,080 B 576 B 46.7%
1024 B no token 2,368 B 1,352 B 42.9%
1024 B token 3,384 B 1,344 B 60.3%

The 8-byte no-token payload is padding-bound and remains 336 B. Each factory operation still reports two allocations: the coroutine frame plus the existing task execution/cancellation control block. This change reduces allocated bytes rather than allocation count.

Paired candidate/baseline geometric ratios with 95% bootstrap confidence intervals:

Ready bounded, 256 B:       0.989 [0.963, 1.016]
Ready unbounded, 256 B:     1.004 [0.983, 1.026]
Ready active token, 256 B:  0.883 [0.867, 0.899]

Forced bounded-full:        0.919 [0.903, 0.934]
Forced bounded-full token:  0.942 [0.926, 0.959]
Forced rendezvous:          0.909 [0.887, 0.930]
Forced rendezvous token:    0.924 [0.904, 0.942]

The benchmark's target-local replacement allocation functions are deliberately non-inlined. This prevents GCC from diagnosing the recorder's intentional malloc/free implementation as a mismatched new/delete pair under -Werror; the recorder is isolated from the existing bench_channel executable.

Reviewer Guidance

Areas requiring special attention:

  • The ownership split between borrowed channel-coroutine waiters and public owning awaiters.
  • Claim-before-move and cancellation-versus-transfer ordering in refill, direct handoff, and close paths.
  • Destruction ordering of cancellation registration, the borrowed core, and the frame-owned payload.
  • The cross-translation-unit benchmark factory and exact allocation-to-frame mapping.

Questions for reviewers:

  • Can any queue path retain or dereference the borrowed payload after the channel mutex is released or the coroutine frame is destroyed?
  • Do public directly constructed send awaiters retain their previous independent ownership and source compatibility?
  • Does every cancellation winner leave the operation-owned payload untransferred while every completion winner transfers it exactly once?

Thank you for contributing to Elio! 🎉

Copilot AI balanced review requested due to automatic review settings August 13, 2026 10:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Reduces channel<T>::send coroutine-frame storage by letting internal waiters borrow frame-owned payloads while preserving public awaiter ownership.

Changes:

  • Introduces private borrowed send-waiter infrastructure.
  • Adds move-only lifecycle tests and a frame/performance benchmark.
  • Updates API, performance, and release documentation.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
include/elio/sync/channel.hpp Implements borrowed internal send waiters.
tests/unit/test_channel_success.cpp Adds ownership, move, and race tests.
examples/bench_channel_send_frame.cpp Benchmarks frame size and send performance.
examples/channel_send_frame_bench_factory.hpp Declares benchmark payloads and factories.
examples/channel_send_frame_bench_factory.cpp Implements non-inlined frame factories.
examples/CMakeLists.txt Builds the new benchmark.
wiki/API-Reference.md Documents send payload ownership.
wiki/Performance-Tuning.md Documents optimization and benchmarking.
CHANGELOG.md Records the frame-size improvement.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/unit/test_channel_success.cpp
@Coldwings

Copy link
Copy Markdown
Owner Author

@copilot Please review the updated pull request only.

Copilot AI commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

@copilot Please review the updated pull request only.

The updated PR is correct. Here is a summary of the review:

Active-token fix (55bd422): The cancel_source / source.get_token() change in the "active-token ready send transfers once" section correctly exercises the registration-bearing path. The token is live but uncancelled, so await_ready() returns false, await_suspend runs the bounded fast-path claim, pushes the value, and the test observes exactly one transfer. ✓

Ownership and lifetime:

  • send_awaitable declares T value_ before send_waiter_core core_. C++ destroys members in reverse declaration order, so core_ is destroyed first (removing itself from the waiter list under the mutex) before value_ is destroyed. No use-after-free path exists. ✓
  • borrowed_cancellable_send_awaitable declares send_waiter_core core_ before cancel_registration_, so cancel_registration_ is destroyed first (callback unregistered), then core_ cleans up the queue node. ✓
  • The frame-owned value in send(T value, cancel_token) lives for the full suspended lifetime; the borrowed core only holds a pointer to it and is destroyed before the frame resumes past co_return. ✓

Double-unregister safety: cancel_registration::unregister() guards on state_ && node_ and moves both out on first call, making subsequent calls no-ops. Safe. ✓

Claim-before-move ordering: try_push(T& value) in LockfreeMPMCRing only calls std::move(value) inside the successful CAS branch. Combined with claim_completion() gating the push call, a cancellation winner can never observe a partially moved value. ✓

Close drain: close() moves sender->value() into queue_ and then calls mark_success() and schedules the sender — the sender's borrowed pointer is only dereferenced while the mutex is held and before the wake state is scheduled, preserving the correct ordering. ✓

No issues found in the updated code. The PR looks good to merge.

@Coldwings
Coldwings merged commit d9bb0d3 into main Aug 13, 2026
11 checks passed
@Coldwings
Coldwings deleted the perf/channel-send-frame-core branch August 13, 2026 11:09
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.

[Performance] Avoid duplicate payload storage in channel send coroutine frames

3 participants