Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 148 additions & 16 deletions tests/integration/defs/perf/test_perf_sanity.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@
import socket
import subprocess
import time
from collections import deque
from typing import Dict, List, NamedTuple, Optional, Tuple

import pytest
import yaml
from test_common.error_utils import report_error
from test_common.error_utils import check_error, report_error
from test_common.http_utils import fail_if_proc_died, wait_for_endpoint_ready
from test_common.perf_sanity_matching import get_client_match_keys, get_server_match_keys

Expand Down Expand Up @@ -98,6 +99,142 @@ def ensure_bench_serving_repo() -> str:


DEFAULT_TIMEOUT = 10800

# Bound the benchmark client run.
#
# The client subprocess had no timeout at all, and every other harness wait is
# bounded by DEFAULT_TIMEOUT (10800s) -- which sits *above* the pytest per-test
# marker, so none of them can expire first. A stall anywhere below the HTTP
# layer therefore surfaced only as "the client is still running", and the stage
# burned its whole Slurm allocation before something external killed it with no
# diagnostic. Measured: stages running 1.7-2.3h against 18-41 minute budgets,
# producing no results XML at all.
#
# One hour is deliberately generous against the largest per-test budget in the
# perf-sanity lists, so this bounds the pathological case without touching
# healthy long runs. Set to 0 to disable.
BENCHMARK_CLIENT_TIMEOUT_ENV_VAR_NAME = "TRTLLM_PERF_SANITY_CLIENT_TIMEOUT_SEC"
DEFAULT_BENCHMARK_CLIENT_TIMEOUT = 3600

# Grace period between SIGTERM and SIGKILL when stopping a server. A worker
# wedged in a non-interruptible native call never runs the Python signal
# handler, and the bare wait() this replaces would block teardown indefinitely.
SERVER_TERMINATE_GRACE_SEC = 60

# How much of each server log to attach when the client bound fires.
SERVER_LOG_TAIL_LINES = 60


def _benchmark_client_timeout() -> Optional[int]:
"""Effective client bound in seconds, or None when explicitly disabled."""
raw = os.environ.get(BENCHMARK_CLIENT_TIMEOUT_ENV_VAR_NAME)
if raw is None:
return DEFAULT_BENCHMARK_CLIENT_TIMEOUT
try:
value = int(raw)
except ValueError:
print_info(
f"{BENCHMARK_CLIENT_TIMEOUT_ENV_VAR_NAME}={raw!r} is not an integer; "
f"falling back to {DEFAULT_BENCHMARK_CLIENT_TIMEOUT}s"
)
return DEFAULT_BENCHMARK_CLIENT_TIMEOUT
return None if value <= 0 else value
Comment on lines +128 to +141

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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n \
  '^\s*def (_benchmark_client_timeout|stop_process|run_benchmark_client|test_)' \
  tests/integration/defs/perf/test_perf_sanity.py \
  tests/unittest/others/test_perf_sanity_bounds.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 1997


🏁 Script executed:

# Extract the function signatures and immediate context to verify annotations
sed -n '128,141p' tests/integration/defs/perf/test_perf_sanity.py
echo "---"
sed -n '144,164p' tests/integration/defs/perf/test_perf_sanity.py
echo "---"
sed -n '166,180p' tests/integration/defs/perf/test_perf_sanity.py
echo "---"
# Check imports at the top of both files
head -20 tests/integration/defs/perf/test_perf_sanity.py
echo "=== UNITTEST FILE ==="
head -20 tests/unittest/others/test_perf_sanity_bounds.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 4192


🏁 Script executed:

# Get full import block for integration test file
sed -n '1,50p' tests/integration/defs/perf/test_perf_sanity.py | grep -E '(^import|^from)'
echo "---"
# Get full import block for unittest file
sed -n '1,50p' tests/unittest/others/test_perf_sanity_bounds.py | grep -E '(^import|^from)'
echo "---"
# Check test function signatures in unittest file
sed -n '46,70p' tests/unittest/others/test_perf_sanity_bounds.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 2221


🏁 Script executed:

# Check how parameters are used to determine correct types
# For cmd, env, server_logs - look at their usage in run_benchmark_client
sed -n '166,185p' tests/integration/defs/perf/test_perf_sanity.py | grep -E '(cmd|env|server_logs)'
echo "---"
# For proc - check usage in stop_process
sed -n '144,164p' tests/integration/defs/perf/test_perf_sanity.py | grep -E 'proc\.'
echo "---"
# Check test invocations in unittest to see what types are passed
sed -n '114,150p' tests/unittest/others/test_perf_sanity_bounds.py

