Conversation
…n metrics Extends the /proc host sampler from orchestrator-node-only to every allocated node, and adds the scheduler-level fields needed to attribute host-CPU interference to a remedy from a single baseline run: - per-process run_delay_ns (/proc/pid/schedstat): cumulative run-queue wait — the direct contention signal that CPU pinning remedies - per-process nr_migrations (/proc/pid/sched): cross-core churn, near-zero when pinned - per-process affinity_ncpus (sched_getaffinity): direct pinning-state observable (144 = floating, 36 = pinned rank on GB200/GB300) - host procs_running/procs_blocked and a t_mono companion timestamp (cross-node wall clocks have been observed seconds apart) Collection: host_sampler.py gains a stdlib-only standalone CLI mode (one file per node: host_samples_<node>.jsonl); benchmark_stage launches it on all non-orchestrator nodes via one srun --overlap per het group, gated by the new observability.host_sampler_all_nodes knob (default true, follows observability.enabled). This closes the gaps where worker nodes had no per-process host telemetry and a dedicated frontend node had none at all. Ingest: host_series.json gains a per-node hosts map plus run_delay_ms_per_s / migrations_rate / affinity_ncpus / procs_runnable series; rate denominators prefer the monotonic clock so NTP steps can't skew them. docs/host-attribution-metrics.md documents the metric set and the pinning-vs-placement attribution logic. Teardown hardening from adversarial review: benchmark proc creation moved inside the try so sampler sruns can't leak when placement/launch raises; sampler teardown escalates via terminate_and_reap and logs early-exited samplers; the standalone mode exits nonzero if its sampler thread dies; the sampler never samples itself. Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
…idation matrix Replaces the draft rubric with measured thresholds from 7 validation runs (2x baseline, 2x pinned, 3x dedicated-frontend at DSV4 c1010 on GB300): clean-node run-delay floor 0.00-0.01 ms/s; shared-node asymmetry 140-350x (persisting at 190x under pinning, collapsing to 1x in all three dedicated-frontend runs); affinity_ncpus as the direct pinning observable (full-width vs CPUs-per-GPU), unchanged by placement. Adds the operator three-step decision flow and expected-gain guidance (+4.0% pinning on 288-CPU nodes / +1.1% on 144-CPU; +0.5-0.85% placement). Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
…ault The Prometheus surface Tachometer captures says nothing about the processes underneath it: Dynamo publishes no process_* or thread metrics and node_exporter only sees the machine. The AgentX/VR200 frontend spin (fastokens 0.3.1 sizing its BPE rayon pool to all 352 CPUs -> 352 idle workers burning ~110 cores) was invisible in every scraped family and was found with sacct plus an out-of-band per-thread /proc sampler. Add ncabatoff/process-exporter (pinned multi-arch image, port 9256) as a third built-in exporter, launched on every node that hosts a backend rank OR a frontend replica -- the frontend node is the one the per-node exporters skip when the frontend is head-placed or dedicated, and it is where frontend CPU pathologies live. Groups come from <log_dir>/process-exporter.yml written at launch (frontend, dynamo_trtllm/sglang/vllm handlers + ranks, the MPI launcher, the benchmark client, infra daemons; first match wins, no catch-all). -threads=true publishes per-thread-name CPU and thread counts, so a runaway pool shows up as a step in thread_count and a CPU cluster on one thread name. Endpoints are scraped unfiltered so groupname/threadname/mode labels pass through to the parquet and to server_metrics_export.jsonl unchanged. node_exporter additionally gets --collector.processes (host-wide thread total, process states, procs_running/blocked). An explicit process_exporter block overrides the image/port/command, and default_exporters: false still disables all built-ins. Dry-run lists the new exporter; docs/config-reference.md documents it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
The tachometer node_exporter launch enabled only cpu/infiniband/meminfo, and
the host sampler read /proc/stat + /proc/meminfo but no PSI. The result: none
of the scheduler-pressure signal the retired steady_probe.sh sampler carried
(PSI stall time, procs_running/blocked, context-switch and page-fault/reclaim
counters, per-NUMA free memory) was collected anywhere -- exactly the class of
signal that distinguishes a busy box from one whose work is blocked waiting.
Two complementary changes, both cheap procfs/sysfs reads with no measured
decode-latency cost (unlike dense NVML sampling):
- node_exporter (all worker nodes): add the stat, vmstat, pressure, and
meminfo_numa collectors. The vendored NodeExporterFilter passes the new
families through its default arm, so no scraper change is needed. Extracted
into tachometer_node_exporter_command_template() mirroring the DCGM template;
an explicit recipe node_exporter.command still wins.
- host_sampler (orchestrator/head node, which gets no exporter): add a
/proc/pressure/{cpu,memory,io} PSI read to each sample. This is that node's
only PSI source and covers the frontend node the exporters never reach.
Tests: node-exporter template asserts the four new collectors and
explicit-command override; host_sampler PSI parse + CONFIG_PSI-absent cases.
Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
…-setup binary The upstream ncabatoff/process-exporter image is FROM scratch: no shell, no /root. pyxis/enroot on hecate refuses to start it (`enroot-switchroot: failed to change directory: /root`, then `/bin/sh: No such file or directory` once the home is mounted), so the first run from this branch (hecate 565854) had to relaunch the exporter by hand as a bare binary. The exporter is a static Go executable that needs neither a container nor privileges, so ship it the way nats-server and etcd are shipped: `make setup ARCH=<compute_arch>` downloads the release tarball into configs/process-exporter and start_tachometer runs it under plain srun with host paths for the binary and its group file. - TelemetryExporterConfig.binary: host-native launch mode. Relative paths resolve against SRTCTL_SOURCE_DIR / the checkout root, where make setup installs host binaries; container_image is ignored. - DEFAULT_PROCESS_EXPORTER now points at configs/process-exporter with an empty container_image. Validation requires binary or container_image for every exporter, process_exporter included. - Missing binary (checkout whose make setup predates this) skips the process-exporter leg with a warning instead of failing the run; `srtctl apply` warns at submit time as well. - An explicit process_exporter.container_image with binary unset keeps the container launch (group file via the /logs mount). - Makefile setup step (PROCESS_EXPORTER_VERSION ?= 0.8.7), .gitignore, dry-run row shows "host binary configs/process-exporter :9256", docs/config-reference.md, tests for both launch modes, the skip path, binary resolution and validation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
# Conflicts: # src/srtctl/cli/mixins/telemetry_stage.py
…xporterFilter
Two node_exporter families enabled by the combined observability branch lost
their only distinguishing label in the vendored scraper filter, so every series
of the family collapsed into one metric name in the parquet:
- node_memory_numa_*{node="N"} (--collector.meminfo_numa, NVIDIA#415): the generic
`memory_` arm emitted the bare metric name, folding all NUMA nodes together.
Now `memory_numa_<x>{numa_node=N}`; host-wide meminfo stays label-free.
- node_processes_state{state="R"|"S"|"D"|...} and
node_processes_threads_state{thread_state=...} (--collector.processes, NVIDIA#413):
the default arm keeps only a fixed label whitelist that had neither key.
`state` and `thread_state` are added to the whitelist.
Label-free stat/vmstat/pressure families were already passed through unchanged;
a test pins that too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
…docs/comments for the collector union Post-merge fixups from the three-PR integration review: - benchmark_stage._start_remote_host_samplers hardcoded `--interval 2` (NVIDIA#356 predates b83dc4a, which made the in-process sampler follow observability.tachometer.collect_interval_ms). Derive the remote interval from the same knob, clamped to >= 1 s like HostSampler, so head-node and remote host_samples rows share one cadence. Test covers default, 2 s, 5 s, sub-second clamp and 1.5 s. - docs/config-reference.md node_exporter row lists the eight collectors of the union and the widened vmstat field filter (it still named NVIDIA#413's four). - telemetry_stage.py NODE_EXPORTER_COLLECTORS comment no longer claims "no scraper change is needed": the filter keeps numa_node / state / thread_state since the previous commit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
…TRT-LLM workers
The TRT-LLM nsys wrap (`profiling.type: nsys`) used `-t cuda,nvtx,ucx
--cuda-graph-trace=node`. The Dynamo Benchmark Playbook §9.5.1.1 campaign on
nsys 2026.3.x / VR200 disaggregated serving found that set unusable: plain
`cuda` selects the HES hardware trace and SIGSEGVs the KV transceiver's
device-to-device cudaMemcpyAsync, and `node`-level graph tracing hides the
CUDA-graph decode work (0.3 % GPU busy reads as idle). Every usable
multi-node capture came from the recipe now emitted by default:
nsys profile --force-overwrite=true -t cuda-sw,nvtx,python-gil
--cuda-graph-trace=graph --sample=none --cpuctxsw=none
--python-sampling=false --python-sampling-frequency=1000
--gpu-metrics-devices=none --flush-on-cudaprofilerstop=false
--cuda-flush-interval=0 -c cudaProfilerApi --capture-range-end=stop
[extra_nsys_args] --kill none --wait all -o <...>_rank%q{SLURM_PROCID}
nsys-time shares the trace flags with --delay/--duration. Each flag is
documented with the playbook's reason in docs/profiling.md and the field
comments. New ProfilingConfig knobs relax the recipe explicitly: nsys_trace,
nsys_cuda_graph_trace, nsys_sample, nsys_cpuctxsw (context switches without
IP sampling is the safer experiment; both need perf_event_paranoid <= 2),
nsys_python_sampling(+_frequency), nsys_gpu_metrics_devices. Validation
checks the enum-like values and warns when IP sampling is requested.
Worker env gains the playbook variables: TLLM_PROFILE_LOG_RANKS (new
`log_ranks`, default all), DYN_ENABLE_RUST_NVTX=1 (effective on wheels built
with the nvtx cargo feature) and NVTX_INJECTION64_PATH when the recipe names
the injection library (`nvtx_injection_path`; TRT-LLM containers ship it at
/usr/local/cuda-0.gpgpu/NsightSystems-cli-<ver>/target-linux-sbsa-armv8/).
The profiling block stays independent of observability.enabled so the
capture cost can be measured against a default-visibility run.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
Report validated inside the container (hecate0057, nsys 2026.3.0, while 570810 was still running)
All 32 reports (24 prefill ranks, 8 decode ranks) were produced during the run; decode ones landed ~40 s after its window closed at iteration 6600. Note for readers: the workstation's nsys 2025.5 refuses these reports ("created in a newer version"), so post-processing has to happen with a 2026.3+ nsys, e.g. inside the same container. |
570810 did not complete — decode worker hung 4 minutes after its capture windowTimeline (all from the run's logs):
The prefill workers, captured 6 minutes earlier, ran fine until teardown. Request rate while nsys was armed but not capturing was within 4 % of the unprofiled run (+10 min: 1,462 vs 1,516 completions). What this does and does not say: eight runs of this recipe without nsys never hung; the one run with nsys hung 229 s after the decode capture closed and the reports were generated. That makes nsys's post-capture state ( |
…tend) The nsys prefix only ever wrapped the backend worker ranks; the Dynamo frontend (Rust HTTP frontend + router) ran unprofiled. Add `profiling.frontend` with a time window (the frontend has no CUDA work and never calls cudaProfilerStart): `nsys profile -t nvtx --delay/--duration --kill none --wait all -o /logs/profiles/frontend/<node>_frontend_<idx>` around `python3 -m dynamo.frontend`, plus DYN_ENABLE_RUST_NVTX=1 / NVTX_INJECTION64_PATH in the frontend environment so Dynamo's Rust NVTX ranges are recorded. Validation: nsys type, frontend.type dynamo, positive duration, non-empty trace. Tests and docs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
…amo frontend launcher Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
nsys writes a report when its capture range closes. If the range is still open when the run ends (stop_step never reached, or an nsys-time duration that outlasts the benchmark) the report is written only after the engine exits, and the 10 s SIGTERM->SIGKILL grace ProcessRegistry.cleanup() gave every process lost it. - ProfilingConfig.teardown_grace_secs (default 180, validated > 0): grace applied to nsys-wrapped worker sruns and the profiled Dynamo frontend. - ManagedProcess.terminate_timeout: per-process grace; terminate() defaults to it; add_processes() preserves it when renaming. - ProcessRegistry.cleanup() is now two-phase: SIGTERM every running process, then wait for each up to its own grace, then SIGKILL. The graces overlap, so a run pays the longest one once instead of once per worker. - docs/profiling.md: new section; tests for the registry and the schema. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
…n exits `nsys profile --delay D --duration T --kill none <app>` writes its report when the window closes and then exits, leaving <app> orphaned. Under Slurm the exiting nsys is the task, so the step ends and slurmstepd kills the orphan. Verified on hecate job 595056: the frontend report landed at 02:09:45, the frontend step completed (exit 0) at 02:09:48 and the frontend was gone; the decode worker step followed seconds later and the benchmark aborted. srtctl.core.nsys_keepalive.keepalive_command wraps the launch: run nsys in the background, record the PID of its child (the profiled app), wait for nsys, then keep the shell alive while that child exists, exiting with nsys's own code. Applied to the profiled Dynamo frontend (always time-windowed) and to workers in nsys-time mode. Iteration-based captures are unchanged: nsys stays attached until the engine exits. Tests run the generated script against a fake nsys that forks a child and exits early, checking the wrapper outlives nsys and propagates its exit code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Yuewei Na <nv-yna@users.noreply.github.com>
What
profiling.type: nsyson TRT-LLM workers now emits the capture recipe from the Dynamo Benchmark Playbook §9.5.1.1 "Dynamo + TRTLLM" instead of-t cuda,nvtx,ucx --cuda-graph-trace=node:Why (from the playbook campaign on nsys 2026.3.x / VR200 disagg): plain
-t cudaselects the HES hardware trace and SIGSEGVs the KV transceiver's device-to-devicecudaMemcpyAsync;--cuda-graph-trace=nodehides CUDA-graph decode work (0.3 % GPU busy reads as idle);--sample=process-treewedged workers atcudaProfilerStop; finalise-at-exit never produced a report,-c cudaProfilerApi --capture-range-end=stopdoes. Each flag is documented with its reason indocs/profiling.mdand the field comments.New
ProfilingConfigknobs relax the recipe explicitly:nsys_trace,nsys_cuda_graph_trace,nsys_sample,nsys_cpuctxsw(context switches without IP sampling is the safer experiment; both needkernel.perf_event_paranoid <= 2),nsys_python_sampling(+_frequency),nsys_gpu_metrics_devices. Validation checks the enum-like values and warns when IP sampling is requested.nsys-timeshares the trace flags with--delay/--duration.Worker env gains
TLLM_PROFILE_LOG_RANKS(newlog_ranks, defaultall),DYN_ENABLE_RUST_NVTX=1(effective on a wheel built with thenvtxcargo feature) andNVTX_INJECTION64_PATHwhennvtx_injection_pathis set (TRT-LLM containers ship it at/usr/local/cuda-0.gpgpu/NsightSystems-cli-<ver>/target-linux-sbsa-armv8/libToolsInjection64.so). Theprofiling:block remains independent ofobservability.enabled, so the capture cost can be measured against a default-visibility run.First run: hecate 570810 (2026-09-10)
AgentX 8-node recipe (3 prefill + 1 decode TP8 workers over 2 nodes each), default visibility,
prefill: 1200-1300,decode: 6000-6600(sized from the reference run's iteration rates: ~2.7 prefill it/s, 8-20 decode it/s).TLLM_PROFILE_LOG_RANKS=all,DYN_ENABLE_RUST_NVTX=1,NVTX_INJECTION64_PATH. Workers loaded normally; benchmark started at +16 min, exactly like the unprofiled run.Profiling started at iteration 1200on all 8 ranks of each prefill worker at 21:08:53,... iteration 6000on all 8 decode ranks at 21:09:47; 32 of 32.nsys-repfiles (71–75 MB each) landed underlogs/profiles/{prefill,decode}/within ~50 s of each window closing, while the benchmark kept running (--kill none).cudaProfilerStop.kernel.perf_event_paranoid = -1, sonsys_cpuctxsw: process-treeis available there for a follow-up run.Report validation with
nsys stats(inside the container, the workstation's 2025.5 nsys refuses 2026.3 reports) and the throughput comparison against the unprofiled run follow when the job completes.Outcome of runs 1 and 2: capture works everywhere, but a worker hangs minutes after its window (n=2)
nsys stats(exact 1200-1299 window, 111 kernel families, GIL tracer active)Hang detected after 300 secondson ranks 2/3 intp_allgather; MPI_Abort, exit 137[RANK 2] Hang detected after 300 secondsincommunicator.py:378 _serialize_and_exchange_lengths; "hard-killing and propagating to peer ranks", exit 137. Decode reached iter 14323 with no hang and was shut down by the cleanupBoth hangs are in TRT-LLM collective / MPI paths on a worker whose nsys capture had ended minutes earlier; the
same recipe without
profiling(job 567529) completed. aiperf aborted its warmup in both runs, so there is nothroughput number for the playbook flag set yet.
Discriminating run 571266 (same recipe, srt-slurm's pre-playbook flag set
nsys_trace: cuda,nvtx,nsys_cuda_graph_trace: node, nopython-gil, noNVTX_INJECTION64_PATH): worse. prefill worker 2 diedthe moment its capture range opened: all 8 ranks' last log line is
iter = 1200(the start step) and the log endswith
!!!!!!! Segfault encountered !!!!!!!incuMemcpyHtoDAsync_v2 <- cudaMemcpyAsync <- at::native::copy_kernel_cuda;exit 137, 0 reports.
So on this stack (nsys 2026.3.0 in the image, CUDA 13.1, VR200):
-t cudahardware tracing segfaults atcudaProfilerStart, while the playbook's-t cuda-sw,nvtx,python-gilcaptures reliably (2 x 32 valid reports) butleaves a worker wedged a few minutes after its window. The playbook's choice of
cuda-swlooks deliberate. Thecapture path this PR implements works; a completed benchmark with nsys attached is not obtainable here, so the
"performance impact of profiling" number has to come from a stack where the post-capture hang does not reproduce
(or from
--capture-range-end=stop-shutdowncapture-only runs, which by construction give no throughput).Also:
profiling.frontend(679483e, b5f8ff4)The nsys prefix only ever wrapped the worker ranks.
profiling.frontend {delay_secs, duration_secs, trace}now alsowraps
python3 -m dynamo.frontendinnsys profile -t nvtx --delay/--duration --kill none --wait all(time window;the frontend has no CUDA work and never calls cudaProfilerStart) and exports
DYN_ENABLE_RUST_NVTX=1/NVTX_INJECTION64_PATHto it, so Dynamo's Rust NVTX ranges land in/logs/profiles/frontend/<node>_frontend_<i>.nsys-rep.Why the profiled runs hang (log forensics over all 8 hang runs)
tp_allgather -> safe_allgather -> _serialize_and_exchange_lengths -> comm.Allgather(MPI, host buffers). Only some ranks report it; the silent rank is the one whose Python threadscannot run (native code holding the GIL).
(
Registering peer in first request to endpoint/Initialized ZMQMessenger(...), i.e. NIXL/UCX peer setup withCUDA memory registration) that happens after the nsys capture range has closed. Baseline job 4 formed its
whole peer mesh by iteration ~1153 (decode) / ~2138 (prefill) and never needed a new connection later. In the
profiled runs one decode-rank/prefill-worker pair connected late (routing chance): 570810 rank 7 at iter 7433 with
the window 6000-6600 closed, hang 8 s later at 7624; 572590 at iter 6612, hang at 7624; prefill runs: connections
at 2115-2310 after the 1200-1300 window, hang at 2317-2324. First-contacts before the window always succeeded,
in both
--capture-range-end=stop(this PR) andrepeat(PR feat(profiling): nsight-slurm profiling type (Nsight Cloud for Slurm) #419) modes.once a range has ended. Two isolation runs are queued: E1
-t nvtx,python-gil(no CUDA interception) and E2-t cuda-sw,nvtx(no GIL tracer), both with the frontend profiled.Validation
pytest tests/→ 1793 passed, 2 skipped; 14 new tests intests/test_profiling_playbook.py(recipe flags, time mode, knobs, env, validation, warning).SrtConfig.Schemaandsrtctl dry-runon hecate.Two launcher fixes from the overnight isolation runs (4b03091, a40335f)
profiling.teardown_grace_secs(default 180):ProcessRegistry.cleanup()is now two-phase (SIGTERM every process, then wait for each up to its ownterminate_timeout, then SIGKILL). nsys-wrapped worker sruns and the profiled frontend get the grace; everything else keeps 10 s. Needed when a capture range is still open at exit — nsys writes that report only after the engine exits.srtctl.core.nsys_keepalive:nsys profile --delay D --duration T --kill none <app>exits after writing its report; under Slurm nsys is the srun task, so the step ends and slurmstepd kills the orphaned app. Verified on hecate 595056: frontend report 02:09:45, frontend step COMPLETED (0:0) 02:09:48, decode step COMPLETED 02:09:52, benchmark aborted. The wrapper runs nsys in the background, records its child PID, waits for nsys, then keeps the task alive while the child runs (exit code = nsys's). Applied toprofiling.frontend(always time-windowed) and tonsys-timeworkers; iteration-based captures are untouched. Tests execute the generated script against a fake nsys.Hang forensics, second pass (verified in the logs)
stop_step + 1024iterations in every hang run: decode 6600 → 7624 (path Astopand path Brepeat), prefill 1300 → 2324 (4 runs; 2319/2317 in two). The stuck iteration onboards a new request (KV receive). Every lateInitialized ZMQMessengerline is a genuine first contact (zero re-initialisations across 5 runs).-c cudaProfilerApiwithout a CUDA tracer in-tmakes nsys enable the default tracer ("CUDA tracing is required for cudaProfilerStart/Stop API support. Turning it on by default."); on VR200 every decode rank then deadlocks in the first per-iterationcudaEventRecord(594484). Keepcuda-swin-twhenever the capture range is used, or switch tonsys-time.🤖 Generated with Claude Code