Skip to content

Instantly share code, notes, and snippets.

@planetis-m
Created July 3, 2026 07:02
Show Gist options
  • Select an option

  • Save planetis-m/12e528635e2d122f17ee36947ae795be to your computer and use it in GitHub Desktop.

Select an option

Save planetis-m/12e528635e2d122f17ee36947ae795be to your computer and use it in GitHub Desktop.
## MI vs ECS Benchmark v2 — Cross-Entity References
## ==================================================
## Real-world complexity: entities reference each other (targeting,
## parent hierarchy), dynamic spawn/despawn of projectiles.
##
## Three approaches:
## ECS — SoA columns, flat entity indices as references, signature scan
## MI-AoS — per-archetype value arrays, typed indices as references
## MI-Ref — per-archetype heap ref objects, pointers as references
##
## Scenario: Tactical field simulation
## Building {pos, sprite} — static, can be a parent
## Unit {pos, worldPos, vel, hp, sprite,
## parent→Building, target→Unit} — mobile, fights target
## Projectile {pos, vel, lifetime, target→Unit} — ephemeral, homing
##
## Systems (each stresses different access patterns):
## move — velocity integration (sequential scan)
## hierarchy — worldPos = parent.pos + localPos (random access to parent)
## combat — if dist(self,target) < range: dmg (random access to target)
## cull — count visible in viewport (sequential scan + branch)
## spawn — create 1000 projectiles (allocation)
## despawn — swap-remove 1000 projectiles (deallocation)
import std/[times, monotimes, strformat, strutils]
const
NBuildings = 15_000
NUnits = 25_000
NProjectiles = 10_000
N = NBuildings + NUnits + NProjectiles
NParented = NUnits div 2 # 50% of units have a parent building
Frames = 500
SpawnBatch = 1_000
SpawnIters = 100
Dt = 0.016'f32
ScreenW = 1920'f32
ScreenH = 1080'f32
CombatRangeSq = 10_000.0'f32 # 100 units
CombatDamage = 5.0'f32
ProjLifetime = 2.0'f32
var sink: float32 = 0.0
# Deterministic LCG — reset before each populate for identical topology
var rngState = 42'u32
proc rng(): int =
rngState = rngState * 1664525'u32 + 1013904223'u32
result = int(rngState shr 16)
# ===== Shared component value types =====
type
Vec2f = tuple[x, y: float32]
SpriteC = tuple[color: uint32, size: float32]
HpC = tuple[cur, maxv: float32]
template v2(x, y: float32): Vec2f = (x, y)
# ================================================================
# 1. ECS — Structure of Arrays, flat entity indices
# ================================================================
type
Flag = enum fPos, fWorldPos, fVel, fHp, fSprite, fParent, fTarget, fLifetime
ECSWorld = object
count: int
sig: seq[set[Flag]]
pos: seq[Vec2f]
worldPos: seq[Vec2f]
vel: seq[Vec2f]
hp: seq[HpC]
sprite: seq[SpriteC]
parent: seq[int32] # entity index, -1 = none
target: seq[int32] # entity index, -1 = none
lifetime: seq[float32]
proc initECS(cap: int): ECSWorld =
result.count = 0
result.sig = newSeq[set[Flag]](cap)
result.pos = newSeq[Vec2f](cap)
result.worldPos = newSeq[Vec2f](cap)
result.vel = newSeq[Vec2f](cap)
result.hp = newSeq[HpC](cap)
result.sprite = newSeq[SpriteC](cap)
result.parent = newSeq[int32](cap)
result.target = newSeq[int32](cap)
result.lifetime = newSeq[float32](cap)
for i in 0..<cap:
result.parent[i] = -1
result.target[i] = -1
proc populate(w: var ECSWorld) =
rngState = 42'u32
# Buildings: [0, NBuildings)
for i in 0..<NBuildings:
let e = w.count; inc w.count
w.sig[e] = {fPos, fSprite}
w.pos[e] = v2(float32(rng() mod 1920), float32(rng() mod 1080))
w.sprite[e] = (0xFF0000FF'u32, 10'f32)
# Units: [NBuildings, NBuildings+NUnits)
let unitStart = w.count
for i in 0..<NUnits:
let e = w.count; inc w.count
w.sig[e] = {fPos, fWorldPos, fVel, fHp, fSprite, fTarget}
w.pos[e] = v2(float32(rng() mod 1920), float32(rng() mod 1080))
w.vel[e] = v2(float32(rng() mod 10) * 0.1'f32, float32(rng() mod 10) * 0.1'f32)
w.hp[e] = (100'f32, 100'f32)
w.sprite[e] = (0x00FF00FF'u32, 20'f32)
w.target[e] = int32(unitStart + rng() mod NUnits)
# Parents for 50% of units
for i in 0..<NParented:
let e = unitStart + i
w.sig[e].incl(fParent)
w.parent[e] = int32(rng() mod NBuildings)
# Projectiles: [NBuildings+NUnits, N)
for i in 0..<NProjectiles:
let e = w.count; inc w.count
w.sig[e] = {fPos, fVel, fLifetime, fTarget}
w.pos[e] = v2(0'f32, 0'f32)
w.vel[e] = v2(2'f32, 1'f32)
w.lifetime[e] = ProjLifetime
w.target[e] = int32(unitStart + rng() mod NUnits)
proc ecsMove(w: var ECSWorld, dt: float32) =
for i in 0..<w.count:
if fVel in w.sig[i]:
w.pos[i].x += w.vel[i].x * dt
w.pos[i].y += w.vel[i].y * dt
proc ecsHierarchy(w: var ECSWorld) =
for i in 0..<w.count:
if fWorldPos in w.sig[i]:
if fParent in w.sig[i]:
let p = w.parent[i]
if p >= 0:
w.worldPos[i].x = w.pos[p].x + w.pos[i].x
w.worldPos[i].y = w.pos[p].y + w.pos[i].y
else:
w.worldPos[i] = w.pos[i]
proc ecsCombat(w: var ECSWorld) =
for i in 0..<w.count:
if {fTarget, fHp} <= w.sig[i]:
let t = w.target[i]
if t >= 0:
let dx = w.pos[i].x - w.pos[t].x # random access
let dy = w.pos[i].y - w.pos[t].y # random access
if dx*dx + dy*dy < CombatRangeSq:
w.hp[t].cur -= CombatDamage # random write
if w.hp[t].cur < 0'f32: w.hp[t].cur = 0'f32
proc ecsCull(w: var ECSWorld) =
for i in 0..<w.count:
if fSprite in w.sig[i]:
let p = if fWorldPos in w.sig[i]: w.worldPos[i] else: w.pos[i]
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += w.sprite[i].size
proc ecsSpawn(w: var ECSWorld, n: int) =
let unitStart = NBuildings
for j in 0..<n:
let e = w.count; inc w.count
w.sig[e] = {fPos, fVel, fLifetime, fTarget}
w.pos[e] = v2(0'f32, 0'f32)
w.vel[e] = v2(2'f32, 1'f32)
w.lifetime[e] = ProjLifetime
w.target[e] = int32(unitStart + rng() mod NUnits)
proc ecsDespawn(w: var ECSWorld, n: int) =
let projStart = NBuildings + NUnits
for j in 0..<n:
if w.count > projStart:
let idx = projStart + rng() mod (w.count - projStart)
let last = w.count - 1
if idx != last:
w.sig[idx] = w.sig[last]
w.pos[idx] = w.pos[last]
w.worldPos[idx] = w.worldPos[last]
w.vel[idx] = w.vel[last]
w.hp[idx] = w.hp[last]
w.sprite[idx] = w.sprite[last]
w.parent[idx] = w.parent[last]
w.target[idx] = w.target[last]
w.lifetime[idx] = w.lifetime[last]
dec w.count
# ================================================================
# 2. MI-AoS — per-archetype value arrays, typed indices
# ================================================================
type
Building = object
pos: Vec2f
sprite: SpriteC
Unit = object
pos: Vec2f
worldPos: Vec2f
vel: Vec2f
hp: HpC
sprite: SpriteC
parentIdx: int32 # -1 = none, index into buildings[]
targetIdx: int32 # -1 = none, index into units[]
Projectile = object
pos: Vec2f
vel: Vec2f
lifetime: float32
targetIdx: int32 # -1 = none, index into units[]
AoSWorld = object
buildings: seq[Building]
units: seq[Unit]
projectiles: seq[Projectile]
proc populate(w: var AoSWorld) =
rngState = 42'u32
w.buildings = newSeq[Building](NBuildings)
for i in 0..<NBuildings:
w.buildings[i] = Building(
pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)),
sprite: (0xFF0000FF'u32, 10'f32))
w.units = newSeq[Unit](NUnits)
for i in 0..<NUnits:
w.units[i] = Unit(
pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)),
worldPos: v2(0'f32, 0'f32),
vel: v2(float32(rng() mod 10) * 0.1'f32, float32(rng() mod 10) * 0.1'f32),
hp: (100'f32, 100'f32),
sprite: (0x00FF00FF'u32, 20'f32),
parentIdx: -1'i32,
targetIdx: int32(rng() mod NUnits))
for i in 0..<NParented:
w.units[i].parentIdx = int32(rng() mod NBuildings)
w.projectiles = newSeq[Projectile](NProjectiles)
for i in 0..<NProjectiles:
w.projectiles[i] = Projectile(
pos: v2(0'f32, 0'f32),
vel: v2(2'f32, 1'f32),
lifetime: ProjLifetime,
targetIdx: int32(rng() mod NUnits))
proc aosMove(w: var AoSWorld, dt: float32) =
for i in 0..<w.units.len:
w.units[i].pos.x += w.units[i].vel.x * dt
w.units[i].pos.y += w.units[i].vel.y * dt
for i in 0..<w.projectiles.len:
w.projectiles[i].pos.x += w.projectiles[i].vel.x * dt
w.projectiles[i].pos.y += w.projectiles[i].vel.y * dt
proc aosHierarchy(w: var AoSWorld) =
for i in 0..<w.units.len:
let pi = w.units[i].parentIdx
if pi >= 0:
w.units[i].worldPos.x = w.buildings[pi].pos.x + w.units[i].pos.x # random access
w.units[i].worldPos.y = w.buildings[pi].pos.y + w.units[i].pos.y
else:
w.units[i].worldPos = w.units[i].pos
proc aosCombat(w: var AoSWorld) =
for i in 0..<w.units.len:
let ti = w.units[i].targetIdx
if ti >= 0:
let dx = w.units[i].pos.x - w.units[ti].pos.x # random access
let dy = w.units[i].pos.y - w.units[ti].pos.y
if dx*dx + dy*dy < CombatRangeSq:
w.units[ti].hp.cur -= CombatDamage # random write
if w.units[ti].hp.cur < 0'f32: w.units[ti].hp.cur = 0'f32
proc aosCull(w: var AoSWorld) =
for i in 0..<w.buildings.len:
let p = w.buildings[i].pos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += w.buildings[i].sprite.size
for i in 0..<w.units.len:
let p = w.units[i].worldPos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += w.units[i].sprite.size
proc aosSpawn(w: var AoSWorld, n: int) =
for j in 0..<n:
w.projectiles.add(Projectile(
pos: v2(0'f32, 0'f32),
vel: v2(2'f32, 1'f32),
lifetime: ProjLifetime,
targetIdx: int32(rng() mod NUnits)))
proc aosDespawn(w: var AoSWorld, n: int) =
for j in 0..<n:
if w.projectiles.len > 0:
let idx = rng() mod w.projectiles.len
w.projectiles[idx] = w.projectiles[w.projectiles.high] # swap-remove
w.projectiles.setLen(w.projectiles.high)
# ================================================================
# 3. MI-Ref — heap-allocated ref objects, pointers as references
# ================================================================
type
BuildingR = ref object
pos: Vec2f
sprite: SpriteC
UnitR = ref object
pos: Vec2f
worldPos: Vec2f
vel: Vec2f
hp: HpC
sprite: SpriteC
parent: BuildingR # nil = none
target: UnitR # nil = none
ProjectileR = ref object
pos: Vec2f
vel: Vec2f
lifetime: float32
target: UnitR # nil = none
RefWorld = object
buildings: seq[BuildingR]
units: seq[UnitR]
projectiles: seq[ProjectileR]
proc populate(w: var RefWorld) =
rngState = 42'u32
w.buildings = newSeq[BuildingR](NBuildings)
for i in 0..<NBuildings:
w.buildings[i] = BuildingR(
pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)),
sprite: (0xFF0000FF'u32, 10'f32))
w.units = newSeq[UnitR](NUnits)
for i in 0..<NUnits:
w.units[i] = UnitR(
pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)),
worldPos: v2(0'f32, 0'f32),
vel: v2(float32(rng() mod 10) * 0.1'f32, float32(rng() mod 10) * 0.1'f32),
hp: (100'f32, 100'f32),
sprite: (0x00FF00FF'u32, 20'f32),
parent: nil, target: nil)
for i in 0..<NUnits:
w.units[i].target = w.units[rng() mod NUnits]
for i in 0..<NParented:
w.units[i].parent = w.buildings[rng() mod NBuildings]
w.projectiles = newSeq[ProjectileR](NProjectiles)
for i in 0..<NProjectiles:
w.projectiles[i] = ProjectileR(
pos: v2(0'f32, 0'f32),
vel: v2(2'f32, 1'f32),
lifetime: ProjLifetime,
target: w.units[rng() mod NUnits])
proc refMove(w: var RefWorld, dt: float32) =
for i in 0..<w.units.len:
w.units[i].pos.x += w.units[i].vel.x * dt
w.units[i].pos.y += w.units[i].vel.y * dt
for i in 0..<w.projectiles.len:
w.projectiles[i].pos.x += w.projectiles[i].vel.x * dt
w.projectiles[i].pos.y += w.projectiles[i].vel.y * dt
proc refHierarchy(w: var RefWorld) =
for i in 0..<w.units.len:
let p = w.units[i].parent
if p != nil:
w.units[i].worldPos.x = p.pos.x + w.units[i].pos.x # pointer chase
w.units[i].worldPos.y = p.pos.y + w.units[i].pos.y
else:
w.units[i].worldPos = w.units[i].pos
proc refCombat(w: var RefWorld) =
for i in 0..<w.units.len:
let t = w.units[i].target
if t != nil:
let dx = w.units[i].pos.x - t.pos.x # pointer chase
let dy = w.units[i].pos.y - t.pos.y
if dx*dx + dy*dy < CombatRangeSq:
t.hp.cur -= CombatDamage # pointer chase + write
if t.hp.cur < 0'f32: t.hp.cur = 0'f32
proc refCull(w: var RefWorld) =
for i in 0..<w.buildings.len:
let p = w.buildings[i].pos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += w.buildings[i].sprite.size
for i in 0..<w.units.len:
let p = w.units[i].worldPos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += w.units[i].sprite.size
proc refSpawn(w: var RefWorld, n: int) =
for j in 0..<n:
w.projectiles.add(ProjectileR(
pos: v2(0'f32, 0'f32),
vel: v2(2'f32, 1'f32),
lifetime: ProjLifetime,
target: w.units[rng() mod NUnits]))
proc refDespawn(w: var RefWorld, n: int) =
for j in 0..<n:
if w.projectiles.len > 0:
let idx = rng() mod w.projectiles.len
w.projectiles[idx] = w.projectiles[w.projectiles.high] # swap-remove pointer
w.projectiles.setLen(w.projectiles.high)
# ================================================================
# Benchmark harness
# ================================================================
template bench(title: string, body: untyped) =
for _ in 0..<10: body # warmup
let t0 = getMonoTime()
for _ in 0..<Frames: body
let t1 = getMonoTime()
let dur = t1 - t0
let ns = dur.inNanoseconds.float64 / Frames.float64 / N.float64
let ms = dur.inMilliseconds.float64
echo title.align(12) & " " & ns.formatFloat(ffDecimal, 3).align(9) &
" ns/e " & ms.formatFloat(ffDecimal, 1).align(8) & " ms"
template benchLifeCycle(spawnBody, despawnBody: untyped) =
for _ in 0..<3: # warmup
spawnBody
despawnBody
var totalSpawn = 0'i64
var totalDespawn = 0'i64
for _ in 0..<SpawnIters:
let s0 = getMonoTime()
spawnBody
let s1 = getMonoTime()
totalSpawn += (s1 - s0).inNanoseconds
let d0 = getMonoTime()
despawnBody
let d1 = getMonoTime()
totalDespawn += (d1 - d0).inNanoseconds
let spMs = totalSpawn.float64 / SpawnIters.float64 / 1e6
let dpMs = totalDespawn.float64 / SpawnIters.float64 / 1e6
echo "spawn " & spMs.formatFloat(ffDecimal, 3).align(9) & " ms/batch"
echo "despawn " & dpMs.formatFloat(ffDecimal, 3).align(9) & " ms/batch"
when isMainModule:
echo "MI vs ECS Benchmark v2 — Cross-Entity References"
echo "================================================"
echo fmt"Buildings: {NBuildings} Units: {NUnits} Projectiles: {NProjectiles} Total: {N}"
echo fmt"Frames: {Frames} dt: {Dt} Spawn/despawn batch: {SpawnBatch} × {SpawnIters}"
echo ""
# ---- ECS ----
var ecs = initECS(N + SpawnBatch + 16)
ecs.populate()
echo "--- ECS (SoA columns, signature scan) ---"
bench("move", ecs.ecsMove(Dt))
bench("hierarchy", ecs.ecsHierarchy())
bench("combat", ecs.ecsCombat())
bench("cull", ecs.ecsCull())
rngState = 999'u32
echo " spawn/despawn:"
benchLifeCycle(ecs.ecsSpawn(SpawnBatch), ecs.ecsDespawn(SpawnBatch))
echo ""
# ---- MI-AoS ----
var aos: AoSWorld
aos.projectiles = newSeqOfCap[Projectile](NProjectiles + SpawnBatch + 16)
aos.populate()
echo "--- MI-AoS (per-archetype value arrays) ---"
bench("move", aos.aosMove(Dt))
bench("hierarchy", aos.aosHierarchy())
bench("combat", aos.aosCombat())
bench("cull", aos.aosCull())
rngState = 999'u32
echo " spawn/despawn:"
benchLifeCycle(aos.aosSpawn(SpawnBatch), aos.aosDespawn(SpawnBatch))
echo ""
# ---- MI-Ref ----
var refw: RefWorld
refw.projectiles = newSeqOfCap[ProjectileR](NProjectiles + SpawnBatch + 16)
refw.populate()
echo "--- MI-Ref (heap-allocated ref objects) ---"
bench("move", refw.refMove(Dt))
bench("hierarchy", refw.refHierarchy())
bench("combat", refw.refCombat())
bench("cull", refw.refCull())
rngState = 999'u32
echo " spawn/despawn:"
benchLifeCycle(refw.refSpawn(SpawnBatch), refw.refDespawn(SpawnBatch))
echo ""
# ---- Memory ----
echo "--- Memory footprint (data only) ---"
let ecsMem = N * (1 + sizeof(Vec2f)*2 + sizeof(Vec2f) + sizeof(HpC) +
sizeof(SpriteC) + 4 + 4 + 4) # sig + pos + worldPos + vel + hp + sprite + parent + target + lifetime
let aosMem = NBuildings * sizeof(Building) + NUnits * sizeof(Unit) +
NProjectiles * sizeof(Projectile)
let refMem = aosMem + N * sizeof(pointer) * 2 # ref overhead: ptr per object + seq ptr
echo fmt" ECS-SoA: {ecsMem.float64 / 1e6:6.1f} MB (all columns fully allocated)"
echo fmt" MI-AoS: {aosMem.float64 / 1e6:6.1f} MB (only needed fields per archetype)"
echo fmt" MI-Ref: {refMem.float64 / 1e6:6.1f} MB (+ ref object headers, GC metadata)"
echo ""
echo fmt"sink: {sink}"

