Skip to content

fix(vllm): add env-gated CPython GC freeze policy for FPM self-benchmark - #12177

Merged
tedzhouhk merged 5 commits into
mainfrom
yuanli/fpm-gc-policy
Sep 3, 2026
Merged

tedzhouhk merged 5 commits into
mainfrom
yuanli/fpm-gc-policy

Conversation

@liyuanzhe1991

@liyuanzhe1991 liyuanzhe1991 commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Overview:

Single-sample self-benchmark wall_time measurements are intermittently poisoned by
3–200x outliers. Root cause: CPython gen2 (full) collections walk every tracked
object, so their pauses grow with the heap — measured 0.4s → 9s over a 30k-point
sweep — and under TP/DP lockstep a single process's pause stalls the whole forward
pass. Triggers follow CPython's 25% long-lived-growth rule, producing outliers at
geometrically spaced, run-independent iteration indices (ratio ≈ 1.23, identical
across nodes/days/topologies) at a fleet-constant ~0.08%/sample rate.

This PR adds an opt-in, benchmark-scoped GC policy that keeps those pauses out
of measured windows. It is env-gated and off by default — zero behavior change
unless explicitly enabled
, engine-core activation is benchmark-gated, and normal
GC is fully restored when the benchmark deactivates.

Details:

  • New dynamo/vllm/gc_policy.py: on activation, disable automatic gen2
    (gc.set_threshold(t0, t1, 1<<30)), gc.freeze() the engine-init heap once,
    then a daemon thread runs a bare gc.freeze() per tick (O(1) generation-list
    splice: no traversal, no pause). Refcounting and gen0/gen1 stay fully active, so
    only cyclic garbage defers; young-gen scans stay bounded by allocation rate
    instead of total heap size.
  • Lifecycle is symmetric: the engine-core process starts the policy from
    _bench_init only when a benchmark is configured, and _bench_deactivate calls
    stop_gc_policy() — stops the freeze loop, restores the saved gen2 threshold,
    and unfreezes + collects the deferred garbage. gc_maintain()
    (unfreeze → collect → freeze) is available for explicit reclamation from untimed
    windows in long runs.
  • Worker processes are covered via vLLM's worker_extension_cls
    (FpmGcWorkerExtension), auto-injected in benchmark mode when
    DYN_FPM_GC_POLICY is set and the user configured no extension; a conflicting
    extension raises rather than silently leaving workers unpoliced. No vLLM changes.
  • Two rejected variants are documented in the module docstring, both reproduce
    measurable stalls: periodic gc.collect() re-introduces growing full-heap walks
    (outlier rate 0.082% → 0.712%), and per-tick gc.get_freeze_count() walks the
    whole permanent generation (0.4–4s once millions of objects are frozen).

Validation (GLM-5.2-NVFP4, 4×B200 attention-DP=4, vLLM 0.25.1)

A/B on identical 30,480-measurement sweeps (same node, same GPUs, same point list;
only the GC policy toggled):

run outliers >3× coordinate median max pause
baseline 25 (0.082%) — geometric gen2 ladder 8,900 ms
GC policy on 2 (0.007%) — the eager first-touch pair, fixed by the warmup PR 1,131 ms

End-to-end: fresh single-sample collections with this policy reach median-of-5
database quality (LOO prefill 4.74%/max 125% vs reference 4.17%/129%; holdout
2.24%/max 12.2% vs 1.96%/13.5%) at ~1/3.5 the collection cost, with zero
curve-neighbor poisoned rows. No OOM across four full sweeps with gen2 disabled
(worker RSS ≈ +1–2 GB/h, decelerating); no change to the non-outlier latency
distribution.

Where should the reviewer start?

  • components/src/dynamo/vllm/gc_policy.py — the policy, including design
    rationale and rejected variants in the module docstring
  • components/src/dynamo/vllm/instrumented_scheduler.py — the two
    benchmark-gated hooks (_bench_init start, _bench_deactivate stop); 9 lines
    total
  • components/src/dynamo/vllm/args.py — benchmark-mode worker_extension_cls
    injection and collision handling
  • components/src/dynamo/vllm/tests/test_gc_policy.py — lifecycle, idempotency,
    threshold restore, and frozen-cycle reclamation tests

Related Issues

🚫 This PR is NOT linked to an issue:

  • Confirmed — no related issue

Open in Devin Review

Summary by CodeRabbit

  • New Features

    • Added an optional garbage-collection policy for self-benchmarking workflows.
    • When enabled with DYN_FPM_GC_POLICY=freeze, memory cleanup is managed during benchmark runs to reduce garbage-collection pauses and improve measurement consistency.
    • Cleanup is automatically started and stopped as benchmarking begins and ends.
    • Added maintenance support for reclaiming cyclic garbage between timed measurement windows.
  • Bug Fixes

    • Conflicting garbage-collection settings now produce a clear configuration error.

@liyuanzhe1991
liyuanzhe1991 requested review from a team as code owners July 26, 2026 05:48
@github-actions github-actions Bot added fix backend::vllm Relates to the vllm backend labels Jul 26, 2026

@devin-ai-integration devin-ai-integration 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.

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread components/src/dynamo/vllm/args.py Outdated
Comment thread components/src/dynamo/vllm/gc_policy.py
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds an environment-controlled FPM garbage-collection freeze policy, worker extension controls, benchmark lifecycle hooks, engine configuration validation, and tests covering startup, maintenance, shutdown, restart, and interval parsing.

Changes

FPM GC policy

Layer / File(s) Summary
GC policy and worker controls
components/src/dynamo/vllm/gc_policy.py, components/src/dynamo/vllm/tests/test_gc_policy.py
Adds configurable periodic freezing, maintenance and shutdown behavior, worker extension methods, import-time startup, and tests for lifecycle, reclamation, and interval parsing.
Engine extension configuration
components/src/dynamo/vllm/args.py
Injects the required worker extension when the GC policy is enabled and rejects conflicting extension configuration.
Benchmark lifecycle integration
components/src/dynamo/vllm/instrumented_scheduler.py
Starts the GC policy during benchmark initialization and stops it during benchmark deactivation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is specific, concise, and accurately summarizes the env-gated CPython GC freeze policy for vLLM FPM self-benchmark.
Description check ✅ Passed The description includes Overview, Details, reviewer start, and Related Issues sections, and it clearly marks the PR as not linked to an issue.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

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: 5

🤖 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 `@components/src/dynamo/vllm/args.py`:
- Around line 362-382: Update the DYN_FPM_GC_POLICY handling in the engine
configuration path to inject or validate FpmGcWorkerExtension only when the
environment value enables the policy’s freeze behavior. Treat values such as
“off” as disabled, preserving custom worker extensions and avoiding injection
when freeze is not enabled; keep the existing extension validation for enabled
freeze mode.

In `@components/src/dynamo/vllm/gc_policy.py`:
- Around line 85-95: The _freeze_loop function broadly catches and suppresses
gc.freeze() failures, allowing the process to continue with the policy disabled.
Catch only a specifically expected exception, or after logging an unexpected
failure re-raise it and transition the policy through its cleanup path so normal
GC thresholds are restored.
- Around line 61-68: Update _interval_seconds to reject non-finite parsed
values, including positive or negative infinity and NaN, alongside invalid
strings. Treat them like other invalid inputs by logging the existing warning
and returning the 60.0-second fallback; retain the existing minimum clamp for
finite values.
- Around line 171-177: Keep the import-time start_gc_policy() behavior in
components/src/dynamo/vllm/gc_policy.py:171-177 unchanged for worker-extension
loading. In InstrumentedScheduler._bench_init at
components/src/dynamo/vllm/instrumented_scheduler.py:1628-1630, start the
engine-core GC policy only after benchmark configuration validation and
successful initialization; ensure failures roll back any already-started policy.
Update the related cleanup or lifecycle logic at
components/src/dynamo/vllm/instrumented_scheduler.py:2834-2838 and adjust
expectations in components/src/dynamo/vllm/tests/test_gc_policy.py:18-25 to
verify startup does not persist after failed initialization.

