Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat(observability): combined host-attribution coverage (#356 + #413 + #415) + scraper label fix - #417

Draft
nv-yna wants to merge 10 commits into
NVIDIA:mainfrom
nv-yna:yna/observability-combined
Draft

feat(observability): combined host-attribution coverage (#356 + #413 + #415) + scraper label fix#417
nv-yna wants to merge 10 commits into
NVIDIA:mainfrom
nv-yna:yna/observability-combined

Conversation

@nv-yna

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

Copy link
Copy Markdown
Collaborator

What

Integration branch carrying the full host-attribution coverage of three open observability PRs on top of main, plus the scraper fix that two of their metric families need to survive into the parquet:

Merge order main#413#415#356. One textual conflict, in start_tachometer: #413 appended --collector.processes to the inline node_exporter command, #415 replaced that literal with tachometer_node_exporter_command_template(). Resolved to the function with processes added to NODE_EXPORTER_COLLECTORS:

/bin/node_exporter --web.listen-address=:{port} --collector.disable-defaults --collector.cpu --collector.infiniband --collector.meminfo --collector.stat --collector.vmstat --collector.pressure --collector.meminfo_numa --collector.processes --collector.vmstat.fields=^(oom_kill|pgpg|pswp|pgsteal|pg.*fault).*

#356 merged clean (its host_sampler.py hunks and #415's _pressure() do not overlap).

Post-merge fixups (third commit): the remote host samplers from #356 hardcoded --interval 2 while the in-process sampler follows observability.tachometer.collect_interval_ms since b83dc4a (after #356's base) — both now use the knob, clamped to ≥ 1 s; the node_exporter row in docs/config-reference.md lists the eight collectors of the union; the collectors comment no longer claims that no scraper change is needed.

Verified overlap table

The table circulated for these three PRs was checked row by row against the diffs (one reader per PR, then a synthesis and an adversarial pass; the adversarial pass upheld every reader verdict and found no unflagged overlap — pairwise file overlap is #356×#413 schema.py, #356×#415 host_sampler.py, #413×#415 telemetry_stage.py + tests/test_telemetry.py). Corrections are marked.

capability #356 #413 #415
edits node_exporter command in telemetry_stage.py ✅ (conflict, resolved above)
edits host_sampler.py ✅ (_pressure() only; no textual conflict with #356)
procs_running / procs_blocked ✅ sampler /proc/stat (was ✅: --collector.processes exports node_processes_{threads,state}, not node_procs_*) --collector.stat
per-process schedstat (run_delay, migrations, affinity)
per-process CPU/threads/ctx/RSS (process-exporter) — (no exporter; but under observability.enabled the all-node sampler ships its pre-existing per-process cpu/threads/ctx/rss fields from every node, so coverage overlaps #413's)
host sampler on all nodes
PSI / pressure ✅ (node_exporter on worker nodes + sampler psi; the sampler field is written, not ingested)
vmstat pgmajfault/pgsteal
per-NUMA free memory ✅ collected, but lost in the scraper until the filter fix in this PR

So "3× redundant" for procs_running/blocked is 2× (sampler on every node via #356, node_exporter on worker nodes via #415); both are kept, they are cheap and cover different node sets.

What runs in which mode

  • Default visibility (no observability: block): tachometer + DCGM + node_exporter with the eight collectors above + host-native process-exporter on backend ∪ frontend nodes.
  • observability.enabled: true: additionally the /proc host sampler on every node (schedstat, procs_running/blocked, PSI) and the span / request-trace legs. The sampler is gated on observability.enabled in all three PRs; this branch does not change that.
  • PSI is absent on kernels without CONFIG_PSI (hecate's 6.17.0-nvidia-64k); node_exporter simply omits the family.

Validation

  • pytest tests/ → 1778 passed, 2 skipped (union of the three PRs' tests; exporter-count assertions still hold).
  • cargo test in src/tachometer → all green incl. the 3 new filter tests; cargo fmt --check clean.
  • ruff check: no new findings beyond those in the source PRs (PLW1510 in tests/test_host_sampler_all_nodes.py:52 from feat(observability): host sampler on every node + scheduler-contention metrics #356) and pre-existing ones on main.
  • srtctl dry-run on hecate from this branch shows the union node_exporter command and process_exporter | host binary configs/process-exporter :9256.
  • Two full runs on hecate (8 VR200 nodes, 2026-09-10, aarch64 scraper built from this branch): 567529 baseline arm and 567533 after-fix arm (FASTOKENS_BPE_THREADS=8), both COMPLETED inside 2h10 with the dashboard rendered in-job. Every family of the union reached the parquet with its labels (memory_numa_*{numa_node=N}, processes_state{state=X}, procs_running/blocked, vmstat_pgsteal_*; pressure_* absent on hecate's kernel), and the process exporter launched host-native on all nodes without manual steps. Frontend group: 83.5 avg cores / 168k ctx-switches/s baseline vs 2.44 cores / 5.5k/s with the fix; client throughput 39,320 vs 41,897 tok/s. Details and the cost table are in the comments below.

Why draft

Integration branch: it should land only after (or instead of) its three sources; the filter fix could be split out to its own PR if reviewers prefer. Known gaps carried over from the sources: the sampler psi field is not ingested; the scraper still writes no final.parquet.

🤖 Generated with Claude Code

nv-yna and others added 9 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 <[email protected]>
…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 <[email protected]>
…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 <[email protected]>
Signed-off-by: Yuewei Na <[email protected]>
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 <[email protected]>
…-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 <[email protected]>
Signed-off-by: Yuewei Na <[email protected]>
# 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 <[email protected]>
Signed-off-by: Yuewei Na <[email protected]>
@codecov-commenter

codecov-commenter commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.42328% with 37 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@d0529aa). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/srtctl/analysis/host_sampler.py 67.07% 27 Missing ⚠️
src/srtctl/cli/mixins/benchmark_stage.py 77.77% 10 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #417   +/-   ##
=======================================
  Coverage        ?   74.10%           
=======================================
  Files           ?      101           
  Lines           ?    14291           
  Branches        ?        0           
=======================================
  Hits            ?    10591           
  Misses          ?     3700           
  Partials        ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…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 <[email protected]>
Signed-off-by: Yuewei Na <[email protected]>
@nv-yna

nv-yna commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

First run from this branch: hecate 567529 (fastokens-ab-base-combined, default visibility, 8 VR200 nodes, started 00:45:59 PDT)

Checkout at 22475f2 with the aarch64 scraper built from 4609568. Inventory of the first parquet shard (incomplete-1.parquet, 1,000,581 rows, 2 minutes in; timestamp_ns present):

  • node_exporter — the union of eight collectors is live (the exporter log lists cpu infiniband meminfo meminfo_numa pressure processes stat vmstat), and the filter fix does what it says:
    • memory_numa_*{numa_node=0}{numa_node=19} — one series per NUMA node (was collapsing into one before 4609568)
    • processes_state{state=D|I|R|S|Z}, processes_threads_state{thread_state=…}, processes_threads, processes_pids
    • procs_running, procs_blocked, context_switches_total, forks_total, intr_total (stat)
    • vmstat_pgmajfault plus vmstat_pgsteal_{anon,direct,file,khugepaged,kswapd,proactive} (the widened field filter)
    • pressure_*: 0 series — expected, hecate's kernel has no CONFIG_PSI
  • process_exporter — launched host-native by start_tachometer on all 8 nodes (tachometer_process_exporter.out: eight Listening on [::]:9256, zero errors, no enroot involvement): 23 namedprocess_* families, groups seen so far frontend, dynamo_trtllm, etcd, nats, tachometer, node_exporter, dcgm_exporter (the client group appears once the benchmark starts).
  • 33 scrape endpoints (8 dcgm + 8 node + 8 process + frontend + 8 backend ranks); exporter .out files have 0 error lines.

Second run 567533 (same, with FASTOKENS_BPE_THREADS=8) is queued. Will attach the frontend-group numbers from both when they finish.

@nv-yna

nv-yna commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

567529 finished (COMPLETED 02:50:17, 2:04:18 of the 2:10 limit) — results and a cost observation

Coverage held for the whole run. Final shard 101,125,428 rows, timestamp_ns present, ingest tachometer -> server_metrics_export.jsonl: 6155 timestamps, bundle + dashboard rendered in-job. Frontend group from the process exporter on hecate0071 over the benchmark window: 83.5 avg cores, num_threads 2 → 1,444 (peak 1,956), 168k ctx switches/s, all CPU under tokio-runtime-w — the fastokens 352-thread BPE-pool signature, same as the earlier PR #413 run (565854). node_exporter order statistics on the same node: per-CPU user busy fraction min 0.140 / p10 0.140 / p90 0.339 versus ~0.000 on the other decode node.

Control run on main with FASTOKENS_BPE_THREADS=8 (567530, default visibility, same recipe otherwise): frontend step 1.07 avg cores by sacct (67.4 here), node-level user p10 0.000 / p90 0.017 on the frontend node — indistinguishable from its neighbour. Client side: output token throughput 43,074 vs 39,320 tok/s (+9.5 %), ITL avg 16.70 vs 18.30 ms, TTFT avg 8.96 vs 11.91 s.

Cost of the collector union, measured against that main run (same node count, same recipe):

567529 (this branch) 567530 (main)
node_exporter step, avg cores per node 0.13 0.05
process-exporter step, avg cores per node 0.50
tachometer scraper, avg cores on the head node 5.66 4.25
final parquet rows 101.1 M (job 565854 on the #413 branch: 56.0 M)
ingest wall time 1,008.8 s 507.8 s

The extra volume is almost entirely --collector.meminfo_numa: Vera nodes expose 20+ NUMA nodes × ~50 meminfo fields, giving 1,882 distinct node_exporter metric strings per node at 1 Hz. It is what makes the per-NUMA breakdown real, but it doubled the parquet and the ingest time, and pushed this 2h10 job to within 6 minutes of its limit. Worth a reviewer decision: keep as default, or scrape meminfo_numa at a lower cadence / restrict to MemFree/MemUsed in the scraper filter.

@nv-yna

nv-yna commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

567533 finished — after-fix arm on this branch (COMPLETED 04:16:11, 1:59:57 of 2:10; ingest 840.8 s; dashboard rendered in-job)

Same recipe with FASTOKENS_BPE_THREADS=8. The union's process-level view shows the fix directly, alongside the baseline run of this branch:

frontend group (process exporter, frontend node), last 5000 s 567529 baseline 567533 fix
cpu_seconds_total avg cores 83.5 2.44
num_threads last / peak 1,444 / 1,956 1,100 / 1,612
context_switches_total /s 168k 5.5k
sacct frontend step avg cores 67.4 1.99

Client side: 41,897 tok/s vs 39,320 (+6.6 %), ITL avg 17.19 vs 18.30 ms, TTFT avg 9.80 vs 11.91 s.

Cross-branch, same arm, same day: main fix run 567530 43,074 tok/s, PR #413 fix run 567532 43,834 tok/s, this branch 41,897 tok/s (−2.7 % vs main). The baselines line up the other way (39,320 here vs 39,543 on main, −0.6 %), and the original study measured ~18 % node-to-node variance for identical deployments, so no runtime cost of the extra collectors is demonstrated by these two pairs — but it is also not excluded. Exporter cost per node was flat: process-exporter 0.46, node_exporter 0.11 avg cores; the tachometer scraper on the head node was higher on this run (8.5 avg cores vs 4.3–5.8 elsewhere; parquet 83 M rows).

Both runs of this branch completed inside 2h10 with the render; the meminfo_numa volume note above still stands (17 and 14 min ingests vs 8.5–9.5 on main / #413).

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.

2 participants