Phase 0: profiling scaffolding for DOD refactor

Adds a tiny zone-based profiler (prof.{h,cpp}) enabled via KOBO_PROF=1.
Instruments run_game with zones for myship.move, enemies.move (split into
realize and move_pass), hit_structure, and hit_bolt. Adds KOBO_STRESS=N
to keep the enemy pool topped up with rocks for stable per-frame timing.

Why: the entity/render pipeline refactor in BENCH.md needs a comparable
baseline before any layout changes. Without a stress workload the natural
gameplay enemy count is too noisy to compare across commits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ville Lindholm 2026-05-29 13:31:31 +03:00
parent aa8387e9ec
commit 869056c5d2
No known key found for this signature in database
GPG Key ID: 89AE9EAA3B6FDE7C
8 changed files with 249 additions and 10 deletions

54
BENCH.md Normal file
View File

@ -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.

View File

@ -160,7 +160,7 @@ set(KOBO_SOURCES
myship.cpp radar.cpp random.cpp scenes.cpp score.cpp screen.cpp myship.cpp radar.cpp random.cpp scenes.cpp score.cpp screen.cpp
filemap.cpp prefs.cpp cfgform.cpp options.cpp gamestate.cpp filemap.cpp prefs.cpp cfgform.cpp options.cpp gamestate.cpp
states.cpp form.cpp cfgparse.cpp game.cpp kobo.cpp logger.c 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}) add_executable(kobodl ${KOBO_SOURCES})

View File

@ -24,6 +24,7 @@
#include "enemies.h" #include "enemies.h"
#include "random.h" #include "random.h"
#include "radar.h" #include "radar.h"
#include "prof.h"
_enemy _enemies::enemy[ENEMY_MAX]; _enemy _enemies::enemy[ENEMY_MAX];
_enemy *_enemies::enemy_max; _enemy *_enemies::enemy_max;
@ -99,14 +100,20 @@ int _enemies::init()
void _enemies::move() void _enemies::move()
{ {
_enemy *enemyp; _enemy *enemyp;
{
PROF_ZONE("enemies.realize");
/* realize reserved enemies */ /* realize reserved enemies */
for(enemyp = enemy; enemyp < enemy + ENEMY_MAX; enemyp++) for(enemyp = enemy; enemyp < enemy + ENEMY_MAX; enemyp++)
{ {
if(enemyp->realize()) if(enemyp->realize())
enemy_max = enemyp; enemy_max = enemyp;
} }
}
{
PROF_ZONE("enemies.move_pass");
for(enemyp = enemy; enemyp <= enemy_max; enemyp++) for(enemyp = enemy; enemyp <= enemy_max; enemyp++)
enemyp->move(); enemyp->move();
}
} }
void _enemies::move_intro() void _enemies::move_intro()

View File

@ -61,6 +61,7 @@ extern "C" {
#include "options.h" #include "options.h"
#include "myship.h" #include "myship.h"
#include "enemies.h" #include "enemies.h"
#include "prof.h"
#define MAX_FPS_RESULTS 64 #define MAX_FPS_RESULTS 64
@ -2054,6 +2055,8 @@ int main(int argc, char *argv[])
signal(SIGTERM, breakhandler); signal(SIGTERM, breakhandler);
signal(SIGINT, breakhandler); signal(SIGINT, breakhandler);
prof_init();
SDL_Init(0); SDL_Init(0);
if(main_init()) if(main_init())

View File

@ -50,9 +50,24 @@
#include "states.h" #include "states.h"
#include "audio.h" #include "audio.h"
#include "random.h" #include "random.h"
#include "prof.h"
#define GIGA 1000000000 #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::blank = 0;
int _manage::next_state_out; int _manage::next_state_out;
int _manage::next_state_next; int _manage::next_state_next;
@ -556,6 +571,7 @@ void _manage::run_pause()
void _manage::run_game() void _manage::run_game()
{ {
PROF_ZONE("run_game");
put_health(); put_health();
put_temp(); put_temp();
@ -583,11 +599,33 @@ void _manage::run_game()
} }
} }
myship.move(); if(kobo_stress_init() > 0)
enemies.move(); kobo_stress_step();
myship.hit_structure();
{ PROF_ZONE("myship.move"); myship.move(); }
{ PROF_ZONE("enemies.move"); enemies.move(); }
{ PROF_ZONE("hit_structure"); myship.hit_structure(); }
update(); update();
++hi.playtime; ++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;
}
} }

View File

@ -28,6 +28,7 @@
#include "manage.h" #include "manage.h"
#include "random.h" #include "random.h"
#include "sound.h" #include "sound.h"
#include "prof.h"
#define WING_GUN_OFFSET 4 #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) int _myship::hit_bolt(int ex, int ey, int hitsize, int health)
{ {
PROF_ZONE("hit_bolt");
int dmg = 0; int dmg = 0;
int i; int i;
for(i = 0; i < MAX_BOLTS; i++) for(i = 0; i < MAX_BOLTS; i++)

41
prof.cpp Normal file
View File

@ -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);
}

94
prof.h Normal file
View File

@ -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 <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#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