`cmake --build build --target memcheck` runs every registered CTest suite under valgrind. There are no new test programs, and there should not be any: tests/benchutil.h notices valgrind in LD_PRELOAD and drops the benchmark scale to 0.0005, which turns the perf suites into the broadest path coverage in the tree at a cost valgrind can survive. A benchmark walks one path a hundred thousand times; a leak check wants every path walked once. Same binaries, one flag apart, and the whole run is about thirty seconds. scripts/memcheck.sh wraps `ctest -T memcheck` because that command records defects and still exits 0, which cannot gate anything. It also forces the headless drivers, so the vendor GPU stack is never loaded -- that removes thousands of unfixable findings inside amdgpu_dri.so without suppressing anything, and it is what the suites are written for anyway. Only definite losses, invalid accesses and uninitialised reads count; "still reachable" is what SDL and FreeType keep for the process lifetime and says nothing about this library. The three suppressions in scripts/valgrind.supp are each a decision that a finding belongs to somebody else. Six defects, all filed in TODO.md under "Memory checking", the first of which is the one that matters: not one of the four json_load_file calls in src/ is ever matched by a json_decref, so every asset load abandons its parsed document -- 1.5 KB per sprite, 2.1 KB per character, 9 KB per load of the 2x2 fixture map, and on the order of a megabyte for a real one. A game that reloads a level on death leaks a level's worth of JSON every time. akgl_get_property also reads up to 4 KiB past the end of every property value it copies, and the savegame name tables read past the end of every registry key and write what they find into the file. tests/util.c zeroes three fixtures it used to leave as stack garbage. The library was never at fault there -- the tests handed it uninitialised floats and then made one real call with them -- but sixteen findings of noise in a new gate is how a gate gets ignored. The unit suites' TIMEOUT goes from 30 to 300 because CTest applies the same property to the checked run, where everything is twenty times slower. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
337 lines
13 KiB
C
337 lines
13 KiB
C
/**
|
|
* @file benchutil.h
|
|
* @brief The timing harness behind the perf suites: run it, record it, report it, hold it to a budget.
|
|
*
|
|
* A benchmark here is a timed region: bench_start() takes a timestamp,
|
|
* bench_stop() takes another and divides by the number of units of work that
|
|
* happened in between. The unit is whatever the measurement is *about* -- one
|
|
* call, one actor, one tile, one frame -- and it is printed alongside the
|
|
* number so nobody has to guess what "42 ns" was 42 nanoseconds of.
|
|
*
|
|
* Two things make the numbers usable rather than merely present:
|
|
*
|
|
* 1. **Best of #AKGL_BENCH_REPETITIONS.** Calling bench_start() again with a
|
|
* name already in the table folds the new run into the old entry and keeps
|
|
* the *lowest* ns/op seen. A benchmark loop therefore repeats itself and the
|
|
* harness reports the run that was interrupted least. The mean is the wrong
|
|
* statistic here: every source of noise on a shared machine makes a run
|
|
* slower and none makes it faster.
|
|
* 2. **The timed region contains no error checking.** `PASS` and `CATCH` both
|
|
* call `akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` looking
|
|
* for a match -- more work than several of the calls being measured. Use
|
|
* #BENCH_LOOP, which stashes the context and stops at the first failure, and
|
|
* hand it to `PASS` after the clock has stopped.
|
|
*
|
|
* A budget is a per-op ceiling in nanoseconds, and a benchmark that exceeds it
|
|
* fails the suite. Budgets are set at roughly ten times the measured baseline:
|
|
* loose enough that a busy machine does not turn CI red, tight enough that an
|
|
* algorithmic regression -- a linear scan that becomes quadratic, a per-frame
|
|
* allocation that becomes a per-actor one -- cannot hide. They are enforced
|
|
* only in an optimized build at full scale; a coverage build measures the
|
|
* instrumentation, not the library, and reports without judging.
|
|
*
|
|
* Set `AKGL_BENCH_SCALE` in the environment to change how long the suite runs:
|
|
* 0.1 for a tenth of the iterations, 10 for ten times as many. Below 1.0 the
|
|
* budgets are not enforced, because a short run is a noisy one.
|
|
*
|
|
* **Under valgrind these suites become the memory-check suites**, and the scale
|
|
* drops itself to #AKGL_BENCH_VALGRIND_SCALE without being asked. That is the
|
|
* whole reason `ctest -T memcheck` does not need benchmark programs of its own:
|
|
* a leak check wants every path walked *once*, and a benchmark is a program that
|
|
* walks one path a hundred thousand times. Divide the iteration counts by two
|
|
* thousand and the same binary is exactly the right shape for memcheck --
|
|
* broader coverage of asset loading, drawing, and the frame loop than any unit
|
|
* suite, at a runtime valgrind can survive. Timings from such a run are
|
|
* meaningless and the report says so.
|
|
*/
|
|
|
|
#ifndef _AKGL_BENCHUTIL_H_
|
|
#define _AKGL_BENCHUTIL_H_
|
|
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <SDL3/SDL.h>
|
|
#include <akerror.h>
|
|
#include <akgl/error.h>
|
|
|
|
/** @brief How many benchmarks one suite can record. */
|
|
#define AKGL_BENCH_MAX_RESULTS 48
|
|
/** @brief Longest benchmark name kept, including the terminator. */
|
|
#define AKGL_BENCH_MAX_NAME 56
|
|
/** @brief Longest unit name kept, including the terminator. */
|
|
#define AKGL_BENCH_MAX_UNIT 12
|
|
/** @brief How many times a benchmark is repeated before the best run is reported. */
|
|
#define AKGL_BENCH_REPETITIONS 5
|
|
/** @brief Environment variable scaling every iteration count. */
|
|
#define AKGL_BENCH_SCALE_ENV "AKGL_BENCH_SCALE"
|
|
/** @brief Scale forced on when running under valgrind. Enough to walk every path, few enough to finish. */
|
|
#define AKGL_BENCH_VALGRIND_SCALE 0.0005
|
|
|
|
/** @brief One row of the report: what was measured, how fast, and what it was allowed to cost. */
|
|
typedef struct akgl_Benchmark {
|
|
char name[AKGL_BENCH_MAX_NAME]; /**< What was measured. Also the key runs are merged under. */
|
|
char unit[AKGL_BENCH_MAX_UNIT]; /**< What one op is: "call", "actor", "tile", "frame". */
|
|
double budget_ns; /**< Per-op ceiling in nanoseconds. 0 reports without judging. */
|
|
double best_ns; /**< Lowest ns/op seen across every run under this name. */
|
|
uint64_t ops; /**< Units of work in the run that produced best_ns. */
|
|
uint64_t elapsed_ns; /**< Wall time of that run. */
|
|
int runs; /**< How many runs were folded in. */
|
|
} akgl_Benchmark;
|
|
|
|
/** @brief Every benchmark this suite has recorded, in the order it first ran. */
|
|
static akgl_Benchmark bench_results[AKGL_BENCH_MAX_RESULTS];
|
|
/** @brief How many entries of #bench_results are in use. */
|
|
static int bench_result_count = 0;
|
|
/** @brief The entry bench_stop() will write to, claimed by bench_start(). */
|
|
static akgl_Benchmark *bench_running = NULL;
|
|
/** @brief `SDL_GetTicksNS()` at the last bench_start(). */
|
|
static uint64_t bench_started_ns = 0;
|
|
|
|
/**
|
|
* @brief Report whether this process is running under valgrind.
|
|
*
|
|
* Detected from `LD_PRELOAD`, which valgrind fills with its own
|
|
* `vgpreload_*.so` before handing the process over. That is a deliberate choice
|
|
* over `RUNNING_ON_VALGRIND` from `valgrind/valgrind.h`: the macro is exact, but
|
|
* it makes the test suite fail to compile anywhere the valgrind headers are not
|
|
* installed, and a benchmark is not worth a build dependency. The cost of the
|
|
* check being wrong is a slow run or a fast one, never a wrong answer.
|
|
*/
|
|
static bool bench_under_valgrind(void)
|
|
{
|
|
static int detected = -1;
|
|
const char *preload = NULL;
|
|
|
|
if ( detected >= 0 ) {
|
|
return ( detected == 1 );
|
|
}
|
|
detected = 0;
|
|
preload = SDL_getenv("LD_PRELOAD");
|
|
if ( preload != NULL && strstr(preload, "vgpreload") != NULL ) {
|
|
detected = 1;
|
|
}
|
|
return ( detected == 1 );
|
|
}
|
|
|
|
/**
|
|
* @brief Multiplier applied to every iteration count, from `AKGL_BENCH_SCALE`.
|
|
*
|
|
* Read once and cached. Anything unparseable, negative, or absent gives 1.0.
|
|
* Under valgrind the result is capped at #AKGL_BENCH_VALGRIND_SCALE -- the
|
|
* smaller of the two wins, so asking for an even shorter run still works.
|
|
*/
|
|
static double bench_scale(void)
|
|
{
|
|
static double scale = -1.0;
|
|
const char *env = NULL;
|
|
|
|
if ( scale >= 0.0 ) {
|
|
return scale;
|
|
}
|
|
scale = 1.0;
|
|
env = SDL_getenv(AKGL_BENCH_SCALE_ENV);
|
|
if ( env != NULL ) {
|
|
scale = SDL_atof(env);
|
|
if ( scale <= 0.0 ) {
|
|
scale = 1.0;
|
|
}
|
|
}
|
|
if ( bench_under_valgrind() == true && scale > AKGL_BENCH_VALGRIND_SCALE ) {
|
|
scale = AKGL_BENCH_VALGRIND_SCALE;
|
|
}
|
|
return scale;
|
|
}
|
|
|
|
/**
|
|
* @brief Scale a nominal iteration count, never down to zero.
|
|
* @param count The count the benchmark was written for.
|
|
* @return @p count times the scale factor, at least 1.
|
|
*/
|
|
static int bench_iterations(int count)
|
|
{
|
|
int scaled = (int)((double)count * bench_scale());
|
|
|
|
if ( scaled < 1 ) {
|
|
scaled = 1;
|
|
}
|
|
return scaled;
|
|
}
|
|
|
|
/**
|
|
* @brief Report whether budgets are being enforced in this run.
|
|
*
|
|
* An unoptimized build measures the instrumentation rather than the library, a
|
|
* scaled-down run is too short to trust, and a run under valgrind is measuring
|
|
* valgrind. All three report without failing.
|
|
*/
|
|
static bool bench_budgets_enforced(void)
|
|
{
|
|
if ( bench_under_valgrind() == true ) {
|
|
return false;
|
|
}
|
|
#ifdef __OPTIMIZE__
|
|
return ( bench_scale() >= 1.0 );
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
|
|
/**
|
|
* @brief Start timing, claiming or reusing the table entry named @p name.
|
|
*
|
|
* @param name What is being measured. Truncated at #AKGL_BENCH_MAX_NAME.
|
|
* Reusing a name folds this run into that entry.
|
|
* @param unit What one op is. Truncated at #AKGL_BENCH_MAX_UNIT.
|
|
* @param budget_ns Per-op ceiling in nanoseconds; 0 to report only. The value
|
|
* from the first run under a name wins.
|
|
*
|
|
* @note The table is fixed. Past #AKGL_BENCH_MAX_RESULTS entries the run is
|
|
* dropped with a message rather than overwriting somebody else's row.
|
|
*/
|
|
static void bench_start(char *name, char *unit, double budget_ns)
|
|
{
|
|
int i = 0;
|
|
|
|
bench_running = NULL;
|
|
for ( i = 0; i < bench_result_count; i++ ) {
|
|
if ( strncmp(bench_results[i].name, name, AKGL_BENCH_MAX_NAME - 1) == 0 ) {
|
|
bench_running = &bench_results[i];
|
|
break;
|
|
}
|
|
}
|
|
if ( bench_running == NULL ) {
|
|
if ( bench_result_count >= AKGL_BENCH_MAX_RESULTS ) {
|
|
SDL_Log("benchutil: no room for benchmark '%s', raise AKGL_BENCH_MAX_RESULTS", name);
|
|
return;
|
|
}
|
|
bench_running = &bench_results[bench_result_count];
|
|
bench_result_count += 1;
|
|
memset(bench_running, 0x00, sizeof(akgl_Benchmark));
|
|
strncpy(bench_running->name, name, AKGL_BENCH_MAX_NAME - 1);
|
|
strncpy(bench_running->unit, unit, AKGL_BENCH_MAX_UNIT - 1);
|
|
bench_running->budget_ns = budget_ns;
|
|
bench_running->best_ns = -1.0;
|
|
}
|
|
bench_started_ns = SDL_GetTicksNS();
|
|
}
|
|
|
|
/**
|
|
* @brief Stop timing and keep the run if it beat every previous one.
|
|
* @param ops Units of work done since bench_start(). Zero is ignored -- a
|
|
* benchmark that did nothing has no rate to report.
|
|
*/
|
|
static void bench_stop(uint64_t ops)
|
|
{
|
|
uint64_t elapsed = SDL_GetTicksNS() - bench_started_ns;
|
|
double per_op = 0.0;
|
|
|
|
if ( bench_running == NULL || ops == 0 ) {
|
|
return;
|
|
}
|
|
per_op = (double)elapsed / (double)ops;
|
|
bench_running->runs += 1;
|
|
if ( bench_running->best_ns < 0.0 || per_op < bench_running->best_ns ) {
|
|
bench_running->best_ns = per_op;
|
|
bench_running->ops = ops;
|
|
bench_running->elapsed_ns = elapsed;
|
|
}
|
|
bench_running = NULL;
|
|
}
|
|
|
|
/**
|
|
* @brief Run @p stmt @p count times, stopping at the first error.
|
|
*
|
|
* The timed loop deliberately holds no `PASS` or `CATCH`: their validity check
|
|
* walks the whole error array and costs more than some of the calls being
|
|
* measured. Hand @p errvar to `PASS` once the clock has stopped.
|
|
*
|
|
* @param errvar Receives the first non-`NULL` context returned, or `NULL`.
|
|
* @param i Loop variable, declared by the caller.
|
|
* @param count How many times to run @p stmt.
|
|
* @param stmt The call under test. Must evaluate to an `akerr_ErrorContext *`.
|
|
*/
|
|
#define BENCH_LOOP(errvar, i, count, stmt) \
|
|
for ( i = 0; i < (count); i++ ) { \
|
|
errvar = (stmt); \
|
|
if ( errvar != NULL ) { \
|
|
break; \
|
|
} \
|
|
}
|
|
|
|
/**
|
|
* @brief Print the recorded benchmarks as a table and count the ones over budget.
|
|
*
|
|
* The table goes to stdout so `ctest --output-on-failure` and a plain run of the
|
|
* executable both show it. Rates are printed as ops per second, which is the
|
|
* number a frame budget is actually built from.
|
|
*
|
|
* @return How many benchmarks exceeded their budget. Always 0 when
|
|
* bench_budgets_enforced() is false.
|
|
*/
|
|
static int bench_report(void)
|
|
{
|
|
int i = 0;
|
|
int over = 0;
|
|
bool enforced = bench_budgets_enforced();
|
|
char *verdict = NULL;
|
|
|
|
printf("\n");
|
|
if ( bench_under_valgrind() == true ) {
|
|
printf("running under valgrind: this is a memory check, not a measurement.\n");
|
|
printf("The timings below are valgrind's and mean nothing about libakgl.\n");
|
|
}
|
|
printf("scale %.4gx, best of %d runs, budgets %s\n",
|
|
bench_scale(),
|
|
AKGL_BENCH_REPETITIONS,
|
|
enforced ? "enforced" : "reported only");
|
|
printf("%-54s %-6s %9s %12s %16s %10s %s\n",
|
|
"benchmark", "unit", "ops", "ns/op", "ops/sec", "budget", "verdict");
|
|
printf("%-54s %-6s %9s %12s %16s %10s %s\n",
|
|
"------------------------------------------------------",
|
|
"------", "---------", "------------", "----------------", "----------", "-------");
|
|
for ( i = 0; i < bench_result_count; i++ ) {
|
|
verdict = "-";
|
|
if ( bench_results[i].budget_ns > 0.0 ) {
|
|
if ( bench_results[i].best_ns > bench_results[i].budget_ns ) {
|
|
verdict = enforced ? "OVER" : "over";
|
|
if ( enforced ) {
|
|
over += 1;
|
|
}
|
|
} else {
|
|
verdict = "ok";
|
|
}
|
|
}
|
|
printf("%-54s %-6s %9llu %12.1f %16.0f %10.0f %s\n",
|
|
bench_results[i].name,
|
|
bench_results[i].unit,
|
|
(unsigned long long)bench_results[i].ops,
|
|
bench_results[i].best_ns,
|
|
( bench_results[i].best_ns > 0.0 ) ? (1000000000.0 / bench_results[i].best_ns) : 0.0,
|
|
bench_results[i].budget_ns,
|
|
verdict);
|
|
}
|
|
printf("\n");
|
|
fflush(stdout);
|
|
return over;
|
|
}
|
|
|
|
/**
|
|
* @brief Fail the enclosing ATTEMPT block if any benchmark blew its budget.
|
|
*
|
|
* The report is printed either way -- a run that fails still has to say what the
|
|
* numbers were.
|
|
*/
|
|
#define BENCH_REPORT_BREAK(e) \
|
|
{ \
|
|
int __bench_over = bench_report(); \
|
|
if ( __bench_over > 0 ) { \
|
|
FAIL_BREAK( \
|
|
e, \
|
|
AKGL_ERR_BEHAVIOR, \
|
|
"%d benchmark(s) exceeded their budget", \
|
|
__bench_over); \
|
|
} \
|
|
}
|
|
|
|
#endif // _AKGL_BENCHUTIL_H_
|