Skip to content

Track external wakeups in DebugScheduler - #123

Merged
ianmccul merged 3 commits into
mainfrom
fix/debug-scheduler-external-wakeups
Nov 13, 2026
Merged

ianmccul merged 3 commits into
mainfrom
fix/debug-scheduler-external-wakeups

Conversation

@ianmccul

Copy link
Copy Markdown
Collaborator

Independent of #121 and #122.

CudaTensorTest.StreamOrdered* aborted intermittently on main (about once in 150 repetitions on polaron): submitted was false, then a CHECK fired on destruction of a still-active task. The cause is in DebugScheduler, not the test alone:

  • Both tests create a zero-initialized tensor with stream_count = 1. The initialization memset holds the only stream until a CUDA host callback returns it to the pool. If the task's acquire_stream runs first it suspends, and the callback thread later reschedules it.
  • DebugScheduler's queue had no lock, so that reschedule raced the driving thread.
  • help_while_waiting() (behind get_wait) reported DEADLOCK whenever nothing was runnable, even with a wakeup pending.
  • The tests relied on run_all() meaning completion. It returns when nothing is runnable.

Change. A balanced external-wait registration on the scheduler interface: BasicTask::begin_external_wait() before an awaiter hands its task to another party, and BasicTask::reschedule_after_external_wait() to wake it (reschedule first, then end the wait, so an empty queue is never observed with the task in transit). The CUDA resource, stream and cuBLAS execution awaiters register their waits; other schedulers treat registration as a no-op.

DebugScheduler guards its queue with a mutex and condition variable. Waits block, with no time limit, while a registered wakeup is pending, and DEADLOCK is reported only when it is provable: nothing runnable and nothing pending. run_all() keeps its meaning as a debugging aid that runs until nothing is runnable; it does not wait for externally suspended tasks. An earlier revision made it wait, and CublasExecutionTest.AsyncOutputRemainsPendingUntilCublasSubmissionCompletes then hung: that test holds every cuBLAS handle on the driving thread and releases them only after run_all() returns. The stream-ordered tests now wait for their task with wait_for.

Also moves test_runtime_floating_eq.cpp from tests/common into the numerical-precision module. It uses the numerics PrecisionContext registry, which includes the tensor layer (its matrix() returns a DenseMatrix), so uni20_common_tests did not build in CUDA configurations.

Tests. New host tests drive a task woken from another thread through run_all() (returns with the wakeup pending) and wait_for() (blocks for it). A new CUDA test holds the only stream with a host-function gate so the task is deterministically suspended on a callback wakeup before waiting.

Validation (polaron, 2x Quadro GV100, CUDA 12.9, GCC 13 Debug; ryzen CPU-only GCC 13 Debug):

  • CUDA + cuSOLVER: full build, including uni20_common_tests; ctest 2760/2760.
  • CPU-only: full build; ctest 2547/2547.
  • CudaTensorTest.Stream* and the new CUDA wait test: 900 repetitions, 0 failures. On main the same tests abort within about 150.
  • New host scheduler tests: 500 repetitions, 0 failures.
  • scripts/check-docs.py passes; clang-format clean on changed files. Not run: Clang, Release, CUDA 13.

🤖 Generated with Claude Code

DebugScheduler had three defects when tasks are woken from outside it,
for example when a CUDA host callback returns a pooled stream to a task
waiting in acquire_stream:

- The runnable queue had no lock, while the callback thread enqueued
  the rescheduled task concurrently with the driving thread.
- help_while_waiting() panicked with DEADLOCK whenever the queue was
  empty, even though a wakeup was pending.
- Tests that called run_all() and then assumed completion raced the
  callback. CudaTensorTest.StreamOrdered* aborted about once in 150
  repetitions on main, destroying a still-suspended task.

Add a balanced external-wait registration to the scheduler interface.
An awaiter that hands its task to another party calls
BasicTask::begin_external_wait() before publishing it and
BasicTask::reschedule_after_external_wait() to wake it; the latter
reschedules before ending the wait, so the scheduler never sees an
empty queue with nothing pending while the task is in transit. The CUDA
resource, stream and cuBLAS execution awaiters register their waits.
Schedulers other than DebugScheduler treat registration as a no-op.

