Skip to content

perf(timing): high-resolution rate-loop pacing for exact rate delivery - #1185

Open
ajcasagrande wants to merge 13 commits into
mainfrom
ajc/perf-high-res-rate-pacing
Open

perf(timing): high-resolution rate-loop pacing for exact rate delivery#1185
ajcasagrande wants to merge 13 commits into
mainfrom
ajc/perf-high-res-rate-pacing

Conversation

@ajcasagrande

@ajcasagrande ajcasagrande commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

What changed

The request-rate loop slept via the event loop's timer wheel, which quantizes sub-millisecond waits to ~1ms under uvloop/libuv. At high rates (e.g. 200us intervals at 5,000 req/s) every sleep overshoots, and the unconditional reset-to-now policy forfeited the overshot schedule on every tick — silent under-delivery.

  • high_res_timer.pyTimerFdPacer (Linux timerfd, kernel hrtimer, observed via loop.add_reader — the same fd path ZMQ uses) and ThreadPacer (cross-platform dedicated time.sleep thread waking the loop via call_soon_threadsafe). Both expose async sleep_until(deadline) with ~50us precision. Selected via AIPERF_TIMING_HIGH_RES_TIMER (default on): timerfd on Linux, else the sleep thread, else event-loop timers.
  • Bounded catch-up window (AIPERF_TIMING_MAX_CATCHUP_SECONDS, default 0.01) — re-anchor to now only once the backlog exceeds the window, so sub-ms oversleeps stay on the original schedule; a genuine multi-second stall still re-anchors instead of firing a burst storm.
  • The pacer is used only for short sleeps (<= RATE_RAMP_UPDATE_INTERVAL); longer sleeps keep the event-wait so mid-sleep rate updates (ramping, set_request_rate) still wake promptly — where ~1ms precision is irrelevant anyway. This preserves existing dynamic-rate behavior exactly.

Why (mechanism)

The open-loop rate pacer was implicitly riding per-message socket wakeups for sub-ms ticks. Under an efficient batched transport the loop instead parks in epoll_wait and only wakes on the ~1ms OS timer floor; the issue-gap histogram shifts right and reset-to-now forfeits every slightly-late issue. The pacer gives the rate loop its own kernel-hrtimer clock (~50us wakeups) independent of transport activity, and the bounded catch-up window stops sub-ms oversleeps from permanently forfeiting schedule.

Evidence

On-box A/B, this change vs origin/main — zero-latency Rust aiperf-mock-server, constant arrival, OSL=1, 30k requests, 5,000 req/s target:

Build Achieved req/s
origin/main (reset-to-now + event timers) 4,463 / 4,474 / 4,493 (~4,477 avg, −10.5%)
this PR, pacer off (catch-up window only) 4,999.09
this PR, pacer on (timerfd) 4,999.06

Reproducible ~10% under-delivery on main; the bounded catch-up window alone recovers it, and the timerfd pacer holds target with no regression. All runs clean, 0 errors, sub-ms TTFT.

c4-standard-144 (the 144-vCPU instance type used in the arXiv inference-benchmarking paper), 5,000 req/s open-loop against llm-d-inference-sim, 144 workers:

  • Shipped 0.11.0: 3,776.8 req/s — barely scales with added cores (timing-bound, not CPU-bound).
  • With these timing fixes: 5,000.1 req/s exact.
  • Session arc @5k: 3,569 → 4,330 (hot-path fixes) → 5,000.0 (timing fixes).
  • Poisson arrival fidelity: with the ~50us timerfd wakeups the realized inter-arrival dispersion matches exponential theory at every quantile; the ~1ms epoll floor structurally cannot render the sub-200us gaps a 5k Poisson process demands.

Supporting artifacts (py-spy TimingManager profiles for the good/regressed/fixed builds, and per-arrival-pattern wiregap inter-arrival captures) are archived under bench-bias-repro/results-c4/ (pyspy_{good,bad,wt}_tm.speedscope, wiregap_*.json).

Validation

  • New unit tests: tests/unit/timing/test_high_res_timer.py (both pacers honor an absolute deadline within tolerance, past/early deadlines return promptly, close() idempotent) and pacer-factory tests in test_request_rate.py. Existing test_request_rate_update_wakes_pending_sleep still passes — mid-sleep rate updates wake promptly with the pacer on.
  • Full unit suite: 15,458 passed, 94 skipped, 1 xfailed.
  • AIPERF_TIMING_HIGH_RES_TIMER / AIPERF_TIMING_MAX_CATCHUP_SECONDS documented via regenerated docs/environment-variables.md.

Notes

Re-implemented against main's request-rate loop (the source branch's timing subsystem had diverged wholesale, so this is not a cherry-pick). The high_res_timer.py module is ported verbatim.

c4_05_credit_issue_density 02_fidelity_achieved 03_poisson_cdf image

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added optional high-resolution request-rate pacing for improved timing precision.
    • Added platform-specific pacing with automatic fallback to event-loop timers.
    • Added configurable limits for schedule catch-up before timing is re-anchored.
    • Added documented environment-variable controls, defaults, and valid ranges.
  • Bug Fixes

    • Rate changes now promptly reschedule pending pacing deadlines.
    • Improved handling of pacing errors, cancellation, and shutdown.

The request-rate loop slept via the event loop's timer wheel, which
quantizes sub-millisecond waits to ~1ms under uvloop/libuv. At high
rates (e.g. 200us intervals at 5,000 req/s) every sleep overshoots, and
the unconditional reset-to-now policy forfeited the overshot schedule on
every tick -> silent under-delivery (measured ~3,569 against a 5,000
target).

- Add high_res_timer.py: TimerFdPacer (Linux timerfd, kernel hrtimer,
  observed via loop.add_reader) and ThreadPacer (cross-platform dedicated
  sleep thread waking the loop via call_soon_threadsafe). Both expose
  async sleep_until(deadline) with ~50us precision, gated on
  AIPERF_TIMING_HIGH_RES_TIMER (default on).
- Bounded catch-up window (AIPERF_TIMING_MAX_CATCHUP_SECONDS, default
  0.01): re-anchor to now only once the backlog exceeds the window, so
  sub-ms oversleeps stay on the original schedule while a genuine
  multi-second stall still re-anchors instead of firing a burst storm.
- Use the pacer only for short sleeps (<= RATE_RAMP_UPDATE_INTERVAL);
  longer sleeps keep the event-wait so mid-sleep rate updates (ramping,
  set_request_rate) still wake promptly, where ~1ms precision is moot.

Ported verbatim from prior 5k-QPS work; re-implemented against main's
request-rate loop. Env-var docs regenerated.

Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com>
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Try out this PR

Quick install:

pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@0d28a334bb73d2da01249d83c37f54fe9d732b11

Recommended with virtual environment (using uv):

uv venv --python 3.12 && source .venv/bin/activate
uv pip install --upgrade --force-reinstall git+https://github.com/ai-dynamo/aiperf.git@0d28a334bb73d2da01249d83c37f54fe9d732b11

Last updated for commit: 0d28a33Browse code

@github-actions github-actions Bot added the perf label Jul 24, 2026
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.46602% with 32 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/aiperf/timing/strategies/request_rate.py 76.13% 17 Missing and 4 partials ⚠️
src/aiperf/timing/high_res_timer.py 90.51% 5 Missing and 6 partials ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The request-rate strategy now supports configurable high-resolution pacing and bounded schedule catch-up. Linux timerfd and threaded fallback pacers provide absolute-deadline sleeps. Tests cover timing, fallback, rescheduling, and cleanup.

Changes

High-Resolution Request Pacing

Layer / File(s) Summary
Timing configuration contract
src/aiperf/common/environment.py, docs/environment-variables.md
Adds HIGH_RES_TIMER and MAX_CATCHUP_SECONDS settings with defaults, constraints, semantics, and documentation.
Absolute-deadline pacers
src/aiperf/timing/high_res_timer.py, tests/unit/timing/test_high_res_timer.py
Adds Linux TimerFdPacer and threaded ThreadPacer implementations with asyncio wake-up, past-deadline handling, cancellation behavior, and idempotent shutdown.
Request-rate pacing integration
src/aiperf/timing/strategies/request_rate.py, tests/unit/timing/strategies/test_request_rate.py
Selects an available pacer, reschedules on rate updates, applies bounded catch-up and adaptive waiting, falls back on pacer errors, and validates factory and execution behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Poem

I’m a rabbit with a timer, quick and bright,
Pacing requests through the night.
Timerfd and threads keep deadlines true,
Catch-up bounds guide the schedule too.
Each pacer closes when work is through!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the pull request's two main changes: high-resolution rate-loop pacing and bounded catch-up.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
tests/unit/timing/test_high_res_timer.py (1)

13-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move pacer imports to module top.

Each test imports ThreadPacer/TimerFdPacer locally (Lines 13, 31, 42, 55, 73, 84). high_res_timer is pure stdlib/ctypes and imports cleanly on every platform, so these can be hoisted to the top of the file.

As per coding guidelines: "keep imports at the top".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/timing/test_high_res_timer.py` at line 13, Move the local
ThreadPacer and TimerFdPacer imports out of the individual tests and place them
with the module-level imports at the top of the test file. Remove the
now-redundant in-test imports while preserving each test’s existing behavior.

Source: Coding guidelines

src/aiperf/timing/strategies/request_rate.py (1)

215-235: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Optional: pacer sleep_until failures aren't recovered at runtime.

Creation falls back gracefully, but TimerFdPacer.sleep_until can still raise OSError (from timerfd_settime) mid-loop, which propagates out and aborts the phase with no fallback to event-loop waiting. It's a low-probability path once the fd exists, but if you want the phase to survive a transient timer failure, catch it here, drop self._pacer to None, and continue with the event-wait branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aiperf/timing/strategies/request_rate.py` around lines 215 - 235, Update
the `_pacer.sleep_until` path in the request-rate loop to catch `OSError` from
runtime timer failures, set `self._pacer` to `None`, and continue into the
existing `_rate_update_event` waiting branch so the phase can proceed without
the pacer.
src/aiperf/timing/high_res_timer.py (1)

65-97: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Declare ctypes signatures for the timerfd calls

argtypes/restype would make the libc contract explicit and catch mismatched arguments sooner, especially for the timerfd_settime pointer parameter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aiperf/timing/high_res_timer.py` around lines 65 - 97, Define explicit
ctypes argtypes and restype for libc.timerfd_create and libc.timerfd_settime
immediately after loading libc in the timer initialization code. Ensure the
declarations match each function’s integer arguments, pointer parameter, and
return type, while leaving _on_readable and sleep_until behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unit/timing/test_high_res_timer.py`:
- Line 26: Make the median wake-error assertions in the high-resolution timer
tests deterministic by removing or gating the host-load-sensitive thresholds at
the assertions for errors_us and TimerFdPacer. Preserve the min(errors_us) >=
0.0 invariants, and use a wider bound or the repository’s existing opt-in
performance-test mechanism rather than enforcing fixed latency limits in normal
CI.

---

Nitpick comments:
In `@src/aiperf/timing/high_res_timer.py`:
- Around line 65-97: Define explicit ctypes argtypes and restype for
libc.timerfd_create and libc.timerfd_settime immediately after loading libc in
the timer initialization code. Ensure the declarations match each function’s
integer arguments, pointer parameter, and return type, while leaving
_on_readable and sleep_until behavior unchanged.

In `@src/aiperf/timing/strategies/request_rate.py`:
- Around line 215-235: Update the `_pacer.sleep_until` path in the request-rate
loop to catch `OSError` from runtime timer failures, set `self._pacer` to
`None`, and continue into the existing `_rate_update_event` waiting branch so
the phase can proceed without the pacer.

In `@tests/unit/timing/test_high_res_timer.py`:
- Line 13: Move the local ThreadPacer and TimerFdPacer imports out of the
individual tests and place them with the module-level imports at the top of the
test file. Remove the now-redundant in-test imports while preserving each test’s
existing behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: e52213b2-a2d6-436c-8496-435c8d267474

📥 Commits

Reviewing files that changed from the base of the PR and between 4f3caae and b2dfd54.

📒 Files selected for processing (6)
  • docs/environment-variables.md
  • src/aiperf/common/environment.py
  • src/aiperf/timing/high_res_timer.py
  • src/aiperf/timing/strategies/request_rate.py
  • tests/unit/timing/strategies/test_request_rate.py
  • tests/unit/timing/test_high_res_timer.py

Comment thread tests/unit/timing/test_high_res_timer.py Outdated
@ajcasagrande
ajcasagrande requested a review from jthomson04 July 24, 2026 05:03
@ajcasagrande

