Files
2026-07-13 12:09:03 +08:00

328 lines
11 KiB
Python

"""Experiment runner: subprocess sandbox with timeout, memory poller, ablation table.
Conceptual references:
- ./docs/en.md (this lesson)
- Phase 19 Track A lessons 20-29 (agent harness primitives)
Stdlib only. Run: python3 code/main.py
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Iterable
_LOGGER = logging.getLogger(__name__)
_MEMORY_POLLER_UNSUPPORTED_WARNED = False
@dataclass
class ExperimentSpec:
spec_id: str
hypothesis_id: int
script_path: str
config: dict
seed: int = 0
wall_timeout_s: float = 30.0
memory_cap_mb: int = 512
metric_keys: list[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"spec_id": self.spec_id,
"hypothesis_id": self.hypothesis_id,
"script_path": self.script_path,
"config": dict(self.config),
"seed": self.seed,
"wall_timeout_s": self.wall_timeout_s,
"memory_cap_mb": self.memory_cap_mb,
"metric_keys": list(self.metric_keys),
}
@dataclass
class ExperimentResult:
spec_id: str
hypothesis_id: int
exit_code: int
terminal: str
wall_time_s: float
peak_rss_mb: float | None
metrics: dict
intermediate_metrics: list[dict]
stdout_tail: str
stderr_tail: str
def to_dict(self) -> dict:
return {
"spec_id": self.spec_id,
"hypothesis_id": self.hypothesis_id,
"exit_code": self.exit_code,
"terminal": self.terminal,
"wall_time_s": round(self.wall_time_s, 4),
"peak_rss_mb": None if self.peak_rss_mb is None else round(self.peak_rss_mb, 2),
"metrics": dict(self.metrics),
"intermediate_metrics": [dict(m) for m in self.intermediate_metrics],
"stdout_tail": self.stdout_tail[-400:],
"stderr_tail": self.stderr_tail[-400:],
}
def _rss_mb(pid: int) -> float | None:
"""Best effort RSS read in MB. Returns None on unsupported platforms."""
proc_status = f"/proc/{pid}/status"
if os.path.exists(proc_status):
try:
with open(proc_status, "rt", encoding="utf-8") as fh:
for line in fh:
if line.startswith("VmRSS:"):
parts = line.split()
if len(parts) >= 2:
return float(parts[1]) / 1024.0
except OSError:
return None
return 0.0
if shutil.which("ps"):
try:
out = subprocess.run(
["ps", "-o", "rss=", "-p", str(pid)],
capture_output=True, text=True, timeout=2.0,
)
value = out.stdout.strip()
if value:
return float(value) / 1024.0
except (OSError, subprocess.SubprocessError, ValueError):
return None
return None
class _MemoryPoller(threading.Thread):
"""Polls subprocess RSS in MB; kills the process if it crosses the cap."""
def __init__(self, proc: subprocess.Popen, cap_mb: int, interval_s: float = 0.05) -> None:
super().__init__(daemon=True)
self._proc = proc
self._cap = cap_mb
self._interval = interval_s
self._stop_event = threading.Event()
self.peak_rss_mb: float | None = None
self.killed_for_oom = False
self.unsupported = False
def stop(self) -> None:
self._stop_event.set()
def run(self) -> None:
global _MEMORY_POLLER_UNSUPPORTED_WARNED
while not self._stop_event.is_set() and self._proc.poll() is None:
rss = _rss_mb(self._proc.pid)
if rss is None:
self.unsupported = True
if not _MEMORY_POLLER_UNSUPPORTED_WARNED:
_MEMORY_POLLER_UNSUPPORTED_WARNED = True
_LOGGER.warning(
"memory poller disabled: platform does not expose RSS via /proc or ps; wall clock timeout still applies",
)
return
self.peak_rss_mb = rss if self.peak_rss_mb is None else max(self.peak_rss_mb, rss)
if rss > self._cap:
self.killed_for_oom = True
try:
self._proc.kill()
except OSError:
pass
return
self._stop_event.wait(self._interval)
def _scan_intermediates(stdout: str, metric_keys: list[str]) -> tuple[dict, list[dict]]:
"""Walk stdout lines and pull every json line whose keys cover metric_keys.
The last covering line is treated as the final metrics. Earlier lines are
returned as intermediates so the evaluator can plot learning curves.
"""
intermediates: list[dict] = []
final: dict = {}
required = set(metric_keys)
for raw_line in stdout.splitlines():
line = raw_line.strip()
if not line.startswith("{"):
continue
try:
parsed = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(parsed, dict):
continue
if required and not required.issubset(parsed.keys()):
continue
if final:
intermediates.append(final)
final = parsed
return final, intermediates
class ExperimentRunner:
"""Spawn a subprocess, enforce timeout and memory cap, return an ExperimentResult."""
def __init__(self, python_path: str | None = None, poll_interval_s: float = 0.05) -> None:
self._python = python_path or sys.executable
self._poll_interval = poll_interval_s
def run(self, spec: ExperimentSpec) -> ExperimentResult:
with tempfile.TemporaryDirectory(prefix="exp_") as workdir:
config_path = os.path.join(workdir, "config.json")
merged_config = dict(spec.config)
merged_config["__seed"] = spec.seed
with open(config_path, "w", encoding="utf-8") as fh:
json.dump(merged_config, fh)
return self._run_subprocess(spec, config_path)
def _run_subprocess(self, spec: ExperimentSpec, config_path: str) -> ExperimentResult:
start = time.perf_counter()
try:
proc = subprocess.Popen(
[self._python, spec.script_path, config_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
except OSError as exc:
wall = time.perf_counter() - start
return ExperimentResult(
spec_id=spec.spec_id,
hypothesis_id=spec.hypothesis_id,
exit_code=-1,
terminal="crash",
wall_time_s=wall,
peak_rss_mb=None,
metrics={},
intermediate_metrics=[],
stdout_tail="",
stderr_tail=str(exc),
)
poller = _MemoryPoller(proc, spec.memory_cap_mb, self._poll_interval)
poller.start()
killed_for_timeout = False
try:
stdout, stderr = proc.communicate(timeout=spec.wall_timeout_s)
except subprocess.TimeoutExpired:
killed_for_timeout = True
try:
proc.kill()
except OSError:
pass
try:
stdout, stderr = proc.communicate(timeout=2.0)
except subprocess.TimeoutExpired:
stdout, stderr = "", ""
finally:
poller.stop()
poller.join(timeout=1.0)
wall = time.perf_counter() - start
metrics, intermediates = _scan_intermediates(stdout, spec.metric_keys)
terminal = self._terminal_label(proc.returncode, killed_for_timeout, poller.killed_for_oom, bool(metrics))
return ExperimentResult(
spec_id=spec.spec_id,
hypothesis_id=spec.hypothesis_id,
exit_code=proc.returncode if proc.returncode is not None else -1,
terminal=terminal,
wall_time_s=wall,
peak_rss_mb=poller.peak_rss_mb,
metrics=metrics,
intermediate_metrics=intermediates,
stdout_tail=stdout,
stderr_tail=stderr,
)
@staticmethod
def _terminal_label(exit_code: int | None, timed_out: bool, oomed: bool, have_metrics: bool) -> str:
if oomed:
return "oom"
if timed_out:
return "timeout"
if exit_code == 0 and have_metrics:
return "ok"
if exit_code == 0 and not have_metrics:
return "crash"
return "crash"
def ablate(base: ExperimentSpec, knob: str, values: Iterable[Any]) -> list[ExperimentSpec]:
specs: list[ExperimentSpec] = []
for value in values:
derived_config = dict(base.config)
derived_config[knob] = value
specs.append(ExperimentSpec(
spec_id=f"{base.spec_id}_{knob}_{value}",
hypothesis_id=base.hypothesis_id,
script_path=base.script_path,
config=derived_config,
seed=base.seed,
wall_timeout_s=base.wall_timeout_s,
memory_cap_mb=base.memory_cap_mb,
metric_keys=list(base.metric_keys),
))
return specs
@dataclass
class AblationTable:
knob: str
rows: list[tuple[Any, ExperimentResult]]
def to_dict(self) -> dict:
return {
"knob": self.knob,
"rows": [{"value": v, "result": r.to_dict()} for v, r in self.rows],
}
class AblationRunner:
def __init__(self, runner: ExperimentRunner) -> None:
self._runner = runner
def sweep(self, base: ExperimentSpec, knob: str, values: Iterable[Any]) -> AblationTable:
value_list = list(values)
rows: list[tuple[Any, ExperimentResult]] = []
for value, spec in zip(value_list, ablate(base, knob, value_list)):
result = self._runner.run(spec)
rows.append((value, result))
return AblationTable(knob=knob, rows=rows)
def _demo() -> None:
here = os.path.dirname(os.path.abspath(__file__))
script = os.path.join(here, "experiments", "sparsity_experiment.py")
base = ExperimentSpec(
spec_id="demo_base",
hypothesis_id=1,
script_path=script,
config={"k": 16, "steps": 4, "sleep_s": 0.0},
seed=7,
wall_timeout_s=15.0,
memory_cap_mb=256,
metric_keys=["perplexity", "final_loss"],
)
runner = ExperimentRunner()
base_result = runner.run(base)
sweep = AblationRunner(runner).sweep(base, "k", [4, 8, 16, 32])
print(json.dumps({
"base": base_result.to_dict(),
"ablation": sweep.to_dict(),
}, indent=2))
if __name__ == "__main__":
_demo()