Skip to content

[None][feat] Add per-rank perf time events capture (TRTLLM_PERF_TIME_EVENTS_PATH) - #16900

Open
chenfeiz0326 wants to merge 10 commits into
NVIDIA:mainfrom
chenfeiz0326:feat/perf-time-events
Open

chenfeiz0326 wants to merge 10 commits into
NVIDIA:mainfrom
chenfeiz0326:feat/perf-time-events

Conversation

@chenfeiz0326

@chenfeiz0326 chenfeiz0326 commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Adds a single master switch, TRTLLM_PERF_TIME_EVENTS_PATH (an output
directory, symmetric with TRTLLM_KVCACHE_TIME_OUTPUT_PATH), that turns on
per-request perf-timing capture and writes per-rank time-event files live from
inside the executor
— exactly mirroring how the disaggregation transceiver
already writes per-rank KV-transfer CSVs (_torch/disaggregation/native/perf_logger.py).

Today, capturing this timing over the HTTP /perf_metrics endpoint is fragile:
the benchmark client must remember --save-request-time-breakdown, the deque
only exists when perf_metrics_max_requests > 0, return_perf_metrics must be
set per worker, and the data must be pulled before the server tears down —
which for a disagg run means racing teardown across separate ctx/gen servers.
This PR replaces that with a live, per-rank, on-disk artifact and no HTTP scrape.

What the switch does

  1. Force-enables capture independent of return_perf_metrics, and turns on
    an extended capture mode (capture_extended). All new behavior is gated
    on capture_extended, so existing return_perf_metrics=True users with the
    env unset see no behavior or output change (regression guard: H3 below).
  2. Adds the previously-missing per-iteration batch context to each
    per-iteration metric dict: iter_batch_size, num_ctx_requests,
    num_gen_requests, context_token_number, generation_token_number,
    per-request req_context_token_number / req_generation_token_number, plus a
    per-iteration starvation count (num_capacity_fitting vs num_scheduled)
    — the signal that explains gen bubbles / scheduling stalls. These ride
    inside the existing time_breakdown_metrics payload, so there is no new
    payload shape and no create_response change
    .
  3. Writes {dir}/time_events_rank{N}_pid{P}.jsonl live, one JSONL line per
    finished request (rank+pid in the name avoids ctx-server/gen-server rank-0
    collisions on a shared FS).

Off the critical path (overhead requirement)

The executor loop only builds a dict and does a non-blocking
queue.Queue.put_nowait — no file I/O on the loop thread. A lazily-started
daemon writer thread drains the queue and does the actual
json.dumps + write + flush; PyExecutor.shutdown() drains and joins it.
queue.Full → warn + drop, never block. So the added on-loop cost is a dict
build + queue append, keeping the A/B overhead delta structurally near-zero.

Optional offline aggregator

python -m tensorrt_llm.serve.scripts.perf_time_events stitches the per-rank
files (+ the KV-transfer CSVs) into one combined JSON/HTML and computes derived
signals (inter-step / inter-chunk gaps, per-iteration starvation). It reuses
time_breakdown's RequestTimeBreakdown for HTML and is stdlib-only on the
parse/merge/JSON path (torch-free; plotly pulled in lazily only for --html),
so it stays under the GPU-free import contract. It is a convenience over the
per-rank files, not the load-bearing capture path.

Python only — no C++. The KV sub-events already exist in Python for the
target PYTHON-runtime (disagg NIXL) workload, so they are only ingested.

Flagged Python-only compromises

  • Category 1b (starvation) is a per-iteration COUNT, not per-request
    attribution
    — _schedule() returns only num_fitting_reqs in Python, so
    which specific requests were starved cannot be attributed without a C++ change.
  • _executor_loop_pp never calls save_timing_to_requests, so the extended
    fields are absent under pipeline parallelism (known gap, documented).
  • Per-rank files are per-server; disagg ctx↔gen correlation reuses the
    request's disagg ctx_request_id when available, else it is an aggregator-side
    join enhancement.

Test Coverage

GPU-free unit tests (both run on CPU; the executor-side one importorskips
torch, the aggregator one is pure stdlib):

  • tests/unittest/_torch/executor/test_perf_metrics_events.py
    • append_step_metrics merges the batch-context dict + per-request token
      counts for ctx and gen (incl. draft tokens), and the disabled-shape guard
      (capture_extended=False → none of the new keys, base shape untouched).
    • _compute_iter_batch_context against a fake ScheduledRequests.
    • The off-thread per-rank writer: env-forced, writes exactly one
      time_events_rank{N}_pid{P}.jsonl line with the right shape; no-ops when
      time_breakdown_metrics is absent (non-final response) and when disabled.
  • tests/unittest/others/test_perf_time_events.py — aggregator
    parse_event_dir / parse_kv_csv_dir (native + gen-summary + C++ headers),
    merge join-by-request-and-ctx-id, unjoined_kv_events + match-rate for an
    orphan rid, derived inter_step_gaps / starved, combined-JSON write.

Run:

python -m pytest tests/unittest/_torch/executor/test_perf_metrics_events.py \
  tests/unittest/others/test_perf_time_events.py \
  tests/unittest/others/test_time_breakdown.py -v

GPU end-to-end verification (gen_only ... gpt-oss-120b-fp4 ... dep2 ... NIXL
on OCI-HSG GB200; no --capture-nsys due to the documented nsys-2026 hang on
this exact case) is in progress and will be posted as a follow-up comment:
per-rank files written with the extended keys, an A/B overhead comparison
(env unset vs set), and the regression guard (env unset → no time_events_*
file and unchanged /perf_metrics keys).

PR Checklist

  • No API changes (no api-compatible / api-breaking label needed): the LLM API
    is untouched; only an env-gated, additive capture path.
  • New code paths are covered by the two GPU-free tests above.
  • Documentation updated: docs/source/developer-guide/perf-analysis.md gains a
    "Perf Time Events (per-rank live capture)" section (switch, JSONL record shape,
    the three known limitations, aggregator recipe; cross-references kv-transfer.md).

Summary

Adds opt-in live per-rank performance time-event capture controlled by TRTLLM_PERF_TIME_EVENTS_PATH.

  • Captures batch context, token counts, starvation counts, and lifecycle metrics.
  • Writes per-request JSONL events through bounded, non-blocking background writers.
  • Captures optional router and benchmark-client timelines.
  • Aggregates worker, router, client, and KV-transfer data.
  • Derives timing spans, gaps, starvation metrics, join match rates, and latency aggregates.
  • Updates serving paths to enable required performance metrics.
  • Adds capacity-admission timestamps for supported schedulers.
  • Adds documentation, GPU-free tests, torch-free tests, and CI registration.
  • Known limitations include pipeline-parallel extended-field gaps, per-iteration starvation attribution, per-server disaggregation files, and standalone client events without worker join keys.

Dev Engineer Review

  • The capture path is opt-in and uses TRTLLM_PERF_TIME_EVENTS_PATH.
  • Extended batch context and effective draft-token counting propagate through executor metrics.
  • Per-rank, router, and client writers use bounded asynchronous queues. Queue overflow does not block request execution.
  • Writer shutdown uses bounded teardown and serialization fallbacks.
  • Router joins require unique ctx_request_id values.
  • The aggregator handles malformed input, multiple KV CSV formats, unmatched records, derived spans, latency aggregates, and optional HTML output.
  • Serving paths enable performance metrics when time-event or KV-cache timing capture is configured.
  • Documentation describes configuration, output schemas, aggregation, joins, and limitations.
  • The A10 test-list entries use valid paths and are present without duplicate entries.

QA Engineer Review

Test changes

Added GPU-free and torch-free coverage in:

  • tests/unittest/_torch/executor/test_perf_metrics_events.py
    • Extended context and token-count capture.
    • Draft-token counting.
    • Admission timestamps.
    • Per-rank JSONL writing.
    • Request timing enrichment and error handling.
  • tests/unittest/others/test_perf_time_events.py
    • Event, KV, router, and client parsing.
    • Event merging and join behavior.
    • Duplicate and unmatched identifiers.
    • Derived timing metrics, aggregation statistics, and output serialization.
  • tests/unittest/others/test_perf_time_events_writer.py
    • Writer enablement, draining, serialization fallback, and idempotent shutdown.

CI coverage

tests/integration/test_lists/test-db/l0_a10.yml includes:

  • unittest/others/test_perf_time_events.py
  • unittest/others/test_perf_time_events_writer.py

All added test files have CI test-list coverage. Verdict: sufficient.

…EVENTS_PATH)

Add a single master switch, TRTLLM_PERF_TIME_EVENTS_PATH (an output
directory, symmetric with TRTLLM_KVCACHE_TIME_OUTPUT_PATH), that turns
on per-request perf-timing capture and writes per-rank time-event files
live from inside the executor -- mirroring how the disaggregation
transceiver already writes per-rank KV-transfer CSVs. No HTTP
/perf_metrics scrape, no server-teardown race, no per-worker
return_perf_metrics / --save-request-time-breakdown fiddling.

What the switch does:
  * Force-enables capture independent of return_perf_metrics and turns on
    an extended capture mode (capture_extended). All new behavior is
    gated on capture_extended, so existing return_perf_metrics=True users
    with the env unset see NO behavior or output change.
  * Adds the previously-missing per-iteration batch context to each
    per-iteration metric dict: iter_batch_size, num_ctx_requests,
    num_gen_requests, context_token_number, generation_token_number,
    per-request req_context_token_number / req_generation_token_number,
    plus a per-iteration starvation count (num_capacity_fitting vs
    num_scheduled). These ride inside the existing time_breakdown_metrics
    payload, so there is no new payload shape and no create_response
    change.
  * Writes {dir}/time_events_rank{N}_pid{P}.jsonl live, one JSONL line
    per finished request.

Off critical path by design (overhead requirement): the executor loop
only builds a dict and does a non-blocking queue.Queue.put_nowait; a
lazily-started daemon thread does the json.dumps + write + flush;
PyExecutor.shutdown() drains and joins it. queue.Full -> warn + drop,
never block.

An OPTIONAL offline aggregator
(python -m tensorrt_llm.serve.scripts.perf_time_events) stitches the
per-rank files together with the KV-transfer CSVs into one combined
JSON/HTML and computes derived signals (inter-step / inter-chunk gaps,
per-iteration starvation). It reuses time_breakdown's RequestTimeBreakdown
for HTML and is stdlib-only on the parse/merge/JSON path (torch-free;
plotly pulled in lazily only for --html), so it stays under the
GPU-free import contract.

Python only -- no C++. The KV sub-events already exist in Python for the
target PYTHON-runtime (disagg NIXL) workload, so they are only ingested.

Flagged Python-only compromises (see docs + PR):
  * Category 1b starvation is a per-iteration COUNT, not per-request
    attribution -- _schedule() returns only num_fitting_reqs in Python.
  * _executor_loop_pp never calls save_timing_to_requests, so the
    extended fields are absent under pipeline parallelism (known gap).
  * Per-rank files are per-server; disagg ctx<->gen correlation reuses
    the request's disagg ctx_request_id when available, else it is an
    aggregator-side join enhancement.

Tests (GPU-free):
  * tests/unittest/_torch/executor/test_perf_metrics_events.py --
    append_step_metrics merge + token counts (and the disabled-shape
    guard), _compute_iter_batch_context, and the off-thread per-rank
    writer (writes one line, no-ops when metrics absent / disabled).
  * tests/unittest/others/test_perf_time_events.py -- aggregator
    parse/merge/join/derive, stdlib-only.

Docs: docs/source/developer-guide/perf-analysis.md gains a
"Perf Time Events (per-rank live capture)" section documenting the
switch, the JSONL record shape, the three known limitations, and the
aggregator recipe.

Signed-off-by: Chenfei Zhang <chenfeiz@nvidia.com>
@coderabbitai

coderabbitai Bot commented Jul 27, 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

Adds opt-in per-rank, router, and client performance event capture, extended executor timing context, environment-based server activation, and an offline JSONL/KV aggregation CLI with tests and documentation.

Changes

Perf time-events workflow

Layer / File(s) Summary
Extended timing capture and JSONL writer
tensorrt_llm/_torch/pyexecutor/{llm_request.py,perf_metrics_manager.py}, tensorrt_llm/serve/perf_time_events_writer.py, tests/unittest/others/test_perf_time_events_writer.py
Adds iteration context, extended token metrics, bounded asynchronous JSONL writing, request timing enrichment, shutdown handling, and writer tests.
Executor context and response integration
tensorrt_llm/_torch/pyexecutor/{py_executor.py,scheduler/scheduler.py}, tests/unittest/_torch/executor/test_perf_metrics_events.py
Computes scheduler context in both executor loops, correlates disaggregated requests, writes completion events, and validates the extended behavior.
Server, router, and client timeline wiring
tensorrt_llm/serve/{openai_server.py,responses_utils.py,openai_disagg_*}, tensorrt_llm/serve/scripts/{backend_request_func.py,benchmark_serving.py}
Enables performance capture from server request paths and records router and benchmark-client timing events.
Offline merge CLI and documentation
tensorrt_llm/serve/scripts/perf_time_events/*, docs/source/developer-guide/perf-analysis.md, tests/unittest/others/test_perf_time_events.py
Adds event and CSV parsing, request joins, derived metrics, JSON/HTML output, CLI entry points, usage documentation, and aggregation tests.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Router
  participant OpenAIServer
  participant PyExecutor
  participant PerfTimeEventsMerger
  Client->>Router: send request and record client timestamps
  Router->>OpenAIServer: dispatch context and generation requests
  OpenAIServer->>PyExecutor: enable performance metrics
  PyExecutor->>OpenAIServer: return response with timing events
  PerfTimeEventsMerger->>PerfTimeEventsMerger: merge worker, router, client, and KV artifacts
  PerfTimeEventsMerger->>Client: write combined JSON or HTML timeline
Loading

Suggested reviewers: qijune

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.54% 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
Title check ✅ Passed The title clearly identifies the new per-rank perf time-events capture feature and follows the repository's required prefix format.
Description check ✅ Passed The description explains the feature, implementation, limitations, tests, documentation, and checklist items with sufficient detail.
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.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/perf-time-events
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

🧹 Nitpick comments (10)
tensorrt_llm/serve/scripts/perf_time_events/__init__.py (1)

26-33: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Sort __all__ to satisfy Ruff RUF022.

♻️ Suggested fix
 __all__ = [
     "PerfTimeEventsMerger",
-    "parse_event_dir",
-    "parse_kv_csv_dir",
     "main",
+    "parse_event_dir",
+    "parse_kv_csv_dir",
 ]
As per static analysis, Ruff flags "`__all__` is not sorted" (RUF022).
🤖 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 `@tensorrt_llm/serve/scripts/perf_time_events/__init__.py` around lines 26 -
33, Sort the entries in __all__ alphabetically to satisfy Ruff RUF022, while
preserving the existing exported symbols from the perf_time_events package.

Source: Linters/SAST tools

tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py (7)

1-1: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

CSV header contract with the KV-transfer producer is hand-duplicated, not imported — drift wouldn't be caught. Both the parser and its test independently hard-code _NATIVE_TASK_HEADER/_GEN_SUMMARY_HEADER, each claiming to mirror _torch/disaggregation/native/perf_logger.py, but neither imports from that module, so a real header change there would silently fall through to the generic/unknown-layout branch instead of failing a test.

  • tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py#L108-177: import the header constants from _torch/disaggregation/native/perf_logger.py (or a shared constants module) instead of re-declaring them here.
  • tests/unittest/others/test_perf_time_events.py#L1-213: assert against the same imported constants (or add a dedicated test that imports perf_logger.py's header and compares it to perf_time_events.py's copy) so an actual producer-side header change trips this suite.
🤖 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 `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py` at line 1,
Replace the locally duplicated _NATIVE_TASK_HEADER and _GEN_SUMMARY_HEADER
declarations in perf_time_events.py with imports from
_torch/disaggregation/native/perf_logger.py or a shared constants module. Update
test_perf_time_events.py to use those same producer-owned constants, ensuring
parser expectations and tests detect future header changes.

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

Add -> None to __init__.

As per coding guidelines, "Annotate every function, use None for non-returning functions."

🤖 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 `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py` around lines
205 - 209, Update the __init__ method signature to include an explicit -> None
return annotation, leaving its existing initialization logic unchanged.

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

Add -> None to main(). Otherwise the CLI wiring, error path (Line 383-384), and merge/write/html sequencing look correct.

As per coding guidelines, "Annotate every function, use None for non-returning functions."

🤖 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 `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py` around lines
347 - 390, Annotate the CLI entry-point function main with an explicit -> None
return type, preserving its existing argument parsing, validation, merge, and
output sequencing.

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

Missing return type annotation on _iter_jsonl.

Generator function lacks an annotation.

As per coding guidelines, "Annotate every function, use None for non-returning functions... prefer built-in generic types."

♻️ Suggested fix
-def _iter_jsonl(path: str):
+def _iter_jsonl(path: str) -> "Iterator[dict[str, Any]]":

(add from collections.abc import Iterator to imports)

🤖 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 `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py` around lines
61 - 71, The generator function _iter_jsonl lacks a return type annotation.
Import Iterator from collections.abc and annotate _iter_jsonl’s return type as
Iterator using the appropriate parsed JSON value type, while preserving its
existing behavior.

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

Use PEP 585 generics instead of typing.Dict/List/Optional.

This is a Python 3.10+ codebase; Dict[str, Any], List[...], Optional[...] are used throughout this file (parse_event_dir, parse_kv_csv_dir, PerfTimeEventsMerger fields/methods) where dict[str, Any], list[...], X | None are preferred.

As per coding guidelines, "Annotate every function... prefer built-in generic types and |."

♻️ Example (apply pattern file-wide)
-from typing import Any, Dict, List, Optional
+from typing import Any

Then replace Dict[K, V] → dict[K, V], List[X] → list[X], Optional[X] → X | None throughout the file.

🤖 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 `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py` around lines
30 - 36, Update type annotations throughout perf_time_events.py, including
parse_event_dir, parse_kv_csv_dir, and PerfTimeEventsMerger, to use PEP 585
built-in generics: replace Dict with dict, List with list, and Optional[T] with
T | None; remove the corresponding typing imports while preserving all
annotation semantics.

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

merge() reuses stale self.kv if called again without kv_csv_dir.

self.kv is only overwritten if kv_csv_dir: (line 228-229); a second merge() call on the same instance without kv_csv_dir will silently keep joining against the previous run's KV data. Not an issue for the current single-call CLI usage (main() calls merge() once), but worth a docstring note or an explicit reset if the class is intended to be reused across merges.

🤖 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 `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py` around lines
211 - 316, Update merge() so each invocation clears or reinitializes self.kv
when kv_csv_dir is not provided, preventing joins from using KV data loaded by a
previous call. Preserve loading parsed KV data when kv_csv_dir is supplied and
keep the existing task_events, gen_summary, and cpp_events processing unchanged.

329-344: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Factor out the timing-row build into RequestTimeBreakdown. write_html() duplicates the parse/duration loop and ties this path to parser/config.metrics; a small public helper would keep the --html flow on one contract and reduce drift.

🤖 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 `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py` around lines
329 - 344, Move the timing-row construction loop from write_html into a small
public helper on RequestTimeBreakdown that parses each record and adds all
configured metric durations. Update write_html to call that helper and pass its
result to create_timing_diagram, removing direct parser/config.metrics access
from write_html.
tests/unittest/others/test_perf_time_events.py (1)

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

Test coverage summary.

Added tests: test_globs_and_concatenates, test_skips_malformed_lines (parse_event_dir); test_native_task_and_gen_summary, test_cpp_send_recv_keyed_on_request_id (parse_kv_csv_dir); test_join_by_request_and_ctx_id, test_unjoined_kv_events_reported, test_derived_inter_step_gaps_and_starved, test_write_combined_json, test_merge_perf_json_input (PerfTimeEventsMerger).

This file lives under tests/unittest/others/, not tests/integration/, so it is collected directly by the pytest unittest run rather than through tests/integration/test_lists/{test-db,qa} entries — no test-list additions are needed for this file.

Coverage verdict: sufficient for the parse/merge/JSON path exercised here (globbing, malformed-line skipping, native/C++ CSV parsing, request/ctx-id joins, unjoined reporting, derived gaps/starvation, JSON round-trip, perf-json input). Gaps worth noting: write_html() is untested (reasonable given the GPU-free/torch-free constraint) and main()'s argument-parsing/error path (Line 383-384 in perf_time_events.py) has no test. Also, _NATIVE_TASK_HEADER/_GEN_SUMMARY_HEADER here are independently hand-copied from the same source the module's own copies claim to mirror (_torch/disaggregation/native/perf_logger.py) rather than shared/imported, so this test only catches drift between the module and the test, not real drift against the actual producer.

As per path instructions, "Always produce a test coverage summary, even if no issues are found."

🤖 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/unittest/others/test_perf_time_events.py` around lines 1 - 213,
Strengthen the CSV header tests in TestParseKvCsvDir by deriving or validating
_NATIVE_TASK_HEADER and _GEN_SUMMARY_HEADER against the actual producer
definitions in perf_logger.py, rather than relying on independently copied
literals. Preserve the existing native task and generation summary parsing
assertions while ensuring producer-header drift causes a test failure.

Source: Path instructions

tests/unittest/_torch/executor/test_perf_metrics_events.py (1)

1-249: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test coverage summary.

Added TestAppendStepMetricsExtended, TestComputeIterBatchContext, and TestPerRankWriter, covering the new extended-capture merge logic, _compute_iter_batch_context, and the JSONL writer's enqueue/no-op gating. This is a new file under tests/unittest/... and does not require entries in tests/integration/test_lists/ (that path gates integration tests, not this unit suite). Coverage verdict: sufficient for the happy-path behavior exercised here; consider adding a regression test that simulates a writer-thread failure (e.g. patch open() to raise inside _writer_loop) and asserts close() returns promptly instead of blocking, covering the hang risk flagged in perf_metrics_manager.py.

As per path instructions, "Act as a QA engineer reviewing test changes and coverage for TensorRT-LLM" and "Always produce a test coverage summary, even if no issues are found."

🤖 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/unittest/_torch/executor/test_perf_metrics_events.py` around lines 1 -
249, Add a regression test in TestPerRankWriter that forces open() to fail
inside PerfMetricsManager._writer_loop, enqueues a request event, and verifies
PerfMetricsManager.close() returns promptly without hanging. Ensure the test
exercises the writer-thread failure path and uses a bounded wait or timeout
while preserving cleanup of the manager.

Source: Path instructions

🤖 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 `@tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py`:
- Around line 415-450: Update close() to enqueue _WRITER_STOP with a bounded or
non-blocking operation so shutdown cannot hang when _writer_loop has exited or
the queue is full. Replace the broad except Exception with handling for the
specific queue exception(s), and log enqueue failures while preserving the
existing thread join and cleanup behavior.
- Around line 322-340: Update the per-request generation metric in the extended
metrics block to use the same draft-token source as _compute_iter_batch_context,
including its getattr(req, "num_draft_tokens", 0) fallback, instead of
get_draft_token_length(request). Ensure req_generation_token_number remains 1
plus that shared draft-token value so batch and per-request totals stay
consistent.

In `@tensorrt_llm/serve/openai_server.py`:
- Around line 590-603: Move the TRTLLM_PERF_TIME_EVENTS_PATH-based
initialization of self.perf_metrics and self.perf_metrics_lock outside the
return_perf_metrics conditional in the surrounding initialization flow. Ensure
the fallback runs whenever the environment variable is set and self.perf_metrics
is None, so /perf_metrics has a live deque even when
generator.args.return_perf_metrics is false.

In `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py`:
- Around line 108-177: The parser duplicates producer CSV header definitions
through _NATIVE_TASK_HEADER and _GEN_SUMMARY_HEADER, allowing header changes to
desynchronize routing. Update parse_kv_csv_dir to import and reuse the
producer’s shared header constants, or move both definitions into a shared
module consumed by the parser and writer, while preserving the existing native
task and generation-summary classification.

---

Nitpick comments:
In `@tensorrt_llm/serve/scripts/perf_time_events/__init__.py`:
- Around line 26-33: Sort the entries in __all__ alphabetically to satisfy Ruff
RUF022, while preserving the existing exported symbols from the perf_time_events
package.

In `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py`:
- Line 1: Replace the locally duplicated _NATIVE_TASK_HEADER and
_GEN_SUMMARY_HEADER declarations in perf_time_events.py with imports from
_torch/disaggregation/native/perf_logger.py or a shared constants module. Update
test_perf_time_events.py to use those same producer-owned constants, ensuring
parser expectations and tests detect future header changes.
- Around line 205-209: Update the __init__ method signature to include an
explicit -> None return annotation, leaving its existing initialization logic
unchanged.
- Around line 347-390: Annotate the CLI entry-point function main with an
explicit -> None return type, preserving its existing argument parsing,
validation, merge, and output sequencing.
- Around line 61-71: The generator function _iter_jsonl lacks a return type
annotation. Import Iterator from collections.abc and annotate _iter_jsonl’s
return type as Iterator using the appropriate parsed JSON value type, while
preserving its existing behavior.
- Around line 30-36: Update type annotations throughout perf_time_events.py,
including parse_event_dir, parse_kv_csv_dir, and PerfTimeEventsMerger, to use
PEP 585 built-in generics: replace Dict with dict, List with list, and
Optional[T] with T | None; remove the corresponding typing imports while
preserving all annotation semantics.
- Around line 211-316: Update merge() so each invocation clears or reinitializes
self.kv when kv_csv_dir is not provided, preventing joins from using KV data
loaded by a previous call. Preserve loading parsed KV data when kv_csv_dir is
supplied and keep the existing task_events, gen_summary, and cpp_events
processing unchanged.
- Around line 329-344: Move the timing-row construction loop from write_html
into a small public helper on RequestTimeBreakdown that parses each record and
adds all configured metric durations. Update write_html to call that helper and
pass its result to create_timing_diagram, removing direct parser/config.metrics
access from write_html.

In `@tests/unittest/_torch/executor/test_perf_metrics_events.py`:
- Around line 1-249: Add a regression test in TestPerRankWriter that forces
open() to fail inside PerfMetricsManager._writer_loop, enqueues a request event,
and verifies PerfMetricsManager.close() returns promptly without hanging. Ensure
the test exercises the writer-thread failure path and uses a bounded wait or
timeout while preserving cleanup of the manager.

In `@tests/unittest/others/test_perf_time_events.py`:
- Around line 1-213: Strengthen the CSV header tests in TestParseKvCsvDir by
deriving or validating _NATIVE_TASK_HEADER and _GEN_SUMMARY_HEADER against the
actual producer definitions in perf_logger.py, rather than relying on
independently copied literals. Preserve the existing native task and generation
summary parsing assertions while ensuring producer-header drift causes a test
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: 9fffc183-8f40-4fb5-9cea-64293732be28

📥 Commits

Reviewing files that changed from the base of the PR and between aae253e and ad9612f.

📒 Files selected for processing (12)
  • docs/source/developer-guide/perf-analysis.md
  • tensorrt_llm/_torch/pyexecutor/llm_request.py
  • tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py
  • tensorrt_llm/_torch/pyexecutor/py_executor.py
  • tensorrt_llm/serve/openai_server.py
  • tensorrt_llm/serve/responses_utils.py
  • tensorrt_llm/serve/scripts/perf_time_events/README.md
  • tensorrt_llm/serve/scripts/perf_time_events/__init__.py
  • tensorrt_llm/serve/scripts/perf_time_events/__main__.py
  • tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py
  • tests/unittest/_torch/executor/test_perf_metrics_events.py
  • tests/unittest/others/test_perf_time_events.py

Comment on lines +590 to +603
self.perf_metrics_maxlen = max_perf_metrics
self.perf_metrics = deque(maxlen=max_perf_metrics)
self.perf_metrics_lock = asyncio.Lock()
# TRTLLM_PERF_TIME_EVENTS_PATH also makes the HTTP /perf_metrics
# endpoint usable for single-server (aggregated) runs, even when
# return_perf_metrics / perf_metrics_max_requests were not set. The
# per-rank live files remain the primary artifact; this is
# convenience.
if (self.perf_metrics is None
and len(os.getenv("TRTLLM_PERF_TIME_EVENTS_PATH", "")) > 0):
self.perf_metrics_maxlen = (
self.generator.args.perf_metrics_max_requests or 65536)
self.perf_metrics = deque(maxlen=self.perf_metrics_maxlen)
self.perf_metrics_lock = asyncio.Lock()

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'TRTLLM_PERF_TIME_EVENTS_PATH' --type=py -C3
rg -n 'return_perf_metrics\s*=' --type=py -C2 -g '!**/test*'

