2026-05-16 05:49:43 +00:00
|
|
|
//! T-integration: in-process end-to-end pipeline-parallel inference.
|
|
|
|
|
//!
|
2026-05-20 07:41:30 +00:00
|
|
|
//! TEST_SPEC §11. One `DistributedNode` per orchestrator + one per pipeline
|
|
|
|
|
//! stage, all in the same test process, connected over real iroh QUIC in
|
|
|
|
|
//! `RelayMode::Disabled`. Each stage runs the stub-mode `pp_tinygrad_worker.py`
|
|
|
|
|
//! (no GPU, no GGUF). The pipeline runs the autoregressive loop end-to-end:
|
|
|
|
|
//! orchestrator submits an `InferenceRequest`, stage 0 produces a
|
|
|
|
|
//! `StageActivation` for prefill, each middle stage echoes activation control
|
|
|
|
|
//! fields forward, the last stage samples a token and sends `NextToken` back
|
|
|
|
|
//! to stage 0, and so on until EOS or `max_tokens`. The last stage emits the
|
|
|
|
|
//! final `InferenceResponse` back to the orchestrator.
|
2026-05-16 05:49:43 +00:00
|
|
|
//!
|
2026-05-30 07:51:22 +00:00
|
|
|
//! The same shape the binary uses (see `pp_orchestrator.rs`), minus the child
|
2026-05-16 05:49:43 +00:00
|
|
|
//! subprocesses — actors live in this test process and addresses are wired up
|
|
|
|
|
//! directly without SWIM resolution.
|
|
|
|
|
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
use std::io::{BufRead, BufReader, Write};
|
|
|
|
|
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
2026-05-20 07:41:30 +00:00
|
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
2026-05-16 05:49:43 +00:00
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
/// Process-wide lock that serialises pipeline lifetimes inside this
|
|
|
|
|
/// test binary. The cargo default test-thread pool is `num_cpus`, and
|
|
|
|
|
/// with each pipeline owning N+1 iroh drivers probing at
|
|
|
|
|
/// `probe_interval=1` tick, the resulting fan-out (up to ~80
|
|
|
|
|
/// driver-pump loops at the default parallelism) reliably starved
|
|
|
|
|
/// SWIM convergence at N=4/5 well past any reasonable cap. A
|
|
|
|
|
/// concurrency limit of 1 (full serialisation of pipeline lifetimes)
|
|
|
|
|
/// is the only setting that converges deterministically on this
|
|
|
|
|
/// box across back-to-back full-suite runs — earlier values (3, 2)
|
|
|
|
|
/// still flaked on N=4 cases under load. `Pipeline` holds the guard
|
|
|
|
|
/// for its whole lifetime so cluster build *and* post-build
|
|
|
|
|
/// inference work both run with exclusive access.
|
|
|
|
|
static PIPELINE_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
|
|
|
|
|
|
fn pipeline_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
|
|
|
PIPELINE_LOCK
|
|
|
|
|
.get_or_init(|| Mutex::new(()))
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
use distribution::iroh_driver::IrohDriverConfig;
|
2026-05-16 05:49:43 +00:00
|
|
|
use distribution::node::DistributedNodeConfig;
|
|
|
|
|
use distribution::registry::RegistryConfig;
|
|
|
|
|
use distribution::swim::probe::SwimConfig;
|
|
|
|
|
use iroh::{PublicKey, RelayMode};
|
|
|
|
|
|
|
|
|
|
use swactor::actor::ActorAddress;
|
2026-06-09 09:29:07 +00:00
|
|
|
use swactor::runtime::{Inbox, Runtime};
|
|
|
|
|
use swactor_transport::CodecRegistry;
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
use pipeline_parallel_inference::cluster::ClusterNode;
|
2026-05-16 05:49:43 +00:00
|
|
|
use pipeline_parallel_inference::iroh_transport::{
|
|
|
|
|
ActorMessagePump, IrohActorTransport, ACTOR_ALPN,
|
|
|
|
|
};
|
|
|
|
|
use pipeline_parallel_inference::messages::{
|
|
|
|
|
inference_codec_registry, InferenceRequest, InferenceResponse, NextToken,
|
|
|
|
|
};
|
|
|
|
|
use pipeline_parallel_inference::stage_actor::{
|
2026-05-20 07:41:30 +00:00
|
|
|
ActivationBridge, NextTokenBridge, RequestBridge, StageActor, StageActorStatus, StageMsg,
|
|
|
|
|
StageRole,
|
2026-05-16 05:49:43 +00:00
|
|
|
};
|
|
|
|
|
use swactor_process::{ProcessMode, ProcessSpec};
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
// ─── Node / cluster helpers ─────────────────────────────────────────────
|
2026-05-16 05:49:43 +00:00
|
|
|
|
|
|
|
|
fn test_node_config() -> DistributedNodeConfig {
|
|
|
|
|
DistributedNodeConfig {
|
2026-06-09 09:29:07 +00:00
|
|
|
// SWIM timing matches t_cluster's proven values: the tokio actor-bridge
|
|
|
|
|
// engine drives probes via the test's pump loop, so the original 30ms
|
|
|
|
|
// probe-timeout was too tight for N>=4 localhost convergence under load
|
|
|
|
|
// (probes timed out before the ack was pumped). These are test-only knobs
|
|
|
|
|
// — production uses SwimConfig::default().
|
2026-05-16 05:49:43 +00:00
|
|
|
swim: SwimConfig {
|
2026-06-09 09:29:07 +00:00
|
|
|
probe_interval: Duration::from_millis(20),
|
|
|
|
|
probe_timeout: Duration::from_millis(200),
|
2026-05-16 05:49:43 +00:00
|
|
|
indirect_probes: 1,
|
2026-06-09 09:29:07 +00:00
|
|
|
suspicion_timeout: Duration::from_millis(500),
|
|
|
|
|
dead_reprobe_interval: Duration::ZERO,
|
2026-05-16 05:49:43 +00:00
|
|
|
..SwimConfig::default()
|
|
|
|
|
},
|
|
|
|
|
cache_capacity: 100,
|
|
|
|
|
registry: RegistryConfig::default(),
|
|
|
|
|
metadata_lambda: 3,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
fn make_node() -> ClusterNode {
|
|
|
|
|
ClusterNode::new(
|
|
|
|
|
IrohDriverConfig {
|
|
|
|
|
secret_key: None,
|
|
|
|
|
relay_mode: RelayMode::Disabled,
|
|
|
|
|
node: test_node_config(),
|
|
|
|
|
peer_auth: None,
|
|
|
|
|
additional_alpns: vec![ACTOR_ALPN.to_vec()],
|
|
|
|
|
},
|
|
|
|
|
test_node_config(),
|
|
|
|
|
inference_codec_registry(),
|
|
|
|
|
|_| {},
|
|
|
|
|
)
|
|
|
|
|
.expect("failed to create cluster node")
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
fn pubkey_of(node: &ClusterNode) -> PublicKey {
|
|
|
|
|
PublicKey::from_bytes(&node.node_id().0).unwrap()
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
fn sees_alive(node: &ClusterNode, peer_key: &PublicKey) -> bool {
|
|
|
|
|
let peer = distribution::types::NodeId(*peer_key.as_bytes());
|
|
|
|
|
node.sees_alive(&peer)
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
/// Build a `num_stages + 1`-node cluster: node `[0]` is the orchestrator,
|
|
|
|
|
/// nodes `[1..=num_stages]` are pipeline stages `0..num_stages-1`. Every
|
2026-05-20 07:41:30 +00:00
|
|
|
/// non-orchestrator node joins via the orchestrator's seed address. Returns
|
|
|
|
|
/// once every node sees every other node alive, or panics on timeout.
|
|
|
|
|
///
|
|
|
|
|
/// Callers are expected to already hold the `PIPELINE_LOCK` (acquired
|
|
|
|
|
/// by `build_pipeline`/`build_real_pipeline`), so SWIM convergence
|
2026-06-09 09:29:07 +00:00
|
|
|
/// runs with exclusive access to the localhost network stack.
|
|
|
|
|
fn make_cluster(num_stages: u32) -> Vec<ClusterNode> {
|
2026-05-20 07:41:30 +00:00
|
|
|
assert!(num_stages >= 2, "pipeline tests require num_stages >= 2");
|
|
|
|
|
let total = num_stages as usize + 1;
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
let mut nodes: Vec<ClusterNode> = (0..total).map(|_| make_node()).collect();
|
|
|
|
|
let seed = nodes[0].endpoint_addr();
|
|
|
|
|
for d in nodes.iter_mut().skip(1) {
|
2026-05-20 07:41:30 +00:00
|
|
|
d.join(&[seed.clone()]);
|
|
|
|
|
}
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
let keys: Vec<PublicKey> = nodes.iter().map(pubkey_of).collect();
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
let timeout = Duration::from_secs(60);
|
2026-05-16 05:49:43 +00:00
|
|
|
let start = Instant::now();
|
|
|
|
|
let mut converged = false;
|
2026-05-20 07:41:30 +00:00
|
|
|
while start.elapsed() < timeout {
|
2026-06-09 09:29:07 +00:00
|
|
|
for n in nodes.iter_mut() {
|
|
|
|
|
n.pump_once();
|
2026-05-20 07:41:30 +00:00
|
|
|
}
|
2026-06-09 09:29:07 +00:00
|
|
|
let all_see_all = nodes.iter().enumerate().all(|(i, n)| {
|
2026-05-20 07:41:30 +00:00
|
|
|
keys.iter()
|
|
|
|
|
.enumerate()
|
2026-06-09 09:29:07 +00:00
|
|
|
.all(|(j, k)| i == j || sees_alive(n, k))
|
2026-05-20 07:41:30 +00:00
|
|
|
});
|
|
|
|
|
if all_see_all {
|
2026-05-16 05:49:43 +00:00
|
|
|
converged = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
}
|
2026-05-20 07:41:30 +00:00
|
|
|
assert!(
|
|
|
|
|
converged,
|
|
|
|
|
"N={num_stages} cluster did not converge within {}s",
|
|
|
|
|
timeout.as_secs(),
|
|
|
|
|
);
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Worker spec (stub mode) ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
fn worker_spec(stage: u32, num_stages: u32) -> ProcessSpec {
|
|
|
|
|
let mut env = HashMap::new();
|
|
|
|
|
env.insert("STAGE".into(), stage.to_string());
|
|
|
|
|
env.insert("NUM_STAGES".into(), num_stages.to_string());
|
|
|
|
|
env.insert("PP_WORKER_STUB".into(), "1".into());
|
|
|
|
|
ProcessSpec {
|
|
|
|
|
command: "python3".into(),
|
|
|
|
|
args: vec![format!("{}/pp_tinygrad_worker.py", env!("CARGO_MANIFEST_DIR"))],
|
|
|
|
|
env,
|
|
|
|
|
working_dir: None,
|
|
|
|
|
mode: ProcessMode::Automated,
|
|
|
|
|
initial_pty_size: None,
|
|
|
|
|
kill_timeout: Some(Duration::from_secs(2)),
|
|
|
|
|
stdin_buffer_limit: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_process_alive(pid: u32) -> bool {
|
|
|
|
|
std::fs::metadata(format!("/proc/{pid}")).is_ok()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn kill_pid(pid: u32) {
|
|
|
|
|
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
// ─── Pipeline harness (N-stage) ───────────────────────────────────────────
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
/// Fully wired in-process pipeline of `num_stages` stages plus an
|
|
|
|
|
/// orchestrator. Indexing: `drivers[0]` and `runtimes[0]` belong to the
|
|
|
|
|
/// orchestrator; `drivers[s+1]` / `runtimes[s+1]` belong to stage `s`.
|
|
|
|
|
/// `stage_actor_addrs[s]` / `stage_pids[s]` index stages directly.
|
2026-05-16 05:49:43 +00:00
|
|
|
struct Pipeline {
|
2026-05-20 07:41:30 +00:00
|
|
|
num_stages: u32,
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes: Vec<ClusterNode>,
|
2026-05-20 07:41:30 +00:00
|
|
|
pumps: Vec<ActorMessagePump>,
|
2026-05-16 05:49:43 +00:00
|
|
|
|
|
|
|
|
codecs: Arc<CodecRegistry>,
|
|
|
|
|
response_inbox: Inbox<InferenceResponse>,
|
2026-05-20 07:41:30 +00:00
|
|
|
/// Receives a copy of every `NextToken` the Last stage emits, when the
|
|
|
|
|
/// pipeline was built with `observe_tokens=true`. Lives on the Last
|
|
|
|
|
/// stage's runtime so the token-observer route is purely local.
|
2026-05-16 05:49:43 +00:00
|
|
|
token_observer_inbox: Option<Inbox<NextToken>>,
|
|
|
|
|
inbox_addr: ActorAddress,
|
|
|
|
|
request_bridge_addr: ActorAddress,
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
stage_actor_addrs: Vec<ActorAddress>,
|
|
|
|
|
stage_pids: Vec<u32>,
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
/// Pipeline-lock guard held for the pipeline's whole lifetime then
|
|
|
|
|
/// released on drop. See `PIPELINE_LOCK`.
|
|
|
|
|
_pipeline_lock: std::sync::MutexGuard<'static, ()>,
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
/// Build an N-stage stub-worker pipeline. `max_tokens` caps the decode loop;
|
|
|
|
|
/// `eos` optionally pins an EOS token id on the Last stage; `observe_tokens`
|
|
|
|
|
/// installs an extra observer inbox on the Last stage's runtime that receives
|
|
|
|
|
/// a clone of every `NextToken` it emits.
|
|
|
|
|
fn build_pipeline(num_stages: u32, max_tokens: u32, eos: Option<u32>, observe_tokens: bool) -> Pipeline {
|
|
|
|
|
assert!(num_stages >= 2, "pipeline requires num_stages >= 2");
|
|
|
|
|
let last_stage = num_stages - 1;
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
let pipeline_guard = pipeline_lock();
|
2026-06-09 09:29:07 +00:00
|
|
|
let nodes = make_cluster(num_stages);
|
2026-05-16 05:49:43 +00:00
|
|
|
let codecs = Arc::new(inference_codec_registry());
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
let pumps: Vec<ActorMessagePump> = (0..=num_stages).map(|_| ActorMessagePump::new()).collect();
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
// Orchestrator-side inbox for the final response.
|
2026-06-09 09:29:07 +00:00
|
|
|
let response_inbox = nodes[0].rt.new_inbox::<InferenceResponse>().unwrap();
|
2026-05-16 05:49:43 +00:00
|
|
|
let inbox_addr = *response_inbox.addr();
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
// Per-stage status inbox — `drain_until_ready` blocks until each stage
|
|
|
|
|
// reports its worker pid.
|
|
|
|
|
let status_inboxes: Vec<Inbox<StageActorStatus>> = (0..num_stages)
|
2026-06-09 09:29:07 +00:00
|
|
|
.map(|s| nodes[s as usize + 1].rt.new_inbox::<StageActorStatus>().unwrap())
|
2026-05-20 07:41:30 +00:00
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// Optional NextToken observer on the Last stage's runtime.
|
|
|
|
|
let (token_observer_inbox, observer_addr): (Option<Inbox<NextToken>>, Option<ActorAddress>) = if observe_tokens {
|
2026-06-09 09:29:07 +00:00
|
|
|
let inbox = nodes[last_stage as usize + 1].rt.new_inbox::<NextToken>().unwrap();
|
2026-05-20 07:41:30 +00:00
|
|
|
let addr = *inbox.addr();
|
|
|
|
|
(Some(inbox), Some(addr))
|
|
|
|
|
} else {
|
|
|
|
|
(None, None)
|
|
|
|
|
};
|
2026-05-16 05:49:43 +00:00
|
|
|
|
|
|
|
|
let placeholder = ActorAddress([0; 32]);
|
2026-05-20 07:41:30 +00:00
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
// Spawn each stage actor on its node's runtime. The First's `next_stage`
|
2026-05-20 07:41:30 +00:00
|
|
|
// address and the Last's `prev_stage` address need addresses from other
|
|
|
|
|
// runtimes that don't exist yet, so we spawn local placeholders and
|
|
|
|
|
// patch the addresses via `SetNeighbors` after every actor exists.
|
|
|
|
|
let mut stage_actor_addrs: Vec<ActorAddress> = Vec::with_capacity(num_stages as usize);
|
|
|
|
|
for s in 0..num_stages {
|
2026-06-09 09:29:07 +00:00
|
|
|
let rt = &nodes[s as usize + 1].rt;
|
2026-05-20 07:41:30 +00:00
|
|
|
let sender = rt.create_sender();
|
|
|
|
|
let status_addr = *status_inboxes[s as usize].addr();
|
|
|
|
|
let actor_addr = if s == 0 {
|
|
|
|
|
let actor = StageActor::first(worker_spec(s, num_stages), sender, placeholder)
|
|
|
|
|
.with_status_addr(status_addr);
|
|
|
|
|
rt.spawn(actor).unwrap()
|
|
|
|
|
} else if s == last_stage {
|
|
|
|
|
let mut actor = StageActor::last(
|
|
|
|
|
worker_spec(s, num_stages),
|
|
|
|
|
sender,
|
|
|
|
|
placeholder,
|
|
|
|
|
inbox_addr,
|
|
|
|
|
max_tokens,
|
|
|
|
|
)
|
|
|
|
|
.with_status_addr(status_addr);
|
|
|
|
|
if let Some(eos_id) = eos {
|
|
|
|
|
actor = actor.with_eos_token_id(eos_id);
|
|
|
|
|
}
|
|
|
|
|
if let Some(addr) = observer_addr {
|
|
|
|
|
actor = actor.with_token_observer(addr);
|
|
|
|
|
}
|
|
|
|
|
rt.spawn(actor).unwrap()
|
|
|
|
|
} else {
|
|
|
|
|
let actor = StageActor::middle(worker_spec(s, num_stages), sender, placeholder)
|
|
|
|
|
.with_status_addr(status_addr);
|
|
|
|
|
rt.spawn(actor).unwrap()
|
|
|
|
|
};
|
|
|
|
|
stage_actor_addrs.push(actor_addr);
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
// Per-stage bridges. Stage 0 carries Request + NextToken bridges (its
|
|
|
|
|
// inbound network types); stages 1..N-1 (Middle) and stage N-1 (Last)
|
|
|
|
|
// each carry an Activation bridge.
|
2026-06-09 09:29:07 +00:00
|
|
|
let request_bridge_addr = nodes[1]
|
|
|
|
|
.rt
|
2026-05-20 07:41:30 +00:00
|
|
|
.spawn(RequestBridge { target: stage_actor_addrs[0] })
|
|
|
|
|
.unwrap();
|
2026-06-09 09:29:07 +00:00
|
|
|
let nt_bridge_addr = nodes[1]
|
|
|
|
|
.rt
|
2026-05-20 07:41:30 +00:00
|
|
|
.spawn(NextTokenBridge { target: stage_actor_addrs[0] })
|
|
|
|
|
.unwrap();
|
|
|
|
|
let mut activation_bridge_addrs: Vec<Option<ActorAddress>> = vec![None; num_stages as usize];
|
|
|
|
|
for s in 1..num_stages {
|
2026-06-09 09:29:07 +00:00
|
|
|
let rt = &nodes[s as usize + 1].rt;
|
2026-05-20 07:41:30 +00:00
|
|
|
let addr = rt
|
|
|
|
|
.spawn(ActivationBridge { target: stage_actor_addrs[s as usize] })
|
|
|
|
|
.unwrap();
|
|
|
|
|
activation_bridge_addrs[s as usize] = Some(addr);
|
|
|
|
|
}
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
// Wire neighbours on every stage actor.
|
2026-05-20 07:41:30 +00:00
|
|
|
for s in 0..num_stages {
|
2026-06-09 09:29:07 +00:00
|
|
|
let rt = &nodes[s as usize + 1].rt;
|
2026-05-20 07:41:30 +00:00
|
|
|
let (next, prev) = if s == last_stage {
|
|
|
|
|
(None, Some(nt_bridge_addr))
|
|
|
|
|
} else {
|
|
|
|
|
(
|
|
|
|
|
activation_bridge_addrs[(s + 1) as usize],
|
|
|
|
|
None,
|
|
|
|
|
)
|
|
|
|
|
};
|
|
|
|
|
rt.send_to(
|
|
|
|
|
stage_actor_addrs[s as usize],
|
|
|
|
|
StageMsg::SetNeighbors {
|
|
|
|
|
prev_stage: prev,
|
|
|
|
|
next_stage: next,
|
|
|
|
|
reply_to: None,
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-05-16 05:49:43 +00:00
|
|
|
.unwrap();
|
2026-05-20 07:41:30 +00:00
|
|
|
}
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
// Wait for every worker to come up. The ready line for each stage is
|
|
|
|
|
// delivered locally on that stage's runtime, so per-stage drain is
|
|
|
|
|
// independent.
|
|
|
|
|
let mut stage_pids: Vec<u32> = Vec::with_capacity(num_stages as usize);
|
|
|
|
|
for s in 0..num_stages {
|
2026-06-09 09:29:07 +00:00
|
|
|
let rt = &nodes[s as usize + 1].rt;
|
2026-05-20 07:41:30 +00:00
|
|
|
let pid = drain_until_ready(rt, &status_inboxes[s as usize], Duration::from_secs(15));
|
|
|
|
|
stage_pids.push(pid);
|
|
|
|
|
}
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
// Transport routes. The ClusterNode already wired a `TransportRouter`
|
|
|
|
|
// shared with the protocol actors and pre-installed it on `rt`; we
|
|
|
|
|
// only need to add per-stage app routes to it.
|
2026-05-20 07:41:30 +00:00
|
|
|
//
|
|
|
|
|
// orch → stage 0's request bridge:
|
|
|
|
|
let orch_to_first = Arc::new(IrohActorTransport::new(
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[0].driver.endpoint().clone(),
|
|
|
|
|
nodes[1].endpoint_addr(),
|
|
|
|
|
nodes[0].driver.tokio_handle(),
|
2026-05-16 05:49:43 +00:00
|
|
|
));
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[0].transport_router.add_route(request_bridge_addr, orch_to_first);
|
2026-05-20 07:41:30 +00:00
|
|
|
|
|
|
|
|
// Stage s (0..N-1) → stage s+1's activation bridge.
|
|
|
|
|
for s in 0..num_stages - 1 {
|
|
|
|
|
let rt_idx = s as usize + 1;
|
|
|
|
|
let next_idx = (s + 1) as usize + 1;
|
|
|
|
|
let transport = Arc::new(IrohActorTransport::new(
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[rt_idx].driver.endpoint().clone(),
|
|
|
|
|
nodes[next_idx].endpoint_addr(),
|
|
|
|
|
nodes[rt_idx].driver.tokio_handle(),
|
2026-05-20 07:41:30 +00:00
|
|
|
));
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[rt_idx].transport_router.add_route(
|
2026-05-20 07:41:30 +00:00
|
|
|
activation_bridge_addrs[(s + 1) as usize].expect("next stage has activation bridge"),
|
|
|
|
|
transport,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Last stage → stage 0's NextToken bridge AND → orchestrator's response
|
|
|
|
|
// inbox.
|
|
|
|
|
let last_idx = last_stage as usize + 1;
|
|
|
|
|
let last_to_first = Arc::new(IrohActorTransport::new(
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[last_idx].driver.endpoint().clone(),
|
|
|
|
|
nodes[1].endpoint_addr(),
|
|
|
|
|
nodes[last_idx].driver.tokio_handle(),
|
2026-05-16 05:49:43 +00:00
|
|
|
));
|
2026-05-20 07:41:30 +00:00
|
|
|
let last_to_orch = Arc::new(IrohActorTransport::new(
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[last_idx].driver.endpoint().clone(),
|
|
|
|
|
nodes[0].endpoint_addr(),
|
|
|
|
|
nodes[last_idx].driver.tokio_handle(),
|
2026-05-16 05:49:43 +00:00
|
|
|
));
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[last_idx].transport_router.add_route(nt_bridge_addr, last_to_first);
|
|
|
|
|
nodes[last_idx].transport_router.add_route(inbox_addr, last_to_orch);
|
2026-05-16 05:49:43 +00:00
|
|
|
|
|
|
|
|
Pipeline {
|
2026-05-20 07:41:30 +00:00
|
|
|
num_stages,
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes,
|
2026-05-20 07:41:30 +00:00
|
|
|
pumps,
|
2026-05-16 05:49:43 +00:00
|
|
|
codecs,
|
|
|
|
|
response_inbox,
|
2026-05-20 07:41:30 +00:00
|
|
|
token_observer_inbox,
|
2026-05-16 05:49:43 +00:00
|
|
|
inbox_addr,
|
|
|
|
|
request_bridge_addr,
|
2026-05-20 07:41:30 +00:00
|
|
|
stage_actor_addrs,
|
|
|
|
|
stage_pids,
|
|
|
|
|
_pipeline_lock: pipeline_guard,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn drain_until_ready(
|
|
|
|
|
rt: &Runtime,
|
|
|
|
|
status_inbox: &Inbox<StageActorStatus>,
|
|
|
|
|
timeout: Duration,
|
|
|
|
|
) -> u32 {
|
|
|
|
|
let start = Instant::now();
|
|
|
|
|
while start.elapsed() < timeout {
|
|
|
|
|
rt.tick();
|
|
|
|
|
if let Some(status) = status_inbox.try_recv() {
|
|
|
|
|
if let StageActorStatus::WorkerReady { pid } = status {
|
|
|
|
|
return pid.expect("worker reports a pid");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(10));
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
2026-05-20 07:41:30 +00:00
|
|
|
panic!("worker did not become ready within {:?}", timeout);
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Pipeline {
|
|
|
|
|
/// One pass through every node: drain QUIC, pump actor messages into each
|
|
|
|
|
/// runtime, tick every runtime.
|
|
|
|
|
fn pump(&mut self) {
|
2026-06-09 09:29:07 +00:00
|
|
|
for n in &mut self.nodes {
|
|
|
|
|
n.pump_once();
|
2026-05-20 07:41:30 +00:00
|
|
|
}
|
|
|
|
|
for (i, pump) in self.pumps.iter().enumerate() {
|
2026-06-09 09:29:07 +00:00
|
|
|
pump.pump(&self.nodes[i].driver, &self.codecs, &self.nodes[i].rt);
|
2026-05-20 07:41:30 +00:00
|
|
|
}
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Pump only the orchestrator side. Used after one of the stages has been
|
2026-05-20 07:41:30 +00:00
|
|
|
/// shut down; pumping a dead node's runtime contributes nothing and just
|
|
|
|
|
/// clutters the loop.
|
2026-05-16 05:49:43 +00:00
|
|
|
fn pump_orch_only(&mut self) {
|
2026-06-09 09:29:07 +00:00
|
|
|
self.nodes[0].pump_once();
|
|
|
|
|
self.pumps[0].pump(&self.nodes[0].driver, &self.codecs, &self.nodes[0].rt);
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn submit(&self, prompt: &str, max_tokens: u32) {
|
|
|
|
|
let req = InferenceRequest {
|
|
|
|
|
reply_to: self.inbox_addr,
|
|
|
|
|
prompt: prompt.into(),
|
|
|
|
|
max_tokens,
|
|
|
|
|
};
|
2026-06-09 09:29:07 +00:00
|
|
|
self.nodes[0]
|
|
|
|
|
.rt
|
2026-05-20 07:41:30 +00:00
|
|
|
.send_to(self.request_bridge_addr, req)
|
|
|
|
|
.unwrap();
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn await_response(&mut self, timeout: Duration) -> Result<InferenceResponse, String> {
|
|
|
|
|
let start = Instant::now();
|
|
|
|
|
while start.elapsed() < timeout {
|
|
|
|
|
self.pump();
|
|
|
|
|
if let Some(r) = self.response_inbox.try_recv() {
|
|
|
|
|
return Ok(r);
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
}
|
|
|
|
|
Err(format!(
|
|
|
|
|
"no InferenceResponse within {:.0}s",
|
|
|
|
|
timeout.as_secs_f32()
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Await a response OR detect that a stage's node has gone non-alive in the
|
2026-05-20 07:41:30 +00:00
|
|
|
/// orchestrator's SWIM view. With N stages plus self, the orchestrator's
|
|
|
|
|
/// snapshot (which omits self) lists `num_stages` peers when healthy;
|
|
|
|
|
/// `alive_count < num_stages` means at least one stage is no longer
|
|
|
|
|
/// reachable.
|
2026-05-16 05:49:43 +00:00
|
|
|
fn await_response_or_stage_failure(
|
|
|
|
|
&mut self,
|
|
|
|
|
timeout: Duration,
|
|
|
|
|
) -> Result<InferenceResponse, String> {
|
|
|
|
|
let start = Instant::now();
|
2026-05-20 07:41:30 +00:00
|
|
|
let expected = self.num_stages;
|
2026-05-16 05:49:43 +00:00
|
|
|
while start.elapsed() < timeout {
|
|
|
|
|
self.pump_orch_only();
|
|
|
|
|
if let Some(r) = self.response_inbox.try_recv() {
|
|
|
|
|
return Ok(r);
|
|
|
|
|
}
|
2026-06-09 09:29:07 +00:00
|
|
|
let snap = self.nodes[0].snapshot();
|
2026-05-20 07:41:30 +00:00
|
|
|
if (snap.alive_count as u32) < expected {
|
2026-05-16 05:49:43 +00:00
|
|
|
let dead: Vec<_> = snap
|
|
|
|
|
.members
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|m| m.state != "alive")
|
|
|
|
|
.map(|m| format!("{}={}", &m.node_id[..8.min(m.node_id.len())], m.state))
|
|
|
|
|
.collect();
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"stage failure detected via SWIM after {:.1}s ({:?})",
|
|
|
|
|
start.elapsed().as_secs_f32(),
|
|
|
|
|
dead
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
|
|
|
}
|
|
|
|
|
Err(format!(
|
|
|
|
|
"no response and no SWIM failure within {:.0}s",
|
|
|
|
|
timeout.as_secs_f32()
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn shutdown(mut self) {
|
2026-05-20 07:41:30 +00:00
|
|
|
for (s, addr) in self.stage_actor_addrs.iter().enumerate() {
|
2026-06-09 09:29:07 +00:00
|
|
|
let _ = self.nodes[s + 1].rt.stop_actor(*addr);
|
2026-05-20 07:41:30 +00:00
|
|
|
}
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(3);
|
|
|
|
|
while Instant::now() < deadline {
|
|
|
|
|
for s in 0..self.num_stages as usize {
|
2026-06-09 09:29:07 +00:00
|
|
|
self.nodes[s + 1].rt.tick();
|
2026-05-20 07:41:30 +00:00
|
|
|
}
|
|
|
|
|
if self.stage_pids.iter().all(|p| !is_process_alive(*p)) {
|
2026-05-16 05:49:43 +00:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
}
|
2026-06-09 09:29:07 +00:00
|
|
|
for n in self.nodes.iter_mut() {
|
|
|
|
|
n.driver.shutdown();
|
2026-05-20 07:41:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn stage_pid(&self, stage: u32) -> u32 {
|
|
|
|
|
self.stage_pids[stage as usize]
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse the stub `detokenize_stub` output `"tokens: [a b c]"` into a vector
|
2026-05-20 07:41:30 +00:00
|
|
|
/// of token ids. The format is owned by `StageActor::detokenize_stub`.
|
2026-05-16 05:49:43 +00:00
|
|
|
fn parse_stub_tokens(text: &str) -> Vec<u32> {
|
|
|
|
|
let inside = text
|
|
|
|
|
.trim_start_matches("tokens: [")
|
|
|
|
|
.trim_end_matches(']')
|
|
|
|
|
.trim();
|
|
|
|
|
if inside.is_empty() {
|
|
|
|
|
return vec![];
|
|
|
|
|
}
|
|
|
|
|
inside
|
|
|
|
|
.split_whitespace()
|
|
|
|
|
.map(|s| s.parse::<u32>().expect("stub token id parses as u32"))
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
2026-05-20 07:41:30 +00:00
|
|
|
// §11 — In-process N-stage integration tests
|
2026-05-16 05:49:43 +00:00
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
/// Full pipeline at N stages: orchestrator submits one `InferenceRequest`,
|
|
|
|
|
/// the chain drives the autoregressive loop with stub workers, the Last
|
|
|
|
|
/// stage emits `InferenceResponse` back to the orchestrator within the
|
|
|
|
|
/// test timeout, and the response text is non-empty.
|
|
|
|
|
fn n_stage_stub_pipeline_returns_response_case(num_stages: u32) {
|
|
|
|
|
let mut pipeline = build_pipeline(num_stages, 4, None, false);
|
2026-05-16 05:49:43 +00:00
|
|
|
pipeline.submit("Say hello", 4);
|
|
|
|
|
|
|
|
|
|
let response = pipeline
|
2026-05-20 07:41:30 +00:00
|
|
|
.await_response(Duration::from_secs(60))
|
2026-05-16 05:49:43 +00:00
|
|
|
.expect("pipeline must produce an InferenceResponse");
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
!response.text.is_empty(),
|
2026-05-20 07:41:30 +00:00
|
|
|
"N={num_stages}: response text must be non-empty, got: {:?}",
|
2026-05-16 05:49:43 +00:00
|
|
|
response.text
|
|
|
|
|
);
|
|
|
|
|
let tokens = parse_stub_tokens(&response.text);
|
2026-05-20 07:41:30 +00:00
|
|
|
assert_eq!(
|
|
|
|
|
tokens.len(),
|
|
|
|
|
4,
|
|
|
|
|
"N={num_stages}: stub response must contain max_tokens=4 token ids: {:?}",
|
2026-05-16 05:49:43 +00:00
|
|
|
response.text
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
pipeline.shutdown();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-20 07:41:30 +00:00
|
|
|
fn n_stage_stub_pipeline_returns_response_n_2() {
|
|
|
|
|
n_stage_stub_pipeline_returns_response_case(2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn n_stage_stub_pipeline_returns_response_n_3() {
|
|
|
|
|
n_stage_stub_pipeline_returns_response_case(3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn n_stage_stub_pipeline_returns_response_n_4() {
|
|
|
|
|
n_stage_stub_pipeline_returns_response_case(4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn n_stage_stub_pipeline_returns_response_n_5() {
|
|
|
|
|
n_stage_stub_pipeline_returns_response_case(5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Configure the Last stage with an EOS token id and verify the decode loop
|
|
|
|
|
/// terminates with exactly that many accumulated tokens. Done by running the
|
|
|
|
|
/// pipeline twice with the same prompt: the first run discovers the
|
|
|
|
|
/// deterministic stub token sequence at this N, the second pins EOS to one
|
|
|
|
|
/// of those tokens and asserts the response truncates at the matching index.
|
|
|
|
|
fn decode_loop_terminates_on_stub_eos_case(num_stages: u32) {
|
2026-05-16 05:49:43 +00:00
|
|
|
const PROMPT: &str = "Say hello once";
|
|
|
|
|
const PROBE_TOKENS: u32 = 5;
|
|
|
|
|
const TARGET_N: usize = 3;
|
|
|
|
|
|
|
|
|
|
let probe_tokens = {
|
2026-05-20 07:41:30 +00:00
|
|
|
let mut probe = build_pipeline(num_stages, PROBE_TOKENS, None, false);
|
2026-05-16 05:49:43 +00:00
|
|
|
probe.submit(PROMPT, PROBE_TOKENS);
|
|
|
|
|
let resp = probe
|
2026-05-20 07:41:30 +00:00
|
|
|
.await_response(Duration::from_secs(60))
|
2026-05-16 05:49:43 +00:00
|
|
|
.expect("probe pipeline must produce a response");
|
|
|
|
|
let tokens = parse_stub_tokens(&resp.text);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
tokens.len(),
|
|
|
|
|
PROBE_TOKENS as usize,
|
2026-05-20 07:41:30 +00:00
|
|
|
"N={num_stages}: probe (no EOS, max_tokens={PROBE_TOKENS}) should yield {PROBE_TOKENS} tokens"
|
2026-05-16 05:49:43 +00:00
|
|
|
);
|
|
|
|
|
probe.shutdown();
|
|
|
|
|
tokens
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let eos = probe_tokens[TARGET_N - 1];
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
let mut real = build_pipeline(num_stages, PROBE_TOKENS, Some(eos), false);
|
2026-05-16 05:49:43 +00:00
|
|
|
real.submit(PROMPT, PROBE_TOKENS);
|
|
|
|
|
let resp = real
|
2026-05-20 07:41:30 +00:00
|
|
|
.await_response(Duration::from_secs(60))
|
2026-05-16 05:49:43 +00:00
|
|
|
.expect("EOS pipeline must produce a response");
|
|
|
|
|
let tokens = parse_stub_tokens(&resp.text);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
tokens.len(),
|
|
|
|
|
TARGET_N,
|
2026-05-20 07:41:30 +00:00
|
|
|
"N={num_stages}: EOS at index {} should truncate response to {TARGET_N} tokens, got {tokens:?}",
|
2026-05-16 05:49:43 +00:00
|
|
|
TARGET_N - 1
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
*tokens.last().unwrap(),
|
|
|
|
|
eos,
|
2026-05-20 07:41:30 +00:00
|
|
|
"N={num_stages}: last accumulated token must be the EOS id"
|
2026-05-16 05:49:43 +00:00
|
|
|
);
|
|
|
|
|
real.shutdown();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-20 07:41:30 +00:00
|
|
|
fn decode_loop_terminates_on_stub_eos_n_2() {
|
|
|
|
|
decode_loop_terminates_on_stub_eos_case(2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn decode_loop_terminates_on_stub_eos_n_3() {
|
|
|
|
|
decode_loop_terminates_on_stub_eos_case(3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn decode_loop_terminates_on_stub_eos_n_4() {
|
|
|
|
|
decode_loop_terminates_on_stub_eos_case(4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// With no EOS configured, the decode loop runs until `max_tokens` and the
|
|
|
|
|
/// Last stage emits `InferenceResponse` containing exactly `max_tokens`
|
|
|
|
|
/// tokens.
|
|
|
|
|
fn decode_loop_terminates_on_max_tokens_case(num_stages: u32) {
|
2026-05-16 05:49:43 +00:00
|
|
|
const MAX: u32 = 4;
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
let mut pipeline = build_pipeline(num_stages, MAX, None, false);
|
2026-05-16 05:49:43 +00:00
|
|
|
pipeline.submit("max tokens stop", MAX);
|
|
|
|
|
|
|
|
|
|
let response = pipeline
|
2026-05-20 07:41:30 +00:00
|
|
|
.await_response(Duration::from_secs(60))
|
2026-05-16 05:49:43 +00:00
|
|
|
.expect("max_tokens pipeline must produce a response");
|
|
|
|
|
let tokens = parse_stub_tokens(&response.text);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
tokens.len() as u32,
|
|
|
|
|
MAX,
|
2026-05-20 07:41:30 +00:00
|
|
|
"N={num_stages}: with no EOS, response must contain exactly max_tokens={MAX} tokens, got {tokens:?}"
|
2026-05-16 05:49:43 +00:00
|
|
|
);
|
|
|
|
|
pipeline.shutdown();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-05-20 07:41:30 +00:00
|
|
|
fn decode_loop_terminates_on_max_tokens_n_2() {
|
|
|
|
|
decode_loop_terminates_on_max_tokens_case(2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn decode_loop_terminates_on_max_tokens_n_3() {
|
|
|
|
|
decode_loop_terminates_on_max_tokens_case(3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn decode_loop_terminates_on_max_tokens_n_4() {
|
|
|
|
|
decode_loop_terminates_on_max_tokens_case(4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Mid-decode failure for the stage at index `victim_stage` in an N=4
|
|
|
|
|
/// pipeline. Kills the worker process AND shuts down the stage's iroh
|
|
|
|
|
/// driver. The orchestrator's await loop must surface a stage failure
|
|
|
|
|
/// (via SWIM) within the detection window — not hang.
|
|
|
|
|
fn stage_failure_mid_decode_case(victim_stage: u32) {
|
|
|
|
|
let num_stages: u32 = 4;
|
|
|
|
|
let mut pipeline = build_pipeline(num_stages, 64, None, false);
|
2026-05-16 05:49:43 +00:00
|
|
|
pipeline.submit("never finishes naturally", 64);
|
|
|
|
|
|
|
|
|
|
// Let a few rounds happen so we are genuinely mid-decode.
|
|
|
|
|
let pump_start = Instant::now();
|
|
|
|
|
while pump_start.elapsed() < Duration::from_millis(400) {
|
|
|
|
|
pipeline.pump();
|
|
|
|
|
if pipeline.response_inbox.try_recv().is_some() {
|
2026-05-20 07:41:30 +00:00
|
|
|
panic!(
|
|
|
|
|
"N={num_stages}: pipeline returned a response too quickly to test mid-decode failure"
|
|
|
|
|
);
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
// Kill the victim stage's worker + driver. Worker death alone leaves
|
|
|
|
|
// the SWIM node alive (it just stops doing pipeline work); killing the
|
|
|
|
|
// driver too is what flips it to dead in the orchestrator's view.
|
|
|
|
|
let victim_pid = pipeline.stage_pid(victim_stage);
|
|
|
|
|
kill_pid(victim_pid);
|
2026-06-09 09:29:07 +00:00
|
|
|
pipeline.nodes[victim_stage as usize + 1].driver.shutdown();
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
// SwimConfig probe/suspicion windows are tight in tests → dead within
|
2026-05-20 07:41:30 +00:00
|
|
|
// ~8-10s. Give 20s for indirect probes + gossip propagation.
|
2026-05-16 05:49:43 +00:00
|
|
|
let result = pipeline.await_response_or_stage_failure(Duration::from_secs(20));
|
|
|
|
|
assert!(
|
|
|
|
|
result.is_err(),
|
2026-05-20 07:41:30 +00:00
|
|
|
"orchestrator should NOT receive an InferenceResponse after stage {victim_stage} dies; got {result:?}",
|
2026-05-16 05:49:43 +00:00
|
|
|
);
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
// Tear down survivors.
|
|
|
|
|
for s in 0..num_stages {
|
|
|
|
|
if s != victim_stage {
|
2026-06-09 09:29:07 +00:00
|
|
|
let _ = pipeline.nodes[s as usize + 1].rt.stop_actor(pipeline.stage_actor_addrs[s as usize]);
|
2026-05-20 07:41:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(2);
|
|
|
|
|
while Instant::now() < deadline {
|
|
|
|
|
for s in 0..num_stages {
|
|
|
|
|
if s != victim_stage {
|
2026-06-09 09:29:07 +00:00
|
|
|
pipeline.nodes[s as usize + 1].rt.tick();
|
2026-05-20 07:41:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let all_dead = (0..num_stages)
|
|
|
|
|
.filter(|s| *s != victim_stage)
|
|
|
|
|
.all(|s| !is_process_alive(pipeline.stage_pid(s)));
|
|
|
|
|
if all_dead {
|
2026-05-16 05:49:43 +00:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
}
|
2026-06-09 09:29:07 +00:00
|
|
|
for (i, n) in pipeline.nodes.iter_mut().enumerate() {
|
|
|
|
|
// Node at `victim_stage + 1` is already shut down.
|
2026-05-20 07:41:30 +00:00
|
|
|
if i != victim_stage as usize + 1 {
|
2026-06-09 09:29:07 +00:00
|
|
|
n.driver.shutdown();
|
2026-05-20 07:41:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn first_stage_failure_mid_decode_surfaces_as_error_to_orchestrator() {
|
|
|
|
|
stage_failure_mid_decode_case(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn middle_stage_failure_mid_decode_surfaces_as_error_to_orchestrator() {
|
|
|
|
|
// N=4 has middle stages 1 and 2; pick 1 (closest to First).
|
|
|
|
|
stage_failure_mid_decode_case(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn last_stage_failure_mid_decode_surfaces_as_error_to_orchestrator() {
|
|
|
|
|
stage_failure_mid_decode_case(3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Every `NextToken` returning to stage 0 carries the same `request_id` as
|
|
|
|
|
/// the `StageActivation` that traversed first → middle → middle → last.
|
|
|
|
|
/// First's `next_request_id` starts at 1 and increments per outbound
|
|
|
|
|
/// activation, so for a single `InferenceRequest` the round-trip rid is
|
|
|
|
|
/// stable across every decode step. This test asserts that every observed
|
|
|
|
|
/// `NextToken` carries the same rid and that the same rid produces a
|
|
|
|
|
/// non-empty response.
|
|
|
|
|
#[test]
|
|
|
|
|
fn activation_request_id_round_trip_through_chain() {
|
|
|
|
|
const MAX: u32 = 4;
|
|
|
|
|
const NUM_STAGES: u32 = 4;
|
|
|
|
|
|
|
|
|
|
let mut pipeline = build_pipeline(NUM_STAGES, MAX, None, true);
|
|
|
|
|
pipeline.submit("rid round trip", MAX);
|
|
|
|
|
|
|
|
|
|
// Drive the pipeline until the final response arrives, draining the
|
|
|
|
|
// observer inbox on the way. Two observed tokens are enough to prove
|
|
|
|
|
// "stable across hops"; collect all of them up to max_tokens for a
|
|
|
|
|
// sharper assertion.
|
|
|
|
|
let mut observed: Vec<NextToken> = Vec::with_capacity(MAX as usize);
|
|
|
|
|
let start = Instant::now();
|
|
|
|
|
let mut response: Option<InferenceResponse> = None;
|
|
|
|
|
while response.is_none() && start.elapsed() < Duration::from_secs(60) {
|
|
|
|
|
pipeline.pump();
|
|
|
|
|
if let Some(observer) = pipeline.token_observer_inbox.as_ref() {
|
|
|
|
|
while let Some(nt) = observer.try_recv() {
|
|
|
|
|
observed.push(nt);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(r) = pipeline.response_inbox.try_recv() {
|
|
|
|
|
response = Some(r);
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let response = response.expect("N=4: pipeline must produce a response");
|
|
|
|
|
assert!(!response.text.is_empty(), "N=4: response text must be non-empty");
|
|
|
|
|
|
|
|
|
|
// Drain anything that arrived after the response.
|
|
|
|
|
if let Some(observer) = pipeline.token_observer_inbox.as_ref() {
|
|
|
|
|
while let Some(nt) = observer.try_recv() {
|
|
|
|
|
observed.push(nt);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
!observed.is_empty(),
|
|
|
|
|
"N=4: must have observed at least one NextToken from Last"
|
|
|
|
|
);
|
|
|
|
|
let first_rid = observed[0].request_id;
|
|
|
|
|
assert_ne!(first_rid, 0, "N=4: round-trip request_id must be non-zero");
|
|
|
|
|
for (i, nt) in observed.iter().enumerate() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
nt.request_id, first_rid,
|
|
|
|
|
"N=4: NextToken #{i} has request_id {} (expected {first_rid}); rid must be stable across the full first->middle->middle->last->first round-trip",
|
|
|
|
|
nt.request_id,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pipeline.shutdown();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Each `StageActivation` after prefill has `position` exactly one greater
|
|
|
|
|
/// than the previous, end-to-end through the chain. The Last stage's
|
|
|
|
|
/// `NextToken.position` equals the inbound `activation.position +
|
|
|
|
|
/// activation.seq_len`; for decode steps `seq_len == 1`, so successive
|
|
|
|
|
/// `NextToken.position`s differ by 1 iff the activation positions did.
|
|
|
|
|
/// Observing `NextToken`s is therefore a faithful proxy for the activation
|
|
|
|
|
/// position stream.
|
|
|
|
|
#[test]
|
|
|
|
|
fn activation_position_advances_one_per_decode_step() {
|
|
|
|
|
const MAX: u32 = 4;
|
|
|
|
|
const NUM_STAGES: u32 = 3;
|
|
|
|
|
const PROMPT: &str = "position advances";
|
|
|
|
|
|
|
|
|
|
let mut pipeline = build_pipeline(NUM_STAGES, MAX, None, true);
|
|
|
|
|
pipeline.submit(PROMPT, MAX);
|
|
|
|
|
|
|
|
|
|
let mut observed: Vec<NextToken> = Vec::with_capacity(MAX as usize);
|
|
|
|
|
let start = Instant::now();
|
|
|
|
|
let mut response: Option<InferenceResponse> = None;
|
|
|
|
|
while response.is_none() && start.elapsed() < Duration::from_secs(60) {
|
|
|
|
|
pipeline.pump();
|
|
|
|
|
if let Some(observer) = pipeline.token_observer_inbox.as_ref() {
|
|
|
|
|
while let Some(nt) = observer.try_recv() {
|
|
|
|
|
observed.push(nt);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(r) = pipeline.response_inbox.try_recv() {
|
|
|
|
|
response = Some(r);
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
response.expect("N=3: pipeline must produce a response");
|
|
|
|
|
if let Some(observer) = pipeline.token_observer_inbox.as_ref() {
|
|
|
|
|
while let Some(nt) = observer.try_recv() {
|
|
|
|
|
observed.push(nt);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
observed.len(),
|
|
|
|
|
MAX as usize,
|
|
|
|
|
"N=3: expected {MAX} NextTokens for max_tokens={MAX} run, got {}: {observed:?}",
|
|
|
|
|
observed.len(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// First NextToken's position == prompt_len (set by Last from the
|
|
|
|
|
// prefill activation). Subsequent positions increment by 1 per decode
|
|
|
|
|
// step.
|
|
|
|
|
let prompt_len = PROMPT.split_whitespace().count() as u32;
|
|
|
|
|
assert_eq!(
|
|
|
|
|
observed[0].position, prompt_len,
|
|
|
|
|
"N=3: first NextToken position must equal prompt_len ({prompt_len}); got {}",
|
|
|
|
|
observed[0].position,
|
|
|
|
|
);
|
|
|
|
|
for (i, win) in observed.windows(2).enumerate() {
|
|
|
|
|
let prev = &win[0];
|
|
|
|
|
let next = &win[1];
|
|
|
|
|
assert_eq!(
|
|
|
|
|
next.position,
|
|
|
|
|
prev.position + 1,
|
|
|
|
|
"N=3: position must advance by 1 between decode steps #{i} -> #{}; prev={prev:?}, next={next:?}",
|
|
|
|
|
i + 1,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pipeline.shutdown();
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
2026-05-20 07:41:30 +00:00
|
|
|
// §12 — Sliced-vs-full equivalence (gated)
|
2026-05-16 05:49:43 +00:00
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
|
//
|
|
|
|
|
// These tests prove that the 2-stage sliced pipeline produces the same
|
|
|
|
|
// token sequence as a single-process run over the full model on the same
|
|
|
|
|
// prompt, with argmax sampling. They:
|
|
|
|
|
//
|
|
|
|
|
// 1. Spawn a long-lived reference worker (single process, `NUM_STAGES=1`,
|
|
|
|
|
// real GGUF). It serves a new `generate_full` op that runs the same
|
|
|
|
|
// unjitted block iteration as the pipeline but over `model.blk[0..N]`
|
|
|
|
|
// in one shot.
|
|
|
|
|
// 2. Build a real-mode 3-node pipeline (workers without `--stub`, stage 0
|
|
|
|
|
// tokenises via the worker's `tokenize` op, stage 1 mirrors each
|
|
|
|
|
// sampled `NextToken` to a test-owned observer inbox).
|
|
|
|
|
// 3. For each prompt, submit through the pipeline, collect `max_tokens`
|
|
|
|
|
// sampled token ids via the observer, ask the reference worker for
|
|
|
|
|
// the same prompt, and assert equality.
|
|
|
|
|
//
|
|
|
|
|
// Both are `#[ignore]`: the GGUF fetch + per-token CPU forward dominate
|
|
|
|
|
// wall-clock. Run with `cargo test -- --ignored`.
|
|
|
|
|
|
|
|
|
|
const REAL_READY_TIMEOUT: Duration = Duration::from_secs(180);
|
|
|
|
|
const REAL_PROMPT_TIMEOUT: Duration = Duration::from_secs(300);
|
|
|
|
|
const EQUIVALENCE_MAX_TOKENS: u32 = 8;
|
|
|
|
|
|
|
|
|
|
fn real_worker_spec(stage: u32, num_stages: u32) -> ProcessSpec {
|
|
|
|
|
let mut env = HashMap::new();
|
|
|
|
|
env.insert("STAGE".into(), stage.to_string());
|
|
|
|
|
env.insert("NUM_STAGES".into(), num_stages.to_string());
|
|
|
|
|
env.insert("MODEL".into(), "llama3.2:1b".into());
|
|
|
|
|
env.insert("PP_WORKER_STUB".into(), "".into());
|
|
|
|
|
ProcessSpec {
|
|
|
|
|
command: "python3".into(),
|
|
|
|
|
args: vec![format!("{}/pp_tinygrad_worker.py", env!("CARGO_MANIFEST_DIR"))],
|
|
|
|
|
env,
|
|
|
|
|
working_dir: None,
|
|
|
|
|
mode: ProcessMode::Automated,
|
|
|
|
|
initial_pty_size: None,
|
|
|
|
|
kill_timeout: Some(Duration::from_secs(5)),
|
|
|
|
|
stdin_buffer_limit: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
/// Build a real-mode N-stage pipeline with a token observer on the Last
|
|
|
|
|
/// stage. Same actor wiring as `build_pipeline` but with the
|
|
|
|
|
/// real-tinygrad worker, `with_real_tokenization` on First (synthetic
|
|
|
|
|
/// stub ids cannot be embedded against a real GGUF vocab), and a
|
|
|
|
|
/// minute-scale ready timeout so the GGUF can load on a cold tinygrad
|
|
|
|
|
/// cache.
|
|
|
|
|
fn build_real_pipeline(num_stages: u32, max_tokens: u32) -> Pipeline {
|
|
|
|
|
assert!(num_stages >= 2, "real pipeline requires num_stages >= 2");
|
|
|
|
|
let last_stage = num_stages - 1;
|
|
|
|
|
|
|
|
|
|
let pipeline_guard = pipeline_lock();
|
2026-06-09 09:29:07 +00:00
|
|
|
let nodes = make_cluster(num_stages);
|
2026-05-16 05:49:43 +00:00
|
|
|
let codecs = Arc::new(inference_codec_registry());
|
|
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
let pumps: Vec<ActorMessagePump> = (0..=num_stages).map(|_| ActorMessagePump::new()).collect();
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
let response_inbox = nodes[0].rt.new_inbox::<InferenceResponse>().unwrap();
|
2026-05-16 05:49:43 +00:00
|
|
|
let inbox_addr = *response_inbox.addr();
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
let status_inboxes: Vec<Inbox<StageActorStatus>> = (0..num_stages)
|
2026-06-09 09:29:07 +00:00
|
|
|
.map(|s| nodes[s as usize + 1].rt.new_inbox::<StageActorStatus>().unwrap())
|
2026-05-20 07:41:30 +00:00
|
|
|
.collect();
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-06-09 09:29:07 +00:00
|
|
|
let token_observer_inbox = nodes[last_stage as usize + 1]
|
|
|
|
|
.rt
|
2026-05-20 07:41:30 +00:00
|
|
|
.new_inbox::<NextToken>()
|
|
|
|
|
.unwrap();
|
2026-05-16 05:49:43 +00:00
|
|
|
let observer_addr = *token_observer_inbox.addr();
|
|
|
|
|
|
|
|
|
|
let placeholder = ActorAddress([0; 32]);
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
let mut stage_actor_addrs: Vec<ActorAddress> = Vec::with_capacity(num_stages as usize);
|
|
|
|
|
for s in 0..num_stages {
|
2026-06-09 09:29:07 +00:00
|
|
|
let rt = &nodes[s as usize + 1].rt;
|
2026-05-20 07:41:30 +00:00
|
|
|
let sender = rt.create_sender();
|
|
|
|
|
let status_addr = *status_inboxes[s as usize].addr();
|
|
|
|
|
let actor_addr = if s == 0 {
|
|
|
|
|
let actor = StageActor::first(real_worker_spec(s, num_stages), sender, placeholder)
|
|
|
|
|
.with_status_addr(status_addr)
|
|
|
|
|
.with_real_tokenization();
|
|
|
|
|
rt.spawn(actor).unwrap()
|
|
|
|
|
} else if s == last_stage {
|
|
|
|
|
let actor = StageActor::last(
|
|
|
|
|
real_worker_spec(s, num_stages),
|
|
|
|
|
sender,
|
|
|
|
|
placeholder,
|
|
|
|
|
inbox_addr,
|
|
|
|
|
max_tokens,
|
|
|
|
|
)
|
|
|
|
|
.with_status_addr(status_addr)
|
|
|
|
|
.with_token_observer(observer_addr);
|
|
|
|
|
rt.spawn(actor).unwrap()
|
|
|
|
|
} else {
|
|
|
|
|
let actor = StageActor::middle(real_worker_spec(s, num_stages), sender, placeholder)
|
|
|
|
|
.with_status_addr(status_addr);
|
|
|
|
|
rt.spawn(actor).unwrap()
|
|
|
|
|
};
|
|
|
|
|
stage_actor_addrs.push(actor_addr);
|
|
|
|
|
}
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
// Bridges: Request + NextToken on stage 0; Activation on every stage
|
|
|
|
|
// s >= 1.
|
2026-06-09 09:29:07 +00:00
|
|
|
let request_bridge_addr = nodes[1]
|
|
|
|
|
.rt
|
2026-05-20 07:41:30 +00:00
|
|
|
.spawn(RequestBridge { target: stage_actor_addrs[0] })
|
2026-05-16 05:49:43 +00:00
|
|
|
.unwrap();
|
2026-06-09 09:29:07 +00:00
|
|
|
let nt_bridge_addr = nodes[1]
|
|
|
|
|
.rt
|
2026-05-20 07:41:30 +00:00
|
|
|
.spawn(NextTokenBridge { target: stage_actor_addrs[0] })
|
|
|
|
|
.unwrap();
|
|
|
|
|
let mut activation_bridge_addrs: Vec<Option<ActorAddress>> = vec![None; num_stages as usize];
|
|
|
|
|
for s in 1..num_stages {
|
2026-06-09 09:29:07 +00:00
|
|
|
let rt = &nodes[s as usize + 1].rt;
|
2026-05-20 07:41:30 +00:00
|
|
|
let addr = rt
|
|
|
|
|
.spawn(ActivationBridge { target: stage_actor_addrs[s as usize] })
|
|
|
|
|
.unwrap();
|
|
|
|
|
activation_bridge_addrs[s as usize] = Some(addr);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wire neighbours: First/Middle → next stage's activation bridge;
|
|
|
|
|
// Last → first stage's NextToken bridge.
|
|
|
|
|
for s in 0..num_stages {
|
2026-06-09 09:29:07 +00:00
|
|
|
let rt = &nodes[s as usize + 1].rt;
|
2026-05-20 07:41:30 +00:00
|
|
|
let (next, prev) = if s == last_stage {
|
|
|
|
|
(None, Some(nt_bridge_addr))
|
|
|
|
|
} else {
|
|
|
|
|
(activation_bridge_addrs[(s + 1) as usize], None)
|
|
|
|
|
};
|
|
|
|
|
rt.send_to(
|
|
|
|
|
stage_actor_addrs[s as usize],
|
|
|
|
|
StageMsg::SetNeighbors {
|
|
|
|
|
prev_stage: prev,
|
|
|
|
|
next_stage: next,
|
|
|
|
|
reply_to: None,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
}
|
2026-05-16 05:49:43 +00:00
|
|
|
|
|
|
|
|
// Real workers can take well over a minute to load the GGUF on a cold
|
2026-05-20 07:41:30 +00:00
|
|
|
// tinygrad cache. Pumping every runtime in parallel keeps any one
|
|
|
|
|
// stage from starving the others' process bridges.
|
2026-06-09 09:29:07 +00:00
|
|
|
let rts_refs: Vec<Arc<Runtime>> = nodes.iter().skip(1).map(|n| Arc::clone(&n.rt)).collect();
|
|
|
|
|
let rts_borrows: Vec<&Runtime> = rts_refs.iter().map(|a| a.as_ref()).collect();
|
2026-05-20 07:41:30 +00:00
|
|
|
let mut stage_pids: Vec<u32> = Vec::with_capacity(num_stages as usize);
|
|
|
|
|
for s in 0..num_stages {
|
|
|
|
|
let label = match StageRole::for_stage(s, num_stages) {
|
|
|
|
|
StageRole::First => format!("stage-{s} real worker (first)"),
|
|
|
|
|
StageRole::Middle => format!("stage-{s} real worker (middle)"),
|
|
|
|
|
StageRole::Last => format!("stage-{s} real worker (last)"),
|
|
|
|
|
};
|
|
|
|
|
let pid = drain_until_ready_pumped(
|
2026-06-09 09:29:07 +00:00
|
|
|
&rts_borrows,
|
2026-05-20 07:41:30 +00:00
|
|
|
&status_inboxes[s as usize],
|
|
|
|
|
REAL_READY_TIMEOUT,
|
|
|
|
|
&label,
|
|
|
|
|
);
|
|
|
|
|
stage_pids.push(pid);
|
|
|
|
|
}
|
2026-06-09 09:29:07 +00:00
|
|
|
drop(rts_borrows);
|
|
|
|
|
drop(rts_refs);
|
2026-05-16 05:49:43 +00:00
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
// Transport routes (mirror of build_pipeline).
|
|
|
|
|
let orch_to_first = Arc::new(IrohActorTransport::new(
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[0].driver.endpoint().clone(),
|
|
|
|
|
nodes[1].endpoint_addr(),
|
|
|
|
|
nodes[0].driver.tokio_handle(),
|
2026-05-16 05:49:43 +00:00
|
|
|
));
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[0].transport_router.add_route(request_bridge_addr, orch_to_first);
|
2026-05-20 07:41:30 +00:00
|
|
|
|
|
|
|
|
for s in 0..num_stages - 1 {
|
|
|
|
|
let rt_idx = s as usize + 1;
|
|
|
|
|
let next_idx = (s + 1) as usize + 1;
|
|
|
|
|
let transport = Arc::new(IrohActorTransport::new(
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[rt_idx].driver.endpoint().clone(),
|
|
|
|
|
nodes[next_idx].endpoint_addr(),
|
|
|
|
|
nodes[rt_idx].driver.tokio_handle(),
|
2026-05-20 07:41:30 +00:00
|
|
|
));
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[rt_idx].transport_router.add_route(
|
2026-05-20 07:41:30 +00:00
|
|
|
activation_bridge_addrs[(s + 1) as usize].expect("next stage has activation bridge"),
|
|
|
|
|
transport,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let last_idx = last_stage as usize + 1;
|
|
|
|
|
let last_to_first = Arc::new(IrohActorTransport::new(
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[last_idx].driver.endpoint().clone(),
|
|
|
|
|
nodes[1].endpoint_addr(),
|
|
|
|
|
nodes[last_idx].driver.tokio_handle(),
|
2026-05-16 05:49:43 +00:00
|
|
|
));
|
2026-05-20 07:41:30 +00:00
|
|
|
let last_to_orch = Arc::new(IrohActorTransport::new(
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[last_idx].driver.endpoint().clone(),
|
|
|
|
|
nodes[0].endpoint_addr(),
|
|
|
|
|
nodes[last_idx].driver.tokio_handle(),
|
2026-05-16 05:49:43 +00:00
|
|
|
));
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes[last_idx].transport_router.add_route(nt_bridge_addr, last_to_first);
|
|
|
|
|
nodes[last_idx].transport_router.add_route(inbox_addr, last_to_orch);
|
2026-05-16 05:49:43 +00:00
|
|
|
|
|
|
|
|
Pipeline {
|
2026-05-20 07:41:30 +00:00
|
|
|
num_stages,
|
2026-06-09 09:29:07 +00:00
|
|
|
nodes,
|
2026-05-20 07:41:30 +00:00
|
|
|
pumps,
|
2026-05-16 05:49:43 +00:00
|
|
|
codecs,
|
|
|
|
|
response_inbox,
|
|
|
|
|
token_observer_inbox: Some(token_observer_inbox),
|
|
|
|
|
inbox_addr,
|
|
|
|
|
request_bridge_addr,
|
2026-05-20 07:41:30 +00:00
|
|
|
stage_actor_addrs,
|
|
|
|
|
stage_pids,
|
|
|
|
|
_pipeline_lock: pipeline_guard,
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Tick every runtime in `rts` until `inbox` produces a `WorkerReady`,
|
|
|
|
|
/// returning the reported pid. Used by `build_real_pipeline` where the
|
|
|
|
|
/// model load can take much longer than `drain_until_ready`'s 10s budget
|
2026-05-20 07:41:30 +00:00
|
|
|
/// — the swactor process bridge consumes stdout on its host runtime, so
|
2026-05-16 05:49:43 +00:00
|
|
|
/// pumping is required for the ready line to surface.
|
|
|
|
|
fn drain_until_ready_pumped(
|
|
|
|
|
rts: &[&Runtime],
|
|
|
|
|
inbox: &Inbox<StageActorStatus>,
|
|
|
|
|
timeout: Duration,
|
|
|
|
|
label: &str,
|
|
|
|
|
) -> u32 {
|
|
|
|
|
let start = Instant::now();
|
|
|
|
|
while start.elapsed() < timeout {
|
|
|
|
|
for rt in rts {
|
|
|
|
|
rt.tick();
|
|
|
|
|
}
|
|
|
|
|
if let Some(status) = inbox.try_recv() {
|
|
|
|
|
if let StageActorStatus::WorkerReady { pid } = status {
|
2026-05-20 07:41:30 +00:00
|
|
|
return pid.unwrap_or_else(|| panic!("{label}: WorkerReady with no pid"));
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
|
|
|
}
|
|
|
|
|
panic!("{label} did not become ready within {:?}", timeout);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Pipeline {
|
|
|
|
|
/// Drive the pipeline for one prompt and return the `max_tokens`
|
|
|
|
|
/// sampled token ids in order. Asserts that the run terminates on
|
|
|
|
|
/// `done=true` (max_tokens reached) within `timeout`.
|
|
|
|
|
fn run_real_prompt(&mut self, prompt: &str, max_tokens: u32, timeout: Duration) -> Vec<u32> {
|
|
|
|
|
assert!(
|
|
|
|
|
self.token_observer_inbox.is_some(),
|
|
|
|
|
"pipeline has no token observer; built via build_pipeline instead of build_real_pipeline?"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
self.submit(prompt, max_tokens);
|
|
|
|
|
|
|
|
|
|
let mut collected: Vec<u32> = Vec::with_capacity(max_tokens as usize);
|
|
|
|
|
let start = Instant::now();
|
|
|
|
|
while collected.len() < max_tokens as usize && start.elapsed() < timeout {
|
|
|
|
|
self.pump();
|
|
|
|
|
if let Some(observer) = self.token_observer_inbox.as_ref() {
|
|
|
|
|
while let Some(nt) = observer.try_recv() {
|
|
|
|
|
collected.push(nt.token_id);
|
|
|
|
|
if nt.done {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
// Drain the final InferenceResponse so a residual response in the
|
|
|
|
|
// inbox doesn't leak across prompts when the harness is reused.
|
2026-05-16 05:49:43 +00:00
|
|
|
let drain_until = Instant::now() + Duration::from_secs(10);
|
|
|
|
|
while Instant::now() < drain_until {
|
|
|
|
|
self.pump();
|
|
|
|
|
if self.response_inbox.try_recv().is_some() {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
collected.len(),
|
|
|
|
|
max_tokens as usize,
|
|
|
|
|
"pipeline produced {} tokens for prompt {prompt:?}, expected {max_tokens}",
|
|
|
|
|
collected.len()
|
|
|
|
|
);
|
|
|
|
|
collected
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
/// Reset every stage actor so a fresh prompt can be submitted. The
|
2026-05-16 05:49:43 +00:00
|
|
|
/// worker's per-block KV cache is left alone — the next prefill at
|
|
|
|
|
/// `position=0` rewrites the cache positions it needs.
|
|
|
|
|
fn reset_for_next_prompt(&mut self) {
|
2026-05-20 07:41:30 +00:00
|
|
|
for (s, addr) in self.stage_actor_addrs.iter().enumerate() {
|
2026-06-09 09:29:07 +00:00
|
|
|
self.nodes[s + 1].rt.send_to(*addr, StageMsg::Reset).unwrap();
|
2026-05-20 07:41:30 +00:00
|
|
|
}
|
2026-05-16 05:49:43 +00:00
|
|
|
for _ in 0..5 {
|
|
|
|
|
self.pump();
|
|
|
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Reference worker (single process, full-model generate) ─────────────
|
|
|
|
|
|
|
|
|
|
/// A standalone real-mode worker driven directly over stdin/stdout. We do
|
|
|
|
|
/// not need the actor/process-bridge infrastructure for the reference —
|
|
|
|
|
/// it answers one op (`generate_full`) and is reused across prompts.
|
|
|
|
|
struct ReferenceWorker {
|
|
|
|
|
child: Child,
|
|
|
|
|
stdout: BufReader<ChildStdout>,
|
|
|
|
|
stdin: ChildStdin,
|
|
|
|
|
next_rid: u64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ReferenceWorker {
|
|
|
|
|
fn spawn() -> Self {
|
|
|
|
|
let mut cmd = Command::new("python3");
|
|
|
|
|
cmd.arg(format!(
|
|
|
|
|
"{}/pp_tinygrad_worker.py",
|
|
|
|
|
env!("CARGO_MANIFEST_DIR")
|
|
|
|
|
));
|
|
|
|
|
cmd.env("STAGE", "0");
|
2026-05-20 07:41:30 +00:00
|
|
|
cmd.env("NUM_STAGES", "2");
|
2026-05-16 05:49:43 +00:00
|
|
|
cmd.env("MODEL", "llama3.2:1b");
|
|
|
|
|
cmd.env_remove("PP_WORKER_STUB");
|
|
|
|
|
cmd.stdin(Stdio::piped());
|
|
|
|
|
cmd.stdout(Stdio::piped());
|
|
|
|
|
cmd.stderr(Stdio::inherit());
|
|
|
|
|
let mut child = cmd.spawn().expect("spawn reference worker");
|
|
|
|
|
let stdout = BufReader::new(child.stdout.take().unwrap());
|
|
|
|
|
let stdin = child.stdin.take().unwrap();
|
2026-05-20 07:41:30 +00:00
|
|
|
let mut me = Self {
|
|
|
|
|
child,
|
|
|
|
|
stdout,
|
|
|
|
|
stdin,
|
|
|
|
|
next_rid: 1,
|
|
|
|
|
};
|
2026-05-16 05:49:43 +00:00
|
|
|
let ready = me.read_json();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
ready.get("status").and_then(|v| v.as_str()),
|
|
|
|
|
Some("ready"),
|
|
|
|
|
"reference worker did not report ready: {ready}"
|
|
|
|
|
);
|
|
|
|
|
me
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_json(&mut self) -> serde_json::Value {
|
|
|
|
|
let mut line = String::new();
|
|
|
|
|
let n = self
|
|
|
|
|
.stdout
|
|
|
|
|
.read_line(&mut line)
|
|
|
|
|
.expect("reference worker: read stdout");
|
|
|
|
|
if n == 0 {
|
|
|
|
|
panic!("reference worker closed stdout before replying");
|
|
|
|
|
}
|
2026-05-20 07:41:30 +00:00
|
|
|
serde_json::from_str(line.trim())
|
|
|
|
|
.unwrap_or_else(|e| panic!("reference worker: invalid JSON {line:?}: {e}"))
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Ask the reference worker for the `max_tokens` argmax-sampled
|
2026-05-20 07:41:30 +00:00
|
|
|
/// continuations of `prompt`.
|
2026-05-16 05:49:43 +00:00
|
|
|
fn generate_full(&mut self, prompt: &str, max_tokens: u32) -> Vec<u32> {
|
|
|
|
|
let rid = self.next_rid;
|
|
|
|
|
self.next_rid += 1;
|
|
|
|
|
let req = serde_json::json!({
|
|
|
|
|
"op": "generate_full",
|
|
|
|
|
"request_id": rid,
|
|
|
|
|
"prompt": prompt,
|
|
|
|
|
"max_tokens": max_tokens,
|
|
|
|
|
});
|
|
|
|
|
let line = serde_json::to_string(&req).unwrap();
|
|
|
|
|
self.stdin.write_all(line.as_bytes()).expect("write");
|
|
|
|
|
self.stdin.write_all(b"\n").expect("write newline");
|
|
|
|
|
self.stdin.flush().expect("flush");
|
|
|
|
|
|
|
|
|
|
let reply = self.read_json();
|
|
|
|
|
if let Some(err) = reply.get("error").and_then(|v| v.as_str()) {
|
|
|
|
|
panic!("reference generate_full failed: {err}");
|
|
|
|
|
}
|
|
|
|
|
let tokens = reply
|
|
|
|
|
.get("tokens")
|
|
|
|
|
.and_then(|v| v.as_array())
|
|
|
|
|
.unwrap_or_else(|| panic!("reference reply missing tokens: {reply}"));
|
|
|
|
|
tokens
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|t| {
|
|
|
|
|
t.as_u64()
|
2026-05-20 07:41:30 +00:00
|
|
|
.unwrap_or_else(|| panic!("reference token not u64: {t}")) as u32
|
2026-05-16 05:49:43 +00:00
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Drop for ReferenceWorker {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
let _ = self.child.kill();
|
|
|
|
|
let _ = self.child.wait();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 07:41:30 +00:00
|
|
|
/// The three "diverse" prompts §12 enumerates: a one-word prompt, a
|
|
|
|
|
/// well-formed sentence, and a short string with punctuation. Catches
|
|
|
|
|
/// per-prompt-shape bugs the single-prompt tests cannot — see TEST_SPEC
|
|
|
|
|
/// §12 for why each shape matters.
|
|
|
|
|
const DIVERSE_PROMPTS: &[&str] = &[
|
|
|
|
|
"Hello",
|
|
|
|
|
"The quick brown fox jumps over the lazy dog.",
|
|
|
|
|
"Wait... what?!",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/// Drive every prompt through both an `num_stages`-stage sliced pipeline
|
|
|
|
|
/// and the single-process reference, asserting per-prompt token-id
|
|
|
|
|
/// equality up to `EQUIVALENCE_MAX_TOKENS`.
|
|
|
|
|
fn assert_pipeline_matches_reference(num_stages: u32, prompts: &[&str]) {
|
2026-05-16 05:49:43 +00:00
|
|
|
assert!(!prompts.is_empty(), "need at least one prompt");
|
|
|
|
|
|
|
|
|
|
let mut reference = ReferenceWorker::spawn();
|
2026-05-20 07:41:30 +00:00
|
|
|
let mut pipeline = build_real_pipeline(num_stages, EQUIVALENCE_MAX_TOKENS);
|
2026-05-16 05:49:43 +00:00
|
|
|
|
|
|
|
|
for (i, prompt) in prompts.iter().enumerate() {
|
|
|
|
|
let ref_tokens = reference.generate_full(prompt, EQUIVALENCE_MAX_TOKENS);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
ref_tokens.len() as u32,
|
|
|
|
|
EQUIVALENCE_MAX_TOKENS,
|
|
|
|
|
"reference returned wrong token count for prompt #{i} {prompt:?}: {ref_tokens:?}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let pipeline_tokens =
|
|
|
|
|
pipeline.run_real_prompt(prompt, EQUIVALENCE_MAX_TOKENS, REAL_PROMPT_TIMEOUT);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
pipeline_tokens, ref_tokens,
|
2026-05-20 07:41:30 +00:00
|
|
|
"N={num_stages}: sliced pipeline diverged from full-model reference on prompt #{i} {prompt:?}\n pipeline: {pipeline_tokens:?}\n reference: {ref_tokens:?}"
|
2026-05-16 05:49:43 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if i + 1 < prompts.len() {
|
|
|
|
|
pipeline.reset_for_next_prompt();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pipeline.shutdown();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[ignore]
|
|
|
|
|
#[test]
|
2026-05-20 07:41:30 +00:00
|
|
|
fn sliced_two_stage_inference_matches_single_node_for_say_hello() {
|
|
|
|
|
assert_pipeline_matches_reference(2, &["Say hello"]);
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[ignore]
|
|
|
|
|
#[test]
|
|
|
|
|
fn sliced_two_stage_inference_matches_single_node_for_three_diverse_prompts() {
|
2026-05-20 07:41:30 +00:00
|
|
|
assert_pipeline_matches_reference(2, DIVERSE_PROMPTS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[ignore]
|
|
|
|
|
#[test]
|
|
|
|
|
fn sliced_three_stage_inference_matches_single_node_for_say_hello() {
|
|
|
|
|
assert_pipeline_matches_reference(3, &["Say hello"]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[ignore]
|
|
|
|
|
#[test]
|
|
|
|
|
fn sliced_three_stage_inference_matches_single_node_for_three_diverse_prompts() {
|
|
|
|
|
assert_pipeline_matches_reference(3, DIVERSE_PROMPTS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[ignore]
|
|
|
|
|
#[test]
|
|
|
|
|
fn sliced_four_stage_inference_matches_single_node_for_say_hello() {
|
|
|
|
|
assert_pipeline_matches_reference(4, &["Say hello"]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The N-invariance property: with argmax sampling the token sequence is
|
|
|
|
|
/// independent of chain length. Runs the same prompt through pipelines at
|
|
|
|
|
/// `N ∈ {2, 3, 4}` and asserts every run yields the same token ids. This
|
|
|
|
|
/// is the test that catches silent state corruption a longer chain would
|
|
|
|
|
/// introduce — e.g. a Middle stage that subtly mangles `hidden` would pass
|
|
|
|
|
/// every individual N-vs-reference test if reference and pipeline mangled
|
|
|
|
|
/// it the same way, but the N=2 run (which has no Middle) would diverge.
|
|
|
|
|
#[ignore]
|
|
|
|
|
#[test]
|
|
|
|
|
fn sliced_n_stage_inference_is_invariant_to_n_for_argmax() {
|
|
|
|
|
const PROMPT: &str = "Say hello";
|
|
|
|
|
|
|
|
|
|
let mut reference = ReferenceWorker::spawn();
|
|
|
|
|
let ref_tokens = reference.generate_full(PROMPT, EQUIVALENCE_MAX_TOKENS);
|
|
|
|
|
|
|
|
|
|
let mut per_n_tokens: Vec<(u32, Vec<u32>)> = Vec::with_capacity(3);
|
|
|
|
|
for &num_stages in &[2u32, 3, 4] {
|
|
|
|
|
let mut pipeline = build_real_pipeline(num_stages, EQUIVALENCE_MAX_TOKENS);
|
|
|
|
|
let tokens = pipeline.run_real_prompt(PROMPT, EQUIVALENCE_MAX_TOKENS, REAL_PROMPT_TIMEOUT);
|
|
|
|
|
pipeline.shutdown();
|
|
|
|
|
per_n_tokens.push((num_stages, tokens));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (n, tokens) in &per_n_tokens {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
tokens, &ref_tokens,
|
|
|
|
|
"N={n}: pipeline disagrees with single-node reference\n pipeline: {tokens:?}\n reference: {ref_tokens:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (first_n, first_tokens) = &per_n_tokens[0];
|
|
|
|
|
for (n, tokens) in per_n_tokens.iter().skip(1) {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
tokens, first_tokens,
|
|
|
|
|
"N-invariance violated: N={first_n} produced {first_tokens:?} but N={n} produced {tokens:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-16 05:49:43 +00:00
|
|
|
}
|