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>
159 lines
5 KiB
Python
159 lines
5 KiB
Python
#!/usr/bin/env python3
|
|
"""tinygrad compute worker — stdin/stdout JSON protocol.
|
|
|
|
Startup: loads a model (or uses --stub for testing), prints {"status": "ready"}.
|
|
|
|
Protocol (newline-delimited JSON):
|
|
→ stdin: {"prompt": "Say hello", "max_tokens": 64, "temperature": 0.7}
|
|
← stdout: {"response": "Hello! How can I help you today?"}
|
|
|
|
Errors:
|
|
← stdout: {"error": "description of what went wrong"}
|
|
|
|
Flags:
|
|
--stub Skip model loading; return a canned response for every request.
|
|
Used for component tests that exercise the protocol without a GPU.
|
|
--model NAME Model from tinygrad's built-in catalog (default: llama3.2:1b).
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="tinygrad inference worker")
|
|
parser.add_argument("--stub", action="store_true",
|
|
help="Stub mode: skip model loading, return canned responses")
|
|
parser.add_argument("--model", default="llama3.2:1b",
|
|
help="Model name from tinygrad catalog (default: llama3.2:1b)")
|
|
args = parser.parse_args()
|
|
|
|
if args.stub:
|
|
model_data = None
|
|
else:
|
|
try:
|
|
model_data = _load_model(args.model)
|
|
except Exception as e:
|
|
import traceback
|
|
_log(traceback.format_exc())
|
|
_write({"error": f"model load failed: {e}"})
|
|
sys.exit(1)
|
|
|
|
# Signal readiness
|
|
_write({"status": "ready", "pid": os.getpid()})
|
|
|
|
# Request loop
|
|
for line in sys.stdin:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
request = json.loads(line)
|
|
except (json.JSONDecodeError, ValueError) as e:
|
|
_write({"error": f"invalid JSON: {e}"})
|
|
continue
|
|
|
|
if "prompt" not in request:
|
|
_write({"error": "missing 'prompt' field"})
|
|
continue
|
|
|
|
prompt = request["prompt"]
|
|
max_tokens = request.get("max_tokens", 64)
|
|
temperature = request.get("temperature", 0.7)
|
|
|
|
try:
|
|
text = _generate(model_data, prompt, max_tokens, temperature, stub=args.stub)
|
|
_write({"response": text})
|
|
except Exception as e:
|
|
_write({"error": f"generation failed: {e}"})
|
|
|
|
|
|
def _write(obj):
|
|
"""Write a JSON object as a single line to stdout and flush."""
|
|
print(json.dumps(obj), flush=True)
|
|
|
|
|
|
def _log(msg):
|
|
"""Write a log message to stderr (not part of the JSON protocol)."""
|
|
print(msg, file=sys.stderr, flush=True)
|
|
|
|
|
|
def _load_model(model_name):
|
|
"""Load a GGUF model via tinygrad 0.12.0's built-in catalog."""
|
|
from tinygrad import Tensor
|
|
from tinygrad.helpers import fetch
|
|
from tinygrad.apps.llm import Transformer, SimpleTokenizer, models
|
|
|
|
if model_name not in models:
|
|
available = ", ".join(models.keys())
|
|
raise ValueError(f"Unknown model '{model_name}'. Available: {available}")
|
|
|
|
url = models[model_name]
|
|
_log(f"Downloading {model_name} from {url}...")
|
|
gguf_path = fetch(url)
|
|
|
|
_log(f"Loading model from {gguf_path}...")
|
|
model, kv = Transformer.from_gguf(Tensor(gguf_path), max_context=512)
|
|
tokenizer = SimpleTokenizer.from_gguf_kv(kv)
|
|
|
|
# Find stop token IDs for generation
|
|
tokens_list = kv.get("tokenizer.ggml.tokens", [])
|
|
stop_ids = set()
|
|
for i, tok in enumerate(tokens_list):
|
|
if tok in ("<|end_of_text|>", "<|eot_id|>", "</s>", "<|endoftext|>"):
|
|
stop_ids.add(i)
|
|
|
|
# Find EOS token ID for chat template end-of-turn
|
|
eot_id = None
|
|
for i, tok in enumerate(tokens_list):
|
|
if tok == "<|eot_id|>":
|
|
eot_id = i
|
|
break
|
|
if eot_id is None:
|
|
for i, tok in enumerate(tokens_list):
|
|
if tok in ("</s>", "<|end_of_text|>"):
|
|
eot_id = i
|
|
break
|
|
|
|
_log(f"Model loaded. Stop IDs: {stop_ids}, EOT ID: {eot_id}")
|
|
return {"model": model, "tokenizer": tokenizer, "stop_ids": stop_ids, "eot_id": eot_id}
|
|
|
|
|
|
def _format_chat_tokens(tokenizer, prompt, eot_id):
|
|
"""Format a prompt using Llama 3 instruct chat template."""
|
|
tokens = tokenizer.role("user")
|
|
tokens += tokenizer.encode(prompt)
|
|
if eot_id is not None:
|
|
tokens += tokenizer.end_turn(eot_id)
|
|
tokens += tokenizer.role("assistant")
|
|
return tokens
|
|
|
|
|
|
def _generate(model_data, prompt, max_tokens, temperature, stub=False):
|
|
"""Generate text from a prompt."""
|
|
if stub:
|
|
return f"stub response to: {prompt}"
|
|
|
|
model = model_data["model"]
|
|
tokenizer = model_data["tokenizer"]
|
|
stop_ids = model_data["stop_ids"]
|
|
eot_id = model_data["eot_id"]
|
|
|
|
# Use chat template for instruction-tuned models
|
|
tokens = _format_chat_tokens(tokenizer, prompt, eot_id)
|
|
prompt_len = len(tokens)
|
|
|
|
for i, tok_id in enumerate(model.generate(tokens)):
|
|
if tok_id in stop_ids:
|
|
tokens.pop() # remove the stop token from output
|
|
break
|
|
if i + 1 >= max_tokens:
|
|
break
|
|
|
|
return tokenizer.decode(tokens[prompt_len:])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|