Add vLLM offline backend with micro-batching support - #736
Conversation
fc01371 to
bbe2874
Compare
efa1d9e to
942fa2e
Compare
942fa2e to
5d2304d
Compare
|
Hi @maryamtahhan, the DCO check has failed. Please click on DCO in the Checks section for instructions on how to resolve this. |
664810c to
251eb67
Compare
|
@sjmonson @jaredoconnell this PR has been rebased and is green again |
dbutenhof
left a comment
There was a problem hiding this comment.
I think the code is mostly OK (except it should be updated to match the current backend init conventions). The documentation needs to be updated to 0.7 CLI conventions.
c581c3c to
5ee9091
Compare
dbutenhof
left a comment
There was a problem hiding this comment.
Some comments ... the ones around shutdown are actually from a Cursor AI review of your branch -- there's more, but I didn't copy everything.
Thank you @dbutenhof I will have a look |
e03b402 to
5c21919
Compare
Introduces VLLMOfflineBackend inheriting from VLLMPythonBackend, using vLLM's synchronous LLM engine for micro-batched inference. Wires RequestStateStats metrics into response timings. Includes registration test, docs, and backends.md update. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
Use llm.get_tokenizer() instead of llm.llm_engine.tokenizer.tokenizer which doesn't exist in the V1 engine. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
- Apply ruff format to offline.py - Fix _wire_vllm_metrics: only use token counts, not monotonic timestamps that produce garbage when mixed with wall-clock times - Apply mdformat to markdown docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
The vLLM LLM engine is now created lazily on the first resolve() call instead of during process_startup(). This avoids a double-startup that wastes resources reloading model weights and can cause CPU-affinity degradation when the engine is created in both the validation path and the worker process. Uses double-checked locking with asyncio.Lock to prevent concurrent engine creation when multiple requests arrive simultaneously. Adds _reset_cpu_affinity() to restore the full cgroup cpuset before engine creation, working around OpenMP/torch restricting CPU affinity in forked worker processes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
Map vLLM's monotonic RequestStateStats timestamps to wall-clock values so TTFT, ITL, and TPOT are reported for offline batches. Split _process_batch into _take_pending_batch + _run_generate to release the batch lock during LLM.generate(), and loop the deferred flush until all pending requests are drained. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
…n, call shutdown - Move _reset_cpu_affinity to common.py so both vllm_python backends can reuse it; add comment explaining cgroup v2/v1 fallback loop - Guard resolve() with _shutting_down check to reject requests during shutdown - Call llm.shutdown() before deleting the engine in process_shutdown Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
- Add _generate_lock to serialize LLM.generate() calls, preventing overlapping generates on the non-thread-safe sync LLM engine - Move _shutting_down check + enqueue under the same batch lock acquisition so shutdown cannot strand in-flight resolve() waiters - Set _shutting_down under lock in process_shutdown for atomicity - Remove unused _BatchedRequest fields (request, request_info, request_id) and the uuid import - Drop duplicate validate_vllm_config; inherited from parent Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
Covers engine laziness, lifecycle/shutdown, batch processing, deferred flush, generate-lock serialization, shutdown-guard, and metrics wiring with 31 test cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
Shutdown now waits for the deferred-flush task to complete and acquires _generate_lock before tearing down the engine, preventing a race where an in-flight LLM.generate() outlives _llm teardown. Add batch_timeout field (default 0.01s) to VLLMOfflineBackendArgs so partial batches accumulate for a configurable window before flushing; full batches still flush immediately. Add ## WRITTEN BY AI ## docstrings to all 34 test functions, replace the old cancellation test with test_shutdown_waits_for_inflight_generate, and add batch_timeout validation tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
… metrics Use throughput,max_concurrency=20 instead of constant,rate=3 in the offline backend guide example to better reflect the backend's throughput-oriented purpose. Replace all getattr calls in _wire_vllm_metrics with hasattr + direct attribute access per AGENTS.md coding standards. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
5c21919 to
69eceea
Compare
|
not ready for re-review... will add a comment when it is. Still doing some testing |
The benchmark framework calls process_startup/validate/shutdown in the parent process, then forks worker subprocesses that call process_startup again. Asyncio locks created on the parent event loop are unusable in the child, causing the worker to hang and produce 0 completed requests. Recreate all asyncio primitives (_batch_lock, _generate_lock, _engine_lock) and reset batch state in process_startup() so each worker gets fresh locks bound to its own event loop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
The vLLM engine cold-start (42s on CPU) was happening during the timed benchmark phase inside resolve(), causing SIGTERM to kill the EngineCore before any requests could complete. Record the creator PID in __init__ so validate() can detect forked workers and eagerly preload the engine before the timed phase. Clear _llm in process_startup so workers never inherit a stale engine handle from the parent process. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
Rewrite the Engine lifecycle section to clearly describe the three-stage flow: parent preflight (cheap check), worker preload (PID-based eager engine creation), and resolve() idempotent fallback. Mention both fork and spawn workers. Strengthen test_startup_recreates_locks: use `is not` per lock, assert batch state is reset, and acquire/release each new lock to prove they work on the current event loop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Maryam Tahhan <mtahhan@redhat.com>
|
Ready for Re-review |
| # Hide the inherited ``stream`` field -- offline is never streaming. | ||
| stream: Literal[False] = Field( # type: ignore[assignment] | ||
| default=False, | ||
| exclude=True, | ||
| description="Offline backend does not support streaming.", | ||
| ) |
There was a problem hiding this comment.
It would be cleaner to build a common base class for the in-process vLLM backends (possibly in common.py) and then add stream only to the asynchronous subclass rather than trying to hide it here. I guess I won't quite go to the extent of demanding a change here, since it is more work & touch surface and this will probably suffice ... but it would be cleaner. 😁
jaredoconnell
left a comment
There was a problem hiding this comment.
Looks pretty good. I have one comment.
| def _wire_vllm_metrics( | ||
| request_info: RequestInfo, | ||
| request_output: Any, | ||
| ) -> None: | ||
| """Populate iteration counts and timing from vLLM metrics. | ||
|
|
||
| Extracts token counts and, when available, maps vLLM's | ||
| monotonic-clock ``RequestStateStats`` timestamps to | ||
| wall-clock values anchored on the wall-clock | ||
| ``arrival_time`` that vLLM also provides. | ||
| """ | ||
| metrics = request_output.metrics if hasattr(request_output, "metrics") else None | ||
| num_gen = ( | ||
| metrics.num_generation_tokens | ||
| if metrics and hasattr(metrics, "num_generation_tokens") | ||
| else 0 | ||
| ) | ||
|
|
||
| if num_gen > 0: | ||
| request_info.timings.token_iterations = num_gen | ||
| elif request_output.outputs and request_output.outputs[0].token_ids is not None: | ||
| num_gen = len(request_output.outputs[0].token_ids) | ||
| request_info.timings.token_iterations = num_gen | ||
|
|
||
| if num_gen > 0: | ||
| request_info.timings.request_iterations = 1 | ||
|
|
||
| if metrics is None: | ||
| return | ||
|
|
||
| arrival = metrics.arrival_time if hasattr(metrics, "arrival_time") else 0.0 | ||
| scheduled = metrics.scheduled_ts if hasattr(metrics, "scheduled_ts") else 0.0 | ||
| queued = metrics.queued_ts if hasattr(metrics, "queued_ts") else 0.0 | ||
| mono_base = scheduled or queued | ||
| first_tok = ( | ||
| metrics.first_token_ts if hasattr(metrics, "first_token_ts") else 0.0 | ||
| ) | ||
| last_tok = metrics.last_token_ts if hasattr(metrics, "last_token_ts") else 0.0 |
There was a problem hiding this comment.
I don't like the repeated use of hasattr. Can you import from vllm import RequestOutput as RequestOutput, and use that to change the type from Any to vllm.RequestOutput? And then you can access the field directly, with the type RequestStateStats | None.
The only reason I could see this being necessary is if some versions of vLLM that you want to support have different fields here. But this kind of thing makes static type analysis fail, resulting in successful runs with blank metrics if vLLM's types change.
Add vLLM Offline Backend for Batch Processing
Adds
VLLMOfflineBackendfor batch-oriented inference using vLLM's synchronousLLMengine. Requests are queued and dispatched in configurable micro-batches viaLLM.generate(), removing per-request scheduling overhead. Ideal for throughput benchmarking.Summary
VLLMOfflineBackendextendingVLLMPythonBackendwith batch processingvalidate(): workers eagerly create the engine before the timed phase; the parent process never loads model weightsprocess_startup()so asyncio primitives work after forkRequestStateStatsfor TTFT/ITL/TPOT reportingcommon.pymodule for utilities used by both vLLM backendsArchitecture
VLLMOfflineBackendinherits fromVLLMPythonBackend, reusing chat template resolution, multimodal data handling, request formatting, and sampling parameter creation. Only the engine lifecycle and request processing are overridden.Key Design Decisions
_creator_pidis recorded in__init__;validate()detects forked/spawned workers (PID mismatch) and calls_ensure_engine()to preload the engine before the timed benchmark phase. The parent preflight skips engine creation entirely.resolve()still calls_ensure_engine()as an idempotent fallback.process_startup()recreates all asyncio locks, clears_llm, and resets batch state — locks from the parent's event loop are unusable in the child._ensure_engine()uses anasyncio.Lockso concurrent callers create only one engine._take_pending_batch()snapshots the queue under lock,_run_generate()runs lock-free so new requests can enqueue during generation._generate_lockserializesLLM.generate()calls (vLLM's sync LLM is not safe for overlapping generates)._shutting_downcheck and enqueue happen under the same_batch_lockacquisition. Shutdown waits for in-flight generates and acquires_generate_lockbefore teardown.batch_timeout(default 0.01s) controls how long partial batches wait before flushing; full batches bypass the delay._deferred_flushloops until_pending_batchis empty, preventing requests stuck when new ones arrive during generate.Files Changed
src/guidellm/backends/vllm_python/offline.pysrc/guidellm/backends/vllm_python/common.pyreset_cpu_affinity()utilitysrc/guidellm/backends/vllm_python/__init__.pyVLLMOfflineBackenddocs/guides/vllm-offline-backend.mddocs/guides/backends.mdtests/unit/backends/vllm_python/test_vllm_offline.pyUsage
Test Plan
Unit Tests (39 passing)
_ensure_enginecreates once, concurrent callers get single enginellm.shutdown(), drains pending batch, awaits deferred flush task, acquires_generate_lockbefore teardownis notand acquire/release),_llmcleared, batch state resetvalidate()skips engine in parent, preloads in worker (simulated PID mismatch),_creator_pidset in__init___take_pending_batchclears queue,_run_generatedistributes results, error signals all waiters, multimodal prompt format_run_generatecallsresolve()rejects during shutdown, flag set under locknum_generation_tokenswith fallback, timing fromRequestStateStats,queued_tsfallbackEC2 Integration Test (verified)
Benchmarked on EC2 with
facebook/opt-125m(CPU):RequestStateStatsVanilla vLLM Comparison (verified)
Ran 10 identical prompts through vanilla
vllm.LLM.generate()and GuideLLM's offline backend:Use of AI
git log
commit 1a923cf
Author: Maryam Tahhan mtahhan@redhat.com
Date: Fri Jul 17 14:30:31 2026 +0100
commit ba0eb27
Author: Maryam Tahhan mtahhan@redhat.com
Date: Fri Jul 17 15:33:26 2026 +0100
commit 6d6fe7a
Author: Maryam Tahhan mtahhan@redhat.com
Date: Fri Jul 17 15:41:25 2026 +0100
commit 7f31ab4
Author: Maryam Tahhan mtahhan@redhat.com
Date: Mon Jul 20 17:27:48 2026 +0100
commit b763a9a
Author: Maryam Tahhan mtahhan@redhat.com
Date: Tue Jul 21 12:57:05 2026 +0100
commit 638a724
Author: Maryam Tahhan mtahhan@redhat.com
Date: Tue Jul 21 14:31:04 2026 +0100
commit 7a552bb
Author: Maryam Tahhan mtahhan@redhat.com
Date: Tue Jul 21 15:55:21 2026 +0100
commit 459f961
Author: Maryam Tahhan mtahhan@redhat.com
Date: Tue Jul 21 16:11:06 2026 +0100
commit 7d684b2
Author: Maryam Tahhan mtahhan@redhat.com
Date: Tue Jul 21 16:26:23 2026 +0100
commit 69eceea
Author: Maryam Tahhan mtahhan@redhat.com
Date: Tue Jul 21 16:41:47 2026 +0100
commit dcecf87
Author: Maryam Tahhan mtahhan@redhat.com
Date: Tue Jul 21 17:33:45 2026 +0100
commit 9f97e6b
Author: Maryam Tahhan mtahhan@redhat.com
Date: Wed Jul 22 10:31:21 2026 +0100
commit 681c155
Author: Maryam Tahhan mtahhan@redhat.com
Date: Wed Jul 22 10:41:26 2026 +0100
Co-Authored-By: Claude Opus 4.6 noreply@anthropic.com
Signed-off-by: Maryam Tahhan mtahhan@redhat.com