Skip to content

Instantly share code, notes, and snippets.

@planetis-m
Last active July 3, 2026 09:10
Show Gist options
  • Select an option

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

Select an option

Save planetis-m/0073057845198d404fb8eb0ced83856b to your computer and use it in GitHub Desktop.
## Archetype ECS vs MI-AoS Benchmark
## ==================================
## Two approaches for a mixin-based entity simulation with cross-entity refs:
##
## 1. ECS-Archetype — SoA columns within archetype buckets, no signature scan
## 2. MI-AoS — per-archetype value arrays (composition), direct field access
##
## 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
Frames = 500
SpawnBatch = 1_000
SpawnIters = 100
Dt = 0.016'f32
ScreenW = 1920'f32
ScreenH = 1080'f32
CombatRangeSq = 10_000.0'f32
CombatDamage = 5.0'f32
ProjLifetime = 2.0'f32
var sink: float32 = 0.0
var rngState = 42'u32
proc rng(): int =
rngState = rngState * 1664525'u32 + 1013904223'u32
result = int(rngState shr 16)
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-Archetype — SoA columns within archetype buckets
# ================================================================
type
ComponentKind = enum
ckTransform, ckVelocity, ckHealth, ckTarget, ckSprite, ckParent, ckLifetime
ArchetypeKind = enum
BuildingArch, UnitArch, ProjectileArch
TransformC = object
pos: Vec2f
worldPos: Vec2f
VelocityC = object
vel: Vec2f
HealthC = object
hp: HpC
TargetC = object
targetIdx: int32
SpriteCol = object
sprite: SpriteC
ParentC = object
parentIdx: int32
LifetimeC = object
lifetime: float32
Archetype = object
mask: set[ComponentKind]
columns: array[ComponentKind, pointer]
ArchWorld = object
archetypes: array[ArchetypeKind, Archetype]
proc newColumn[T](): pointer =
result = create(seq[T])
proc destroyColumn[T](column: pointer) =
if column != nil:
`=destroy`(cast[ptr seq[T]](column)[])
dealloc(column)
proc `=destroy`(x: var Archetype) =
if ckTransform in x.mask: destroyColumn[TransformC](x.columns[ckTransform])
if ckVelocity in x.mask: destroyColumn[VelocityC](x.columns[ckVelocity])
if ckHealth in x.mask: destroyColumn[HealthC](x.columns[ckHealth])
if ckTarget in x.mask: destroyColumn[TargetC](x.columns[ckTarget])
if ckSprite in x.mask: destroyColumn[SpriteCol](x.columns[ckSprite])
if ckParent in x.mask: destroyColumn[ParentC](x.columns[ckParent])
if ckLifetime in x.mask: destroyColumn[LifetimeC](x.columns[ckLifetime])
proc `=wasMoved`(x: var Archetype) =
x.mask = {}
for ck in ComponentKind: x.columns[ck] = nil
proc `=copy`(dest: var Archetype; src: Archetype) {.error.}
proc `=dup`(src: Archetype): Archetype {.error.}
proc `=destroy`(x: var ArchWorld) =
for kind in ArchetypeKind: `=destroy`(x.archetypes[kind])
proc `=wasMoved`(x: var ArchWorld) =
for kind in ArchetypeKind: `=wasMoved`(x.archetypes[kind])
proc `=copy`(dest: var ArchWorld; src: ArchWorld) {.error.}
proc `=dup`(src: ArchWorld): ArchWorld {.error.}
template col(w, kind, ck, T): untyped =
cast[ptr seq[T]](w.archetypes[kind].columns[ck])[]
template colData(w, kind, ck, T): untyped =
cast[ptr UncheckedArray[T]](addr col(w, kind, ck, T)[0])
template colLen(w, kind, ck, T): untyped =
col(w, kind, ck, T).len
proc initArchetype(mask: set[ComponentKind]): Archetype =
result = Archetype(mask: mask)
if ckTransform in mask: result.columns[ckTransform] = newColumn[TransformC]()
if ckVelocity in mask: result.columns[ckVelocity] = newColumn[VelocityC]()
if ckHealth in mask: result.columns[ckHealth] = newColumn[HealthC]()
if ckTarget in mask: result.columns[ckTarget] = newColumn[TargetC]()
if ckSprite in mask: result.columns[ckSprite] = newColumn[SpriteCol]()
if ckParent in mask: result.columns[ckParent] = newColumn[ParentC]()
if ckLifetime in mask: result.columns[ckLifetime] = newColumn[LifetimeC]()
proc reserve(w: var ArchWorld; kind: ArchetypeKind; cap: int) =
template reserveCol(ck, T): untyped =
if ck in w.archetypes[kind].mask:
cast[ptr seq[T]](w.archetypes[kind].columns[ck])[] = newSeqOfCap[T](cap)
reserveCol(ckTransform, TransformC)
reserveCol(ckVelocity, VelocityC)
reserveCol(ckHealth, HealthC)
reserveCol(ckTarget, TargetC)
reserveCol(ckSprite, SpriteCol)
reserveCol(ckParent, ParentC)
reserveCol(ckLifetime, LifetimeC)
proc initArchWorld(capBuild, capUnit, capProj: int): ArchWorld =
result.archetypes[BuildingArch] = initArchetype({ckTransform, ckSprite})
result.archetypes[UnitArch] = initArchetype({ckTransform, ckVelocity, ckHealth, ckTarget, ckSprite, ckParent})
result.archetypes[ProjectileArch] = initArchetype({ckTransform, ckVelocity, ckTarget, ckLifetime})
result.reserve(BuildingArch, capBuild)
result.reserve(UnitArch, capUnit)
result.reserve(ProjectileArch, capProj)
proc populate(w: var ArchWorld) =
rngState = 42'u32
for i in 0..<NBuildings:
col(w, BuildingArch, ckTransform, TransformC).add(
TransformC(pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)),
worldPos: v2(0'f32, 0'f32)))
col(w, BuildingArch, ckSprite, SpriteCol).add(
SpriteCol(sprite: (0xFF0000FF'u32, 10'f32)))
for i in 0..<NUnits:
col(w, UnitArch, ckTransform, TransformC).add(
TransformC(pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)),
worldPos: v2(0'f32, 0'f32)))
col(w, UnitArch, ckVelocity, VelocityC).add(
VelocityC(vel: v2(float32(rng() mod 10) * 0.1'f32, float32(rng() mod 10) * 0.1'f32)))
col(w, UnitArch, ckHealth, HealthC).add(HealthC(hp: (100'f32, 100'f32)))
col(w, UnitArch, ckTarget, TargetC).add(
TargetC(targetIdx: int32(rng() mod NUnits)))
col(w, UnitArch, ckSprite, SpriteCol).add(
SpriteCol(sprite: (0x00FF00FF'u32, 20'f32)))
col(w, UnitArch, ckParent, ParentC).add(
ParentC(parentIdx: if i < NParented: int32(rng() mod NBuildings) else: -1'i32))
for i in 0..<NProjectiles:
col(w, ProjectileArch, ckTransform, TransformC).add(
TransformC(pos: v2(0'f32, 0'f32), worldPos: v2(0'f32, 0'f32)))
col(w, ProjectileArch, ckVelocity, VelocityC).add(VelocityC(vel: v2(2'f32, 1'f32)))
col(w, ProjectileArch, ckTarget, TargetC).add(
TargetC(targetIdx: int32(rng() mod NUnits)))
col(w, ProjectileArch, ckLifetime, LifetimeC).add(
LifetimeC(lifetime: ProjLifetime))
proc archMove(w: var ArchWorld, dt: float32) =
let n = colLen(w, UnitArch, ckTransform, TransformC)
let transforms = colData(w, UnitArch, ckTransform, TransformC)
let velocities = colData(w, UnitArch, ckVelocity, VelocityC)
for i in 0..<n:
transforms[i].pos.x += velocities[i].vel.x * dt
transforms[i].pos.y += velocities[i].vel.y * dt
let pn = colLen(w, ProjectileArch, ckTransform, TransformC)
let ptransforms = colData(w, ProjectileArch, ckTransform, TransformC)
let pvelocities = colData(w, ProjectileArch, ckVelocity, VelocityC)
for i in 0..<pn:
ptransforms[i].pos.x += pvelocities[i].vel.x * dt
ptransforms[i].pos.y += pvelocities[i].vel.y * dt
proc archHierarchy(w: var ArchWorld) =
let n = colLen(w, UnitArch, ckTransform, TransformC)
let transforms = colData(w, UnitArch, ckTransform, TransformC)
let parents = colData(w, UnitArch, ckParent, ParentC)
let buildingTransforms = colData(w, BuildingArch, ckTransform, TransformC)
for i in 0..<n:
let pi = parents[i].parentIdx
if pi >= 0:
transforms[i].worldPos.x = buildingTransforms[pi].pos.x + transforms[i].pos.x
transforms[i].worldPos.y = buildingTransforms[pi].pos.y + transforms[i].pos.y
else:
transforms[i].worldPos = transforms[i].pos
proc archCombat(w: var ArchWorld) =
let n = colLen(w, UnitArch, ckTransform, TransformC)
let transforms = colData(w, UnitArch, ckTransform, TransformC)
let targets = colData(w, UnitArch, ckTarget, TargetC)
let healths = colData(w, UnitArch, ckHealth, HealthC)
for i in 0..<n:
let ti = targets[i].targetIdx
if ti >= 0:
let dx = transforms[i].pos.x - transforms[ti].pos.x
let dy = transforms[i].pos.y - transforms[ti].pos.y
if dx*dx + dy*dy < CombatRangeSq:
healths[ti].hp.cur -= CombatDamage
if healths[ti].hp.cur < 0'f32: healths[ti].hp.cur = 0'f32
proc archCull(w: var ArchWorld) =
let bn = colLen(w, BuildingArch, ckTransform, TransformC)
let btransforms = colData(w, BuildingArch, ckTransform, TransformC)
let bsprites = colData(w, BuildingArch, ckSprite, SpriteCol)
for i in 0..<bn:
let p = btransforms[i].pos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += bsprites[i].sprite.size
let un = colLen(w, UnitArch, ckTransform, TransformC)
let utransforms = colData(w, UnitArch, ckTransform, TransformC)
let usprites = colData(w, UnitArch, ckSprite, SpriteCol)
for i in 0..<un:
let p = utransforms[i].worldPos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += usprites[i].sprite.size
proc archSpawn(w: var ArchWorld, n: int) =
for j in 0..<n:
col(w, ProjectileArch, ckTransform, TransformC).add(
TransformC(pos: v2(0'f32, 0'f32), worldPos: v2(0'f32, 0'f32)))
col(w, ProjectileArch, ckVelocity, VelocityC).add(VelocityC(vel: v2(2'f32, 1'f32)))
col(w, ProjectileArch, ckTarget, TargetC).add(
TargetC(targetIdx: int32(rng() mod NUnits)))
col(w, ProjectileArch, ckLifetime, LifetimeC).add(
LifetimeC(lifetime: ProjLifetime))
proc archDespawn(w: var ArchWorld, n: int) =
let ptf = addr col(w, ProjectileArch, ckTransform, TransformC)
for j in 0..<n:
let len = ptf[].len
if len > 0:
let idx = rng() mod len
let last = len - 1
if idx != last:
col(w, ProjectileArch, ckTransform, TransformC)[idx] = col(w, ProjectileArch, ckTransform, TransformC)[last]
col(w, ProjectileArch, ckVelocity, VelocityC)[idx] = col(w, ProjectileArch, ckVelocity, VelocityC)[last]
col(w, ProjectileArch, ckTarget, TargetC)[idx] = col(w, ProjectileArch, ckTarget, TargetC)[last]
col(w, ProjectileArch, ckLifetime, LifetimeC)[idx] = col(w, ProjectileArch, ckLifetime, LifetimeC)[last]
col(w, ProjectileArch, ckTransform, TransformC).setLen(last)
col(w, ProjectileArch, ckVelocity, VelocityC).setLen(last)
col(w, ProjectileArch, ckTarget, TargetC).setLen(last)
col(w, ProjectileArch, ckLifetime, LifetimeC).setLen(last)
# ================================================================
# 2. MI-AoS — per-archetype value arrays (composition)
# ================================================================
type
Building = object
pos: Vec2f
sprite: SpriteC
Unit = object
pos: Vec2f
worldPos: Vec2f
vel: Vec2f
hp: HpC
sprite: SpriteC
parentIdx: int32
targetIdx: int32
Projectile = object
pos: Vec2f
vel: Vec2f
lifetime: float32
targetIdx: int32
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: if i < NParented: int32(rng() mod NBuildings) else: -1'i32,
targetIdx: int32(rng() mod NUnits))
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
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
let dy = w.units[i].pos.y - w.units[ti].pos.y
if dx*dx + dy*dy < CombatRangeSq:
w.units[ti].hp.cur -= CombatDamage
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]
w.projectiles.setLen(w.projectiles.high)
# ================================================================
# 3. MI-AoS-Split — hot/cold split per archetype
# ================================================================
type
BuildingHot = object
pos: Vec2f
BuildingCold = object
sprite: SpriteC
UnitHot = object # 24 bytes — touched by move + hierarchy + combat
pos: Vec2f
worldPos: Vec2f
vel: Vec2f
UnitCold = object # 24 bytes — touched only by combat + cull
hp: HpC
sprite: SpriteC
parentIdx: int32
targetIdx: int32
SplitWorld = object
buildingHot: seq[BuildingHot]
buildingCold: seq[BuildingCold]
unitHot: seq[UnitHot]
unitCold: seq[UnitCold]
projectiles: seq[Projectile]
proc populate(w: var SplitWorld) =
rngState = 42'u32
w.buildingHot = newSeq[BuildingHot](NBuildings)
w.buildingCold = newSeq[BuildingCold](NBuildings)
for i in 0..<NBuildings:
w.buildingHot[i] = BuildingHot(pos: v2(float32(rng() mod 1920), float32(rng() mod 1080)))
w.buildingCold[i] = BuildingCold(sprite: (0xFF0000FF'u32, 10'f32))
w.unitHot = newSeq[UnitHot](NUnits)
w.unitCold = newSeq[UnitCold](NUnits)
for i in 0..<NUnits:
w.unitHot[i] = UnitHot(
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))
w.unitCold[i] = UnitCold(
hp: (100'f32, 100'f32),
sprite: (0x00FF00FF'u32, 20'f32),
parentIdx: if i < NParented: int32(rng() mod NBuildings) else: -1'i32,
targetIdx: int32(rng() mod NUnits))
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 splitMove(w: var SplitWorld, dt: float32) =
for i in 0..<w.unitHot.len: # only touches 24 bytes, not 48
w.unitHot[i].pos.x += w.unitHot[i].vel.x * dt
w.unitHot[i].pos.y += w.unitHot[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 splitHierarchy(w: var SplitWorld) =
for i in 0..<w.unitHot.len:
let pi = w.unitCold[i].parentIdx
if pi >= 0:
w.unitHot[i].worldPos.x = w.buildingHot[pi].pos.x + w.unitHot[i].pos.x
w.unitHot[i].worldPos.y = w.buildingHot[pi].pos.y + w.unitHot[i].pos.y
else:
w.unitHot[i].worldPos = w.unitHot[i].pos
proc splitCombat(w: var SplitWorld) =
for i in 0..<w.unitHot.len:
let ti = w.unitCold[i].targetIdx
if ti >= 0:
let dx = w.unitHot[i].pos.x - w.unitHot[ti].pos.x
let dy = w.unitHot[i].pos.y - w.unitHot[ti].pos.y
if dx*dx + dy*dy < CombatRangeSq:
w.unitCold[ti].hp.cur -= CombatDamage
if w.unitCold[ti].hp.cur < 0'f32: w.unitCold[ti].hp.cur = 0'f32
proc splitCull(w: var SplitWorld) =
for i in 0..<w.buildingHot.len:
let p = w.buildingHot[i].pos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += w.buildingCold[i].sprite.size
for i in 0..<w.unitHot.len:
let p = w.unitHot[i].worldPos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += w.unitCold[i].sprite.size
proc splitSpawn(w: var SplitWorld, 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 splitDespawn(w: var SplitWorld, 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]
w.projectiles.setLen(w.projectiles.high)
# ================================================================
# Benchmark harness
# ================================================================
template bench(title: string, body: untyped) =
for _ in 0..<10: body
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:
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 "Archetype ECS vs MI-AoS Benchmark"
echo "================================="
echo fmt"Buildings: {NBuildings} Units: {NUnits} Projectiles: {NProjectiles} Total: {N}"
echo fmt"Frames: {Frames} Spawn/despawn batch: {SpawnBatch} x {SpawnIters}"
echo ""
# ---- ECS-Archetype ----
var arch = initArchWorld(NBuildings, NUnits, NProjectiles + SpawnBatch + 16)
arch.populate()
echo "--- ECS-Archetype (SoA columns per archetype) ---"
bench("move", arch.archMove(Dt))
bench("hierarchy", arch.archHierarchy())
bench("combat", arch.archCombat())
bench("cull", arch.archCull())
rngState = 999'u32
echo " spawn/despawn:"
benchLifeCycle(arch.archSpawn(SpawnBatch), arch.archDespawn(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-AoS-Split ----
var spl: SplitWorld
spl.projectiles = newSeqOfCap[Projectile](NProjectiles + SpawnBatch + 16)
spl.populate()
echo "--- MI-AoS-Split (hot/cold split per archetype) ---"
bench("move", spl.splitMove(Dt))
bench("hierarchy", spl.splitHierarchy())
bench("combat", spl.splitCombat())
bench("cull", spl.splitCull())
rngState = 999'u32
echo " spawn/despawn:"
benchLifeCycle(spl.splitSpawn(SpawnBatch), spl.splitDespawn(SpawnBatch))
echo ""
# ---- Memory ----
echo "--- Memory footprint (data only) ---"
let archMem = NBuildings * (sizeof(TransformC) + sizeof(SpriteCol)) +
NUnits * (sizeof(TransformC) + sizeof(VelocityC) + sizeof(HealthC) +
sizeof(TargetC) + sizeof(SpriteCol) + sizeof(ParentC)) +
NProjectiles * (sizeof(TransformC) + sizeof(VelocityC) +
sizeof(TargetC) + sizeof(LifetimeC))
let aosMem = NBuildings * sizeof(Building) + NUnits * sizeof(Unit) +
NProjectiles * sizeof(Projectile)
let splMem = NBuildings * (sizeof(BuildingHot) + sizeof(BuildingCold)) +
NUnits * (sizeof(UnitHot) + sizeof(UnitCold)) +
NProjectiles * sizeof(Projectile)
echo fmt" ECS-Archetype: {archMem.float64 / 1e6:6.1f} MB (SoA columns, only needed comps)"
echo fmt" MI-AoS: {aosMem.float64 / 1e6:6.1f} MB (struct arrays per archetype)"
echo fmt" MI-AoS-Split: {splMem.float64 / 1e6:6.1f} MB (hot/cold struct arrays)"
echo ""
echo fmt"sink: {sink}"

Results

System ECS-Archetype MI-AoS Winner
move (sequential) 0.23 ns/e 0.65 ns/e Arch (2.8x)
hierarchy (parent lookup) 0.26 ns/e 0.58 ns/e Arch (2.2x)
combat (target lookup) 0.43 ns/e 0.78 ns/e Arch (1.8x)
cull (viewport scan) 1.99 ns/e 1.27 ns/e AoS (1.6x)
spawn (1k entities) 0.003 ms 0.002 ms AoS (1.5x)
despawn (1k entities) 0.007 ms 0.003 ms AoS (2.3x)
memory 1.9 MB 1.7 MB AoS

Archetype ECS wins iteration (move/hierarchy/combat) by 1.8-2.8x. SoA columns mean each system touches only the bytes it needs — move reads 16 bytes (pos+vel) per entity instead of the full 52-byte Unit struct. Narrower working set, better cache utilization, more entities per cache line.

MI-AoS wins cull by 1.6x. Cull needs worldPos + sprite (12 bytes) which live in the same struct — one cache line has both. Archetype ECS must walk two separate columns in parallel, doubling the number of cache lines touched.

MI-AoS wins despawn by 2.3x. One struct assignment + setLen vs four separate column swap-removes + four setLen calls. The structural cost scales with column count.

The split is clean and predictable: whoever wastes fewer cache lines wins that system.

Results

System ECS-Archetype MI-AoS MI-AoS-Split
move 0.22 ns/e 0.68 ns/e 0.63 ns/e
hierarchy 0.26 ns/e 0.55 ns/e 0.60 ns/e
combat 0.43 ns/e 0.73 ns/e 0.73 ns/e
cull 2.05 ns/e 1.16 ns/e 1.21 ns/e
despawn 0.007 ms 0.003 ms 0.003 ms
memory 1.9 MB 1.7 MB 1.7 MB

The hot/cold split barely helped

This is the surprising result. Splitting Unit from 48 bytes into two 24-byte halves should have doubled cache density on move — but it barely moved (0.68→0.63, 7% gain). Why?

The Unit is already small enough. At 48 bytes, the entire struct fits in a single 64-byte cache line (with some padding). Splitting into two 24-byte halves doesn't reduce cache misses — you still pull one cache line per unit either way. The gain only appears when a struct spans multiple cache lines (e.g. a 128-byte struct split into two 64-byte halves).

The split actually hurt hierarchy (0.55→0.60). The hierarchy system needs parentIdx (cold) to look up the parent's pos (hot), so it now crosses two cache lines per unit instead of one. Splitting forced a cold→hot access pattern that didn't exist before.

When does hot/cold splitting actually win?

The pattern helps when the struct is larger than a cache line and systems touch mutually exclusive fields. Our 48-byte Unit is simply too small. The split would matter for a 200-byte entity with distinct subsystems:

PhysicsBody {pos, vel, accel, force, torque, mass, ...}  // 96 bytes — physics system only
RenderState {mesh, materials, bones, shader, ...}        // 64 bytes — render system only
GameLogic   {hp, aiState, target, inventory, ...}         // 72 bytes — game logic only

Each system pulls only its own cache line, and splitting avoids pulling 232 bytes when you need 96. But for small composites under 64 bytes, splitting adds indirection without reducing cache pressure.

The most common fix isn't splitting — it's the archetype ECS. That's exactly what it does: it splits everything into per-field columns, getting optimal cache density for every system regardless of struct size. The cost is the despawn complexity (N column copies). For small structs, MI-AoS with a single array is simpler and cache-equivalent.

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