vastai-utils/dtrain
Zachery Aaron Shores-Chmielewski b0b04034d7 init
Toolkit for interaction with vastai instances. Eventual framework for low price spot distributed training.
2026-02-04 16:07:32 +07:00

405 lines
14 KiB
Python
Executable file

#!/usr/bin/env python3
"""
dtrain - CLI for distributed training on vast.ai
Commands:
search - Find cheap GPU offers, sorted by price
rent - Rent instances by ID or auto-select N cheapest
status - Show instance info (ID, GPU, status, SSH command)
ps - Show training status (log age + last output line)
deploy - Copy script to all instances and run in background
run - Run arbitrary command on all instances
destroy - Tear down all instances
Requires: vastai CLI configured with API key, SSH key registered with vast.ai
"""
import argparse
import json
import os
import subprocess
import sys
def run_vast(*args):
"""Run a vastai CLI command and return the result."""
cmd = ["vastai"] + list(args)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"Error: {result.stderr}", file=sys.stderr)
return None
return result.stdout
# ============ COMMANDS ============
def cmd_search(args):
"""Find cheap GPU offers."""
query = f"gpu_ram>={args.vram} inet_down>=100"
output = run_vast("search", "offers", query, "-o", "dph_total", "--limit", str(args.limit), "--raw")
if not output:
return 1
offers = json.loads(output)
print(f"{'ID':<12} {'GPU':<16} {'$/hr':<8} {'VRAM':<6} {'Location':<20}")
print("-" * 70)
for o in offers:
print(f"{o['id']:<12} {o['gpu_name']:<16} ${o['dph_total']:<7.4f} {o['gpu_ram']/1024:.0f}GB {o['geolocation'][:20]:<20}")
return 0
def cmd_rent(args):
"""Rent instances by ID or pick n cheapest."""
# If --cheap N is set, find the N cheapest offers
if args.cheap:
query = f"gpu_ram>={args.vram} inet_down>=100"
output = run_vast("search", "offers", query, "-o", "dph_total", "--limit", str(args.cheap), "--raw")
if not output:
return 1
offers = json.loads(output)
offer_ids = [o['id'] for o in offers]
total = sum(o['dph_total'] for o in offers)
print(f"Selected {len(offers)} cheapest:")
for o in offers:
print(f" {o['id']} - {o['gpu_name']:<16} ${o['dph_total']:.4f}/hr {o['geolocation'][:20]}")
print(f"Total: ${total:.4f}/hr")
else:
offer_ids = args.ids
if not offer_ids:
print("Error: provide IDs or use --cheap N")
return 1
print(f"\nRenting {len(offer_ids)} instance(s)...")
for oid in offer_ids:
output = run_vast("create", "instance", str(oid),
"--image", args.image,
"--disk", str(args.disk))
if output:
print(output.strip())
print("\nRun 'dtrain status' to check instance status.")
return 0
def cmd_status(args):
"""Show running instances."""
output = run_vast("show", "instances", "--raw")
if not output:
print("No instances or error fetching.")
return 1
instances = json.loads(output)
if not instances:
print("No running instances.")
return 0
print(f"{'ID':<10} {'GPU':<16} {'Status':<12} {'$/hr':<8} {'SSH'}")
print("-" * 80)
for inst in instances:
status = inst.get('actual_status', 'unknown')
gpu = inst.get('gpu_name', 'N/A')
cost = inst.get('dph_total') or 0
ssh_host = inst.get('ssh_host', '')
ssh_port = inst.get('ssh_port', '')
ssh_cmd = f"ssh -p {ssh_port} root@{ssh_host}" if ssh_host else "(not ready)"
print(f"{inst['id']:<10} {gpu:<16} {status:<12} ${cost:<7.4f} {ssh_cmd}")
return 0
def get_instances():
"""Get list of running instances."""
output = run_vast("show", "instances", "--raw")
if not output:
return []
return json.loads(output)
def run_on_instance(inst, command, timeout=10):
"""Run a command on an instance via SSH. Returns (returncode, output) or (None, msg) if not ready."""
ssh_host = inst.get('ssh_host', '')
ssh_port = inst.get('ssh_port', '')
if not ssh_host:
return None, "not ready"
# BatchMode=yes prevents password prompts from hanging
cmd = ["ssh", "-p", str(ssh_port),
"-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=5",
"-o", "BatchMode=yes",
f"root@{ssh_host}", command]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return result.returncode, result.stdout + result.stderr
except subprocess.TimeoutExpired:
return 1, "timeout"
def cmd_deploy(args):
"""Deploy a script to all instances, verify, and run it."""
instances = get_instances()
if not instances:
print("No running instances.")
return 1
filename = os.path.basename(args.script)
dest_path = args.dest.rstrip('/') + '/' + filename
ready_instances = []
# Phase 1: Copy script to all instances
print("Copying script to instances...")
for inst in instances:
ssh_host = inst.get('ssh_host', '')
ssh_port = inst.get('ssh_port', '')
if not ssh_host:
print(f" {inst['id']} - not ready, skipping")
continue
# Copy file
dest = f"root@{ssh_host}:{args.dest}"
cmd = ["scp", "-P", str(ssh_port), "-o", "StrictHostKeyChecking=no", args.script, dest]
print(f" {inst['id']} - copying {args.script}...", end=" ")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"FAILED: {result.stderr.strip()}")
continue
# Verify
ret, out = run_on_instance(inst, f"ls -la {dest_path}")
if ret == 0:
print("OK")
ready_instances.append((inst, dest_path))
else:
print(f"FAILED (verify): {out.strip()}")
if not ready_instances:
print("No instances ready for execution.")
return 1
if args.no_run:
return 0
# Phase 2: Run script on all instances (in background)
print(f"\nStarting {filename} on {len(ready_instances)} instance(s)...")
for inst, path in ready_instances:
logfile = filename.replace('.py', '.log')
run_cmd = f"cd {args.dest} && nohup python {filename} > {logfile} 2>&1 &"
ret, out = run_on_instance(inst, run_cmd)
if ret is None:
print(f" {inst['id']} - not ready")
elif ret == 0:
print(f" {inst['id']} - started (log: {args.dest}{logfile})")
else:
print(f" {inst['id']} - FAILED: {out.strip()}")
# Summary
total = len(instances)
skipped = total - len(ready_instances)
print(f"\nSummary: {len(ready_instances)}/{total} instances deployed")
if skipped:
print(f" {skipped} skipped/failed")
return 0
def cmd_ps(args):
"""Show training status by reading log files on each node."""
instances = get_instances()
if not instances:
print("No running instances.")
return 1
# Print header immediately
print(f"{'ID':<10} {'GPU':<14} {'Status':<10} {'Log age':<10} {'Last output'}")
print("-" * 100)
for inst in instances:
iid = inst['id']
gpu = inst.get('gpu_name', 'N/A')[:14]
status = inst.get('actual_status', 'unknown')
if not inst.get('ssh_host'):
print(f"{iid:<10} {gpu:<14} {status:<10} {'-':<10} (no ssh)")
continue
# Show we're checking this node
print(f"{iid:<10} {gpu:<14} {status:<10} ", end="", flush=True)
# Find most recent .log file, output format: "seconds_since_modified|last_line"
log_cmd = 'log=$(ls -t /workspace/*.log 2>/dev/null | head -1); if [ -n "$log" ]; then age=$(($(date +%s) - $(stat -c %Y "$log"))); echo "$age|$(tail -1 "$log" | head -c 60)"; else echo "NO_LOG|"; fi'
ret, out = run_on_instance(inst, log_cmd)
# Check for SSH/connection errors
error_phrases = ['connection refused', 'no route to host', 'connection timed out',
'permission denied', 'host key verification failed', 'network is unreachable', 'timeout']
out_lower = (out or '').lower()
is_conn_error = any(err in out_lower for err in error_phrases)
if is_conn_error:
err_msg = out.strip().split('\n')[0][:40] if out else 'SSH error'
print(f"{'-':<10} ({err_msg})")
continue
# Parse "age|last_line" - skip vast.ai SSH banner lines by looking for our format
result_line = None
for line in (out or '').strip().split('\n'):
if '|' in line and (line[0].isdigit() or line.startswith('NO_LOG')):
result_line = line
break
if result_line:
age_str, last_line = result_line.split('|', 1)
if age_str == 'NO_LOG':
print(f"{'-':<10} (no log file)")
else:
# Convert raw seconds to friendly format
if age_str.endswith('s'):
age_str = age_str # already formatted
elif age_str.isdigit():
secs = int(age_str)
if secs < 60:
age_str = f"{secs}s ago"
elif secs < 3600:
age_str = f"{secs//60}m ago"
else:
age_str = f"{secs//3600}h ago"
print(f"{age_str:<10} {last_line}")
else:
print(f"{'-':<10} (no output)")
return 0
def cmd_run(args):
"""Run a command on all instances."""
instances = get_instances()
if not instances:
print("No running instances.")
return 1
command = " ".join(args.command)
# Track results for summary
results = {'ok': 0, 'failed': 0, 'not_ready': 0}
for inst in instances:
iid = inst['id']
gpu = inst.get('gpu_name', 'N/A')
status = inst.get('actual_status', 'unknown')
ssh_host = inst.get('ssh_host', '')
# Header with instance info
header = f"[{iid}] {gpu} ({status})"
if not ssh_host:
print(f"{header} -- SKIPPED: {status}, waiting for SSH")
results['not_ready'] += 1
continue
ret, out = run_on_instance(inst, command)
if ret is None:
print(f"{header} -- SKIPPED: SSH not available")
results['not_ready'] += 1
elif ret == 0:
print(f"{header} -- OK")
if out.strip():
# Indent output for readability
for line in out.strip().split('\n'):
print(f" {line}")
results['ok'] += 1
else:
print(f"{header} -- FAILED (exit {ret})")
if out.strip():
for line in out.strip().split('\n'):
print(f" {line}")
results['failed'] += 1
# Summary
total = len(instances)
print(f"\n--- {results['ok']}/{total} ok", end="")
if results['failed']:
print(f", {results['failed']} failed", end="")
if results['not_ready']:
print(f", {results['not_ready']} not ready", end="")
print(" ---")
return 0 if results['failed'] == 0 else 1
def cmd_destroy(args):
"""Destroy instances."""
output = run_vast("show", "instances", "--raw")
if not output:
return 1
instances = json.loads(output)
if not instances:
print("No instances to destroy.")
return 0
print(f"Destroying {len(instances)} instance(s)...")
for inst in instances:
print(f" {inst['id']} - {inst.get('gpu_name', 'N/A')}")
run_vast("destroy", "instance", str(inst['id']))
print("Done.")
return 0
# ============ MAIN ============
def main():
parser = argparse.ArgumentParser(prog="dtrain", description="CLI for distributed training on vast.ai")
sub = parser.add_subparsers(dest="cmd")
# search
p = sub.add_parser("search", help="Find cheap GPU offers")
p.add_argument("--vram", type=int, default=8, help="Min VRAM in GB (default: 8)")
p.add_argument("--limit", type=int, default=10, help="Max results (default: 10)")
p.set_defaults(func=cmd_search)
# rent
p = sub.add_parser("rent", help="Rent instances by ID or pick cheapest")
p.add_argument("ids", type=int, nargs="*", help="Offer ID(s) to rent")
p.add_argument("-n", "--cheap", type=int, help="Rent N cheapest offers instead")
p.add_argument("--vram", type=int, default=8, help="Min VRAM for --cheap (default: 8)")
p.add_argument("--image", default="pytorch/pytorch:latest", help="Docker image")
p.add_argument("--disk", type=int, default=20, help="Disk GB (default: 20)")
p.set_defaults(func=cmd_rent)
# status
p = sub.add_parser("status", help="Show running instances")
p.set_defaults(func=cmd_status)
# ps
p = sub.add_parser("ps", help="Show training status (log age + last line)")
p.set_defaults(func=cmd_ps)
# deploy
p = sub.add_parser("deploy", help="Deploy script to all instances and run it")
p.add_argument("--script", required=True, help="Script to deploy")
p.add_argument("--dest", default="/workspace/", help="Destination path (default: /workspace/)")
p.add_argument("--no-run", action="store_true", help="Only copy, don't run the script")
p.set_defaults(func=cmd_deploy)
# run
p = sub.add_parser("run", help="Run command on all instances")
p.add_argument("command", nargs="+", help="Command to run")
p.set_defaults(func=cmd_run)
# destroy
p = sub.add_parser("destroy", help="Destroy all instances")
p.set_defaults(func=cmd_destroy)
args = parser.parse_args()
if not args.cmd:
parser.print_help()
return 0
return args.func(args)
if __name__ == "__main__":
sys.exit(main())