Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save planetis-m/5347a329007523a4520fb4502e9595ac to your computer and use it in GitHub Desktop.
## MI vs ECS Benchmark v3 — Grouped SoA + Archetype ECS
## ====================================================
## Addresses v2 feedback: fragmented single-field columns waste cache.
##
## Four approaches:
## 1. ECS-Frag — 8 separate SoA columns (baseline, from v2)
## 2. ECS-Grouped — 5 grouped columns based on system access patterns
## 3. ECS-Archetype — archetype buckets with DenseVec columns (from paste)
## 4. MI-AoS — per-archetype value arrays (baseline winner)
##
## Grouping rationale (by system access, not conceptual meaning):
## SpatialC { pos, vel } → move system touches 1 column
## ViewC { worldPos, sprite } → cull system touches 1 column
## CombatC { hp, targetIdx } → combat system: hp+target in one cache line
## LinkC { parentIdx } → hierarchy system
## LifeC { lifetime } → projectile expiry
import std/[times, monotimes, strformat, strutils]
from std/typetraits import supportsCopyMem
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-Fragmented — 8 separate SoA columns (baseline)
# ================================================================
type
Flag = enum fPos, fWorldPos, fVel, fHp, fSprite, fParent, fTarget, fLifetime
FragWorld = 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]
target: seq[int32]
lifetime: seq[float32]
proc initFrag(cap: int): FragWorld =
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 FragWorld) =
rngState = 42'u32
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)
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)
for i in 0..<NParented:
let e = unitStart + i
w.sig[e].incl(fParent)
w.parent[e] = int32(rng() mod NBuildings)
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 fragMove(w: var FragWorld, dt: float32) =
for i in 0..<w.count:
if fVel in w.sig[i]:
w.pos[i].x += w.vel[i].x * dt # pos & vel in separate columns
w.pos[i].y += w.vel[i].y * dt
proc fragHierarchy(w: var FragWorld) =
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 fragCombat(w: var FragWorld) =
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
let dy = w.pos[i].y - w.pos[t].y
if dx*dx + dy*dy < CombatRangeSq:
w.hp[t].cur -= CombatDamage
if w.hp[t].cur < 0'f32: w.hp[t].cur = 0'f32
proc fragCull(w: var FragWorld) =
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 fragSpawn(w: var FragWorld, n: int, unitStart: int) =
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 fragDespawn(w: var FragWorld, n: int, projStart: int) =
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. ECS-Grouped — 5 grouped columns based on system access
# ================================================================
type
SpatialG = object # move system: pos+vel together
pos: Vec2f
vel: Vec2f
ViewG = object # cull system: worldPos+sprite together
worldPos: Vec2f
sprite: SpriteC
CombatG = object # combat system: hp+target together
hp: HpC
targetIdx: int32
LinkG = object # hierarchy: parent reference
parentIdx: int32
LifeG = object # projectile lifetime
lifetime: float32
GroupedWorld = object
count: int
sig: seq[set[Flag]]
spatial: seq[SpatialG]
view: seq[ViewG]
combat: seq[CombatG]
link: seq[LinkG]
life: seq[LifeG]
proc initGrouped(cap: int): GroupedWorld =
result.count = 0
result.sig = newSeq[set[Flag]](cap)
result.spatial = newSeq[SpatialG](cap)
result.view = newSeq[ViewG](cap)
result.combat = newSeq[CombatG](cap)
result.link = newSeq[LinkG](cap)
result.life = newSeq[LifeG](cap)
for i in 0..<cap:
result.link[i].parentIdx = -1
result.combat[i].targetIdx = -1
proc populate(w: var GroupedWorld) =
rngState = 42'u32
for i in 0..<NBuildings:
let e = w.count; inc w.count
w.sig[e] = {fPos, fSprite}
w.spatial[e].pos = v2(float32(rng() mod 1920), float32(rng() mod 1080))
w.view[e].sprite = (0xFF0000FF'u32, 10'f32)
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.spatial[e].pos = v2(float32(rng() mod 1920), float32(rng() mod 1080))
w.spatial[e].vel = v2(float32(rng() mod 10) * 0.1'f32, float32(rng() mod 10) * 0.1'f32)
w.combat[e].hp = (100'f32, 100'f32)
w.view[e].sprite = (0x00FF00FF'u32, 20'f32)
w.combat[e].targetIdx = int32(unitStart + rng() mod NUnits)
for i in 0..<NParented:
let e = unitStart + i
w.sig[e].incl(fParent)
w.link[e].parentIdx = int32(rng() mod NBuildings)
for i in 0..<NProjectiles:
let e = w.count; inc w.count
w.sig[e] = {fPos, fVel, fLifetime, fTarget}
w.spatial[e].pos = v2(0'f32, 0'f32)
w.spatial[e].vel = v2(2'f32, 1'f32)
w.life[e].lifetime = ProjLifetime
w.combat[e].targetIdx = int32(unitStart + rng() mod NUnits)
proc groupedMove(w: var GroupedWorld, dt: float32) =
for i in 0..<w.count:
if fVel in w.sig[i]:
w.spatial[i].pos.x += w.spatial[i].vel.x * dt # pos+vel in same struct!
w.spatial[i].pos.y += w.spatial[i].vel.y * dt
proc groupedHierarchy(w: var GroupedWorld) =
for i in 0..<w.count:
if fWorldPos in w.sig[i]:
if fParent in w.sig[i]:
let p = w.link[i].parentIdx
if p >= 0:
w.view[i].worldPos.x = w.spatial[p].pos.x + w.spatial[i].pos.x
w.view[i].worldPos.y = w.spatial[p].pos.y + w.spatial[i].pos.y
else:
w.view[i].worldPos = w.spatial[i].pos
proc groupedCombat(w: var GroupedWorld) =
for i in 0..<w.count:
if {fTarget, fHp} <= w.sig[i]:
let t = w.combat[i].targetIdx # hp+target in same struct!
if t >= 0:
let dx = w.spatial[i].pos.x - w.spatial[t].pos.x
let dy = w.spatial[i].pos.y - w.spatial[t].pos.y
if dx*dx + dy*dy < CombatRangeSq:
w.combat[t].hp.cur -= CombatDamage # target's hp in same struct as target idx
if w.combat[t].hp.cur < 0'f32: w.combat[t].hp.cur = 0'f32
proc groupedCull(w: var GroupedWorld) =
for i in 0..<w.count:
if fSprite in w.sig[i]:
let p = if fWorldPos in w.sig[i]: w.view[i].worldPos else: w.spatial[i].pos
if p.x >= 0 and p.x <= ScreenW and p.y >= 0 and p.y <= ScreenH:
sink += w.view[i].sprite.size # worldPos+sprite in same struct!
proc groupedSpawn(w: var GroupedWorld, n: int, unitStart: int) =
for j in 0..<n:
let e = w.count; inc w.count
w.sig[e] = {fPos, fVel, fLifetime, fTarget}
w.spatial[e] = SpatialG(pos: v2(0'f32, 0'f32), vel: v2(2'f32, 1'f32))
w.life[e] = LifeG(lifetime: ProjLifetime)
w.combat[e].targetIdx = int32(unitStart + rng() mod NUnits)
proc groupedDespawn(w: var GroupedWorld, n: int, projStart: int) =
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.spatial[idx] = w.spatial[last] # 1 struct copy vs 2 in fragmented
w.view[idx] = w.view[last]
w.combat[idx] = w.combat[last]
w.link[idx] = w.link[last]
w.life[idx] = w.life[last]
dec w.count
# ================================================================
# 3. ECS-Archetype — archetype buckets with DenseVec columns
# ================================================================
type
ComponentKind = enum
ckTransform, ckVelocity, ckHealth, ckTarget, ckSprite, ckParent, ckLifetime
ArchetypeKind = enum
BuildingArch, UnitArch, ProjectileArch
# Component value types for archetype columns
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]
# --- Archetype column helpers ---
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.}
# --- Column access templates ---
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
# --- Archetype initialization ---
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
# Buildings
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)))
# Units
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))
# Projectiles
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) =
# Units — no signature check, iterate exactly the right entities
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
# Projectiles
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) =
# Buildings
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
# Units
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)
# ================================================================
# 4. MI-AoS — per-archetype value arrays
# ================================================================
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)
# ================================================================
# 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 "MI vs ECS Benchmark v3 — Grouped + Archetype"
echo "============================================"
echo fmt"Buildings: {NBuildings} Units: {NUnits} Projectiles: {NProjectiles} Total: {N}"
echo fmt"Frames: {Frames} Spawn/despawn batch: {SpawnBatch} x {SpawnIters}"
echo ""
# ---- ECS-Fragmented ----
var frag = initFrag(N + SpawnBatch + 16)
frag.populate()
let fragUnitStart = NBuildings
let fragProjStart = NBuildings + NUnits
echo "--- ECS-Fragmented (8 separate SoA columns) ---"
bench("move", frag.fragMove(Dt))
bench("hierarchy", frag.fragHierarchy())
bench("combat", frag.fragCombat())
bench("cull", frag.fragCull())
rngState = 999'u32
echo " spawn/despawn:"
benchLifeCycle(frag.fragSpawn(SpawnBatch, fragUnitStart),
frag.fragDespawn(SpawnBatch, fragProjStart))
echo ""
# ---- ECS-Grouped ----
var grp = initGrouped(N + SpawnBatch + 16)
grp.populate()
let grpUnitStart = NBuildings
let grpProjStart = NBuildings + NUnits
echo "--- ECS-Grouped (5 grouped columns by system access) ---"
bench("move", grp.groupedMove(Dt))
bench("hierarchy", grp.groupedHierarchy())
bench("combat", grp.groupedCombat())
bench("cull", grp.groupedCull())
rngState = 999'u32
echo " spawn/despawn:"
benchLifeCycle(grp.groupedSpawn(SpawnBatch, grpUnitStart),
grp.groupedDespawn(SpawnBatch, grpProjStart))
echo ""
# ---- ECS-Archetype ----
var arch = initArchWorld(NBuildings.int32, NUnits.int32,
(NProjectiles + SpawnBatch + 16).int32)
arch.populate()
echo "--- ECS-Archetype (DenseVec 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 ""
# ---- Memory ----
echo "--- Memory footprint (data only) ---"
let fragMem = N * (1 + sizeof(Vec2f)*2 + sizeof(Vec2f) + sizeof(HpC) +
sizeof(SpriteC) + 4 + 4 + 4)
let grpMem = N * (sizeof(SpatialG) + sizeof(ViewG) + sizeof(CombatG) +
sizeof(LinkG) + sizeof(LifeG))
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)
echo fmt" ECS-Frag: {fragMem.float64 / 1e6:6.1f} MB (8 columns x {N})"
echo fmt" ECS-Grouped: {grpMem.float64 / 1e6:6.1f} MB (5 grouped columns x {N})"
echo fmt" ECS-Archetype: {archMem.float64 / 1e6:6.1f} MB (only needed comps per archetype)"
echo fmt" MI-AoS: {aosMem.float64 / 1e6:6.1f} MB (struct arrays per archetype)"
echo ""
echo fmt"sink: {sink}"