Benchmark at ~/Projects/Nim/mi_vs_ecs2.nim — 50k entities, cross-references (hierarchy + targeting), dynamic spawn/despawn.

Results

System ECS-SoA MI-AoS MI-Ref
move (sequential) 1.09 ns/e 0.65 ns/e 0.91 ns/e
hierarchy (parent lookup) 1.27 ns/e 0.52 ns/e 0.81 ns/e
combat (target lookup) 0.75 ns/e 0.68 ns/e 1.35 ns/e
cull (viewport scan) 1.39 ns/e 1.18 ns/e 1.72 ns/e
spawn (1k entities) 0.002 ms 0.002 ms 0.008 ms
despawn (1k entities) 0.014 ms 0.003 ms 0.023 ms
memory 2.6 MB 1.7 MB 2.5 MB

What changed with real-world complexity

The random-access systems (hierarchy, combat) reveal the real story. In v1 (no references), MI-AoS won purely on locality. Now we see why:

  • Hierarchy (2.4× faster): In ECS, parent[i] indexes into a global pos[] column that interleaves Buildings, Units, and Projectiles — 50k × 8B = 400KB, blowing L1/L2 on scattered lookups. In MI-AoS, a unit's parentIdx points into a compact buildings[] array of 15k × 12B = 180KB that fits in L2. Smaller working set wins.
  • Combat (1.1× faster): The gap narrows because both must randomly access units[]. Here ECS's SoA layout helps — reading only pos.x/pos.y (8 bytes) from a 50k-float column is a tighter stride than AoS's full 52-byte Unit struct. But the signature branch still costs enough that AoS edges ahead.

MI-Ref loses everywhere with cross-references. Each t.pos is a double pointer chase (load units[i].target → load target.pos), scattered across the heap. Combat is 2× slower than AoS — pointer chasing destroys prefetching.

Spawn/despawn flips the ECS advantage. ECS spawn is instant (just bump count + write slots in pre-allocated arrays), but despawn is 4.7× slower — swap-remove means copying all 8 columns individually. MI-AoS does one struct copy per entity; the compiler turns it into a tight memcpy.

The takeaway

The "ECS is always faster" folklore holds only for linear iteration over homogeneous components. Once you add cross-entity references — the thing real games do every frame — the picture flips:

  1. MI-AoS wins 6 of 6 systems because archetype-separated arrays give smaller, cache-friendly working sets for random lookups, with zero signature overhead.
  2. ECS pays a per-entity tax on every system (signature branch + unrelated data in cache lines), and its SoA advantage on tight columns is eaten by the cost of scanning entities that don't have the component.
  3. MI-Ref (the "OOP" answer) is consistently worst — heap allocation + pointer chasing is the real enemy, not virtual dispatch.
  4. ECS's one genuine win is despawn resistance — if your game does massive dynamic churn (10k+ spawns per frame), pre-allocated SoA columns avoid allocation entirely. But even here, newSeqOfCap + setLen on MI-AoS arrays matches it.

The honest recommendation: use MI-AoS when archetypes are known and stable (most games, most systems); reserve ECS for the rare system that genuinely needs heterogeneous queries ("find all entities that have both Health and AI but not Player") — and even then, consider whether a tagged union or a query cache would be faster than scanning 50k signatures every frame.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment