Totoro 16d36ccd21
feat(sandbox): add bounded AIO prewarming and lifecycle benchmarks (#6998)
* fix(benchmark): validate sandbox lifecycle and concurrent measurements

* feat(sandbox): prewarm likely AIO tool turns

(cherry picked from commit 3a1017ed032ed9331987bc3dc61f23dcdac4e905)

* fix(sandbox): bind bounded prewarming to execution leases

* docs: trim frontend guidance below its existing size limit

* fix(benchmark): separate failed lifecycle stage timings

* test(sandbox): measure prewarm overlap and unused resources through agent graphs

* fix(sandbox): finish lease and sampler cleanup after cancellation or errors

* docs(benchmark): distinguish initialization wait from model overlap

* docs(helm): align the configuration example with version 61

* fix(sandbox): preserve synchronous agent graphs when prewarming is enabled

* test(cargo): isolate native fixtures from inherited make jobserver flags

* fix(sandbox): require managed cleanup before prewarming

* fix(sandbox): log denied prewarming as an expected skip

* fix(benchmark): filter provider summary input records

---------

Co-authored-by: nothingyuancando <1579035512@qq.com>
2026-10-18 08:10:56 +08:00

269 lines
12 KiB
Python

#!/usr/bin/env python3
"""Sample resources for a uniquely prefixed Docker benchmark run, without mutation.
Run from ``backend`` alongside the benchmark::
python -m scripts.benchmark.sandbox.resources \
--prefix deerflow-bench-<run-id>- --output resources.jsonl --duration 60
The caller must reserve a unique prefix and use it for benchmark containers.
Only matching *running* containers are measured. An empty successful inventory
means zero; unavailable, partial or invalid measurements remain unknown (null).
Peaks are peaks of samples, not guaranteed process maxima. Docker's reported
memory excludes cache on Linux; CPU is Docker's percentage and may exceed 100.
This does not measure Docker VM overhead or assign active/warm lifecycle states.
Use the provider benchmark's lifecycle records alongside these samples.
"""
from __future__ import annotations
import argparse
import json
import math
import re
import subprocess
import threading
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, TextIO
def _validate_prefix(prefix: str) -> None:
if not isinstance(prefix, str) or len(prefix) < 8 or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]*-", prefix):
raise ValueError("Use a unique container prefix of at least 8 characters ending in '-'; only Docker name characters are allowed")
def parse_bytes(value: str) -> int | None:
"""Parse Docker's decimal SI and binary IEC memory values; invalid is unknown."""
match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([kKmMgGtTpP]?[iI]?[bB])\s*", value)
if match is None:
return None
number = float(match.group(1))
unit = match.group(2).lower()
power = "bkmgtp".index(unit[0]) if unit != "b" else 0
amount = number * (1024 if "i" in unit else 1000) ** power
return int(amount) if math.isfinite(amount) else None
def _cpu_percent(value: str) -> float | None:
if not re.fullmatch(r"\d+(?:\.\d+)?%", value.strip()):
return None
result = float(value.strip()[:-1])
return result if math.isfinite(result) else None
def _docker(command: list[str], timeout: float) -> tuple[list[dict[str, Any]] | None, str | None]:
"""Return only selected JSON fields downstream; never persist CLI error output."""
try:
process = subprocess.run(["docker", *command], check=True, capture_output=True, encoding="utf-8", errors="replace", timeout=timeout)
except subprocess.TimeoutExpired:
return None, f"docker_{command[0]}_timeout"
except subprocess.CalledProcessError:
return None, f"docker_{command[0]}_failed"
except OSError:
return None, f"docker_{command[0]}_unavailable"
try:
rows = [json.loads(line) for line in process.stdout.splitlines() if line.strip()]
except (ValueError, TypeError):
return None, f"invalid_{command[0]}_json"
if any(not isinstance(row, dict) for row in rows):
return None, f"invalid_{command[0]}_json"
return rows, None
def collect_docker_sample(prefix: str, *, command_timeout: float = 5.0) -> dict[str, Any]:
"""Inventory exact name-prefix matches, then request stats for their IDs only."""
_validate_prefix(prefix)
if not math.isfinite(command_timeout) or command_timeout <= 0:
raise ValueError("command_timeout must be finite and positive")
started = time.monotonic()
sample: dict[str, Any] = {
"record_type": "resource_sample",
"timestamp_utc": datetime.now(UTC).isoformat(),
"monotonic_s": started,
"container_prefix": prefix,
"matched_containers": None,
"containers": [],
"total_memory_bytes": None,
"total_cpu_percent": None,
"errors": [],
}
def finish() -> dict[str, Any]:
sample["collection_seconds"] = time.monotonic() - started
return sample
inventory, error = _docker(["ps", "--no-trunc", "--format", "{{json .}}"], command_timeout)
if inventory is None:
sample["errors"].append(error)
return finish()
selected: dict[str, str] = {}
for row in inventory:
name = row.get("Names")
if not isinstance(name, str):
sample["errors"].append("invalid_inventory")
return finish()
if not name.startswith(prefix):
continue
container_id = row.get("ID")
if not isinstance(container_id, str) or not re.fullmatch(r"[a-f0-9]{64}", container_id) or container_id in selected:
sample["errors"].append("invalid_inventory")
return finish()
selected[container_id] = name
sample["matched_containers"] = len(selected)
if not selected:
sample["total_memory_bytes"] = 0
sample["total_cpu_percent"] = 0.0
return finish()
# An empty argument list means "all containers" to docker stats. Never call it
# until exact local selection has produced at least one validated ID.
stats, error = _docker(["stats", "--no-stream", "--format", "{{json .}}", "--", *selected], command_timeout)
if stats is None:
sample["errors"].append(error)
stats = []
for container_id, name in selected.items():
# Docker stats normally abbreviates ID even though inventory is no-trunc.
matching = [row for row in stats if isinstance(row.get("ID"), str) and re.fullmatch(r"[a-f0-9]{12,64}", row["ID"]) and container_id.startswith(row["ID"])]
stat = matching[0] if len(matching) == 1 else {}
usage = str(stat.get("MemUsage", "")).split("/")
memory = parse_bytes(usage[0]) if len(usage) == 2 else None
limit = parse_bytes(usage[1]) if len(usage) == 2 else None
cpu = _cpu_percent(str(stat.get("CPUPerc", "")))
if memory is None or limit is None or cpu is None:
sample["errors"].append("missing_or_invalid_container_stats")
sample["containers"].append({"container_id": container_id, "name": name, "memory_bytes": memory, "memory_limit_bytes": limit, "cpu_percent": cpu})
containers = sample["containers"]
if all(item["memory_bytes"] is not None for item in containers):
sample["total_memory_bytes"] = sum(item["memory_bytes"] for item in containers)
if all(item["cpu_percent"] is not None for item in containers):
sample["total_cpu_percent"] = sum(item["cpu_percent"] for item in containers)
sample["errors"] = list(dict.fromkeys(sample["errors"]))
return finish()
class DockerResourceSampler:
"""Background sampler with drain-on-exit and an exclusively created JSONL file."""
def __init__(self, prefix: str, output: str | Path, *, interval: float = 1.0, command_timeout: float = 5.0):
_validate_prefix(prefix)
if not all(math.isfinite(value) and value > 0 for value in (interval, command_timeout)):
raise ValueError("interval and command_timeout must be finite and positive")
self.prefix = prefix
self.output = Path(output)
self.interval = interval
self.command_timeout = command_timeout
self._stop = threading.Event()
self._first_sample = threading.Event()
self._thread: threading.Thread | None = None
self._file: TextIO | None = None
self._error: BaseException | None = None
self.summary: dict[str, Any] = {
"record_type": "resource_summary",
"container_prefix": prefix,
"sample_count": 0,
"incomplete_samples": 0,
"peak_sampled_total_memory_bytes": None,
"peak_sampled_total_cpu_percent": None,
"max_sampled_containers": None,
"interval_seconds": interval,
"peak_semantics": "maximum observed sample; not a guaranteed peak or host/VM total",
}
@property
def running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def _write(self, value: dict[str, Any]) -> None:
assert self._file is not None
self._file.write(json.dumps(value, allow_nan=False) + "\n")
self._file.flush()
def _run(self) -> None:
try:
while not self._stop.is_set():
sample = collect_docker_sample(self.prefix, command_timeout=self.command_timeout)
self._write(sample)
self.summary["sample_count"] += 1
self.summary["incomplete_samples"] += bool(sample["errors"])
for field, target in (("total_memory_bytes", "peak_sampled_total_memory_bytes"), ("total_cpu_percent", "peak_sampled_total_cpu_percent"), ("matched_containers", "max_sampled_containers")):
value = sample[field]
previous = self.summary[target]
if value is not None and (previous is None or value > previous):
self.summary[target] = value
self._first_sample.set()
self._stop.wait(self.interval)
except BaseException as exc:
self._error = exc
finally:
self._first_sample.set()
def start(self) -> DockerResourceSampler:
if self._thread is not None:
raise RuntimeError("A sampler instance can only be started once")
self._file = self.output.open("x", encoding="utf-8")
self._thread = threading.Thread(target=self._run, name="sandbox-resource-sampler")
try:
self._thread.start()
except BaseException:
self._file.close()
self._file = None
raise
return self
def wait_for_first_sample(self, timeout: float | None = None) -> bool:
"""Optional synchronization before a workload; never starts Docker resources."""
return self._first_sample.wait(timeout)
def stop(self) -> dict[str, Any]:
if self._thread is None or self._file is None:
return self.summary
self._stop.set()
# Each subprocess has a timeout. Drain the in-flight sample before closing
# its output; a stop must not leave the writer or a Docker client running.
self._thread.join()
try:
self.summary["timestamp_utc"] = datetime.now(UTC).isoformat()
self.summary["monotonic_s"] = time.monotonic()
self.summary["sampler_failed"] = self._error is not None
self._write(self.summary)
finally:
self._file.close()
self._file = None
if self._error is not None:
raise RuntimeError("Resource sampler failed; measurements are incomplete") from self._error
return self.summary
def __enter__(self) -> DockerResourceSampler:
return self.start()
def __exit__(self, exc_type, exc_value, traceback) -> None:
self.stop()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--prefix", required=True, help="Unique benchmark container prefix, ending in '-'")
parser.add_argument("--output", required=True, type=Path, help="New JSONL file; existing files are never overwritten")
parser.add_argument("--interval", type=float, default=1.0, help="Seconds between completed samples")
parser.add_argument("--command-timeout", type=float, default=5.0)
parser.add_argument("--duration", type=float, required=True, help="Bounded measurement duration in seconds")
args = parser.parse_args(argv)
if not math.isfinite(args.duration) or args.duration <= 0:
parser.error("duration must be finite and positive")
try:
sampler = DockerResourceSampler(args.prefix, args.output, interval=args.interval, command_timeout=args.command_timeout)
except ValueError as exc:
parser.error(str(exc))
try:
with sampler:
threading.Event().wait(args.duration)
except KeyboardInterrupt:
return 130
return 0
if __name__ == "__main__":
raise SystemExit(main())