Performance and benchmarks
Numbers from a MacBook Pro (M4 Max), release builds. Reproduce them yourself; every number here has a command next to it.
These are historical measurements made under the Crabby name, retained without rerunning them for the Ponko documentation update. They are specific workload results, not current-version guarantees or proof of overall engine superiority. See the engine comparison and baseline issues #132 and #134 for measurement scope.
ECS iteration
cargo run --release -p ponko_bench
| Benchmark (1M entities) | Crabby | bevy_ecs | hecs |
|---|---|---|---|
iterate (&mut Pos, &Vel) | 0.15 ms | 0.36 ms | 1.77 ms |
par_for_each on a heavy per-entity workload, 10M entities: 8.1 ms on all
cores vs 57.6 ms on one.
Headless simulation
AGENTS=1000000 cargo run --release --example headless
| Agents | ms per tick |
|---|---|
| 1M | 1.0 |
| 10M | 8.7 |
Boids (neighbor search)
HEADLESS=1 BOIDS=<n> cargo run --release --example boids
| Boids | ms per tick |
|---|---|
| 20k | 0.9 |
| 100k | 3.7 |
| 1M | ~33 |
Ant colonies (whole game loop)
HEADLESS=1 ANTS=<n> cargo run --release --example ants: steering with
pheromone sensing, trail laying, foraging, colony growth.
| Ants | ms per tick |
|---|---|
| 5k | 0.22 |
| 50k | 1.2 |
| 1M | 15 |
Flow fields (pathfinding)
cargo run --release -p ponko_bench -- flow for the field itself;
HEADLESS=1 AGENTS=<n> cargo run --release --example flow for units
following it around walls and mud, with a new goal every 2 seconds.
| What | Time |
|---|---|
compute, 256 × 256 cells | 1.25 ms |
compute, 1024 × 1024 cells | 13.0 ms |
1M direction_at lookups, parallel | 0.29 ms |
| 20k units, per tick | 0.14 ms |
| 1M units, per tick | 3.2 ms |
Battle (the flagship demo)
HEADLESS=1 UNITS=<n> TICKS=1200 cargo run --release --example battle:
two armies march along flow fields, keep their spacing, find enemies with
per-team spatial grids, fight, and rebuild from their bases. The map grows
with the army, so the crowd density stays the same.
| Units | Map (cells) | ms per tick |
|---|---|---|
| 20k | 400 × 225 | 0.57 |
| 200k | 1264 × 711 | 4.0 |
| 1M | 2828 × 1590 | 18.9 |
With a window, 20k units run at the display’s 120 FPS with 1.2 ms of simulation per frame.
How it stays fast, in case your game needs the same tricks:
- Marching units look for enemies only on their turn (
ctx.turns(4)), units in a fight every tick. - Separation uses a fine grid of friends, every other tick; enemy search a coarse grid of enemies, at melee reach first and full sight only if nothing is in reach.
- The four grids are rebuilt at the same time (
rayon::scope). - Once a second, units are sorted by
spatial_key, so neighbors on the map are neighbors in memory.
Events and attachments
cargo run --release -p ponko_bench -- events and -- attach:
| What | Per tick |
|---|---|
| 100k events sent, read by another system and dropped | 0.42 ms (4.2 ns per event) |
| 100k attachments following 100k moving entities | 0.66 ms |
Both allocate nothing once running: events reuse their buffers, and attachments are placed in parallel into scratch kept between ticks.
Rendering a million sprites
CRABS=1000000 SCALE=<s> cargo run --release --example bunnymark_ecs:
1M bouncing crabs, drawn by the built-in draw_sprites. The example prints
the render stats described below.
| Crab size on screen | FPS | render CPU | GPU |
|---|---|---|---|
1.6 px (SCALE=0.05) | 120 (display cap) | 3.3 ms | 2.4 ms |
8 px (SCALE=0.25) | 99 | 3.3 ms | 8.6 ms |
32 px (SCALE=1) | 15 | 2.8 ms | 71 ms |
The 32 px row is limited by the GPU filling pixels: on a Retina screen a million 32 px crabs are about 4 billion blended pixels per frame. Fewer or smaller sprites on screen is the only fix for that; the cost per sprite is small (about 1.6 ms of GPU time per million).
What keeps the CPU side small:
draw_spritesbuilds the instance data on all cores (1M sprites: 1.1 ms, was 3.4 ms on one core).- Each sprite is 36 bytes on the GPU (was 52): the per-frame upload for 1M sprites is 36 MB. (A 28-byte version without a texture region measured the same render CPU time; the region is needed for text and atlases.)
Is it the CPU or the GPU?
ctx.render_stats() tells you what drawing the last frame cost:
#![allow(unused)]
fn main() {
use ponko::*;
fn report(ctx: &mut Ctx) {
let r = ctx.render_stats();
println!(
"{} sprites in {} draw calls: {:.2} ms CPU, GPU {:?} ms",
r.sprites, r.draw_calls, r.cpu_ms, r.gpu_ms
);
}
}
gpu_ms is measured on the GPU with timestamp queries, so the frame rate
the window shows (which the compositor can cap or smooth) can’t fool it.
If gpu_ms is close to the frame time, the GPU is the limit: draw fewer
or smaller sprites. If cpu_ms plus your systems’ time
(ctx.system_timings()) is close to it, the CPU is. Agents
get the same numbers from the control endpoint’s info method.
Profiling a frame
(The profiling chapter has the rest: your own timing scopes, counting allocations, headless performance tests with budgets, and what to do about what you find.)
A frame takes about as long as the slower of the CPU (systems, then recording draws) and the GPU. The profiler shows which, and where the time goes, in a running game:
- The overlay (
ctx.set_perf_hud(true), orCRABBY_HUD=1): fps, frame-time percentiles, CPU and GPU time, and the slowest GPU passes. ctx.frame_stats(): the same numbers over the last few seconds: fps, average, p50, p95, p99 and worst frame time, CPU time in systems, in rendering and waiting on the display, and GPU time. Display wait includes pacing; use measured GPU/CPU work to identify the bottleneck.ctx.gpu_passes(): GPU time per pass (each shadow cascade, depth prepass, opaque, transparent, screen effects, bloom, post), measured with timestamp queries. On GPUs that draw in tiles, passes overlap, so each is counted from the end of the one before.ctx.render_stats(): draw calls; 3D models and triangles drawn for the camera and, separately, for the shadow maps; and the render CPU time split into culling (collect_ms), recording (record_ms, all cores) and submitting.ctx.mesh_costs(): per mesh, how many models and triangles it drew for the camera and the shadows last frame, heaviest first.- Traces:
ctx.record_trace(path, frames)(orCRABBY_TRACE=path) writes the next frames as a Chrome trace: every system, rendering, display waits and the GPU passes on a timeline. Open it in Perfetto to see what’s in a hitch.
#![allow(unused)]
fn main() {
/// F3 shows the performance overlay; F4 records the next 300 frames as a
/// trace to open in Perfetto. Slow stretches are logged for investigation.
fn perf_keys(ctx: &mut Ctx) {
if ctx.key_pressed(Key::F3) {
let on = !ctx.perf_hud();
ctx.set_perf_hud(on);
}
if ctx.key_pressed(Key::F4) {
ctx.record_trace("trace.json", 300);
}
let stats = ctx.frame_stats();
if stats.frames > 0 && stats.p99_ms > 20.0 && ctx.frame().is_multiple_of(600) {
// Display wait includes pacing; inspect measured CPU/GPU work separately.
println!("slow: p99 {:.1} ms, gpu {:?} ms", stats.p99_ms, stats.gpu_ms);
for pass in ctx.gpu_passes() {
println!(" {}: {:.2} ms", pass.name, pass.ms);
}
}
}
}
/// F3 shows the performance overlay; F4 records the next 300 frames as a
/// trace to open in Perfetto. Slow stretches are logged for investigation.
static void perf_keys(crabby::ctx& ctx) {
static bool hud = false;
if (ctx.key_pressed(CRABBY_KEY_F3)) ctx.set_perf_hud(hud = !hud);
if (ctx.key_pressed(CRABBY_KEY_F4)) ctx.record_trace("trace.json", 300);
auto stats = ctx.frame_stats();
if (stats.frames > 0 && stats.p99_ms > 20 && ctx.frame() % 600 == 0) {
// Display wait includes pacing; inspect measured CPU/GPU work separately.
std::printf("slow: p99 %.1f ms, gpu %.2f ms\n", stats.p99_ms, stats.gpu_ms);
}
}
Benchmark runs
CRABBY_BENCH=<frames> turns any windowed game into a benchmark: after
CRABBY_BENCH_WARMUP frames (240), it measures that many with vsync off,
prints a report and quits. The report has the frame-time percentiles, the
CPU split, the GPU passes, the slowest systems and the meshes drawing the
most triangles; CRABBY_BENCH_OUT=file.json also writes it as JSON, and
CRABBY_BENCH_MESHES=file.csv writes every mesh’s counts. Frames are
drawn offscreen, so the display’s refresh rate neither caps nor stalls
them (CRABBY_BENCH_PRESENT=1 shows them instead).
CRABBY_BENCH=600 CRABBY_BENCH_OUT=before.json cargo run --release
Run it at a fixed camera before and after a change, and compare.
From an agent
The control endpoint has the same numbers (perf.get,
perf.trace, perf.hud), and the MCP server offers them as
perf_report, record_trace and perf_hud: an agent can ask a running
game why it’s slow, change something, and ask again.
What usually helps in 3D
- Shadows: every caster is drawn again for each shadow cascade it
falls in, also behind the camera. Compare the camera’s triangles with
the shadows’ in
render_stats. Turn shadows off for small things (set_mesh_shadows), and give detailed models a simplerShadowMesh, especially far away. - Distant detail: swap far models for simpler meshes or billboards.
mesh_costsshows which meshes cost the most. - Pixels: on a large Retina screen,
Game::msaa(2)costs about a third less GPU time for the 3D passes than the default 4x.
3D: Crabby vs Unity
The same 3D scene in Crabby and in Unity 6, built the way a normal Unity
game is: one GameObject per cube with a MeshRenderer and a script whose
Update() turns it. Both scenes have:
- N spinning, tinted cubes;
- 12 physically based spheres and a ground;
- a sun with soft shadows (one 2048² shadow map, 60 units);
- sky and ground light and 4x MSAA.
Both run fullscreen at the display’s native resolution with vsync off, on the M4 Max. Each number is the median of three 10-second runs, after a 3-second warmup.
Crabby (left) and Unity 6 (right), 100,000 cubes. Unity’s axes are left-handed, so it sees the field mirrored.
| Spinning cubes | Unity 6 (URP) | Crabby | Crabby’s lead |
|---|---|---|---|
| 1,000 | 728 FPS | 750 FPS | about even |
| 10,000 | 398 FPS | 724 FPS | 1.8x |
| 100,000 | 54 FPS | 402 FPS | 7.5x |
CPU time per frame:
| Spinning cubes | Unity main thread | Crabby (everything but waiting for the display) |
|---|---|---|
| 1,000 | 0.65 ms | 0.17 ms |
| 10,000 | 2.51 ms | 0.43 ms |
| 100,000 | 18.7 ms | 2.48 ms |
Unity’s time goes to the per-object work every GameObject brings: 100k
Update() calls, transform syncing, and culling and batching 100k
renderers. Crabby’s goes to one query that turns every cube and one pass
that culls them, groups them by mesh and uploads them (28 draw calls).
To be fair to Unity:
- SSAO, post-processing and HDR were disabled in this historical comparison. The current engine has additional rendering features.
- The cubes share 64 tinted materials. Giving each its own color, as Crabby does for free, would break Unity’s batching.
- Crabby’s fullscreen frame is 3% smaller: macOS keeps the strip by the camera notch, so it’s 3456 x 2168 against Unity’s 3456 x 2234.
- The Unity player uses Mono, Unity’s default script backend. IL2CPP
would make the
Update()calls somewhat faster. - Each run started 10 seconds after the last one ended, so neither engine inherited the other’s fullscreen exit.
- Unity Entities/DOTS, Jobs and Burst were not measured. The result applies to this GameObject/Mono workload; its performance gap cannot be generalized to Unity’s optimized data-oriented workflows.
Run it yourself:
- Crabby:
CUBES=100000 BENCH_SECONDS=10 cargo run --release --features 3d --example 3d(fullscreen;BENCH_WINDOWED=1keeps the window). - Unity: the project and the steps are in
bench/unity.
On macOS, a game in a window is shown at most once per display refresh
even with vsync off. To measure past 120 FPS, run fullscreen
(Game::fullscreen(true)).
Memory
Speed is half of scale; the other half is what each entity costs. The
headless example prints it: 1M agents with Transform2D, Sprite and a
small Agent component take 76 bytes per entity allocated, spare
capacity included.
From code, world.allocated_bytes() is the total for all component data,
and Archetype::bytes_per_entity() is what one more entity of that exact
component set costs. Agents get both from the control endpoint’s
world.summary (the MCP world_summary tool).
Rules the engine follows
- No heap allocation per frame in steady state on hot paths.
- Components live in tightly packed columns per archetype, so
for_eachcompiles to plain loops the compiler can vectorize. - Performance claims in pull requests come with numbers from release builds.
Tips
- Use
--release. Always. - Prefer
for_each/par_for_eachoveriter()for big queries. - Spawn with
spawn_batch. - Run expensive logic at a reduced rate with
sim_system_every(n, system), or spread per-entity work over ticks withctx.turns(n)(see The simulation clock).
C++
The battle demo in C++ runs at the same speed as the Rust one for 20k units (0.57 ms/tick) and 6% slower for a million (19.97 against 18.85 ms/tick). C++ boids run 1M boids at 34.5 ms/tick headless, against 31.3 ms/tick for the Rust version; plain column updates are the same speed in both (about 0.15 ms per million).