swactor/examples/pipeline-parallel-inference/tests/t_vastai.rs
Zachery Aaron Shores-Chmielewski a2ec548134 feat: two-node pipeline parallel example
Extend the single-GPU example into a two-node pipeline-parallel run that splits llama3.2:1b across two rented vast.ai GPUs and closes the autoregressive decode loop over iroh.

- topology: add linear-chain helpers where each stage derives its neighbours locally from `STAGE`/`NUM_STAGES`, registering `pp-entry`/`pp-exit`/`pp-stage-{i}` SWIM names
- messages: add `StageActivation` (bf16 hidden-state hand-off carrying position/seq_len/is_prefill) and `NextToken` (sampled-token feedback with a `done` flag) that close the autoregressive loop between stage 0 and stage 1
- stage_actor: add `Stage0Actor` (tokenize -> embed_and_forward -> prefill activation; decode_step on each NextToken) and `Stage1Actor` (forward_and_sample -> NextToken back; emit InferenceResponse on EOS/max_tokens)
- vastai: fork the client and add `create_pipeline_instances` (rents one instance per stage, threading `STAGE`/`NUM_STAGES`, best-effort destroys on partial failure) and `destroy_all_instances`
- pp_tinygrad_worker.py: per-stage worker slicing `model.blk[start:end]` in stub and real (GGUF) modes, plus new `pp_gpu_node`/`pp_smoke_run` binaries and ROADMAP/SPEC/TEST_SPEC docs
- reuse: build on the single-GPU example's iroh transport and process bridge unchanged; add actor/codec/topology/integration test suites

Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-05-16 09:49:43 +04:00

229 lines
7.5 KiB
Rust

//! T-vastai: pipeline-parallel vast.ai client tests.
//!
//! Every test runs against a local wiremock server — no real vast.ai traffic,
//! no `VAST_API_KEY` required. Covers TEST_SPEC §7 verbatim.
//!
//! Reused single-instance behaviors (offer search, status polling) are
//! validated in `single-gpu-inference/tests/t_vastai.rs` and not duplicated.
use pipeline_parallel_inference::vastai;
use reqwest::Client;
use wiremock::matchers::{header, method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
const SEED_ADDR: &str = "iroh://pp-seed-abc123";
const IMAGE: &str = "swactor-pp-gpu:latest";
const API_KEY: &str = "test-key";
/// Two consecutive `create_instance` calls send `STAGE=0` and `STAGE=1`
/// (one per stage), distinctly.
#[tokio::test]
async fn create_two_instances_sends_distinct_stage_env_vars() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path_regex("/api/v0/asks/101/"))
.and(header("Authorization", format!("Bearer {API_KEY}").as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"new_contract": 9001
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path_regex("/api/v0/asks/102/"))
.and(header("Authorization", format!("Bearer {API_KEY}").as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"new_contract": 9002
})))
.expect(1)
.mount(&server)
.await;
let client = Client::new();
let infos = vastai::create_pipeline_instances(
&client,
&server.uri(),
API_KEY,
&[101, 102],
SEED_ADDR,
None,
IMAGE,
)
.await
.expect("creation must succeed");
assert_eq!(infos.len(), 2);
assert_eq!(infos[0].contract_id, 9001);
assert_eq!(infos[1].contract_id, 9002);
let reqs = server.received_requests().await.unwrap();
assert_eq!(reqs.len(), 2, "exactly one PUT per offer");
// Pair each request body with the offer id from its URL so the assertion
// is order-independent (sequential ordering is implementation detail).
let mut by_offer: std::collections::HashMap<String, serde_json::Value> =
std::collections::HashMap::new();
for req in &reqs {
let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
let path = req.url.path().to_string();
by_offer.insert(path, body);
}
let body_101 = by_offer.get("/api/v0/asks/101/").expect("PUT to offer 101");
let body_102 = by_offer.get("/api/v0/asks/102/").expect("PUT to offer 102");
assert_eq!(body_101["env"]["STAGE"], "0", "offer 101 -> stage 0");
assert_eq!(body_102["env"]["STAGE"], "1", "offer 102 -> stage 1");
assert_ne!(
body_101["env"]["STAGE"], body_102["env"]["STAGE"],
"stage env vars must be distinct between the two creates"
);
}
/// Both creation calls carry the same `SEED_ADDR` in their env payload.
#[tokio::test]
async fn create_two_instances_both_receive_same_seed_addr() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path_regex(r"/api/v0/asks/(201|202)/"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"new_contract": 9999
})))
.expect(2)
.mount(&server)
.await;
let client = Client::new();
vastai::create_pipeline_instances(
&client,
&server.uri(),
API_KEY,
&[201, 202],
SEED_ADDR,
None,
IMAGE,
)
.await
.expect("creation must succeed");
let reqs = server.received_requests().await.unwrap();
assert_eq!(reqs.len(), 2);
for req in &reqs {
let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
assert_eq!(
body["env"]["SEED_ADDR"], SEED_ADDR,
"both creates must carry the same SEED_ADDR"
);
}
}
/// If the second `create_instance` fails, the first instance is destroyed
/// before the error is returned.
#[tokio::test]
async fn failure_to_create_second_instance_triggers_destroy_of_first() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path_regex("/api/v0/asks/301/"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"new_contract": 8001
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path_regex("/api/v0/asks/302/"))
.respond_with(ResponseTemplate::new(500).set_body_string("kaboom"))
.expect(1)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path_regex("/api/v0/instances/8001/"))
.and(header("Authorization", format!("Bearer {API_KEY}").as_str()))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true})))
.expect(1)
.mount(&server)
.await;
let client = Client::new();
let result = vastai::create_pipeline_instances(
&client,
&server.uri(),
API_KEY,
&[301, 302],
SEED_ADDR,
None,
IMAGE,
)
.await;
assert!(
result.is_err(),
"partial-success creation must surface an error"
);
// The mock's .expect(1) on the DELETE confirms the first instance was
// destroyed exactly once before we exited. server drop verifies it.
}
/// Best-effort cleanup over a list of contract ids issues one DELETE per id.
#[tokio::test]
async fn destroy_two_instances_sends_two_delete_requests() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path_regex("/api/v0/instances/4001/"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path_regex("/api/v0/instances/4002/"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true})))
.expect(1)
.mount(&server)
.await;
let client = Client::new();
let results =
vastai::destroy_all_instances(&client, &server.uri(), API_KEY, &[4001, 4002]).await;
assert_eq!(results.len(), 2);
assert!(results[0].is_ok(), "first destroy should succeed");
assert!(results[1].is_ok(), "second destroy should succeed");
}
/// A failure on one DELETE does not prevent the attempt on the other.
#[tokio::test]
async fn destroy_continues_when_one_delete_fails() {
let server = MockServer::start().await;
// First id fails server-side.
Mock::given(method("DELETE"))
.and(path_regex("/api/v0/instances/5001/"))
.respond_with(ResponseTemplate::new(500).set_body_string("nope"))
.expect(1)
.mount(&server)
.await;
// Second id must still be attempted and must succeed.
Mock::given(method("DELETE"))
.and(path_regex("/api/v0/instances/5002/"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"success": true})))
.expect(1)
.mount(&server)
.await;
let client = Client::new();
let results =
vastai::destroy_all_instances(&client, &server.uri(), API_KEY, &[5001, 5002]).await;
assert_eq!(results.len(), 2);
assert!(
results[0].is_err(),
"the failing DELETE should surface as Err"
);
assert!(
results[1].is_ok(),
"second DELETE must have been attempted and succeeded"
);
}