diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md index db1927a..70c3830 100644 --- a/docs/benchmarks/README.md +++ b/docs/benchmarks/README.md @@ -9,10 +9,12 @@ Benchmark methodology, baselines, results, and the regression policy map onto them, the baseline-file convention, the PRD §8.1 regression policy, and workload discipline. - [baselines/](baselines/README.md) — recorded baseline reports - (`-.md`). **Currently empty**: every - `budgets.json` entry has `measured: 0`. The first baseline, - `baselines/m0-synthetic.md`, lands with M0-EXIT-01, and - `baselines/m1-profiler-cost.md` with M1-PROF-01. + (`-.md`). Every `budgets.json` entry still has + `measured: 0`: the two baselines recorded so far + (`baselines/m0-synthetic.md`, M0-EXIT-01, and + `baselines/m1-ecs-stress.md`, M1-ECS-07) measure harness and stress + workloads that update no budget. `baselines/m1-profiler-cost.md` + lands with M1-PROF-01. - Per-milestone performance results (M1 10k-entity tick, M2 50k-sprite scene, M6/M7 zone server, …) land here as their milestones close — see the [roadmap progress board](../../roadmap/README.md). diff --git a/docs/benchmarks/baselines/README.md b/docs/benchmarks/baselines/README.md index 5bc88b0..0617575 100644 --- a/docs/benchmarks/baselines/README.md +++ b/docs/benchmarks/baselines/README.md @@ -17,7 +17,13 @@ and the exact command + commit that produced it. Baseline files are `measured` in `budgets.json` — it never edits an existing baseline. Every `budgets.json` entry still has `measured: 0` (their subsystems land -in M1+); the first recorded baseline is -[m0-synthetic.md](m0-synthetic.md) (M0-EXIT-01, 2026-09-13) — the -synthetic harness workload, which proves the measurement pipeline end to -end but does not measure any real budget. +in M1+). Recorded so far: + +- [m0-synthetic.md](m0-synthetic.md) (M0-EXIT-01, 2026-09-13) — the + synthetic harness workload; proves the measurement pipeline end to + end but does not measure any real budget. +- [m1-ecs-stress.md](m1-ecs-stress.md) (M1-ECS-07, 2026-09-14) — the + M1 ECS stress workload (10k entities × 6 component types × 10k frames + of add/remove churn): no leaks (ASan), pool high-water stable, + iteration within the documented cost, accounted ECS storage bytes. + Not a `budgets.json` workload — no `measured` field updated. diff --git a/docs/benchmarks/baselines/m1-ecs-stress.md b/docs/benchmarks/baselines/m1-ecs-stress.md new file mode 100644 index 0000000..dd3f1eb --- /dev/null +++ b/docs/benchmarks/baselines/m1-ecs-stress.md @@ -0,0 +1,139 @@ +# Baseline: `m1-ecs-stress` — M1 ECS stress + memory accounting workload + +Recorded by **M1-ECS-07** (2026-09-14). This is the **second** baseline +file; it is immutable (methodology §4 — superseding it later adds a new +file, it is never edited). + +## What this baseline measures + +The `EcsStress` suite of `laige-sim_tests` (M1-ECS-07): **10k entities, +6 component types, 10k frames of add/remove churn**. Each frame is a +driven simulation frame (`beginFrame()` for the G-R3/G-R4 guardrails), +followed by 64 seeded Tag adds + 64 Tag removes (cyclic permutation +order; 128 ops/frame, within the default G-R4 budget of 256) and one +`each` iteration (Read, Read) over every Tagged entity +(visit count + 64-bit FNV-1a checksum over slot/generation/tag value). +A 700-frame warm-up (one full 625-frame cohort period plus margin) +precedes the measured window so every archetype's columns reach their +high water before it. + +The step's verified properties: + +- **No leaks** — the green `ctest -R ecs_stress` run in the ASan tree + (leak check; Run B below). +- **Pool high-water stable** — zero `totalReservations`/ + `totalArchetypeGrowth` delta across the 10k-frame window; the + high water (9 archetypes, 24,592 reserved rows, 549,024 reserved + bytes) was reached in the warm-up and never changed in the window. +- **Iteration within the documented cost** (query.h: bounded archetype + scan + one visit per matching entity) — window-wide + `ns_per_visit` throughput floor of 600 ns/visit; measured + **58.7 ns/visit** on the canonical Debug g++ tree. +- **Zero-allocation window** (test-only operator-new counter, + non-sanitizer trees; sanitizer trees prove it via the leak-free run + plus the reservation delta). +- **Memory accounting** (PRD §8.1 base memory, accounted bytes): + 110,000 entity bookkeeping bytes (11 B/slot × 10k) + 549,024 + reserved row bytes = 659,024 B of accounted ECS storage at the + 100%-full scene. + +**It is not a `budgets.json` workload** — the `sim_tick_*` budgets +measure the 10k-entity/2k-dynamic-body tick with systems (M1-BENCH-01), +and `engine_base_rss` measures the *empty-scene* running engine — so +this baseline **does not update any `measured` field in `budgets.json`** +(all entries keep `measured: 0` until their subsystem lands). + +## AGENTS §12 metadata + +| # | Field | Value | +|---|---|---| +| 1 | Hardware | AMD Ryzen 9 7950X3D (16 cores / 32 threads), 64 GB RAM | +| 2 | OS | CachyOS (Arch-based Linux), kernel `7.2.2-1-cachyos`, x86_64 | +| 3 | Compiler and version | `g++ (GCC) 16.2.1 20260810` | +| 4 | Build type | `Debug` (canonical, `build/` tree) | +| 5 | Relevant flags | Engine policy (NFR-8.10): `-Wall -Werror -fno-exceptions -fno-rtti`; SimMath pinned set (ADR 0002): `-ffp-contract=off -fno-associative-math`. No sanitizers (canonical tree). | +| 6 | Dataset / workload | `EcsStress` — 10k entities at the 100% scene budget (capacity 10000), 6 component types (Pos/Vel/Flag/Quad/Pair/Tag, 4/8/8/16/8/4 B), base archetypes {Pos,Vel}, {Pos,Vel,Flag}, {Pos,Quad}, {Pos,Pair} (+ transient {Pos}); per frame: `beginFrame` + 64 Tag adds + 64 Tag removes (seeded cyclic permutation) + `each` iteration; deterministic under the default test seed `0x1F055EED` | +| 7 | Warm-up | 700 discarded frames (`kWarmupFrames` = one full 625-frame cohort period + margin; archetype high water reached) | +| 8 | Sample count | `n=10000` per-frame iteration samples (histogram window `capacity == frames`; no truncation); window ops: 160,000 adds + 160,000 removes; 49,920,000 visits | +| 9 | Summary statistics | Per-frame iteration (ms): `min=0.275945 mean=0.292961 p50=0.292867 p95=0.301392 p99=0.305571 max=0.433534`; window-wide `ns_per_visit=58.7` | +| 10 | Before / after | `before=0` (first recording — no prior `m1-ecs-stress` baseline) · `after=58.7` ns/visit · `target=n/a` (not a `budgets.json` entry — see above; the iteration cost is part of the `sim_tick_*` workloads measured by M1-BENCH-01) | + +## Verbatim run output + +### Run A — canonical statistics run (Debug, g++) + +Command (run from the repository root; the three `ecs-stress` lines are +printed by the test on **every** ctest run of `ecs_stress`, on every +tree): + +```console +$ ctest --test-dir build -R ecs_stress --output-on-failure +``` + +```text +1/1 Test #17: ecs_stress ....................... Passed 14.74 sec + +100% tests passed out of 1 +``` + +Test stdout (machine-greppable lines): + +```text +ecs-stress window: frames=10000 adds=160000 removes=160000 visits=49920000 tags_peak=4992 checksum=0xa1578baae8788fcf +ecs-stress iteration: stats: n=10000 min=0.275945 mean=0.292961 p50=0.292867 p95=0.301392 p99=0.305571 max=0.433534 ns_per_visit=58.7 +ecs-stress memory: entities=10000 entity_bytes_capacity=110000 entity_bytes_inuse=110000 archetypes=9 rows_live=10000 rows_reserved=24592 bytes_reserved=549024 +``` + +Exit code: `0`. + +### Run B — ASan/UBSan tree (the step's required no-leak Verify) + +```console +$ ctest --test-dir build-asan -R ecs_stress --output-on-failure +``` + +```text +1/1 Test #17: ecs_stress ....................... Passed 37.65 sec +``` + +Exit code: `0` — no AddressSanitizer/UBSanitizer report, leak-free +(the ASan run of the same 10k-frame window is the no-leak check the +step's Verify clause names). + +### Run C — cross-compiler determinism spot check (clang++, same machine) + +```console +$ ./build-clang/bin/laige-sim_tests --gtest_filter=EcsStress.* +``` + +```text +ecs-stress window: frames=10000 adds=160000 removes=160000 visits=49920000 tags_peak=4992 checksum=0xa1578baae8788fcf +ecs-stress iteration: stats: n=10000 min=0.332031 mean=0.36074 p50=0.355916 p95=0.391624 p99=0.42103 max=0.505089 ns_per_visit=72.3 +ecs-stress memory: entities=10000 entity_bytes_capacity=110000 entity_bytes_inuse=110000 archetypes=9 rows_live=10000 rows_reserved=24592 bytes_reserved=549024 +``` + +The `window:` and `memory:` lines are **byte-identical** to Run A +(g++ 16.2.1 vs clang++ 22.1.8): the workload is pure integer bookkeeping +(ARCH-010 — no floating point, no randomness beyond the seeded PRNG, no +addresses, no unordered iteration), so only the wall-clock `iteration:` +line differs (58.7 vs 72.3 ns/visit; both far under the 600 ns floor). +Two consecutive runs of Run A on the same binary also reproduced the +`window:`/`memory:` lines byte-identically. + +## Cross-tree results (local, this commit) + +| Tree | Build | `ctest` result | +|---|---|---| +| `build` | Debug, g++ 16.2.1 (static) | 40/40 passed (full suite) | +| `build-release` | Release, g++ 16.2.1 | `laige-sim_tests` + `ecs_stress` passed (1.2 s) | +| `build-asan` | Debug, g++ 16.2.1, ASan+UBSan | 40/40 passed; `ecs_stress` 37.7 s, leak-free | +| `build-clang` | Debug, clang++ 22.1.8 | 40/40 passed | +| `build-tsan` | Debug, g++ 16.2.1, TSan (`halt_on_error=1`) | `laige-sim_tests` + `ecs_stress` passed (144.9 s) | +| `build-shared` | Debug, g++ 16.2.1 (shared, NFR-8.9) | `laige-sim_tests` + `ecs_stress` passed | + +Zero new warnings under the NFR-8.10 policy on all trees. + +## Measured on + +- Commit: `2995ec7` (branch `m1-ecs-07-ecs-stress`, step M1-ECS-07) +- Date: 2026-09-14 (13:00 UTC session time) diff --git a/roadmap/M1-heartbeat.md b/roadmap/M1-heartbeat.md index d6e5605..754b96a 100644 --- a/roadmap/M1-heartbeat.md +++ b/roadmap/M1-heartbeat.md @@ -81,7 +81,7 @@ zero-allocation property (M1-ALLOC-01 enforces it once it exists; before that, A - **Verify:** `ctest -R ecs_guardrails` green; log output matches the error grammar. - **Size:** ~150 lines + tests -- [ ] **M1-ECS-07 · ECS stress + memory accounting test** +- [x] **M1-ECS-07 · ECS stress + memory accounting test** - **Refs:** AGENTS TEST-001/007; PRD §8.1 (base memory) - **Depends:** M1-ECS-06 - **Scope:** diff --git a/roadmap/README.md b/roadmap/README.md index 92d9541..363c1a2 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -156,7 +156,7 @@ Updated in the same PR that closes steps. "Done" = box checked + Verify green. | Milestone | Steps | Done | Status | |---|---|---|---| | M0 | 22 | 22 | ✅ complete (2026-09-13, M0-EXIT-01) | -| M1 | 25 | 5 | 🚧 in progress (M1-ECS-05) | +| M1 | 25 | 7 | 🚧 in progress (M1-ECS-07) | | M2 | 32 | 0 | ⬜ not started | | M3 | 36 | 0 | ⬜ not started | | M4 | 12 | 0 | ⬜ not started | @@ -200,6 +200,8 @@ One line per completed (or split/renumbered) step. | 2026-09-13 | M1-ECS-03 | `cad0594` | Archetype SoA component storage (M1-ECS-03 scope, nothing else): archetype = an ordered component set stored SoA — one packed `T[]` column per component, rows in ascending slot-id order (a pure function of the world state; the convergence property M1-ECS-05 iterates), entity→archetype map as two dense per-slot tables (`archetypeOf_` 2 B + `rowOf_` 4 B — no hash; per-slot bookkeeping 5 → 11 B, entity.md) with `get`/`has` O(1) (slot → record → column binary search ≤ 32 → row); `addComponent` create-or-update (in-place overwrite when present) and `removeComponent` no-op-ok, both pool-backed moves (tail memmove + `rowOf_` re-sync) with **zero heap allocation per operation** — the reserve policy (initial `min(16, capacity)` rows, ×2 growth capped at world capacity, one accounted+logged reserve per growth, bounded by `log2(capacity/16)+1`); budgets `kMaxArchetypes` = 256 / `kMaxArchetypeComponents` = 32 (`BudgetExhausted` + rate-limited warns `ecs/archetype_budget`, `ecs/component_limit`), unregistered type → `InvalidArgument` + `ecs/component_unregistered`, stale handle → the M1-ECS-01 contract (nullptr + warn-once), archetypes never destroyed (empty sets persist, accounted in `bytesReserved`); type→id via splitmix64 open addressing (512 slots, lookup-only — never iterated, no pointer-order portability issue, no 256-scan in the hot path); signature match via FNV-1a 32 short-circuit + lexicographic verify; `destroy`/`clear` now detach rows first (documented O(tail × row-stride) cost, still no allocation); 25 cases in the `archetype` CTest entry (basics, access/stale matrix, layout properties: per-column contiguity + 32 B alignment + slot-ordered addresses + shift semantics, move data preservation, two-world layout convergence, 256-set/32-component/growth-cap-at-18 budget boundaries, warn-once via memory sink, stats/bytes tracking, **10k-entity churn: 20k add/remove ops in seeded random order — zero failures, zero reservation delta, zero process-wide allocations (test-only `operator new` counter, non-sanitizer trees; sanitizer trees prove it leak-free), p99/p50 ≈ 1.98 (flat), machine-greppable `archetype-churn ` line on every ctest run** — measured baseline g++ 16.2.1: Debug p50 0.123 ms / Release p50 0.0021 ms per op); API contract in `docs/api/archetype.md` (+ cross-refs in entity.md, component_registry.md, docs/README, sim README); `laige-api.json` regenerated (443 symbols, api-real-tree green); local Verify: canonical g++ tree zero-warning, full `ctest` green (36 tests), `ctest -R archetype` green, sim suites green on `build-asan` (ASan+UBSan, leak-free churn), `build-clang`, `build-release`, `build-shared`, `build-tsan`, `tools/laige-include-lint` OK | | 2026-09-14 | M1-ECS-04 | `f2f57e8` | Query API + iteration legality (M1-ECS-04 scope, nothing else): `World::each(fn, Read/Write tags...)` over the M1-ECS-03 SoA rows — superset match (extra components do not exclude), per-component access declared by tag TYPE (`Read` → `const T&`, `Write` → `T&`, decided at compile time; count/type checked by static_assert), the access tags follow the callable (a pack of parameters must be last to be deducible — the PRD sketch's `(access_flags, fn)` order settled here and documented in query.h), `each<>(fn)` visits all live entities ascending slot order, an unregistered listed type matches nothing (ok Status, zero visits); the iteration-legality guard — two stack-scoped membership-only `detail::IdSet256` sets (the matched-archetype set, complete before the first callback, plus the Read-declared component set) — enforces the query.h legality table: in-place write of a Read-declared queried component, structural add/remove/destroy/clear touching a matched source/target archetype, and a nested `each()` all **assert in debug** (six forked SIGABRT children) and in release return `ErrorCode::InvalidArgument` (no new codes — the registry stays additive-only) + one rate-limited warn + **skip-without-applying** while the iteration continues over the unmutated storage (FR-12.3; events `ecs/iteration_write_during_read`, `ecs/iteration_mutation`, `ecs/iteration_clear`, `ecs/iteration_nested`); legal paths: `create()` always, structural moves outside the matched set, writes through `Write` references, in-place overwrites of Write-declared components; no hidden allocations — iteration state is stack-scoped (`ids[N]`/`cols[N]`/`matchedIds[256]`/two 256-bit sets), the 10k-entity × 2-pass window (a Write pass storing per visit + a Read pass) measured **zero process-wide heap allocations** (test-only `operator new` counter, non-sanitizer trees; sanitizer trees leak-free) and zero reservation delta (pool-steady), ≈0.044 µs/visit on the `-O0` tree (machine-greppable `query-iteration ` lines per ctest run — CORE-001, M1-BENCH-01 baseline input); `clear()` is now `Status` (guard check first — a clear under a live iteration is rejected whole, never partial; the dtor never sees an active iteration) and all call sites honor `[[nodiscard]]`; compile-time machinery: the component/access packs ride as single tuple types (`std::tuple`, `std::tuple`) because an explicit template argument list cannot partition between consecutive packs, `rowRef` returns one `conditional_t` reference type (a `decltype(auto)` if/constexpr pair of returns forces inconsistent deduction), `visitRowRec` carries the accumulated references as a `Refs&...` reference pack (forwarded each level — no component copies); new public header `src/laige-sim/include/laige/sim/query.h` (`Access`/`Read`/`Write`, `detail::IdSet256`, the full contract) + `src/laige-sim/query.cpp` (the four guard helpers); 17 cases in the `query` CTest entry (exact mixed sets incl. superset, empty-query slot order + legal concurrent destroy, unregistered no-match, access-tag reference kinds, pinned archetype-then-slot visit order, guard release after iteration, the legal-mutation matrix, the release skip matrix (state unchanged + iteration continues + the same op succeeds afterwards), six debug assert cases, warn-once + `rate_limited` summary via a memory sink, the zero-alloc window); API contract in `docs/api/query.md` (+ cross-refs in entity.md, archetype.md, component_registry.md, docs/README, sim README); `laige-api.json` regenerated (451 symbols, api-real-tree green); local Verify: canonical g++ tree zero-warning, full `ctest` green (37 tests), `ctest -R query` green (15 passed + 2 release-only skips; 11 passed + 6 debug-only skips on `build-release`), sim suites green on `build-asan` (ASan+UBSan, leak-free), `build-clang`, `build-release`, `build-shared`, `build-tsan`, `tools/laige-include-lint` OK | | 2026-09-14 | M1-ECS-05 | `3cf8f91` | Deterministic iteration order (M1-ECS-05 scope, nothing else): the documented contract over the `World::each` visit order — archetypes in ascending archetype id (= the order a component set is first seen by a mutation, i.e. creation order), entities within an archetype in ascending slot id, the empty query ascending slot id; the order is a pure function of the world state, never of the operation history; the dense-id-order scheme (rows kept in ascending slot-id order through insert/remove — archetype.h invariants I1–I4) documented as what makes convergent histories visit identically; no unordered containers in the iteration path (only the fixed 256-record archetype table scanned in id order, packed slot columns, per-slot direct-index records, and membership-only 256-bit guard sets — the anticipated entity→archetype "one internal hash structure" is direct indexing, not even a hash, a stricter reading of the allowance; the one hash structure in `laige-sim`, the component type-key index, is lookup-only and never iterated, and sits on the setup path, not any tick); convergence property test: two worlds whose operation sequences interleave create/destroy differently (sequence A: serial per-entity scripts + end-phase dead destroys; sequence B: the same create phase — LIFO requires it for the identical entity→id assignment — with PRNG-scattered dead-destroys at round boundaries, round-robin component steps over the live entities in per-round PRNG permutations, and PRNG-placed component-less scratch pairs) converge on the identical final state including the entity→id assignment and iterate identically for five queries (`each<>`, `each`, `each`, `each`, `each`), verified per-visit (slot, generation, component values) against an independent oracle (free-list simulation + first-seen archetype order); component moves are structural throughout (adds of absent / removes of present components — the dense-id scheme pinned, not assumed); the archetype-1/archetype-2 boundary is non-vacuously distinguished from a global slot order (archetype 2 opens at slot 4, below archetype 1's top slot 19); KAT test pins the exact degenerate visit sequences; fixed PRNG seed (`TestPrng` substreams 1005–1007, `LAIGE_TEST_SEED` overridable) with machine-greppable `iter-order … fnv1a=0x…` lines — the visit hash is byte-identical across all four scenario instantiations and across g++/Clang/ASan/TSan/release trees and overridden seeds; new `iter_order` CTest entry (the step's Verify command, added to the TSan property list) over the shared `laige-sim_tests` executable; no public API added — `laige-api.json` unchanged (452 symbols, scanner rerun clean), no include-graph change (comments only; `tools/laige-include-lint` OK); API contract in `docs/api/iteration_order.md` (+ cross-refs in query.md, entity.md, archetype.md, component_registry.md, docs/README, sim README); local Verify: `ctest -R iter_order` green on `build` (g++), `build-asan`, `build-tsan`, `build-clang`, `build-release`; full `laige-sim_tests` suite green on `build` | +| 2026-09-14 | M1-ECS-06 | `675fb94` | ECS guardrails (G-R3 entity-count thresholds, G-R4 per-frame component churn; M1-ECS-06 scope, nothing else): create() warns exactly when the live count REACHES 25/50/100% of the declared scene budget (integer thresholds capacity*pct/100; a level whose threshold computes to 0 never fires), at most once per level per frame (events `ecs/entity_budget_{25,50,100}`); the G-R4 per-frame churn counter (successful addComponent calls, including in-place overwrites, plus row-detaching removeComponent calls) warns when the per-frame total STRICTLY EXCEEDS `Options::churnPerFrameBudget` (default 256; 0 disables), at most once per frame (event `ecs/churn_per_frame`); both O(1) integer bookkeeping, no hot-path allocation (the warn paths are cold, LOG-003); NFR-13.3 5-field message grammar, build-stable; debug builds carry the PRD §9.3 advice as a structured `advice` FIELD (never message text); beginFrame() drives the per-frame windows (M1-LOOP-01 will wire it); guardrailStats() is the M1-PROF-01 feed; new EcsGuardrails suite (`ctest -R ecs_guardrails` — exact threshold firing, once-per-level-per-frame dedup, degenerate thresholds, churn budget exceed/strictness, per-frame reset, no-op removes uncounted, budget-0 disable, grammar parse, profiler feed, zero-alloc below-threshold window); local Verify: canonical g++ tree zero-warning, full ctest green, `ctest -R ecs_guardrails` green, sim suites green on build-asan/build-clang/build-release/build-shared/build-tsan, include-lint OK; (board/changelog row retroactively added 2026-09-14 by the M1-ECS-07 PR — the step merged as `675fb94`/PR #22 without updating this board or log) | +| 2026-09-14 | M1-ECS-07 | `2995ec7` | ECS stress + memory accounting test (M1-ECS-07 scope, nothing else): new EcsStress suite — the step's Verify command `ctest -R ecs_stress` (added to the TSan property list) over the shared `laige-sim_tests` executable: 10k entities at the 100% scene budget (capacity 10000), 6 registered component types (Pos/Vel/Flag/Quad/Pair/Tag — 4/8/8/16/8/4 B, distinct strides), 10k frames of add/remove churn — each frame `beginFrame()` (drives G-R3/G-R4) + 64 seeded Tag adds + 64 Tag removes (cyclic Fisher-Yates permutation, `TestPrng` substream 1008, default-seed deterministic; 128 ops/frame, the default 256 G-R4 budget never exceeded) + one `each` iteration (Read, Read) with visit count + 64-bit FNV-1a checksum over (slot, generation, tag value); 700-frame warm-up (one full 625-frame cohort period — 8 cycles × 10000/128 picks/cycle — plus margin) brings every archetype's columns to their high water before the window, so the window's zero `totalReservations`/`totalArchetypeGrowth` delta IS the "pool high-water stable" claim (measured high water: 9 archetypes — 4 base + 4 Tagged + transient {Pos} — 24592 reserved rows, 549024 bytes); iteration within the documented cost (query.h: bounded archetype scan + one visit per matching entity) via a window-wide ns-per-visit throughput floor (600 ns, ≥8x the slowest measured: 58.7 ns g++ 16.2.1 / 72.3 ns clang++ 22.1.8, -O0 Debug); zero-allocation window (test-only operator-new counter, non-sanitizer trees; sanitizer trees: leak-free run + reservation delta); no NEW guardrail warns in the window (churnWarns delta 0; the entity-budget 25/50/100% warns fire exactly once at setup and never re-cross — the entity count never changes); memory accounting (PRD §8.1 base memory, accounted bytes): 110000 entity bookkeeping bytes (11 B/slot × 10k) + 549024 reserved row bytes at the 100%-full scene; machine-greppable `ecs-stress window/iteration/memory` lines on every ctest run — the window/memory lines are BYTE-IDENTICAL across g++/clang++ (pure integer workload, ARCH-010) and across repeated runs; no-leak Verify: `ctest -R ecs_stress` green on `build-asan` (37.7 s, no ASan/UBSan report); second baseline `docs/benchmarks/baselines/m1-ecs-stress.md` (AGENTS §12 fields, verbatim runs, cross-tree results; not a `budgets.json` workload — no `measured` field updated); baselines index + benchmarks README updated; no public API added — `laige-api.json` unchanged (452 symbols, `api-real-tree` green in the full-suite runs), no include-graph change (comments only); local Verify: `ctest -R ecs_stress` green on `build` (Debug g++, 14.7 s), `build-asan` (required, leak-free), `build-release`, `build-clang`, `build-tsan`, `build-shared`; full suite 40/40 on `build`/`build-asan`/`build-clang`, `laige-sim_tests` + `ecs_stress` green on the other trees; zero new warnings under NFR-8.10 | --- diff --git a/tests/laige-sim/CMakeLists.txt b/tests/laige-sim/CMakeLists.txt index b88fbeb..68c1d8e 100644 --- a/tests/laige-sim/CMakeLists.txt +++ b/tests/laige-sim/CMakeLists.txt @@ -1,21 +1,24 @@ -# laige-sim tests (M1-ECS-01/02/03/04/05/06): entity handle + World +# laige-sim tests (M1-ECS-01/02/03/04/05/06/07): entity handle + World # entity storage, component type registry, archetype SoA storage, -# query API + iteration legality, deterministic iteration order, and -# the ECS guardrails (G-R3/G-R4). +# query API + iteration legality, deterministic iteration order, the +# ECS guardrails (G-R3/G-R4), and the ECS stress + memory accounting +# suite. # # One executable per module (tests/README.md; docs/testing.md is the # source of truth): laige-sim_tests links the module under test plus # gtest_main. The unfiltered entry runs the whole module; the `entity`, -# `component_registry`, `archetype`, `query`, `iter_order`, and -# `ecs_guardrails` entries are the M1-ECS-01, M1-ECS-02, M1-ECS-03, -# M1-ECS-04, M1-ECS-05, and M1-ECS-06 Verify commands (`ctest -R entity`, -# `ctest -R component_registry`, `ctest -R archetype`, `ctest -R query`, -# `ctest -R iter_order`, `ctest -R ecs_guardrails`), selecting exactly +# `component_registry`, `archetype`, `query`, `iter_order`, +# `ecs_guardrails`, and `ecs_stress` entries are the M1-ECS-01, +# M1-ECS-02, M1-ECS-03, M1-ECS-04, M1-ECS-05, M1-ECS-06, and M1-ECS-07 +# Verify commands (`ctest -R entity`, `ctest -R component_registry`, +# `ctest -R archetype`, `ctest -R query`, `ctest -R iter_order`, +# `ctest -R ecs_guardrails`, `ctest -R ecs_stress`), selecting exactly # the suites below from the shared executable. set(LAIGE_SIM_TEST_SOURCES entity_tests.cpp component_registry_tests.cpp archetype_tests.cpp query_tests.cpp - iter_order_tests.cpp ecs_guardrails_tests.cpp) + iter_order_tests.cpp ecs_guardrails_tests.cpp + ecs_stress_tests.cpp) # M1-ECS-03: the test-only allocation counter overrides the global # operator new/new[]; the sanitizer runtimes define their own # new/delete (strong symbols in the Clang/GCC TSan runtime archives, @@ -101,10 +104,20 @@ add_test(NAME ecs_guardrails COMMAND laige-sim_tests --gtest_filter=EcsGuardrails.*) +# M1-ECS-07: ECS stress + memory accounting. The step's Verify command +# is `ctest -R ecs_stress` (required green under ASan: the leak-free +# build-asan run of this window is the no-leak check); this entry +# selects exactly the EcsStress suite from the shared laige-sim_tests +# executable (the machine-greppable window/iteration/memory lines land +# in the ctest output). +add_test(NAME ecs_stress + COMMAND laige-sim_tests + --gtest_filter=EcsStress.*) + if(LAIGE_TSAN) # Make the first data race report fatal to the test process (NFR-8.2), # so ctest fails loudly on any TSan report. set_tests_properties(laige-sim_tests entity component_registry archetype - query iter_order ecs_guardrails + query iter_order ecs_guardrails ecs_stress PROPERTIES ENVIRONMENT "TSAN_OPTIONS=halt_on_error=1") endif() diff --git a/tests/laige-sim/ecs_stress_tests.cpp b/tests/laige-sim/ecs_stress_tests.cpp new file mode 100644 index 0000000..4b4c94b --- /dev/null +++ b/tests/laige-sim/ecs_stress_tests.cpp @@ -0,0 +1,501 @@ +// laige-sim ECS stress + memory accounting suite (M1-ECS-07). +// +// Step scope (roadmap/M1-heartbeat.md, M1-ECS-07): +// - Stress test: 10k entities, 6 component types, 10k frames of +// add/remove churn. Verify: no leaks (the green `ctest -R ecs_stress` +// run in the ASan tree is the check — ASan's leak report fails the +// run), pool high-water stability (zero reservation/growth delta +// over the measured window), and iteration staying within the +// documented cost (query.h: bounded archetype scan + one visit per +// matching entity — checked as a platform-robust ns-per-visit +// throughput floor, CORE-001: measured, not assumed). +// - Memory accounting: the accounted storage bytes (EntityStats + +// ArchetypeStats) are printed as machine-greppable lines on every +// ctest run and recorded in +// docs/benchmarks/baselines/m1-ecs-stress.md (AGENTS §12). +// +// Workload (deterministic under the fixed test seed; docs/testing.md §4): +// - kEntities = 10000 entities (the PRD §8.1 reference scene size); +// the world capacity is exactly 10000, so the scene sits at the +// 100% G-R3 level for the whole run (the 100% warn fires once at +// setup and never re-crosses: the entity count never changes). +// - 6 registered component types (Pos, Vel, Flag, Quad, Pair, Tag — +// distinct sizes/alignments to exercise the column strides). The +// base sets are fixed by entity index (4 archetypes: +// {Pos,Vel}, {Pos,Vel,Flag}, {Pos,Quad}, {Pos,Pair}); Tag is the +// churned component. +// - Each frame: beginFrame() drives the guardrails (the owning loop +// drives it — here the test is the loop), then a seeded cyclic +// permutation supplies kAddPicks adds (Tag added to the picked +// entity when it lacks it) and kRemovePicks removes (Tag removed +// when present). 128 ops per frame — within the DEFAULT G-R4 +// budget of 256, so the stress window must run with zero churn +// warns (asserted). Then one `each` iteration (Read, +// Read) visits every Tagged entity: visit count + 64-bit FNV-1a +// checksum over (slot, generation, tag value). +// - A kWarmupFrames = 700-frame warm-up (one full 625-frame cohort +// period — 8 cycles x 10000/128 picks per cycle — plus margin) +// lets every archetype's columns grow to their high water before +// the measured window. The window's zero reservation delta is the +// "pool high-water stable" claim. +// +// Runs as CTest `ecs_stress` (the step's Verify command: +// `ctest -R ecs_stress`, required green under ASan): a filtered view +// of the shared laige-sim_tests executable, selecting exactly the +// EcsStress suite below. + +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "laige/budget_harness.h" +#include "laige/errors.h" +#include "laige/logging.h" +#include "laige/prng.h" +#include "laige/sim/entity.h" + +#include "laige_test_seed.h" + +#if defined(LAIGE_ALLOC_COUNTER) +#include "logging_alloc_counter.h" +#endif + +// --------------------------------------------------------------------------- +// NFR-8.10 policy self-checks (compile-time; a violation fails the build) +// --------------------------------------------------------------------------- + +#if defined(__cpp_exceptions) +static_assert(false, + "ecs_stress_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#elif defined(__EXCEPTIONS) && __EXCEPTIONS +static_assert(false, + "ecs_stress_tests must be built with exceptions " + "disabled (NFR-8.10); see laige_apply_engine_policy()."); +#endif + +#if defined(__cpp_rtti) && __cpp_rtti +static_assert(false, + "ecs_stress_tests must be built with RTTI disabled " + "(NFR-8.10); see laige_apply_engine_policy()."); +#endif + +// MSVC never updates __cplusplus from /std (it stays 199711L, a legacy +// compatibility value); the active standard is reported by _MSVC_LANG. +// Every other supported compiler (NFR-8.10) sets __cplusplus from -std. +#if defined(_MSC_VER) +# define STRESS_TESTS_ACTIVE_CPLUSPLUS _MSVC_LANG +#else +# define STRESS_TESTS_ACTIVE_CPLUSPLUS __cplusplus +#endif + +#if STRESS_TESTS_ACTIVE_CPLUSPLUS < 202002L +static_assert(false, + "ecs_stress_tests must be built as C++20 (NFR-8.10); " + "see laige_apply_engine_policy()."); +#endif + +// --------------------------------------------------------------------------- +// Test component types (global scope on purpose — LAIGE_COMPONENT must +// specialize the primary template in its enclosing namespace) +// --------------------------------------------------------------------------- + +// 8 B, 4 B-aligned: the basic packed-column case. +struct StressPos { + std::int32_t x{}; + std::int32_t y{}; +}; +LAIGE_COMPONENT(StressPos) + +// 8 B, 8 B-aligned: the SoA column alignment case. +struct StressVel { + std::int64_t v{}; +}; +LAIGE_COMPONENT(StressVel) + +// 4 B: the small-column case. +struct StressFlag { + std::int32_t f{}; +}; +LAIGE_COMPONENT(StressFlag) + +// 16 B: the wide-column case (the largest row stride in the workload). +struct StressQuad { + std::int32_t a{}; + std::int32_t b{}; + std::int32_t c{}; + std::int32_t d{}; +}; +LAIGE_COMPONENT(StressQuad) + +// 8 B from 4 B members: a mixed-stride column. +struct StressPair { + std::int16_t w{}; + std::int16_t h{}; + std::int16_t p{}; + std::int16_t q{}; +}; +LAIGE_COMPONENT(StressPair) + +// The churned component: toggled between the base archetypes every +// frame (the add/remove churn of the workload). +struct StressTag { + std::int32_t t{}; // the frame that added it (value churn too) +}; +LAIGE_COMPONENT(StressTag) + +namespace { + +// The PRNG substream id for this file (docs/testing.md §4, M0-TEST-01): +// distinct from the archetype suite (1003) and the iter-order scenario +// instantiations (1005-1007). +inline constexpr std::uint32_t kStressTestsSubstreamId = 1008; + +// The workload parameters (CORE-005: named, justified constants): +// kEntities the PRD §8.1 reference scene size (10k entities). +// kFrames the step's window length ("10k frames"). +// kWarmupFrames one full 625-frame cohort period (8 cycles x +// 10000/128 picks per cycle) plus margin: every +// archetype's columns reach their high water before +// the window, so the window's zero reservation delta +// is the "pool high-water stable" claim. +// kAddPicks / kRemovePicks 128 ops per frame — within the DEFAULT +// G-R4 budget (kDefaultChurnPerFrameBudget = 256), so +// the window must complete with zero churn warns. +// At this rate the steady-state structural churn is +// ~16 moves/frame (~160k over the window) plus 1.28M +// has() probes and 10k iterations over ~5k matches — +// the full-scale stress on the documented workload, +// in bounded wall time (~10 s on the -O0 Debug tree). +inline constexpr std::uint32_t kEntities = 10000; +inline constexpr std::uint32_t kFrames = 10000; +inline constexpr std::uint32_t kWarmupFrames = 700; +inline constexpr std::uint32_t kAddPicks = 64; +inline constexpr std::uint32_t kRemovePicks = 64; + +// The window's iteration throughput floor: max ns per visited entity +// (CORE-005 — the measured evidence and derivation live in the +// EcsStress test; the same platform-robust pattern as the churn test's +// kChurnMaxNsPerRowShifted: wall time per unit of accounted work, +// immune to shared-runner preemption noise, catching an +// order-of-magnitude per-visit regression). Measured P0 evidence +// (Debug, -O0, this workload): ~58 ns/visit Linux x64 (g++ 16.2.1), +// ~72 ns/visit Linux x64 (clang++ 22.1.8); the 600 ns floor is +// >= 8x the slowest measured. +inline constexpr double kStressMaxNsPerVisit = 600.0; + +// FNV-1a 64 (FNV-1a spec constants, fnv.org): the visit checksum. +inline constexpr std::uint64_t kFnvOffset64 = 0xcbf29ce484222325ull; +inline constexpr std::uint64_t kFnvPrime64 = 0x100000001b3ull; + +// One world with the default G-R4 budget, taken out of its Result +// (Result::value() is const; takeValue() && moves the storage out — +// the documented ownership-transfer path, result.h). +laige::World makeWorld(std::uint32_t capacity) { + auto w = laige::World::create(laige::World::Options{capacity}); + if (!w.ok()) { + ADD_FAILURE() << "World::create(" << capacity + << ") failed: " << laige::errorName(w.error()); + abort(); + } + return std::move(w).takeValue(); +} + +// One frame's churn + iteration sample. +struct FrameSample { + bool ok; // every attempted op and the iteration succeeded + std::uint64_t adds; // successful structural Tag adds + std::uint64_t removes; // successful structural Tag removes + std::uint64_t visits; // entities visited by the frame's iteration + double iterMs; // the frame's iteration wall time (ms) +}; + +// One frame (see the file preamble for the exact scheme): beginFrame +// drives the guardrails; the add phase picks kAddPicks entities from +// the cyclic permutation and adds Tag where absent; the remove phase +// picks kRemovePicks and removes Tag where present (skips are not +// counted — the G-R4 counting contract, entity.h); then the frame's +// iteration visits every entity with both Pos and Tag (superset +// match: all 4 Tagged variant archetypes), counting visits and +// folding (slot, generation, tag value) into the 64-bit FNV-1a +// checksum. No allocation anywhere (the M1 zero-allocation property; +// the window's zero operator-new count is asserted in the test). +FrameSample runFrame(laige::World& world, + const std::vector& entities, + const std::vector& perm, + std::uint32_t& cursor, std::uint32_t frame, + std::uint64_t& checksum) { + world.beginFrame(); + FrameSample s{true, 0, 0, 0, 0.0}; + for (std::uint32_t i = 0; i < kAddPicks && s.ok; ++i) { + const std::uint32_t pos = perm[cursor]; + cursor = (cursor + 1) % kEntities; + const laige::Entity e = entities[pos]; + if (world.has(e)) continue; // skip, not counted + if (!world + .addComponent(e, + StressTag{static_cast(frame)}) + .ok()) { + s.ok = false; + } else { + ++s.adds; + } + } + for (std::uint32_t i = 0; i < kRemovePicks && s.ok; ++i) { + const std::uint32_t pos = perm[cursor]; + cursor = (cursor + 1) % kEntities; + const laige::Entity e = entities[pos]; + if (!world.has(e)) continue; // skip, not counted + if (!world.removeComponent(e).ok()) { + s.ok = false; + } else { + ++s.removes; + } + } + if (!s.ok) return s; + laige::TimeIt iterTimer; + auto st = world.each( + [&](const laige::Entity& e, const StressPos&, const StressTag& tag) { + ++s.visits; + checksum ^= static_cast(e.id); + checksum *= kFnvPrime64; + checksum ^= static_cast(e.generation); + checksum *= kFnvPrime64; + checksum ^= static_cast( + static_cast(tag.t)); + checksum *= kFnvPrime64; + }, + laige::Read{}, laige::Read{}); + s.iterMs = iterTimer.elapsedMs(); + if (!st.ok()) s.ok = false; + return s; +} + +} // namespace + +// --------------------------------------------------------------------------- +// The stress step (M1-ECS-07 scope): 10k entities x 6 component types x +// 10k frames of add/remove churn — no leaks (ASan tree), pool +// high-water stable, iteration within the documented cost, accounted +// memory printed for the baseline record +// --------------------------------------------------------------------------- + +TEST(EcsStress, TenKEntitiesSixTypesTenKFramesChurn) { + // Setup (phase: backing allocations and archetype growth allowed — + // the window under test starts after the warm-up): the world at + // exactly its scene budget, 6 registered types, 10k entities in the + // 4 base archetypes (by index — no randomness in the base sets). + laige::World world = makeWorld(kEntities); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + ASSERT_TRUE(world.registerComponent().ok()); + EXPECT_EQ(world.componentCount(), 6u); + + std::vector entities(kEntities); + for (std::uint32_t i = 0; i < kEntities; ++i) { + auto e = world.create(); + ASSERT_TRUE(e.ok()); // the scene fills the budget exactly + entities[i] = e.value(); + const std::int32_t base = static_cast(i); + ASSERT_TRUE(world.addComponent(entities[i], + StressPos{base, -base}) + .ok()); + switch (i % 4) { + case 0: + ASSERT_TRUE(world + .addComponent( + entities[i], StressVel{static_cast(i)}) + .ok()); + break; + case 1: + ASSERT_TRUE(world + .addComponent( + entities[i], StressVel{static_cast(i)}) + .ok()); + ASSERT_TRUE(world + .addComponent(entities[i], + StressFlag{base}) + .ok()); + break; + case 2: + ASSERT_TRUE(world + .addComponent( + entities[i], StressQuad{base, base, base, base}) + .ok()); + break; + case 3: + ASSERT_TRUE(world + .addComponent( + entities[i], + StressPair{ + static_cast(i), + static_cast(i % 100), + static_cast(i % 50), + static_cast(i % 25)}) + .ok()); + break; + } + } + // 5 archetypes: the 4 base sets plus the transient {Pos} set created + // while the first component of each entity is attached (each entity + // leaves {Pos} immediately — its rows stay reserved at the initial + // 16, the M1 "archetypes are never destroyed" rule). + EXPECT_EQ(world.archetypeCount(), 5u); + + // The pick order: a seeded Fisher-Yates permutation (docs/testing.md + // §4: deterministic per seed, LAIGE_TEST_SEED overridable). + laige::Prng rng = laige::testing::TestPrng(kStressTestsSubstreamId); + std::vector perm(kEntities); + for (std::uint32_t i = 0; i < kEntities; ++i) perm[i] = i; + for (std::uint32_t i = kEntities; i > 1; --i) { + const std::uint32_t j = rng.next_range(0, i); // [0, i) + std::swap(perm[i - 1], perm[j]); + } + + // Warm-up (kWarmupFrames = one full cohort period + margin): the + // archetypes' columns grow to their high water before the window. + std::uint32_t cursor = 0; + std::uint64_t checksum = kFnvOffset64; + bool warmupOk = true; + for (std::uint32_t f = 0; f < kWarmupFrames; ++f) { + const FrameSample s = runFrame(world, entities, perm, cursor, f, checksum); + if (!s.ok) { + ADD_FAILURE() << "warm-up frame " << f << " failed"; + warmupOk = false; + break; + } + } + ASSERT_TRUE(warmupOk); + + const laige::ArchetypeStats before = world.archetypeStats(); + // The setup phase attached ~32.5k components without a driven + // beginFrame(), so the G-R4 counter accumulated them in one window + // and its warn-once-per-lifetime degradation may have fired (the + // documented no-frame behavior, entity.h). The window's claim is + // that no NEW churn warn fires in it: the delta must be zero. + const laige::GuardrailStats beforeGuardrails = world.guardrailStats(); + + // The measured window: 10k frames of churn + iteration. + laige::Histogram hist(laige::Histogram::Options{kFrames}); + std::uint64_t totalAdds = 0; + std::uint64_t totalRemoves = 0; + std::uint64_t totalVisits = 0; + std::uint64_t tagsPeak = 0; + double iterMsTotal = 0.0; +#if defined(LAIGE_ALLOC_COUNTER) + // The window starts here: the permutation, histogram, and warm-up + // allocated above, so the reset lands between setup and the ops. + laige::test::resetAllocCounter(); +#endif + bool windowOk = true; + for (std::uint32_t f = 0; f < kFrames; ++f) { + const FrameSample s = runFrame(world, entities, perm, cursor, + kWarmupFrames + f, checksum); + if (!s.ok) { + ADD_FAILURE() << "window frame " << f << " failed"; + windowOk = false; + break; + } + hist.record(s.iterMs); + totalAdds += s.adds; + totalRemoves += s.removes; + totalVisits += s.visits; + if (s.visits > tagsPeak) tagsPeak = s.visits; + iterMsTotal += s.iterMs; + } + const laige::ArchetypeStats after = world.archetypeStats(); + ASSERT_TRUE(windowOk); + + // Zero pool overflow and pool high-water stability: every op + // succeeded (no BudgetExhausted, no growth failure) and the window + // reserved nothing new — the churn moved only between the + // pre-reserved column blocks (the reserve policy, archetype.h). + EXPECT_EQ(after.totalReservations, before.totalReservations); + EXPECT_EQ(after.totalArchetypeGrowth, before.totalArchetypeGrowth); + EXPECT_EQ(after.totalAdds, before.totalAdds + totalAdds); + EXPECT_EQ(after.totalRemoves, before.totalRemoves + totalRemoves); + // Only Tag was toggled: every entity keeps its base components. + EXPECT_EQ(after.rowsLive, kEntities); + EXPECT_EQ(world.entityCount(), kEntities); + // No archetype may reserve more than the world capacity (the + // documented growth cap, archetype.h). + EXPECT_LE(after.rowsReserved, + std::uint32_t(laige::kMaxArchetypes) * kEntities); + +#if defined(LAIGE_ALLOC_COUNTER) + // The window's zero-allocation property (non-sanitizer trees): only + // the pre-reserved column blocks are touched. (The sanitizer trees + // prove the same property with the leak-free run of this window — + // the step's ASan Verify — plus the reservation delta above.) + EXPECT_EQ(laige::test::allocCounter(), 0u); +#endif + + // Iteration within the documented cost (query.h: bounded archetype + // scan + one visit per matching entity). The window-wide + // throughput floor (CORE-001: measured, not assumed): the sum of the + // per-frame iteration times over the window, per visited entity. A + // regression that adds per-visit work (an extra indirection, a + // hidden allocation, a per-visit log) pushes the per-visit cost up + // by orders of magnitude; preemption noise spreads across the whole + // window and cannot. Measured P0 evidence (Debug, -O0, this + // workload): ~58 ns/visit on Linux x64 (g++ 16.2.1) and ~72 + // ns/visit on Linux x64 (clang++ 22.1.8); the 600 ns floor is + // >= 8x the slowest measured. + ASSERT_GT(totalVisits, 0u); + const double nsPerVisit = + iterMsTotal * 1e6 / static_cast(totalVisits); + EXPECT_LT(nsPerVisit, kStressMaxNsPerVisit); + + // The window issued no new guardrail warns: the per-frame churn + // (128 ops max) never exceeded the default 256 budget, and the + // entity count sat at the 100% level the whole time (the 100% warn + // fired once at setup and never re-crossed). + const laige::GuardrailStats g = world.guardrailStats(); + EXPECT_EQ(g.churnWarns, beforeGuardrails.churnWarns); + EXPECT_EQ(g.entityBudgetWarns[0], 1u); + EXPECT_EQ(g.entityBudgetWarns[1], 1u); + EXPECT_EQ(g.entityBudgetWarns[2], 1u); + EXPECT_EQ(g.entityBudgetLevel, 100u); + EXPECT_LE(g.frameChurn, g.churnPerFrameBudget); + + // Machine-greppable stats lines for the baseline record (CORE-001 / + // AGENTS §12: the measured cost and the accounted work/memory, on + // every ctest run). + const laige::HistogramStats st = hist.stats(); + ASSERT_EQ(st.n, kFrames); + const laige::EntityStats es = world.stats(); + std::printf( + "ecs-stress window: frames=%u adds=%llu removes=%llu " + "visits=%llu tags_peak=%llu checksum=0x%016llx\n", + kFrames, static_cast(totalAdds), + static_cast(totalRemoves), + static_cast(totalVisits), + static_cast(tagsPeak), + static_cast(checksum)); + std::printf("ecs-stress iteration: %s ns_per_visit=%.1f\n", + laige::formatStatsLine(st).c_str(), nsPerVisit); + std::printf( + "ecs-stress memory: entities=%u entity_bytes_capacity=%llu " + "entity_bytes_inuse=%llu archetypes=%u rows_live=%u " + "rows_reserved=%llu bytes_reserved=%llu\n", + kEntities, static_cast(es.bytesCapacity), + static_cast(es.bytesInUse), + after.archetypeCount, after.rowsLive, + static_cast(after.rowsReserved), + static_cast(after.bytesReserved)); + std::fflush(stdout); + + // Spot-check the final state through the public API. + EXPECT_TRUE(world.has(entities[0])); + EXPECT_TRUE(world.has(entities[3])); + EXPECT_NE(world.get(entities[0]), nullptr); + EXPECT_EQ(world.entityCount(), kEntities); +}