Results — v3: Grouped + Archetype ECS

System ECS-Frag ECS-Grouped ECS-Archetype MI-AoS
move 1.14 ns/e 0.75 ns/e 0.19 ns/e 0.69 ns/e
hierarchy 1.10 ns/e 0.80 ns/e 0.31 ns/e 0.56 ns/e
combat 0.78 ns/e 0.96 ns/e 0.49 ns/e 0.74 ns/e
cull 1.57 ns/e 1.68 ns/e 1.63 ns/e 1.13 ns/e
spawn 0.002 ms 0.002 ms 0.003 ms 0.002 ms
despawn 0.012 ms 0.010 ms 0.008 ms 0.003 ms
memory 2.6 MB 2.6 MB 1.9 MB 1.7 MB

What changed

The archetype ECS is a game-changer for iteration systems. Your feedback was spot-on — once you group data by archetype and skip the signature scan entirely, the numbers flip dramatically:

  • move: 6× faster than MI-AoS (0.19 vs 0.69 ns/e). The archetype ECS iterates only units + projectiles (35k entities with velocity) via two tight SoA column pairs. MI-AoS touches the full 52-byte Unit struct per iteration even though it only needs pos + vel (16 bytes). SoA wins hard when the working set is narrow.
  • hierarchy: 1.8× faster than MI-AoS. Same story — reading only pos from the buildings column (8 bytes) vs the full 12-byte Building struct.
  • combat: MI-AoS finally loses on random access too. The archetype ECS reads pos (8B) + targetIdx (4B) = 12 bytes per unit, vs MI-AoS reading the scattered 52-byte Unit. For random target lookups, the smaller cache footprint of SoA columns wins.

But MI-AoS still wins on cull and despawn. Cull touches worldPos + sprite (12 bytes) — in MI-AoS these live together in the same struct (one cache line), while in ECS-Archetype they're in separate columns requiring two parallel array walks. Despawn is 2.7× faster in MI-AoS because it's one struct copy vs four separate column swap-removes.

Grouped ECS didn't help as much as expected. Grouping pos+vel into SpatialG helped move (1.14→0.75), but actually hurt combat and cull — because the grouped struct pulls in unrelated data. The grouping has to match exactly what each system reads, and different systems want different groupings. This is the fundamental tension: SoA lets you pick the optimal layout per system, but only archetype ECS gets you close to per-system optimal for free.

The real takeaway

The "ECS vs MI" question was the wrong frame. The real axis is SoA vs AoS, and the answer depends on the system's access width:

System reads Winner Why
Few fields, many entities SoA (Archetype) Tight cache lines, no wasted bandwidth
Many fields, few entities AoS (MI) One cache line has everything, no column indirection
Random cross-entity access SoA (Archetype) Smaller per-record footprint = more in cache
Bulk insert/delete AoS (MI) One struct copy beats N column copies

The archetype ECS from your paste is genuinely the best of both worlds for iteration — it gets SoA's cache efficiency without the signature scan tax. MI-AoS remains better for structural operations (spawn/despawn) and for systems that touch most fields of a fat struct. A real game would use both: archetype ECS for the 90% case of "update all entities that have X", MI-AoS for hot structural paths.

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