Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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)Crabbybevy_ecshecs
iterate (&mut Pos, &Vel)0.15 ms0.36 ms1.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

Agentsms per tick
1M1.0
10M8.7

HEADLESS=1 BOIDS=<n> cargo run --release --example boids

Boidsms per tick
20k0.9
100k3.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.

Antsms per tick
5k0.22
50k1.2
1M15

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.

WhatTime
compute, 256 × 256 cells1.25 ms
compute, 1024 × 1024 cells13.0 ms
1M direction_at lookups, parallel0.29 ms
20k units, per tick0.14 ms
1M units, per tick3.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.

UnitsMap (cells)ms per tick
20k400 × 2250.57
200k1264 × 7114.0
1M2828 × 159018.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:

WhatPer tick
100k events sent, read by another system and dropped0.42 ms (4.2 ns per event)
100k attachments following 100k moving entities0.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 screenFPSrender CPUGPU
1.6 px (SCALE=0.05)120 (display cap)3.3 ms2.4 ms
8 px (SCALE=0.25)993.3 ms8.6 ms
32 px (SCALE=1)152.8 ms71 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_sprites builds 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), or CRABBY_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) (or CRABBY_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 simpler ShadowMesh, especially far away.
  • Distant detail: swap far models for simpler meshes or billboards. mesh_costs shows 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 cubesUnity 6 (URP)CrabbyCrabby’s lead
1,000728 FPS750 FPSabout even
10,000398 FPS724 FPS1.8x
100,00054 FPS402 FPS7.5x

CPU time per frame:

Spinning cubesUnity main threadCrabby (everything but waiting for the display)
1,0000.65 ms0.17 ms
10,0002.51 ms0.43 ms
100,00018.7 ms2.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=1 keeps 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_each compiles 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_each over iter() 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 with ctx.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).