Toolkit for interaction with vastai instances. Eventual framework for low price spot distributed training.
405 lines
14 KiB
Python
Executable file
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())
|