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>
…cher wrapper hecate 596172 showed that the profiled engine stalls the moment the nsys process exits, even with the srun task kept alive: the decode engine iterated normally through its --duration window end and the report write, stopped within one second of nsys exiting, lost its etcd lease and was dropped by the router. Fixed --duration windows are therefore fragile on this stack. - ProfilingFrontendConfig.duration_secs and nsys-time duration_secs are now optional (None, the new frontend default): nsys captures from --delay until the process exits and writes the report at teardown. - keepalive_command(): nsys (and the app it forks) start in their own session via setsid, so the step's SIGTERM reaches only the wrapper shell; the wrapper forwards SIGTERM/SIGINT to the profiled app only and keeps waiting for nsys, which finalises the report once the app is gone. Exit code stays nsys's own. Falls back to the previous behaviour when setsid is missing. - Validation: nsys-time requires delay_secs; duration_secs > 0 when given. - docs/profiling.md: "prefer capture until exit" and why. Tests: SIGTERM-forwarding test against a fake nsys that waits for its child and writes a marker afterwards; shape tests updated. 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>
…et written ProcessRegistry.cleanup() terminated the srun client (SIGTERM). srun answers that with "forcing job termination" and slurmstepd SIGKILLs the tasks within seconds, so an nsys that writes its report at app exit dies with nothing on disk. hecate 596299: benchmark completed with nsys on all 32 ranks and the frontend, steps CANCELLED 2-5 s into cleanup, 0 reports. Verified on the same node that nsys writes its report within 2 s when it receives SIGTERM itself. - start_srun_process(step_name=...) names the job step (srun --job-name); worker and frontend launches pass their process name. - ManagedProcess.step_name; cleanup() first delivers SIGTERM to the processes of named steps with a raised terminate_timeout (scancel --signal=TERM <jobid>.<stepid>, resolved via squeue -s), then waits up to the grace, escalating to srun SIGTERM and SIGKILL only on expiry. Everything else keeps the previous terminate/wait/kill path, and any lookup failure falls back to it. Tests cover the signalled path, the fallback when the step is missing, the escalation on expiry, and that default-grace processes never touch SLURM. 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>
With the graceful step teardown in place, hecate 596583 showed the next layer: the Dynamo worker shuts down within seconds of SIGTERM, but the TRT-LLM MPI rank processes under trtllm-llmapi-launch (mgmn_worker_node / mgmn_leader_node) stayed alive for more than 10 minutes, so nsys --wait all kept waiting, the step outlived the grace and the prefill reports were lost. nsys writes its report fine when the app is killed, so the wrapper now snapshots the app's process tree when it forwards SIGTERM and, after profiling.app_exit_grace_secs (default 120, validated < teardown_grace_secs), TERMs and then KILLs those pids only. nsys and its report importer are never signalled. The escalation timer is dropped as soon as nsys exits so the task ends promptly. Test: a fake app that ignores SIGTERM under a fake nsys that writes a marker after its child is gone; the wrapper escalates and exits 0 with the marker present. 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>
ProfilingFrontendConfig.sample and .cpuctxsw override the global nsys_sample / nsys_cpuctxsw for the Dynamo frontend only. The frontend has no CUDA work, so sampling it does not carry the cudaProfilerStop wedge seen on TRT-LLM workers, and it is the way to attribute frontend CPU to code (per-thread IP samples and stacks). Workers keep the playbook defaults (--sample=none --cpuctxsw=none). 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.Short proof runs (2026-09-16 morning): what each one established
Short recipes (1 request/lane warmup, 300 s measured phase with
--unsafe-override, 70-minute limit,-p batch-xdr,batch-spx) backfilled within minutes where the 2 h 10 min runs never scheduled.--duration 120,-t nvtx,python-gilRemoving worker ...), aiperf abortedcuda-swon workers,nvtx,osrton the frontend--delay 1200until exit (no--duration)srun: forcing job termination→ tasks SIGKILLed 2–5 s laterTwo more commits follow from that:
f379e862capture until exit:duration_secsis optional fornsys-timeworkers and the profiled frontend (now the default for the frontend); the launcher wrapper starts nsys in its own session (setsid) and forwards SIGTERM to the profiled app only, so nsys can finalise the report at teardown.80b0de3fgraceful step teardown: worker/frontend steps are named (srun --job-name);ProcessRegistry.cleanup()first delivers SIGTERM to those steps' processes (scancel --signal=TERM <jobid>.<stepid>) and waits up toteardown_grace_secs, escalating to srun SIGTERM/SIGKILL only on expiry. Verified on the production image that nsys writes its report within 2 s of receiving SIGTERM.Also learned:
-c cudaProfilerApiwithout a CUDA tracer in-tmakes nsys enable the default tracer, which deadlocked every decode rank in the firstcudaEventRecord(594484). Keepcuda-swin-twith the capture range, or usensys-time.Proof runs completed (2026-09-16 afternoon)
nvtx,python-gil, capture launch+1200 s → exitnvtx, capture launch+1500 s → exit, grace 900 smgmn_worker_node) stayed alive >10 min after the Dynamo worker had shut down, sonsys --wait allnever finished7deed3f1)Report validation (in-image nsys 2026.3.0): prefill rank 0 of 596422 holds 131.6 M NVTX events (TRT-LLM KV-transfer ranges included), decode ranks 10–15 M; the frontend report contains the thread table but no NVTX events — Dynamo's Rust NVTX ranges do not reach nsys with this wheel, so the frontend capture proves attachment only.
Last commit in this series:
7deed3f1— when the profiled app ignores the teardown SIGTERM, the launcher wrapper TERMs then KILLs the app's process tree (snapshotted at signal time, nsys never touched) afterprofiling.app_exit_grace_secs(default 120, must be <teardown_grace_secs), so nsys can finalise its report.Operational lessons baked into the docs: functional/profiling proofs use the shortest recipe (1P+1D, 1 request/lane warmup, 300 s measured phase with
--unsafe-override,-t nvtx, ≤ 70 min, both fabric partitions) — the 2 h 10 min full-length jobs never scheduled in a day, the 70-minute ones backfilled within an hour every time.🤖 Generated with Claude Code