Skip to content

Commit ac12626

Browse files
committed
wire per-iteration linsys timing into pcg_times_us + bdsv timing-session driver
1 parent c0460e0 commit ac12626

3 files changed

Lines changed: 335 additions & 2 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,6 @@ docs/open-tasks/
7171
# python bytecode caches
7272
__pycache__/
7373
examples/paper-figures/overnight_logs/
74+
75+
# bdsv timing session module stashes (rebuilt artifacts)
76+
examples/benchmarks/data/bdsv_timing/so_*/
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
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()

gato/bsqp/bsqp.cuh

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,13 @@ class BSQP {
149149
// path byte-identical; mode 2 (BDSV_FIRST) takes the exact solve on the
150150
// first linearization only — its λ then warm-starts PCG.
151151
const bool use_bdsv = (linsys_mode_ == 1) || (linsys_mode_ == 2 && i == 0);
152+
if (collect_stats_) { gpuErrchk(cudaEventRecord(pcg_start_event_)); }
152153
if (use_bdsv) {
153154
solveBDSVBatched<T>(batch_size_, d_lambda_batch_, schur_system_batch_, d_kkt_converged_batch_, d_pcg_iterations_);
154155
} else {
155156
solvePCGBatched<T>(batch_size_, d_lambda_batch_, schur_system_batch_, d_pcg_tol_batch_, max_pcg_iters_, d_kkt_converged_batch_, d_pcg_iterations_);
156157
}
158+
if (collect_stats_) { gpuErrchk(cudaEventRecord(pcg_stop_event_)); }
157159

158160
computeDzBatched<T>(batch_size_, d_dz_batch_, d_lambda_batch_, kkt_system_batch_, d_kkt_converged_batch_);
159161

@@ -167,8 +169,14 @@ class BSQP {
167169
gpuErrchk(cudaEventSynchronize(sync_event_));
168170

169171
for (uint32_t b = 0; b < batch_size_; ++b) { pcg_stats.num_iterations[b] = static_cast<int>(h_pcg_iters_[b]); }
170-
pcg_stats.solve_time_us = 0;
171-
if (collect_stats_) { sqp_stats.pcg_stats.push_back(pcg_stats); }
172+
if (collect_stats_) {
173+
// sync_event_ was recorded after pcg_stop_event_ on the same
174+
// stream, so both timing events have completed by here.
175+
float linsys_ms = 0.0f;
176+
gpuErrchk(cudaEventElapsedTime(&linsys_ms, pcg_start_event_, pcg_stop_event_));
177+
pcg_stats.solve_time_us = 1000.0 * linsys_ms;
178+
sqp_stats.pcg_stats.push_back(pcg_stats);
179+
}
172180

173181
uint32_t num_solved = 0;
174182
for (uint32_t b = 0; b < batch_size_; ++b) {

0 commit comments

Comments
 (0)