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

View File

@ -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,15 +100,21 @@ int _enemies::init()
void _enemies::move()
{
_enemy *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();
}
}
void _enemies::move_intro()
{

View File

@ -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())

View File

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

View File

@ -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++)

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