Skip to main content
← Blog & Papers

Benchmarking the Laplace Engine: Methodology and First Real Numbers

A reproducible micro-benchmark harness for LplPlugin (allocators, ECS, fixed-point math, physics), with honest numbers, their variance, and the caveats that come with them.

Every figure below comes from lpl-benchmark, an in-tree executable (apps/benchmark) that anyone can build and run. Where a result is unflattering, it stays in: a benchmark you only publish when it looks good is marketing, not engineering.

Methodology

A single timed run tells you almost nothing. It conflates the work you actually care about with scheduler preemption, cold caches, CPU frequency ramp and, in an optimised build, the compiler quietly deleting code whose result is never read. The harness answers all four at once.

Warm-up runs prime the caches and branch predictors before anything is measured. Each timed section then repeats until it reaches a wall-time budget, so a cheap kernel collects thousands of samples and an expensive one at least a handful. The numbers come out as a median (which shrugs off preemption outliers), a coefficient of variation (CV, the relative spread), a min (the best case, where interference is lowest) and a p99 tail. Finally, a doNotOptimize() / clobberMemory() pair stands between the compiler and the result: it forces the work to be computed and stored. That last point is not academic. It is precisely why the earlier single-shot harness reported 0.000 ms on tight numeric loops: the optimiser had erased them.

Test machine

CPUIntel Core Ultra 7 165H (22 logical cores)
RAM31 GiB
OSLinux 6.6 (WSL2) on Windows
CompilerGCC 15.2.0, C++23
BuildRelease: -O2/fastest, -fno-exceptions, -fno-rtti

Caveat: read the variance, not just the median. This run was captured under WSL2, where the CPU frequency governor is not exposed to the guest, so the cores throttle and boost freely mid-run. That inflates the CV on the shortest measurements (a few exceed ±20 %). Concretely, these are the numbers of a developer laptop, not those of a pinned, isolated, performance-governor CI box: read them as orders of magnitude and as ratios between approaches, not as absolute records. The harness prints the governor and flags it whenever it is not performance.

Memory allocators

The same workload, 100 000 fixed 64-byte blocks, run through three strategies. This is where the custom allocators have to earn their place against malloc.

AllocatorStrategyMedianvs malloc
Poolintrusive free-list, per-block acquire/release441 µs3.5× faster
Arenabump-pointer alloc, single O(1) mass reset887 µs1.7× faster
malloc/freelibc baseline1.53 ms1.0×

The pool wins because acquire and release are a single linked-list pop and push, with no size-class lookup on the way. The arena does more work per call here, since it recomputes alignment on each of the 100k allocations. Its real edge does not even show up in a per-block micro-benchmark: it is the ability to reclaim the whole slab in one pointer reset, which only pays off at end-of-frame.

Fixed-point math

The engine keeps anything that must be deterministic across machines (physics, spatial hashing) in Q16.16 fixed-point, because IEEE floating-point can differ bit-for-bit from one CPU to another.

KernelMedianNotes
Fixed32 multiply1.38 ms / 1M ops≈ 1.4 ns per multiply
Morton encode3D2.62 ms / 1M ops3D → Z-order interleave

CORDIC vs libm: an honest trade-off

Cordic::sin computes a sine with nothing but shifts and adds, so it needs no FPU, which is the whole point on the freestanding i686 kernel target. It is benchmarked over its convergent domain [-π/2, π/2], since this CORDIC does no argument range reduction and expects the caller to fold angles into that range first.

sin(x), 1M evaluationsMedianAccuracy vs libm
CORDIC (Fixed32)10.7 msmax abs err 1.8·10⁻⁴, RMS 3.3·10⁻⁵
std::sin (double, libm)4.3 msreference

The accuracy is excellent for a 16-bit fractional format. The speed is where you have to be honest: on x86, CORDIC comes out about 2.5× slower than a modern vectorised libm. What it buys is not throughput but determinism and independence from the FPU (it runs identically in ring-0 with no floating-point unit enabled). On the i686 kernel target with soft-float, the comparison flips the other way.

ECS: entities and lookups

OperationMedianRate
Create + destroy 10k entities332 µs
Create 100k entities (batch)1.84 ms≈ 54M entities/s
SparseSet lookup (O(1))468 ns / 1k lookups~0.47 ns each
Linear scan (O(n))2.18 ms / 1k lookups~4600× slower

That last row is the whole reason the ECS keys entities through a sparse set. At 10k entities, an O(1) resolution is already about 4600× faster than a naïve scan, and since the scan is linear in the entity count, the gap only widens as the world grows.

Data layout: SoA vs AoS

Summing a single component (x) across 1M elements, Structure-of-Arrays against Array-of-Structs:

LayoutMedian (best-case min)
SoA (float[])453 µs (436 µs)
AoS (Vec3[])562 µs (503 µs)

SoA runs about 1.2 to 1.3× faster here because it touches only the cache lines it needs, whereas AoS drags y and z through cache for nothing. The margin is modest at this stride, and noisy under WSL, but it is the right sign, and it grows with wider components or with real per-element work. This is why the ECS stores components column-major.

Physics

The CPU physics backend runs the full pipeline on every step: semi-implicit Euler integration, then AABB collision detection with impulse resolution, then sleeping. The collision pass is O(n²) per chunk, and it dominates everything else.

EntitiesMedian stepThroughputVerdict @ 60 fps
1 0003.30 ms3.0·10⁵ ent/sreal-time
2 0006.74 ms3.0·10⁵ ent/sreal-time
5 00016.1 ms3.1·10⁵ ent/splayable
10 00031.2 ms3.2·10⁵ ent/sbelow 30 fps
20 00051.4 ms3.9·10⁵ ent/sbelow 30 fps

Read the two trends together: the throughput stays flat while the step time roughly doubles with each doubling of the entity count. That is the quadratic wall. Brute-force collision does not scale, and past a few thousand bodies a single 16.6 ms frame budget is already gone. This is the measured motivation for the broad-phase and GPU paths, rather than a number picked to look impressive.

Broad-phase pays for itself

Isolating collision at 2 000 bodies, spatial partitioning against brute force:

MethodMedian
Broad-phase queryRadius (WorldPartition cells)588 µs
N² AABB pair check4.58 ms

A 7.8× win at 2k, and it grows with n. The spatial index that buys it is cheap to build: populating a 10k-entity WorldPartition costs 1.42 ms.

A note on WorldPartition::step(). Its CPU path is, by design, only a gateway that dispatches to the GPU: it does no integration. Timing it directly therefore returns a meaningless ~17 ns, which is the cost of a function call. The physics numbers above come from CpuPhysicsBackend, which does the real work. Measuring the gateway instead of the engine is exactly the mistake this harness exists to prevent.

Concurrency & containers

OperationMedian
FlatAtomicHashMap insert 16k313 µs
FlatAtomicHashMap get 16k327 µs
FlatAtomicHashMap forEach 16k318 µs
FlatAtomicHashMap forEach parallel 16k (4 threads)739 µs
FlatAtomicHashMap insert/remove churn 16k5.31 ms
ThreadPool dispatch 10k tasks~35 ms (high variance)

Two results are worth stopping on. The parallel forEach is slower than the serial one at 16k elements, which looks wrong until you count the work: a single add per element cannot cover the cost of dispatching threads and bouncing cache lines between them, so parallelism only starts to help once the per-element work is heavy enough to hide that overhead. The ThreadPool figure is similar in spirit: dispatching 10k trivial tasks is dominated by the per-task std::future allocation, and the wide CV says as much. It stresses the queue, it does not model a realistic workload.

Reproducing

git clone --recursive <LplPlugin>
cd LplPlugin
xmake f -m release -y
xmake build lpl-benchmark
xmake run lpl-benchmark      # or: ./build/linux/<arch>/release/lpl-benchmark

The binary opens with its own system-info block (CPU, cores, RAM, OS, compiler, build config, governor), so every set of results carries its own context. For low-variance numbers, run it on a machine set to the performance governor with the process pinned to an isolated core. The WSL2 figures here are, on purpose, plain developer-laptop conditions.

What’s next

The network path (zero-copy from NIC to engine to GPU) is not measured yet: the harness above is the template it will use once the in-kernel driver path is wired end-to-end. When it lands, it gets the same treatment. Real numbers, their variance, and the caveats, or it does not get published.