swactor/examples/single-gpu-inference/tests/t_cluster.rs
Zachery Aaron Shores-Chmielewski 8e4987618f feat: Working vastai single-node deployment for LLM inference
Add a complete single-GPU distributed-inference example that rents a vast.ai GPU, boots a worker container, and runs a prompt end-to-end over iroh/SWIM.

- examples/single-gpu-inference: add the `single_gpu_inference` orchestrator binary that starts a local iroh node, waits for the remote gpu-node to register the `"inference"` SWIM name, then sends an `InferenceRequest` and prints the response
- examples/single-gpu-inference: add the `gpu_node` binary that joins the cluster via `SEED_ADDR`, spawns an `InferenceActor` over `tinygrad_worker.py`, and registers the `"inference"` bridge
- inference_actor: bridge swactor messaging to a Python child process via stdin/stdout JSON, with `ProcessBridge`/`RequestBridge` adapters that satisfy the single-`Incoming` actor constraint
- iroh_transport: add `IrohActorTransport` that sends `WireEnvelope`s over iroh QUIC uni-streams (connection-cached against early close), plus wire encode/decode and an inbound drain helper
- vastai: add a vast.ai REST client (`find_offer` with reliability/cuda/geo filters excluding CN, `create_instance`, `wait_for_running`, `destroy_instance`) parameterised by a mockable `base_url`
- worker/docs/tests: ship `tinygrad_worker.py`/`echo_worker.py` (newline-JSON, `--stub`/`--model` defaulting to llama3.2:1b), a Dockerfile, Makefile, SPEC, and actor/codec/cluster/integration/vastai test suites

Signed-off-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-05-14 11:19:28 +04:00

270 lines
8.3 KiB
Rust

//! T-cluster: Cross-node messaging component tests.
//!
//! Two swactor nodes on localhost via iroh. Tests SWIM convergence,
//! actor-level InferenceRequest/InferenceResponse exchange, and
//! SWIM death detection after node shutdown.
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::ActorInterface;
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};
use swactor::transport::TransportRouter;
use single_gpu_inference::iroh_transport::{drain_actor_messages, IrohActorTransport, ACTOR_ALPN};
use single_gpu_inference::messages::{inference_codec_registry, InferenceRequest, InferenceResponse};
// ── Test SWIM config ─────────────────────────────────────────────────────
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")
}
// ── Pump helpers ─────────────────────────────────────────────────────────
fn pump_one(driver: &mut IrohDriver) {
driver.recv();
driver.tick();
}
fn pump_until_pair(
a: &mut IrohDriver,
b: &mut IrohDriver,
timeout: Duration,
check_fn: fn(&IrohDriver, &IrohDriver) -> bool,
) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
pump_one(a);
pump_one(b);
if check_fn(a, b) {
return true;
}
std::thread::sleep(Duration::from_millis(10));
}
false
}
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 both_alive(a: &IrohDriver, b: &IrohDriver) -> bool {
let a_key = PublicKey::from_bytes(&a.node_id().0).unwrap();
let b_key = PublicKey::from_bytes(&b.node_id().0).unwrap();
sees_alive(a, &b_key) && sees_alive(b, &a_key)
}
/// Create two IrohDrivers and converge them via seed join.
fn make_converged_pair() -> (IrohDriver, IrohDriver) {
let mut driver_a = make_driver();
let mut driver_b = make_driver();
let a_addr = driver_a.endpoint_addr();
driver_b.join(&[a_addr]);
let converged = pump_until_pair(
&mut driver_a,
&mut driver_b,
Duration::from_secs(5),
both_alive,
);
assert!(converged, "cluster setup: nodes did not converge within 5s");
(driver_a, driver_b)
}
// ── Echo actor (replies InferenceResponse for any InferenceRequest) ─────
struct EchoInferenceActor;
impl ActorInterface for EchoInferenceActor {
type Incoming = InferenceRequest;
type Response = InferenceResponse;
fn handle(&mut self, ctx: &Ctx, msg: InferenceRequest) {
let _ = ctx.send(
msg.reply_to,
InferenceResponse {
text: format!("echo: {}", msg.prompt),
},
);
}
}
// ── Tests ────────────────────────────────────────────────────────────────
/// Node B joins node A via seed address. SWIM converges — both nodes see
/// each other alive within 5 seconds.
#[test]
fn cluster_converges_via_iroh_seed_join() {
let mut driver_a = make_driver();
let mut driver_b = make_driver();
let a_addr = driver_a.endpoint_addr();
driver_b.join(&[a_addr]);
let converged = pump_until_pair(
&mut driver_a,
&mut driver_b,
Duration::from_secs(5),
both_alive,
);
assert!(converged, "nodes did not converge within 5s");
assert_eq!(driver_a.snapshot().alive_count, 1);
assert_eq!(driver_b.snapshot().alive_count, 1);
driver_a.shutdown();
driver_b.shutdown();
}
/// Actor on node A sends InferenceRequest to actor on node B via the
/// transport router + codec. InferenceResponse arrives back at node A.
#[test]
fn inference_request_roundtrips_across_two_nodes() {
let (mut driver_a, mut driver_b) = make_converged_pair();
let codecs = Arc::new(inference_codec_registry());
let mut rt_a = Runtime::new(RuntimeConfig::default());
let mut rt_b = Runtime::new(RuntimeConfig::default());
// Spawn echo actor on node B
let echo_addr = rt_b.spawn(EchoInferenceActor).unwrap();
rt_b.tick();
// Inbox on node A for responses
let response_inbox = rt_a.new_inbox::<InferenceResponse>().unwrap();
let inbox_addr = *response_inbox.addr();
// Build iroh-backed transports for actor messages
let transport_a_to_b = Arc::new(IrohActorTransport::new(
driver_a.endpoint().clone(),
driver_b.endpoint_addr(),
driver_a.tokio_handle(),
));
let transport_b_to_a = Arc::new(IrohActorTransport::new(
driver_b.endpoint().clone(),
driver_a.endpoint_addr(),
driver_b.tokio_handle(),
));
// Wire routes: A knows echo_addr is on B, B knows inbox_addr is on A
let router_a = TransportRouter::new();
router_a.add_route(echo_addr, transport_a_to_b);
let router_b = TransportRouter::new();
router_b.add_route(inbox_addr, transport_b_to_a);
rt_a.set_codec_registry(codecs.clone());
rt_a.set_transport_router(Arc::new(router_a));
rt_b.set_codec_registry(codecs.clone());
rt_b.set_transport_router(Arc::new(router_b));
// Send InferenceRequest from node A → actor on node B
rt_a.send_to(
echo_addr,
InferenceRequest {
prompt: "Hello from node A".into(),
max_tokens: 8,
temperature: 0.7,
reply_to: inbox_addr,
},
)
.unwrap();
// Allow iroh transport to deliver, then drain into runtime B
std::thread::sleep(Duration::from_millis(200));
drain_actor_messages(&driver_b, &codecs, &rt_b, Duration::from_millis(500));
rt_b.tick();
// Actor replied — allow transport to deliver, then drain into runtime A
std::thread::sleep(Duration::from_millis(200));
drain_actor_messages(&driver_a, &codecs, &rt_a, Duration::from_millis(500));
let response = response_inbox
.try_recv()
.expect("InferenceResponse should arrive at node A");
assert!(
response.text.contains("Hello from node A"),
"expected echo of prompt, got: {:?}",
response.text
);
driver_a.shutdown();
driver_b.shutdown();
}
/// When node B shuts down, node A detects the death via SWIM within the
/// configured suspicion window.
#[test]
fn node_death_detected_via_swim_after_shutdown() {
let (mut driver_a, mut driver_b) = make_converged_pair();
assert_eq!(
driver_a.snapshot().alive_count, 1,
"precondition: A sees B alive"
);
// Kill node B
driver_b.shutdown();
// Pump node A until it sees zero alive peers
let start = Instant::now();
let timeout = Duration::from_secs(10);
let mut detected = false;
while start.elapsed() < timeout {
driver_a.recv();
driver_a.tick();
if driver_a.snapshot().alive_count == 0 {
detected = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(
detected,
"node A should detect node B's death via SWIM within the suspicion window"
);
driver_a.shutdown();
}