DebugScheduler guards its queue and the wait count with a mutex and
condition variable. Waits block, without a time limit, while the queue
is empty and an external wakeup is pending, and report DEADLOCK only
when nothing is runnable and nothing is pending. run_all() keeps its
meaning as a debugging aid that runs until nothing is runnable; it does
not wait for externally suspended tasks, so a caller that holds the
resource a task needs can still drive the scheduler. The stream-ordered
tensor tests now wait for their task instead of relying on run_all().

New tests cover a host-thread wakeup through run_all() and wait_for(),
and a deterministic CUDA case in which the only stream is held by a
host-function gate until the task is suspended on it.

Also move test_runtime_floating_eq.cpp into the numerical-precision
test module. It uses the numerics PrecisionContext registry, which
includes the tensor layer, so uni20_common_tests failed to build in
CUDA configurations for lack of the CUDA include path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 12, 2026 18:06

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@chatgpt-codex-connector

Copy link
Copy Markdown
Codex Review Summary: ❌ Failed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ❌ Failed 2026-11-12T18:06:59.520189Z
We couldn’t complete this review. Comment @codex review to try again.
b08ab3b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request introduces support for tracking and waiting on external wakeups (such as CUDA host callbacks) within the DebugScheduler and BasicTask async framework, updating the CUDA/cuBLAS awaiters and adding corresponding tests. The review feedback highlights critical race conditions where wakeup_.notify_all() is called outside of lock blocks, and where tasks can run to completion and destroy the scheduler before the external wait is fully released. To resolve these issues atomically, it is recommended to introduce a virtual reschedule_after_external_wait function to IScheduler and implement it in DebugScheduler to perform both rescheduling and wait-decrementing under a single lock acquisition.

Comment thread src/uni20/async/debug_scheduler.hpp Outdated
Comment on lines +177 to +185
void end_external_wait() noexcept override
{
{
std::lock_guard lock(mutex_);
CHECK(external_waits_ > 0, "DebugScheduler external wait ended without a matching begin");
--external_waits_;
}
wakeup_.notify_all();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

Calling wakeup_.notify_all() outside the lock block in end_external_wait creates a critical race condition. If the waiting thread is woken up, it can acquire the lock, see that external_waits_ == 0, return from wait_for_runnable_or_no_external_waits, and proceed to destroy the DebugScheduler object before notify_all() completes, leading to undefined behavior (use-after-free of the condition variable).

Additionally, calling BasicTask::reschedule before end_external_wait in reschedule_after_external_wait allows the task to run to completion and destroy the scheduler before the wait is released. To solve both issues atomically, we should override reschedule_after_external_wait in DebugScheduler to perform both operations under a single lock acquisition, and move notify_all() inside the lock block.

    void end_external_wait() noexcept override
    {
      std::lock_guard lock(mutex_);
      CHECK(external_waits_ > 0, "DebugScheduler external wait ended without a matching begin");
      --external_waits_;
      wakeup_.notify_all();
    }

    void reschedule_after_external_wait(BasicTask&& task) override
    {
      TRACE_MODULE(ASYNC, "Rescheduling a task after external wait", &task, task.coroutine_handle());
      {
        std::lock_guard lock(mutex_);
        Handles_.push_back(std::move(task));
        CHECK(external_waits_ > 0, "DebugScheduler external wait ended without a matching begin");
        --external_waits_;
      }
      wakeup_.notify_all();
    }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 47d6191: DebugScheduler::complete_external_wait requeues, decrements and notifies in one critical section, so once the task can run the waking thread only releases the lock. Destroying a scheduler while an external party may still call into it, beyond that, is a lifetime-contract violation rather than a scheduler defect.

Comment on lines +267 to +274
void enqueue_task(BasicTask&& task)
{
{
std::lock_guard lock(mutex_);
Handles_.push_back(std::move(task));
}
wakeup_.notify_all();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

Calling wakeup_.notify_all() outside the lock block in enqueue_task creates a similar race condition where the waiting thread can wake up, run the task to completion, and destroy the scheduler before notify_all() completes. Moving notify_all() inside the lock block ensures safe and robust notification.

    void enqueue_task(BasicTask&& task)
    {
      std::lock_guard lock(mutex_);
      Handles_.push_back(std::move(task));
      wakeup_.notify_all();
    }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

notify_all() is now inside the lock here too (47d6191). Only external wakeups enqueue from other threads, and they now go through complete_external_wait, so this was for consistency rather than a reachable defect.

Comment on lines +31 to +38
inline void BasicTask::reschedule_after_external_wait(BasicTask task)
{
DEBUG_CHECK(task.handle_);
auto* scheduler = task.handle_.promise().scheduler();
DEBUG_CHECK(scheduler, "unexpected: task scheduler is not set!");
BasicTask::reschedule(std::move(task));
scheduler->end_external_wait();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

Calling BasicTask::reschedule before scheduler->end_external_wait() introduces a critical race condition where the task can run to completion and destroy the scheduler before end_external_wait() is executed. To resolve this atomically, we should delegate to a single reschedule_after_external_wait call on the scheduler.

inline void BasicTask::reschedule_after_external_wait(BasicTask task)
{
  DEBUG_CHECK(task.handle_);
  auto* scheduler = task.handle_.promise().scheduler();
  DEBUG_CHECK(scheduler, "unexpected: task scheduler is not set!");
  task = BasicTask::make_sole_owner(std::move(task));
  if (task)
  {
    scheduler->reschedule_after_external_wait(std::move(task));
  }
  else
  {
    scheduler->end_external_wait();
  }
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Partly agreed; fixed in 47d6191. The general premise that a scheduler may be destroyed while work is outstanding is outside the contract: schedulers do not track their tasks and must outlive them. But rescheduling before ending the wait did leave a real window. The woken task could finish, the program could correctly conclude that all its tasks were done and destroy the scheduler, and the waking thread would still call into it. end_external_wait() is replaced by IScheduler::complete_external_wait(BasicTask&&), which reschedules and releases the wait as one step, and BasicTask::reschedule_after_external_wait makes that single call.

Comment thread src/uni20/async/scheduler.hpp Outdated
Comment on lines 110 to 113
/// \brief Record that an external wakeup recorded by `begin_external_wait()` was delivered.
/// \details Called after the woken task, if any, has been rescheduled.
virtual void end_external_wait() noexcept {}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

Add a virtual reschedule_after_external_wait function to IScheduler to allow schedulers to atomically reschedule a task and end its external wait under a single lock acquisition, preventing race conditions during destruction.

    /// \brief Record that an external wakeup recorded by begin_external_wait() was delivered.
    /// \brief Called after the woken task, if any, has been rescheduled.
    virtual void end_external_wait() noexcept {}

    /// \brief Reschedule a task woken from outside and end its external wait atomically.
    virtual void reschedule_after_external_wait(BasicTask&& task)
    {
      this->reschedule(std::move(task));
      this->end_external_wait();
    }
};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Adopted in 47d6191 as complete_external_wait(BasicTask&&), replacing end_external_wait() rather than adding a second entry point. The default just reschedules; DebugScheduler overrides it.

@ian-review-bot

Copy link
Copy Markdown
Collaborator

Review Bot: review of exact head b08ab3b72de7d08d31e04b9f475e03645704c9cf

Found 1 issue; details below.

What the PR does. It adds a balanced external-wait registration to the scheduler interface: IScheduler::begin_external_wait/end_external_wait, wrapped by BasicTask::begin_external_wait and BasicTask::reschedule_after_external_wait. The CUDA resource/stream awaiters and the cuBLAS execution awaiter now use it. DebugScheduler now protects its queue with a mutex and condition variable. Its help_while_waiting blocks while a registered wakeup is pending, and it reports DEADLOCK only when nothing is runnable and nothing is pending. The stream-ordered CUDA tests switch from run_all() to wait_for. New host and CUDA tests are added, and test_runtime_floating_eq.cpp moves to the numerical-precision module.

Finding. BasicTask::reschedule_after_external_wait enqueues the task before it ends the wait. The task can therefore complete, and the scheduler can be destroyed, while the waking thread is still between those two calls. The destructor CHECK then fires (reproduced naturally in 10/10 runs), and external_waits() can read 1 after the result is ready. The inline comment on src/uni20/async/async_task_impl.hpp:37 has the reproduction and a fix I tested.

What I reviewed. The full diff. DebugScheduler (help_while_waiting, run, run_all, enqueue_task, the destructor). BasicTask::reschedule and make_sole_owner. Every awaiter that hands a task to another party (git grep take_ownership()): the three changed awaiters, plus CudaDeviceAwaiter, which reschedules on the driving thread and correctly does not register. The stream-pool return path (runtime.cpp return_stream_callback → make_available, which notifies outside the pool lock). ResourcePool::release. DebugCudaScheduler. The doc changes in docs/async/schedulers.md and external_io_design.md.

Verified (polaron, GCC 13.3, Debug, -DUNI20_ENABLE_CUDA=ON, CUDA 12.9, CMAKE_CUDA_ARCHITECTURES=70, 2× Quadro GV100):

  • uni20_async_tests 201/201, uni20_cuda_runtime_tests 87/87, uni20_cublas_tests 39/39.
  • CudaTensorTest.Stream*:CudaTensorTest.WaitBlocks* repeated 300×: all pass. CudaTensorTest.WaitBlocksForStreamReturnedByHostCallback repeated 3000×: all pass. CublasExecutionTest.* repeated 100×: all pass, including AsyncOutputRemainsPendingUntilCublasSubmissionCompletes, which the description says hung with an earlier revision.
  • DebugSchedulerTest.* repeated 200× in a CPU-only Debug build: all pass.
  • File-move claim: uni20_common_tests builds in the CUDA configuration and passes 350/350.
  • python3 scripts/check-docs.py passes (155 Markdown files).

Not checked.

  • I did not build or run main, so the "aborts within about 150 repetitions on main" baseline is not reproduced.
  • No full-tree build or ctest; I built only the targets above. That means no cuSOLVER and no uni20_numerical_precision_tests (where the moved test now lives); the claimed 2760/2547 totals are not re-derived.
  • No Release, Clang 19, wide-scalar or CUDA 13 builds. No TSan/ASan run.
  • The race in the finding is not reproduced on the CUDA callback path, only with host threads.
  • CI status was not consulted; no failed-job logs were provided.

Review tier: full (code or new pull request) · model: claude-opus-5-5 · effort: high

Comment thread src/uni20/async/async_task_impl.hpp Outdated
auto* scheduler = task.handle_.promise().scheduler();
DEBUG_CHECK(scheduler, "unexpected: task scheduler is not set!");
BasicTask::reschedule(std::move(task));
scheduler->end_external_wait();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review Bot: review of exact head b08ab3b72de7d08d31e04b9f475e03645704c9cf

The woken task can finish, and its scheduler can be destroyed, before this end_external_wait() runs. Line 36 enqueues the task and releases DebugScheduler::mutex_. From then on the driving thread can run the task to completion, see its wait_for predicate become true, and destroy the scheduler. All of that can happen while the waking thread has still not reached line 37. Two things follow:

  • the destructor CHECK(external_waits_ == 0, ...) (debug_scheduler.hpp:83) fires even though the task has completed; or the waking thread decrements and notifies through a scheduler that is already destroyed;
  • external_waits() can still read 1 after the awaited result is ready. The new CUDA test asserts exactly this with no synchronisation (test_cuda_tensor.cpp:570, EXPECT_EQ(scheduler.external_waits(), 0U) straight after wait_for).

This confirms the concern raised in #123 (comment), here with a reproduction.

Reproduction. Host only, GCC 13.3 Debug, polaron. I added three probe tests to tests/async/test_debug_scheduler.cpp; they are not part of the PR. Each uses the PR's own ExternalWakeup pattern without the gate and the 20 ms sleep: a std::thread calls BasicTask::reschedule_after_external_wait at once. The main thread calls scheduler.run() and then scheduler.wait_for([&]{ return resumed; }). Then:

  • stale count: it reads external_waits() before joining the waker. Over 10 × 20 000 iterations it read 1 in 37 iterations after the task had resumed;
  • destroy: it lets the scheduler go out of scope and only then joins the waker. All 10/10 runs of 20 000 iterations aborted with CHECK at debug_scheduler.hpp:83 ➡️ DebugScheduler destroyed while tasks await external wakeups, external_waits_ = 0. The message prints 0 because the waker decremented the counter between the check and the message, i.e. concurrently with destruction;
  • widened window: a DebugScheduler subclass sleeps 50 ms in end_external_wait() before calling the base, as preemption of the waker would. With it, wait_for returned with resumed=1 external_waits=1.

Possible fix (tested locally). Do the enqueue and the decrement under a single lock acquisition, and notify while still holding the lock. In my probe I added a virtual IScheduler::reschedule_after_external_wait(BasicTask&&). Its default implementation keeps today's two calls; DebugScheduler overrides it to push the task, decrement and notify_all under mutex_. BasicTask::reschedule_after_external_wait applies make_sole_owner and then calls it. If no task is left it calls only end_external_wait(). I also moved the notify_all into the locked region in enqueue_task and end_external_wait. With those changes the same probes gave 0/10 aborts and 0 stale reads, and the 201 existing uni20_async_tests passed.

Not checked. I did not reproduce this on the CUDA path: the new CudaTensorTest.WaitBlocksForStreamReturnedByHostCallback passed 3000/3000 repetitions on a GV100. There the driving thread's work after the wakeup (stream lease, cudaLaunchHostFunc) makes the window small, and streams().synchronize() runs before the scheduler is destroyed. No TSan/ASan run, and no Clang build.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks, confirmed. This reviewed b08ab3b7; it's fixed in 47d6191 the same way you suggest. end_external_wait() became IScheduler::complete_external_wait(BasicTask&&) (the default just reschedules), and DebugScheduler pushes, decrements and notifies under one lock acquisition. BasicTask::reschedule_after_external_wait applies make_sole_owner and makes that single call. enqueue_task also notifies under the lock now.

Your probe is now a regression test in 6f0bebb: SchedulerMayBeDestroyedOnceTheExternallyWokenTaskHasRun. The waker wakes the task at once, and the scheduler is destroyed before the waker is joined, for 20000 iterations. With the previous scheduler files it fails in 3/3 runs, at iterations 749, 1528 and 988 (stale external_waits(), then the destructor CHECK). With the fix it passes 3/3, and uni20_async_tests passes 202/202.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review Bot: recheck of exact head 6f0bebba7cb4df61ebbb50ffdc4cb36cecef719e

Confirmed fixed. I ran the same test source against both scheduler versions (GCC 13.3 Debug, polaron). With the four scheduler headers from b08ab3b7, SchedulerMayBeDestroyedOnceTheExternallyWokenTaskHasRun aborts in 3/3 runs (iterations 2766, 1584, 4329). At this head it passes in 3/3 runs. My multi-task probes also pass at this head: 8 tasks woken twice each, fifo/reverse/random order, 5000 iterations, scheduler destroyed before the wakers are joined. They pass 12/12, against 9/12 aborted with the old headers. TSan reports no warnings on the DebugScheduler tests. Details are in the review comment for this head.

ianmccul and others added 2 commits November 13, 2026 02:24
reschedule_after_external_wait rescheduled the task and then called
end_external_wait() separately. In between, the driving thread could run
the woken task to completion; a program that then correctly concluded
that all its tasks had finished could destroy the scheduler while the
waking thread was still about to touch it. It also let
external_waits() and the destructor's balance check observe a pending
wait for a task that had already run.

Replace end_external_wait() with complete_external_wait(BasicTask&&),
which reschedules the task when present and releases the wait as one
operation. DebugScheduler requeues, decrements and notifies inside a
single critical section, so once the task can run the waking thread
only releases the lock. Other schedulers keep the default, which just
reschedules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The waking thread wakes the task at once, and the scheduler is
destroyed after the task has run but before that thread is joined.
Against the previous reschedule-then-end ordering this fails within
about 750 to 1500 of the 20000 iterations, first with a stale
external_waits() count and then with the destructor's balance check;
with complete_external_wait it passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ian-review-bot

Copy link
Copy Markdown
Collaborator

Review Bot: review of exact head 6f0bebba7cb4df61ebbb50ffdc4cb36cecef719e

No defect found. The earlier finding (#123 (comment), wake-before-release race in reschedule_after_external_wait) is fixed: I reproduced it with the previous scheduler headers, and it no longer reproduces at this head.

What changed since b08ab3b7. IScheduler::end_external_wait() is replaced by complete_external_wait(BasicTask&&). The default implementation reschedules a non-empty task. DebugScheduler overrides it to push the task, decrement external_waits_ and notify_all in one critical section. BasicTask::reschedule_after_external_wait now applies make_sole_owner and makes that single call. enqueue_task also notifies under the lock. A new regression test, DebugSchedulerTest.SchedulerMayBeDestroyedOnceTheExternallyWokenTaskHasRun, destroys the scheduler before it joins the waker, 20000 times.

What I reviewed. The incremental diff (async_task.hpp, async_task_impl.hpp, debug_scheduler.hpp, scheduler.hpp, test_debug_scheduler.cpp). Every caller of reschedule_after_external_wait: the CUDA resource and stream awaiters and the cuBLAS execution awaiter. All take a sole claim through take_ownership(), so make_sole_owner returns the task there. Every reschedule override (DebugScheduler, TbbScheduler, TbbNumaScheduler). DebugCudaScheduler, which overrides only resume_task, so the direct Handles_.push_back in complete_external_wait bypasses no derived behaviour. For the TBB schedulers the default complete_external_wait is the old BasicTask::reschedule path. Docs: no remaining mention of end_external_wait.

Verified (polaron, GCC 13.3.0, Debug, the REVIEW-hint CMake options with -DUNI20_BUILD_PYTHON=OFF):

  • Head, CPU-only: uni20_async_tests 202/202. DebugSchedulerTest.* 5 further runs, all pass.
  • Old versus new, same test source. I built uni20_async_tests with the four scheduler headers checked out from b08ab3b7 and the head's tests. The new regression test aborts in 3/3 runs, at iterations 2766, 1584 and 4329 (destructor CHECK at debug_scheduler.hpp:83). At head it passes in 3/3 runs. This matches the author's 3/3 result.
  • Probes beyond the author's test (not in the PR): 8 tasks, each woken twice from fresh threads, under fifo, reverse and random order. Also a get_wait() on an Async<int> written after an external wakeup. Each destroys the scheduler before it joins the wakers, 5000 iterations, 3 runs each. Old headers: 9/12 runs aborted. Head: 12/12 runs passed.
  • ThreadSanitizer (-fsanitize=thread, run under setarch -R because GCC 13 TSan rejects the default mmap layout on kernel 6.8): DebugSchedulerTest.* plus the probes (300 iterations each), 15/15 pass, 0 TSan warnings.
  • CUDA 12.9.86, -DUNI20_ENABLE_CUDA=ON -DUNI20_BACKEND_CUSOLVER=ON -DCMAKE_CUDA_ARCHITECTURES=70, 2× Quadro GV100: uni20_cuda_runtime_tests 87/87, uni20_cublas_tests 39/39, uni20_cuda_tbb_scheduler_tests 13/13, uni20_cusolver_tests 6/6. With one GPU visible, CudaTensorTest.Stream*:CudaTensorTest.WaitBlocks* ran --gtest_repeat=500 and CublasExecutionTest.* ran --gtest_repeat=50, with 0 failures.
  • python3 scripts/check-docs.py passes.

Not checked.

  • main baseline (the "aborts within about 150 repetitions" claim): not rebuilt or reproduced.
  • Full-tree build and ctest in any configuration. I built only uni20_async_tests (CPU) and the four CUDA test targets above, so uni20_common_tests and uni20_numerical_precision_tests (the moved test_runtime_floating_eq.cpp) were not built at this head.
  • Release, Clang 19, wide-scalar/MPLAPACK jobs, CUDA 13, macOS. TSan covered only the host DebugScheduler tests, not the CUDA or TBB paths.
  • CI for this head was not available to me.

Review tier: full (code or new pull request) · model: claude-opus-5-5 · effort: high

@ianmccul
ianmccul merged commit f5c8ab8 into main Nov 13, 2026
6 checks passed
@ianmccul
ianmccul deleted the fix/debug-scheduler-external-wakeups branch November 13, 2026 02:38
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.

3 participants