Repository: NVIDIA/TensorRT-LLM

Length of output: 1933


🏁 Script executed:

# Check for imports of typing modules and how fixtures are used
sed -n '1,30p' tests/unittest/others/test_perf_sanity_bounds.py
echo "---"
# Check if there are other test functions with annotations in unittest
sed -n '76,110p' tests/unittest/others/test_perf_sanity_bounds.py
echo "---"
# Look for pathlib usage
grep -n "pathlib\|Path" tests/unittest/others/test_perf_sanity_bounds.py | head -10

Repository: NVIDIA/TensorRT-LLM

Length of output: 2914


Add complete annotations to all new functions using Python 3.10+ syntax.

All three helper functions and test functions lack required type annotations per coding guidelines.

  • tests/integration/defs/perf/test_perf_sanity.py#L128: Change Optional[int] to int | None.
  • tests/integration/defs/perf/test_perf_sanity.py#L144: Add proc: subprocess.Popen[bytes].
  • tests/integration/defs/perf/test_perf_sanity.py#L166: Add cmd: Sequence[str], env: dict[str, str], server_logs: list[str] | None.
  • tests/unittest/others/test_perf_sanity_bounds.py#L46-189: Add -> None to all test functions. Annotate fixture parameters: monkeypatch: MonkeyPatch (from _pytest.monkeypatch), tmp_path: Path (from pathlib).
📍 Affects 2 files
  • tests/integration/defs/perf/test_perf_sanity.py#L128-L141 (this comment)
  • tests/integration/defs/perf/test_perf_sanity.py#L144-L164
  • tests/integration/defs/perf/test_perf_sanity.py#L166-L235
  • tests/unittest/others/test_perf_sanity_bounds.py#L46-L189
🤖 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/integration/defs/perf/test_perf_sanity.py` around lines 128 - 141,
Complete the Python 3.10+ annotations for the new performance sanity helpers and
tests: in tests/integration/defs/perf/test_perf_sanity.py lines 128-141, change
_benchmark_client_timeout to return int | None; at lines 144-164, annotate the
process parameter as subprocess.Popen[bytes]; at lines 166-235, annotate cmd as
Sequence[str], env as dict[str, str], and server_logs as list[str] | None. In
tests/unittest/others/test_perf_sanity_bounds.py lines 46-189, add -> None to
every test function and annotate fixture parameters with MonkeyPatch and Path,
importing those types as needed.

Source: Coding guidelines

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

Reject negative benchmark-client timeouts.

Only 0 is documented to disable the deadline. Line 141 also treats negative values as disabled. A value such as -1 can restore an unbounded client wait.

  • tests/integration/defs/perf/test_perf_sanity.py#L141-L141: treat negative values as invalid and fall back to DEFAULT_BENCHMARK_CLIENT_TIMEOUT.
  • tests/unittest/others/test_perf_sanity_bounds.py#L65-L68: add a regression test that asserts a negative value does not disable the deadline.
Proposed fix
     except ValueError:
         print_info(
             f"{BENCHMARK_CLIENT_TIMEOUT_ENV_VAR_NAME}={raw!r} is not an integer; "
             f"falling back to {DEFAULT_BENCHMARK_CLIENT_TIMEOUT}s"
         )
         return DEFAULT_BENCHMARK_CLIENT_TIMEOUT
-    return None if value <= 0 else value
+    if value < 0:
+        print_info(
+            f"{BENCHMARK_CLIENT_TIMEOUT_ENV_VAR_NAME}={raw!r} is negative; "
+            f"falling back to {DEFAULT_BENCHMARK_CLIENT_TIMEOUT}s"
+        )
+        return DEFAULT_BENCHMARK_CLIENT_TIMEOUT
+    return None if value == 0 else value
📍 Affects 2 files
  • tests/integration/defs/perf/test_perf_sanity.py#L141-L141 (this comment)
  • tests/unittest/others/test_perf_sanity_bounds.py#L65-L68
🤖 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/integration/defs/perf/test_perf_sanity.py` at line 141, Update the
timeout normalization logic in tests/integration/defs/perf/test_perf_sanity.py
at lines 141-141 so negative values are invalid and fall back to
DEFAULT_BENCHMARK_CLIENT_TIMEOUT, while preserving 0 as the only value that
disables the deadline. Add a regression test in
tests/unittest/others/test_perf_sanity_bounds.py at lines 65-68 asserting that a
negative timeout does not disable the benchmark-client deadline.

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 zero/negative convention here is the opposite of server_ready_timeout() at line 248, which treats <= 0 as "invalid, ignore, use the default". Here 0 means "disable the bound entirely" and a negative value disables it silently (no print_info, unlike the ValueError path). Two adjacent timeout knobs in the same file reading 0 in opposite directions is a foot-gun for whoever sets these in a Jenkins stage. At minimum log when a value <= 0 disables the bound.



