|
## 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}" |