[None][feat] Add duration-based execution to benchmark - #13385
[None][feat] Add duration-based execution to benchmark#13385weikuo0506 wants to merge 19 commits into
Conversation
Signed-off-by: Kuo Wei <weikuo@google.com>
|
/bot run |
📝 WalkthroughWalkthroughThis PR adds an optional Changes
Sequence DiagramsequenceDiagram
actor CLI
participant async_benchmark
participant LlmManager
participant worker as Worker Loop
participant inbox as Inbox Queue
CLI->>async_benchmark: Call with duration=N seconds
async_benchmark->>LlmManager: Create with duration=N
Note over CLI,LlmManager: Execution Phase
activate worker
worker->>worker: Initialize start_time=None
loop For each request
inbox->>worker: Receive request
worker->>worker: Set start_time on first request
worker->>worker: Calculate elapsed = now() - start_time
alt elapsed < duration
worker->>worker: Process request
else elapsed >= duration
worker->>worker: Log duration reached
worker->>inbox: Drain remaining requests
worker->>worker: Break loop
end
end
deactivate worker
Note over worker: Shutdown Phase
alt stop event is set
worker->>worker: Cancel remaining tasks
else stop event not set
worker->>worker: Wait for in-flight tasks
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/unittest/llmapi/test_bench_async.py (1)
17-17: Remove unused import.The
timemodule is imported but never used in this file.🧹 Proposed fix
import asyncio -import time from unittest.mock import MagicMock🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/unittest/llmapi/test_bench_async.py` at line 17, Remove the unused top-level import "time" from tests/unittest/llmapi/test_bench_async.py; locate the import statement at the top of the file and delete the line "import time" so the file no longer imports an unused module.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@tests/unittest/llmapi/test_bench_async.py`:
- Line 17: Remove the unused top-level import "time" from
tests/unittest/llmapi/test_bench_async.py; locate the import statement at the
top of the file and delete the line "import time" so the file no longer imports
an unused module.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 0a86d31f-8f27-450a-8a37-83b52e329f83
📒 Files selected for processing (5)
tensorrt_llm/bench/benchmark/__init__.pytensorrt_llm/bench/benchmark/low_latency.pytensorrt_llm/bench/benchmark/throughput.pytensorrt_llm/bench/benchmark/utils/asynchronous.pytests/unittest/llmapi/test_bench_async.py
Signed-off-by: Kuo Wei <weikuo@google.com>
|
/bot run |
|
@FrankD412 Can you help review this? |
Can do -- will handle it asap 🙂 |
|
|
||
| if self.duration is not None and self.start_time and time.perf_counter() - self.start_time >= self.duration: | ||
| logger.info("Duration reached. Stopping pulling requests.") | ||
| while not self._inbox.empty(): |
There was a problem hiding this comment.
Question here -- if the duration is over, do we want to accept new requests? Is this simply to make sure that we don't violate concurrency/keep the engine saturated? Couldn't we just set the stop event and break the loop?
There was a problem hiding this comment.
Hi @FrankD412, thanks for the review! The reason we don't set self._stop.set() and just break here is to support the drain period for in-flight requests:
- If we set
self._stop.set(), thefinallyblock will detect it and proactively cancel all remaining active tasks (for task in self._tasks: task.cancel()). This would interrupt requests that are currently being processed and cause us to lose their performance statistics. - The reason we clear
_inboxis thatasync_benchmarkrelies onbackend.busyreturningFalseto determine when it can wrap up. Since the dataset submission process eagerly enqueues all requests into the_inboxat the start, the inbox is full. If we break without clearing the inbox,backend.busywill stayTrue, causing the benchmark to hang indefinitely. Clearing the inbox allows us to skip all upcoming requests while letting the in-flight ones run to completion and be recorded.
There was a problem hiding this comment.
Ok — yeah, looking over this it makes sense. For some reason it wasn’t clicking on my last review but that makes sense!
There was a problem hiding this comment.
In this while loop body, all calls are synchronous, so it will fully drain all requests from the inbox and dispatch them until the queue is empty. Only then does the first scheduling point occur, at which time the first request gets scheduled and self.start_time is set in the _process_single_request function.
If the above logic is correct, then regardless of how duration is configured, all requests will still be dispatched in full (i.e., the control logic runs before the actual behavior, resulting in a priority inversion).
Given that dispatched requests are not actively killed at present, those requests will eventually complete as in-flight requests. Therefore, duration cannot effectively control the benchmark run time.
There was a problem hiding this comment.
Hi @YihuiLu512, thanks for catching this — you're right, and the analysis is exactly on point.
Since the dispatch path in the worker loop has no await point (get_nowait() and create_task() are both synchronous), the entire inbox is dispatched into tasks in one synchronous burst before the first request coroutine ever runs. start_time is therefore still None throughout dispatch, so the duration check could never fire, and the finally block then waited for every dispatched task to complete — --duration had no effect on runtime.
I've pushed a fix that enforces the deadline at execution time instead:
- Each request coroutine now checks the deadline after acquiring its concurrency slot and returns without submitting (and without emitting a perf item) if the duration has elapsed. In-flight requests still drain to completion, preserving the statistics semantics discussed earlier in this thread with @FrankD412. The multi-turn path also stops issuing further turns once the deadline passes.
- The worker-side check you quoted is kept, with a comment documenting precisely why it cannot fire during dispatch and what it does do: it lets the worker exit its idle loop cleanly after the deadline, and the inbox drain defensively guarantees
busycan reachFalse(preventing a benchmark hang if request submission ever becomes paced). - Known limitation: with unbounded concurrency (
--concurrencyunset), all requests are submitted to the engine immediately and no client-side checkpoint remains — enforcing duration there would require aborting engine-side requests, which also raises a stats-censoring question (requests still running at the deadline skew long). I've added a warning log for this combination and propose handling engine-side abort in a follow-up issue to keep this PR's scope contained. - Tests: the previous unit test passed only due to a timing artifact (it sampled
outbox.qsize()while the third request was still in flight). It now awaits full worker drain, and I've added a regression test for exactly the eager-dispatch scenario you described (20 requests, concurrency=2, duration=1s → ~10 processed, wall time bounded by duration plus one drain).
Please take another look when you get a chance. Thanks again for the careful review!
There was a problem hiding this comment.
@weikuo0506 , thank you for the quick turnaround!
A quick comment, while waiting for @YihuiLu512 's reply.
Given the known limitation you mentioned, the test_async_benchmark_duration also doesn't provide --concurrency setting, and expecting only 2 of 3 requests to be processed. I'm not sure if the test will pass.
There was a problem hiding this comment.
Hi @karljang, good catch — thank you!
You're right that test_async_benchmark_duration runs without a concurrency setting. Digging into it: without a concurrency limit, all three mock requests (0.6s each) start immediately and finish at ~t=0.6s, before the 1s deadline — so the == 2 assertion couldn't hold. This was an oversight in the test when I first wrote it (its sibling tests test_llm_manager_duration and test_llm_manager_duration_not_exceeded both correctly pass concurrency=1, and its own comment — "only 2 should complete within 1s limit" — assumes serialized execution). The flaw predates yesterday's enforcement fix: with unbounded concurrency, every request starts before the deadline under both the old and new code.
I've pushed a commit that passes concurrency=1 to async_benchmark in that test, with a comment explaining why the limit is required for duration to bound the run (matching the warning now emitted by LlmManager for the unbounded case).
There was a problem hiding this comment.
@YihuiLu512 , could you please take a look at the updated code? Thank you!
Signed-off-by: Kuo Wei <weikuo@google.com>
…async_benchmark Signed-off-by: Kuo Wei <weikuo@google.com>
Signed-off-by: Kuo Wei <weikuo@google.com>
|
Hi @dc3671 , can you help to review? Thanks |
|
/bot run --disable-fail-fast |
|
PR_Github #53742 [ run ] triggered by Bot. Commit: |
|
PR_Github #53742 [ run ] completed with state
|
|
/bot run --disable-fail-fast |
|
PR_Github #54496 [ run ] triggered by Bot. Commit: |
|
/bot kill |
|
@weikuo0506, |
|
PR_Github #54501 [ kill ] triggered by Bot. Commit: |
|
PR_Github #54496 [ run ] completed with state |
|
PR_Github #54501 [ kill ] completed with state |
Signed-off-by: Kuo Wei <weikuo@google.com>
The worker dispatches the entire inbox in one synchronous burst (no await point on the dispatch path), so start_time is still None when the duration check runs and every request is dispatched and completed regardless of the configured duration. Enforce the deadline in the request coroutines instead, after a concurrency slot is acquired: requests past the deadline are skipped while in-flight requests drain to completion, keeping recorded statistics unbiased. The multi-turn path stops issuing further turns past the deadline. Warn when --duration is combined with unbounded concurrency, where no client-side checkpoint exists to enforce it. Rework the unit test to await full worker drain (it previously passed only due to a timing artifact) and add a regression test for the eager-dispatch scenario raised in review. Signed-off-by: Kuo Wei <weikuo@google.com>
70d1283 to
10a197e
Compare
The test asserts that 2 of 3 requests (0.6s each) complete within a 1s duration, which requires requests to run back-to-back. Without a concurrency limit all three start immediately and finish before the deadline, so the assertion could not hold: duration cannot bound an unbounded-concurrency run, as there is no client-side checkpoint after dispatch. Pass concurrency=1, matching the test's sibling LlmManager duration tests and its own stated intent. Signed-off-by: Kuo Wei <weikuo@google.com>
10a197e to
acd262b
Compare
| duration=duration, | ||
| ) | ||
|
|
||
| req = InferenceRequest(input_ids=[1, 2, 3], output_tokens=10) |
There was a problem hiding this comment.
InferenceRequest’s task_id: int is still required and has no default value at head 1dd8b9 (tensorrt_llm/bench/dataclasses/general.py:22). Can the current constructor still run successfully?
There was a problem hiding this comment.
same problem for: L62/L134/L192/L241
There was a problem hiding this comment.
Confirmed and fixed — InferenceRequest.task_id is required with no default, so all four constructions (L62/L134/L192/L241) raised ValidationError and no test in the file could run. Each now passes a task_id; the three requests in test_async_benchmark_duration get distinct ids since they model distinct dataset entries.
| # 1s deadline and is skipped. Without a concurrency limit all three would | ||
| # start immediately and finish within 0.6s, before the deadline — duration | ||
| # cannot bound an unbounded-concurrency run (see LlmManager warning). | ||
| assert len(stats.requests) == 2 |
There was a problem hiding this comment.
This assertion seems impossible to satisfy. stats is a dictionary deduplicated by request_id, and here request_id is always 1 (L231). Therefore, both items have request_id == 1, so they both hit key 1, leaving only one key in the dictionary. As a result, len(stats.requests) == 1, while the assertion expects == 2, so the test will inevitably fail.
There was a problem hiding this comment.
Does this test pass on your local machine?
There was a problem hiding this comment.
Confirmed on both counts.
The assertion: StatsKeeper.requests is keyed by request_id, which comes from response.id, and the mock returned a single shared object with id = 1. Both completed requests therefore collapsed into one RequestRecord — which not only made len(stats.requests) == 2 unsatisfiable but merged their events, skewing the recorded statistics. generate_async now returns a fresh output with a distinct id per call. (Worth noting num_complete increments without dedup, so it would have passed either way — len(stats.requests) was the only assertion that could catch this.)
"Does this test pass on your local machine?" — it did not, and I had not run it. I've since set up an environment that can (tensorrt-llm==1.3.0rc21 matching the branch, CPU-only — the tests mock LLM entirely and need no GPU) and confirmed the before/after:
before: 4 failed in 17.88s
after: 4 passed in 22.83s (stable across 3 consecutive runs)
Running it also surfaced a third defect neither of us had caught: from tensorrt_llm._tensorrt_engine import LLM — that module was removed in #15918 and reached this branch through a main merge, so the file failed at collection. It now imports LLM from tensorrt_llm, matching the other tests in this directory.
Root cause: tests/unittest/llmapi/test_bench_async.py is not referenced by any list under tests/integration/test_lists/, so CI has never collected it — which is how all three defects survived. I've registered it in l0_cpu_x86.yml and l0_cpu_arm.yml (both system_gpu_count: 0). I also replaced a fixed 1.5s sleep with awaiting the perf items, and widened the margin on the eager-dispatch wall-clock bound, since these now run in pre-merge. Happy to move it to a different stage if you'd prefer.
There was a problem hiding this comment.
No further issues are apparent in the functional logic.
However, the test is unsound and is not included in the CI test list. In light of the ongoing code normalization effort (/cc @QiJune ), please evaluate whether to remove the invalid test or to correct it.
This file is not referenced by any test list, so CI has never collected it and three defects went unnoticed since it was added: - tensorrt_llm._tensorrt_engine was removed in NVIDIA#15918 and reached this branch through a main merge, so the module import failed at collection time and no test in the file could run. - InferenceRequest.task_id is required with no default, so every request construction raised ValidationError. - StatsKeeper keys its records by request id while the mock returned one shared id, so both completed requests collapsed into a single record. That merges their events and skews the recorded statistics, and it made the len(stats.requests) == 2 assertion unsatisfiable. Also harden two timing-sensitive waits. The duration-not-exceeded test slept a fixed 1.5s for work that takes 1.2s; it now awaits the perf items themselves, which is both faster and immune to scheduling delay. The eager-dispatch test now issues enough requests that an unenforced run would take ~5s against a ~1s enforced run, so the wall-clock bound has a wide margin in both directions instead of 0.3s. Signed-off-by: Kuo Wei <weikuo@google.com>
CI collects unit tests only through the lists under tests/integration/test_lists/test-db, so this file has never run since it was added and its failures went unnoticed. Register it in the two GPU-free pre-merge stages: the tests mock LLM entirely and need no device. Signed-off-by: Kuo Wei <weikuo@google.com>
|
@karljang @YihuiLu512 — pushed fixes for both of YihuiLu512's findings, plus a third defect that running the tests surfaced ( One change worth flagging: this test file was not in any test list, so CI has never collected it — that's how the defects survived. I registered it in |
# Conflicts: # tests/integration/test_lists/test-db/l0_cpu_x86.yml
| ) | ||
| for task in self._tasks: | ||
| task.cancel() | ||
| else: |
There was a problem hiding this comment.
When _raise_for_failed_tasks raises a ValueError, self._stop is not set (stop is only set by backend.stop() during normal shutdown). This causes execution to enter the else path, skip cancellation, and wait for all tasks to finish. As a result, runtime on failure may be longer than the pre-change baseline.
There was a problem hiding this comment.
Good catch — this was a real regression. self._stop is only set by an explicit shutdown, so the failure path took the drain branch and the worker waited for every outstanding request before surfacing the error. Draining is only correct on duration expiry, where the point is to record the in-flight requests' statistics; on failure the run is aborting anyway.
Fixed by tracking the duration-triggered exit explicitly and cancelling in every other case, so a stop signal and a failure both behave as they did before duration support was added. Also added a regression test for the failure path, which had no coverage — verified it fails against the previous code (worker blocks on the hung requests until the test times out) and passes with the fix.
| ) | ||
| @optgroup.option( | ||
| "--duration", | ||
| type=int, |
There was a problem hiding this comment.
0 and negative values are accepted. With duration=0, _duration_exceeded() becomes true immediately after start_time is first set for the initial request, effectively skipping almost all requests. Please evaluate whether this aligns with the intended design.
There was a problem hiding this comment.
Agreed — --duration 0 silently produced a run that skips nearly everything while looking like it worked, which is worse than an error.
I considered treating 0 as "no limit" for consistency with --num-requests, but "run for zero seconds" has no useful meaning the way "cap at the dataset length" does, so a silent reinterpretation seemed more surprising than a rejection. The option is now click.IntRange(min=1) on both throughput and latency, which also rejects negatives and reports the mistake at the command line. Leaving it unset still means no limit.
YihuiLu512
left a comment
There was a problem hiding this comment.
LGTM
Please address the remaining corner cases as appropriate.
Draining in-flight requests instead of cancelling them is only correct when the duration limit is reached, where the point is to record their statistics. The check used self._stop, which is only set by an explicit shutdown, so a request failure took the drain path too: the worker waited for every outstanding request before surfacing the error, making a failing run slower than it was before duration support was added. Track the duration-triggered exit explicitly and cancel in every other case, restoring the original abort behaviour on failure while keeping the drain semantics on expiry. Add a regression test covering the failure path, which previously had no coverage. Signed-off-by: Kuo Wei <weikuo@google.com>
--duration accepted 0 and negative values. With 0 the deadline is already past once start_time is set for the first request, so every subsequent request is skipped and the benchmark produces almost no data while appearing to run normally. Constrain the option to positive integers so the mistake is reported at the command line instead. Leaving it unset still means no limit. Signed-off-by: Kuo Wei <weikuo@google.com>
|
Thanks @YihuiLu512 — both corner cases are pushed: the failure path now cancels in-flight requests (with a regression test), and On your earlier point about the test not being in the CI list, I corrected it rather than removing it and registered it in |
| task.cancel() | ||
| logger.debug("Waiting for requests...") | ||
| if self._tasks: | ||
| await asyncio.wait(self._tasks) |
There was a problem hiding this comment.
@weikuo0506 , thank you for addressing the review comments!
I believe this comment might be the last one :)
There may still be a failure-after-duration corner case. If the duration expires first, drain_in_flight becomes True and the finally block waits for every task. If an in-flight request then fails while another remains slow or hung, the failure is not checked until all tasks finish.
Could the drain path use asyncio.wait(..., return_when=asyncio.FIRST_EXCEPTION) and cancel the pending tasks when any completed task has an exception? If all tasks succeed, it would retain the intended duration-drain behavior. A regression test where duration expires before one request fails and another hangs would cover this ordering.
Thank you.
Description
This PR adds a duration-based execution feature to the TensorRT-LLM Python benchmark suite (
trtllm-bench). Currently, benchmarks rely on a fixednum_requests, which can lead to excessively long run times for scenarios with high Input Sequence Length (ISL) and Output Sequence Length (OSL).The new
--durationoption (in seconds) allows users to limit the benchmark run time. The implementation uses a wall-clock check in the worker loop to stop pulling new requests after the duration has elapsed, while allowing in-flight requests to drain to ensure valid statistics.This affects both
throughputandlatencycommands as they share the same execution logic.Fixes #13487
Potential Impacts
Test Coverage
test_bench_async.pyto verifyLlmManagerduration logic using mocks.torch,pytest) in the host environment, but the test is designed to be run in the standard container.PR Checklist
[✓] PR description clearly explains what and why.
[✓] PR Follows TRT-LLM CODING GUIDELINES.
[✓] Test cases are provided for new code paths.