Skip to content

perf(runtime): transfer existing tasks directly - #1031

Merged
Coldwings merged 2 commits into
mainfrom
perf/direct-task-spawn
Aug 12, 2026
Merged

perf(runtime): transfer existing tasks directly#1031
Coldwings merged 2 commits into
mainfrom
perf/direct-task-spawn

Conversation

@Coldwings

@Coldwings Coldwings commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Description

Add direct rvalue coro::task<T> overloads to the high-level spawn APIs. A
caller that already owns a lazy task can now transfer that root frame directly
instead of allocating a callable-wrapper coroutine and a second execution
context.

Callable overloads remain unchanged and continue to own arbitrary callables
and arguments safely. This distinction is documented because eagerly invoking
a temporary coroutine lambda can leave its frame referring to a destroyed
callable object.

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 #1030

Changes Made

Core Changes

  • Add direct-task overloads for scheduler and namespace-level spawn APIs.
  • Route direct tasks and callable wrappers through one admission helper while
    preserving tracking, affinity, rejection, shutdown, and frame ownership.
  • Attach join state to the transferred root execution context so cancellation
    authority and result/destruction ordering do not depend on a wrapper.
  • Reject empty or already-completed task owners before transfer and destroy
    consumed frames correctly on admission or join-state allocation failure.
  • Extend the quick benchmark with callable and direct-task spawn measurements.

API Changes

scheduler.go(coro::task<T>&&);
scheduler.go_to(worker_id, coro::task<T>&&);
scheduler.go_joinable(coro::task<T>&&);
scheduler.go_joinable_to(worker_id, coro::task<T>&&);

elio::go(coro::task<T>&&);
elio::go_to(worker_id, coro::task<T>&&);
elio::spawn(coro::task<T>&&);

All existing callable overloads remain source-compatible.

Testing

Unit Tests

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

Coverage includes allocation count, result transfer, affinity, cancellation,
scheduler rejection while running, injected join-state allocation failure,
no-scheduler behavior, moved-from/empty/completed owners, and exactly-once
frame destruction.

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

cmake --build <debug-build> --parallel 2 \
  --target quick_benchmark elio_tests elio_tests_asan elio_tests_tsan

elio_tests --reporter compact
All tests passed (12150 assertions in 801 test cases)

ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 \
  elio_tests_asan '[task][spawn]' --reporter compact
All tests passed (646 assertions in 36 test cases)

TSAN_OPTIONS=halt_on_error=1 \
  elio_tests_tsan '[task][spawn]' --reporter compact
All tests passed (646 assertions in 36 test cases)

Release benchmark, pinned to CPU 0, three runs:

Path Mean ns/task Mean tasks/s
Callable wrapper 658.45 1,293,511
Direct task 502.77 1,680,210

The direct path reduced mean spawn time by 23.6% and increased mean throughput
by 29.9% in this local comparison. The benchmark is descriptive and does not
set a shared-runner timing threshold.

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

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

Additional Notes

The direct overload is intended for tasks returned by ordinary coroutine
functions or for tasks whose callable lifetime is otherwise known to be safe.
The callable overload remains the safe default for temporary coroutine lambdas
and arbitrary callable objects.

Reviewer Guidance

Please focus on:

  • exactly-once frame ownership across join-state allocation and admission
    failures;
  • join-handle cancellation using the transferred root execution context;
  • overload resolution preserving the callable lifetime wrapper;
  • documentation of the temporary coroutine-lambda lifetime boundary.

Copilot AI balanced review requested due to automatic review settings August 12, 2026 22:22

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

Adds direct transfer of lazy tasks to spawn APIs, avoiding wrapper coroutine overhead while preserving callable lifetime safety.

Changes:

  • Adds direct-task scheduler and namespace-level overloads.
  • Preserves join, cancellation, affinity, and rejection behavior.
  • Adds tests, benchmarks, and documentation.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
include/elio/runtime/scheduler.hpp Implements direct task admission.
include/elio/runtime/spawn.hpp Adds namespace-level overloads.
include/elio/coro/promise_base.hpp Adds allocation-count test instrumentation.
tests/unit/test_task.cpp Tests transfer, affinity, rejection, and allocation count.
tests/unit/test_cancellation_context.cpp Tests direct-root cancellation.
examples/quick_benchmark.cpp Benchmarks callable versus direct spawning.
README.md Documents direct spawn usage.
CHANGELOG.md Records the new API.
wiki/API-Reference.md Documents overloads and examples.
wiki/API-Contracts.md Defines ownership and cancellation contracts.
wiki/Core-Concepts.md Explains direct root-frame transfer.
wiki/Performance-Tuning.md Provides performance guidance.
Suppressed comments (1)

examples/quick_benchmark.cpp:166

  • As above, the no-argument wait_for_idle() waits forever, making the stated timeout path unreachable for this external-thread call. Use a finite timeout for the direct-spawn batch too.
        if (!sched.wait_for_idle()) {
            std::cerr << "Timed out waiting for direct-spawn batch"
                      << std::endl;
            std::abort();

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

Comment thread include/elio/runtime/scheduler.hpp
Comment thread examples/quick_benchmark.cpp Outdated
Comment thread wiki/API-Reference.md Outdated
Comment thread include/elio/runtime/scheduler.hpp
Comment thread tests/unit/test_task.cpp
@Coldwings

Copy link
Copy Markdown
Owner Author

@copilot please re-review only; do not make direct code modifications.

Copilot AI commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

@copilot please re-review only; do not make direct code modifications.

Re-review complete. All five concerns from the original review are correctly addressed in a303254:

  • Completed-task rejection: Both scheduler.hpp and spawn.hpp now check .done() before releasing ownership, so the caller retains and can destroy a completed frame.
  • Benchmark finite timeouts: Both wait_for_idle calls use SPAWN_BATCH_TIMEOUT, making the abort branch reachable.
  • Wiki example count: "All four run in parallel" with a correct sum of 200.
  • Join-state allocation failure: The fail_next_join_state_allocation_for_test hook fires after ownership is released; the catch block calls handle.destroy() guaranteeing exactly-once destruction. The regression test verifies bad_alloc propagation, invalidated owner, one destruction, scheduler idle, and clean shutdown.
  • Running-scheduler enqueue rejection: The new test uses reject_next_spawn_for_test with a live scheduler and verifies consumed owner, terminal join handle with rejection error, exactly-one frame destruction, and idle tracking before shutdown.

No issues found. The implementation is correct and complete.

@Coldwings

Copy link
Copy Markdown
Owner Author

@copilot please re-review the current head only; do not make direct code modifications.

Copilot AI commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

@copilot please re-review the current head only; do not make direct code modifications.

Re-review of current HEAD (a303254) complete. No issues found. Full analysis:

do_go_task_ ownership and tracking — all paths are balanced. If admission skips the lambda, mark_tracked_ is never called and on_spawn_completion_ is null (guarded). If the lambda runs but do_spawn returns false, tracking is +1 then -1 via ~promise_base. If do_spawn throws, the outer catch destroys the frame and balances. On success, the worker destroys the frame at completion. Every path is exact.

Rejection path signal orderinghandle.destroy() calls ~promise_type which sets destroyed_=true via mark_destroyed(), then the outer state->set_exception(...) calls complete() setting completed_=true. These are orthogonal flags (mutex+CV vs. completion waiter slot) and both are correctly set. The test verifies is_ready(), is_destroyed(), and await_resume() all behave as expected.

final_awaiter vs. force-destroy — in final_awaiter, join_state_ is moved out before h.destroy() so ~promise_type skips mark_destroyed(); it is called explicitly after the frame is fully torn down. In the force-destroy path, ~promise_type calls mark_destroyed() during destruction, which is safe (no frame access). The different ordering between normal completion and force-destroy is intentional.

spawn.hpp no-scheduler pathtask_handle is obtained before the scheduler check while task still owns the frame; execution_context() is valid. The task_handle = release(std::move(task)) reassignment returns the same handle value and is harmless.

Completed-task rejection — both paths reject before release(), so the caller's owner remains valid after the throw, as verified by the test.

@Coldwings
Coldwings merged commit 3cabcd3 into main Aug 12, 2026
12 checks passed
@Coldwings
Coldwings deleted the perf/direct-task-spawn branch August 12, 2026 23:12
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] Add direct task spawn overloads to eliminate wrapper frames

3 participants