|
| 1 | +#!/usr/bin/env python |
| 2 | +"""Single-command driver for the hybrid pcg/bdsv TIMING session |
| 3 | +(`docs/open-tasks/hybrid_pcg_bdsv_plan_2026-07-07.md` §6.2-6.4). |
| 4 | +
|
| 5 | +Run from the repo root with a python that has pinocchio (the GRiD venv): |
| 6 | +
|
| 7 | + python examples/benchmarks/bdsv_timing_session.py # all phases, in order |
| 8 | + python examples/benchmarks/bdsv_timing_session.py --build # GATO_BDSV_THREADS variants |
| 9 | + python examples/benchmarks/bdsv_timing_session.py --kernel # §6.2 per-SQP-iter linsys A/B |
| 10 | + python examples/benchmarks/bdsv_timing_session.py --mpc # §6.3/6.4 fig8 modes × τ |
| 11 | + python examples/benchmarks/bdsv_timing_session.py --report # aggregate → markdown |
| 12 | + python examples/benchmarks/bdsv_timing_session.py --restore # put the pre-session .so back |
| 13 | +
|
| 14 | +§6.1 (GLASS-level characterization) is separate — standalone GLASS + the bs14 shapes patch; |
| 15 | +measured 2026-07-09 in `docs/open-tasks/glass_bs14_solvers_results_2026-07-09.md`. |
| 16 | +
|
| 17 | +Methodology guards baked in: refuses to time unless the GPU is idle; builds and timed legs |
| 18 | +never overlap (build phase completes and stashes .so variants first; timing only copies |
| 19 | +files); provenance (GPU clocks/temp, git SHA) logged per leg; every timed cell reports its |
| 20 | +spread so sub-noise deltas can't be over-read. The per-iteration linsys time comes from |
| 21 | +`SolverStats.pcg_times_us` (cudaEvent pair around the solvePCG/solveBDSV launch, collected |
| 22 | +only when stats collection is on). The default-flip decision is a HUMAN call — this script |
| 23 | +measures and reports, it does not change any default. |
| 24 | +""" |
| 25 | +import argparse |
| 26 | +import json |
| 27 | +import shutil |
| 28 | +import subprocess |
| 29 | +import sys |
| 30 | +import time |
| 31 | +from pathlib import Path |
| 32 | + |
| 33 | +ROOT = Path(__file__).resolve().parents[2] |
| 34 | +sys.path.insert(0, str(ROOT / "python")) |
| 35 | +OUT = ROOT / "examples" / "benchmarks" / "data" / "bdsv_timing" |
| 36 | +PLANT = "indy7" |
| 37 | +KNOTS = [32, 64] |
| 38 | +BATCHES = [1, 16, 64, 128] |
| 39 | +THREADS = [128, 256, 512] # GATO_BDSV_THREADS candidates (256 = current default) |
| 40 | +TAUS = [0.05, 0.10, 0.17, 0.35] # around the measured anchor 5×median(pred_err) ≈ 0.17 |
| 41 | +URDF = str(ROOT / "examples" / "indy7_description" / "indy7.urdf") |
| 42 | + |
| 43 | + |
| 44 | +# ─── shared helpers ────────────────────────────────────────────────────────── |
| 45 | + |
| 46 | +def sh(cmd, **kw): |
| 47 | + print(f" $ {' '.join(map(str, cmd))}") |
| 48 | + subprocess.run([str(c) for c in cmd], check=True, **kw) |
| 49 | + |
| 50 | + |
| 51 | +def module_sos(knots=KNOTS): |
| 52 | + pats = [f"bsqpN{n}_{PLANT}.*.so" for n in knots] |
| 53 | + return [p for pat in pats for p in sorted((ROOT / "python" / "gato").glob(pat))] |
| 54 | + |
| 55 | + |
| 56 | +def gpu_idle_or_die(): |
| 57 | + apps = subprocess.run( |
| 58 | + ["nvidia-smi", "--query-compute-apps=pid", "--format=csv,noheader"], |
| 59 | + capture_output=True, text=True).stdout.strip() |
| 60 | + if apps: |
| 61 | + sys.exit(f"REFUSING to time: GPU busy (compute pids: {apps.replace(chr(10), ' ')})") |
| 62 | + |
| 63 | + |
| 64 | +def provenance(tag): |
| 65 | + smi = subprocess.run( |
| 66 | + ["nvidia-smi", "--query-gpu=name,clocks.sm,temperature.gpu,driver_version", |
| 67 | + "--format=csv,noheader"], capture_output=True, text=True).stdout.strip() |
| 68 | + sha = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"], |
| 69 | + capture_output=True, text=True).stdout.strip() |
| 70 | + dirty = subprocess.run(["git", "-C", str(ROOT), "status", "--porcelain", |
| 71 | + "--ignore-submodules=untracked"], |
| 72 | + capture_output=True, text=True).stdout.strip() |
| 73 | + rec = {"tag": tag, "when": time.strftime("%F %T"), "gpu": smi, |
| 74 | + "gato": sha + ("+dirty" if dirty else "")} |
| 75 | + OUT.mkdir(parents=True, exist_ok=True) |
| 76 | + with open(OUT / "provenance.jsonl", "a") as f: |
| 77 | + f.write(json.dumps(rec) + "\n") |
| 78 | + print(f" [{tag}] {smi} | gato @{rec['gato']}") |
| 79 | + |
| 80 | + |
| 81 | +def child(args_list): |
| 82 | + """Run this script as a subprocess child; return its parsed JSON stdout.""" |
| 83 | + r = subprocess.run([sys.executable, __file__] + [str(a) for a in args_list], |
| 84 | + capture_output=True, text=True, cwd=ROOT) |
| 85 | + if r.returncode != 0: |
| 86 | + sys.exit(f"child {args_list} FAILED:\n{r.stdout}\n{r.stderr}") |
| 87 | + return json.loads(r.stdout.splitlines()[-1]) |
| 88 | + |
| 89 | + |
| 90 | +# ─── phase: build the GATO_BDSV_THREADS variants ───────────────────────────── |
| 91 | + |
| 92 | +def phase_build(): |
| 93 | + orig = OUT / "so_orig" |
| 94 | + if not orig.exists(): |
| 95 | + orig.mkdir(parents=True) |
| 96 | + for so in module_sos(): |
| 97 | + shutil.copy2(so, orig) |
| 98 | + print(f" stashed pre-session modules -> {orig}") |
| 99 | + for t in THREADS: |
| 100 | + bdir = ROOT / f"build_bdsv_t{t}" |
| 101 | + sh(["cmake", "-S", ROOT, "-B", bdir, f"-DKNOTS={';'.join(map(str, KNOTS))}", |
| 102 | + f"-DPLANT={PLANT}", "-DCMAKE_BUILD_TYPE=Release", |
| 103 | + f"-DPython3_EXECUTABLE={sys.executable}", |
| 104 | + "-DCMAKE_CUDA_ARCHITECTURES=120", |
| 105 | + f"-DCMAKE_CUDA_FLAGS=-DGATO_BDSV_THREADS={t}"], |
| 106 | + stdout=subprocess.DEVNULL) |
| 107 | + sh(["cmake", "--build", bdir, "--parallel", "4"]) |
| 108 | + stash = OUT / f"so_t{t}" |
| 109 | + stash.mkdir(parents=True, exist_ok=True) |
| 110 | + for so in module_sos(): |
| 111 | + shutil.copy2(so, stash) |
| 112 | + print(f" T={t}: stashed {len(module_sos())} modules -> {stash}") |
| 113 | + |
| 114 | + |
| 115 | +def install_variant(name): |
| 116 | + src = OUT / name |
| 117 | + for so in sorted(src.glob("*.so")): |
| 118 | + shutil.copy2(so, ROOT / "python" / "gato") |
| 119 | + |
| 120 | + |
| 121 | +# ─── phase: kernel-level A/B (child does one (N, B) cell, both modes) ──────── |
| 122 | + |
| 123 | +def phase_kernel(): |
| 124 | + gpu_idle_or_die() |
| 125 | + provenance("kernel") |
| 126 | + rows = [] |
| 127 | + variants = [f"so_t{t}" for t in THREADS if (OUT / f"so_t{t}").exists()] or ["so_orig"] |
| 128 | + for var in variants: |
| 129 | + install_variant(var) |
| 130 | + for n in KNOTS: |
| 131 | + for b in BATCHES: |
| 132 | + row = child(["--child-kernel", n, b]) |
| 133 | + row.update(variant=var) |
| 134 | + rows.append(row) |
| 135 | + print(f" {var} N={n:>3} B={b:>3}: " |
| 136 | + f"pcg {row['pcg_us_med']:.1f}us (it~{row['pcg_iters_med']:.0f}) " |
| 137 | + f"vs bdsv {row['bdsv_us_med']:.1f}us " |
| 138 | + f"[spreads {row['pcg_us_iqr']:.1f}/{row['bdsv_us_iqr']:.1f}]") |
| 139 | + install_variant("so_t256" if (OUT / "so_t256").exists() else "so_orig") |
| 140 | + (OUT / "kernel_results.json").write_text(json.dumps(rows, indent=1)) |
| 141 | + print(f"==> wrote {OUT / 'kernel_results.json'}") |
| 142 | + |
| 143 | + |
| 144 | +def child_kernel(n, b): |
| 145 | + import numpy as np |
| 146 | + from gato.interface import BSQP |
| 147 | + from gato.config import DEFAULT_SOLVER_PARAMS as SP |
| 148 | + solver = BSQP(model_path=URDF, batch_size=b, N=n, dt=0.01, plant_type=PLANT, |
| 149 | + **{**SP, "max_sqp_iters": 5, "rho": 1e-3}) |
| 150 | + rng = np.random.default_rng(0) |
| 151 | + x = np.zeros((b, solver.nx), dtype=np.float32) |
| 152 | + x[:, :solver.nq] = rng.uniform(-0.4, 0.4, (b, solver.nq)).astype(np.float32) |
| 153 | + g = np.tile(np.concatenate([rng.uniform(0.2, 0.5, 3), np.zeros(3)]) |
| 154 | + .astype(np.float32), (b, n)) |
| 155 | + out = {"N": n, "B": b} |
| 156 | + for mode in ("pcg", "bdsv"): |
| 157 | + solver.set_linsys(mode) |
| 158 | + times, iters = [], [] |
| 159 | + for r in range(13): # 3 warmup + 10 measured |
| 160 | + solver.solver.reset_dual(); solver.solver.reset_rho() |
| 161 | + solver.XU_B = np.zeros_like(solver.XU_B) |
| 162 | + res = solver.solve(x.copy(), g.copy()) |
| 163 | + if r >= 3: |
| 164 | + times += list(res.stats.pcg_times_us) # one entry per SQP iter |
| 165 | + iters += list(res.stats.pcg_iters.reshape(-1)) |
| 166 | + t = np.asarray(times) |
| 167 | + out[f"{mode}_us_med"] = float(np.median(t)) |
| 168 | + out[f"{mode}_us_iqr"] = float(np.percentile(t, 75) - np.percentile(t, 25)) |
| 169 | + out[f"{mode}_iters_med"] = float(np.median(iters)) |
| 170 | + print(json.dumps(out)) |
| 171 | + |
| 172 | + |
| 173 | +# ─── phase: end-to-end MPC fig8, modes × τ, nominal + perturbed ────────────── |
| 174 | + |
| 175 | +def phase_mpc(): |
| 176 | + gpu_idle_or_die() |
| 177 | + provenance("mpc") |
| 178 | + install_variant("so_t256" if (OUT / "so_t256").exists() else "so_orig") |
| 179 | + rows = [] |
| 180 | + cells = [("pcg", 0.0), ("bdsv", 0.0), ("bdsv_first", 0.0)] + \ |
| 181 | + [("auto", tau) for tau in TAUS] |
| 182 | + for perturb in (0, 25): # 0 = nominal; else perturb every K steps |
| 183 | + for mode, tau in cells: |
| 184 | + row = child(["--child-mpc", mode, tau, perturb]) |
| 185 | + rows.append(row) |
| 186 | + print(f" {'nominal' if not perturb else f'perturb/{perturb}'} " |
| 187 | + f"{mode}{f'(t={tau})' if mode == 'auto' else '':<8}: " |
| 188 | + f"solve p50 {row['solve_ms_p50']:.3f}ms p95 {row['solve_ms_p95']:.3f}ms | " |
| 189 | + f"track mean {row['track_mean']:.4f} max {row['track_max']:.4f} | " |
| 190 | + f"iters p50 {row['iters_p50']:.0f}") |
| 191 | + (OUT / "mpc_results.json").write_text(json.dumps(rows, indent=1)) |
| 192 | + print(f"==> wrote {OUT / 'mpc_results.json'}") |
| 193 | + |
| 194 | + |
| 195 | +def child_mpc(mode, tau, perturb_every): |
| 196 | + import numpy as np |
| 197 | + import pinocchio as pin |
| 198 | + from gato.mpc_gato import MPC_GATO |
| 199 | + from gato.controller import MPCController |
| 200 | + from gato.common import figure8 |
| 201 | + from gato.config import INDY7_START_CONFIGS, FIG8_DEFAULT_PARAMS |
| 202 | + N, DT = 64, 0.01 |
| 203 | + model = pin.buildModelFromUrdf(URDF) |
| 204 | + mpc = MPC_GATO(model, model_path=URDF, N=N, dt=DT, batch_size=1, plant_type=PLANT) |
| 205 | + kw = {"linsys": mode} if mode != "pcg" else {} # pcg == solver default path |
| 206 | + if mode == "auto": |
| 207 | + kw["bdsv_threshold"] = tau |
| 208 | + mpc.controller = MPCController(mpc.solver, hypotheses=mpc.controller.hypotheses, |
| 209 | + warm_start="shift", reset_rho_each_step=True, **kw) |
| 210 | + |
| 211 | + iters, pred_errs = [], [] |
| 212 | + rng = np.random.default_rng(7) |
| 213 | + orig_step = mpc.controller.step |
| 214 | + nq = mpc.solver.nq |
| 215 | + state = {"k": 0} |
| 216 | + |
| 217 | + def step(x, g, **skw): |
| 218 | + state["k"] += 1 |
| 219 | + if perturb_every and state["k"] % perturb_every == 0: |
| 220 | + x = x.copy() |
| 221 | + x[:nq] += rng.normal(0.0, 0.05, nq) # seeded joint-position kick |
| 222 | + r = orig_step(x, g, **skw) |
| 223 | + iters.append(np.asarray(r.solve.stats.pcg_iters).reshape(-1)) |
| 224 | + pred_errs.append(r.pred_err) |
| 225 | + return r |
| 226 | + |
| 227 | + mpc.controller.step = step |
| 228 | + xs = np.hstack((INDY7_START_CONFIGS["ready"], np.zeros(mpc.solver.nx - 6))) |
| 229 | + fig8 = figure8(DT, **FIG8_DEFAULT_PARAMS) |
| 230 | + _, stats = mpc.run_mpc_fig8(xs, fig8, sim_dt=0.001, sim_time=3.0, |
| 231 | + pace_by_solve_time=False) # fixed pacing: deterministic |
| 232 | + st = np.asarray(stats["solve_times"], dtype=float) |
| 233 | + gd = np.asarray(stats["goal_distances"], dtype=float) |
| 234 | + it = np.concatenate(iters) |
| 235 | + print(json.dumps({ |
| 236 | + "mode": mode, "tau": tau, "perturb_every": perturb_every, |
| 237 | + "steps": int(len(st)), |
| 238 | + "solve_ms_p50": float(np.percentile(st, 50)), |
| 239 | + "solve_ms_p95": float(np.percentile(st, 95)), |
| 240 | + "track_mean": float(gd.mean()), "track_max": float(gd.max()), |
| 241 | + "iters_p50": float(np.percentile(it, 50)), |
| 242 | + "iters_p95": float(np.percentile(it, 95)), |
| 243 | + "iters_hist": {str(k): int((it == k).sum()) for k in np.unique(it)[:12]}, |
| 244 | + "pred_err_med": float(np.median(pred_errs)), |
| 245 | + })) |
| 246 | + |
| 247 | + |
| 248 | +# ─── phase: report ──────────────────────────────────────────────────────────── |
| 249 | + |
| 250 | +def phase_report(): |
| 251 | + lines = ["# bdsv timing session — measured results", "", |
| 252 | + f"_Generated {time.strftime('%F %T')} by bdsv_timing_session.py; " |
| 253 | + f"provenance in `data/bdsv_timing/provenance.jsonl`. Default-flip is a " |
| 254 | + f"human decision — see plan §6.5._", ""] |
| 255 | + kj = OUT / "kernel_results.json" |
| 256 | + if kj.exists(): |
| 257 | + rows = json.loads(kj.read_text()) |
| 258 | + lines += ["## §6.2 kernel-level: per-SQP-iteration linsys time (µs, median of " |
| 259 | + "50 iters; IQR in parens)", "", |
| 260 | + "| variant | N | B | pcg µs | pcg iters | bdsv µs | pcg/bdsv |", |
| 261 | + "|---------|---|---|--------|-----------|---------|----------|"] |
| 262 | + for r in rows: |
| 263 | + lines.append( |
| 264 | + f"| {r['variant']} | {r['N']} | {r['B']} " |
| 265 | + f"| {r['pcg_us_med']:.1f} ({r['pcg_us_iqr']:.1f}) " |
| 266 | + f"| {r['pcg_iters_med']:.0f} " |
| 267 | + f"| {r['bdsv_us_med']:.1f} ({r['bdsv_us_iqr']:.1f}) " |
| 268 | + f"| {r['pcg_us_med'] / r['bdsv_us_med']:.2f} |") |
| 269 | + lines.append("") |
| 270 | + mj = OUT / "mpc_results.json" |
| 271 | + if mj.exists(): |
| 272 | + rows = json.loads(mj.read_text()) |
| 273 | + lines += ["## §6.3/6.4 end-to-end fig8 (indy7 N=64 M=1, fixed pacing, 3s)", "", |
| 274 | + "| run | mode | τ | solve p50 ms | p95 ms | track mean | track max " |
| 275 | + "| iters p50/p95 |", |
| 276 | + "|-----|------|---|--------------|--------|------------|-----------" |
| 277 | + "|---------------|"] |
| 278 | + for r in rows: |
| 279 | + run = "nominal" if not r["perturb_every"] else f"perturb/{r['perturb_every']}" |
| 280 | + lines.append( |
| 281 | + f"| {run} | {r['mode']} | {r['tau'] or ''} " |
| 282 | + f"| {r['solve_ms_p50']:.3f} | {r['solve_ms_p95']:.3f} " |
| 283 | + f"| {r['track_mean']:.4f} | {r['track_max']:.4f} " |
| 284 | + f"| {r['iters_p50']:.0f}/{r['iters_p95']:.0f} |") |
| 285 | + lines.append("") |
| 286 | + md = OUT / "BDSV_TIMING_RESULTS.md" |
| 287 | + md.write_text("\n".join(lines)) |
| 288 | + print(f"==> wrote {md}") |
| 289 | + |
| 290 | + |
| 291 | +def phase_restore(): |
| 292 | + if (OUT / "so_orig").exists(): |
| 293 | + install_variant("so_orig") |
| 294 | + print("restored pre-session modules") |
| 295 | + |
| 296 | + |
| 297 | +# ─── entry ──────────────────────────────────────────────────────────────────── |
| 298 | + |
| 299 | +if __name__ == "__main__": |
| 300 | + ap = argparse.ArgumentParser() |
| 301 | + for f in ("build", "kernel", "mpc", "report", "restore"): |
| 302 | + ap.add_argument(f"--{f}", action="store_true") |
| 303 | + ap.add_argument("--child-kernel", nargs=2, type=int) |
| 304 | + ap.add_argument("--child-mpc", nargs=3) |
| 305 | + a = ap.parse_args() |
| 306 | + if a.child_kernel: |
| 307 | + child_kernel(*a.child_kernel) |
| 308 | + elif a.child_mpc: |
| 309 | + child_mpc(a.child_mpc[0], float(a.child_mpc[1]), int(a.child_mpc[2])) |
| 310 | + elif not any((a.build, a.kernel, a.mpc, a.report, a.restore)): |
| 311 | + phase_build(); phase_kernel(); phase_mpc(); phase_report() |
| 312 | + else: |
| 313 | + if a.build: |
| 314 | + phase_build() |
| 315 | + if a.kernel: |
| 316 | + phase_kernel() |
| 317 | + if a.mpc: |
| 318 | + phase_mpc() |
| 319 | + if a.report: |
| 320 | + phase_report() |
| 321 | + if a.restore: |
| 322 | + phase_restore() |
0 commit comments