Skip to content

perf(snapshot): stop persisting derived sparse-set tables in snapshots - #935

Open
AnthonyWadham wants to merge 1 commit into
scott/cardinal-persiststate-perffrom
Anthony/drop-derived-snapshot-tables
Open

perf(snapshot): stop persisting derived sparse-set tables in snapshots#935
AnthonyWadham wants to merge 1 commit into
scott/cardinal-persiststate-perffrom
Anthony/drop-derived-snapshot-tables

Conversation

@AnthonyWadham

@AnthonyWadham AnthonyWadham commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Snapshots persisted two sparse sets that are pure derived data:

  • WorldState.entity_arch — which archetype an entity belongs to. Already implied by which archetype's entities list the entity appears in.
  • Archetype.rows — entity → row. The exact inverse of Archetype.entities.

Both are now rebuilt on load instead of written to storage. Each is a repeated int64 sized by the highest entity ID ever allocated, not by the live entity count, so they were the part of a snapshot that grew with churn rather than with world size.

Breaking: snapshot format 1 → 2

CurrentVersion goes to 2, and ValidateVersion keeps its exact-equality policy — a version-1 snapshot is refused at boot, not migrated.

The direction that matters here is the reverse one. A v1 snapshot is in fact readable by this build: protobuf ignores the fields it no longer knows, and the rebuild only needs entities and archetypes, both of which v1 carries. What is not safe is an older build reading a v2 snapshot — it would call fromInt64Slice(nil), get empty tables, and come up silently wrong.

Bumping the version is what stops that: the older build already refuses anything above its own CurrentVersion. Staying at 1 would leave a build on an older checkout accepting these snapshots and constructing a broken world with no error — which in practice means branch-switching quietly corrupts local state.

Impact: any existing v1 snapshot will not restore after this lands. Wipe local state, or land a migration path first. Nothing is deployed, so this is a one-time developer cost in exchange for a loud failure instead of a silent one.

Benchmarks

BenchmarkSnapshotStore, pkg/plugin/physics2d/test. linux/amd64, go1.26.5, GOMAXPROCS 16, -benchtime=200x -count=5. Baseline is scott/cardinal-persiststate-perf (this PR's base). Times are medians of 5 with full ranges; byte counts are exact and identical across all runs.

Snapshot size — the point of the change

bodies before after delta
1000 157,765 B 142,914 B −14,851 B (−9.4%)
5000 877,089 B 715,194 B −161,895 B (−18.5%)

Serialization on the production path (SingleMarshal)

bodies before after delta
1000 77.1 µs (74.0–77.7) 69.7 µs (67.4–75.8) −9.5%
5000 418.2 µs (395.2–425.6) 312.5 µs (296.4–332.0) −25.3%
5000 B/op 884,738 720,897 −163,841 B

The 5000-body ranges do not overlap. The 1000-body ranges overlap slightly, so read that one as "smaller, roughly in line with the byte reduction."

What this does not claim

  • BenchmarkSnapshotTick was too noisy on my machine to attribute. The Rate_1000000 control case — which never takes a snapshot and is untouched by this change — swung −10.9% between runs. Nothing from that benchmark is reported here. Worth a rerun on a quiet box if anyone wants the end-to-end tick figure.
  • Restore cost is not benchmarked — there is no restore benchmark in the repo. Rebuilding is O(total entities), one pass per archetype, which is the same order as the fromInt64Slice copy it replaces, and it drops the varint decode of two large arrays. That reasoning says restore should be neutral-to-faster, but it is reasoning, not a measurement.

Testing

go test ./pkg/cardinal/... — all passing.

Round-trip coverage in world_state_internal_test.go and archetype_internal_test.go now compares the rebuilt sparse sets through get() over the key domain rather than by backing array, because a rebuilt table is sized to the highest live entity while the original grew to its high-water mark.

New: TestRestoredWorldStaysUsable drives a restored world instead of only comparing it — reads every survivor through both Iter() (archetype entity lists) and GetByID() (the rebuilt tables), then creates, destroys, and snapshots a second time.

One thing reviewers should know: TestSnapshotRoundTripThroughStorage no longer covers the sparse sets. Its proto.Equal compares ToProto output, and ToProto doesn't emit these tables anymore — with the rows rebuild deleted outright, that test still reports ok. I verified this by mutation: disabling either rebuild loop fails TestRestoredWorldStaysUsable and nothing else. That new test is the only thing standing on this now.

@AnthonyWadham
AnthonyWadham force-pushed the Anthony/drop-derived-snapshot-tables branch 2 times, most recently from 1cbaf8d to f2d665f Compare August 6, 2026 18:52
@AnthonyWadham
AnthonyWadham force-pushed the Anthony/drop-derived-snapshot-tables branch from f2d665f to e191abf Compare August 6, 2026 20:59
@AnthonyWadham
AnthonyWadham marked this pull request as ready for review August 6, 2026 21:00
@AnthonyWadham
AnthonyWadham requested a review from rmrt1n August 6, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant