Skip to content

feat(profiling): adopt the Dynamo Benchmark Playbook nsys recipe for TRT-LLM workers - #418

Draft
nv-yna wants to merge 17 commits into
NVIDIA:mainfrom
nv-yna:yna/nsys-playbook
Draft

nv-yna wants to merge 17 commits into
NVIDIA:mainfrom
nv-yna:yna/nsys-playbook

Conversation

@nv-yna

@nv-yna nv-yna commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

What

profiling.type: nsys on 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:

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 /logs/profiles/<mode>/<leader>_<mode>_w<i>_profile_rank%q{SLURM_PROCID}
  trtllm-llmapi-launch python3 -m dynamo.trtllm ...

Why (from the playbook campaign on nsys 2026.3.x / VR200 disagg): plain -t cuda selects the HES hardware trace and SIGSEGVs the KV transceiver's device-to-device cudaMemcpyAsync; --cuda-graph-trace=node hides CUDA-graph decode work (0.3 % GPU busy reads as idle); --sample=process-tree wedged workers at cudaProfilerStop; finalise-at-exit never produced a report, -c cudaProfilerApi --capture-range-end=stop does. Each flag is documented with its 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 kernel.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-time shares the trace flags with --delay/--duration.

Worker env gains TLLM_PROFILE_LOG_RANKS (new log_ranks, default all), DYN_ENABLE_RUST_NVTX=1 (effective on a wheel built with the nvtx cargo feature) and NVTX_INJECTION64_PATH when nvtx_injection_path is set (TRT-LLM containers ship it at /usr/local/cuda-0.gpgpu/NsightSystems-cli-<ver>/target-linux-sbsa-armv8/libToolsInjection64.so). The profiling: block remains independent of observability.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).

  • All four workers launched under the recipe (sweep log), env carried the window, 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 1200 on all 8 ranks of each prefill worker at 21:08:53, ... iteration 6000 on all 8 decode ranks at 21:09:47; 32 of 32 .nsys-rep files (71–75 MB each) landed under logs/profiles/{prefill,decode}/ within ~50 s of each window closing, while the benchmark kept running (--kill none).
  • Scheduling shape during the window matched the unprofiled run at the same offset (one scheduled request per decode iteration, 8–11 it/s), so the capture did not starve the pipeline. Cost visible in the iteration log: report generation runs in-line and stalled a decode rank for 1.8 s and 4.4 s around cudaProfilerStop.
  • Compute nodes report kernel.perf_event_paranoid = -1, so nsys_cpuctxsw: process-tree is 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)

run reports what happened after the capture windows closed
570810 32/32, validated with in-container nsys stats (exact 1200-1299 window, 111 kernel families, GIL tracer active) decode worker: all 8 ranks stopped at iter 7624, 229 s after its window; Hang detected after 300 seconds on ranks 2/3 in tp_allgather; MPI_Abort, exit 137
570978 32/32 (1.8 GB), written 22:47:59-22:48:01 prefill worker 0: all 8 ranks stopped at iter 2324, ~5.5 min after its window; [RANK 2] Hang detected after 300 seconds in communicator.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 cleanup

Both 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 no
throughput 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, no python-gil, no NVTX_INJECTION64_PATH): worse. prefill worker 2 died
the moment its capture range opened: all 8 ranks' last log line is iter = 1200 (the start step) and the log ends
with !!!!!!! Segfault encountered !!!!!!! in cuMemcpyHtoDAsync_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 cuda hardware tracing segfaults at
cudaProfilerStart, while the playbook's -t cuda-sw,nvtx,python-gil captures reliably (2 x 32 valid reports) but
leaves a worker wedged a few minutes after its window. The playbook's choice of cuda-sw looks deliberate. The
capture 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-shutdown capture-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 also
wraps python3 -m dynamo.frontend in nsys 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_PATH to 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)

  • Every hang: the executor thread is in 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 threads
    cannot run (native code holding the GIL).
  • Every hang follows a KV-transfer first-contact between a decode rank and a prefill worker
    (Registering peer in first request to endpoint / Initialized ZMQMessenger(...), i.e. NIXL/UCX peer setup with
    CUDA 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) and repeat (PR feat(profiling): nsight-slurm profiling type (Nsight Cloud for Slurm) #419) modes.
  • Working hypothesis: NIXL/UCX's CUDA driver calls from non-Python threads deadlock in nsys's CUDA API interception
    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 in tests/test_profiling_playbook.py (recipe flags, time mode, knobs, env, validation, warning).
  • Recipe loaded through SrtConfig.Schema and srtctl dry-run on 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 own terminate_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 to profiling.frontend (always time-windowed) and to nsys-time workers; iteration-based captures are untouched. Tests execute the generated script against a fake nsys.

Hang forensics, second pass (verified in the logs)

  • The post-window hang is at exactly stop_step + 1024 iterations in every hang run: decode 6600 → 7624 (path A stop and path B repeat), prefill 1300 → 2324 (4 runs; 2319/2317 in two). The stuck iteration onboards a new request (KV receive). Every late Initialized ZMQMessenger line is a genuine first contact (zero re-initialisations across 5 runs).
  • -c cudaProfilerApi without a CUDA tracer in -t makes 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-iteration cudaEventRecord (594484). Keep cuda-sw in -t whenever the capture range is used, or switch to nsys-time.

🤖 Generated with Claude Code

nv-yna and others added 11 commits August 27, 2026 12:34
…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>
@nv-yna

nv-yna commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Report validated inside the container (hecate0057, nsys 2026.3.0, while 570810 was still running)

nsys stats --report cuda_gpu_kern_sum,nvtx_sum,cuda_api_sum on prefill/hecate0057_prefill_w0_profile_rank0.nsys-rep (74.7 MB):

  • Window is exact: nvtx_sum has 100 TensorRT-LLM:_forward_step instances and per-iteration ranges [Executor] _forward_step 1200 … 1299 — the configured prefill: 1200-1300.
  • CUDA trace is real: 111 kernel families; top entries MegaMoE FC1/FC2 38.8 % (6,100 instances = 61 per iteration), FMHA 12.9 %, deep_gemm::sm100_mqa_logits 11.3 %, topK-per-row prefill 5.2 %. cuda_api_sum: cudaEventSynchronize 56.7 % of API time.
  • python-gil tracer is active: GIL Trace:Holding GIL 4.03 M instances, Waiting for GIL 23 k.
  • Stats CSVs written next to the reports under logs/profiles/.

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.

@nv-yna

nv-yna commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

570810 did not complete — decode worker hung 4 minutes after its capture window

Timeline (all from the run's logs):

time event
21:00:50 benchmark starts (+16 min after job start, same as the unprofiled run)
21:08:53 → 21:09:54 prefill capture (iter 1200–1300 on all 3 workers), 24 reports written
21:09:47 → 21:11:19 decode capture (iter 6000–6600), 8 reports written by 21:11:46
21:12–21:14 decode GPU utilisation 12 % → 6.6 → 3.5 → 1.9 %; frontend completions +108–152/min → +84 / +31 / +43
21:15:08 all 8 decode ranks log iteration 7624 with host_step_time = 343 ms and never log again
21:20:08 TRT-LLM hang detector on ranks 2 and 3: "Hang detected after 300 seconds", both stuck in tp_allgather → safe_allgather → _serialize_and_exchange_lengths from cuda_graph_runner.pad_batch; KV transceiver idle (0 sessions)
21:20:08 worker killed (exit 137) → srtctl critical-process failure → aiperf ServerDisconnected, ProfileAborted (still in the ~36-min AgentX warmup) → sweep exit 1 at 21:28:43

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 (--capture-range-end=stop leaves the injection in the process) the prime suspect, but n = 1 and the stacks show a collective waiting for a rank whose own stack was not dumped, so the trigger is not identified. Job 570978 is a resubmission of the identical recipe to get n = 2 and, if it completes, the throughput comparison. The 32 reports from 570810 are intact and valid regardless.

nv-yna and others added 6 commits September 15, 2026 23:57
…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>
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.

1 participant