def stop_process(proc, name: str, grace: int = SERVER_TERMINATE_GRACE_SEC) -> 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.

SIGKILL on server_proc reaches only the launcher. trtllm-serve forks MPI worker ranks for TP>1, and those don't get the signal — the old terminate(); wait() at least gave the parent a chance to reap them. The trade (a guaranteed hang for possible GPU-holding orphans) is probably still right inside a Slurm step whose cgroup gets cleaned up, but for a multi-client / multi-server-index test the next iteration runs in the same allocation and will hit "device in use".

Cheap fix: pass start_new_session=True at the three Popen sites (1232, 1558, 1586) and os.killpg(os.getpgid(proc.pid), SIGKILL) here on escalation.

"""SIGTERM a server, then SIGKILL it if it has not exited within `grace`.

Replaces a bare ``terminate(); wait()``. Teardown must not be able to hang:
a rank blocked in native code never reaches the Python signal handler, and
the unbounded wait would hold the whole allocation until Slurm intervenes.
"""
if proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(timeout=grace)
return
except subprocess.TimeoutExpired:
print_info(f"{name} did not exit within {grace}s of SIGTERM; escalating to SIGKILL")
proc.kill()
try:
proc.wait(timeout=grace)
except subprocess.TimeoutExpired:
print_info(f"{name} is still alive after SIGKILL; leaving it to the harness")


def run_benchmark_client(cmd, env, server_logs) -> str:
"""Run a benchmark client under a deadline.

Preserves ``check_output`` semantics -- combined stdout/stderr returned on
success, ``CalledProcessError`` on a nonzero exit -- and adds the bound that
was missing: if the client neither finishes nor fails within the budget,
kill it and raise naming what it was waiting on, with the partial client
output and the server-side context attached.

Without this the stage cannot fail on its own; only Slurm or Jenkins stops
it, hours later, with no results XML.
"""
timeout_s = _benchmark_client_timeout()
started = time.monotonic()
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
try:
raw, _ = proc.communicate(timeout=timeout_s)
except subprocess.TimeoutExpired:
elapsed = time.monotonic() - started
proc.kill()
# Drain whatever the client wrote before it was killed -- that partial
# output is usually the only record of how far the run got.
try:
raw, _ = proc.communicate(timeout=SERVER_TERMINATE_GRACE_SEC)
except subprocess.TimeoutExpired:
raw = b""
partial = (raw or b"").decode(errors="replace")

# Two sources, because neither alone is sufficient. check_error()
# matches ERROR_KEYWORDS, which are Python exception names -- it does
# NOT match "[TRT-LLM] [E]" lines, so a server that died the TRT-LLM way
# surfaces nothing. Deliberately not widening ERROR_KEYWORDS here: it
# also drives wait_for_endpoint_ready()'s fast-fail, where a benign [E]
# during startup would start failing healthy runs. Keyword hits are
# best-effort; a raw tail is always attached.
keyword_hits = []

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.

keyword_hits is an unbounded list, but only the last 20 entries are ever used (keyword_hits[-20:]). The comment nine lines below explains that gen logs reach 77–232 MB and that materialising them risks an OOM on the already-failing rank — a log wedged in a retry loop emitting ConnectionRefusedError/TimeoutError per line is exactly the case that produces millions of hits here. Use deque(maxlen=20) for the same reason you used it for the tail.

tails = []
for log_path in server_logs or []:
base = os.path.basename(log_path)
for line_idx, line in check_error(log_path):
keyword_hits.append(f"{base}:{line_idx}: {line}")
try:
with open(log_path, "r", errors="replace") as handle:
# deque(maxlen=) streams the file and keeps only the tail.
# readlines() would materialise the whole log first, and
# measured perf-sanity gen logs reach 77-232 MB -- risking
# an OOM on the rank that is already failing, which would
# swallow the very timeout report we are assembling.
tail = list(deque(handle, maxlen=SERVER_LOG_TAIL_LINES))
except OSError:
continue
if tail:
tails.append(f"--- tail of {base} ---\n" + "".join(tail))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
detail = "\n".join(keyword_hits[-20:]) if keyword_hits else "<none matched>"
tail_blob = "\n".join(tails) if tails else "<no server logs readable>"

raise RuntimeError(
f"Benchmark client made no progress for {elapsed:.0f}s "

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.

"made no progress for Ns" isn't what was measured — the client may well have been progressing, just slower than the budget (large ISL/OSL, high concurrency, an over-tight override). Someone triaging from this line will go looking for a stall that isn't there. "did not finish within {elapsed:.0f}s" states exactly what the harness knows.

f"(bound {timeout_s}s, set {BENCHMARK_CLIENT_TIMEOUT_ENV_VAR_NAME} to change "
f"it, 0 disables). The client was killed so the stage fails here instead of "
f"running to the harness timeout.\n"
f"--- server-side error keywords ---\n{detail}\n"
f"{tail_blob}\n"
f"--- last client output ---\n{partial[-4000:]}"
) from None

output = (raw or b"").decode(errors="replace")
if proc.returncode != 0:
raise subprocess.CalledProcessError(proc.returncode, cmd, output=output.encode())
return output


# Defaults for the server *ready* wait, separate from the whole-test timeout:
# a server that is not healthy after this long is not going to be, and failing
# here (with server-log tails, see wait_for_endpoint_ready) instead of at the
Expand Down Expand Up @@ -1127,11 +1264,9 @@ def run_cmd(self, server_idx: int) -> List[str]:
client_env = copy.deepcopy(os.environ)
if client_config:
client_env.update(client_config.to_env())
output = subprocess.check_output(
client_cmd_with_port,
stderr=subprocess.STDOUT,
env=client_env,
).decode()
output = run_benchmark_client(
client_cmd_with_port, client_env, [server_file_path]
)

with open(client_file_path, "w") as client_ctx:
client_ctx.write(output)
Expand All @@ -1157,8 +1292,7 @@ def run_cmd(self, server_idx: int) -> List[str]:

finally:
if server_proc:
server_proc.terminate()
server_proc.wait()
stop_process(server_proc, "server")

return outputs

Expand Down Expand Up @@ -1434,8 +1568,7 @@ def run_cmd(self, server_idx: int) -> List[str]:
)
finally:
print_info(f"Server {self.disagg_serving_type} stopped")
server_proc.terminate()
server_proc.wait()
stop_process(server_proc, "server")

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Initialize process locals before entering try.

If open() or subprocess.Popen() fails, these finally blocks reference an unassigned local and mask the server-start failure with UnboundLocalError.

  • tests/integration/defs/perf/test_perf_sanity.py#L1571-L1571: initialize server_proc = None before try, then call stop_process only when it is assigned.
  • tests/integration/defs/perf/test_perf_sanity.py#L1599-L1599: initialize disagg_server_proc = None before try, then call stop_process only when it is assigned.
📍 Affects 1 file
  • tests/integration/defs/perf/test_perf_sanity.py#L1571-L1571 (this comment)
  • tests/integration/defs/perf/test_perf_sanity.py#L1599-L1599
🤖 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/integration/defs/perf/test_perf_sanity.py` at line 1571, Initialize
server_proc before its try block and guard the corresponding stop_process
cleanup so it runs only when a process was created; apply the same change to
disagg_server_proc and its finally cleanup in
tests/integration/defs/perf/test_perf_sanity.py at lines 1571-1571 and
1599-1599.


elif self.disagg_serving_type == "DISAGG_SERVER":
try:
Expand Down Expand Up @@ -1463,8 +1596,7 @@ def run_cmd(self, server_idx: int) -> List[str]:
)
finally:
print_info(f"Disagg server {self.disagg_serving_type} stopped")
disagg_server_proc.terminate()
disagg_server_proc.wait()
stop_process(disagg_server_proc, "disagg server")

elif self.disagg_serving_type == "BENCHMARK":
# Perf-benchmark clients whose gen-worker device step time must be
Expand Down Expand Up @@ -1515,11 +1647,11 @@ def run_cmd(self, server_idx: int) -> List[str]:
bench_env = copy.deepcopy(os.environ)
if client_config:
bench_env.update(client_config.to_env())
output = subprocess.check_output(
output = run_benchmark_client(
client_cmd_with_port,
env=bench_env,
stderr=subprocess.STDOUT,
).decode()
bench_env,
self.get_server_logs(server_idx),
)

with open(benchmark_file_path, "w") as benchmark_ctx:
benchmark_ctx.write(output)
Expand Down
Loading
Loading