Repository navigation
fix(vllm): add env-gated CPython GC freeze policy for FPM self-benchmark #12177
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
136fca2
fix(vllm): add env-gated CPython GC freeze policy for FPM self-benchmark
liyuanzhe1991 9f4c1cc
fix(vllm): harden GC policy per review
liyuanzhe1991 5ad1f16
test: reload gc_policy with the autostart disabled in the test fixture
liyuanzhe1991 826ee21
fix(vllm): stop the FPM GC policy in every model worker after the ben…
liyuanzhe1991 73bae73
fix(vllm): restore vLLM's serving GC freeze and stop workers on every…
liyuanzhe1991 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """GC pause mitigation for FPM self-benchmarking. | ||
|
|
||
| CPython gen2 (full) collections walk every tracked object and trigger on | ||
| the 25% long-lived-growth rule, so their pauses grow with the heap: over a | ||
| 30k-point benchmark sweep we measured pauses rising from ~0.4s to ~9s at | ||
| geometrically spaced iteration indices (ratio ~1.23), inside the vLLM | ||
| worker processes. Under TP/DP lockstep a single worker's pause stalls the | ||
| whole forward pass, and with the scheduler's host-side wall_time it lands | ||
| in the measured latency as a 3-200x outlier. | ||
|
|
||
| ``gc.freeze()`` moves currently tracked objects into the permanent | ||
| generation, excluding them from future collections: after a periodic | ||
| freeze, gen2 only walks objects allocated since the previous tick, so the | ||
| pause is bounded by the allocation rate instead of total heap size. | ||
| The policy's only goal is keeping GC pauses out of measured wall_time: | ||
| automatic gen2 collections are disabled entirely and the periodic tick | ||
| only freezes (no traversal, no pause). Cyclic garbage is therefore NOT | ||
| reclaimed while the policy is active - acceptable for benchmark sweeps; | ||
| long-running processes can reclaim it by calling :func:`gc_maintain` | ||
| from an untimed window. | ||
|
|
||
| The policy is env-gated and off by default: | ||
|
|
||
| DYN_FPM_GC_POLICY=freeze enable the periodic freeze loop | ||
| DYN_FPM_GC_FREEZE_INTERVAL_S=60 tick interval in seconds | ||
|
|
||
| Coverage: | ||
| * worker processes - pass ``--worker-extension-cls | ||
| dynamo.vllm.gc_policy.FpmGcWorkerExtension``; resolving the class | ||
| imports this module inside every worker, which auto-starts the | ||
| policy (no collective_rpc required). The RPC methods remain | ||
| available for explicit control. | ||
| * engine-core process - ``InstrumentedScheduler`` imports this module, | ||
| calls :func:`start_gc_policy` when benchmarking starts, and | ||
| :func:`stop_gc_policy` when it deactivates. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import gc | ||
| import logging | ||
| import math | ||
| import os | ||
| import threading | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| _lock = threading.Lock() | ||
| _started = False | ||
| _stop_event: threading.Event | None = None | ||
| _freeze_thread: threading.Thread | None = None | ||
| _saved_gen2_threshold: int | None = None | ||
|
|
||
|
|
||
| def _policy() -> str: | ||
| return os.environ.get("DYN_FPM_GC_POLICY", "").strip().lower() | ||
|
|
||
|
|
||
| def _interval_seconds() -> float: | ||
| raw = os.environ.get("DYN_FPM_GC_FREEZE_INTERVAL_S", "60") | ||
| try: | ||
| value = float(raw) | ||
| except ValueError: | ||
| logger.warning("invalid DYN_FPM_GC_FREEZE_INTERVAL_S=%r, using 60", raw) | ||
| return 60.0 | ||
| if not math.isfinite(value): | ||
| logger.warning("invalid DYN_FPM_GC_FREEZE_INTERVAL_S=%r, using 60", raw) | ||
| return 60.0 | ||
| return max(1.0, value) | ||
|
|
||
|
|
||
| def gc_maintain() -> int: | ||
| """Reclaim cyclic garbage from an untimed window, then re-freeze. | ||
|
|
||
| Unfreeze first: the periodic ticks freeze unreachable-but-uncollected | ||
| cycles into the permanent generation, which ``gc.collect()`` never | ||
| scans. The full-heap walk this costs is why callers must be outside | ||
| any measured window. | ||
| """ | ||
| # Serialize with the periodic tick: a freeze fired between unfreeze() | ||
| # and collect() would re-freeze the garbage this call is meant to | ||
| # reclaim, silently turning the maintenance pass into a no-op. | ||
| with _lock: | ||
| gc.unfreeze() | ||
| gc.collect() | ||
| gc.freeze() | ||
| return gc.get_freeze_count() | ||
|
|
||
|
|
||
| def _freeze_loop(interval: float, stop: threading.Event) -> None: | ||
| while not stop.wait(interval): | ||
| try: | ||
| # freeze only: O(1) generation-list splice, no heap traversal, | ||
| # no pause. Never collect on the timer (a periodic collect walks | ||
| # the unfrozen heap) and never call gc.get_freeze_count() here | ||
| # (it walks the whole permanent generation: measured 0.4-4s | ||
| # pauses once millions of objects are frozen). Serialized with | ||
| # gc_maintain() through _lock. | ||
| with _lock: | ||
| gc.freeze() | ||
| except Exception: # pragma: no cover - defensive | ||
| logger.exception("fpm gc tick failed; freeze loop exiting") | ||
| raise | ||
|
|
||
|
|
||
| def start_gc_policy() -> bool: | ||
| """Idempotently start the env-gated freeze loop in this process.""" | ||
| global _started, _stop_event, _freeze_thread, _saved_gen2_threshold | ||
| if _policy() != "freeze": | ||
| return False | ||
| with _lock: | ||
| if _started: | ||
| return True | ||
| # One-time setup. Automatic full (gen2) collections are the pause | ||
| # source: they walk every tracked object and grow with the heap | ||
| # (measured 0.4s -> 9s over a sweep). Disable them outright; gen0/1 | ||
| # stay enabled and only walk objects younger than the last freeze. | ||
| t0, t1, _saved_gen2_threshold = gc.get_threshold() | ||
| gc.set_threshold(t0, t1, 1 << 30) | ||
| gc.freeze() | ||
| interval = _interval_seconds() | ||
| _stop_event = threading.Event() | ||
| _freeze_thread = threading.Thread( | ||
| target=_freeze_loop, | ||
| args=(interval, _stop_event), | ||
| name="fpm-gc-freeze", | ||
| daemon=True, | ||
| ) | ||
| _freeze_thread.start() | ||
| _started = True | ||
| logger.info( | ||
| "FPM GC policy active: auto-gen2 disabled, gc.freeze() every %.0fs (pid=%d)", | ||
| interval, | ||
| os.getpid(), | ||
| ) | ||
| return True | ||
|
|
||
|
|
||
| def stop_gc_policy() -> None: | ||
| """Restore the serving GC state once benchmarking ends. | ||
|
|
||
| Stops the freeze loop, re-enables automatic gen2 collections, reclaims | ||
| the benchmark-era garbage (including cycles the periodic ticks froze), | ||
| and then re-establishes vLLM's own serving freeze. vLLM deliberately | ||
| freezes the EngineCore startup heap and each GPU worker's post-warmup | ||
| heap (``vllm.utils.gc_utils.freeze_gc_heap``: collect every generation, | ||
| then ``gc.freeze()``) so gen2 never scans model weights, KV caches, or | ||
| CUDA graphs during inference; a bare ``gc.unfreeze()`` here would strip | ||
| that baseline and leave the process paying larger gen2 pauses than one | ||
| that never benchmarked. Idempotent; no-op if the policy never started. | ||
| """ | ||
| global _started, _stop_event, _freeze_thread, _saved_gen2_threshold | ||
| with _lock: | ||
| if not _started: | ||
| return | ||
| stop_event = _stop_event | ||
| thread = _freeze_thread | ||
| saved_gen2_threshold = _saved_gen2_threshold | ||
| if stop_event is not None: | ||
| stop_event.set() | ||
| if saved_gen2_threshold is not None: | ||
| t0, t1, _ = gc.get_threshold() | ||
| gc.set_threshold(t0, t1, saved_gen2_threshold) | ||
| _started = False | ||
| _stop_event = None | ||
| _freeze_thread = None | ||
| _saved_gen2_threshold = None | ||
| if thread is not None: | ||
| thread.join(timeout=5.0) | ||
| # Serialized like gc_maintain(): a late tick must not interleave with | ||
| # the unfreeze/collect/freeze sequence. | ||
| with _lock: | ||
| gc.unfreeze() | ||
| gc.collect() | ||
| # Mirror freeze_gc_heap(): a full collection has already promoted | ||
| # every survivor to the oldest generation, so freezing now pins the | ||
| # static serving heap vLLM intended to exclude from collection plus | ||
| # whatever benchmark-era objects are still alive at this point (a | ||
| # small superset of the baseline; anything in it that dies later is | ||
| # never reclaimed, which matches the behaviour of vLLM's own freeze | ||
| # for long-lived startup objects). | ||
| gc.freeze() | ||
| logger.info( | ||
| "FPM GC policy stopped: auto-gen2 restored, serving freeze " | ||
| "re-established (pid=%d)", | ||
| os.getpid(), | ||
| ) | ||
|
|
||
|
|
||
| class FpmGcWorkerExtension: | ||
| """vLLM worker extension exposing GC control inside worker processes.""" | ||
|
|
||
| def fpm_gc_start(self) -> bool: | ||
| return start_gc_policy() | ||
|
|
||
| def fpm_gc_stop(self) -> None: | ||
| return stop_gc_policy() | ||
|
tedzhouhk marked this conversation as resolved.
Resolved
|
||
|
|
||
| def fpm_gc_maintain(self) -> int: | ||
| return gc_maintain() | ||
|
|
||
|
|
||
| # Auto-start when a worker process imports this module while resolving | ||
| # worker_extension_cls (args.py injects the class path as a literal for the | ||
| # same reason: importing this module starts the policy in the importing | ||
| # process). The engine-core process instead imports lazily and starts from | ||
| # InstrumentedScheduler._bench_init, so serving without an active benchmark | ||
| # never runs the policy. No-op unless DYN_FPM_GC_POLICY is set. | ||
| start_gc_policy() | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.