Copy link
Copy Markdown
Contributor Author

Cross-platform pacing (macOS / Windows)

The high-res pacer is not Linux-only. The selection chain is timerfd (Linux) → ThreadPacer (macOS / Windows / restricted Linux) → event-loop timers, all behind the same default-on AIPERF_TIMING_HIGH_RES_TIMER flag.

ThreadPacer (in high_res_timer.py, included in this PR) is the portable fallback: a dedicated thread sleeps to each absolute perf_counter deadline and wakes the loop via call_soon_threadsafe — the one mechanism every loop flavor supports, including the Windows proactor loop (which has no add_reader). Per-platform backing:

  • macOStime.sleep is clock_nanosleep / mach-timer backed, ~50–100µs.
  • Windows — CPython 3.11+ uses high-resolution waitable timers inside time.sleep, ~0.5ms.
  • Linux (measured) — p50 67µs / p99 166µs oversleep, an order of magnitude tighter than the ~1ms event-loop wheel.

Cross-OS validation

The portability of the underlying technique (absolute deadlines beat relative sleeps regardless of timer quality) was validated in a standalone 3-target CI harness across Linux, macOS, and Windows. Cumulative schedule drift at 50k req/s, constant arrivals:

variant Linux macOS (VM) Windows
relative gaps (1ms wheel) 2,402 ms 2,402 ms 20,463 ms
relative + high-res timer 7 ms 2,408 ms 1,106 ms
absolute deadlines, no spin 0.006 ms 1.6 ms 0.5 ms
absolute + spin tail 0.000 ms 0.000 ms 0.002 ms (burns a core)

Takeaway: the schedule model (absolute vs relative) dominates timer quality. Windows' high-res timer is ~18× better than the wheel, yet relative gaps still drift to ~1.1s because they accumulate; switching the same timer to absolute deadlines holds 0.5ms. Absolute deadlines also tame the pathological macOS-VM timer for free (~1.6ms total drift over the whole run, never accumulating, zero extra CPU). This is exactly what ThreadPacer does on those platforms — converting macOS/Windows from "1ms beats + forfeit" to near-complete delivery, with the bounded catch-up window absorbing the remainder.

Harness + full results: https://github.com/ajcasagrande/timer-lab

Comment thread src/aiperf/timing/strategies/request_rate.py Outdated
Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/aiperf/timing/strategies/request_rate.py`:
- Around line 170-190: Update ThreadPacer’s worker loop, including _run and
sleep_until, so cancellation interrupts any active wait and stale deadlines
cannot signal _tick after the pacer is reused; preserve correct signaling for
the current deadline. Add a regression test that cancels/replaces a pending wait
and verifies the subsequent sleep_until does not return before its new deadline.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 07881d60-e783-48f3-b4cd-f03cfe846b28

📥 Commits

Reviewing files that changed from the base of the PR and between b2dfd54 and 3761023.

📒 Files selected for processing (4)
  • src/aiperf/timing/high_res_timer.py
  • src/aiperf/timing/strategies/request_rate.py
  • tests/unit/timing/strategies/test_request_rate.py
  • tests/unit/timing/test_high_res_timer.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/unit/timing/test_high_res_timer.py
  • src/aiperf/timing/high_res_timer.py

Comment thread src/aiperf/timing/strategies/request_rate.py
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unit/timing/test_high_res_timer.py`:
- Around line 63-65: Update the cleanup in the timing test around pacer.close()
and pacer._thread.join() so the blocking worker join runs outside the event-loop
thread via an awaitable async mechanism. After cleanup, assert that the worker
thread has terminated, while preserving the existing finally-based resource
cleanup.
- Around line 49-51: Update the cleanup block around first_sleep.cancel() to
assert that awaiting first_sleep raises asyncio.CancelledError rather than
suppressing it, ensuring the test fails if cancellation does not occur.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 565e6190-8c52-4a55-921f-8ec525cfc32b

📥 Commits

Reviewing files that changed from the base of the PR and between 3761023 and 5ca6d64.

📒 Files selected for processing (2)
  • src/aiperf/timing/high_res_timer.py
  • tests/unit/timing/test_high_res_timer.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/aiperf/timing/high_res_timer.py

Comment thread tests/unit/timing/test_high_res_timer.py
Comment thread tests/unit/timing/test_high_res_timer.py Outdated
ajcasagrande and others added 3 commits August 3, 2026 17:45
Address review feedback on the ThreadPacer tests:

- Require the cancelled sleep to actually raise CancelledError instead of
  suppressing it, so the test fails if cancellation never happens.
- Join the pacer worker via asyncio.to_thread so the blocking join runs off
  the event-loop thread, and assert the worker actually terminated.
- Document the generation-gating protocol in _run/_set_tick_if_current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com>
test_cancelled_sleep_does_not_wake_replacement_early failed on CI
(linux/3.12) with "pacer worker did not start waiting".

The autouse no_sleep fixture in tests/unit/conftest.py rewrites asyncio.sleep
to a bare event-loop yield, so the spin loop waiting for the worker thread
never released the GIL and never let that thread be scheduled. All 100
iterations completed in microseconds and the test failed. It passed on other
Python versions only by scheduling luck.

Wait for the worker, and for the elapsed deadline, in a worker thread via
asyncio.to_thread so a real time.sleep hands over the interpreter without
blocking the event loop. Deadlines widened to leave headroom on loaded runners.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Anthony Casagrande <acasagrande@nvidia.com>
@ajcasagrande ajcasagrande changed the title perf: high-resolution rate-loop pacing + bounded catch-up perf(timing): high-resolution rate-loop pacing for exact rate delivery Aug 4, 2026

@debermudez debermudez left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Overall assessment: High-quality, thoroughly-benchmarked performance work. The root cause (event-loop timer quantization + unconditional catch-up reset) is correctly identified and cleanly fixed, and the benchmark evidence — on-box A/B, hardware profiling, Poisson arrival fidelity, and the c4-144 session arc (3,569 → 4,330 → 5,000 req/s) — is exceptional. One correctness concern in the cleanup path and one coverage gap are worth addressing before merge.

Fix order:

  1. (Recommended) Shield the cleanup gather in _wait_for_pacer_or_rate_update — see inline comment.
  2. (Optional) Add a test for execute_phase cancellation during active pacing.

What's working well: ThreadPacer generation tracking for cancellation safety is elegant; test_cancelled_sleep_does_not_wake_replacement_early is a solid negative test; the short-sleep gate (sleep_duration <= RATE_RAMP_UPDATE_INTERVAL) correctly routes long sleeps to the existing asyncio.wait_for path, preserving mid-sleep rate-update behavior for ramps; the fallback chain and IS_LINUX usage are correct; the bounded catch-up window alone recovering ~10% under-delivery is a great isolated proof of the two independent mechanisms.

if not task.done():
task.cancel()
await asyncio.gather(pacer_task, rate_update_task, return_exceptions=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Finally-block gather not shielded from re-cancellation. When execute_phase is cancelled (benchmark abort, timeout) while inside _wait_for_pacer_or_rate_update, CancelledError is raised at asyncio.wait. In the common single-cancel case (Python 3.11), the cancel counter is consumed before the finally block's first await, so the gather runs safely. But with multiple concurrent Task.cancel() calls (Python 3.12 eager cancellation, or an external shutdown racing a first cancel), the gather itself can be interrupted before both tasks are awaited. This leaves _tick.wait() inside the pacer's sleep_until pending, producing "Task was destroyed but it is pending!" warnings and potentially leaving the tick event stale for a future sleep_until caller.

Minimal fix:

await asyncio.shield(
    asyncio.gather(pacer_task, rate_update_task, return_exceptions=True)
)

asyncio.shield lets the gather complete even if the outer task has a pending cancellation, then re-raises CancelledError after cleanup. A follow-up test cancelling execute_phase mid-sleep and asserting the pacer is closed without warnings would cement the guarantee.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in eda2b86 — the cleanup gather in _wait_for_pacer_or_rate_update is now wrapped in asyncio.shield, so a re-cancellation cannot interrupt the drain and orphan the pacer's in-flight sleep_until (stale tick event / "Task was destroyed but it is pending!").

Also added the follow-up test you suggested — test_execute_phase_cancelled_mid_pace_drains_pacer_and_closes cancels execute_phase mid-pace and asserts CancelledError propagates, the pacer sleep is drained rather than abandoned, and the pacer is closed. One caveat worth recording: the test cancels twice, but it does not by itself discriminate the shielded gather from the unshielded one. CPython's Task.__step clears _must_cancel when the pending exception is already CancelledError, so a synchronous double-cancel gets absorbed before the finally await; I verified this with a standalone probe across both variants. The test covers the cancellation path (your item 2); the shield is the invariant it protects.

tests/unit/timing is green: 1309 passed.

A second Task.cancel() on execute_phase (eager cancellation on 3.12, or an
external shutdown racing a first cancel) sets _must_cancel and interrupts the
finally-block gather in _wait_for_pacer_or_rate_update before both child tasks
are drained. That leaves the pacer's _tick.wait() pending -- 'Task was destroyed
but it is pending!' warnings, and a stale tick event for the next sleep_until
caller. Shield the gather so the drain completes regardless.

Adds a regression test that cancels execute_phase mid-pace (twice) and asserts
the pacer sleep is drained and the pacer closed.
The 1ms interval let setup overhead push the first deadline into the past, so
_wait_until_next_target took the sleep_duration <= 0 branch, the pacer was never
used, and the test deadlocked on pacer.started.wait(). Use a 50ms interval (well
inside RATE_RAMP_UPDATE_INTERVAL) and bound the start wait with wait_for.

Also trims the docstring: the repeat cancel documents intent, but CPython clears
_must_cancel when the pending exception is already CancelledError, so the test
does not by itself discriminate the shielded gather.
@ajcasagrande

Copy link
Copy Markdown
Contributor Author

Review addressed

Addressed 1 of 6 review threads.

Fixed:

  1. Shielded the finally-block gather in _wait_for_pacer_or_rate_update so a re-cancellation cannot interrupt the drain and orphan the pacer's in-flight sleep_until (commit eda2b86), plus the follow-up cancellation test (test fixup in 95513d2)

Skipped (5 threads):

  1. coderabbitai: "Median wake-error thresholds may flake on loaded CI runners" — already resolved (addressed in 4f3caae..3761023).
  2. dynamo-review-agent: "The high-res branch awaits only self._pacer.sleep_until()..." — already resolved; _wait_for_pacer_or_rate_update races the deadline against _rate_update_event as of 3761023.
  3. coderabbitai: "cancel-then-reuse safety" on ThreadPacer — already resolved; generation-guarded wakeup landed in 5ca6d64.
  4. coderabbitai: "Require cancellation to occur." — already resolved in bcaf60a.
  5. coderabbitai: "Keep cleanup awaitable and verify worker termination." — already resolved in bcaf60a.

Verification: tests/unit/timing — 1309 passed, 7 deselected.

Note on the new test: it cancels execute_phase twice, but it does not by itself discriminate the shielded gather from the unshielded one — CPython clears _must_cancel when the pending exception is already CancelledError, so a synchronous double-cancel is absorbed before the finally await. It covers the cancellation path; the shield is the invariant it protects.

🤖 Generated with Claude Code

@ajcasagrande
ajcasagrande requested a review from debermudez August 4, 2026 22:56

@FrankD412 FrankD412 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed against latest commit (95513d2). Three code issues and a documentation gap remain.

return False

pacer_task = asyncio.create_task(self._pacer.sleep_until(deadline_perf_s))
rate_update_task = asyncio.create_task(self._rate_update_event.wait())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two create_task allocations on every tick

rate_update_task is created unconditionally on every sleep, even though execute_phase already filters the is_set() case before entering here. At 5,000 req/s this is ~10,000 task allocations per second. A fast-path guard at the top of this method (or before creating rate_update_task) would drop that cost in the common case:

if self._rate_update_event.is_set():
    return True

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 966d5e6: added a fast-path is_set() check at the top of _wait_for_pacer_or_rate_update that returns True immediately when a rate update is already pending, avoiding both create_task calls and the asyncio.wait machinery in that case.

os.read(self._fd, 8) # drain the expiration count
self._tick.set()

async def sleep_until(self, deadline_perf_s: float) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Single-waiter contract is advisory, not enforced

The class docstring says "supports one waiter at a time" but nothing prevents a second coroutine from calling sleep_until concurrently. Both would share _tick and both would wake at the first deadline. Since TimerFdPacer is only ever used by the rate loop (a single coroutine), this is safe today, but an assert or a lock would make the invariant explicit and catch future misuse at the call site rather than silently at the wrong wakeup time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 1eb5c69: added an _active_waiter flag to TimerFdPacer (and ThreadPacer, which has the same contract) with an assert not self._active_waiter at the top of sleep_until and a try/finally to clear it. Concurrent mis-use now raises AssertionError instead of silently firing both waiters at the first deadline.

Waits on the condition rather than ``time.sleep`` so a cancellation or
a replacement deadline interrupts the current wait immediately.
"""
while True:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

while/else semantics aren't obvious to most Python readers

The outer while True with an inner while not self._closed: and else: return is correct but the Python while/else idiom (the else runs only when the condition becomes false, not on break) is obscure enough that it trips reviewers. A short comment on the else line — something like # _closed became True: exit the thread — would save the next reader from having to verify it by hand.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 1eb5c69: replaced else: return with if self._closed: return. The semantics are identical but the while/else idiom (else fires when the condition goes false, not on break) is non-obvious enough to trip reviewers, and the explicit guard makes the exit condition clear without needing to know the idiom.

@FrankD412

Copy link
Copy Markdown
Contributor

Documentation gap: docs/benchmark-modes/timing-modes-reference.md

The PR adds env var entries in docs/environment-variables.md but timing-modes-reference.md has no mention of the pacer, the catch-up window, or the two new env vars. A user hitting under-delivery at high QPS has nowhere to look: the reference doc for --request-rate reads as though the only timing knob is --arrival-pattern. At minimum the --request-rate section should note that AIPERF_TIMING_HIGH_RES_TIMER=false and AIPERF_TIMING_MAX_CATCHUP_SECONDS exist and why you would change them.

ajcasagrande and others added 4 commits August 5, 2026 00:46
At 5,000 req/s the old code allocated ~10,000 asyncio.Task objects per
second unconditionally. A fast-path `is_set()` check at the top of
`_wait_for_pacer_or_rate_update` returns immediately when a rate update
is already pending, avoiding both task allocations and the asyncio.wait
machinery in the common case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add an `_active_waiter` flag to both TimerFdPacer and ThreadPacer with
an assertion in `sleep_until`, surfacing mis-use (two concurrent waiters
sharing _tick / _generation) instead of silently misfiring.

Replace the `while/else` idiom in ThreadPacer._run with an explicit
`if self._closed: return` check. The semantics are identical but the
while/else pattern (else runs only when the condition becomes false,
not on break) is obscure enough that it trips code reviewers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
timing-modes-reference.md had no mention of the pacer, the catch-up
window, or the AIPERF_TIMING_HIGH_RES_TIMER / MAX_CATCHUP_SECONDS env
vars. Add a "High-Resolution Rate Pacing" section covering:
- platform selection (timerfd on Linux, ThreadPacer elsewhere)
- the two pacing env vars with their defaults and tuning guidance
- a step-by-step checklist for diagnosing under-delivery at high QPS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@copy-pr-bot

copy-pr-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@ajcasagrande

Copy link
Copy Markdown
Contributor Author

Docs addressed in f257731: added a 'High-Resolution Rate Pacing' section to timing-modes-reference.md covering timerfd/ThreadPacer platform selection, both env vars (AIPERF_TIMING_HIGH_RES_TIMER and AIPERF_TIMING_MAX_CATCHUP_SECONDS) with defaults and tuning guidance, and a step-by-step checklist for diagnosing under-delivery at high QPS.

@ajcasagrande

Copy link
Copy Markdown
Contributor Author

Review addressed

Addressed 4 of 4 review threads.

Fixed:

  1. Fast-path guard when _rate_update_event is already set: skip both create_task allocations and asyncio.wait in _wait_for_pacer_or_rate_update (commit 966d5e6)
  2. Enforce single-waiter contract on TimerFdPacer and ThreadPacer: _active_waiter flag + assert in sleep_until catches concurrent mis-use instead of silently misfiring (commit 1eb5c69)
  3. Replace while/else with explicit if self._closed: return in ThreadPacer._run for clarity (commit 1eb5c69)
  4. Document high-res pacing in timing-modes-reference.md: platform selection table, both pacing env vars with tuning guidance, and a QPS under-delivery checklist (commit f257731)

Skipped (0 threads): none

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants