Skip to content

Commit 4b81bca

Browse files
viraatcclaude
andcommitted
feat(trace): add -vvv binary trace events + live dashboard
Adds a low-overhead binary trace pipeline for inspecting per-request lifecycle latency end-to-end (issue -> worker_received -> conn_acquired -> write -> headers -> 1st chunk -> final chunk -> main_received -> complete), plus per-process asyncio event-loop lag sampling. - New `inference_endpoint.utils.trace` lock-free SPSC emitter (~90 ns per event), FIFO transport with O_NONBLOCK writes and 1 MiB kernel pipe buffer, per-process `emit_loop_lag` async task drives flush at 0.3 s cadence — sole flush driver, no locks needed. - New `scripts/trace_dashboard.py` rich.Live TUI reading the FIFO and the loadgen `final_snapshot.json` written by the metrics aggregator. Renders REQUEST LIFECYCLE / LOADGEN vs TRACE / EVENT LOOP LAG with one-row-per-metric layout (skips rows with no data on either side). - Worker + main-process emit sites for the 9-stage lifecycle. - 40 unit tests for the dashboard's count, fold, drop, loop-lag, loadgen comparison, and row-skip behavior. Disabled by default; enabled with `-vvv` on `inference-endpoint`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2cc39ad commit 4b81bca

14 files changed

Lines changed: 2543 additions & 11 deletions

File tree

scripts/trace_dashboard.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""CLI entry point for the -vvv trace dashboard.
6+
7+
Reads fixed-size binary frames from the FIFO opened by
8+
:func:`inference_endpoint.utils.trace.bootstrap` and renders the
9+
dashboard via ``rich.Live``. Dashboard aggregation lives in
10+
:mod:`inference_endpoint.utils.trace_dashboard` so it can be unit
11+
tested without standing up a TUI.
12+
13+
Linux only: timestamps are compared across processes and rely on
14+
``CLOCK_MONOTONIC`` being system-wide (per ``man 7 time``).
15+
"""
16+
17+
# ruff: noqa: I001
18+
# The pre-commit ruff hook is pinned to v0.3.3 (see
19+
# .pre-commit-config.yaml's "TODO: sync rev with ruff version"), which
20+
# does not auto-detect `inference_endpoint` as a first-party package
21+
# and therefore disagrees with the project's local ruff (v0.15.8) on
22+
# import order in this file. File-level noqa keeps both versions quiet
23+
# until the rev is synced.
24+
from __future__ import annotations
25+
26+
import argparse
27+
import json
28+
import os
29+
import sys
30+
import threading
31+
import time
32+
33+
from inference_endpoint.utils.trace import FRAME_SIZE, snapshot_sidecar_path
34+
from inference_endpoint.utils.trace_dashboard import (
35+
DASHBOARD_THEME,
36+
READ_CHUNK,
37+
REFRESH_HZ,
38+
Dashboard,
39+
)
40+
from rich.console import Console
41+
from rich.live import Live
42+
43+
44+
def _try_load_snapshot(path: str) -> dict | None:
45+
"""Best-effort read of the loadgen snapshot sidecar. Returns None
46+
if the file is missing or transiently mid-rename (atomic write may
47+
briefly produce a half-rename window that json.load tolerates)."""
48+
try:
49+
with open(path) as f:
50+
return json.load(f)
51+
except (OSError, json.JSONDecodeError):
52+
return None
53+
54+
55+
class _FrameReader(threading.Thread):
56+
"""Blocking read loop; ingests whole frames into the Dashboard."""
57+
58+
def __init__(self, fd: int, dash: Dashboard) -> None:
59+
super().__init__(daemon=True, name="trace-reader")
60+
self._fd = fd
61+
self._dash = dash
62+
self._pending = bytearray()
63+
self._eof = threading.Event()
64+
65+
@property
66+
def eof(self) -> bool:
67+
return self._eof.is_set()
68+
69+
def run(self) -> None:
70+
try:
71+
while True:
72+
try:
73+
chunk = os.read(self._fd, READ_CHUNK)
74+
except OSError:
75+
return
76+
if not chunk:
77+
return
78+
self._pending.extend(chunk)
79+
whole = (len(self._pending) // FRAME_SIZE) * FRAME_SIZE
80+
if whole:
81+
self._dash.ingest_frames(bytes(self._pending[:whole]))
82+
del self._pending[:whole]
83+
finally:
84+
self._eof.set()
85+
86+
87+
def _open_trace_input(pipe_path: str | None) -> int:
88+
if pipe_path:
89+
return os.open(pipe_path, os.O_RDONLY)
90+
return sys.stdin.fileno()
91+
92+
93+
def main() -> int:
94+
parser = argparse.ArgumentParser(description=__doc__)
95+
parser.add_argument(
96+
"--trace-pipe",
97+
help="FIFO path to read binary trace frames from (default: stdin).",
98+
)
99+
args = parser.parse_args()
100+
101+
dash = Dashboard()
102+
# Dashboard renders to stderr because the parent benchmark process
103+
# has redirected its own stdout/stderr to a log file (see trace.bootstrap).
104+
console = Console(file=sys.stderr, force_terminal=True, theme=DASHBOARD_THEME)
105+
reader = _FrameReader(_open_trace_input(args.trace_pipe), dash)
106+
reader.start()
107+
108+
# Snapshot sidecar path is convention-named after the parent's pid
109+
# (main proc that spawned us). The benchmark writes it periodically
110+
# under -vvv so we can live-update the LOADGEN vs TRACE panel.
111+
snap_path = snapshot_sidecar_path(os.getppid())
112+
113+
# screen=True uses the alternate-screen buffer so updates redraw
114+
# cleanly without scrollback noise. When Live() exits the alt
115+
# screen is torn down — to keep the final frame visible we capture
116+
# it BEFORE leaving the context and print it to the normal buffer
117+
# afterward.
118+
final_frame = None
119+
with Live(
120+
dash.render(),
121+
console=console,
122+
refresh_per_second=REFRESH_HZ,
123+
screen=True,
124+
transient=False,
125+
) as live:
126+
while not reader.eof:
127+
snap = _try_load_snapshot(snap_path)
128+
if snap is not None:
129+
dash.attach_loadgen_snapshot(snap)
130+
live.update(dash.render())
131+
time.sleep(1.0 / REFRESH_HZ)
132+
snap = _try_load_snapshot(snap_path)
133+
if snap is not None:
134+
dash.attach_loadgen_snapshot(snap)
135+
# Bypass the per-tick fold-defer window so the final render
136+
# captures COMPLETE frames that landed within the last 300 ms
137+
# — otherwise they sit queued and the closing frame shows
138+
# stale stage histograms / verdict.
139+
dash.flush_pending_folds()
140+
final_frame = dash.render()
141+
live.update(final_frame)
142+
# Now we're back on the normal screen — print the last snapshot
143+
# so the user can see the totals + verdict after the run ends.
144+
if final_frame is not None:
145+
console.print(final_frame)
146+
console.print("[dim]── trace finished ──[/dim]")
147+
return 0
148+
149+
150+
if __name__ == "__main__":
151+
sys.exit(main())

src/inference_endpoint/commands/benchmark/execute.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import asyncio
2727
import json
2828
import logging
29+
import os
2930
import random
3031
import shutil
3132
import signal
@@ -96,6 +97,7 @@
9697
SessionResult,
9798
)
9899
from inference_endpoint.metrics.report import Report
100+
from inference_endpoint.utils import trace
99101

100102
transformers_logging.set_verbosity_error()
101103

@@ -556,6 +558,14 @@ async def _run_benchmark_async(
556558
config = ctx.config
557559
session_id = f"cli_benchmark_{uuid.uuid4().hex[:8]}"
558560

561+
# Trace mode (-vvv): drive periodic emitter flush + main-proc loop
562+
# lag sampling on this loop. The same task does both so we have
563+
# exactly one trace consumer per process on the producer's thread.
564+
# Held in a local so the teardown block below can cancel it.
565+
lag_task: asyncio.Task[None] | None = None
566+
if trace.is_enabled():
567+
lag_task = loop.create_task(trace.emit_loop_lag(trace.MAIN_PROC_LOOP_ID))
568+
559569
# Progress bar + response collector
560570
pbar = tqdm(
561571
desc=f"{config.model_params.name} (Streaming: {ctx.enable_streaming})",
@@ -603,6 +613,19 @@ async def _run_benchmark_async(
603613
)
604614
metrics_subscriber.start()
605615

616+
# -vvv only: tap the latest snapshot to a JSON sidecar so the
617+
# trace dashboard can show "LOADGEN vs TRACE" live. Path is
618+
# derived from our pid; dashboard finds it via os.getppid().
619+
# Held in a local so we can cancel it in the teardown block —
620+
# otherwise it would race the subscriber's close().
621+
tap_task: asyncio.Task[None] | None = None
622+
if trace.is_enabled():
623+
tap_task = loop.create_task(
624+
_snapshot_tap_loop(
625+
metrics_subscriber, trace.snapshot_sidecar_path(os.getpid())
626+
)
627+
)
628+
606629
# Launch service subprocesses
607630
launcher = ServiceLauncher(zmq_ctx)
608631
aggregator_args: list[str] = [
@@ -663,7 +686,15 @@ async def _run_benchmark_async(
663686
event_logs_dir=ctx.report_dir,
664687
cpu_affinity=ctx.affinity_plan,
665688
)
666-
http_client = await HTTPEndpointClient.create(http_config, loop)
689+
# -vvv on main proc → tell HTTPEndpointClient which FIFO
690+
# workers should attach to. Threaded as an explicit arg so
691+
# the trace channel stays out of the public CLI / YAML
692+
# config schema (would otherwise leak into generated YAML
693+
# templates and create a CLI flag users could misuse).
694+
trace_pipe = trace.fifo_path(os.getpid()) if trace.is_enabled() else None
695+
http_client = await HTTPEndpointClient.create(
696+
http_config, loop, trace_pipe_path=trace_pipe
697+
)
667698
issuer = HttpClientSampleIssuer(http_client)
668699
except Exception as e:
669700
pbar.close()
@@ -813,6 +844,33 @@ def _on_phase_start(phase: PhaseConfig) -> None:
813844
except Exception as e: # noqa: BLE001 — best-effort report build.
814845
logger.warning(f"Failed to build report from snapshot: {e}")
815846

847+
# Trace-mode (-vvv) only: flush the terminal snapshot to
848+
# the sidecar BEFORE cancelling the tap task. The tap loop
849+
# ticks every 0.5s, so a short run can finalize between
850+
# ticks and the dashboard would read stale data for its
851+
# final LOADGEN vs TRACE frame.
852+
if trace.is_enabled() and snap_dict is not None:
853+
snap_path = trace.snapshot_sidecar_path(os.getpid())
854+
tmp_path = f"{snap_path}.tmp"
855+
try:
856+
with open(tmp_path, "w") as f:
857+
json.dump(snap_dict, f)
858+
os.replace(tmp_path, snap_path)
859+
except OSError as e:
860+
logger.warning(f"final snapshot sidecar write failed: {e}")
861+
862+
# Cancel trace-mode helper tasks before closing the
863+
# subscriber so they don't tick on a closed pipe.
864+
for t in (tap_task, lag_task):
865+
if t is not None and not t.done():
866+
t.cancel()
867+
try:
868+
await t
869+
except (asyncio.CancelledError, Exception):
870+
# CancelledError is the normal cancel ack;
871+
# other exceptions during shutdown are logged
872+
# by the task itself and not worth surfacing.
873+
pass
816874
metrics_subscriber.close()
817875
pbar.close()
818876

@@ -824,6 +882,35 @@ def _on_phase_start(phase: PhaseConfig) -> None:
824882
)
825883

826884

885+
async def _snapshot_tap_loop(
886+
subscriber: MetricsSnapshotSubscriber,
887+
out_path: str,
888+
period_s: float = 0.5,
889+
) -> None:
890+
"""Trace-mode helper. Periodically serialize the latest
891+
``MetricsSnapshot`` from the subscriber to ``out_path`` as JSON via
892+
atomic tmp + rename so the dashboard's poll never reads a partial
893+
file. Stops naturally when the task is cancelled at benchmark
894+
teardown.
895+
"""
896+
logger.debug("snapshot tap → %s every %.2fs", out_path, period_s)
897+
tmp_path = f"{out_path}.tmp"
898+
while True:
899+
try:
900+
await asyncio.sleep(period_s)
901+
snap = subscriber.latest
902+
if snap is None:
903+
continue
904+
payload = snapshot_to_dict(snap)
905+
with open(tmp_path, "w") as f:
906+
json.dump(payload, f)
907+
os.replace(tmp_path, out_path)
908+
except asyncio.CancelledError:
909+
return
910+
except Exception: # noqa: BLE001 — telemetry, never crash the bench
911+
logger.debug("snapshot tap write failed", exc_info=True)
912+
913+
827914
def run_benchmark_async(ctx: BenchmarkContext) -> BenchmarkResult:
828915
"""Run async benchmark. Sync entry point — drives the event loop."""
829916
loop = LoopManager().default_loop

src/inference_endpoint/endpoint_client/http.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -791,13 +791,14 @@ def build_request(
791791

792792
@dataclass(slots=True)
793793
class InFlightRequest:
794-
"""State for a single HTTP request through its lifecycle:
794+
"""State for a single HTTP request through its lifecycle.
795795
796796
Attributes:
797797
query_id: Correlates response back to original Query.
798798
http_bytes: Serialized HTTP request for socket.write().
799799
is_streaming: Whether this is a streaming (SSE) request or not.
800-
connection: PooledConnection assigned to this request (set once request is fired).
800+
connection: PooledConnection assigned to this request (set once
801+
request is fired).
801802
"""
802803

803804
query_id: str

src/inference_endpoint/endpoint_client/http_client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,11 @@ def __init__(
4949
self,
5050
config: HTTPClientConfig,
5151
loop: asyncio.AbstractEventLoop | None = None,
52+
trace_pipe_path: str | None = None,
5253
):
5354
self.client_id = uuid.uuid4().hex[:8]
5455
self.config = config
56+
self._trace_pipe_path = trace_pipe_path
5557
self._worker_cycle = cycle(range(self.config.num_workers))
5658

5759
# Use provided loop or create one via LoopManager (uvloop + eager task factory)
@@ -87,17 +89,24 @@ async def create(
8789
cls,
8890
config: HTTPClientConfig,
8991
loop: asyncio.AbstractEventLoop,
92+
trace_pipe_path: str | None = None,
9093
) -> "HTTPEndpointClient":
9194
"""Async factory for shared-loop usage.
9295
9396
Use this instead of __init__ when the caller is already running on
9497
the target event loop (e.g., inside run_benchmark_async). The regular
9598
constructor uses run_coroutine_threadsafe().result() which deadlocks
9699
when called from the same loop.
100+
101+
``trace_pipe_path`` opts each worker into the -vvv trace channel
102+
(the FIFO main proc created in ``trace.bootstrap``). It rides
103+
with the client rather than ``config`` so the trace plumbing
104+
stays out of the public CLI / YAML schema.
97105
"""
98106
self = cls.__new__(cls)
99107
self.client_id = uuid.uuid4().hex[:8]
100108
self.config = config
109+
self._trace_pipe_path = trace_pipe_path
101110
self._worker_cycle = cycle(range(config.num_workers))
102111
self._owns_loop = False
103112
self._loop_name = None
@@ -118,7 +127,9 @@ async def _initialize(self) -> None:
118127
self._dropped_requests: int = 0
119128

120129
assert self.loop is not None
121-
self.worker_manager = WorkerManager(self.config, self.loop)
130+
self.worker_manager = WorkerManager(
131+
self.config, self.loop, trace_pipe_path=self._trace_pipe_path
132+
)
122133
await self.worker_manager.initialize()
123134
self.pool = self.worker_manager.pool_transport
124135

0 commit comments

Comments
 (0)