Repository: NVIDIA/TensorRT-LLM

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant server code and nearby argument wiring.
fd -a 'openai_server.py' .
fd -a 'args.py' .
fd -a 'llm_args.py' .
fd -a 'config.py' .

# Find all references to the env var and perf-metrics flags.
rg -n 'TRTLLM_PERF_TIME_EVENTS_PATH|return_perf_metrics|perf_metrics_max_requests|perf_metrics' tensorrt_llm -g '*.py' -C 2

# Show the relevant section of openai_server.py with line numbers.
python3 - <<'PY'
from pathlib import Path
p = Path('tensorrt_llm/serve/openai_server.py')
text = p.read_text().splitlines()
for i in range(540, 620):
    if i <= len(text):
        print(f"{i:4d}: {text[i-1]}")
PY

Repository: NVIDIA/TensorRT-LLM

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant files =="
git ls-files | rg 'openai_server\.py|llm_args|args\.py|config\.py|server' || true

echo
echo "== perf-metrics references =="
rg -n 'TRTLLM_PERF_TIME_EVENTS_PATH|return_perf_metrics|perf_metrics_max_requests|perf_metrics' tensorrt_llm -g '*.py' -C 3 || true

echo
echo "== openai_server.py excerpt =="
python3 - <<'PY'
from pathlib import Path
p = Path('tensorrt_llm/serve/openai_server.py')
text = p.read_text().splitlines()
for i in range(550, 615):
    if i <= len(text):
        print(f"{i:4d}: {text[i-1]}")
PY

Repository: NVIDIA/TensorRT-LLM

Length of output: 50375


Move the env-var fallback out of the return_perf_metrics gate TRTLLM_PERF_TIME_EVENTS_PATH still won’t populate self.perf_metrics unless self.generator.args.return_perf_metrics is already true. The request path can set sampling_params.return_perf_metrics = True, but that only affects per-request metrics; /perf_metrics still returns [] when the deque is never initialized.

🤖 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 `@tensorrt_llm/serve/openai_server.py` around lines 590 - 603, Move the
TRTLLM_PERF_TIME_EVENTS_PATH-based initialization of self.perf_metrics and
self.perf_metrics_lock outside the return_perf_metrics conditional in the
surrounding initialization flow. Ensure the fallback runs whenever the
environment variable is set and self.perf_metrics is None, so /perf_metrics has
a live deque even when generator.args.return_perf_metrics is false.

Comment on lines +108 to +177
def parse_kv_csv_dir(kv_csv_dir: str) -> Dict[str, Any]:
"""Parse KV-transfer CSVs into join-ready structures.

Handles two producers:

* Python-native transceiver (``perf_logger.py``):
- ``{instance}_{rank}.csv`` -> task rows keyed by ``unique_rid``
(KVSendTask / AuxSendTask / KVRecvTask; recv rows leave the middle
latency fields blank).
- ``{instance}_{rank}_gen_transfer_summary.csv`` -> gen summary keyed by
``RequestID``.
* C++ transceiver: ``*_send.csv`` / ``*_recv.csv`` keyed by ctx-phase
``RequestID`` (parsed for completeness so mixed dirs still aggregate).

Returns a dict with ``task_events`` (rid -> list[row]), ``gen_summary``
(rid -> row) and ``cpp_events`` (rid -> list[row]).
"""
task_events: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
gen_summary: Dict[str, Dict[str, Any]] = {}
cpp_events: Dict[str, List[Dict[str, Any]]] = defaultdict(list)

csv_files = sorted(glob.glob(os.path.join(kv_csv_dir, "*.csv")))
for path in csv_files:
base = os.path.basename(path)
try:
with open(path, "r", newline="") as f:
reader = csv.reader(f)
try:
header = next(reader)
except StopIteration:
continue
header = [h.strip() for h in header]
rows = [row for row in reader if row]
except OSError as e:
print(f"WARNING: failed to read KV CSV {path}: {e}")
continue

if base.endswith("_gen_transfer_summary.csv") or header == _GEN_SUMMARY_HEADER:
for row in rows:
rec = dict(zip(header, row))
rid = rec.get("RequestID")
if rid is not None:
rec["_source_file"] = base
gen_summary[str(rid)] = rec
elif header == _NATIVE_TASK_HEADER:
for row in rows:
rec = dict(zip(header, row))
rid = rec.get("unique_rid")
if rid is not None:
rec["_source_file"] = base
task_events[str(rid)].append(rec)
else:
# C++ (or unknown) layout: try to key on a RequestID-like column.
rid_key = None
for candidate in ("RequestID", "request_id", "unique_rid"):
if candidate in header:
rid_key = candidate
break
for row in rows:
rec = dict(zip(header, row))
rec["_source_file"] = base
rid = rec.get(rid_key) if rid_key else None
if rid is not None:
cpp_events[str(rid)].append(rec)

return {
"task_events": dict(task_events),
"gen_summary": gen_summary,
"cpp_events": dict(cpp_events),
}

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'timestamp,task_type,unique_rid|_NATIVE_TASK_HEADER|_GEN_SUMMARY_HEADER' -g '*.py' tensorrt_llm/_torch/disaggregation

Repository: NVIDIA/TensorRT-LLM

Length of output: 562


🏁 Script executed:

#!/bin/bash
sed -n '1,240p' tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py
printf '\n--- PRODUCER ---\n'
sed -n '1,460p' tensorrt_llm/_torch/disaggregation/native/perf_logger.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 25791


Share the KV CSV headers with the producer
_NATIVE_TASK_HEADER and _GEN_SUMMARY_HEADER are duplicated literals here, so a producer-side header change will silently route native rows into the generic C++/unknown path. Import the producer constants or define them once in a shared module to keep the parser and writer in sync.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 132-132: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(path, "r", newline="")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🪛 Ruff (0.15.21)

[warning] 147-147: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


[warning] 154-154: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)


[warning] 167-167: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)

🤖 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 `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py` around lines
108 - 177, The parser duplicates producer CSV header definitions through
_NATIVE_TASK_HEADER and _GEN_SUMMARY_HEADER, allowing header changes to
desynchronize routing. Update parse_kv_csv_dir to import and reuse the
producer’s shared header constants, or move both definitions into a shared
module consumed by the parser and writer, while preserving the existing native
task and generation-summary classification.

@chenfeiz0326

Copy link
Copy Markdown
Collaborator Author

GPU end-to-end verification (OCI-HSG GB200)

Posting the promised follow-up. Workload:
disagg gen_only-gb200_gpt-oss-120b-fp4_8k1k_con512_ctx1_tp1_gen1_dep2_eplb0_mtp0_ccb-NIXL
(gen tp2/ep2 attention_dp + ctx tp1, NIXL/PYTHON transceiver). No --capture-nsys
(documented nsys-2026 hang on this exact case; instrumentation is host timestamps

  • KV CSV, so nsys is unnecessary).

Method — single-wheel A/B. ONE instrumented wheel is used for both arms; only
the env switch toggles. Both arms' server config dump return_perf_metrics=False perf_metrics_max_requests=0, so the baseline is fully stock and the
instrumented arm is the switch force-enabling the whole capture path — i.e.
the A/B measures stock → full capture, not a marginal delta over an
already-capturing run. All 7 reps pinned to the same node (--run-count 1,
interleaved) to remove node variance.

H2 — capture works ✅

Each instrumented rep wrote 3 per-rank files (12 files / 4 reps, 4108 records,
0 missing extended keys):

file role records
time_events_rank0_pid{P_ctx}.jsonl ctx server 513 (ctx_chunk_metrics)
time_events_rank0_pid{P_gen0}.jsonl gen rank0 257 (256 gen; 1023 step_metrics/req)
time_events_rank1_pid{P_gen1}.jsonl gen rank1 257

All 7 extended keys present (iter_batch_size, num_ctx_requests,
num_gen_requests, context_token_number, generation_token_number,
num_capacity_fitting, num_scheduled) + req_context_token_number /
req_generation_token_number. The rank{N}_pid{P} naming correctly separated the
ctx-server rank0 from the gen-server rank0 (same rank, different pid) — the
collision the naming was designed for, observed in practice.

H3 — switch-off is dormant ✅

Every baseline rep (env unset) wrote 0 time_events_*.jsonl; every
instrumented rep wrote exactly 3. Same binary, switch alone gates all new I/O.

H2b — overhead when the switch is ON: ≈4% (opt-in only)

Two independent signals agree, with zero overlap between arms:

arm step_ms (↓) median throughput tok/s (↑) median
instrumented (n=4) 20.215 — [20.02, 20.34, 20.09, 20.36] 8612 — [8627, 8607, 8617, 8588]
baseline (n=3) 19.370 — [20.23, 19.34, 19.37] 8983 — [8866, 9020, 8982]
delta +4.36% −4.12%

So enabling the switch costs ≈4% on this high-step-count decode workload — real
and reproducible, not run-to-run noise.

Attribution (verified in source) — the ≈4% is the pre-existing per-iteration
capture that the switch turns on, not the new writer:

  1. append_step_metrics / save_timing_to_requests early-return when
    enabled=False (baseline). The switch sets enabled=True, so a steady-clock
    read + a per-step dict accumulation now runs for every request on every gen
    step
    — 256 req × ~1023 steps ≈ 2.6×10⁵ builds/rank. This is the bulk.
  2. _compute_iter_batch_context (new, capture_extended-gated): one small dict
    per iteration — marginal.
  3. maybe_write_request_events (new): a put_nowait of an existing payload
    reference — no loop-thread I/O, no deep copy; json.dumps + write + flush
    is on the daemon thread. This confirms the "added on-loop cost is near-zero"
    claim in the description
    — the writer is genuinely off the critical path; the
    cost is the timing capture itself (the price of the data), which only exists
    when the switch is explicitly set. The default path (H3) is unchanged.

(Gen-rank files are ~131 MB each — 256 req × 1023 per-iteration step_metrics
over the 1024-token decode — written off-thread; the throughput A/B confirms the
write volume does not collapse throughput.)

Summary: H2 PASS · H3 PASS · H2b ≈+4% (opt-in capture cost, off-by-default).

…request lifecycle scalars

Builds on the per-rank live capture (TRTLLM_PERF_TIME_EVENTS_PATH) by adding
the timing sources the executor cannot see, so a perf timeline can span
client-send -> router-dispatch -> worker admission/decode.

Item 4 (executor): enrich each per-rank JSONL record with the C++
RequestPerfMetrics lifecycle scalars (arrival / first_scheduled / first_token /
last_token / kv_cache_transfer start+end / kv_cache_size) via
executor.result.get_metrics_dict. Populated on the serve/disagg path (the
entrypoints force return_perf_metrics under the env); best-effort otherwise
(empty dict -> key omitted, never crashes the writer). These steady-clock
anchors give per-request admission/prefill/decode boundaries the relative
per-iteration time_breakdown_metrics cannot express.

Item 1 (client + router): two new optional capture files, each written by a
stdlib-only, torch-free writer (atexit flush) so the GPU-free import contract
holds:
  * TRTLLM_PERF_TIME_EVENTS_ROUTER_PATH -- the disagg router stamps
    arrival / ctx_dispatch / gen_dispatch / first_token / resp_done
    (steady-clock, same epoch as the workers) keyed by ctx_request_id; gen-only
    requests with no ctx id are collected separately.
  * TRTLLM_PERF_TIME_EVENTS_CLIENT_PATH -- the benchmark client stamps
    send/first_token/completion (perf_counter) + send_wall_time (time.time).
    Standalone timeline: response_id is a fresh UUID, not a worker join key.

Aggregator: --router-dir / --client-dir (default to the two new env vars) join
the router record onto each worker record by ctx_request_id (exposed as
router_dispatch) and derive the cross-process spans
router_arrival_to_ctx_dispatch / router_ctx_to_gen_dispatch /
router_to_worker_arrival, plus the worker-side arrival_to_first_schedule /
schedule_to_first_token / decode_duration from request_timing_metrics.
Unmatched router rows (incl. gen-only) surface under unjoined_router_events;
client rows pass through untouched under client_events. Any one input suffices.

Tests (GPU-free): stdlib JsonlEventWriter contract (inert/drain/default=str/
idempotent); router+client parsers; enriched merge (join + all derived spans +
unjoined + standalone client payload); worker request_timing_metrics enrichment
(populated / empty / exception-swallowed).

Docs: document the two env vars, the --router-dir/--client-dir flags, the
lifecycle-scalar enrichment, and the standalone-client / shared-steady-clock
caveats in perf-analysis.md.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Chenfei Zhang <chenfeiz@nvidia.com>
@chenfeiz0326

Copy link
Copy Markdown
Collaborator Author

Pushed b676a2bf71, extending the capture with the two timing sources the executor cannot observe, so a timeline can now span client-send → router-dispatch → worker admission/decode.

Item 4 — executor per-request lifecycle scalars. Each per-rank JSONL record is enriched with the C++ RequestPerfMetrics lifecycle timestamps (arrival / first_scheduled / first_token / last_token / kv_cache_transfer start+end / kv_cache_size) via executor.result.get_metrics_dict, under a new request_timing_metrics key. Populated on the serve/disagg path (the entrypoints force return_perf_metrics under the env); best-effort otherwise (empty → key omitted, never crashes the writer).

Item 1 — client + disagg-router capture files (two new optional env vars, each written by a stdlib-only, torch-free writer with atexit flush so the GPU-free import contract holds):

  • TRTLLM_PERF_TIME_EVENTS_ROUTER_PATH — the router stamps arrival/ctx_dispatch/gen_dispatch/first_token/resp_done (steady-clock, same epoch as the workers), keyed by ctx_request_id; gen-only requests collected separately.
  • TRTLLM_PERF_TIME_EVENTS_CLIENT_PATH — the benchmark client stamps send/first-token/completion (perf_counter) + send_wall_time (time.time). Standalone timeline (response_id is a fresh UUID, not a worker join key).

Aggregator — --router-dir/--client-dir (default to the new env vars): joins the router record onto each worker record by ctx_request_id and derives cross-process spans (router_arrival_to_ctx_dispatch, router_ctx_to_gen_dispatch, router_to_worker_arrival) plus worker-side arrival_to_first_schedule/schedule_to_first_token/decode_duration. Unmatched router rows (incl. gen-only) → unjoined_router_events; client rows pass through under client_events.

