These results are expected purely because of architectural differences: ODE_ECS uses a relational database-like approach (with tables for components and views), while moecs and odecs use archetype approaches.
All tests were generated automatically by Claude AI, and the conclusions below were also made by the AI without any human intervention.
ODE_ECS link: https://github.com/odin-engine/ode_ecs
moecs link: https://github.com/helioscout/moecs
odecs link: https://github.com/NateTheGreatt/odecs
Features comparison is here: features.md
Same machine, -o:aggressive, same tracking-allocator harness (mem.Tracking_Allocator,
current_memory_allocated after setup). moecs re-fetched from
https://github.com/helioscout/moecs (commit 8d50786, still latest upstream — unchanged
since 7/5/2026); odecs re-fetched from https://github.com/NateTheGreatt/odecs (commit
e3ca0a5, also unchanged since 7/5/2026). Each library uses its idiomatic fast path: ODE_ECS
via direct table iteration, View + Iterator, view_dense_slice, or (new this run) an
owned Group + group_dense_slice; moecs via an ARCHETYPE system driven by progress();
odecs via a per-frame query + get_table batch loop over its archetype columns (the
pattern its own docs and bundled benchmarks use).
ODE_ECS includes the dense (aligned) view fast path added on 7/2/2026: when view row i
corresponds to row i in every Table of the view (true whenever components are added per
entity in the same order, and preserved under despawn/respawn churn), the Iterator reads
components directly from the tables' dense arrays instead of going through the view's per-row
pointer records, falling back transparently otherwise. view_dense_slice additionally exposes
raw component slices in view-row order, which compiles to a pure SoA sweep. On this machine a
plain pos[i] += vel[i] loop over two raw arrays runs at 0.30 ns/ent/frame — the batch path
runs at that hardware floor.
New this run: ODE_ECS's Group (commit 6d96b39, an EnTT-style full-owning group). Where
a View detects dense alignment and falls back when it breaks, a Group takes exclusive
ownership of a set of Tables and enforces alignment — every add_component that completes
group membership swaps the entity's rows into a contiguous prefix [0, group_len) shared by
every owned table, and every remove_component/destroy_entity that breaks membership swaps
it back out. group_dense_slice is therefore always valid — no alignment check, no fallback
path, ever — at the cost of paying O(owned tables) row swaps on every membership change, and a
table can be owned by at most one group. Added to the three scenarios where every entity keeps
a stable component set (movement, many-types, churn) as ode_group, ode_many_group,
ode_churn_group.
Benchmark sources live under G:\odin\ecs_bench\:
ode_one, moecs_one, odecs_one (scenario 0); ode, ode_batch, ode_group,
moecs_bench, odecs_bench (scenario 1); ode_many, ode_many_group, moecs_many,
odecs_many (scenario 2); ode_churn, ode_churn_batch, ode_churn_group, moecs_churn,
odecs_churn (scenario 3); ode_relations, moecs_relations, odecs_relations
(scenario 4). All numbers are medians of 3 runs, all binaries alternating within each pass.
Each entity has one Position{x,y:f64}; per frame pos.x += pos.y. This isolates raw
iteration with no multi-component lookup at all — ODE_ECS iterates the Table directly
(for &p in positions.rows), moecs runs a one-component archetype system, odecs sweeps its
single archetype's Position column via get_table.
| Library | setup | iter/frame | ns/ent/frame | live mem |
|---|---|---|---|---|
| ODE_ECS (table) | 16.0 ms | 0.31 ms | 0.31 | 80 MB |
| ODE_ECS (view+iter) | — | 0.29 ms | 0.29 | — |
| moecs | 694 ms | 3.21 ms | 3.21 | 168 MB |
| odecs | 3,668 ms | 0.22 ms | 0.22 | 51 MB |
ODE_ECS iterates a single component ~10x faster than moecs and its View+Iterator costs
nothing over raw table iteration (the dense fast path reduces it to the same SoA sweep). A
single-table Group would add nothing here either — with one component there is no
"membership" to enforce, the table already is the aligned set — so this scenario has no
group variant. moecs pays per-entity get_mut (typeid lookup + chunk indexing) plus
per-frame system dispatch even in the simplest possible case. odecs actually posts the
fastest sweep here (0.22 ns) and the leanest footprint — its column is the same dense SoA
array, and the small delta vs ODE_ECS's own 0.31 ns table loop is loop/allocation codegen, not
architecture (both are at the read+write-16-bytes-per-entity memory floor). The catch is the
other column: odecs takes 3.7 seconds to create 1M entities (~230x ODE_ECS, ~5x moecs) —
every add_entity funnels components through a variadic ..any path with per-call
temp-allocator bookkeeping and typeid→ComponentID map lookups.
Each entity has Position{x,y:f64} + Velocity{x,y:f64}; per frame pos += vel.
| Library | setup | iter/frame | ns/ent/frame | live mem |
|---|---|---|---|---|
| ODE_ECS (iterator) | 20.0 ms | 0.58 ms | 0.58 | 114 MB |
ODE_ECS (view_dense_slice) |
19.3 ms | 0.31 ms | 0.31 | 114 MB |
ODE_ECS (group_dense_slice) |
15.7 ms | 0.30 ms | 0.30 | 83 MB |
| moecs | 702 ms | 4.21 ms | 4.21 | 184 MB |
| odecs | 6,885 ms | 0.31 ms | 0.31 | 66 MB |
ODE_ECS vs moecs: ~35x faster setup, ~7.3x faster iteration with the unchanged Iterator
API and ~14x with the batch/group APIs (both run at the measured raw-SoA hardware floor),
1.3-1.6x leaner. odecs ties ODE_ECS's batch/group paths at the hardware floor (0.31 vs 0.30 ns)
with its ordinary documented query loop — no special fast path needed, the archetype
guarantees alignment by construction. Its setup cost balloons to 6.9 s (~440x ODE_ECS,
~10x moecs): two components per entity doubles the per-add_entity type-resolution work.
The Group row is new this run and is the cheapest and the leanest of the three ODE_ECS
variants, not just the fastest to iterate. Iteration ties the batch view (both hit the
0.30 ns raw-SoA floor) but setup is ~20% faster than plain View+Iterator and memory drops
from 114 MB to 83 MB — the View's per-row pointer-record bookkeeping (view__add_record on
every add_component, sized to entity capacity) simply doesn't exist for a Group: a
membership-completing add only pays a bit-subset check plus, since these entities are created
in the same order in both tables, a swap that's already a no-op (the row is already at the
prefix position). That is precisely the case group.md recommends: a set whose membership
never changes after setup, iterated every frame — pay the (here, ~free) swap cost once, get
the enforced floor forever with no per-row fallback structure to maintain.
Every entity has all 32 component types (identical 16-byte shape); movement still touches only 2 of them.
| Library | setup | iter/frame | ns/ent/frame | live mem |
|---|---|---|---|---|
| ODE_ECS (view) | 19.7 ms | 0.15 ms | 0.58 | 228 MB |
| ODE_ECS (group, 2/32) | 19.6 ms | 0.07 ms | 0.29 | 221 MB |
| moecs | 99.9 ms | 1.75 ms | 6.98 | 160 MB |
| odecs | 7,434 ms | 0.08 ms | 0.30 | 131 MB |
The group row owns only the 2 tables the movement pass touches (Position, Velocity) out of
the 32 registered — the other 30 stay ordinary Tables the group never looks at. Setup cost
is a wash against the plain view (both pay for all 32 add_component calls per entity; only 2
of them touch group/view bookkeeping at all), but iteration nearly halves again vs the
already-fast view path (0.58 -> 0.29 ns), landing ODE_ECS below odecs's archetype-column sweep
in this scenario too.
| Library | total | ms/frame | ns per churn-op | last-entity x |
|---|---|---|---|---|
| ODE_ECS (iterator) | 50.3 ms | 0.50 | 25.2 | 10 |
| ODE_ECS (batch) | 49.3 ms | 0.49 | 24.6 | 10 |
| ODE_ECS (group) | 44.2 ms | 0.44 | 22.1 | 10 |
| moecs | 113 ms | 1.13 | 56.4 | 9 |
| odecs | 179 ms | 1.79 | 89.5 | 10 |
The group variant is the fastest here too, ~10% ahead of the batch view. Membership never
actually toggles in this workload (every despawned entity is immediately respawned with both
components), so the group pays exactly one swap per create/destroy — the same row movement
the table's own tail-swap already performs — but skips the view's separate per-row
pointer-record maintenance and its per-frame alignment re-check entirely, since
group_dense_slice needs neither.
Exercises each library's entity-relations feature. 100k entities with a Position are linked
into a 10-ary forest (100 roots, depth ~4). Per frame (x100): 10k leaf re-parents, 10k
children-list reads, 10k ancestor walks to the root; then 50 depth-1 subtrees
(5,550 entities) are cascade-destroyed. ODE_ECS uses the new Relations_Table
(set_parent / children_of / parent_of / destroy_entity(..., destroy_children=true));
moecs uses ChildOf/ParentOf relations (unrelate+child_of for a re-parent, children,
parent, despawn which cascades to single-parent children); odecs uses flecs-style
ChildOf pairs with the Exclusive trait (so one add_pair re-parents, auto-dropping the
old parent) and the Cascade trait (so remove_entity on a parent deletes descendants),
with query({pair(ChildOf, parent)}) for children reads and find_relation_target for
ancestor hops. moecs runs its leanest valid configuration for this workload (.ITERATION
approach: despawn immediate like ODE_ECS, no archetype bookkeeping, no systems). All three
programs do identical logical work verified by an identical checksum (x=13120122).
| Library | setup | reparent ns/op | children ns/op | ancestor ns/hop | cascade destroy | live mem |
|---|---|---|---|---|---|---|
| ODE_ECS | 1.4 ms | 6.0 | 17.4 | 1.4 | 0.12 ms | 10 MB |
| moecs | 13.5 ms | 245 | 2.8 | 6.5 | 0.62 ms | 16 MB |
| odecs | 107 ms | 197 | 2,306 | 5.3 | 9.39 ms | 19 MB |
Group doesn't apply to this scenario: it owns plain Tables to enforce component-set
alignment, while parent/child links live in ODE_ECS's separate Relations_Table structure —
a different mechanism entirely, with its own intrusive-array design (see the discussion
above the table).
ODE_ECS re-parents ~33-41x faster than either archetype library and walks ancestor chains
4-5x faster: Relations_Table is flat intrusive arrays indexed by entity index (parent,
first_child, doubly-linked siblings), so every link/unlink is a handful of array writes.
moecs must linear-search the old parent's dynamic targets array to unlink
(unordered_remove after linear_search) and slice.contains-check the new parent's —
arrays that grow to hundreds of children under this churn. odecs pays a different price for
the same op: a re-parent is a structural archetype move (drop the old (ChildOf, parent)
pair component, add the new one — two archetype transitions under Exclusive), landing at
197 ns/op, on par with moecs. Cascade destroy is where the pair encoding hurts most: every
remove_entity in odecs linearly scans all archetype signatures (~10k of them here, one per
distinct parent) looking for cascade dependents, so destroying the 5,550-entity subtrees
costs 9.4 ms vs ODE_ECS's 0.12 ms (iterative deepest-first BFS over intrusive links) and
moecs's 0.62 ms. The honest counterpoints: moecs reads a children list ~6x faster than
ODE_ECS because children() returns a direct slice of its stored dynamic array, whereas
ODE_ECS's children_of walks the sibling linked list (cache-unfriendly) and copies ids into
a scratch buffer. odecs has no children accessor at all — enumerating children is a query
(term decode + context build + hash + cache lookup per call, ~10k distinct cached queries
here), which is why its children reads cost ~2.3 µs, ~130x ODE_ECS. Its 5.3 ns ancestor hop
(archetype-signature scan for the ChildOf pair) sits between ODE_ECS's 1.4 ns array read
and moecs's 6.5 ns. Also note the features are not equivalent: ODE_ECS pays for an always-on
cycle check on every set_parent (the others perform none — cycles are the user's problem)
but supports only single-parent parent/child, while moecs and odecs support multi-target and
typed relations with data (odecs additionally gets relational queries — "all children of X"
composes with any other term). In this run all five scenarios were measured back-to-back in
a single session, all three libraries alternating within each pass.
1. SoA iteration is flat vs component-type count; moecs degrades. From 1 -> 2 -> 32
registered component types, ODE_ECS's per-entity cost through the same-API iterator moves
0.29 -> 0.58 -> 0.58 ns (the 1->2 step is just the extra Velocity stream; 2->32 is flat) — its
SoA layout means iterating {Position, Velocity} only ever touches those two dense arrays no
matter how many other component types exist. The Group/group_dense_slice path goes one
step further and is also flat but lower, 0.30 -> 0.29 ns, because it carries no per-row
fallback machinery to begin with (see point 6). odecs holds flat the same way
(0.22 -> 0.31 -> 0.30): its archetype stores each component as a separate column, so the
movement sweep touches only 2 of the 32 columns. moecs goes 3.21 -> 4.21 -> 6.98 ns, a ~66%
slowdown from 2 to 32 types, because (a) each get_mut strides into a now-512-byte AoS
chunk, so the two fields you want share cache lines with 30 unused components, and (b) the
component_index typeid scan (component.odin:38) is now over 32 entries. The gap widens
from ~7.3x to ~13.5x (or, group vs moecs, ~11x to ~24x) exactly as the architecture predicts
as a game grows more component types.
2. The dense fast path makes View overhead disappear — Group removes the check itself.
Before the 7/2/2026 optimization, ODE_ECS's Iterator walked per-row records of
{entity_id, component pointers} (~24 extra bytes streamed per entity per frame). Now,
whenever the view is dense-aligned — the common case, verified incrementally with O(tables)
work per structural change and a lazy early-abort rescan — the iterator reads table.rows[i]
directly. Scenario 0 shows the result: view iteration (0.29 ns) is indistinguishable from raw
table iteration (0.31 ns). view_dense_slice goes one step further and hands the user the raw
slices in view-row order; a plain loop over them is exactly the 0.30 ns raw-SoA floor measured
outside any ECS — and scenario 1 shows odecs's ordinary query loop landing on the same floor
(0.31 ns), because an archetype's columns are aligned by construction. Group goes one step
further still: there is no alignment detection at all, dense or otherwise, because a group
never allows misalignment to occur — group_dense_slice is either the current prefix slice or
nil (dirty), full stop. The difference is what happens off the fast path: when alignment
genuinely breaks under a plain View (e.g. removing one component from an entity that keeps
others), everything transparently falls back to the pointer-record path — correctness is
guarded by a randomized fuzz test in the library's test suite that cross-checks iterator
results against direct table lookups. A Group's owned tables can't reach that state at all
(that's the whole point of ownership); in odecs the equivalent event is an archetype move,
which is where its costs concentrate (see #4 and #5).
3. Memory: the archetype libraries win when components are universal — except a Group
closes most of the gap. In the dense scenarios odecs is the leanest of the three outright
via View (51 / 66 / 131 MB in scenarios 0/1/2): one archetype, each component a single
packed column, no per-table index overhead. moecs beats ODE_ECS via View only in scenario 2
(160 vs 228 MB). But Group carries no per-entity bookkeeping at all — group__memory_usage
is just the owned-tables list — so it inherits only the plain Table rows plus each table's
own eid_to_ptr/rid_to_eid index arrays, none of a View's subscriber records. In scenario
1 that drops ODE_ECS from 114 MB (view) to 83 MB (group), closing most of the distance to
odecs's 66 MB; in scenario 2, owning only the 2 tables actually iterated drops 228 MB to
221 MB (the other 30 Tables' index overhead — eid_to_ptr + rid_to_eid sized to full
entity capacity, ~4 MB x 30 = 120 MB — dominates regardless of view or group, since it's a
property of the tables themselves, not the query mechanism over them). Caveat both ways: if
components were sparse (each entity has 2 of 32), ODE_ECS would size the other 30 tables
small or use Compact_Table/Tiny_Table (the README's explicit guidance, though note groups
can only own the plain Table type) and win memory handily; moecs would still reserve the
full 512-byte chunk per entity; and odecs would fragment entities across many archetypes (see
the relations scenario, where ~10k archetypes cost it real money on every structural
operation).
4. Churn: ODE_ECS is ~2.5x faster than moecs, ~4x faster than odecs — and Group shaves off
another ~12%. moecs's deferred-mutation model exists to make structural changes safe
during iteration, not necessarily fast. ODE_ECS's immediate tail-swap costs ~25 ns per
despawn/respawn op (iterator), ~22 ns with a Group owning the tables, vs moecs's ~56 ns
(deferred queue + perform() rebuild + per-frame slice.filter over archetypes,
world.odin:755) and odecs's ~90 ns. odecs's structural ops are immediate like ODE_ECS's (its
x=10 confirms same-frame visibility) but each remove_entity unconditionally scans every
archetype signature for Cascade dependents — cheap here with 2 archetypes, ruinous with 10k
(scenario 4) — and each respawn re-runs the variadic ..any component-resolution machinery.
Notably, ODE_ECS's dense fast path survives this churn regardless of which mechanism keeps
it aligned: tables with identical membership perform identical tail-swaps and stay
row-aligned, so the batch variant (ode_churn_batch) still runs the movement pass as a raw
SoA sweep, re-verifying alignment with one linear scan per frame; the group variant
(ode_churn_group) needs no re-verification at all, since membership here never actually
changes (every despawned entity is immediately respawned with both components) — the group
just pays its usual one-swap-per-membership-change cost, which coincides with the tail-swap
the table would have paid anyway, and comes out ~12% ahead of the batch view by skipping the
view's separate bookkeeping. The x=10 vs x=9 in the output is not an error — it is moecs's
1-frame deferral made visible: ODE_ECS and odecs apply churn immediately so the respawned
entity is updated the same frame; moecs archetypes it at end-of-frame, so it starts updating
next frame. That deferral is the price and the feature (you can safely despawn
mid-system-iteration in moecs; in ODE_ECS you must follow the "don't mutate while iterating"
rule, or use pause_tail_swap which also defers group maintenance; odecs defers automatically
only when you mutate during a query iteration).
5. Entity creation spans two orders of magnitude. Setting up 1M two-component entities:
ODE_ECS 15.7-20.0 ms depending on variant, moecs 702 ms, odecs 6,885 ms. ODE_ECS preallocates
typed tables and an add is a couple of array writes (or, owned by a group, a bit-subset check
plus a swap that's a no-op when insertion order already matches); moecs pays
deferred-archetyping bookkeeping per entity; odecs routes every add_entity through a
variadic ..any interface that builds a temp-allocator component-ID array and map per call,
resolves each component's typeid through map lookups, and checks observers — per entity.
odecs's own benchmark suite measures entity/component ops in ops/sec and this is consistent
with it; it is simply the cost of its very dynamic creation API, and it is the single biggest
number separating these libraries.
6. Groups: enforce what views merely detect, and it's cheaper than it sounds. The
textbook expectation for a full-owning group (EnTT's design, which Group adapts) is a
trade: pay more on structural change to get a guaranteed-flat iteration floor with zero
runtime checks. That trade shows up nowhere as a cost in these three scenarios — group setup
(scenario 1: 15.7 ms) undercuts plain View setup (20.0 ms), and group churn (scenario 3:
22.1 ns/op) undercuts batch-view churn (24.6 ns/op) — because in all three, a View isn't
free either: every add_component that matches a subscribed view still calls
view__add_record to maintain its pointer-record fallback path, win or lose. A Group
skips that path's existence entirely, paying only a bit-subset check plus a row swap that
collapses to a length increment whenever the entity is already sitting where the group wants
it (true here, since components are always added in the same order). The honest place the
textbook trade would bite: an entity set whose owned-component membership toggles on and off
across many frames relative to how often it's swept (e.g. a Stunned tag table that gets
added/removed constantly on a small fraction of entities each frame) — every toggle there
pays a real O(owned-tables) swap that a View would pay too (to update its own records) but a
Group pays in addition to the cost of moving the row physically, whereas a View's
pointer-record update never moves component data. None of scenarios 1-3 exercise that case
(component sets are stable per entity outside of whole-entity destroy/create), which is
exactly the "hot set, stable membership" niche Group is documented for — pick it there, keep
View for churn-heavy or filtered sets.
odecs's arrival splits the story into two axes. On iteration, the SoA libraries are now
indistinguishable: odecs's plain documented query loop runs at the same raw-SoA hardware
floor as ODE_ECS's batch and group paths (0.29-0.31 ns/ent/frame, scenario 1) and stays flat as
component-type count grows, leaving moecs ~11-24x behind at 32 types depending on which
ODE_ECS variant you compare. ODE_ECS's dense fast path earns its keep by delivering that floor
through a stable iterator API with a transparent fallback, rather than by
construction-only guarantees; its new Group delivers the same floor with no fallback
machinery at all, and in these scenarios that turns out to cost nothing extra on setup or
churn either — it's a strict improvement over View whenever membership is stable, which
scenarios 1-3 all are. On structural operations, ODE_ECS is decisively fastest across the
board: ~35x (moecs) to ~440x (odecs) faster setup, ~2.5-4x faster churn (more with Group),
~40x faster re-parenting, ~8-70x faster cascade destroy. odecs concentrates its costs exactly
there — variadic per-entity creation, archetype moves for every pair change, all-archetype
scans on delete, and query-shaped children reads (~2.3 µs vs 17.4 ns/2.8 ns) — while winning
dense-memory footprint outright in scenarios 0 and 2 (odecs's View-based numbers are leanest
there; ODE_ECS's Group closes most of the gap in scenario 1, see point 3). moecs's wins
remain feature-driven, not throughput-driven: safe deferred structural changes,
observers/scheduler, direct children slices, and typed multi-target relations. If your
bottleneck is raw component iteration and structural churn, ODE_ECS is the only library here
fast at both, and its Group is the one to reach for when a hot set's membership is stable;
odecs matches it on iteration if entity creation and hierarchy churn are rare in your game;
moecs trades throughput for its deferred-safety programming model and batteries-included
features.
- One machine, one run set (medians of 3 passes, all 20 binaries alternating within each pass in a single session); absolute numbers vary between sessions, but the ratios track the architectural differences and were stable across repeated runs.
- moecs is the latest upstream commit (
8d50786), re-fetched and re-verified unchanged on 7/9/2026 (same commit as the 7/5/2026 run). odecs is the latest upstream commit (e3ca0a5), likewise re-fetched and unchanged since 7/5/2026. ODE_ECS is at its latest commit978f2e5(7/9/2026), which adds theGroup/group_dense_slicefull-owning-group feature (6d96b39) plus doc/README updates over theRelations_Tablework measured last time. Numbers this session drifted a few percent from the 7/5/2026 run in both directions (normal machine noise), which is why every table above was refreshed from this single back-to-back session rather than mixing sessions. - Three new binaries this run add
Groupvariants to the scenarios where it applies:ode_group(scenario 1),ode_many_group(scenario 2),ode_churn_group(scenario 3).Groupneeds no counterpart in moecs or odecs — it's an ODE_ECS-specific mechanism for enforcing (not just detecting) dense alignment over a fixed set of owned tables; the other two libraries' archetype/chunk storage gets the equivalent guarantee by construction, which is exactly why their numbers already sit at the same floor without a comparable API. - All workloads verified correct via a checksum (
xvalue) that also defeats dead-code elimination, identical across libraries per scenario. (ode_onereports x=400 because it runs the same 100 frames twice — once through the table, once through the view;moecs_oneandodecs_onerun them once, x=200. All three relations programs print x=13120122 and destroy exactly 5,550 entities.) - ODE_ECS results are identical with
ECS_VALIDATIONS=false; the validation asserts cost nothing on these hot paths. - The raw-SoA floor (0.30 ns/ent/frame for scenario 1's access pattern on this machine) was measured with a standalone Odin program iterating two plain slices, outside any ECS. Scenario 0's access pattern (read+write one array) has a different, slightly lower floor, which is how odecs's 0.22 ns there is possible.
- The odecs benchmarks call
free_all(context.temp_allocator)once per frame (outside the timed relations sections): odecs allocates per-call scratch (query terms,add_entitybookkeeping) from the temp allocator and expects the host loop to reset it, so this is its intended usage, not overhead added to it. - Movement is ODE_ECS's home turf; moecs does more per-frame bookkeeping by design (deferred actions, archetype re-filtering) to support features not exercised here.