diff --git a/common_libs/tests/measure_parallel_vbullet_cost.nim b/common_libs/tests/measure_parallel_vbullet_cost.nim new file mode 100644 index 0000000..c2e8648 --- /dev/null +++ b/common_libs/tests/measure_parallel_vbullet_cost.nim @@ -0,0 +1,309 @@ +## MEASUREMENT ONLY — offline cost benchmark for "keep per-enemy virtual bullets +## for ALL enemies in parallel" (kill-stealing feature). +## +## Drives the REAL 14-gun rack from `common_libs/guns/*` and the REAL +## `VirtualTracker`/`spawnBullets`/`tickBullets` from +## `common_libs/gun_harness/virtual_bullets.nim`, over recorded DrussGT movement +## (`tools/fixtures/*.jsonl`, READ-ONLY). +## +## NO shipped source is edited; NO bot is rebuilt; offline only. +## +## Synthesised melee: there is no melee fixture in the repo, so N "enemies" are +## N DISTINCT recorded trajectories (different fixtures), all placed in one +## 800x600 arena with the shooter pose taken from fixture 0. Stated explicitly — +## it is not a real melee capture. +## +## Two rack topologies are measured: +## * SHARED — one 14-gun rack, predict() called once per (target, gun, bin). +## The literal minimal change against the current bot, whose guns +## are global. Per-tick caches (keyed on state.tick) are SHARED +## across targets. +## * PER-ENEMY — one 14-gun rack PER target (N racks), so each enemy gets a +## clean gun history. Gun ids are flattened to target*14+gun. +## +## RING CAP: VirtualTracker.MaxBullets = 8192 (shipped const). At N targets the +## spawn rate is 56*N/tick and a path-metric bullet lives until it leaves the +## arena (tens of ticks), so N>=2 overflows the ring and silently drops bullets. +## The `dropped/tick` column measures that. The full-pipeline cost for a WORKING +## ring is therefore reported as a PROJECTION: measured predict+spawn(N) plus +## N x the measured N=1 onResult cost per tick (onResult is per resolved bullet, +## and a working pipeline resolves every spawned bullet). +## +## Usage: +## GUN_SELECTOR_MODE=absolute nim c -r common_libs/tests/measure_parallel_vbullet_cost.nim + +import std/[os, strformat, times, tables, math, algorithm, monotimes, random] +import gun_harness/offline_range +import gun_harness/gun_interface +import gun_harness/virtual_bullets as vb +import range_guns + +const + NGun = 14 + NBins = len(vb.PowerBins) + TmGun = 13 ## TMSelect index (shipped DISABLED) + RepoRoot = currentSourcePath().parentDir.parentDir.parentDir + FixturesDir = RepoRoot / "tools" / "fixtures" + FixtureNames = [ + "tr_drussgt_vs_corners.jsonl", + "tr_drussgt_vs_crazy.jsonl", + "tr_drussgt_vs_modularbot.jsonl", + "tr_drussgt_vs_modularbot_shield.jsonl", + "tr_drussgt_vs_spinbot.jsonl", + "drussgt_vs_ramfire.jsonl", + "drussgt_vs_drussgt.jsonl", + ] + MaxTicks = 2000 + +# ── fixtures / state synthesis ──────────────────────────────────────────────── + +proc loadFixtures(): seq[Fixture] = + for n in FixtureNames: + let p = FixturesDir / n + if fileExists(p): result.add loadFixture(p) + +proc buildStates(fx: seq[Fixture], maxTicks: int): seq[seq[WorldState]] = + ## Per-target WorldState streams. Target k keeps its own recorded enemy pose + ## but shares the shooter pose of fixture 0, and all targets share a single + ## continuous tick counter `si` so the guns' per-tick caches align. + var ticks = maxTicks + for f in fx: ticks = min(ticks, f.states.len) + result = newSeq[seq[WorldState]](fx.len) + for k in 0.. 0: + var ds: seq[tuple[d: float, k: int]] + for k in 0.. 0 and (si mod rotateMod) != (k mod rotateMod): continue + let ws = states[k][si] + let rack = racks[if perEnemy: k else: 0] + for gi in 0..= warmup and si < warmup + measure: + inc result.spawns, NBins + let t1 = getMonoTime() + if (si mod resolveEvery) == 0: + let et = enemyTable(states, si) + tracker.tickBullets(states[0][si], et, + proc(gunId: GunId, binIdx: int, e: FeedbackEvent) = + if includeTm or gunId mod NGun != TmGun: + racks[gunId div NGun][gunId mod NGun].resultCb(e)) + let t2 = getMonoTime() + if si == warmup: + dropStart = tracker.droppedBullets + if si >= warmup and si < warmup + measure: + inc result.ticks + result.predictSpawnNs += (t1 - t0).inNanoseconds + result.resolveNs += (t2 - t1).inNanoseconds + dropEnd = tracker.droppedBullets + # dropped during the MEASUREMENT window only + result.dropped = dropEnd - dropStart + +# ── per-gun breakdown (N = 1, timed per call) ───────────────────────────────── + +type + PerGun = object + calls: array[NGun, array[NBins, int]] + predNs: array[NGun, array[NBins, int64]] + onCalls: array[NGun, int] + onNs: array[NGun, int64] + measure: int + +proc runPerGun(states: seq[seq[WorldState]], warmup, measure: int): ref PerGun = + let rack = buildAllGunDrivers(seed = 1, enableTmSelector = true) + var tracker = vb.initTracker(NGun, bmPath) + let r = new(PerGun) + r.measure = measure + let ticks = states[0].len + for si in 0..= warmup and si < warmup + measure: + r.predNs[gi][b] += dt + inc r.calls[gi][b] + tracker.spawnBullets(gi, preds, ws, 1) + let et = enemyTable(states, si) + tracker.tickBullets(ws, et, + proc(gunId: GunId, binIdx: int, e: FeedbackEvent) = + let t0 = getMonoTime() + rack[gunId].resultCb(e) + let dt = (getMonoTime() - t0).inNanoseconds + if si >= warmup and si < warmup + measure: + r.onNs[gunId] += dt + inc r.onCalls[gunId]) + result = r + +const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", + "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", + "DecayGF", "KNN", "TMSelect"] + +proc printPerGun(pg: ref PerGun) = + echo "" + echo "=================== BASELINE PER-GUN (N=1, 14-gun rack) ===================" + echo "(times include one getMonoTime pair ~50ns/call; cheap guns are overhead-bound)" + echo "gun calls/tick bin0 ns/call bin1-3 ns/call predict ns/tick onResult ns/call onRes/tick" + for gi in 0.. 0: pg.predNs[gi][0].float / c0.float else: 0.0 + let b13 = if c13 > 0: + (pg.predNs[gi][1] + pg.predNs[gi][2] + pg.predNs[gi][3]).float / + c13.float else: 0.0 + let pt = (pg.predNs[gi][0] + pg.predNs[gi][1] + pg.predNs[gi][2] + + pg.predNs[gi][3]).float / max(1, pg.measure).float + let onc = if pg.onCalls[gi] > 0: pg.onNs[gi].float / pg.onCalls[gi].float else: 0.0 + let onpt = pg.onNs[gi].float / max(1, pg.measure).float + echo fmt"{GunNames[gi]:<12} {float(c0 + c13) / max(1, pg.measure).float:10.1f} " & + fmt"{b0:12.0f} {b13:14.0f} {pt:14.0f} {onc:16.0f} {onpt:9.0f}" + +proc onNsPerTick(pg: ref PerGun, includeTm: bool): float = + ## Total onResult cost per tick at N=1 for the selected rack. + for gi in 0.. 0: 1000.0 / projMs else: 0.0 + echo fmt"{rk:<10} {topo} {n:>2} {psMs:11.2f} {rMs:11.2f} {dropPt:11.1f} " & + fmt"{projMs:13.2f} {tps:7.0f} {projMs / BudgetMs * 100.0:6.0f}%" + + # ── mitigations at N = 6 (shipped 13-gun shared rack) ─────────────────────── + echo "" + echo "=================== MITIGATIONS at N=6 (shipped 13-gun, shared rack) ===================" + var basePs = 1.0e18 + for rep in 0..