Tests (GPU-free): stdlib writer contract, router+client parsers, enriched merge (join + all derived spans + unjoined + standalone client), and worker enrichment (populated / empty / exception-swallowed). Docs updated in perf-analysis.md.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tensorrt_llm/serve/openai_disagg_service.py (1)

386-412: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

on_gen_dispatch hook is not called on the generation-first schedule path.

_send_disagg_request_gen_first calls hooks.on_ctx_dispatch(request) but never hooks.on_gen_dispatch(...) before dispatching to self._gen_client.send_request(...). As a result, RawRequestResponseHooks.gen_dispatch_time stays 0 (→ None in the emitted record) for any deployment using schedule_style: generation_first, silently degrading the new router dispatch-timeline for that mode. Since the ctx-first path calls the hook right before gen-server resolution, consider adding an equivalent call here (e.g. right before line 403's get_next_server/dispatch) for parity.

🛠️ Proposed fix
         disagg_request_id = await self._coordinator.get_disagg_request_id()
         if need_ctx:
             # arrival->here = pre-ctx wait in the orchestrator/fleet.
             if hooks:
                 hooks.on_ctx_dispatch(request)
             ctx_server, ctx_server_info = await self._ctx_router.get_next_server(
                 request, req_id=disagg_request_id
             )
             ctx_req = self._get_ctx_request(request, disagg_request_id)
+        # Mark gen-dispatch start for the gen-first schedule style too.
+        if hooks:
+            hooks.on_gen_dispatch(request)
         gen_req = self._get_gen_request(
🤖 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 `@tensorrt_llm/serve/openai_disagg_service.py` around lines 386 - 412, Update
_send_disagg_request_gen_first to invoke hooks.on_gen_dispatch(request) before
generation-server resolution or dispatch, matching the ctx-first path and
ensuring generation-first requests record gen dispatch timing. Keep the existing
on_ctx_dispatch call for context routing unchanged.
🧹 Nitpick comments (1)
tests/unittest/others/test_perf_time_events.py (1)

283-332: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a negative case where request_id collides with an unrelated ctx_request_id.

This test uses a string ctx id ("ctxA") and an int request_id (10), so it can never exercise the router join's request_id fallback (flagged on perf_time_events.py Line 333). A record with request_id=10 and a router row keyed "10" for a different request would currently join and emit bogus router_* spans.

🤖 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/unittest/others/test_perf_time_events.py` around lines 283 - 332,
Extend test_router_join_on_ctx_id_and_derived_spans with a negative case where
request_id 10 conflicts with an unrelated router row keyed by "10" while
ctx_request_id remains different. Assert the router record is not joined, no
bogus router_* derived spans are emitted, and the unrelated router event remains
unjoined, covering the request_id fallback without changing the existing
ctx_request_id match assertions.
🤖 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 `@tensorrt_llm/serve/openai_disagg_server.py`:
- Around line 115-143: Preserve ctx_request_id as None in
_maybe_write_router_event when no ctx round-trip occurred; do not substitute
disagg_request_id for it. Keep the existing disagg_request_id field for router
dispatch identity so parse_router_dir() continues routing gen-only records to
_no_ctx.

In `@tensorrt_llm/serve/perf_time_events_writer.py`:
- Around line 73-106: Ensure the `_writer_loop` OSError path transitions the
writer to an inert state when directory creation or file opening fails. Update
`write` and the writer-state handling so records are no longer enqueued after
that failure, and ensure `_writer_thread` cannot remain represented as an
active/draining writer. Preserve the existing warning while making the failure
immediately prevent further queue growth.

In `@tensorrt_llm/serve/scripts/backend_request_func.py`:
- Around line 71-82: Update async_request_openai_chat_completions to assign
send_time and send_wall_time at the same POST-send point as
async_request_openai_completions, and capture the returned response ID into
response_id. Preserve the existing timestamp sources and response-ID extraction
behavior so openai-chat perf-time-events records contain the same client
timeline data.

In `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py`:
- Around line 333-341: Update the router dispatch join around router_by_ctx to
look up only the worker record’s ctx_request_id, not both values in rids. Remove
the request_id-first iteration and retain matched_router_ctx tracking and
router_dispatch assignment only when the ctx-key lookup succeeds.
- Around line 105-124: Update the relevant function’s docstring to document the
actual "_no_ctx" sentinel used by merge(), and widen its return annotation/value
type to represent both per-context record dictionaries and the no-context record
list. Remove the unnecessary type: ignore on the "_no_ctx" assignment while
preserving the existing grouping behavior.

In `@tests/unittest/others/test_perf_time_events_writer.py`:
- Around line 30-95: Add tests/unittest/others/test_perf_time_events_writer.py
to the appropriate integration test-list entries under
tests/integration/test_lists/test-db/ and qa/ so all six TestJsonlEventWriter
and TestMakeEnvWriter tests are discovered by integration coverage.

In `@tests/unittest/others/test_perf_time_events.py`:
- Around line 364-395: Add tests/unittest/others/test_perf_time_events.py to the
appropriate CI test-list entry under the existing database or QA test-list
configuration, ensuring the listed test functions and helper coverage run
automatically. Do not modify the test implementations.

---

Outside diff comments:
In `@tensorrt_llm/serve/openai_disagg_service.py`:
- Around line 386-412: Update _send_disagg_request_gen_first to invoke
hooks.on_gen_dispatch(request) before generation-server resolution or dispatch,
matching the ctx-first path and ensuring generation-first requests record gen
dispatch timing. Keep the existing on_ctx_dispatch call for context routing
unchanged.

---

Nitpick comments:
In `@tests/unittest/others/test_perf_time_events.py`:
- Around line 283-332: Extend test_router_join_on_ctx_id_and_derived_spans with
a negative case where request_id 10 conflicts with an unrelated router row keyed
by "10" while ctx_request_id remains different. Assert the router record is not
joined, no bogus router_* derived spans are emitted, and the unrelated router
event remains unjoined, covering the request_id fallback without changing the
existing ctx_request_id match assertions.
🪄 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: a7cf282c-c07e-4a9b-acb4-669c1bb75863

📥 Commits

Reviewing files that changed from the base of the PR and between ad9612f and b676a2b.

📒 Files selected for processing (14)
  • docs/source/developer-guide/perf-analysis.md
  • tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py
  • tensorrt_llm/_torch/pyexecutor/py_executor.py
  • tensorrt_llm/serve/openai_disagg_server.py
  • tensorrt_llm/serve/openai_disagg_service.py
  • tensorrt_llm/serve/perf_time_events_writer.py
  • tensorrt_llm/serve/responses_utils.py
  • tensorrt_llm/serve/scripts/backend_request_func.py
  • tensorrt_llm/serve/scripts/benchmark_serving.py
  • tensorrt_llm/serve/scripts/perf_time_events/__init__.py
  • tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py
  • tests/unittest/_torch/executor/test_perf_metrics_events.py
  • tests/unittest/others/test_perf_time_events.py
  • tests/unittest/others/test_perf_time_events_writer.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • tensorrt_llm/serve/scripts/perf_time_events/init.py
  • docs/source/developer-guide/perf-analysis.md
  • tensorrt_llm/_torch/pyexecutor/py_executor.py
  • tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py

Comment on lines +30 to +95
class TestJsonlEventWriter(unittest.TestCase):
def test_inert_when_no_dir(self):
# Falsy events_dir -> inert: enabled False, write a no-op, close safe.
writer = JsonlEventWriter("", "disagg_router")
self.assertFalse(writer.enabled)
writer.write({"source": "disagg_router", "x": 1}) # must not raise
writer.close() # safe even though no thread ever started

def test_writes_one_line_per_record_and_drains_on_close(self):
with tempfile.TemporaryDirectory() as d:
writer = JsonlEventWriter(d, "disagg_router")
self.assertTrue(writer.enabled)
writer.write({"source": "disagg_router", "ctx_request_id": 1})
writer.write({"source": "disagg_router", "ctx_request_id": 2})
writer.close() # drains + joins the daemon thread

files = glob.glob(os.path.join(d, "disagg_router_pid*.jsonl"))
self.assertEqual(len(files), 1)
self.assertIn(f"pid{os.getpid()}", os.path.basename(files[0]))
with open(files[0]) as f:
lines = [json.loads(ln) for ln in f if ln.strip()]
self.assertEqual([r["ctx_request_id"] for r in lines], [1, 2])

def test_non_serializable_value_falls_back_to_str(self):
# default=str keeps a non-JSON-native value from killing the writer
# thread (and losing every subsequent record).
with tempfile.TemporaryDirectory() as d:
writer = JsonlEventWriter(d, "client")
writer.write({"source": "client", "weird": {1, 2, 3}})
writer.close()
files = glob.glob(os.path.join(d, "client_pid*.jsonl"))
self.assertEqual(len(files), 1)
with open(files[0]) as f:
lines = [json.loads(ln) for ln in f if ln.strip()]
self.assertEqual(len(lines), 1)
# The set was stringified rather than raising TypeError.
self.assertIsInstance(lines[0]["weird"], str)

def test_close_is_idempotent(self):
with tempfile.TemporaryDirectory() as d:
writer = JsonlEventWriter(d, "disagg_router")
writer.write({"source": "disagg_router"})
writer.close()
writer.close() # second call is a no-op, must not raise


class TestMakeEnvWriter(unittest.TestCase):
def test_inert_when_env_unset(self):
with patch.dict(os.environ, {}, clear=False):
os.environ.pop(ROUTER_EVENTS_PATH_ENV, None)
writer = make_env_writer(ROUTER_EVENTS_PATH_ENV, "disagg_router")
self.assertFalse(writer.enabled)

def test_enabled_when_env_set(self):
with tempfile.TemporaryDirectory() as d:
with patch.dict(os.environ, {CLIENT_EVENTS_PATH_ENV: d}):
writer = make_env_writer(CLIENT_EVENTS_PATH_ENV, "client")
self.assertTrue(writer.enabled)
writer.write({"source": "client", "client_index": 0})
writer.close()
files = glob.glob(os.path.join(d, "client_pid*.jsonl"))
self.assertEqual(len(files), 1)


if __name__ == "__main__":
unittest.main()

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'test_perf_time_events_writer' tests/integration/test_lists

Repository: NVIDIA/TensorRT-LLM

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Test-list files:\n'
git ls-files 'tests/integration/test_lists/**' | sed -n '1,200p'

printf '\nOccurrences of test module name in test lists:\n'
rg -n 'test_perf_time_events_writer|test_perf_time|perf_time_events_writer' tests/integration/test_lists || true

Repository: NVIDIA/TensorRT-LLM

Length of output: 4926


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Relevant mentions in integration test lists:\n'
rg -n 'unittest|tests/unittest|others/test_perf_time_events_writer|perf_time_events_writer|test_perf_time|pytest|python -m unittest' tests/integration/test_lists || true

printf '\nA few sample entries from list files:\n'
sed -n '1,120p' tests/integration/test_lists/qa/llm_perf_core.yml
printf '\n---\n'
sed -n '1,120p' tests/integration/test_lists/test-db/l0_perf.yml

Repository: NVIDIA/TensorRT-LLM

Length of output: 50375


Add tests/unittest/others/test_perf_time_events_writer.py to the integration test lists

Changed tests: test_inert_when_no_dir, test_writes_one_line_per_record_and_drains_on_close, test_non_serializable_value_falls_back_to_str, test_close_is_idempotent, test_inert_when_env_unset, test_enabled_when_env_set. None of these appear in tests/integration/test_lists/test-db/ or qa/, so this module won’t be picked up by integration coverage. Coverage verdict: insufficient.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 48-48: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(files[0])
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)


[warning] 61-61: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(files[0])
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🤖 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/unittest/others/test_perf_time_events_writer.py` around lines 30 - 95,
Add tests/unittest/others/test_perf_time_events_writer.py to the appropriate
integration test-list entries under tests/integration/test_lists/test-db/ and
qa/ so all six TestJsonlEventWriter and TestMakeEnvWriter tests are discovered
by integration coverage.

Source: Path instructions

Comment on lines +364 to +395
def test_client_events_standalone_in_payload(self):
with tempfile.TemporaryDirectory() as ev, tempfile.TemporaryDirectory() as cl:
_write(
os.path.join(ev, "time_events_rank0_pid1.jsonl"),
json.dumps(_make_record(10)) + "\n",
)
_write(
os.path.join(cl, "client_pid1.jsonl"),
json.dumps(
{
"source": "client",
"client_index": 0,
"send_wall_time": 1.0,
"ttft": 0.1,
"latency": 2.0,
}
)
+ "\n",
)
merger = PerfTimeEventsMerger()
merger.merge(event_dir=ev, client_dir=cl)
self.assertEqual(len(merger.client_events), 1)
self.assertEqual(merger.match_stats["num_client_events"], 1)
# Client records are NOT joined onto request records.
self.assertNotIn("client", merger.records[0])

out = os.path.join(cl, "combined.json")
merger.write(out)
with open(out) as f:
payload = json.load(f)
self.assertEqual(len(payload["client_events"]), 1)
self.assertIn("unjoined_router_events", payload)

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'test_perf_time_events' tests
rg -n 'unittest/others|test_perf_time_events' tests/integration/test_lists -g '*.yml' -g '*.txt' -g '*.yaml' | head -50

Repository: NVIDIA/TensorRT-LLM

Length of output: 2491


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== test file outline ==\n'
ast-grep outline tests/unittest/others/test_perf_time_events.py --view expanded || true

printf '\n== test file head/tail around relevant tests ==\n'
wc -l tests/unittest/others/test_perf_time_events.py
sed -n '1,260p' tests/unittest/others/test_perf_time_events.py
printf '\n--- later section ---\n'
sed -n '260,460p' tests/unittest/others/test_perf_time_events.py

printf '\n== integration list mentions ==\n'
rg -n 'test_perf_time_events\.py|unittest/others/test_perf_time_events\.py|unittest/others/' tests/integration/test_lists -g '*.yml' -g '*.yaml' -g '*.txt' || true

Repository: NVIDIA/TensorRT-LLM

Length of output: 20601


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in $(git ls-files 'tests/integration/test_lists/**/*.yml' 'tests/integration/test_lists/**/*.yaml' 'tests/integration/test_lists/**/*.txt'); do
  if rg -n 'unittest/others/|test_perf_time_events\.py' "$f" >/dev/null; then
    echo "### $f"
    rg -n 'unittest/others/|test_perf_time_events\.py' "$f"
  fi
done

Repository: NVIDIA/TensorRT-LLM

Length of output: 1766


Add tests/unittest/others/test_perf_time_events.py to the CI test lists

Changed test functions (all added; none modified or removed): TestParseRouterDir.test_keyed_by_ctx_id_and_no_ctx_collected, TestParseRouterDir.test_ignores_unrelated_files, TestParseClientDir.test_sorted_by_send_wall_time, TestParseClientDir.test_missing_wall_time_sorts_first, TestRouterAndClientMerge.test_router_join_on_ctx_id_and_derived_spans, TestRouterAndClientMerge.test_unjoined_router_events_include_no_ctx_and_leftovers, TestRouterAndClientMerge.test_client_events_standalone_in_payload (plus helper _router_record).

Test-list registration: tests/unittest/others/test_perf_time_events.py is not listed in any provided tests/integration/test_lists/test-db/ or tests/integration/test_lists/qa/ entry. Add it to the appropriate CI list so these tests run automatically.

Coverage verdict: insufficient.

🧰 Tools
🪛 ast-grep (0.44.1)

[info] 367-367: use jsonify instead of json.dumps for JSON output
Context: json.dumps(_make_record(10))
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 371-379: use jsonify instead of json.dumps for JSON output
Context: json.dumps(
{
"source": "client",
"client_index": 0,
"send_wall_time": 1.0,
"ttft": 0.1,
"latency": 2.0,
}
)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[warning] 391-391: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(out)
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🤖 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/unittest/others/test_perf_time_events.py` around lines 364 - 395, Add
tests/unittest/others/test_perf_time_events.py to the appropriate CI test-list
entry under the existing database or QA test-list configuration, ensuring the
listed test functions and helper coverage run automatically. Do not modify the
test implementations.

Source: Path instructions

Add the per-request capacity-scheduler admission event (event 1 of the
five per-step/per-chunk perf time events). Previously only a per-iteration
starvation count existed for the capacity stage; the other four events
(micro-batch admit, forward begin, sampler begin, decode/prefill end) already
carried per-request timestamps.

- SimpleScheduler stamps `_last_capacity_admit_time` via
  `steady_clock_now().total_seconds()` right after the capacity scheduler
  returns the fitting set, before micro-batch scheduling. Gated on a new
  `capture_admit_time` flag (default False) so the base scheduling path pays
  nothing.
- PyExecutor flips the flag on only under `perf_manager.capture_extended`, and
  only when the scheduler exposes it (hasattr guard leaves PP / non-Simple
  schedulers untouched). It reads the stamp in `_prepare_and_schedule_batch`
  and threads it through `_compute_iter_batch_context` as
  `capacity_scheduled_time`, riding onto every admitted request's per-chunk /
  per-step entry.

Same steady clock as `scheduled_time`, so the gap
`scheduled_time - capacity_scheduled_time` isolates the micro-batch scheduling
stage. Key is omitted when the scheduler does not expose the stamp.

Adds GPU-free unit tests for the emit gating and the scheduler stamp.

Signed-off-by: Chenfei Zhang <chenfeiz@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/unittest/_torch/executor/test_perf_metrics_events.py`:
- Around line 398-406: Update test_stamp_refreshes_each_round to mock the
module-local steady_clock_now with two distinct sequential values, then assert
_last_capacity_admit_time equals the first value after the initial
schedule_request and the second value after the next call. Replace the
non-deterministic ordering assertion while preserving the existing two-round
setup.
- Around line 205-241: The current tests cover _compute_iter_batch_context() and
SimpleScheduler but not the PyExecutor handoff from _last_capacity_admit_time.
Add an executor-level test in test_perf_metrics_events.py that exercises the
PyExecutor scheduling path, verifies the stored _last_capacity_admit_time is
passed through, and confirms capacity_scheduled_time is emitted; also cover the
absent-value path if needed to preserve omission 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: 43ef280c-ebcd-4842-8d3f-0000dcdc422c

📥 Commits

Reviewing files that changed from the base of the PR and between b676a2b and 9c368ea.

📒 Files selected for processing (3)
  • tensorrt_llm/_torch/pyexecutor/py_executor.py
  • tensorrt_llm/_torch/pyexecutor/scheduler/scheduler.py
  • tests/unittest/_torch/executor/test_perf_metrics_events.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tensorrt_llm/_torch/pyexecutor/py_executor.py

Comment on lines +205 to +241
def test_admission_timestamps_emitted_when_given(self):
# Both scheduler-stage timestamps ride into the shared dict; the gap
# scheduled_time - capacity_scheduled_time isolates the micro-batch stage.
scheduled = SimpleNamespace(
num_context_requests=0,
num_generation_requests=1,
batch_size=1,
context_requests=[],
generation_requests=[SimpleNamespace(num_draft_tokens=0)],
)
fake_self = SimpleNamespace(iter_counter=3)
ctx = PyExecutor._compute_iter_batch_context(
fake_self,
scheduled,
schedule_time=100.5,
capacity_admit_time=100.2,
)
assert ctx["scheduled_time"] == 100.5
assert ctx["capacity_scheduled_time"] == 100.2
assert ctx["scheduled_time"] >= ctx["capacity_scheduled_time"]

def test_capacity_scheduled_time_omitted_when_absent(self):
# PP local scheduler / non-SimpleScheduler expose no admit time -> the
# key must simply not appear (no None leaking into the record).
scheduled = SimpleNamespace(
num_context_requests=0,
num_generation_requests=1,
batch_size=1,
context_requests=[],
generation_requests=[SimpleNamespace(num_draft_tokens=0)],
)
fake_self = SimpleNamespace(iter_counter=1)
ctx = PyExecutor._compute_iter_batch_context(
fake_self, scheduled, schedule_time=5.0, capacity_admit_time=None
)
assert ctx["scheduled_time"] == 5.0
assert "capacity_scheduled_time" not in ctx

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
test_file='tests/unittest/_torch/executor/test_perf_metrics_events.py'
rg -n "test_perf_metrics_events|${test_file}" tests/integration/test_lists/test-db tests/integration/test_lists/qa

Repository: NVIDIA/TensorRT-LLM

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the touched test file and nearby context
test_file='tests/unittest/_torch/executor/test_perf_metrics_events.py'
wc -l "$test_file"
sed -n '1,260p' "$test_file"
printf '\n--- tail ---\n'
sed -n '260,460p' "$test_file"

# Find the relevant integration list files and whether this test file/test names are listed
printf '\n--- list files ---\n'
find tests/integration/test_lists -maxdepth 2 -type f \( -path '*/test-db/*' -o -path '*/qa/*' \) | sort

printf '\n--- file/test-name matches in test-db and qa lists ---\n'
rg -n --hidden --no-messages \
  -e 'test_perf_metrics_events|tests/unittest/_torch/executor/test_perf_metrics_events\.py|test_admission_timestamps_emitted_when_given|test_capacity_scheduled_time_omitted_when_absent' \
  tests/integration/test_lists/test-db tests/integration/test_lists/qa || true

Repository: NVIDIA/TensorRT-LLM

Length of output: 22019


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n --hidden \
  -e 'def _compute_iter_batch_context|capacity_admit_time|capture_admit_time|_last_capacity_admit_time|scheduled_time' \
  tensorrt_llm/_torch/pyexecutor tensorrt_llm/_torch/pyexecutor/scheduler tests/unittest/_torch/executor/test_perf_metrics_events.py

printf '\n--- py_executor slice ---\n'
sed -n '1,260p' tensorrt_llm/_torch/pyexecutor/py_executor.py

printf '\n--- scheduler slice ---\n'
sed -n '1,260p' tensorrt_llm/_torch/pyexecutor/scheduler/scheduler.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 26880


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for any tests that exercise the production PyExecutor scheduling path
# with capacity admission timestamps / admit-time capture.
rg -n --hidden --glob '!**/.git/**' \
  -e 'capacity_scheduled_time|capacity_admit_time|capture_admit_time|_last_capacity_admit_time|_compute_iter_batch_context|PyExecutor' \
  tests/unittest tests/integration

printf '\n--- potential executor tests ---\n'
find tests/unittest -path '*pyexecutor*' -o -path '*executor*' | sort

Repository: NVIDIA/TensorRT-LLM

Length of output: 37063


Add an executor-level handoff test. The new cases cover _compute_iter_batch_context() and SimpleScheduler in isolation, but not the PyExecutor path that reads _last_capacity_admit_time and emits capacity_scheduled_time; a wiring regression there would still pass these tests.

Coverage summary: test_admission_timestamps_emitted_when_given, test_capacity_scheduled_time_omitted_when_absent, and the SimpleScheduler admit-time tests are in tests/unittest/_torch/executor/test_perf_metrics_events.py; this file is not listed under tests/integration/test_lists/test-db/* or tests/integration/test_lists/qa/*. Coverage verdict: insufficient.

🤖 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/unittest/_torch/executor/test_perf_metrics_events.py` around lines 205
- 241, The current tests cover _compute_iter_batch_context() and SimpleScheduler
but not the PyExecutor handoff from _last_capacity_admit_time. Add an
executor-level test in test_perf_metrics_events.py that exercises the PyExecutor
scheduling path, verifies the stored _last_capacity_admit_time is passed
through, and confirms capacity_scheduled_time is emitted; also cover the
absent-value path if needed to preserve omission behavior.

Source: Path instructions

Comment on lines +398 to +406
def test_stamp_refreshes_each_round(self):
sched = self._make_scheduler()
sched.capture_admit_time = True
sched.schedule_request([], set())
first = sched._last_capacity_admit_time
sched.schedule_request([], set())
second = sched._last_capacity_admit_time
# A new reading every round (monotonic non-decreasing).
assert second >= first

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the refresh assertion deterministic.

second >= first also passes when both timestamps are identical, so it would not detect a regression that stops refreshing _last_capacity_admit_time. Mock the module-local steady_clock_now with distinct values and assert both expected timestamps.

🤖 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/unittest/_torch/executor/test_perf_metrics_events.py` around lines 398
- 406, Update test_stamp_refreshes_each_round to mock the module-local
steady_clock_now with two distinct sequential values, then assert
_last_capacity_admit_time equals the first value after the initial
schedule_request and the second value after the next call. Replace the
non-deterministic ordering assertion while preserving the existing two-round
setup.

…hang

Both the worker-side PerfMetricsManager writer and the stdlib
JsonlEventWriter (router/client) could hang forever on shutdown: if the
daemon writer thread had already exited after failing to open its output
file, the bounded queue was full and never drained, so close()'s blocking
queue.put(_WRITER_STOP) would block indefinitely on a consumer that was
gone.

Fix both writers symmetrically:
- On an open() failure the writer thread now goes permanently inert --
  clears _events_dir (so maybe_write_request_events / write() short-circuit
  and stop enqueueing) and drops the thread handle (so close() early-returns).
- close() snapshots the thread handle first, uses a bounded
  put(_WRITER_STOP, timeout=5) guarded by except queue.Full, and joins with
  a timeout, so teardown is bounded regardless of the writer's state.
- Open the JSONL files with encoding="utf-8" and dump with default=str so a
  non-serializable value degrades to its str form instead of killing the
  writer thread (and losing every later record).

Signed-off-by: Chenfei Zhang <chenfeiz@nvidia.com>
…perf batch context

The extended per-iteration batch context (_compute_iter_batch_context)
counted attention-DP / CUDA-graph padding (is_dummy) requests, inflating
exactly the iter_batch_size / num_ctx_requests / num_gen_requests /
num_scheduled numbers this feature exists to expose, and skewing the
derived starvation gap (num_capacity_fitting - num_scheduled). Filter dummy
requests out of every count, matching the sibling _collect_scheduled_batch_stats
collector and the acceptance-length aggregation.

Also unify draft-token counting. The batch-level generation_token_number
used `1 + num_draft_tokens` while the per-request req_generation_token_number
used a different expression, so the two could disagree, and the raw
num_draft_tokens includes trailing padding zeros. Add a single shared helper
get_effective_draft_token_length() (prefers the C++ num_draft_tokens, falls
back to counting non-zero py_draft_tokens so padding is never miscounted) and
use it in both places so the per-iteration total and the per-request count can
never diverge.

Signed-off-by: Chenfei Zhang <chenfeiz@nvidia.com>
The offline aggregator joined router dispatch records to worker records by
iterating over both the worker request_id and its ctx_request_id, so a router
record whose ctx_request_id numerically equalled an unrelated worker
request_id would false-join. Worse, on the gen-only benchmark path the service
hardcodes ctx_request_id=1 for every request (TRTLLM_DISAGG_BENCHMARK_GEN_ONLY),
so parse_router_dir's last-write-wins map kept a single surviving row under key
"1" and attached it to every worker record.

Join strictly on ctx_request_id (the only key the router and worker genuinely
share), and in parse_router_dir demote any ctx_request_id reported by more than
one distinct request to the non-joinable "_no_ctx" bucket with a WARNING, so
those rows surface as unjoined_router_events leftovers instead of false-joining.
The KV-transfer join (legitimately keyed on request_id) is unchanged.

Add regression tests: the duplicate-ctx-id demotion in parse_router_dir and the
end-to-end gen-only ctx_request_id=1 no-false-join through the merger.

Signed-off-by: Chenfei Zhang <chenfeiz@nvidia.com>
…hmark path

async_request_openai_chat_completions did not populate the perf-time-events
client fields, so a chat-backend benchmark run produced no client_*.jsonl
timeline (only the plain completions path did). Set output.send_time /
output.send_wall_time right after the POST timestamp and capture the server
response_id once in both the streaming and non-streaming branches, mirroring
async_request_openai_completions.

Signed-off-by: Chenfei Zhang <chenfeiz@nvidia.com>
The two GPU-free unit test files (test_perf_time_events.py,
test_perf_time_events_writer.py) were never listed in a test-db yml, so the
unittest/others suite (file-by-file, not auto-collected) never ran them. Add
both to l0_a10.yml next to the sibling test_time_breakdown.py entry.

Also refresh the aggregator README: document the scheduled_time /
capacity_scheduled_time admission timestamps (and the latter's omission under
the merged KV-cache-manager-v2 scheduler), the dummy-request exclusion, the
optional router/client companion timelines and their env vars, the
request_timing_metrics record field, the --router-dir / --client-dir args, the
updated combined-JSON shape, and the strict-ctx router_dispatch join with its
gen-only collision handling.

Signed-off-by: Chenfei Zhang <chenfeiz@nvidia.com>
@chenfeiz0326
chenfeiz0326 requested review from a team as code owners July 31, 2026 10:08

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py (2)

95-150: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Complete the type contract for the aggregation API.

parse_router_dir() and the merger state use Any, so static checking cannot validate the JSON and router-record contracts. PerfTimeEventsMerger.__init__(), write(), and write_html() also omit their None return annotations.

Define concrete event record types and represent the context-indexed records and no-context records with separate typed fields. Add -> None to non-returning methods.

As per coding guidelines: “Annotate every function, use None for non-returning functions, avoid Any.”

Also applies to: 285-303, 478-507

🤖 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 `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py` around lines
95 - 150, Complete the type contract for parse_router_dir and the merger by
defining concrete typed router/event record types instead of Any, with separate
types or fields for context-indexed records and the "_no_ctx" list. Apply these
types to parse_router_dir’s return value and the merger state, and add -> None
annotations to PerfTimeEventsMerger.__init__, write, and write_html (and any
other non-returning functions in the affected code).

Source: Coding guidelines


407-418: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not compute router_to_worker_arrival from unadjusted event timestamps.

PerfMetricsManager writes worker request_timing_metrics from the worker-local clock. The synchronization offset is applied only by /perf_metrics, not by the per-rank event writer. Apply the offset before writing event records, or omit router_to_worker_arrival unless both timestamps use an aligned clock domain. Document this requirement in README.md.

🤖 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 `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py` around lines
407 - 418, The router_to_worker_arrival calculation in PerfMetricsManager must
not subtract timestamps from different clock domains; apply the worker
synchronization offset before event records are written, or omit this derived
metric unless router arrival and worker arrival are aligned. Document the
aligned-clock requirement in
tensorrt_llm/serve/scripts/perf_time_events/README.md at lines 119-129.
♻️ Duplicate comments (1)
tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py (1)

505-514: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Handle only expected queue-full failures during writer shutdown.

Both writers suppress unrelated exceptions with except Exception. This hides teardown defects and fails Ruff S110.

  • tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py#L505-L514: remove the broad handler. Keep queue.Full handling and log a failed shutdown drain.
  • tensorrt_llm/serve/perf_time_events_writer.py#L175-L184: apply the same narrow exception handling and stderr warning.

As per coding guidelines, “Catch specific exceptions instead of using broad or bare exception handling such as except:.”

🤖 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 `@tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py` around lines 505 -
514, In tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py (lines 505-514),
remove the broad except Exception handler and replace it with stderr logging or
warning output when the writer queue shutdown drain fails. Keep the queue.Full
exception handling unchanged. In tensorrt_llm/serve/perf_time_events_writer.py
(lines 175-184), apply the identical narrow exception handling pattern with
stderr warning output instead of the broad except Exception handler. Both sites
should only catch queue.Full and log/warn on unexpected failures rather than
silently suppressing all exceptions during writer thread shutdown.

Sources: Coding guidelines, Linters/SAST tools

🧹 Nitpick comments (4)
tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py (1)

48-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add precise type annotations to the extended capture interfaces.

__init__ has no return annotation. iter_batch_context, response, and ctx_request_id are untyped. _extract_request_timing_metrics() returns a bare dict.

Use None return annotations, | unions, parameterized mappings, and a narrow response protocol or concrete response type. Do not use Any.

As per coding guidelines, “Annotate every function, use None for non-returning functions, avoid Any and unnecessary type ignores, prefer built-in generic types and |.”

Also applies to: 181-203, 363-448

🤖 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 `@tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py` around lines 48 - 58,
Add explicit annotations to __init__, iter_batch_context, and related
extended-capture interfaces, using None for non-returning methods and | unions
where values may be absent. Type response with a narrow protocol or existing
concrete response type, and annotate ctx_request_id with its actual optional
identifier type. Change _extract_request_timing_metrics() from bare dict to a
parameterized built-in mapping type, avoiding Any and unnecessary type ignores
throughout the affected methods.

Source: Coding guidelines

tensorrt_llm/serve/perf_time_events_writer.py (1)

74-107: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Complete JsonlEventWriter type annotations.

__init__ has no None return annotation. write() accepts a bare dict. Use str | None and a parameterized record type such as dict[str, object] or a TypedDict.

As per coding guidelines, “Annotate every function, use None for non-returning functions, avoid Any and unnecessary type ignores, prefer built-in generic types and |.”

🤖 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 `@tensorrt_llm/serve/perf_time_events_writer.py` around lines 74 - 107, Add a
`-> None` return type annotation to the `__init__` method of JsonlEventWriter.
Update the `events_dir` parameter type annotation from `Optional[str]` to `str |
None`. In the `write` method, replace the bare `dict` type annotation for the
`record` parameter with a more specific type such as `dict[str, object]` or a
`TypedDict`, and add a `-> None` return type annotation to the method.

Source: Coding guidelines

tensorrt_llm/_torch/pyexecutor/llm_request.py (1)

1237-1256: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the public helper interface.

Add Args and Returns sections to this docstring. The helper is imported by other executor modules.

Proposed documentation update
 def get_effective_draft_token_length(request: LlmRequest) -> int:
     """Return the *effective* number of speculative draft tokens for a request.
+
+    Args:
+        request: Request that provides speculative draft-token state.
+
+    Returns:
+        Number of non-padding draft tokens for the current request step.
 
     Single source of truth for "how many draft tokens did this request actually

As per coding guidelines, “document public function arguments.”

🤖 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 `@tensorrt_llm/_torch/pyexecutor/llm_request.py` around lines 1237 - 1256,
Update the get_effective_draft_token_length docstring to document its public
interface with an Args section describing the request parameter and a Returns
section describing the effective draft-token count. Preserve the existing
behavior and explanatory documentation.

Source: Coding guidelines

tensorrt_llm/_torch/pyexecutor/py_executor.py (1)

1910-1914: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Annotate _compute_iter_batch_context parameters and return mapping.

num_fitting_reqs is an optional integer. Both timestamps are optional floats. The mapping contains only integer and float values.

Proposed type annotations
-                                    num_fitting_reqs=None,
-                                    schedule_time=None,
-                                    capacity_admit_time=None) -> dict:
+                                    num_fitting_reqs: int | None = None,
+                                    schedule_time: float | None = None,
+                                    capacity_admit_time: float | None = None
+                                    ) -> dict[str, int | float]:
🤖 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 `@tensorrt_llm/_torch/pyexecutor/py_executor.py` around lines 1910 - 1914,
Update _compute_iter_batch_context with type annotations: declare
num_fitting_reqs as an optional integer, schedule_time and capacity_admit_time
as optional floats, and annotate the returned dict as mapping string keys to
integer or float values. Preserve the existing behavior and parameter defaults.

Sources: Coding guidelines, Learnings

🤖 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 `@tensorrt_llm/_torch/pyexecutor/py_executor.py`:
- Around line 1993-1994: Update the `num_capacity_fitting` assignment in the
scheduler context to exclude attention-DP dummy requests, using the same
`is_dummy` filtering already applied when computing `num_scheduled`. Keep
`num_scheduled` unchanged and ensure the fitting count reflects only real
requests.

In `@tests/unittest/others/test_perf_time_events.py`:
- Around line 386-410: Extend the regression coverage in
test_gen_only_ctx_id_1_does_not_false_join to add a worker record with
request_id="10" and ctx_request_id="ctxA", plus a router record whose
ctx_request_id="10". Assert the worker has no router_dispatch and the router
remains in merger.unjoined_router_events, preventing matching through the
worker-local request_id while preserving the existing duplicate-context
assertions.

---

Outside diff comments:
In `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py`:
- Around line 95-150: Complete the type contract for parse_router_dir and the
merger by defining concrete typed router/event record types instead of Any, with
separate types or fields for context-indexed records and the "_no_ctx" list.
Apply these types to parse_router_dir’s return value and the merger state, and
add -> None annotations to PerfTimeEventsMerger.__init__, write, and write_html
(and any other non-returning functions in the affected code).
- Around line 407-418: The router_to_worker_arrival calculation in
PerfMetricsManager must not subtract timestamps from different clock domains;
apply the worker synchronization offset before event records are written, or
omit this derived metric unless router arrival and worker arrival are aligned.
Document the aligned-clock requirement in
tensorrt_llm/serve/scripts/perf_time_events/README.md at lines 119-129.

---

Duplicate comments:
In `@tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py`:
- Around line 505-514: In tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py
(lines 505-514), remove the broad except Exception handler and replace it with
stderr logging or warning output when the writer queue shutdown drain fails.
Keep the queue.Full exception handling unchanged. In
tensorrt_llm/serve/perf_time_events_writer.py (lines 175-184), apply the
identical narrow exception handling pattern with stderr warning output instead
of the broad except Exception handler. Both sites should only catch queue.Full
and log/warn on unexpected failures rather than silently suppressing all
exceptions during writer thread shutdown.

---

Nitpick comments:
In `@tensorrt_llm/_torch/pyexecutor/llm_request.py`:
- Around line 1237-1256: Update the get_effective_draft_token_length docstring
to document its public interface with an Args section describing the request
parameter and a Returns section describing the effective draft-token count.
Preserve the existing behavior and explanatory documentation.

In `@tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py`:
- Around line 48-58: Add explicit annotations to __init__, iter_batch_context,
and related extended-capture interfaces, using None for non-returning methods
and | unions where values may be absent. Type response with a narrow protocol or
existing concrete response type, and annotate ctx_request_id with its actual
optional identifier type. Change _extract_request_timing_metrics() from bare
dict to a parameterized built-in mapping type, avoiding Any and unnecessary type
ignores throughout the affected methods.

In `@tensorrt_llm/_torch/pyexecutor/py_executor.py`:
- Around line 1910-1914: Update _compute_iter_batch_context with type
annotations: declare num_fitting_reqs as an optional integer, schedule_time and
capacity_admit_time as optional floats, and annotate the returned dict as
mapping string keys to integer or float values. Preserve the existing behavior
and parameter defaults.

In `@tensorrt_llm/serve/perf_time_events_writer.py`:
- Around line 74-107: Add a `-> None` return type annotation to the `__init__`
method of JsonlEventWriter. Update the `events_dir` parameter type annotation
from `Optional[str]` to `str | None`. In the `write` method, replace the bare
`dict` type annotation for the `record` parameter with a more specific type such
as `dict[str, object]` or a `TypedDict`, and add a `-> None` return type
annotation to the method.
🪄 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: 046160f9-ee05-4c61-9393-c052556a8781

📥 Commits

Reviewing files that changed from the base of the PR and between 9c368ea and e8827be.

📒 Files selected for processing (9)
  • tensorrt_llm/_torch/pyexecutor/llm_request.py
  • tensorrt_llm/_torch/pyexecutor/perf_metrics_manager.py
  • tensorrt_llm/_torch/pyexecutor/py_executor.py
  • tensorrt_llm/serve/perf_time_events_writer.py
  • tensorrt_llm/serve/scripts/backend_request_func.py
  • tensorrt_llm/serve/scripts/perf_time_events/README.md
  • tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py
  • tests/integration/test_lists/test-db/l0_a10.yml
  • tests/unittest/others/test_perf_time_events.py

Comment on lines +1993 to +1994
ctx["num_capacity_fitting"] = num_fitting_reqs
ctx["num_scheduled"] = iter_batch_size

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C5 'num_fitting_requests|num_fitting' \
  tensorrt_llm/_torch/pyexecutor/scheduler \
  tensorrt_llm/_torch/pyexecutor/py_executor.py

rg -n -C4 'is_dummy|is_attention_dp_dummy|is_cuda_graph_dummy' \
  tensorrt_llm/_torch/pyexecutor/scheduler \
  tensorrt_llm/_torch/pyexecutor/py_executor.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 38655


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline tensorrt_llm/_torch/pyexecutor/scheduler/scheduler.py
ast-grep outline tensorrt_llm/_torch/pyexecutor/scheduler/scheduler_v2.py

sed -n '1740,1910p' tensorrt_llm/_torch/pyexecutor/scheduler/scheduler.py
sed -n '180,270p' tensorrt_llm/_torch/pyexecutor/scheduler/scheduler_v2.py

rg -n -C8 'fitting_requests|is_dummy_request|is_cuda_graph_dummy|is_attention_dp_dummy|is_dummy' \
  tensorrt_llm/_torch/pyexecutor/scheduler tensorrt_llm/_torch/pyexecutor/py_executor.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- dummy flag definitions and assignments ---'
rg -n -C3 'is_dummy\s*=|is_dummy_request\s*=|is_attention_dp_dummy\s*=|is_cuda_graph_dummy\s*=' \
  tensorrt_llm --glob '*.py' --glob '*.cpp' --glob '*.h' --glob '*.hpp'

printf '%s\n' '--- dummy creation and scheduling order ---'
rg -n -C12 '_pad_attention_dp_dummy_request|add_dummy_requests|cuda_graph_dummy|is_attention_dp_dummy' \
  tensorrt_llm/_torch/pyexecutor/py_executor.py

printf '%s\n' '--- scheduler admission paths ---'
sed -n '3760,3840p' tensorrt_llm/_torch/pyexecutor/py_executor.py
sed -n '2660,2720p' tensorrt_llm/_torch/pyexecutor/py_executor.py
sed -n '500,620p' tensorrt_llm/_torch/pyexecutor/scheduler/scheduler.py
sed -n '250,430p' tensorrt_llm/_torch/pyexecutor/scheduler/scheduler_v2.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 47945


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- LlmRequest dummy properties ---'
rg -n -C10 'def is_dummy|is_dummy_request|is_dummy\b' \
  tensorrt_llm/_torch/pyexecutor/llm_request.py \
  tensorrt_llm/_torch/pyexecutor/py_executor.py \
  tensorrt_llm/_torch/pyexecutor/resource_manager.py \
  tensorrt_llm/_torch/pyexecutor/kv_cache_manager_v2.py

printf '%s\n' '--- batch-size calculation and dummy filtering ---'
sed -n '1856,1998p' tensorrt_llm/_torch/pyexecutor/py_executor.py

printf '%s\n' '--- dummy request initial state ---'
sed -n '900,970p' tensorrt_llm/_torch/pyexecutor/resource_manager.py
sed -n '2935,2980p' tensorrt_llm/_torch/pyexecutor/kv_cache_manager_v2.py
rg -n -C5 'class LlmRequest|LlmRequest\(' tensorrt_llm/_torch/pyexecutor/llm_request.py | head -120

Repository: NVIDIA/TensorRT-LLM

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- dummy state assignment ---'
sed -n '2980,3035p' tensorrt_llm/_torch/pyexecutor/kv_cache_manager_v2.py
sed -n '960,1015p' tensorrt_llm/_torch/pyexecutor/resource_manager.py

printf '%s\n' '--- CUDA-graph dummy lifecycle ---'
sed -n '570,625p' tensorrt_llm/_torch/pyexecutor/cuda_graph_runner.py
rg -n -C8 'active_requests\.append|add_dummy_requests' \
  tensorrt_llm/_torch/pyexecutor/cuda_graph_runner.py

printf '%s\n' '--- static consistency check ---'
python3 - <<'PY'
from pathlib import Path

executor = Path("tensorrt_llm/_torch/pyexecutor/py_executor.py").read_text()
scheduler = Path("tensorrt_llm/_torch/pyexecutor/scheduler/scheduler.py").read_text()
scheduler_v2 = Path(
    "tensorrt_llm/_torch/pyexecutor/scheduler/scheduler_v2.py"
).read_text()
request = Path("tensorrt_llm/_torch/pyexecutor/llm_request.py").read_text()

checks = {
    "ADP dummy is appended before scheduling": (
        "self.active_requests.append(llm_request)" in executor
        and "_pad_attention_dp_dummy_request()" in executor
    ),
    "dummy property includes dummy-request flag": (
        "return self.is_attention_dp_dummy or self.is_cuda_graph_dummy or self.is_dummy_request"
        in request
    ),
    "V1 fitting count is raw fitting-list length": (
        "num_fitting_requests=len(fitting_requests)" in scheduler
    ),
    "V2 fitting count is raw scheduled-list length": (
        "num_fitting_requests=(len(scheduled_encoder) + len(scheduled_ctx) + len(scheduled_gen))"
        in scheduler_v2
    ),
    "perf scheduled count filters dummy requests": (
        "if not self._is_stats_dummy_request(req)" in executor
    ),
}

for name, result in checks.items():
    print(f"{name}: {'YES' if result else 'NO'}")
PY

Repository: NVIDIA/TensorRT-LLM

Length of output: 11739


Exclude attention-DP dummy requests from num_capacity_fitting. The scheduler adds them to the fitting count, while num_scheduled excludes them through is_dummy. This inflates the starvation gap.

🤖 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 `@tensorrt_llm/_torch/pyexecutor/py_executor.py` around lines 1993 - 1994,
Update the `num_capacity_fitting` assignment in the scheduler context to exclude
attention-DP dummy requests, using the same `is_dummy` filtering already applied
when computing `num_scheduled`. Keep `num_scheduled` unchanged and ensure the
fitting count reflects only real requests.

When perf-time-events capture is enabled, summarize the lifecycle intervals
between time events as mean / P50 / P99 (+ min / max / n) and write them to an
additional JSONL file via a new `-a/--agg-jsonl` flag on the offline merger
(also honoring TRTLLM_PERF_TIME_EVENTS_AGG_PATH). One JSON object per line, one
line per metric, in a stable order: the 12 canonical time_breakdown spans, the
router dispatch chain, the custom worker spans, and the vLLM-named views
(TTFT / TPOT / ITL / E2E). Rows are always emitted (never silently dropped); an
interval never validly measured carries `status: not_recorded`, and each row
records `clock_safe` so the two cross-clock-domain disagg spans are flagged
rather than differenced.

The aggregate path is pure stdlib -- it does NOT import time_breakdown / numpy /
plotly, so the offline aggregate runs anywhere, not just inside the torch
container.

Validated on a real GB200 gpt-oss-120b NIXL gen_only capture; three refinements
fell out of that data:
- rank-dedup by (role, request_id): a tensor-parallel worker writes each request
  once per rank with lockstep-identical timings, so worker-derived groups dedup
  first, otherwise n inflates by the TP degree and fakes precision.
- _infer_role keys on step_metrics / nonzero kv_cache_size, NOT
  kv_cache_transfer_start (ctx records carry a nonzero value there while gen
  records on the sync path carry 0.0), so roles are never misclassified.
- the canonical-12 spec is hardcoded and guarded by a drift test that
  cross-checks time_breakdown.TimingMetricsConfig whenever it is importable.

Also registers the previously orphaned test_perf_time_events.py in the l0_a10
CI test list.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Chenfei Zhang <chenfeiz@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

🧹 Nitpick comments (2)
tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py (1)

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

Document the cross-server limit of the request_id dedup key.

The key is request_id only. request_id is assigned per executor, so two different logical requests handled by two different servers of the same role can share the same value. In that case the second sample is dropped and n under-reports. The docstring states only the tensor-parallel rank case. Add the cross-server caveat so readers of the aggregate do not treat n as an exact request count.

🤖 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 `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py` around lines
435 - 456, The docstring for _dedup_records_by_role should document that
request_id is only unique within an executor, so different servers with the same
role may collide and have one sample dropped. Add this cross-server limitation
and clarify that the resulting n is not necessarily an exact logical-request
count; leave the deduplication behavior unchanged.
tests/unittest/others/test_perf_time_events.py (1)

454-880: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test coverage summary

  1. Changed test functions. Added: TestAggregateHelpers.test_percentile_math, test_duration_excludes_missing_and_inverted, test_nz_treats_zero_as_missing, test_infer_role; TestAggregateMetrics.test_all_rows_present_and_ordered, test_empty_input_all_not_recorded, test_gen_arrival_to_first_scheduled_stats, test_rank_duplication_deduped, test_zeros_excluded, test_kv_span_not_recorded_on_zero_setters, test_ctx_forward_span_skips_warmup, test_router_chain_stats, test_router_no_ctx_bucket_included, test_itl_and_tpot_from_step_metrics, test_vllm_ttft_e2e_from_client_events, test_clock_safe_flags, test_canonical_rows_not_recorded_on_per_rank_capture, test_write_agg_jsonl_roundtrip; TestAggregatePathIsDependencyLight.test_no_toplevel_numpy_or_time_breakdown_import, test_canonical_matches_time_breakdown; helpers _row, _gen_record, _ctx_record, _disagg_perf_record, constant _EXPECTED_METRICS. Modified: test_gen_only_ctx_id_1_does_not_false_join (formatting only). Removed: none.
  2. Test-list registration. The PR states the file is registered in tests/integration/test_lists/test-db/l0_a10.yml. That list file is not supplied in this prompt, so registration is not confirmed here. No tests/integration/test_lists/qa/ entry was supplied.
  3. Coverage verdict: needs follow-up. Two gaps remain: no test exercises the --agg-jsonl CLI option in main() (only write_agg_jsonl is called directly), and the request-ID collision regression for the router join is still absent.

As per path instructions: "Always produce a test coverage summary, even if no issues are found."

🤖 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/unittest/others/test_perf_time_events.py` around lines 454 - 880,
Extend the aggregate test coverage in TestAggregateMetrics to invoke the main()
CLI path with --agg-jsonl, verifying it writes the expected JSONL output, and
add a regression test for router joins where request IDs collide across sources,
ensuring events remain associated with the correct request. Preserve the
existing direct write_agg_jsonl and router coverage.

Source: Path instructions

🤖 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/unittest/others/test_perf_time_events.py`:
- Around line 856-866: Update the module-scope import checks in the AST loop to
apply the existing time_breakdown prohibition to ast.Import aliases as well as
ast.ImportFrom modules, detecting the full dotted import name rather than only
its root for plain imports. Preserve the current numpy and ImportFrom checks.

---

Nitpick comments:
In `@tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py`:
- Around line 435-456: The docstring for _dedup_records_by_role should document
that request_id is only unique within an executor, so different servers with the
same role may collide and have one sample dropped. Add this cross-server
limitation and clarify that the resulting n is not necessarily an exact
logical-request count; leave the deduplication behavior unchanged.

In `@tests/unittest/others/test_perf_time_events.py`:
- Around line 454-880: Extend the aggregate test coverage in
TestAggregateMetrics to invoke the main() CLI path with --agg-jsonl, verifying
it writes the expected JSONL output, and add a regression test for router joins
where request IDs collide across sources, ensuring events remain associated with
the correct request. Preserve the existing direct write_agg_jsonl and router
coverage.
🪄 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: 1d8faa3f-69bc-4c9c-a41a-5e4bd0a3b903

📥 Commits

Reviewing files that changed from the base of the PR and between e8827be and 8cb9693.

📒 Files selected for processing (2)
  • tensorrt_llm/serve/scripts/perf_time_events/perf_time_events.py
  • tests/unittest/others/test_perf_time_events.py

Comment on lines +856 to +866
with open(pte.__file__) as f:
tree = ast.parse(f.read())
banned = ("numpy", "plotly")
for node in tree.body: # module scope only
if isinstance(node, ast.Import):
for alias in node.names:
self.assertNotIn(alias.name.split(".")[0], banned)
elif isinstance(node, ast.ImportFrom):
root = (node.module or "").split(".")[0]
self.assertNotIn(root, banned)
self.assertNotIn("time_breakdown", (node.module or ""))

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Apply the time_breakdown check to plain import statements too.

The docstring states the test guards against a module-scope import of numpy or time_breakdown. The time_breakdown assertion runs only in the ast.ImportFrom branch. A module-scope import tensorrt_llm.serve.scripts.time_breakdown.time_breakdown is an ast.Import whose root name is tensorrt_llm, so it passes both checks and the guard does not fire.

💚 Proposed fix
         banned = ("numpy", "plotly")
         for node in tree.body:  # module scope only
             if isinstance(node, ast.Import):
                 for alias in node.names:
                     self.assertNotIn(alias.name.split(".")[0], banned)
+                    self.assertNotIn("time_breakdown", alias.name)
             elif isinstance(node, ast.ImportFrom):
                 root = (node.module or "").split(".")[0]
                 self.assertNotIn(root, banned)
                 self.assertNotIn("time_breakdown", (node.module or ""))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
with open(pte.__file__) as f:
tree = ast.parse(f.read())
banned = ("numpy", "plotly")
for node in tree.body: # module scope only
if isinstance(node, ast.Import):
for alias in node.names:
self.assertNotIn(alias.name.split(".")[0], banned)
elif isinstance(node, ast.ImportFrom):
root = (node.module or "").split(".")[0]
self.assertNotIn(root, banned)
self.assertNotIn("time_breakdown", (node.module or ""))
with open(pte.__file__) as f:
tree = ast.parse(f.read())
banned = ("numpy", "plotly")
for node in tree.body: # module scope only
if isinstance(node, ast.Import):
for alias in node.names:
self.assertNotIn(alias.name.split(".")[0], banned)
self.assertNotIn("time_breakdown", alias.name)
elif isinstance(node, ast.ImportFrom):
root = (node.module or "").split(".")[0]
self.assertNotIn(root, banned)
self.assertNotIn("time_breakdown", (node.module or ""))
🤖 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/unittest/others/test_perf_time_events.py` around lines 856 - 866,
Update the module-scope import checks in the AST loop to apply the existing
time_breakdown prohibition to ast.Import aliases as well as ast.ImportFrom
modules, detecting the full dotted import name rather than only its root for
plain imports. Preserve the current numpy and ImportFrom checks.

The shipped capture emits one compound JSON object per request at
completion (the worker record is gated on `time_breakdown_metrics`, which
`create_response` fills only on the final response). Two problems:

1. Hung requests capture nothing. A request that wedges (KV-transfer
   timeout livelock at iter=0, fill-gate livelock, HangDetector MPI_Abort
   at 300s) never completes, so the writer emits zero lines for exactly
   the requests whose partial timeline would localize the stall.
2. Volume. The compound record carries a per-decode-step series
   (~256-1024 entries) per request; the lifecycle signal is buried.

Redesign: write one line per lifecycle event, flushed as it fires, into
each rank's own log. Only the gen-init request's lifecycle is recorded --
decode requests emit no time events (the dense per-decode-step series is
dropped entirely). An offline compiler reads all per-rank worker logs +
router log + client log and pivots them long -> wide into one combined
JSONL with every request's time events. A request that never completes
leaves its partial timeline; the last event present pins the wedge.

Worker/router/client emit lines share one flat envelope:
{role, event, request_id, ctx_request_id, rank, t, pid, **extra}.

Changes:
- perf_time_events_writer.py: add make_event_record (flat envelope),
  process-global worker writer + set_worker_rank, and emit_event
  (worker-only, inert unless TRTLLM_PERF_TIME_EVENTS_PATH is set). The
  sole steady-clock import is quarantined in _lazy_steady_clock_now so
  the router/client stay torch-free.
- py_executor.py / seq_slot_manager.py: emit the gen-init lifecycle
  events (arrival, gen_init_scheduled, KV-transfer start/end, first/last
  token) and ctx events (arrival, first_scheduled, first_token,
  ctx_ready_sent) at their lifecycle sites, all first-fire guarded and
  env-gated. Adds the missing logger import in seq_slot_manager.py (the
  gen-init branch's existing logger.info was a latent NameError).
- openai_disagg_server.py: RawRequestResponseHooks now emits one router
  line per hook (arrival/ctx_dispatch/gen_dispatch/first_token/resp_done)
  instead of one compound record at resp_done.
- Retire the completion-gated in-class worker writer
  (maybe_write_request_events) and the extended per-iteration
  batch-context path (capture_extended, _compute_iter_batch_context,
  iter_batch_context, SimpleScheduler.capture_admit_time,
  get_effective_draft_token_length).
- perf_time_events.py: rework ingestion to one line = one event, group by
  (role, request_id), dedup TP ranks, join ctx<->gen<->router on
  ctx_request_id, and pivot long -> wide into --events-jsonl
  combined_time_events.jsonl (primary output). Stays torch-free.
- Rewrite tests to event-line fixtures + a hung-request test; delete the
  retired compound-writer test.
- Update perf-analysis.md + scripts/.../README.md to the per-event model.

Signed-off-by: Chenfei Zhang <chenfeiz@nvidia.com>

@brnguyen2 brnguyen2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The capture design (per-event append+flush, off-thread writer, offline long→wide compiler) is sound and the aggregator/writer tests are thorough. Three things before merge:

  1. The PR description no longer matches the code. It describes capture_extended, per-iteration batch context (iter_batch_size, starvation counts) riding inside time_breakdown_metrics, PyExecutor.shutdown() joining the writer, and a test file tests/unittest/_torch/executor/test_pe… — none of which exist in this diff (grep confirms no capture_extended/num_capacity_fitting anywhere; writer teardown is atexit-only; only tests/unittest/others/ tests are added). Please rewrite the description to match the per-event design the code actually implements — reviewers and future readers will otherwise review a PR that isn't there.

  2. /perf_metrics crash regression — see the inline comment on [openai_server.py:1389](https://github.com/NVIDIA/TensorRT-LLM/pull/16900/files#diff-2eb783dbd719ea8b2067105279f5a50460168065912d0efd2a3b2cdc0b3a78e5R1389); reproducible with one chat-completions request while the env var is set (and also for pre-existing perf_metrics_max_requests > 0 + return_perf_metrics=False configs).

  3. Ticket: this is a substantial feature (+3435 lines, new env vars, new user-facing tool); it should carry a JIRA ticket rather than [None].

Smaller: the chat-completions path never gets the env force-enable (inline), and the aggregator's TP-dedup assumption breaks with multiple ctx/gen server instances (inline). The pipeline-parallel gap is documented — fine as a follow-up. I could not run the new unit tests in my environment; please confirm the l0_a10 stage is green.

if getattr(self.generator.args, "enable_iter_perf_stats", True):
self._iteration_stats_wakeup_event.set()
if self.generator.args.return_perf_metrics:
if self.generator.args.return_perf_metrics or self.perf_metrics is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This widened gate appends items whose perf_metrics is None for any request that didn't set return_perf_metrics — notably every /v1/chat/completions request, since openai_chat has no env force-enable (see comment at line 1957). get_perf_metrics then does metrics.timing_metrics with no None guard (line 1298), so one chat request while TRTLLM_PERF_TIME_EVENTS_PATH is set makes GET /perf_metrics 500 with AttributeError: 'NoneType' object has no attribute 'timing_metrics'. It also regresses the pre-existing perf_metrics_max_requests > 0 + return_perf_metrics=False config, which previously appended nothing. Either skip the append when res.outputs[0].request_perf_metrics is None, or skip None entries in get_perf_metrics.

# TODO: better way to enable metrics
if len(os.getenv("TRTLLM_KVCACHE_TIME_OUTPUT_PATH", "")) > 0:
if (len(os.getenv("TRTLLM_KVCACHE_TIME_OUTPUT_PATH", "")) > 0
or len(os.getenv("TRTLLM_PERF_TIME_EVENTS_PATH", "")) > 0):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This force-enable exists on the completions path (here), the harmony path (line 2117), and responses ([responses_utils.py:891](https://github.com/NVIDIA/TensorRT-LLM/pull/16900/files#diff-7cdeb7d30f2f1098702aaba2e58365398261a69256a7f5a2b08628b3af05d3e8R891)) — but not in openai_chat. With only TRTLLM_PERF_TIME_EVENTS_PATH set, chat-completions requests reach the executor with return_perf_metrics=False, so every worker emit site is skipped and the per-rank files stay empty — for exactly the endpoint the benchmark client in this PR instruments. Consider adding the same check in openai_chat (or factoring the env check into one helper so the fourth copy can't drift).


### Primary output: `combined_time_events.jsonl`

One line per request. The compiler groups events by `request_id`, dedups the TP

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Grouping worker events by request_id alone (_pivot_worker_events) collapses TP ranks correctly, but with more than one ctx (or gen) server instance writing into the same directory — a common disagg topology — the per-server request-id counters collide, and first-seen-wins silently merges two different requests' timestamps into one record (also corrupting the ctx_request_id join). pid can't disambiguate since TP ranks are distinct pids too. At minimum document a single-instance-per-role restriction here; a proper fix is a per-server instance token in the record or filename (the KV CSVs already carry an {instance} prefix you could mirror).

# make close()'s put() block forever on an exited consumer) and drop
# the thread handle so close() early-returns.
self._events_dir = None
self._writer_thread = None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Setting self._writer_thread = None on open failure races with a concurrent write() that already passed the _events_dir is None check: _ensure_writer sees no thread and spawns a fresh queue+thread, which retries the failing open and warns again — once per racing write. Consider keeping the dead thread handle (or a _failed flag) so the writer goes permanently inert after the first failure; close() already tolerates a non-draining queue via the timed put.


**Known limitations:**

* **Two clock domains.** The steady clocks split into domain A = {router, gen

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor doc accuracy: the A/B domain split isn't intrinsic to the roles — steady-clock epochs are per-host (monotonic since boot), so the split is whatever the deployment's process placement happens to be. On a topology where router and ctx share a host, the doc's stated pairing is wrong in both directions. Worth rephrasing as "timestamps are only comparable between processes on the same host; in the reference deployment that meant {router, gen} vs {ctx, client}" — the aggregator itself is safe since it only differences within one record.

@ZhanruiSunCh ZhanruiSunCh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM for infra part.

@crazydemo crazydemo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary - BLOCK

Verdict: Cannot merge: the branch is in conflict (mergeable_state: dirty) and, more importantly, the broadened /perf_metrics extraction guard introduces a reproducible crash regression on the chat-completions path when the new env var is set.

Issues

  • [BLOCKER] tensorrt_llm/serve/openai_server.py:1389 - extraction guard or self.perf_metrics is not None runs on requests with no per-request perf metrics -> crash (chat-completions path is not force-enabled).
  • [MINOR] tensorrt_llm/serve/openai_disagg_service.py:174 - on_gen_dispatch wired only on the ctx-first path; generation_first loses router gen_dispatch events.
  • [NIT] tensorrt_llm/serve/scripts/perf_time_events/__init__.py:35 - __all__ not sorted (Ruff RUF022).

QA view

  • Test coverage: partial - the aggregator and the stdlib writer are well covered by the two new unit tests; the production instrumentation (py_executor emit sites, seq_slot_manager, and the openai_server:1389 guard = the crash path) has no test. The test_perf_metrics_events.py referenced in the description is absent from this diff.
  • SM coverage: architecture-independent - pure Python timing capture and stdlib aggregation; no arch-guarded / fp8 / nvfp4 paths.
  • Test code: no issues in the added tests themselves (deterministic, torch-free, drift guard present); the gap is what is left untested.
  • Test time: small - two CPU-only torch-free files added to l0_a10; fast.
  • Needs /qa-verify: yes - a reproducible crash regression on an untested path, plus GPU e2e verification still in progress.

Possible new issues

  • Chat-completions + TRTLLM_PERF_TIME_EVENTS_PATH set: request enters the extraction block without perf metrics -> crash (also regresses pre-existing perf_metrics_max_requests>0 + return_perf_metrics=False).
  • generation_first disagg schedule style: gen_dispatch_time stays 0, so router gen_dispatch spans are silently missing.

What I could not verify

  • The exact crash site inside _extract_metrics is past the visible diff window (the item construction beyond line 1392 is not shown), so I am relying on the diff's guard change plus reviewer brnguyen2's reproduction rather than tracing the failing dereference line-by-line.
  • Whether the merge conflict (dirty) is trivial or substantive - only the mechanical signal is available.
  • Runtime overhead of the on-loop dict-build + put_nowait and the daemon-thread behavior under real load (the claimed near-zero A/B delta) cannot be confirmed from the diff.

Automated review by NVCortex Lite, run by @crazydemo.

if getattr(self.generator.args, "enable_iter_perf_stats", True):
self._iteration_stats_wakeup_event.set()
if self.generator.args.return_perf_metrics:
if self.generator.args.return_perf_metrics or self.perf_metrics is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[BLOCKER] /perf_metrics extraction guard broadened -> crash on requests without perf metrics

The guard changed from if self.generator.args.return_perf_metrics: to if self.generator.args.return_perf_metrics or self.perf_metrics is not None:. When TRTLLM_PERF_TIME_EVENTS_PATH is set, self.perf_metrics becomes a non-None deque even when the LLM-level return_perf_metrics=False. The env force-enable that flips sampling_params.return_perf_metrics=True is only wired into the completions / responses / request_preprocess paths (lines 1956-1957, 2116-2117 here, and responses_utils:891-892) — the chat-completions path is NOT force-enabled. So a single chat-completions request with the env var set enters this block while its res.outputs[0] carries no per-request perf metrics, and the subsequent item construction dereferences absent timing metrics -> crash. This is reproducible and was independently confirmed by reviewer brnguyen2. It also regresses the pre-existing perf_metrics_max_requests > 0 + return_perf_metrics=False configuration for the same reason. Fix: gate on the request actually having perf metrics (e.g. also check output.request_perf_metrics is not None) before building the item, or ensure the chat-completions path receives the same env force-enable so the metrics are always populated when this block runs.

if (self.generator.args.return_perf_metrics or self.perf_metrics is not None):
    output = res.outputs[0]
    if getattr(output, "request_perf_metrics", None) is None:
        return  # nothing to record for this request
    ...

# Mark gen-dispatch start: ctx-response (or arrival, on the no-ctx
# path) to here is the pre-generation wait in the orchestrator.
if hooks:
hooks.on_gen_dispatch(gen_req)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] on_gen_dispatch wired only on ctx-first schedule path

on_gen_dispatch is invoked here in _send_disagg_request_ctx_first, but the generation-first schedule path (_send_disagg_request_gen_first, not in this diff) never calls it. For deployments using schedule_style: generation_first, RawRequestResponseHooks.gen_dispatch_time stays 0 -> the router gen_dispatch event and the router:*->gen_dispatch spans are silently absent for that mode. Not a crash, but the new router dispatch-timeline is incomplete for generation_first. Add an equivalent hooks.on_gen_dispatch(...) call before gen-server resolution in the gen-first path for parity.

parse_router_dir,
)

__all__ = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[NIT] all not sorted (Ruff RUF022)

Ruff RUF022 flags __all__ as unsorted. Reorder alphabetically (PerfTimeEventsMerger, main, parse_client_dir, parse_event_dir, parse_kv_csv_dir, parse_router_dir) to satisfy the linter. Cosmetic only.

@crazydemo crazydemo left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review summary - Approve (non-blocking)

Approving so this is not blocked on me. The points raised in my review comment above are non-blocking — please read them and address what you agree with before merging.

Worth doing before this is relied on: A reproducible /perf_metrics crash regression exists on the chat-completions path with the env var set, and that path plus the executor instrumentation have no automated test. GPU end-to-end verification is still 'in progress' per the PR. A human QA should reproduce the crash, confirm the fix, and validate the l0_a10 stage is green.

Automated review by NVCortex Lite, run by @crazydemo.

@JunyiXu-nv JunyiXu-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requesting changes: the branch conflicts with main's perf-metrics and disagg-coordinator refactors (a mechanical rebase also crashes return_perf_metrics runs), and the ctx/gen join plus two timestamps need fixes. Details inline.

# return_perf_metrics / perf_metrics_max_requests were not set. The
# per-rank live files remain the primary artifact; this is
# convenience.
if (self.perf_metrics is None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On main, #16668 removed /perf_metrics and this deque, so this block, get_perf_metrics, the L1389 gate and the compiler's --perf-json input all target code that no longer exists. The branch is about 3 months behind and conflicts in py_executor.py and several other files; main's DisaggTransferCoordinator refactor (#18827, #19128, #19266) moved the code the ctx_ready_sent and gen_kv_transfer_* emits hook into. #16668 also added perf_metrics_output_dir, which already forces return_perf_metrics for every endpoint, chat included. Could you rebase and gate capture on it instead of three new env vars?

# server sees the rest). Inert unless TRTLLM_PERF_TIME_EVENTS_PATH is set.
for request in validated_requests:
if request.return_perf_metrics:
role = ("gen" if request.is_generation_only_request()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Main made LlmRequest.is_generation_only_request a property in #18858. This call and the one at seq_slot_manager.py:61 merge without a conflict, so after the rebase they raise TypeError: 'bool' object is not callable whenever return_perf_metrics is on, with or without the env var (any perf_metrics_output_dir or return_perf_metrics: true run). The call at L7014 sits in a conflict hunk and needs the same change. Please drop the () at all three.

non-disagg runs or when disagg params are absent.
"""
params = getattr(request, "py_disaggregated_params", None)
return getattr(params, "ctx_request_id",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

On the ctx worker py_disaggregated_params.ctx_request_id looks always None: _get_ctx_request only sets disagg_request_id, and gen learns ctx_request_id later from the ctx response, where it is the ctx request's own request_id. So every ctx_* line has ctx_request_id: null and the compiler never joins the ctx half. When I feed the writer and compiler those shapes, one request comes out as 2 records (ctx only, and gen + router). Did a real ctx+gen run join for you? For context-only requests, falling back to request.request_id here fixes it (1 record in the same repro).

if request.return_perf_metrics:
role = ("gen" if request.is_generation_only_request()
else "ctx")
emit_event(role, f"{role}_arrival",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

*_arrival is stamped here, after _pop_from_waiting_queue (gated by capacity and the fill throttle) and after ADP routing, so it is activation time, not arrival. A request held in waiting_queue because the worker is at max_num_active_requests writes no worker line at all, and arrival->*_scheduled leaves that wait out. That is the kind of stall this tool is for. Was that intended? Stamping when the request enters waiting_queue in _fetch_and_enqueue_requests would cover it.

if (req.return_perf_metrics
and not getattr(req, "py_te_gen_kv_transfer_end", False)):
req.py_te_gen_kv_transfer_end = True
emit_event("gen", "gen_kv_transfer_end",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

SEQ_SLOT_MANAGER.prepare_resources at L5909 has already emitted gen_first_scheduled for this request, so gen_kv_transfer_end lands a few microseconds after it. gen:kv_transfer_end->first_scheduled is then inverted, and _span_or_none always drops it. The wait between the transfer finishing and the request getting into a batch is counted in gen:kv_transfer_start->end instead. Suggest stamping it where the request turns DISAGG_GENERATION_TRANS_COMPLETE (after request_and_receive_sync / check_gen_transfer_status).

"""Daemon loop: drain the queue and append one JSON line per record."""
path = os.path.join(
self._events_dir,
f"{self._filename_prefix}_pid{os.getpid()}.jsonl",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

{prefix}_pid{P}.jsonl opened in append mode is only unique within one live PID namespace. ctx and gen in separate containers writing to a shared volume can both get time_events_rank0_pid<N>. A restarted container usually gets the same pid back and appends to the previous run's file, and the router's (pid, request_id) keys restart at 0 too. Main's PerfMetricsJsonlWriter names files {kind}-{hostname}-{pid}-{utc timestamp}. Could this do the same?

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants