Files
VoxelForge/CODEX-TASK-002-column-memo-thrash.md
T
Fr0zka 7313201e91 docs: spec CODEX-TASK-002 (column-memo thrash) and aim the PERF work
Three orchestration findings on the parked PERF item, none measured yet:

1. The perf A/B needs no new code -- two Insights traces on a deterministic
   world are a clean before/after. But tile skipping changes how many times
   GenerateMesh runs, so totals conflate "cheaper per tile" with "fewer
   tiles". TilesMeshed from task 001 is the denominator. 001 is therefore a
   PREREQUISITE for the perf work, not a parallel item as the handoff had it.

2. The suspects don't share an archetype: SurfaceWorld is the column memo,
   TunnelNetwork is 19 virtual calls per voxel (16 + 3 structural post).
   One lumped number can't be acted on -- measure one strate at a time.

3. GetColumn is a direct-mapped hashed table where GSurfColCache is a
   direct-indexed box, and the mesher pre-samples Z-OUTERMOST (verified,
   VoxelMarchingCubesMesher.cpp ~226) so every column is revisited ~34x per
   tile. The table's sizing comment reasons about fullness; a direct-mapped
   table evicts on collision. At load factor 0.28 that is still ~25% of
   columns recomputing the full height stack every Z plane -- derived ~9x.

Task 002 measures that and fixes nothing, so the instrument and the fix
cannot land in the same build and make each other unreadable.

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

6.0 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

Fly a route through a SurfaceWorld strate that has bUseOperatorStack ticked, stat VoxelForge on screen, and read the ratio.

  • ColumnMemoMiss ≈ one per distinct column per tile (hit rate climbing toward ~97 % as Z planes accumulate) ⇒ the hypothesis is WRONG, the table is fine, and the perf cost is elsewhere. Record that and move to suspect 2 (per-voxel virtual dispatch). A negative result here is a real result and must be written down, not quietly dropped.
  • ColumnMemoMiss in the neighbourhood of 2030 % of all lookups, and staying thereconfirmed: the table is evicting on collision every Z plane. The fix is then obvious and cheap (direct-indexed box keyed like GSurfColCache, or a set-associative table), and gets its own task with the before/after this run supplies.

Report both numbers, not the ratio alone — a ratio cannot distinguish "few lookups" from "many".

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.