759 lines
29 KiB
Markdown
759 lines
29 KiB
Markdown
|
|
# Pipeline-Parallel Inference: Diagnostics Plan
|
|||
|
|
|
|||
|
|
This plan describes the data we intend to collect on every run of
|
|||
|
|
`pp-smoke-run` (local, Docker, and especially vast.ai) so that failures
|
|||
|
|
of the iroh / SWIM layer become explainable from a single artifact
|
|||
|
|
produced by the run itself, without ssh, re-renting, or guessing.
|
|||
|
|
|
|||
|
|
It is the response to `VASTAI_STATUS.md`. The open questions in that
|
|||
|
|
doc — "which stage is the dead one?", "is routing asymmetric?", "are
|
|||
|
|
canary relays the cause?", "is the connection cache stale?" — should
|
|||
|
|
all be answerable from the bundle produced by a single tier-1 run, and
|
|||
|
|
the deeper *why* questions should be answerable from a tier-2 run.
|
|||
|
|
|
|||
|
|
This document describes **what** is collected, not how. An
|
|||
|
|
implementation pass should treat each collection below as a discrete
|
|||
|
|
unit of work whose shape is fixed by this doc but whose mechanism is
|
|||
|
|
open.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Goal
|
|||
|
|
|
|||
|
|
A single vast.ai run produces one tarball. The tarball contains
|
|||
|
|
per-node snapshots, event streams, and host context, time-aligned
|
|||
|
|
across nodes, plus a one-page human-readable summary. Reading the
|
|||
|
|
tarball answers:
|
|||
|
|
|
|||
|
|
- Which peer (by hex) corresponds to which stage, on which host.
|
|||
|
|
- For every peer-pair, in each direction, when packets last flowed.
|
|||
|
|
- The exact moment, peer, and reason for the SWIM transition that
|
|||
|
|
killed the cluster.
|
|||
|
|
- The iroh-internal view of each peer at that moment: connection type,
|
|||
|
|
known addresses, known relays, latency.
|
|||
|
|
- The host's network and DNS state at that moment.
|
|||
|
|
- Whether the orchestrator and the dead peer shared a relay or not.
|
|||
|
|
|
|||
|
|
If the bundle doesn't answer one of these, the plan has a gap and we
|
|||
|
|
patch it before the next session.
|
|||
|
|
|
|||
|
|
## Principles
|
|||
|
|
|
|||
|
|
1. **Coverage of local observations, not global truth.** We cannot
|
|||
|
|
reconstruct a globally true mesh — silence is unrecorded and clocks
|
|||
|
|
skew. We can make each node's local record complete and time-tagged
|
|||
|
|
well enough to stitch together post-hoc.
|
|||
|
|
|
|||
|
|
2. **Delivery survives the failure being diagnosed.** Anything iroh+
|
|||
|
|
SWIM are responsible for, we don't ship diagnostics over. The
|
|||
|
|
collector path is HTTP, side-channel to the layer under test.
|
|||
|
|
|
|||
|
|
3. **Bundles are read by tools, not eyeballs.** Free-text logs are
|
|||
|
|
what cost us 8 vast.ai rentals. Every record is structured. The
|
|||
|
|
post-processing tool is part of the plan, not a follow-up.
|
|||
|
|
|
|||
|
|
4. **Boot identity is the keystone.** Every diagnostic is useless if
|
|||
|
|
we cannot bind `node_id_hex` ↔ `stage_index` ↔ `host`. Land that
|
|||
|
|
first or nothing else parses.
|
|||
|
|
|
|||
|
|
5. **Capture broadly, summarize narrowly.** We collect everything;
|
|||
|
|
the post-processor produces a one-pager. The raw data is for the
|
|||
|
|
tool, the one-pager is for the human.
|
|||
|
|
|
|||
|
|
6. **No code in core swactor.** Diagnostics live in the crate layers
|
|||
|
|
(`crates/distribution`, a new `crates/diagnostics`, the
|
|||
|
|
pipeline-parallel example) and consume swactor through its public
|
|||
|
|
surface. The actor runtime under `src/` stays clean; instrumentation
|
|||
|
|
lives where the behavior being observed lives.
|
|||
|
|
|
|||
|
|
7. **Crate-level feature, not pipeline-parallel-specific.** Event
|
|||
|
|
emission, the aggregator, and the collector protocol are a reusable
|
|||
|
|
feature consumed by any binary built on this stack.
|
|||
|
|
`pp-smoke-run` is the first consumer, not the owner. Other examples
|
|||
|
|
(single-gpu-inference, future binaries) get the same diagnostics for
|
|||
|
|
free.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Tier 1 — Self-Contained Post-Mortem Bundle
|
|||
|
|
|
|||
|
|
Tier 1 is the skeleton. Around iroh, not inside it. Goal: produce a
|
|||
|
|
parseable bundle that answers every open question in
|
|||
|
|
`VASTAI_STATUS.md`. Each collection below is independent and can be
|
|||
|
|
landed separately, but the tier is only useful when all are present.
|
|||
|
|
|
|||
|
|
## T1.1 — Identity Binding
|
|||
|
|
|
|||
|
|
A single canonical record emitted by every node at boot and
|
|||
|
|
re-emitted in the header of every snapshot.
|
|||
|
|
|
|||
|
|
Fields:
|
|||
|
|
|
|||
|
|
- `node_id_hex` — full hex, never truncated in records
|
|||
|
|
- `node_id_short` — first 8 hex chars (matches what orch logs print)
|
|||
|
|
- `role` — `"orchestrator"` or `"stage"`
|
|||
|
|
- `stage_index` — integer, 0..N-1 for stages, null for orchestrator
|
|||
|
|
- `stage_count` — N for the current run
|
|||
|
|
- `run_id` — opaque string assigned by `pp-smoke-run` at run start,
|
|||
|
|
identical across all nodes in the same run
|
|||
|
|
- `vastai_contract_id` — null when not on vast.ai
|
|||
|
|
- `host_ip_public` — best-effort, from vastai metadata or an external
|
|||
|
|
reflection probe at boot
|
|||
|
|
- `host_country`, `datacenter_id` — from vast.ai metadata
|
|||
|
|
- `hostname`, `container_id`
|
|||
|
|
- `process_start_unix_ms`
|
|||
|
|
- `boot_sequence` — incremented on restart (allows distinguishing
|
|||
|
|
reruns inside a single contract)
|
|||
|
|
- `binary_version`, `git_sha`, `iroh_version`
|
|||
|
|
- `home_relay_url_at_boot` — best-effort; null if iroh hasn't picked
|
|||
|
|
one yet at the moment of the record
|
|||
|
|
|
|||
|
|
Trigger: emitted to the collector on boot and embedded in every
|
|||
|
|
snapshot header. Logged to stdout once at boot in a single grep-able
|
|||
|
|
line.
|
|||
|
|
|
|||
|
|
Answers: "which peer hash is which stage" — without this, nothing
|
|||
|
|
else parses.
|
|||
|
|
|
|||
|
|
## T1.2 — Per-Peer Per-Direction Reachability Log
|
|||
|
|
|
|||
|
|
Per-node, per-remote-peer record, maintained continuously. The
|
|||
|
|
*local* reachability matrix from the perspective of this node.
|
|||
|
|
|
|||
|
|
Fields per peer:
|
|||
|
|
|
|||
|
|
- `peer_node_id_hex`
|
|||
|
|
- `last_inbound_packet_at_ms` — wall time of last inbound traffic of
|
|||
|
|
any kind from this peer, plus `via_relay_url` (or `"direct"`)
|
|||
|
|
- `last_outbound_success_at_ms` — last outbound message we observed
|
|||
|
|
succeed, plus `via_relay_url`
|
|||
|
|
- `last_dial_started_at_ms`
|
|||
|
|
- `last_dial_outcome` — `success | timeout | refused | no_route |
|
|||
|
|
error(string)`
|
|||
|
|
- `last_dial_duration_ms`
|
|||
|
|
- `current_swim_opinion` — `alive | suspect | dead | unknown`
|
|||
|
|
- `current_swim_opinion_since_ms`
|
|||
|
|
- `swim_transition_history` — bounded ring buffer (~32) of
|
|||
|
|
`{from, to, at_ms, reason}` tuples
|
|||
|
|
- `metadata_version_seen` — latest metadata version we have from this
|
|||
|
|
peer
|
|||
|
|
- `metadata_relay_url_seen` — relay URL this peer told us about (may
|
|||
|
|
differ from what iroh actually used)
|
|||
|
|
|
|||
|
|
Trigger: maintained in memory continuously; emitted as part of every
|
|||
|
|
snapshot.
|
|||
|
|
|
|||
|
|
Answers: asymmetric routing. By aligning `node_A.last_inbound_from_B`
|
|||
|
|
against `node_B.last_outbound_to_A` across the bundle, asymmetry
|
|||
|
|
becomes immediately visible.
|
|||
|
|
|
|||
|
|
## T1.3 — Structured Event Stream
|
|||
|
|
|
|||
|
|
Per-node append-only stream of typed events. Free-text logs are
|
|||
|
|
retained for human debugging but the diagnostic record is structured.
|
|||
|
|
|
|||
|
|
Common envelope:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
{ node_id, monotonic_seq, wall_ms, event_type, fields }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`monotonic_seq` is a per-node integer that never decreases, used for
|
|||
|
|
intra-node ordering even when wall clock jumps. `wall_ms` is best-
|
|||
|
|
effort and aligned post-hoc via T1.5.
|
|||
|
|
|
|||
|
|
Event types (minimum set):
|
|||
|
|
|
|||
|
|
- `boot` — re-emits identity block
|
|||
|
|
- `relay_changed` — `{old_url, new_url, reason?}` for our own home
|
|||
|
|
relay (subscribed via iroh; see T2.2)
|
|||
|
|
- `swim_metadata_sent` — `{version, payload_hash}`
|
|||
|
|
- `swim_metadata_received` — `{peer, version, payload_hash, fields}`
|
|||
|
|
- `dial_started` — `{peer, attempt, timeout_ms}`
|
|||
|
|
- `dial_outcome` — `{peer, attempt, outcome, duration_ms, error?}`
|
|||
|
|
- `swim_transition` — `{peer, from, to, reason}`
|
|||
|
|
- `connection_cache_hit` / `cache_miss` / `cache_invalidated`
|
|||
|
|
— `{peer, generation, reason?}`
|
|||
|
|
- `message_sent` / `message_received` — `{peer, message_type, size}`
|
|||
|
|
- `probe_sent` / `probe_received` — for our own probe layer (T3.3)
|
|||
|
|
- `error` — `{component, message, peer?}`
|
|||
|
|
|
|||
|
|
Delivery: batched POST to the collector every ~1s and immediately on
|
|||
|
|
state-transition events (`swim_transition`, `relay_changed`,
|
|||
|
|
`cache_invalidated`).
|
|||
|
|
|
|||
|
|
Answers: ordered causality of failure within a node and, post-
|
|||
|
|
alignment, across nodes.
|
|||
|
|
|
|||
|
|
## T1.4 — Snapshot Fan-Out
|
|||
|
|
|
|||
|
|
A snapshot is a full point-in-time dump of the local view: identity
|
|||
|
|
block + reachability log (T1.2) + event tail since last snapshot +
|
|||
|
|
(in tier 2) iroh and SWIM internals + (in tier 3) host context.
|
|||
|
|
|
|||
|
|
Triggers:
|
|||
|
|
|
|||
|
|
- **Periodic** — every 5s (default; configurable).
|
|||
|
|
- **Local-transition** — each node snapshots immediately whenever its
|
|||
|
|
own SWIM view transitions any peer (alive→suspect→dead etc.).
|
|||
|
|
Different nodes will snapshot at different moments; the
|
|||
|
|
post-processor correlates them via T1.5 clock alignment plus the
|
|||
|
|
SWIM message versions both sides observed. This replaces what would
|
|||
|
|
otherwise be a fan-out broadcast — neither the orchestrator (NAT'd
|
|||
|
|
laptop) nor vast.ai workers can reliably push to each other on
|
|||
|
|
demand, so we rely on independent local triggers + post-hoc
|
|||
|
|
correlation.
|
|||
|
|
- **Pull-trigger** — the collector can attach a `snapshot_now` hint in
|
|||
|
|
the response to any node's HTTP POST. Nodes honor the hint on their
|
|||
|
|
next opportunity. `pp-smoke-run` uses this at end-of-run to force a
|
|||
|
|
global final snapshot.
|
|||
|
|
|
|||
|
|
Snapshot record envelope:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
{ identity_block, run_id, snapshot_id, wall_ms, monotonic_seq,
|
|||
|
|
trigger: periodic | transition(event_id) | on_demand,
|
|||
|
|
body: { ... tier-1/2/3 fields ... } }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Answers: correlated views at the smoking-gun moment. Periodic
|
|||
|
|
sampling is the safety net; transition-driven is the smoking gun.
|
|||
|
|
|
|||
|
|
## T1.5 — Clock Alignment via Collector
|
|||
|
|
|
|||
|
|
Without a global clock, post-hoc alignment is the next best thing.
|
|||
|
|
The collector is the canonical time source; alignment piggybacks on
|
|||
|
|
every diagnostic POST and needs no dedicated channel.
|
|||
|
|
|
|||
|
|
Mechanism (described as data, not protocol): on every POST a node
|
|||
|
|
makes to the collector, the node includes `node_send_ms` (its wall
|
|||
|
|
clock at send). The collector's response includes
|
|||
|
|
`{node_send_ms_echoed, collector_recv_ms, collector_send_ms}`. The
|
|||
|
|
node records its own `wall_ms_at_receive`. From these four values
|
|||
|
|
the node (or the post-processor) computes the node's offset to
|
|||
|
|
collector time with bounded error (RTT/2 worst case).
|
|||
|
|
|
|||
|
|
Each `clock_sample` is recorded as a structured event so the post-
|
|||
|
|
processor has a stream of offsets per node over the lifetime of the
|
|||
|
|
run, not just a single calibration.
|
|||
|
|
|
|||
|
|
Every node — including the orchestrator on the user's laptop — talks
|
|||
|
|
to the collector, so every node gets aligned to the same reference.
|
|||
|
|
|
|||
|
|
Sub-second precision is enough for our purposes.
|
|||
|
|
|
|||
|
|
Answers: when reading the bundle, "did stage 0 go dead before or
|
|||
|
|
after stage 2's outbound dial timed out?" — currently unanswerable
|
|||
|
|
because we only have local clocks.
|
|||
|
|
|
|||
|
|
## T1.6 — Out-of-Band Collector (VPS-Hosted)
|
|||
|
|
|
|||
|
|
A small HTTP server deployed once to a stable VPS with public ports.
|
|||
|
|
All nodes — orchestrator (on the user's laptop, NAT'd) and every
|
|||
|
|
stage (on vast.ai) — POST to the same collector URL. The URL is
|
|||
|
|
injected into every process at startup via an environment variable
|
|||
|
|
(`SWACTOR_DIAG_COLLECTOR_URL` or similar).
|
|||
|
|
|
|||
|
|
The collector is shared infrastructure, not part of any single run.
|
|||
|
|
Multiple concurrent or sequential runs are separated by `run_id`. A
|
|||
|
|
run's diagnostic bundle is the slice of collector storage tagged
|
|||
|
|
with that id.
|
|||
|
|
|
|||
|
|
Why a VPS and not the orchestrator: the orchestrator runs on the
|
|||
|
|
user's laptop with no stable public ingress, so vast.ai workers
|
|||
|
|
cannot reach back to it. The collector must live somewhere both
|
|||
|
|
sides can reach. The user already operates a VPS with stable ports;
|
|||
|
|
the collector deploys there.
|
|||
|
|
|
|||
|
|
Endpoints (conceptual; one per record kind):
|
|||
|
|
|
|||
|
|
- `POST /diag/boot` — identity block on boot
|
|||
|
|
- `POST /diag/events` — batch of structured events
|
|||
|
|
- `POST /diag/snapshot` — a single snapshot record
|
|||
|
|
- `POST /diag/finalize` — run end marker, includes summary metadata
|
|||
|
|
|
|||
|
|
All requests carry `run_id` and `node_id` headers. All responses
|
|||
|
|
include a `clock` block (T1.5) and an optional `hints` block (T1.4
|
|||
|
|
pull-trigger).
|
|||
|
|
|
|||
|
|
The collector persists to `{collector_root}/{run_id}/{node_id}/`.
|
|||
|
|
|
|||
|
|
If the collector is temporarily unreachable, the node spools records
|
|||
|
|
to a local on-disk queue (`/tmp/swactor-diag/{run_id}/`) and retries
|
|||
|
|
with exponential backoff. The on-disk spool is included in the
|
|||
|
|
tarball post-hoc so we never silently lose data on transient
|
|||
|
|
collector outages. No external-fallback collector is needed —
|
|||
|
|
there's just the one collector, and the spool covers its downtime.
|
|||
|
|
|
|||
|
|
Answers: ensures diagnostics survive iroh failures, orchestrator
|
|||
|
|
death, and individual stage isolation, because none of the failure
|
|||
|
|
modes the system is being diagnosed for involve the diagnostic path
|
|||
|
|
itself.
|
|||
|
|
|
|||
|
|
## T1.7 — Run Tarball Assembly
|
|||
|
|
|
|||
|
|
At end of run (success, failure, SIGTERM, or `wait_for_running`
|
|||
|
|
timeout), `pp-smoke-run` POSTs `/diag/finalize` to the collector,
|
|||
|
|
which:
|
|||
|
|
|
|||
|
|
- Sets a `snapshot_now` hint for every node still posting under this
|
|||
|
|
`run_id`, so each node emits a final snapshot
|
|||
|
|
- Waits up to ~5s for stragglers
|
|||
|
|
- Tars `{collector_root}/{run_id}/` into one archive
|
|||
|
|
- Writes a `MANIFEST.json` at the tar root listing nodes, snapshot
|
|||
|
|
counts, event counts, run start/end times, exit reason
|
|||
|
|
- Drops the archive at a configurable path on the collector host
|
|||
|
|
(default: `{collector_root}/bundles/{run_id}.tar.gz`)
|
|||
|
|
|
|||
|
|
`pp-smoke-run` can optionally fetch the tarball back to the laptop
|
|||
|
|
via a `GET /diag/bundle/{run_id}` endpoint for offline inspection.
|
|||
|
|
|
|||
|
|
This is the artifact for the next session. Everything else exists to
|
|||
|
|
fill it.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Tier 2 — Why Did the Reachability Gap Exist?
|
|||
|
|
|
|||
|
|
Tier 1 tells us *that* the cluster failed and *where*. Tier 2 tells
|
|||
|
|
us *why* by going inside iroh and inside SWIM. These collections live
|
|||
|
|
inside snapshots (T1.4) and add events to the stream (T1.3).
|
|||
|
|
|
|||
|
|
## T2.1 — iroh RemoteInfo Scrape (per-peer)
|
|||
|
|
|
|||
|
|
For each peer iroh has heard of, capture iroh's own view:
|
|||
|
|
|
|||
|
|
- `conn_type` — `Direct | Relay | Mixed | None`
|
|||
|
|
- `latency_ms` — if iroh reports
|
|||
|
|
- `last_used_ms`, `last_received_ms` — iroh's accounting (compare
|
|||
|
|
against our own T1.2)
|
|||
|
|
- `direct_addresses` — list of (ip, port) iroh has discovered
|
|||
|
|
- `relay_urls` — list of relays iroh has for this peer
|
|||
|
|
- `addr_sources` — for each address, where iroh learned it (discovery,
|
|||
|
|
add_node_addr, observed inbound)
|
|||
|
|
|
|||
|
|
Trigger: included in every snapshot. Additionally, emit a
|
|||
|
|
`conn_type_changed` event whenever iroh transitions between
|
|||
|
|
Direct/Relay/Mixed/None for a given peer.
|
|||
|
|
|
|||
|
|
Answers: "did iroh ever have a path to this peer?" — separates
|
|||
|
|
"we never told iroh how to reach them" from "iroh tried and gave up."
|
|||
|
|
|
|||
|
|
## T2.2 — iroh Home-Relay Watch
|
|||
|
|
|
|||
|
|
Subscribe to iroh's home-relay watcher (per node, for our own home
|
|||
|
|
relay). Emit `relay_changed` events on every transition.
|
|||
|
|
|
|||
|
|
Captured fields per event:
|
|||
|
|
|
|||
|
|
- `old_url`, `new_url`
|
|||
|
|
- `reason` — if iroh exposes one
|
|||
|
|
- `time_since_last_change_ms`
|
|||
|
|
|
|||
|
|
Answers: did our home relay flap mid-run? Did the orchestrator and
|
|||
|
|
the dead stage actually share a relay at the moment they failed to
|
|||
|
|
reach each other? Currently the doc says "every run landed on canary"
|
|||
|
|
but we don't know if that was stable across the run.
|
|||
|
|
|
|||
|
|
## T2.3 — iroh Metrics Counters
|
|||
|
|
|
|||
|
|
Pull all `iroh-metrics` counter values into every tier-2 snapshot.
|
|||
|
|
The set is whatever iroh exposes; we don't curate. The post-processor
|
|||
|
|
computes deltas.
|
|||
|
|
|
|||
|
|
Counters of particular interest (named roughly per iroh's vocabulary;
|
|||
|
|
exact names per iroh version):
|
|||
|
|
|
|||
|
|
- `relay_send_ok`, `relay_send_err`
|
|||
|
|
- `magicsock_*` (holepunch attempts, successes, failures)
|
|||
|
|
- `conn_open`, `conn_close`
|
|||
|
|
- discovery counters
|
|||
|
|
|
|||
|
|
Answers: a fingerprint of what iroh is actually doing under the
|
|||
|
|
hood. Deltas around a `swim_transition` event are the most diagnostic
|
|||
|
|
slice.
|
|||
|
|
|
|||
|
|
## T2.4 — Connection-Cache Lifecycle
|
|||
|
|
|
|||
|
|
Our iroh driver caches one `Connection` per `NodeId`. The cache is
|
|||
|
|
currently invisible. For each entry:
|
|||
|
|
|
|||
|
|
- `peer_node_id_hex`
|
|||
|
|
- `generation` — incremented on every invalidation/recreate
|
|||
|
|
- `created_at_ms`
|
|||
|
|
- `last_successful_send_at_ms`, `last_successful_recv_at_ms`
|
|||
|
|
- `last_failure_at_ms`, `last_failure_reason`
|
|||
|
|
- `observed_conn_type_at_last_use` — Direct/Relay/Mixed at the last
|
|||
|
|
successful traffic moment (from T2.1)
|
|||
|
|
|
|||
|
|
Trigger: included in every tier-2 snapshot. Cache mutations
|
|||
|
|
(`cache_hit`, `cache_miss`, `cache_invalidated`) already emit T1.3
|
|||
|
|
events; this adds the *aggregate* view per peer.
|
|||
|
|
|
|||
|
|
Answers: the doc's open question about whether stale cached
|
|||
|
|
connections matter. We currently never invalidate; this collection
|
|||
|
|
shows when we should have.
|
|||
|
|
|
|||
|
|
## T2.5 — NodeMap Delta Tracking
|
|||
|
|
|
|||
|
|
Whenever we push address info into iroh (e.g. `add_node_addr` after
|
|||
|
|
parsing a SWIM metadata update), record both the input and the
|
|||
|
|
result:
|
|||
|
|
|
|||
|
|
- `peer_node_id_hex`
|
|||
|
|
- `from_source` — `swim_metadata | discovery | static | other`
|
|||
|
|
- `endpoint_addr_in` — relay URL, direct addrs as passed
|
|||
|
|
- `iroh_return` — success, error, or "noop" if iroh ignored
|
|||
|
|
- `diff_from_previous` — fields that changed vs. last known
|
|||
|
|
|
|||
|
|
Trigger: event-driven on every push, recorded as `nodemap_update`
|
|||
|
|
events in T1.3.
|
|||
|
|
|
|||
|
|
Answers: did we *try* to tell iroh about a peer's relay URL but iroh
|
|||
|
|
silently kept old state? The relay-url-gossip-via-SWIM-metadata fix
|
|||
|
|
in `VASTAI_STATUS.md` assumes iroh accepts the update; we currently
|
|||
|
|
have no way to verify.
|
|||
|
|
|
|||
|
|
## T2.6 — SWIM Internals Snapshot
|
|||
|
|
|
|||
|
|
For each peer in the SWIM membership list:
|
|||
|
|
|
|||
|
|
- `incarnation`
|
|||
|
|
- `last_ping_sent_at_ms`, `last_ping_received_at_ms`
|
|||
|
|
- `last_ack_sent_at_ms`, `last_ack_received_at_ms`
|
|||
|
|
- `suspect_timer_started_at_ms`, `suspect_timer_expires_at_ms`
|
|||
|
|
(when applicable)
|
|||
|
|
- `metadata_version`
|
|||
|
|
- `current_state` — alive/suspect/dead (same as T1.2 but from SWIM's
|
|||
|
|
own structures, sanity-check against the reachability log)
|
|||
|
|
|
|||
|
|
Plus configured timeouts at the top of the SWIM block so the snapshot
|
|||
|
|
is self-describing (`probe_interval_ms`, `suspect_timeout_ms`,
|
|||
|
|
`ack_timeout_ms`, indirect-probe-k, gossip fanout, etc).
|
|||
|
|
|
|||
|
|
Plus a bounded ring buffer of recent SWIM messages received
|
|||
|
|
(~64 entries): `{at_ms, from_peer, message_type, size_bytes}`.
|
|||
|
|
|
|||
|
|
Trigger: included in every tier-2 snapshot.
|
|||
|
|
|
|||
|
|
Answers: why did SWIM decide a peer was dead? Was the suspect timer
|
|||
|
|
too short for the observed ack latency? Did we lose acks but receive
|
|||
|
|
pings (a one-way break)?
|
|||
|
|
|
|||
|
|
## T2.7 — Discovery Activity
|
|||
|
|
|
|||
|
|
For each iroh discovery resolve attempt:
|
|||
|
|
|
|||
|
|
- `peer_node_id_hex`
|
|||
|
|
- `started_at_ms`, `completed_at_ms`
|
|||
|
|
- `outcome` — `success | timeout | not_found | error`
|
|||
|
|
- `addresses_returned`
|
|||
|
|
- `relay_returned`
|
|||
|
|
|
|||
|
|
Emitted as `discovery_resolve_started` / `_completed` events in T1.3.
|
|||
|
|
|
|||
|
|
Answers: did discovery contribute anything at this scale, or are we
|
|||
|
|
purely relying on SWIM metadata? Useful when the metadata path itself
|
|||
|
|
is suspect.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Tier 3 — Causes Outside Our Process
|
|||
|
|
|
|||
|
|
Tier 2 might still leave the diagnosis at "iroh thought it had no
|
|||
|
|
path to peer X." Tier 3 explains why the environment let that happen.
|
|||
|
|
|
|||
|
|
## T3.1 — Host Network Snapshot
|
|||
|
|
|
|||
|
|
Captured at boot and refreshed every ~30s (not every snapshot, too
|
|||
|
|
heavy):
|
|||
|
|
|
|||
|
|
- Network interfaces: name, ip addresses (v4/v6), mtu, state up/down
|
|||
|
|
- Default routes (v4, v6)
|
|||
|
|
- `/proc/net/udp` entries for iroh's bound sockets (so we can confirm
|
|||
|
|
the socket exists and where it's bound)
|
|||
|
|
- conntrack count, if available (best-effort, requires capability;
|
|||
|
|
null if not)
|
|||
|
|
- IPv6 enabled? (from `/proc/sys/net/ipv6/conf/all/disable_ipv6`)
|
|||
|
|
- Container's view of `/etc/resolv.conf` nameservers
|
|||
|
|
|
|||
|
|
Trigger: boot, then every ~30s; full snapshot includes the most
|
|||
|
|
recent value.
|
|||
|
|
|
|||
|
|
Answers: did the host have IPv6 disabled when iroh expected it? Did
|
|||
|
|
the bound UDP socket disappear? Did the route table change mid-run?
|
|||
|
|
|
|||
|
|
## T3.2 — DNS Resolution Snapshots
|
|||
|
|
|
|||
|
|
For every relay URL we've ever seen mentioned (ours or any peer's
|
|||
|
|
via metadata), periodically resolve it and record:
|
|||
|
|
|
|||
|
|
- `hostname`
|
|||
|
|
- `a_records` (list of v4)
|
|||
|
|
- `aaaa_records` (list of v6)
|
|||
|
|
- `ttl_seconds`
|
|||
|
|
- `resolver_used` — from resolv.conf
|
|||
|
|
- `resolved_at_ms`
|
|||
|
|
|
|||
|
|
Trigger: every ~30s, per known relay URL. Recorded into snapshots.
|
|||
|
|
|
|||
|
|
Answers: did orchestrator and the dead stage actually resolve the
|
|||
|
|
same relay name to the same IP? Did relay DNS flap? This is a
|
|||
|
|
classic asymmetric-connectivity cause that's invisible without
|
|||
|
|
explicit collection.
|
|||
|
|
|
|||
|
|
## T3.3 — Outbound Reachability Probes
|
|||
|
|
|
|||
|
|
Independent of iroh. From each node, periodically:
|
|||
|
|
|
|||
|
|
- UDP probe to each known relay URL (ours and peers' as seen in
|
|||
|
|
metadata), on iroh's expected ports
|
|||
|
|
- UDP probe to each other node's `host_ip_public` (from their
|
|||
|
|
identity block) on a known echo port if we expose one
|
|||
|
|
- Baseline probe to a known-stable target (e.g., a public STUN or
|
|||
|
|
echo service) — gives a "network is up at all" signal
|
|||
|
|
|
|||
|
|
Each probe records `{target, kind, started_at_ms, outcome, rtt_ms?,
|
|||
|
|
error?}` as `probe_sent` / `probe_received` events in T1.3.
|
|||
|
|
|
|||
|
|
Trigger: every ~10s.
|
|||
|
|
|
|||
|
|
Answers: separates "iroh can't reach this relay" from "this host
|
|||
|
|
can't reach this relay at all." If raw UDP works but iroh fails,
|
|||
|
|
the bug is iroh-side. If raw UDP fails, the bug is environment-side.
|
|||
|
|
|
|||
|
|
## T3.4 — vast.ai-Side Context
|
|||
|
|
|
|||
|
|
At boot, capture every piece of vast.ai metadata available to the
|
|||
|
|
container:
|
|||
|
|
|
|||
|
|
- `CONTAINER_ID`, `VAST_*` env vars (whatever vast.ai exposes)
|
|||
|
|
- The full offer record we created the instance from (orchestrator-
|
|||
|
|
side, since `pp-smoke-run` already has it — keyed by contract id
|
|||
|
|
in the bundle)
|
|||
|
|
- `host_country`, `datacenter_id`, `machine_id` if exposed
|
|||
|
|
- Advertised bandwidth, GPU type, driver version, CUDA version
|
|||
|
|
- Container start time vs. our process start time (catches slow
|
|||
|
|
container-start hosts)
|
|||
|
|
|
|||
|
|
Plus capture *any* stderr/log output from vast.ai's runtime layer
|
|||
|
|
that mentions failures (CDI, container init, etc) at startup. The
|
|||
|
|
CDI errors in `VASTAI_STATUS.md` were caught by accident; this makes
|
|||
|
|
them mandatory.
|
|||
|
|
|
|||
|
|
Trigger: once at boot; included in identity block extensions in
|
|||
|
|
every snapshot.
|
|||
|
|
|
|||
|
|
Answers: the CDI-class question, the "is this a bad host pool"
|
|||
|
|
question, and gives us correlations across runs (do failures cluster
|
|||
|
|
on specific datacenter_ids?).
|
|||
|
|
|
|||
|
|
## T3.5 — Process Resource Snapshot
|
|||
|
|
|
|||
|
|
Per-snapshot, low-cost:
|
|||
|
|
|
|||
|
|
- RSS, VmSize
|
|||
|
|
- Open FD count
|
|||
|
|
- Tokio runtime stats: worker count, active tasks, blocking pool
|
|||
|
|
size, idle workers
|
|||
|
|
- Per-actor mailbox depth (if the actor framework exposes it)
|
|||
|
|
- CPU time used since last snapshot
|
|||
|
|
|
|||
|
|
Trigger: every snapshot.
|
|||
|
|
|
|||
|
|
Answers: occasional smoking gun — a tokio worker stalled on a sync
|
|||
|
|
call delays SWIM probes enough to look like network failure. Cheap
|
|||
|
|
to collect, sometimes decisive.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Architecture
|
|||
|
|
|
|||
|
|
Three decoupled components. The split matters because each can be
|
|||
|
|
implemented, tested, and changed independently. Per principle 6, none
|
|||
|
|
of this lives in core swactor (`src/`); everything lives in crates
|
|||
|
|
above it, with the protocol and aggregator implemented in a reusable
|
|||
|
|
diagnostics crate (per principle 7).
|
|||
|
|
|
|||
|
|
## A.1 — Diagnostics Crate (`crates/diagnostics`)
|
|||
|
|
|
|||
|
|
A new crate that provides:
|
|||
|
|
|
|||
|
|
- **Event emission API** — a trait or lightweight macro consumed by
|
|||
|
|
the layers that produce events (`crates/distribution` for SWIM and
|
|||
|
|
iroh driver events, the pipeline-parallel example for actor-level
|
|||
|
|
events). Crates that want to be observable depend on
|
|||
|
|
`crates/diagnostics` and emit events through its API. Adding new
|
|||
|
|
event types is a matter of adding a variant, not plumbing new
|
|||
|
|
channels.
|
|||
|
|
- **The aggregator** — one instance per process. Owns the event ring
|
|||
|
|
buffer, the reachability log (T1.2), snapshot assembly, the
|
|||
|
|
delivery queue to the collector (with on-disk spool fallback per
|
|||
|
|
T1.6), and clock-sample tracking (T1.5). Every observing subsystem
|
|||
|
|
reports *into* the aggregator via in-process channels. The
|
|||
|
|
aggregator is the single place that talks to the collector.
|
|||
|
|
- **The collector protocol** — the wire format for `POST /diag/*`,
|
|||
|
|
shared between aggregator and collector binary.
|
|||
|
|
|
|||
|
|
Consumers (pp-smoke-run, pp-gpu-node, single-gpu-inference, future
|
|||
|
|
binaries) construct one aggregator at startup and pass its handle to
|
|||
|
|
whichever subsystems want to emit. Nothing about this is
|
|||
|
|
pipeline-parallel-specific.
|
|||
|
|
|
|||
|
|
The isolation between aggregator and the systems it observes
|
|||
|
|
matters: a bug in delivery never corrupts collection, and
|
|||
|
|
back-pressure in delivery never blocks the actor runtime.
|
|||
|
|
|
|||
|
|
## A.2 — Collector Binary
|
|||
|
|
|
|||
|
|
A small standalone HTTP server, separate binary in the same
|
|||
|
|
diagnostics crate. Deployed once to the VPS. Owns:
|
|||
|
|
|
|||
|
|
- The HTTP endpoints listed in T1.6
|
|||
|
|
- Persistence to disk under `{root}/{run_id}/{node_id}/`
|
|||
|
|
- Returning the `clock` block on every response (T1.5)
|
|||
|
|
- Returning optional `snapshot_now` hints in response bodies
|
|||
|
|
(T1.4 pull-trigger)
|
|||
|
|
- Tarball finalization at `/diag/finalize`
|
|||
|
|
- Optional bundle retrieval at `GET /diag/bundle/{run_id}`
|
|||
|
|
|
|||
|
|
The collector is shared infrastructure — one deployment serves all
|
|||
|
|
runs. It does not interpret data, only stores it. All interpretation
|
|||
|
|
is in A.3.
|
|||
|
|
|
|||
|
|
Operational hygiene (disk usage, bundle expiry) is the collector's
|
|||
|
|
responsibility — configurable retention (e.g. expire bundles older
|
|||
|
|
than 14 days).
|
|||
|
|
|
|||
|
|
## A.3 — Post-Processing Tool
|
|||
|
|
|
|||
|
|
A standalone binary (separate from `pp-smoke-run`; runs against a
|
|||
|
|
bundle). Inputs: a tarball. Outputs:
|
|||
|
|
|
|||
|
|
- **Reachability matrix over time** — for each time bucket (e.g.,
|
|||
|
|
5s), an N×N table per direction with cells colored by SWIM
|
|||
|
|
opinion and annotated with conn_type from T2.1.
|
|||
|
|
- **Per-peer-pair timeline** — events in order, clock-aligned per
|
|||
|
|
T1.5, with conn_type transitions, dial outcomes, and SWIM
|
|||
|
|
transitions marked.
|
|||
|
|
- **One-page summary** — the first peer to go dead, when, what each
|
|||
|
|
side's reachability log showed at the moment, what relay each side
|
|||
|
|
was on, what iroh's conn_type to that peer was, whether raw UDP
|
|||
|
|
probes to that peer's relay were working.
|
|||
|
|
- **Cross-run diff mode** — given two bundles, highlight what's
|
|||
|
|
different (e.g., relay choice, host country, conn_type evolution).
|
|||
|
|
Useful for "why did N=3 fail but N=2 pass on the same day?"
|
|||
|
|
|
|||
|
|
The one-pager is the only artifact a human needs to look at in the
|
|||
|
|
common case. Everything else is for deep dives.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Bundle Format
|
|||
|
|
|
|||
|
|
The tarball at `./diagnostics/{run_id}.tar.gz` contains:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
{run_id}/
|
|||
|
|
MANIFEST.json
|
|||
|
|
orchestrator/
|
|||
|
|
boot.json
|
|||
|
|
snapshots/{snapshot_id}.json
|
|||
|
|
events/{batch_seq}.json
|
|||
|
|
finalize.json
|
|||
|
|
stage-0/
|
|||
|
|
boot.json
|
|||
|
|
snapshots/...
|
|||
|
|
events/...
|
|||
|
|
finalize.json
|
|||
|
|
stage-1/
|
|||
|
|
...
|
|||
|
|
collector.log # diagnostic log of the collector itself
|
|||
|
|
summary.md # produced by post-processor on first read
|
|||
|
|
# (optional; the tool can also regenerate)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`MANIFEST.json` lists nodes, run start/end, exit reason, snapshot
|
|||
|
|
and event counts per node, and the orchestrator's identity block.
|
|||
|
|
|
|||
|
|
Files within `snapshots/` and `events/` are JSON; the post-
|
|||
|
|
processing tool is the canonical consumer.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Open Decisions
|
|||
|
|
|
|||
|
|
The forks below are not blocking but should be settled before
|
|||
|
|
implementation rather than during.
|
|||
|
|
|
|||
|
|
1. **Collector hosting and discovery.** *Resolved.* Collector
|
|||
|
|
deploys to the user's VPS, which has stable public ports. The
|
|||
|
|
orchestrator runs from the user's laptop behind NAT and cannot
|
|||
|
|
itself host an ingress reachable from vast.ai workers — that's
|
|||
|
|
why the collector lives elsewhere. Every node (orchestrator and
|
|||
|
|
stages) receives the collector URL via env var at process start.
|
|||
|
|
No port-sharing with anything inference-related; the collector is
|
|||
|
|
its own service. The VPS-hosted collector *is* the canonical
|
|||
|
|
collector — no separate "external fallback" exists.
|
|||
|
|
|
|||
|
|
2. **Snapshot cadence vs. data volume.** Default 5s seems right for
|
|||
|
|
our 3–15 minute runs. Configurable per-tier (tier 1 every 5s,
|
|||
|
|
tier 2 every 10s, tier 3 every 30s) if volume is an issue. Easy
|
|||
|
|
to dial later; the protocol shouldn't bake it in.
|
|||
|
|
|
|||
|
|
3. **Event-emission scope.** *Resolved.* Event emission is a
|
|||
|
|
crate-level feature in `crates/diagnostics`, consumed by
|
|||
|
|
`crates/distribution` (for SWIM and iroh events), by the
|
|||
|
|
pipeline-parallel example (for actor-level events), and by any
|
|||
|
|
future binary on the same stack. Not pipeline-parallel-specific.
|
|||
|
|
Per principle 6, no event-emission code lives inside
|
|||
|
|
`src/` (core swactor) — observing crates import the diagnostics
|
|||
|
|
trait and emit through it.
|
|||
|
|
|
|||
|
|
4. **Clock alignment mechanism.** *Resolved.* Piggybacks on every
|
|||
|
|
HTTP POST to the collector — no separate channel. The collector
|
|||
|
|
is the canonical time source. See T1.5.
|
|||
|
|
|
|||
|
|
5. **iroh API surface stability.** Tier 2 reaches into iroh's
|
|||
|
|
internals (RemoteInfo, metrics, home-relay watcher). The wrapping
|
|||
|
|
layer should be one file so iroh API churn has a local blast
|
|||
|
|
radius. Worth a brief check that the surfaces we want exist in
|
|||
|
|
iroh 0.96 specifically before tier 2 work begins.
|
|||
|
|
|
|||
|
|
6. **Probe targets for T3.3.** "Baseline UDP probe to a known target"
|
|||
|
|
needs a target. Options: a public STUN server (free, occasionally
|
|||
|
|
flaky), our own echo service (more reliable, infrastructure cost).
|
|||
|
|
Since we're already operating a VPS for the collector, running a
|
|||
|
|
tiny UDP echo there is essentially free — recommend co-locating
|
|||
|
|
the echo service with the collector and using it as the baseline
|
|||
|
|
target. STUN remains useful as a NAT-type probe; keep both.
|
|||
|
|
|
|||
|
|
7. **Tier 3.1 conntrack capability.** Capturing conntrack count
|
|||
|
|
needs `CAP_NET_ADMIN` or equivalent, which vast.ai containers may
|
|||
|
|
not have. Capture best-effort and null out when unavailable;
|
|||
|
|
don't gate the rest of T3.1 on it.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Implementation Order
|
|||
|
|
|
|||
|
|
Suggested landing order — each step produces a usable artifact:
|
|||
|
|
|
|||
|
|
1. T1.1 (identity), T1.6 (collector), T1.7 (tarball) — minimum
|
|||
|
|
plumbing.
|
|||
|
|
2. T1.2 (reachability log) + T1.3 (event stream) + T1.4 (snapshot
|
|||
|
|
fan-out) — tier 1 functionally complete.
|
|||
|
|
3. T1.5 (clock alignment) + A.3 (post-processor v1 producing the
|
|||
|
|
one-pager from tier-1 data).
|
|||
|
|
4. **Re-run vast.ai N=3.** Read the bundle. Most of the
|
|||
|
|
`VASTAI_STATUS.md` questions should now be answerable.
|
|||
|
|
5. T2.1 (RemoteInfo) + T2.2 (home-relay watch) + T2.4 (cache
|
|||
|
|
lifecycle) — closes the iroh-side mystery class.
|
|||
|
|
6. T2.3, T2.5, T2.6, T2.7 — fills in the rest of tier 2.
|
|||
|
|
7. T3.3 (probes) — second-most-valuable tier-3 collection.
|
|||
|
|
8. T3.1, T3.2, T3.4, T3.5 — environmental context.
|
|||
|
|
|
|||
|
|
The bet is that re-running vast.ai after step 4 will sharply narrow
|
|||
|
|
the remaining work. Some of tier 2 / 3 may turn out to be unneeded
|
|||
|
|
once we can read the tier-1 bundle from a real failure.
|