mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-21 06:10:15 -05:00
* feat(bfi): add validated capture and bounded research tooling * fix(deps): patch Wasmtime for RUSTSEC-2026-0316
315 lines
14 KiB
Python
315 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Run bounded serial public-data GPU jobs on Linux; never score final test."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
|
|
SEEDS = (1729, 1730, 1731)
|
|
CHILD_TIMEOUT = 1020
|
|
MAX_LOG_BYTES = 8 * 1024 * 1024
|
|
MAX_METRICS_BYTES = 8 * 1024 * 1024
|
|
SUPPORTED_HOST = sys.platform == 'linux'
|
|
|
|
|
|
def require(ok, message):
|
|
if not ok:
|
|
raise ValueError(message)
|
|
|
|
|
|
def private_path(value):
|
|
path = Path(value).expanduser().resolve()
|
|
require(not any((p / '.git').exists() for p in (path, *path.parents)), 'private path must be outside Git')
|
|
return path
|
|
|
|
|
|
def digest(path, limit=1024 ** 3):
|
|
h = hashlib.sha256()
|
|
total = 0
|
|
with path.open('rb') as f:
|
|
for block in iter(lambda: f.read(1024 * 1024), b''):
|
|
total += len(block)
|
|
require(total <= limit, 'file exceeded hash byte budget')
|
|
h.update(block)
|
|
return h.hexdigest()
|
|
|
|
|
|
def save(path, state):
|
|
value = json.dumps(state, indent=2, allow_nan=False)
|
|
require(len(value) < 256 * 1024, 'state exceeds budget')
|
|
temporary = path.with_suffix('.tmp')
|
|
temporary.write_text(value + '\n', encoding='utf-8')
|
|
temporary.replace(path)
|
|
|
|
|
|
def gpu_free():
|
|
result = subprocess.run(['nvidia-smi', '--id=0', '--query-gpu=memory.free', '--format=csv,noheader,nounits'],
|
|
capture_output=True, text=True, timeout=15, check=True)
|
|
text = result.stdout.strip()
|
|
require(len(text) <= 16 and text.isdigit(), 'invalid GPU memory response')
|
|
available = int(text)
|
|
require(4096 <= available <= 1024 * 1024, 'GPU 0 needs at least 4096 MiB free')
|
|
return available
|
|
|
|
|
|
def command(args, seed, output):
|
|
require(seed in SEEDS, 'unsupported seed')
|
|
experiment = experiment_config(args)
|
|
encoder = experiment['encoder']
|
|
argv = [sys.executable, str(args.source_root / 'train.py'),
|
|
'--dataset', str(args.dataset), '--metadata', str(args.metadata), '--output', str(output),
|
|
'--mode', 'identity', '--identity-protocol', 'published_files', '--device', 'cuda',
|
|
'--seed', str(seed), '--hidden', '64', '--window', '128', '--stride', '8',
|
|
'--batch-size', '64', '--epochs', str(experiment['epochs']), '--max-steps', '100000', '--max-seconds', '900',
|
|
'--max-train-windows', '40000', '--max-eval-windows', '50000', '--threads', '2']
|
|
if encoder is not None:
|
|
argv.extend(['--encoder', encoder])
|
|
for name in ('representation', 'temporal_order'):
|
|
if experiment[name] is not None:
|
|
argv.extend(['--' + name.replace('_', '-'), experiment[name]])
|
|
return argv
|
|
|
|
|
|
def experiment_config(args):
|
|
result = {name: getattr(args, name, None) for name in ('encoder', 'representation', 'temporal_order')}
|
|
result['epochs'] = getattr(args, 'epochs', 200)
|
|
require(result['encoder'] in (None, 'lstm', 'gru'), 'unsupported encoder')
|
|
require(result['representation'] in (None, 'raw', 'demean'), 'unsupported representation')
|
|
require(result['temporal_order'] in (None, 'original', 'shuffle'), 'unsupported temporal order')
|
|
require(type(result['epochs']) is int and 1 <= result['epochs'] <= 200, 'epochs must be 1 through 200')
|
|
return result
|
|
|
|
|
|
def stop_child(child, process_group=False):
|
|
previous = {}
|
|
for name in ('SIGINT', 'SIGTERM', 'SIGHUP'):
|
|
sig = getattr(signal, name, None)
|
|
if sig is not None:
|
|
previous[sig] = signal.signal(sig, signal.SIG_IGN)
|
|
try:
|
|
def send(sig):
|
|
if process_group:
|
|
try:
|
|
os.killpg(child.pid, sig)
|
|
except ProcessLookupError:
|
|
pass
|
|
elif sig == signal.SIGTERM:
|
|
child.terminate()
|
|
else:
|
|
child.kill()
|
|
if child.poll() is None:
|
|
send(signal.SIGTERM)
|
|
try:
|
|
child.wait(timeout=20)
|
|
except subprocess.TimeoutExpired:
|
|
send(signal.SIGKILL if process_group else None)
|
|
child.wait(timeout=20)
|
|
else:
|
|
child.wait(timeout=1)
|
|
if process_group:
|
|
# The leader may have exited while descendants retain the output pipe.
|
|
send(signal.SIGTERM)
|
|
send(signal.SIGKILL)
|
|
finally:
|
|
for sig, handler in previous.items():
|
|
signal.signal(sig, handler)
|
|
|
|
|
|
def drain_log(pipe, log, errors, limit=MAX_LOG_BYTES):
|
|
written = 0
|
|
try:
|
|
while block := pipe.read(65536):
|
|
remaining = limit - written
|
|
log.write(block[:remaining])
|
|
written += min(len(block), remaining)
|
|
if len(block) > remaining:
|
|
errors.append('child log limit exceeded')
|
|
return
|
|
except (OSError, ValueError):
|
|
errors.append('child log write failed')
|
|
|
|
|
|
def monitor_child(child, log, process_group):
|
|
errors = []
|
|
reader = threading.Thread(target=drain_log, args=(child.stdout, log, errors), daemon=True)
|
|
reader.start()
|
|
deadline = time.monotonic() + CHILD_TIMEOUT
|
|
try:
|
|
while child.poll() is None:
|
|
require(not errors, errors[0] if errors else '')
|
|
if time.monotonic() >= deadline:
|
|
raise subprocess.TimeoutExpired('training child', CHILD_TIMEOUT)
|
|
time.sleep(0.2)
|
|
returncode = child.wait(timeout=1)
|
|
# Clean up any descendants before waiting for pipe EOF.
|
|
stop_child(child, process_group)
|
|
reader.join(timeout=5)
|
|
require(not reader.is_alive(), 'child log reader failed to stop')
|
|
require(not errors, errors[0] if errors else '')
|
|
return returncode
|
|
finally:
|
|
stop_child(child, process_group)
|
|
reader.join(timeout=5)
|
|
child.stdout.close()
|
|
|
|
|
|
def verify_inputs(args, state):
|
|
for name, path in (('trainer', args.source_root / 'train.py'),
|
|
('dataset', args.dataset), ('metadata', args.metadata)):
|
|
require(digest(path, 1024 ** (3 if name == 'dataset' else 2)) == state[name + '_sha256'],
|
|
name + ' changed during batch')
|
|
|
|
|
|
def verify_metrics(output, state, seed):
|
|
path = output / 'metrics.json'
|
|
require(path.is_file() and path.stat().st_size <= MAX_METRICS_BYTES, 'missing or oversized child metrics')
|
|
with path.open('rb') as stream:
|
|
raw = stream.read(MAX_METRICS_BYTES + 1)
|
|
require(len(raw) <= MAX_METRICS_BYTES, 'child metrics grew beyond limit')
|
|
metrics = json.loads(raw)
|
|
require(isinstance(metrics, dict) and metrics.get('status') == 'COMPLETED', 'child metrics incomplete')
|
|
for name in ('trainer', 'dataset', 'metadata'):
|
|
key = name + '_sha256'
|
|
require(metrics.get(key) == state[key], 'child metrics ' + key + ' mismatch')
|
|
require('test' in metrics and metrics['test'] is None, 'missing or unexpected final test evaluation')
|
|
require(metrics.get('mode') == 'identity' and metrics.get('test_evaluation_requested') is False,
|
|
'child must be validation-only identity training')
|
|
expected_config = {'seed': seed, 'mode': 'identity', 'identity_protocol': 'published_files',
|
|
'evaluate_test': False, 'device': 'cuda', 'hidden': 64, 'window': 128,
|
|
'stride': 8, 'batch_size': 64, 'max_steps': 100000, 'max_seconds': 900.0,
|
|
'max_train_windows': 40000, 'max_eval_windows': 50000, 'threads': 2}
|
|
config = metrics.get('config')
|
|
require(isinstance(config, dict), 'missing child configuration')
|
|
for key, expected in expected_config.items():
|
|
require(key in config and type(config[key]) is type(expected)
|
|
and config[key] == expected, 'child configuration mismatch: ' + key)
|
|
for key, expected in state.get('experiment', {}).items():
|
|
if expected is not None:
|
|
require(key in config and type(config[key]) is type(expected)
|
|
and config[key] == expected, 'child configuration mismatch: ' + key)
|
|
return hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
def run(args):
|
|
# Process groups bound descendants and inherited pipes. The Windows fallback
|
|
# in the cleanup helper is not equivalent; do not launch GPU batches there.
|
|
require(SUPPORTED_HOST, 'GPU batch supervision requires Linux process groups')
|
|
seeds = getattr(args, 'seeds', SEEDS)
|
|
require(isinstance(seeds, (list, tuple)) and 1 <= len(seeds) <= len(SEEDS)
|
|
and all(type(seed) is int and seed in SEEDS for seed in seeds)
|
|
and len(set(seeds)) == len(seeds), 'seeds must be a nonempty unique subset of the fixed seeds')
|
|
experiment = experiment_config(args)
|
|
encoder = experiment['encoder']
|
|
args.dataset, args.metadata = private_path(args.dataset), private_path(args.metadata)
|
|
args.run_root = private_path(args.run_root)
|
|
args.source_root = Path(args.source_root).expanduser().resolve()
|
|
require(args.dataset.is_file() and args.dataset.stat().st_size <= 1024 ** 3, 'invalid dataset file')
|
|
require(args.metadata.is_file() and args.metadata.stat().st_size <= 1024 ** 2, 'invalid metadata file')
|
|
trainer = args.source_root / 'train.py'
|
|
require(trainer.is_file() and trainer.stat().st_size <= 1024 ** 2, 'invalid trainer source')
|
|
os.umask(0o077)
|
|
args.run_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
lock = args.run_root / '.batch.lock'
|
|
state = {'status': 'STARTING', 'pid': os.getpid(), 'jobs': [], 'test_evaluation': False,
|
|
'requested_seeds': list(seeds),
|
|
'encoder_requested': encoder,
|
|
'experiment': experiment,
|
|
'trainer_sha256': digest(trainer), 'launcher_sha256': digest(Path(__file__)),
|
|
'dataset_sha256': digest(args.dataset), 'metadata_sha256': digest(args.metadata)}
|
|
lock.mkdir(mode=0o700) # Atomic and exclusive; a stale lock requires operator review.
|
|
path = args.run_root / ('batch-' + uuid.uuid4().hex)
|
|
state_path = path / 'batch-state.json'
|
|
old_signals = {}
|
|
def interrupted(signum, frame):
|
|
raise KeyboardInterrupt('batch interrupted')
|
|
try:
|
|
path.mkdir(mode=0o700)
|
|
(lock / 'owner.json').write_text(json.dumps({'pid': os.getpid(), 'batch': path.name}), encoding='utf-8')
|
|
for name in ('SIGINT', 'SIGTERM', 'SIGHUP'):
|
|
sig = getattr(signal, name, None)
|
|
if sig is not None:
|
|
old_signals[sig] = signal.signal(sig, interrupted)
|
|
save(state_path, state)
|
|
env = dict(os.environ, CUDA_VISIBLE_DEVICES='0', OMP_NUM_THREADS='2', MKL_NUM_THREADS='2', PYTHONUNBUFFERED='1')
|
|
for seed in seeds:
|
|
verify_inputs(args, state)
|
|
free = gpu_free()
|
|
output = path / f'seed-{seed}'
|
|
require(not output.exists(), 'job output already exists')
|
|
argv = command(args, seed, output)
|
|
job = {'seed': seed, 'command': argv, 'status': 'RUNNING', 'gpu_free_before_mib': free,
|
|
'started_unix': time.time(), 'external_timeout_seconds': CHILD_TIMEOUT,
|
|
'peak_vram_mib': None, 'peak_vram_note': 'Not sampled by launcher'}
|
|
state['jobs'].append(job)
|
|
state['status'] = 'RUNNING'
|
|
save(state_path, state)
|
|
try:
|
|
with (path / f'seed-{seed}.log').open('xb') as log:
|
|
# Store PID immediately after spawn so supervision does not wait for job completion.
|
|
process_group = os.name == 'posix'
|
|
child = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
env=env, shell=False, start_new_session=process_group)
|
|
job['pid'] = child.pid
|
|
try:
|
|
save(state_path, state)
|
|
job['returncode'] = monitor_child(child, log, process_group)
|
|
finally:
|
|
stop_child(child, process_group)
|
|
job['status'] = 'COMPLETED' if job['returncode'] == 0 else 'FAILED'
|
|
require(job['returncode'] == 0, 'training child failed')
|
|
verify_inputs(args, state)
|
|
job['metrics_sha256'] = verify_metrics(output, state, seed)
|
|
except BaseException as error:
|
|
job['status'] = 'FAILED'
|
|
job['error_type'] = type(error).__name__
|
|
raise
|
|
finally:
|
|
job['ended_unix'] = time.time()
|
|
save(state_path, state)
|
|
state['status'] = 'COMPLETED'
|
|
return state
|
|
except BaseException as error:
|
|
state['status'] = 'INCOMPLETE'
|
|
state['error_type'] = type(error).__name__
|
|
raise
|
|
finally:
|
|
for sig, previous in old_signals.items():
|
|
signal.signal(sig, previous)
|
|
if path.exists():
|
|
save(state_path, state)
|
|
owner = lock / 'owner.json'
|
|
if owner.exists():
|
|
owner.unlink()
|
|
lock.rmdir()
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
for name in ('dataset', 'metadata', 'source-root', 'run-root'):
|
|
parser.add_argument('--' + name, required=True)
|
|
parser.add_argument('--seeds', nargs='+', type=int, choices=SEEDS, default=list(SEEDS),
|
|
help='unique subset of the fixed seeds; default runs all three')
|
|
parser.add_argument('--encoder', choices=('lstm', 'gru'), default=None,
|
|
help='append an explicit encoder only when supplied; omission supports legacy trainers')
|
|
parser.add_argument('--representation', choices=('raw', 'demean'), default=None)
|
|
parser.add_argument('--temporal-order', choices=('original', 'shuffle'), default=None)
|
|
parser.add_argument('--epochs', type=int, default=200, help='bounded comparison budget, 1 through 200')
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
run(args)
|
|
return 0
|
|
except (ValueError, OSError, subprocess.SubprocessError, KeyboardInterrupt) as error:
|
|
print(json.dumps({'status': 'INCOMPLETE', 'error_type': type(error).__name__}), file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|