In `@components/src/dynamo/vllm/tests/test_gc_policy.py`:
- Around line 46-57: Update test_stop_is_idempotent_and_allows_restart so the
complete GC policy start/stop and restart sequence is enclosed in a try/finally
block, with gc_policy.stop_gc_policy() in finally. Preserve the existing
assertions while ensuring cleanup runs after any assertion failure.
🪄 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: 658f3bb5-0fce-4ceb-9a79-34474affb880

📥 Commits

Reviewing files that changed from the base of the PR and between 769ee07 and b3db0aa.

📒 Files selected for processing (4)
  • components/src/dynamo/vllm/args.py
  • components/src/dynamo/vllm/gc_policy.py
  • components/src/dynamo/vllm/instrumented_scheduler.py
  • components/src/dynamo/vllm/tests/test_gc_policy.py

Comment thread components/src/dynamo/vllm/args.py Outdated
Comment thread components/src/dynamo/vllm/gc_policy.py
Comment thread components/src/dynamo/vllm/gc_policy.py Outdated
Comment thread components/src/dynamo/vllm/gc_policy.py
Comment thread components/src/dynamo/vllm/tests/test_gc_policy.py
@datadog-official

This comment has been minimized.

Comment thread components/src/dynamo/vllm/gc_policy.py Outdated
Comment thread components/src/dynamo/vllm/tests/test_gc_policy.py
@liyuanzhe1991
liyuanzhe1991 force-pushed the yuanli/fpm-gc-policy branch 3 times, most recently from e443d89 to 6129fd2 Compare August 4, 2026 00:41

@tedzhouhk tedzhouhk 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.

Blocking on post-benchmark GC equivalence for real serving. _bench_deactivate() restores only the engine-core process; workers auto-start the policy on extension import and no call site invokes fpm_gc_stop, so they continue serving with automatic gen2 collection disabled and the freeze daemon active. I reopened the existing worker-lifecycle thread with the required normal-completion and abort coverage.

Comment thread components/src/dynamo/vllm/gc_policy.py Outdated
Comment thread components/src/dynamo/vllm/worker_factory.py Outdated
Comment thread components/src/dynamo/vllm/tests/test_gc_policy.py Outdated
@tianhaox

tianhaox commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Question on the completion path: if _wait_and_load_benchmark itself raises (e.g. artifact validation failure), _stop_worker_gc_policy never runs. If that path is guaranteed to terminate the process, this is fine as-is; if any caller can survive it and proceed to serving, the stop belongs in a finally. Worth a one-line clarification either way.

liyuanzhe1991 and others added 5 commits September 3, 2026 11:03
CPython gen2 (full) collections walk every tracked object and trigger
on the 25% long-lived-growth rule, so their pauses grow with the heap:
measured 0.4s -> 9s over a 30k-point sweep, inside the engine
processes. Under TP/DP lockstep a single worker's pause stalls the
whole forward pass and lands in the measured wall_time as a 3-200x
outlier at geometrically spaced, run-independent iteration indices.

- New dynamo/vllm/gc_policy.py: disable automatic gen2, gc.freeze()
  the engine-init heap once, then a daemon thread runs a bare
  gc.freeze() per tick (O(1) splice, no traversal). gc_maintain()
  reclaims cycles from untimed windows (unfreeze -> collect -> freeze);
  stop_gc_policy() restores thresholds and reclaims the frozen heap.
- Env-gated and off by default (DYN_FPM_GC_POLICY=freeze): zero
  behavior change unless explicitly enabled.
- Workers are covered via vLLM's worker_extension_cls, auto-injected
  in benchmark mode when the env var is set and no extension is
  configured; the engine-core process starts the policy from
  _bench_init (benchmark-gated) and stops it on deactivate.

Split from the combined noise-elimination PR; the eager-shape warmup
fix lands separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: YZLi <yuanli@nvidia.com>
- Gate worker-extension injection on the normalized freeze value: any
  other DYN_FPM_GC_POLICY value neither injects an inert extension nor
  rejects a custom one (the policy itself only activates on 'freeze').
- Start the engine-core policy at the END of _bench_init so a config
  validation error never leaves the process with auto-gen2 disabled.
- Serialize gc_maintain() with the periodic tick through the module
  lock: a freeze fired between unfreeze() and collect() would re-freeze
  the garbage the maintenance pass is meant to reclaim.
- Reject non-finite freeze intervals (inf reaches Event.wait and kills
  the daemon thread with an overflow while gen2 stays disabled).
- Fail fast if a tick ever raises instead of looping silently, and
  always stop the policy in test cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: YZLi <yuanli@nvidia.com>
Reloading with DYN_FPM_GC_POLICY=freeze ran the module-tail autostart
twice when a freeze test executed in isolation, leaking the first
daemon thread and leaving gen2 disabled after stop. Reload with the
policy unset and let each test start it explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: YZLi <yuanli@nvidia.com>
…chmark

Workers auto-start the freeze policy when worker_extension_cls resolves,
but only the engine-core process restored GC on deactivate: workers kept
serving with automatic gen2 collection disabled and the freeze daemon
alive, so cyclic garbage was never reclaimed. The launcher now awaits
collective_rpc("fpm_gc_stop") right after the benchmark-wait completes
- both normal completion and abort funnel through that call site - so
serving resumes only once every worker is GC-equivalent to one that
never benchmarked. Lifecycle tests cover threshold/enabled/freeze-thread
state and frozen-cycle reclamation for both paths, plus the RPC wiring
(issued, gated off, and failure propagation).

Signed-off-by: YZLi <yuanli@nvidia.com>
… benchmark exit path

stop_gc_policy() unfroze the whole heap on the way out, which also
stripped the baseline freeze vLLM deliberately installs for serving
(freeze_gc_heap() after EngineCore startup and after each GPU worker's
warmup) so gen2 never scans model weights, KV caches, or CUDA graphs.
A post-benchmark process therefore paid larger gen2 pauses than one
that never benchmarked. Reclaim the benchmark garbage, then re-freeze
the surviving heap under the policy lock to re-establish that baseline.

The launcher's worker stop only ran when the benchmark wait returned;
_bench_abort publishes status=failed artifacts, so an aborted benchmark
raised during validation and skipped the RPC entirely. Route both call
sites through a wrapper that stops the workers on success and failure,
preserves the original error, fails closed when a success-path stop
fails, and time-boxes the RPC so a dead engine cannot hang the launcher
on the very path meant to surface the error.

Lifecycle coverage: a pre-frozen-heap restoration test that proves the
baseline object is back in the permanent generation (mutation-verified
against a stop without the re-freeze), a fresh-interpreter worker test
asserting thresholds, enabled state, dead freeze thread, restored
serving freeze, and reclaimable cycles, a real status=failed artifact
driving the production validation failure into the worker stop,
time-box tests for both paths, call-site guards on the prefill and
decode startup paths, and a stop-before-registration ordering test.

Signed-off-by: YZLi <yuanli@nvidia.com>
@liyuanzhe1991

Copy link
Copy Markdown
Contributor Author

@tianhaox Good catch — that path could survive into serving on the prefill worker. The stop now sits on both exit paths (see _await_benchmark_then_restore_workers); the original error still propagates.

@tedzhouhk tedzhouhk 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.

Re-reviewed at 73bae73. The benchmark lifecycle now restores the vLLM serving freeze, stops worker GC policy on both success and failure before model registration, preserves the primary error, and has process-level lifecycle coverage. Targeted GC-policy tests and current CI pass.

@tedzhouhk
tedzhouhk merged commit 65cb3a0 into main Sep 3, 2026
113 checks passed
@tedzhouhk
tedzhouhk deleted the yuanli/fpm-gc-policy branch September 3, 2026 23:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend::vllm Relates to the vllm backend fix size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants