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>
11 KiB
Smoke Test Specification: Rent a GPU, Run Inference, Say Hello
1. Wanted Behavior
smoke-run --vastai --api-key <key> runs on the local machine. It:
- Finds the cheapest available GPU on vast.ai and rents it.
- Starts a local swactor node with iroh transport.
- The rented instance boots our pre-built Docker image containing
gpu-node+ tinygrad. - The remote
gpu-nodereadsSEED_ADDRfrom env and joins the local cluster via iroh/QUIC. - The local node discovers the remote
InferenceActorvia SWIM name resolution ("inference"). - The local node sends an
InferenceRequestto the remote actor. - The remote actor runs tinygrad inference, replies with
InferenceResponse. - Asserts the response is non-empty text and prints it.
- Destroys the vast.ai instance.
- Exits 0.
If anything fails, the script destroys the instance (if one was created) and exits 1.
Total wall-clock budget: 10 minutes.
local machine (smoke-run) vast.ai GPU (gpu-node)
| |
| 1. search offers (REST) |
| 2. create instance (REST) |
| SEED_ADDR=<local node id> |
| ------------------------------------> |
| | [pulling docker image]
| 3. poll vast.ai status every 10s | [gpu-node starting]
| ------------------------------------> | [tinygrad loading model into VRAM]
| "loading" |
| <------------------------------------ |
| "running" |
| <------------------------------------ |
| |
| 4. SWIM cluster join (iroh/QUIC) |
| <--------------------------------->>> |
| [cluster converged] |
| [name "inference" resolved] |
| |
| 5. InferenceRequest (swactor msg) |
| "Say hello" |
| ------------------------------------> |
| | [tinygrad forward pass]
| InferenceResponse |
| "Hello! How can I help you?" |
| <------------------------------------ |
| |
| 6. assert len(response) > 0 |
| 7. DELETE instance (vast.ai REST) |
| ------------------------------------> |
| 8. exit 0 |
2. What Already Exists
The entire pipeline is implemented and tested locally. The remaining work is deploying it to vast.ai for real.
Implemented components
| Component | File(s) | Status |
|---|---|---|
| Message types + codec | src/messages.rs |
Done, tested (T-codec: 7 tests) |
| InferenceActor + ProcessBridge | src/inference_actor.rs |
Done, tested (T-actor: 4 tests) |
| RequestBridge (network→actor type bridge) | src/inference_actor.rs |
Done |
| Iroh actor transport (shared) | src/iroh_transport.rs |
Done, used by tests + binaries |
| vast.ai REST client | src/vastai.rs |
Done, tested (T-vastai: 7 tests) |
| tinygrad worker (real GGUF model) | tinygrad_worker.py |
Done, tested (T-worker: 5 test classes) |
| Cluster transport tests | tests/t_cluster.rs |
Done (3 tests) |
| In-process integration test (echo) | tests/t_integration.rs |
Done, passes |
| In-process integration test (tinygrad) | tests/t_integration.rs |
Done, passes (#[ignore], needs .venv) |
gpu-node binary |
src/bin/gpu_node.rs |
Done, compiles |
smoke-run binary (localhost + vastai) |
src/bin/smoke_run.rs |
Done, compiles |
| Binary e2e test (echo) | tests/t_binary.rs |
Done, passes |
| Binary e2e test (tinygrad) | tests/t_binary.rs |
Done, passes (#[ignore], needs .venv) |
| Dockerfile | Dockerfile |
Done |
Test commands
# Fast tests (no GPU, no downloads, no vast.ai) — 22 tests
cargo test --manifest-path examples/single-gpu-inference/Cargo.toml
# Slow tests (downloads ~1GB GGUF model, runs tinygrad on CPU)
cargo test --manifest-path examples/single-gpu-inference/Cargo.toml -- --ignored
# Binary e2e on localhost (spawns gpu-node + smoke-run as child processes)
cargo test --manifest-path examples/single-gpu-inference/Cargo.toml binary_e2e_echo_worker
3. What Remains — Deployment to vast.ai
3.1 Fix relay mode for WAN
Both binaries currently use RelayMode::Disabled, which works on localhost but not over WAN. The remote gpu-node behind a vast.ai NAT cannot reach the local node without iroh relay servers.
Changes needed:
gpu-node: changeRelayMode::DisabledtoRelayMode::Defaultso iroh uses its public relay infrastructure for NAT traversal.smoke-run(vastai path): same — useRelayMode::Default.- The localhost path (
smoke-run --seed) can keepRelayMode::Disabled.
3.2 Fix worker script path in Dockerfile
The Dockerfile copies tinygrad_worker.py to /usr/local/share/tinygrad_worker.py, but gpu-node defaults WORKER_SCRIPT to ./tinygrad_worker.py.
Fix: either change the Dockerfile COPY destination to /app/tinygrad_worker.py and set WORKDIR /app, or set ENV WORKER_SCRIPT=/usr/local/share/tinygrad_worker.py in the Dockerfile.
3.3 Add pid to tinygrad worker ready signal
tinygrad_worker.py emits {"status": "ready"} but echo_worker.py emits {"status": "ready", "pid": <pid>}. The InferenceActor parses the pid field for WorkerReady { pid } status reporting. Without it, worker_pid is None — not fatal, but cleanup assertions in tests rely on it.
Fix: change the ready signal to _write({"status": "ready", "pid": os.getpid()}).
3.4 Build and push the Docker image
# Cross-compile gpu-node for linux/amd64 (if not already on linux/amd64)
cargo build --release --bin gpu-node --manifest-path examples/single-gpu-inference/Cargo.toml
# Build image
docker build -t <your-registry>/swactor-gpu:latest -f examples/single-gpu-inference/Dockerfile .
# Push to registry (vast.ai pulls from here)
docker push <your-registry>/swactor-gpu:latest
The image name in vastai.rs::create_instance is hardcoded to "swactor-gpu:latest". Update this to match whatever registry you push to, or make it a parameter.
3.5 Run it for real
VAST_API_KEY=<key> cargo run --manifest-path examples/single-gpu-inference/Cargo.toml --bin single-gpu-inference -- \
--vastai --api-key <key> --gpu RTX_4090
4. Architecture
Message flow (same for localhost and vast.ai)
smoke-run gpu-node
───────── ────────
rt.send_to(bridge_addr, InferenceRequest)
→ codec encodes → TransportRouter
→ IrohActorTransport (QUIC to gpu-node)
drain_and_collect_reply_addrs()
→ decode_wire → codecs.receive()
→ rt.deliver_raw(bridge_addr, InferenceRequest)
→ extract reply_to, add return transport route
RequestBridge.handle()
→ ctx.send(inference_addr, InferenceActorMsg::Request)
InferenceActor.handle()
→ JSON to tinygrad_worker.py stdin
tinygrad_worker.py
→ Transformer forward pass
→ JSON response to stdout
InferenceActor.process_output_line()
→ ctx.send(reply_to, InferenceResponse)
→ TransportRouter → IrohActorTransport (QUIC back)
drain_actor_messages()
→ decode_wire → codecs.receive()
→ rt.deliver_raw(inbox_addr, InferenceResponse)
response_inbox.try_recv() ✓
Name discovery
The gpu-node registers its RequestBridge under the name "inference" via DistributedNode::register_name. This propagates through SWIM gossip piggyback. The smoke-run orchestrator calls driver.node().resolve_name("inference") to discover the bridge's ActorAddress without needing to know it ahead of time.
Dynamic return routing
The gpu-node's drain loop inspects incoming InferenceRequest payloads to extract the reply_to address. It then dynamically registers a transport route for that address pointing back to the only alive SWIM member (the orchestrator). This is necessary because the gpu-node doesn't know the orchestrator's inbox address at startup.
5. tinygrad Worker
tinygrad_worker.py — managed by swactor's process crate via stdin/stdout JSON.
Model: llama3.2:1b from tinygrad's built-in GGUF catalog (~1GB download, fits in any modern GPU's VRAM). Loaded via tinygrad.apps.llm.Transformer.from_gguf().
Protocol:
← stdout: {"status": "ready", "pid": 12345}
→ stdin: {"prompt": "Say hello", "max_tokens": 64, "temperature": 0.7}
← stdout: {"response": "Hello! How can I help you today?"}
→ stdin: {invalid json}
← stdout: {"error": "invalid JSON: ..."}
Modes:
- Default: downloads and loads real GGUF model, runs real inference.
--stub: canned responses, no tinygrad import (for fast protocol tests).--model <name>: override model (e.g.,qwen3:0.6bfor smaller download).
Environment:
CUDA=1→ tinygrad uses CUDA backend (GPU).- No env var → tinygrad auto-detects (CUDA if available, else CPU).
PYTHON=1→ forces tinygrad's pure-Python CPU backend (no clang needed).
6. Error Modes
| Failure | Detection | Response |
|---|---|---|
| No GPU offers available | vast.ai returns empty list | Print error, exit 1. No cleanup needed. |
| Instance creation rejected | API returns non-success | Print error, exit 1. No cleanup needed. |
Instance never reaches running |
60 polls exhausted or terminal status | Destroy instance, exit 1. |
| Cluster never converges | SWIM timeout (2 min) | Destroy instance, exit 1. |
Name "inference" never resolves |
Timeout (1 min) | Destroy instance, exit 1. |
| tinygrad worker never reports ready | Process timeout (10 min) | Destroy instance, exit 1. |
| Empty/malformed response | Assertion on response text | Destroy instance, exit 1. |
| Destroy fails | Catch around cleanup | Print warning, exit 1. |
Every path that allocates an instance also destroys it.
7. Environment Requirements
- Rust toolchain
- Python 3.10+ with tinygrad 0.12.0 + numpy (in
.venv/) - Docker (for building the GPU node image)
VAST_API_KEY(vast.ai deployment only — not needed for local tests)- Network access to iroh relay servers (vast.ai deployment only)
8. What This Proves
- swactor nodes on rented GPUs can join a cluster with a local node over WAN via iroh/QUIC.
- swactor messages traverse the WAN transparently — no HTTP layer needed for node-to-node communication.
- tinygrad can load and run a LLaMA model on rented hardware via its CUDA backend.
- The process crate manages the Python child process lifecycle cleanly.
- SWIM gossip propagates actor name registrations, enabling dynamic service discovery.
- The full lifecycle (rent → cluster → discover → infer → teardown) is fully automated.