Benchmarks¶
KKE's promise is that a game made with it runs on a 1-core, 2 GB machine with no GPU. This page is the evidence: what is measured, how to measure it yourself, and the numbers so far.
There are two engine benchmarks, and both write the same kind of report
(kke::BenchmarkReport: a .txt for people and a .json for tools):
| What it measures | Run it | |
|---|---|---|
| Stress test | The whole engine as a player sees it: the showcase level for 36 s (walk with animation and IK, 300 crates raining, heavy impacts), uncapped. Frames per second, 1% lows, GPU and physics time per phase, and the list of modules that were running. | KKE_STRESS_TEST=1 ./kke_demo, or Performance → Run stress test |
| kke_bench | The engine's CPU building blocks one at a time, headless and single-threaded, with fixed seeds, so a change in a number means the code changed. | ./kke_bench (--quick, --filter NAME, --list) |
Reports go to benchmark/ (or $KKE_BENCH_DIR). Use a Release build
for numbers you compare; Debug is several times slower.
Benchmark for everyone: kke_benchmark¶
The question the two above can't answer is "does every demo run, and how
well, on this machine?". kke_benchmark answers it on anyone's
computer. It ships in every download in its own benchmark/ folder,
beside the demos it runs, so the steps for a friend are: unpack, open
benchmark/, double-click kke_benchmark (on Linux, run
./benchmark/kke_benchmark in a terminal), leave the mouse alone for
about ten minutes, send back the .json file and the -shots.zip from
the results folder that opens at the end. README.txt in the download says the same. In a
build tree it is bin/benchmark/kke_benchmark.
It plays every demo in benchmarks/suite.yaml
one after the other, each in its own process with its own autopilot or bot
mode, so nobody has to play and a demo that crashes, hangs or can't start
is recorded as that while the rest still run. Each demo measures itself
(kke/BenchRecorder.h): after a 3 s warm-up, 20 s uncapped (no vsync, no
frame cap).
./kke_benchmark # every demo, about 10 minutes
./kke_benchmark --quick # fewer demos, 8 s each, about 4 minutes
./kke_benchmark --only duel,sea_demo # just these (ids: --list)
./kke_benchmark --seconds 60 # longer runs catch rarer hitches
./kke_benchmark --suite my_suite.yaml # another list of demos (any path)
Owners of the Synty packs can set KKE_ASSETS_DIR first; the results say
per demo whether it ran on Synty art or on stand-in blocks. To send
friends a download with the art, bake a private one:
COOKED_ART.md.
Collect mode is for launchers that start the demos themselves (the
Android app can't spawn processes): they run each demo with the same
KKE_BENCH_* variables, then hand the folder to kke_benchmark, which
writes the usual results file without running anything:
kke_benchmark --collect RUN_DIR --suite FILE [--out DIR] [--only ids] [--seconds S] --no-wait --no-open
RUN_DIR/runs.json lists what was run:
[{"id": "duel", "status": "ok", "wall_s": 31.2, "exit_code": 0}, ...]
(status one of the statuses below; exit_code may be left out or null;
"skipped" takes a reason). An id may be <suite id>@<variant> when
one entry is run several ways (the phone runs duel@portrait and
duel@landscape). Screenshots are read from RUN_DIR/reports/shots/
(KKE_BENCH_DIR/shots/, where the recorder writes them). Each demo's
report is RUN_DIR/reports/<id>.json and its log RUN_DIR/logs/<id>.log
(both optional: a missing report reads as no_report, a demo absent from
runs.json as missing). Results go to --out, by default RUN_DIR's
parent folder.
The results file¶
benchmark/results/kke-benchmark-<date>_<time>.json, plus a .txt of the
same for people, kke-benchmark-<date>_<time>-shots.zip with each demo's
screenshots, and run_<stamp>/ with each demo's full log and report:
| Key | What |
|---|---|
system |
OS and version, CPU, usable cores, RAM, GPU, type, VRAM, driver name and version, Vulkan version, display, engine version and commit, build type |
vulkan |
Whether a Vulkan driver exists, every GPU it lists (usable: Vulkan 1.1+, a graphics queue and swapchains, what the engine needs), and a plain-words problem when none can run the demos |
demos[].status |
ok, broken_modules (ran, but a module threw and was switched off), ended_early, crashed, hung, no_report, missing (not in this download), skipped (the launcher didn't run it, with a reason), failed_to_start |
demos[].variant |
Collect mode: the part after @ in a run id (duel@portrait runs the duel entry in portrait); suite_id is the entry |
screenshots_zip |
The zip of every demo's screenshots, next to this file |
demos[].exit |
The exit code in words: an illegal instruction (the CPU lacks AVX2), access violation, missing DLL, killed... |
demos[].log |
Warning and error counts, every distinct warning/error text with how often it came, and the last 80 log lines when the demo didn't finish |
demos[].report.summary |
fps average, 1% and 0.1% lows, frame time avg/p50/p95/p99/max, GPU time, verdict, sim_behind_frames (frames where the fixed-step simulation hit its cap and fell behind real time), memory at start/peak/end, hitch counts |
demos[].report.load_s |
From the process starting to its first frame |
demos[].report.stages_ms |
Where a frame's time goes: events, simulate (fixed ticks), update, gpu_wait (waiting for the GPU to free a frame), record (building the frame), present (submit + present), idle |
demos[].report.modules |
Every module's cost per frame, its worst frame, its worst single call and which call that was, and its init() time |
demos[].report.per_second |
One row per second: fps, frame avg/max, GPU, each stage, memory, hitches |
demos[].report.screenshots |
The pictures the demo took of itself: kind (worst-frame, slowest-second, fastest-second, view-1-of-3...), file, t_s, value (ms or fps) |
demos[].report.hitches |
Every hitch (up to 200): when (seconds and wall-clock time), how long against the median, severity (minor < 50 ms, major < 250 ms, freeze), a cause in words (the stage that grew and the module call that dominated it), the stage split, the three most expensive calls, GPU time, memory change, window events that frame, and log_near: the log lines written within a second of it |
Screenshots. Each demo pictures itself (3D and UI, what the player
sees) at the moments that matter: the slowest frame, the slowest and the
fastest whole second, and three views at 10%, 50% and 90% of the measured
time. The moment is only known once it has happened, so the picture is of
the next frame, a few milliseconds later. Names say what and when:
duel_worst-frame_352ms_at-43.4s.jpg. A later record of the same kind
replaces the earlier picture, so there are at most six per demo. The
frames that copy or read back a picture aren't measured
(summary.screenshot_frames counts them), and the JPEGs are written after
measuring, so the pictures don't change the numbers. KKE_BENCH_SHOTS=0
turns them off.
A hitch is a frame slower than both twice the median of the last 61 frames and that median + 8 ms, so a steady 25 fps is slow but not hitchy, and a single 40 ms frame at 144 fps is caught.
To read results, or compare them (two engine versions on one machine show the gains, several machines show who struggles where):
python3 benchmarks/results.py kke-benchmark-X.json # one machine
python3 benchmarks/results.py old.json new.json # side by side, % change
python3 benchmarks/results.py --hitches kke-benchmark-X.json # every hitch with its cause
Any game can record itself the same way without the launcher:
KKE_BENCHMARK=20 ./my_game writes benchmark/<game>_<stamp>.json and
quits (KKE_BENCH_WARMUP, KKE_BENCH_DIR, KKE_BENCH_NAME,
KKE_BENCH_VSYNC=1 to keep vsync). A launcher that can't set
environment variables, such as an Android activity, calls
Application::startBenchmark() before run(). A game can add its own
markers with app.benchmark()->addEvent("wave 3"), so a hitch says what
the game was doing.
kke_bench cases¶
Each case reports the median and p95 time of one sample, in ms (lower is better), and a checksum of what it computed. The checksum is the same on every run of the same commit; if it isn't, the workload stopped being deterministic, and that's a bug.
| Case | One sample is |
|---|---|
rigid_crates_400 |
One 60 Hz Jolt step while 400 crates fall and pile up |
rigid_raycast_1000 |
1000 raycasts into a settled pile (picking, audio occlusion, camera arm) |
fracture_bake_cube |
Voxelizing a 1 m cube at 10 cm and baking a Voronoi fracture (what spawning a breakable costs) |
particle_fluid_2000 |
One step of 2000 Position Based Fluids particles (the melt and lava budget) |
audio_mix_32_voices |
10 ms of output with 32 spatial voices and reverb; under 10 ms is real time |
impact_synth_8_materials |
Synthesizing one impact sound for each default material |
lua_think_50_hooks |
One Think hook fanned out to 50 Lua handlers |
net_snapshot_256_bodies |
Bit-packing and unpacking a 256-body network snapshot |
Cases for code that needs Jolt or Lua are left out of builds without them.
Tracked over time¶
The Benchmarks workflow (.forgejo/workflows/benchmarks.yml, on
git.kreative-kompas.com) builds Release every night on main, on one fixed
machine (soucouyant, in the CI image docker/ci.Dockerfile), runs kke_bench and the stress test (under
Xvfb with Mesa's lavapipe software Vulkan, like the smoke tests), and
appends the results to the
benchmark-data branch:
history.jsonl holds every run, and its README shows a table and a trend
chart per number (Forgejo's push mirror copies the branch to GitHub). A run
started by hand on another branch does a quick pass and prints a comparison
without being recorded, and pull requests do the same quick pass on any free
CI worker (.forgejo/workflows/benchmarks-pr.yml; its numbers aren't
soucouyant's, which is why they only print). The history samples main once a night rather than
listing every commit; runs before 2026-10-04 came from GitHub's shared
runners, so expect one step in each chart where the machine changed.
Every number is compared with the median of the last 10 runs, and changes beyond ±25% are marked in the job log. Nothing fails on a slow number: a machine that also runs other work varies between runs, so a single flag is a reason to look at the chart, not proof. The job does fail when a benchmark crashes, writes no report, or the build has a warning.
benchmarks/track.py does the bookkeeping and needs only Python 3. To
compare your own runs: python3 benchmarks/track.py --history my-history
--commit $(git rev-parse HEAD) --date now benchmark/*.json.
Hardware profiles¶
benchmarks/docker/compose.yml runs both benchmarks inside limits, so
anyone can reproduce the low-end rows on whatever machine they have:
docker compose -f benchmarks/docker/compose.yml run --rm build # once: Release build in build-bench/
docker compose -f benchmarks/docker/compose.yml run --rm floor-1c-2g # reports in benchmark/floor-1c-2g/
| Profile | Cores | RAM | GPU |
|---|---|---|---|
floor-1c-2g |
1 | 2 GB | none (lavapipe) |
laptop-1c-2g-vram512 |
1 | 2 GB | lavapipe, 512 MB VRAM cap |
dual-2c-2g-vram1g |
2 | 2 GB | lavapipe, 1 GB VRAM cap |
mid-4c-8g |
4 | 8 GB | lavapipe |
high-8c-16g |
8 | 16 GB | lavapipe |
intel-arc, amd |
all | all | the host GPU through /dev/dri (and /dev/kfd for AMD) |
nvidia |
all | all | the host GPU (needs nvidia-container-toolkit) |
custom |
KKE_CPUSET |
KKE_MEM |
KKE_VRAM_BUDGET_MB, KKE_GPU_DEVICE, KKE_VK_DRIVER_FILES |
What this can and can't show, honestly:
- Cores are pinned with a cpuset, so the engine sees exactly that many (it sizes its worker threads from CPU affinity). RAM is a hard cgroup limit with no extra swap.
- Docker can't limit VRAM or turn one GPU into another. The engine's
KKE_VRAM_BUDGET_MBsetting caps the Vulkan allocator's device-local heaps, so allocations beyond the cap fail as they would on the smaller card, and the stress report records the cap. It works outside Docker too. - The software-GPU profiles measure the CPU doing the GPU's work, which is the real worst case for "no GPU". They say nothing about how fast a real low-end GPU is; those rows have to come from real hardware.
- Phones and Macs can't be emulated honestly (different CPUs, GPUs and drivers). Their rows come from people's own devices.
Results¶
Release builds, kke_demo without FEMFX (the default build), 1280x720.
Frame times are the stress test's; the kke_bench column is the
rigid_crates_400 median as a CPU speed reference.
Core modules (Settings, Input, RigidBodies, Network, Models, UI, Audio, Scripts, Showcase)¶
| Machine | Profile | GPU | FPS avg | 1% low | Verdict | Jolt step (ms) |
|---|---|---|---|---|---|---|
| Cloud VM, Xeon @ 2.80 GHz (2026-09-26) | floor-1c-2g |
lavapipe (software) | 7.2 | 4.1 | too slow | 1.66 |
| Cloud VM, Xeon @ 2.80 GHz (2026-09-26) | laptop-1c-2g-vram512 |
lavapipe, 512 MB cap | 7.3 | 4.6 | too slow | 1.70 |
| Cloud VM, Xeon @ 2.80 GHz (2026-09-26) | dual-2c-2g-vram1g |
lavapipe, 1 GB cap | 11.8 | 7.6 | too slow | 1.69 |
| Cloud VM, Xeon @ 2.80 GHz (2026-09-26) | mid-4c-8g |
lavapipe (software) | 19.4 | 11.3 | too slow | 1.66 |
| Cloud VM, Xeon @ 2.80 GHz, 4 vCPU, 16 GB (2026-09-26) | whole machine, no Docker | lavapipe (software) | 20.0 | 12.3 | too slow | 1.74 |
What these say: on a machine with no GPU at all, the CPU also draws every pixel, and drawing is most of the frame (on the floor profile the GPU passes take about 95% of it; physics is under 1 ms). The stress test is deliberately harder than a normal scene, so "too slow" here is the worst case, not what a typical game on that machine gets. The fps roughly scale with cores because lavapipe renders on every core it's given. The Jolt column stays flat because kke_bench is single-threaded by design. The VRAM caps didn't change anything: the showcase fits in 512 MB.
Core + FEMFX¶
No rows yet: the default build leaves FEMFX off. Build with
-DKKE_ENABLE_FEMFX=ON and send yours.
Your hardware¶
Real machines are the rows that matter. Run the stress test (and
./kke_bench if you like) on a Release build, then open a
Benchmark result
issue with the reports attached. Rows are added here with the CPU, GPU
and OS from the report, never the host name.