//! T-integration: in-process end-to-end pipeline-parallel inference. //! //! TEST_SPEC §8. Three swactor `DistributedNode`s in one process — orchestrator, //! stage 0, stage 1 — connected over real iroh QUIC. 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, stage 1 samples a token //! and sends `NextToken` back, stage 0 produces another activation, and so on //! until EOS or `max_tokens`. Stage 1 emits the final `InferenceResponse` back //! to the orchestrator. //! //! The same shape the binary uses (see `pp_smoke_run.rs`), minus the child //! 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}; use std::sync::Arc; use std::time::{Duration, Instant}; use distribution::iroh_driver::{IrohDriver, IrohDriverConfig}; use distribution::node::DistributedNodeConfig; use distribution::registry::RegistryConfig; use distribution::swim::probe::SwimConfig; use iroh::{PublicKey, RelayMode}; use swactor::actor::ActorAddress; use swactor::runtime::{Inbox, Runtime, RuntimeConfig}; use swactor::transport::{CodecRegistry, TransportRouter}; 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::{ Stage0Actor, Stage0Msg, Stage0NextTokenBridge, Stage0RequestBridge, Stage1ActivationBridge, Stage1Actor, Stage1Msg, StageActorStatus, }; use swactor_process::{ProcessMode, ProcessSpec}; // ─── Driver / cluster helpers (mirror t_cluster.rs) ─────────────────────── fn test_node_config() -> DistributedNodeConfig { DistributedNodeConfig { swim: SwimConfig { probe_interval: 1, probe_timeout: 3, indirect_probes: 1, suspicion_timeout: 5, dead_reprobe_interval: 0, ..SwimConfig::default() }, cache_capacity: 100, republish_interval: 50, registry: RegistryConfig::default(), metadata_lambda: 3, } } fn make_driver() -> IrohDriver { IrohDriver::new(IrohDriverConfig { secret_key: None, relay_mode: RelayMode::Disabled, node: test_node_config(), peer_auth: None, additional_alpns: vec![ACTOR_ALPN.to_vec()], }) .expect("failed to create iroh driver") } fn pump_one(driver: &mut IrohDriver) { driver.recv(); driver.tick(); } fn pubkey_of(driver: &IrohDriver) -> PublicKey { PublicKey::from_bytes(&driver.node_id().0).unwrap() } fn sees_alive(driver: &IrohDriver, peer_key: &PublicKey) -> bool { let snap = driver.snapshot(); let peer_hex: String = peer_key .as_bytes() .iter() .map(|b| format!("{:02x}", b)) .collect(); snap.members .iter() .any(|m| m.node_id == peer_hex && m.state == "alive") } fn make_three_node_cluster() -> [IrohDriver; 3] { let mut orch = make_driver(); let mut s0 = make_driver(); let mut s1 = make_driver(); let seed = orch.endpoint_addr(); s0.join(&[seed.clone()]); s1.join(&[seed]); let orch_key = pubkey_of(&orch); let s0_key = pubkey_of(&s0); let s1_key = pubkey_of(&s1); let start = Instant::now(); let mut converged = false; while start.elapsed() < Duration::from_secs(10) { pump_one(&mut orch); pump_one(&mut s0); pump_one(&mut s1); if sees_alive(&orch, &s0_key) && sees_alive(&orch, &s1_key) && sees_alive(&s0, &orch_key) && sees_alive(&s0, &s1_key) && sees_alive(&s1, &orch_key) && sees_alive(&s1, &s0_key) { converged = true; break; } std::thread::sleep(Duration::from_millis(20)); } assert!(converged, "3-node cluster did not converge within 10s"); [orch, s0, s1] } // ─── 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 drain_until_ready( rt: &Runtime, status_inbox: &Inbox, 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)); } panic!("worker did not become ready within {:?}", timeout); } 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(); } // ─── Pipeline harness ───────────────────────────────────────────────────── /// Fully wired in-process pipeline: 3 iroh drivers + 3 runtimes + actors. struct Pipeline { orch: IrohDriver, s0: IrohDriver, s1: IrohDriver, rt_orch: Runtime, rt_s0: Runtime, rt_s1: Runtime, codecs: Arc, response_inbox: Inbox, /// Real-mode only: receives a copy of every `NextToken` Stage 1 emits. /// `None` for stub-mode pipelines built via `build_pipeline`. token_observer_inbox: Option>, inbox_addr: ActorAddress, request_bridge_addr: ActorAddress, s0_actor_addr: ActorAddress, s1_actor_addr: ActorAddress, s0_pid: u32, s1_pid: u32, orch_pump: ActorMessagePump, s0_pump: ActorMessagePump, s1_pump: ActorMessagePump, } /// Build the pipeline with a fixed `max_tokens` and an optional EOS token id. /// All addresses are known up front (single-process test), so neighbour /// addresses are passed directly into actor constructors and only stage 0's /// next-stage address uses `SetNextStage` (since the activation bridge is /// spawned after the actor it routes into). fn build_pipeline(max_tokens: u32, eos: Option) -> Pipeline { let [orch, s0, s1] = make_three_node_cluster(); let codecs = Arc::new(inference_codec_registry()); let mut rt_orch = Runtime::new(RuntimeConfig::default()); let mut rt_s0 = Runtime::new(RuntimeConfig::default()); let mut rt_s1 = Runtime::new(RuntimeConfig::default()); let response_inbox = rt_orch.new_inbox::().unwrap(); let inbox_addr = *response_inbox.addr(); let s0_status = rt_s0.new_inbox::().unwrap(); let s1_status = rt_s1.new_inbox::().unwrap(); // Stage 0 actor with placeholder next_stage; we set it via SetNextStage // once the stage-1 activation bridge address exists. let placeholder = ActorAddress([0; 32]); let sender_s0 = rt_s0.create_sender(); let stage0_actor = Stage0Actor::new(worker_spec(0, 2), sender_s0, placeholder) .with_status_addr(*s0_status.addr()); let s0_actor_addr = rt_s0.spawn(stage0_actor).unwrap(); let request_bridge = Stage0RequestBridge { target: s0_actor_addr }; let request_bridge_addr = rt_s0.spawn(request_bridge).unwrap(); let nt_bridge = Stage0NextTokenBridge { target: s0_actor_addr }; let nt_bridge_addr = rt_s0.spawn(nt_bridge).unwrap(); // Stage 1 actor receives real prev/reply_to up front. let sender_s1 = rt_s1.create_sender(); let mut stage1_actor = Stage1Actor::new( worker_spec(1, 2), sender_s1, nt_bridge_addr, inbox_addr, max_tokens, ) .with_status_addr(*s1_status.addr()); if let Some(eos_id) = eos { stage1_actor = stage1_actor.with_eos_token_id(eos_id); } let s1_actor_addr = rt_s1.spawn(stage1_actor).unwrap(); let activation_bridge = Stage1ActivationBridge { target: s1_actor_addr }; let activation_bridge_addr = rt_s1.spawn(activation_bridge).unwrap(); rt_s0 .send_to(s0_actor_addr, Stage0Msg::SetNextStage(activation_bridge_addr)) .unwrap(); // Wait for both worker subprocesses to report ready. let s0_pid = drain_until_ready(&rt_s0, &s0_status, Duration::from_secs(10)); let s1_pid = drain_until_ready(&rt_s1, &s1_status, Duration::from_secs(10)); // Install codec registries and transport routers. // Orchestrator → s0 (InferenceRequest into stage 0's request bridge). let orch_to_s0 = Arc::new(IrohActorTransport::new( orch.endpoint().clone(), s0.endpoint_addr(), orch.tokio_handle(), )); let router_orch = TransportRouter::new(); router_orch.add_route(request_bridge_addr, orch_to_s0); rt_orch.set_codec_registry(codecs.clone()); rt_orch.set_transport_router(Arc::new(router_orch)); // s0 → s1 (StageActivation into stage 1's activation bridge). let s0_to_s1 = Arc::new(IrohActorTransport::new( s0.endpoint().clone(), s1.endpoint_addr(), s0.tokio_handle(), )); let router_s0 = TransportRouter::new(); router_s0.add_route(activation_bridge_addr, s0_to_s1); rt_s0.set_codec_registry(codecs.clone()); rt_s0.set_transport_router(Arc::new(router_s0)); // s1 → s0 (NextToken) and s1 → orch (InferenceResponse). let s1_to_s0 = Arc::new(IrohActorTransport::new( s1.endpoint().clone(), s0.endpoint_addr(), s1.tokio_handle(), )); let s1_to_orch = Arc::new(IrohActorTransport::new( s1.endpoint().clone(), orch.endpoint_addr(), s1.tokio_handle(), )); let router_s1 = TransportRouter::new(); router_s1.add_route(nt_bridge_addr, s1_to_s0); router_s1.add_route(inbox_addr, s1_to_orch); rt_s1.set_codec_registry(codecs.clone()); rt_s1.set_transport_router(Arc::new(router_s1)); Pipeline { orch, s0, s1, rt_orch, rt_s0, rt_s1, codecs, response_inbox, token_observer_inbox: None, inbox_addr, request_bridge_addr, s0_actor_addr, s1_actor_addr, s0_pid, s1_pid, orch_pump: ActorMessagePump::new(), s0_pump: ActorMessagePump::new(), s1_pump: ActorMessagePump::new(), } } impl Pipeline { /// One pass through every node: drain QUIC, pump actor messages into each /// runtime, tick every runtime. fn pump(&mut self) { self.orch.recv(); self.orch.tick(); self.s0.recv(); self.s0.tick(); self.s1.recv(); self.s1.tick(); self.orch_pump.pump(&self.orch, &self.codecs, &self.rt_orch); self.s0_pump.pump(&self.s0, &self.codecs, &self.rt_s0); self.s1_pump.pump(&self.s1, &self.codecs, &self.rt_s1); self.rt_orch.tick(); self.rt_s0.tick(); self.rt_s1.tick(); } /// Pump only the orchestrator side. Used after one of the stages has been /// shut down (calling `recv`/`tick` on a dead driver is fine, but pumping /// its runtime contributes nothing and clutters the loop). fn pump_orch_only(&mut self) { self.orch.recv(); self.orch.tick(); self.orch_pump.pump(&self.orch, &self.codecs, &self.rt_orch); self.rt_orch.tick(); } fn submit(&self, prompt: &str, max_tokens: u32) { let req = InferenceRequest { reply_to: self.inbox_addr, prompt: prompt.into(), max_tokens, }; self.rt_orch.send_to(self.request_bridge_addr, req).unwrap(); } fn await_response(&mut self, timeout: Duration) -> Result { 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 /// orchestrator's SWIM view. The orchestrator's snapshot omits self, so /// `alive_count < 2` means at least one stage is no longer reachable. fn await_response_or_stage_failure( &mut self, timeout: Duration, ) -> Result { let start = Instant::now(); while start.elapsed() < timeout { self.pump_orch_only(); if let Some(r) = self.response_inbox.try_recv() { return Ok(r); } let snap = self.orch.snapshot(); if snap.alive_count < 2 { 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) { let _ = self.rt_s0.stop_actor(self.s0_actor_addr); let _ = self.rt_s1.stop_actor(self.s1_actor_addr); let start = Instant::now(); while start.elapsed() < Duration::from_secs(3) { self.rt_s0.tick(); self.rt_s1.tick(); if !is_process_alive(self.s0_pid) && !is_process_alive(self.s1_pid) { break; } std::thread::sleep(Duration::from_millis(20)); } self.orch.shutdown(); self.s0.shutdown(); self.s1.shutdown(); } } /// Parse the stub `detokenize_stub` output `"tokens: [a b c]"` into a vector /// of token ids. The format is owned by `Stage1Actor::detokenize_stub`. fn parse_stub_tokens(text: &str) -> Vec { let inside = text .trim_start_matches("tokens: [") .trim_end_matches(']') .trim(); if inside.is_empty() { return vec![]; } inside .split_whitespace() .map(|s| s.parse::().expect("stub token id parses as u32")) .collect() } // ═══════════════════════════════════════════════════════════════════════ // §8 tests // ═══════════════════════════════════════════════════════════════════════ /// Full pipeline: orchestrator submits one `InferenceRequest`, stage 0 → /// stage 1 → stage 0 round-trips drive the autoregressive loop with the stub /// worker, stage 1 emits `InferenceResponse` back to the orchestrator within /// the test timeout, and the response text is non-empty. #[test] fn distributed_pipeline_through_stub_workers_returns_response() { let mut pipeline = build_pipeline(4, None); pipeline.submit("Say hello", 4); let response = pipeline .await_response(Duration::from_secs(30)) .expect("pipeline must produce an InferenceResponse"); assert!( !response.text.is_empty(), "response text must be non-empty, got: {:?}", response.text ); let tokens = parse_stub_tokens(&response.text); assert!( !tokens.is_empty(), "stub response must contain at least one token id: {:?}", response.text ); pipeline.shutdown(); } /// Configure the stub worker's stage-1 actor 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, the second pins EOS to one of those /// tokens and asserts the response truncates at the matching index. #[test] fn decode_loop_terminates_on_stub_eos() { const PROMPT: &str = "Say hello once"; const PROBE_TOKENS: u32 = 5; const TARGET_N: usize = 3; // First run: no EOS, collect the stub's natural token sequence. let probe_tokens = { let mut probe = build_pipeline(PROBE_TOKENS, None); probe.submit(PROMPT, PROBE_TOKENS); let resp = probe .await_response(Duration::from_secs(30)) .expect("probe pipeline must produce a response"); let tokens = parse_stub_tokens(&resp.text); assert_eq!( tokens.len(), PROBE_TOKENS as usize, "probe (no EOS, max_tokens={PROBE_TOKENS}) should yield {PROBE_TOKENS} tokens" ); probe.shutdown(); tokens }; let eos = probe_tokens[TARGET_N - 1]; // Second run: same prompt, configured EOS at the target position. Pipeline // must terminate after exactly TARGET_N tokens are accumulated. let mut real = build_pipeline(PROBE_TOKENS, Some(eos)); real.submit(PROMPT, PROBE_TOKENS); let resp = real .await_response(Duration::from_secs(30)) .expect("EOS pipeline must produce a response"); let tokens = parse_stub_tokens(&resp.text); assert_eq!( tokens.len(), TARGET_N, "EOS at index {} should truncate response to {TARGET_N} tokens, got {tokens:?}", TARGET_N - 1 ); assert_eq!( *tokens.last().unwrap(), eos, "last accumulated token must be the EOS id" ); real.shutdown(); } /// With no EOS configured, the decode loop runs until `max_tokens` and stage /// 1 emits `InferenceResponse` containing exactly `max_tokens` tokens. #[test] fn decode_loop_terminates_on_max_tokens() { const MAX: u32 = 4; let mut pipeline = build_pipeline(MAX, None); pipeline.submit("max tokens stop", MAX); let response = pipeline .await_response(Duration::from_secs(30)) .expect("max_tokens pipeline must produce a response"); let tokens = parse_stub_tokens(&response.text); assert_eq!( tokens.len() as u32, MAX, "with no EOS, response must contain exactly max_tokens={MAX} tokens, got {tokens:?}" ); pipeline.shutdown(); } /// Mid-decode failure: kill stage 1's worker process and shut down stage 1's /// iroh driver while the autoregressive loop is in flight. The orchestrator's /// await loop must surface a stage failure (via SWIM) within the configured /// detection window — not hang waiting forever. #[test] fn stage_failure_mid_decode_surfaces_as_error_to_orchestrator() { // Generous max_tokens so the loop will not finish before we kill stage 1. let mut pipeline = build_pipeline(64, None); 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() { panic!("pipeline returned a response too quickly to test mid-decode failure"); } std::thread::sleep(Duration::from_millis(20)); } // Take down stage 1: kill its worker subprocess AND shut down its iroh // node. Killing the worker alone leaves the SWIM node alive (it just stops // making progress); shutting down the driver is what makes SWIM mark it // dead in the orchestrator's view. Both happen together to simulate a // complete stage death. kill_pid(pipeline.s1_pid); pipeline.s1.shutdown(); // SwimConfig: probe_timeout=3, suspicion_timeout=5 → dead within ~8-10s. // Give 20s of slack for indirect probes and gossip propagation. let result = pipeline.await_response_or_stage_failure(Duration::from_secs(20)); assert!( result.is_err(), "orchestrator should NOT receive an InferenceResponse after stage 1 dies; got {result:?}" ); // Tear down what is left. let _ = pipeline.rt_s0.stop_actor(pipeline.s0_actor_addr); let teardown = Instant::now(); while teardown.elapsed() < Duration::from_secs(2) { pipeline.rt_s0.tick(); if !is_process_alive(pipeline.s0_pid) { break; } std::thread::sleep(Duration::from_millis(20)); } pipeline.orch.shutdown(); pipeline.s0.shutdown(); } // ═══════════════════════════════════════════════════════════════════════ // §9 — Sliced-vs-full equivalence // ═══════════════════════════════════════════════════════════════════════ // // 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`. // // Slow-test prerequisites mirror `tests/test_worker.py::TestRealTinygradWorker`: // // - clang installed (tinygrad's CPU backend compiles kernels with it). // - `llama3.2:1b` already cached under `~/.cache/tinygrad/downloads/`. // The first run on a fresh machine downloads ~1 GB; subsequent runs // reuse it. 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()); // Explicitly clear any inherited stub gate; presence of the var (not // its value) toggles stub mode on some startup paths. 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, } } /// Build a real-mode pipeline: same shape as `build_pipeline` but with /// real workers, real tokenization at stage 0, and a token observer on /// stage 1 so tests can capture the sampled token sequence. fn build_real_pipeline(max_tokens: u32) -> Pipeline { let [orch, s0, s1] = make_three_node_cluster(); let codecs = Arc::new(inference_codec_registry()); let mut rt_orch = Runtime::new(RuntimeConfig::default()); let mut rt_s0 = Runtime::new(RuntimeConfig::default()); let mut rt_s1 = Runtime::new(RuntimeConfig::default()); let response_inbox = rt_orch.new_inbox::().unwrap(); let inbox_addr = *response_inbox.addr(); let s0_status = rt_s0.new_inbox::().unwrap(); let s1_status = rt_s1.new_inbox::().unwrap(); let token_observer_inbox = rt_s1.new_inbox::().unwrap(); let observer_addr = *token_observer_inbox.addr(); let placeholder = ActorAddress([0; 32]); let sender_s0 = rt_s0.create_sender(); let stage0_actor = Stage0Actor::new(real_worker_spec(0, 2), sender_s0, placeholder) .with_status_addr(*s0_status.addr()) .with_real_tokenization(); let s0_actor_addr = rt_s0.spawn(stage0_actor).unwrap(); let request_bridge = Stage0RequestBridge { target: s0_actor_addr }; let request_bridge_addr = rt_s0.spawn(request_bridge).unwrap(); let nt_bridge = Stage0NextTokenBridge { target: s0_actor_addr }; let nt_bridge_addr = rt_s0.spawn(nt_bridge).unwrap(); let sender_s1 = rt_s1.create_sender(); let stage1_actor = Stage1Actor::new( real_worker_spec(1, 2), sender_s1, nt_bridge_addr, inbox_addr, max_tokens, ) .with_status_addr(*s1_status.addr()) .with_token_observer(observer_addr); let s1_actor_addr = rt_s1.spawn(stage1_actor).unwrap(); let activation_bridge = Stage1ActivationBridge { target: s1_actor_addr }; let activation_bridge_addr = rt_s1.spawn(activation_bridge).unwrap(); rt_s0 .send_to(s0_actor_addr, Stage0Msg::SetNextStage(activation_bridge_addr)) .unwrap(); // Real workers can take well over a minute to load the GGUF on a cold // tinygrad cache. We need both ready signals before driving the // pipeline; pumping the runtimes in parallel keeps either side from // starving the other's process bridge. let s0_pid = drain_until_ready_pumped( &[&rt_s0, &rt_s1], &s0_status, REAL_READY_TIMEOUT, "stage-0 real worker", ); let s1_pid = drain_until_ready_pumped( &[&rt_s0, &rt_s1], &s1_status, REAL_READY_TIMEOUT, "stage-1 real worker", ); let orch_to_s0 = Arc::new(IrohActorTransport::new( orch.endpoint().clone(), s0.endpoint_addr(), orch.tokio_handle(), )); let router_orch = TransportRouter::new(); router_orch.add_route(request_bridge_addr, orch_to_s0); rt_orch.set_codec_registry(codecs.clone()); rt_orch.set_transport_router(Arc::new(router_orch)); let s0_to_s1 = Arc::new(IrohActorTransport::new( s0.endpoint().clone(), s1.endpoint_addr(), s0.tokio_handle(), )); let router_s0 = TransportRouter::new(); router_s0.add_route(activation_bridge_addr, s0_to_s1); rt_s0.set_codec_registry(codecs.clone()); rt_s0.set_transport_router(Arc::new(router_s0)); let s1_to_s0 = Arc::new(IrohActorTransport::new( s1.endpoint().clone(), s0.endpoint_addr(), s1.tokio_handle(), )); let s1_to_orch = Arc::new(IrohActorTransport::new( s1.endpoint().clone(), orch.endpoint_addr(), s1.tokio_handle(), )); let router_s1 = TransportRouter::new(); router_s1.add_route(nt_bridge_addr, s1_to_s0); router_s1.add_route(inbox_addr, s1_to_orch); rt_s1.set_codec_registry(codecs.clone()); rt_s1.set_transport_router(Arc::new(router_s1)); Pipeline { orch, s0, s1, rt_orch, rt_s0, rt_s1, codecs, response_inbox, token_observer_inbox: Some(token_observer_inbox), inbox_addr, request_bridge_addr, s0_actor_addr, s1_actor_addr, s0_pid, s1_pid, orch_pump: ActorMessagePump::new(), s0_pump: ActorMessagePump::new(), s1_pump: ActorMessagePump::new(), } } /// 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 /// — the SwACTOR process bridge consumes stdout on its host runtime, so /// pumping is required for the ready line to surface. fn drain_until_ready_pumped( rts: &[&Runtime], inbox: &Inbox, 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 { return pid.unwrap_or_else(|| { panic!("{label}: WorkerReady with no pid") }); } } 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 { 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 = 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)); } // Drain the final InferenceResponse (the stub-text path is fine; we // don't read the text). Without this, a residual response in the // inbox would leak across prompts when the harness is reused. 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 } /// Reset the two stage actors so a fresh prompt can be submitted. The /// 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) { self.rt_s0.send_to(self.s0_actor_addr, Stage0Msg::Reset).unwrap(); self.rt_s1.send_to(self.s1_actor_addr, Stage1Msg::Reset).unwrap(); // Give the runtimes a moment to deliver the Reset before the next // submit so the actors don't race their own pending bookkeeping. 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, 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"); cmd.env("NUM_STAGES", "1"); 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(); let mut me = Self { child, stdout, stdin, next_rid: 1 }; 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(); // BufRead::read_line blocks until newline or EOF. For a test, a // blocking read is acceptable — the worker either replies or the // test times out at the cargo level. Catching EOF (read_line // returning Ok(0)) makes the panic message more useful when the // child has died. let n = self .stdout .read_line(&mut line) .expect("reference worker: read stdout"); if n == 0 { panic!("reference worker closed stdout before replying"); } serde_json::from_str(line.trim()).unwrap_or_else(|e| { panic!("reference worker: invalid JSON {line:?}: {e}") }) } /// Ask the reference worker for the `max_tokens` argmax-sampled /// continuations of `prompt`. Drives the worker's `generate_full` op, /// which runs the full model in one process — no per-stage slicing. fn generate_full(&mut self, prompt: &str, max_tokens: u32) -> Vec { 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() .unwrap_or_else(|| panic!("reference token not u64: {t}")) as u32 }) .collect() } } impl Drop for ReferenceWorker { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } /// Drive every prompt through both the sliced pipeline and the /// single-process reference, asserting per-prompt token-id equality up to /// `EQUIVALENCE_MAX_TOKENS`. Factored so each `#[ignore]` test reads as /// "what prompts" instead of "what plumbing". fn assert_pipeline_matches_reference(prompts: &[&str]) { assert!(!prompts.is_empty(), "need at least one prompt"); let mut reference = ReferenceWorker::spawn(); let mut pipeline = build_real_pipeline(EQUIVALENCE_MAX_TOKENS); 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, "sliced pipeline diverged from full-model reference on prompt #{i} {prompt:?}\n pipeline: {pipeline_tokens:?}\n reference: {ref_tokens:?}" ); if i + 1 < prompts.len() { pipeline.reset_for_next_prompt(); } } pipeline.shutdown(); // ReferenceWorker drops here — its `Drop` impl kills the subprocess. } /// Single-prompt equivalence: the canonical "Say hello" prompt from SPEC §1 /// must produce the same token ids through the sliced 2-stage pipeline as /// it does through a full-model `Transformer.generate`-style run. #[ignore] #[test] fn sliced_two_stage_inference_matches_single_node_full_inference_for_say_hello() { assert_pipeline_matches_reference(&["Say hello"]); } /// Three diverse prompt shapes — single word, full sentence, punctuation — /// stress position handling and tokenizer behaviour at the layer boundary. /// Per the plan, any off-by-one in `start_pos` or a misaligned KV cache /// slice falls out here, not on "Say hello". #[ignore] #[test] fn sliced_two_stage_inference_matches_single_node_for_three_diverse_prompts() { assert_pipeline_matches_reference(&[ "Hello", "The quick brown fox jumps over the lazy dog.", "Wait... what?!", ]); }