diff --git a/BENCH.md b/BENCH.md new file mode 100644 index 0000000..564fde0 --- /dev/null +++ b/BENCH.md @@ -0,0 +1,54 @@ +# Kobo Deluxe DOD Refactor — Benchmark Scoreboard + +Each row is one phase of the refactor (see +`~/.claude/plans/can-you-explain-the-noble-wilkes.md`). Numbers are median +ns/frame from `KOBO_PROF=1 KOBO_STRESS=1500 ./build/kobodl`, ~1000 frames, +release build (`-O2 -g`). + +## How to capture a baseline + +```sh +cd build +cmake --build . -j +KOBO_PROF=1 KOBO_STRESS=1500 ./kobodl +# play through a stage; profile dumps every 600 frames to stderr. +# Redirect stderr to a file if you want to keep it: +KOBO_PROF=1 KOBO_STRESS=1500 ./kobodl 2>prof.log +``` + +The profiler dumps a table per zone with `calls`, `total_ms`, `ns/call`. +Record the `ns/call` for these zones (they are the moving parts the refactor +targets): + +| Zone | What it measures | +|-----------------------|-----------------------------------------------------| +| `run_game` | Full game tick | +| `myship.move` | Player + bolt update | +| `enemies.move` | Realize pass + move pass combined | +| `enemies.realize` | Just the realize-reserved pass | +| `enemies.move_pass` | Just the per-enemy move pass (the hot loop) | +| `hit_structure` | Player vs map + bolt vs map | +| `hit_bolt` | Bolt-vs-enemy scan (called from inside enemy move) | + +## Scoreboard + +| Phase | run_game | myship.move | enemies.move_pass | hit_bolt | Notes | +|------------------------------|---------:|------------:|------------------:|---------:|-------| +| 0 — baseline | ? | ? | ? | ? | TBD — user to capture | +| 1 — hot/cold split | | | | | | +| 2 — SoA pool | | | | | | +| 3 — bucket dispatch | | | | | | +| 4 — flipped bolt collision | | | | | | +| 5 — render decouple | | | | | | +| 6 — SIMD integrator (opt.) | | | | | | + +## Notes + +- `KOBO_STRESS=N` keeps the pool topped up with rocks; N=1500 sits well + below ENEMY_MAX=2048 so the spawner doesn't thrash on full-pool checks. +- The stress spawner uses `pubrand`, which is acceptable for benchmarking + but will *not* produce identical demos across builds — that's fine, we + only care about steady-state cost. +- For a stable run, start the game, get into a stage, then let it sit for + ~1000 frames before reading numbers. The first dump (frame 600) will + include startup costs and should be discarded. diff --git a/CMakeLists.txt b/CMakeLists.txt index c7a3c16..10aa884 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -160,7 +160,7 @@ set(KOBO_SOURCES myship.cpp radar.cpp random.cpp scenes.cpp score.cpp screen.cpp filemap.cpp prefs.cpp cfgform.cpp options.cpp gamestate.cpp states.cpp form.cpp cfgparse.cpp game.cpp kobo.cpp logger.c - dashboard.cpp sound.cpp + dashboard.cpp sound.cpp prof.cpp ) add_executable(kobodl ${KOBO_SOURCES}) diff --git a/enemies.cpp b/enemies.cpp index 81700a0..80a521e 100644 --- a/enemies.cpp +++ b/enemies.cpp @@ -24,6 +24,7 @@ #include "enemies.h" #include "random.h" #include "radar.h" +#include "prof.h" _enemy _enemies::enemy[ENEMY_MAX]; _enemy *_enemies::enemy_max; @@ -99,14 +100,20 @@ int _enemies::init() void _enemies::move() { _enemy *enemyp; - /* realize reserved enemies */ - for(enemyp = enemy; enemyp < enemy + ENEMY_MAX; enemyp++) { - if(enemyp->realize()) - enemy_max = enemyp; + PROF_ZONE("enemies.realize"); + /* realize reserved enemies */ + for(enemyp = enemy; enemyp < enemy + ENEMY_MAX; enemyp++) + { + if(enemyp->realize()) + enemy_max = enemyp; + } + } + { + PROF_ZONE("enemies.move_pass"); + for(enemyp = enemy; enemyp <= enemy_max; enemyp++) + enemyp->move(); } - for(enemyp = enemy; enemyp <= enemy_max; enemyp++) - enemyp->move(); } void _enemies::move_intro() diff --git a/kobo.cpp b/kobo.cpp index e10dbe0..780d682 100644 --- a/kobo.cpp +++ b/kobo.cpp @@ -61,6 +61,7 @@ extern "C" { #include "options.h" #include "myship.h" #include "enemies.h" +#include "prof.h" #define MAX_FPS_RESULTS 64 @@ -2054,6 +2055,8 @@ int main(int argc, char *argv[]) signal(SIGTERM, breakhandler); signal(SIGINT, breakhandler); + prof_init(); + SDL_Init(0); if(main_init()) diff --git a/manage.cpp b/manage.cpp index caa266f..851f712 100644 --- a/manage.cpp +++ b/manage.cpp @@ -50,9 +50,24 @@ #include "states.h" #include "audio.h" #include "random.h" +#include "prof.h" #define GIGA 1000000000 +// Stress-test spawner: keeps the enemy pool topped up with rocks so frame +// timing is stable. Enabled by KOBO_STRESS=N (target population, 0 = off). +static int kobo_stress_target = -1; +static int kobo_stress_init(void) +{ + if (kobo_stress_target >= 0) return kobo_stress_target; + const char *e = getenv("KOBO_STRESS"); + kobo_stress_target = (e && *e) ? atoi(e) : 0; + if (kobo_stress_target > 0) + fprintf(stderr, "[stress] target population=%d\n", kobo_stress_target); + return kobo_stress_target; +} +static void kobo_stress_step(void); + int _manage::blank = 0; int _manage::next_state_out; int _manage::next_state_next; @@ -556,6 +571,7 @@ void _manage::run_pause() void _manage::run_game() { + PROF_ZONE("run_game"); put_health(); put_temp(); @@ -583,11 +599,33 @@ void _manage::run_game() } } - myship.move(); - enemies.move(); - myship.hit_structure(); + if(kobo_stress_init() > 0) + kobo_stress_step(); + + { PROF_ZONE("myship.move"); myship.move(); } + { PROF_ZONE("enemies.move"); enemies.move(); } + { PROF_ZONE("hit_structure"); myship.hit_structure(); } update(); ++hi.playtime; + prof_frame_tick(); +} + +// Top up the enemy pool with rocks until we hit the target population. +// Rocks are inert enough to give a stable per-frame integration cost. +static void kobo_stress_step(void) +{ + extern const enemy_kind rock; + int target = kobo_stress_target; + if (target <= 0) return; + // Spawn at most a few per frame to avoid burst allocation spikes. + for (int i = 0; i < 8; ++i) { + int px = (pubrand.get() & (WORLD_SIZEX - 1)); + int py = (pubrand.get() & (WORLD_SIZEY - 1)); + int h = (int)(pubrand.get() & 0x7f) - 64; + int v = (int)(pubrand.get() & 0x7f) - 64; + // enemies.make returns 0 on success, non-zero when pool is full. + if (enemies.make(&rock, px, py, h, v) != 0) break; + } } diff --git a/myship.cpp b/myship.cpp index e3dc0a2..e6f3fd0 100644 --- a/myship.cpp +++ b/myship.cpp @@ -28,6 +28,7 @@ #include "manage.h" #include "random.h" #include "sound.h" +#include "prof.h" #define WING_GUN_OFFSET 4 @@ -384,6 +385,7 @@ int _myship::hit_structure() int _myship::hit_bolt(int ex, int ey, int hitsize, int health) { + PROF_ZONE("hit_bolt"); int dmg = 0; int i; for(i = 0; i < MAX_BOLTS; i++) diff --git a/prof.cpp b/prof.cpp new file mode 100644 index 0000000..3a8aa08 --- /dev/null +++ b/prof.cpp @@ -0,0 +1,41 @@ +#include "prof.h" + +prof_zone_t prof_zones[PROF_MAX_ZONES]; +int prof_nzones = 0; +int prof_enabled = 0; +uint64_t prof_frame_count = 0; + +extern "C" void prof_init(void) +{ + const char *e = getenv("KOBO_PROF"); + prof_enabled = (e && *e && *e != '0') ? 1 : 0; + if (prof_enabled) + fprintf(stderr, "[prof] enabled (dump every %d frames)\n", + PROF_DUMP_EVERY_FRAMES); +} + +extern "C" void prof_dump(FILE *f) +{ + if (!prof_enabled) return; + fprintf(f, "[prof] frame=%llu zones=%d\n", + (unsigned long long)prof_frame_count, prof_nzones); + for (int i = 0; i < prof_nzones; ++i) { + prof_zone_t *z = &prof_zones[i]; + double ns_per_call = z->calls ? (double)z->total_ns / (double)z->calls : 0.0; + double total_ms = (double)z->total_ns / 1.0e6; + fprintf(f, " %-28s calls=%-10llu total_ms=%-10.3f ns/call=%.1f\n", + z->name, + (unsigned long long)z->calls, + total_ms, + ns_per_call); + } + fflush(f); +} + +extern "C" void prof_frame_tick(void) +{ + if (!prof_enabled) return; + ++prof_frame_count; + if (prof_frame_count % PROF_DUMP_EVERY_FRAMES == 0) + prof_dump(stderr); +} diff --git a/prof.h b/prof.h new file mode 100644 index 0000000..a25fadb --- /dev/null +++ b/prof.h @@ -0,0 +1,94 @@ +// Lightweight zone-based profiler for measuring the DOD refactor. +// Enabled at runtime via KOBO_PROF=1 in the environment. +// See plans: .claude/plans/can-you-explain-the-noble-wilkes.md (Phase 0). + +#ifndef KOBO_PROF_H +#define KOBO_PROF_H + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define PROF_MAX_ZONES 32 +#define PROF_DUMP_EVERY_FRAMES 600 + +typedef struct { + const char *name; + uint64_t calls; + uint64_t total_ns; +} prof_zone_t; + +extern prof_zone_t prof_zones[PROF_MAX_ZONES]; +extern int prof_nzones; +extern int prof_enabled; +extern uint64_t prof_frame_count; + +static inline uint64_t prof_now_ns(void) +{ +#if defined(__APPLE__) || defined(__linux__) + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +#else + return 0; +#endif +} + +// Returns the index of the zone with the given name, creating it on first use. +// Names must be string literals (we compare by pointer for speed). +static inline int prof_zone_id(const char *name) +{ + for (int i = 0; i < prof_nzones; ++i) + if (prof_zones[i].name == name) + return i; + if (prof_nzones >= PROF_MAX_ZONES) + return PROF_MAX_ZONES - 1; + int id = prof_nzones++; + prof_zones[id].name = name; + prof_zones[id].calls = 0; + prof_zones[id].total_ns = 0; + return id; +} + +static inline void prof_accumulate(int id, uint64_t ns) +{ + prof_zones[id].calls += 1; + prof_zones[id].total_ns += ns; +} + +void prof_init(void); +void prof_frame_tick(void); +void prof_dump(FILE *f); + +#ifdef __cplusplus +} + +// RAII helper for C++ scoping. +struct ProfScope { + int id; + uint64_t t0; + inline ProfScope(int zone_id) : id(zone_id), t0(prof_enabled ? prof_now_ns() : 0) {} + inline ~ProfScope() { + if (prof_enabled) prof_accumulate(id, prof_now_ns() - t0); + } +}; + +// Pastes the line number to make the static unique per use-site. +#define PROF_ZONE_CONCAT2(a,b) a##b +#define PROF_ZONE_CONCAT(a,b) PROF_ZONE_CONCAT2(a,b) +#define PROF_ZONE(name) \ + static int PROF_ZONE_CONCAT(_prof_zid_, __LINE__) = -1; \ + if (PROF_ZONE_CONCAT(_prof_zid_, __LINE__) < 0) \ + PROF_ZONE_CONCAT(_prof_zid_, __LINE__) = prof_zone_id(name); \ + ProfScope PROF_ZONE_CONCAT(_prof_scope_, __LINE__)( \ + PROF_ZONE_CONCAT(_prof_zid_, __LINE__)) + +#endif + +#endif // KOBO_PROF_H