Files
VoxelForge/CODEX-TASK-002-column-memo-thrash.md
T
Fr0zka b5294b2d5b docs: close C2, sharpen the column-memo prediction, reassess REVIEW_FINDINGS
Reading session -- no build available, so: verify things instead of writing code.

- Cross-checked the eight stat declarations against their definitions and use
  sites. Identical. The one compile risk I had only eyeballed is now retired.

- REVIEW_FINDINGS: the GetDensityWithParams and BuildChunkCache splits are now
  counter-productive rather than optional -- the op stack is replacing the
  first, and FRoomGraphSource deliberately CALLS the second so a restructure
  forks what was kept unforked. The two passage enums are not duplicates and
  are UMETA-serialised into authored assets; merging them rewrites content for
  tidiness. Judged: leave, and close the item.

- CODEX-TASK-002's acceptance said "20-30% of lookups", a guess wearing a
  number. Replaced with arithmetic from the real grid: 1225 columns/tile over
  35 Z planes, load factor 0.299, ~317 colliding columns, ~12000 vs 1225
  computations = ~9.8x. Plus the reading rule that matters -- the overhang and
  cliff mods re-query the same XY and add HITS only, so compare the ratio of
  the two hypotheses, not an absolute percentage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 16:14:18 +02:00

7.2 KiB
Raw Blame History

Codex task 002 — is the op-stack column memo thrashing? Count, don't guess.

Owner: Codex (Model Luna, xHigh) · Orchestrator: Claude · Branch: experimental Status: specified, not started Depends on: CODEX-TASK-001 — this adds counters to the same stat VoxelForge group. Do 001 first; this task assumes VoxelStats.h already exists.


Why this exists

The operator-stack density path is measurably slower than the switch it replaces, and the cause has never been attributed. There are three standing suspects. This task measures the first one, and does not fix anything. That order is deliberate: AUDIT §C10 cost six builds and five refuted hypotheses by reasoning first, and the last session re-learned it.

The hypothesis, derived from the code

FSurfaceColumnSource::GetColumn (VoxelDensityOpStack.cpp ~615) memoises a computed column in a direct-mapped, 4096-entry thread_local table, indexed by a hash of the two XY floats:

struct FSlot { uint64 Key; float X, Y; FColumn C; };   // 40 bytes
thread_local FSlot Slots[4096] = {};                   // 160 KB per worker
const uint32 Idx = ((HX * 0x9E3779B9u) ^ (HY * 0x85EBCA6Bu)) >> 20;   // [0,4095]

The original path, GSurfColCache in GetDensityAt, is instead a direct-indexed box: CI = (IY - Box.BaseY) * Dim + (IX - Box.BaseX), with a Computed[CI] flag. No hash, therefore no collisions, therefore every column is computed exactly once.

Now the sampling order, which is the premise that makes this bite. FVoxelMarchingCubesMesher pre-samples with Z as the OUTERMOST loop (VoxelMarchingCubesMesher.cpp ~226):

for (int32 gz = GzLo; gz <= GzHi; gz++)
    for (int32 gy = -1; gy <= GridDim; gy++)
        for (int32 gx = -1; gx <= GridDim; gx++)
            Generator->GetDensityAt(...);

So the mesher sweeps a whole XY plane at every Z level. Every column in the tile is revisited once per Z plane — roughly 34 times.

The table's own comment sized it for this: "A chunk is CHUNK_SIZE² columns (1024), so the first draft's 256 entries could not even hold one chunk and thrashed inside a single tile. 4096 covers four chunks." That reasoning has a gap. A direct-mapped table does not need to be full to evict — it needs two live keys to collide. At ~1156 columns per plane in 4096 slots (load factor 0.28), the expected number of columns sharing a slot with another is ~285, about 25 %. Those columns evict each other, miss again on the next Z plane, and recompute the entire height stack — structural source, cliff (four structural resamples), terrace, layer-line, beach, and the ceiling stack — every single plane.

Order of magnitude if that is right: ~1156 column computations on the original path versus ~1156 + 285 × 34 ≈ 10 000 on the op path. Roughly 9× the column work, on the plugin's most expensive archetype.

That is a derivation, not a measurement, and it is exactly the kind of confident chain this project has watched reverse six times. Hence: count first.

What to build — two counters, no behaviour change

Add to the existing stat VoxelForge group from task 001:

counter incremented when
ColumnMemoHit GetColumn found a live entry (the if body did not run)
ColumnMemoMiss GetColumn recomputed (the if body ran)

That is the entire change. Two INC_DWORD_STAT calls inside FSurfaceColumnSource::GetColumn, around the existing if (S.Key != ColumnKey || S.X != WorldX || S.Y != WorldY).

⚠️ Invariants

  1. Change nothing else in GetColumn. Not the table size, not the hash, not the key comparison. The point of this task is to produce a number that decides whether the fix is worth writing; a change bundled in makes the number unattributable. Do not "obviously improve" the table while you are in there — if the fix ships in the same build as the instrument, we learn nothing, and this project has a written lesson about exactly that.
  2. GetColumn is const and runs on mesher workers. INC_DWORD_STAT, never a static int32++ — same rule as task 001, same reason.
  3. The key comparison is load-bearing and must stay complete. S.Key != ColumnKey || S.X != X || S.Y != Y — the full key is compared on every touch precisely so a hash collision can only cost a recompute and never return someone else's column. Do not shorten it to feed a counter.
  4. Zero cost when STATS == 0. No value computed outside the macros.
  5. FCaveCliffMod and the overhang read this same memo through Column->GetColumn(...) (~line 864). They are legitimate traffic and must be counted, not excluded — they are part of why a miss is expensive.

Acceptance — the prediction is now numeric (tightened 2026-08-16 from the real grid dimensions)

The hand-wavy "2030 %" band this section used to carry has been replaced by an arithmetic prediction, because the inputs are all statically knowable and were read out of the source:

  • CHUNK_SIZE = 32, CellsPerAxis = 32, GridDim = 33, and the pre-sample loops run g ∈ [-1, GridDim] per axis ⇒ 35 × 35 = 1225 distinct columns per tile, over 35 Z planes (the mesher's own buffer comment, "35³ floats", confirms the dimension).
  • 1225 keys in 4096 slots is a load factor of 0.299. Expected slots holding exactly one key = 4096 · np(1-p)^(n-1) ≈ 908, so ~317 columns (25.9 %) share a slot with another and evict each other on every plane.
  • ⇒ op path ≈ 1225 + 34 × 31712 000 column computations per tile. Original path (GSurfColCache, direct-indexed, Computed[CI] persists) = 1225. ≈ 9.8×.

How to read the result

⚠️ Compare the RATIO OF THE TWO HYPOTHESES, not an absolute percentage. The overhang and cliff modifiers call GetColumn again at the same XY (~line 864); every extra consumer adds hits and no misses, so it inflates the denominator and drags the miss rate down without changing the verdict. What does not move is the ~10× gap between the two outcomes.

observation verdict
misses ≈ 810× the hit-path baseline (single-consumer: ~28 % of lookups) CONFIRMED — the table evicts on collision every Z plane. The fix gets its own task, with this run as its "before".
misses ≈ 1 per distinct column (single-consumer: ~3 %, hit rate ≥ 97 %) hypothesis WRONG. The table behaves like the box, the ~9.8× does not exist, and the perf cost is suspect 2 (19 virtual calls per voxel).

A negative result here is a real result. It retires the most-suspected cause and is worth the build either way; it must be written into OPSTACK-PROGRESS.md, not quietly dropped.

Report both raw numbers, never the ratio alone — a ratio cannot distinguish "few lookups" from "many", and the absolute miss count is what the fix would be reducing.

Notes for the reviewer (Claude)

  • Confirm the miss counter sits inside the if, and the hit counter in an else — not computed from a subtraction, which would silently agree with itself.
  • Confirm GetColumn's early-out path (if any is added later) cannot skip both counters.
  • Confirm nothing else in VoxelDensityOpStack.cpp changed. git diff --stat should show one file and a handful of lines.