perf: the column memo threw itself away every chunk
Jahni measured what I had only flagged: generation is slower on the op-stack path. Two compounding causes. The memo was keyed on InstanceId, which changes on every stack rebuild — every chunk. GSurfColCache, the cache this path replaced, is keyed on (XY box, StrateKey, Seed, LayoutVersion) with no ChunkZ, deliberately shared down the whole vertical strate stack. So a 4-chunk strate recomputed every column four times, including the cliff's four extra structural samples per column. And the table held 256 entries where a chunk is CHUNK_SIZE^2 = 1024 columns, so it thrashed against itself within a single tile before any cross-chunk question arose. PrepareChunk now derives a shared ColumnKey from (StrateBottomWorldZ, LayoutVersion, Seed) — the same identity GSurfColCache uses — and the table is 4096 entries (~150 KB/worker, in line with GSurfColCache's 6 x 59 KB). The memo is thread_local so it already survived rebuilds; only the key was discarding the contents. Sharing across chunk Z is sound because heights are XY-pure by type and the biome field is documented Z-independent — the same justification GSurfColCache rests on. ColumnKey starts at InstanceId rather than 0: slots initialise to Key = 0, so a zero key would falsely hit the pristine slot at (0,0). Without PrepareChunk you get per-instance caching, which is less sharing but still correct. This may not close the gap entirely and I am not claiming it does. Virtual dispatch and the hashed lookup vs a direct-indexed box both remain; they are smaller than a 4x column recompute, but "smaller" is a guess until measured. UNVERIFIED: not compiled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1631,3 +1631,48 @@ the case that was guarded off until now.
|
|||||||
`FSdfRoughnessMod` and `FSdfCarve` unchanged from Maze, so it should be the cheapest of the eight.
|
`FSdfRoughnessMod` and `FSdfCarve` unchanged from Maze, so it should be the cheapest of the eight.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-27 — SurfaceWorld verified visually. And Jahni measured the thing I had only flagged.
|
||||||
|
|
||||||
|
All 10 green, biome checks now visible and correct (blend bit-exact across the whole weight sweep;
|
||||||
|
ceiling selects rather than blends at weight 1.0). Jahni: *"the opstack looks similar if not
|
||||||
|
identical to the old terrain"* — the biome case included, which is what step 2c added.
|
||||||
|
|
||||||
|
**And: *"it took a bit more time generating with the opstack."*** That is a real regression, it was
|
||||||
|
predictable, and I had flagged it as "pending" rather than fixed. Diagnosed, two compounding causes:
|
||||||
|
|
||||||
|
**1. The memo key invalidated on every chunk.** It was keyed on `InstanceId`, which changes on every
|
||||||
|
stack rebuild — i.e. every chunk. `GSurfColCache`, the path it replaced, is keyed on
|
||||||
|
`(XY box, StrateKey, Seed, LayoutVersion)` with **no ChunkZ**, deliberately *"shared down the whole
|
||||||
|
vertical strate stack"*. So a 4-chunk-tall strate recomputed **every column four times**, cliff
|
||||||
|
resamples included — and the cliff costs four extra structural samples per column.
|
||||||
|
|
||||||
|
**2. The table could not hold one chunk.** A chunk is `CHUNK_SIZE²` = 1024 columns; the table had
|
||||||
|
**256** entries. It thrashed against itself *within a single tile*, before any cross-chunk question.
|
||||||
|
|
||||||
|
**Fixed:** `PrepareChunk` now derives a **shared** `ColumnKey` from `(StrateBottomWorldZ,
|
||||||
|
LayoutVersion, Seed)` — the same identity `GSurfColCache` uses — and the table is 4096 entries
|
||||||
|
(~150 KB/worker, in line with `GSurfColCache`'s 6 × 59 KB). The memo is `thread_local`, so it
|
||||||
|
already survived stack rebuilds; **only the key was throwing the contents away.**
|
||||||
|
|
||||||
|
**Why sharing across chunk Z is sound:** heights are XY-pure *by type* (the whole point of
|
||||||
|
`VoxelHeightOp.h` — there is no Z in the signature), and the biome field is documented
|
||||||
|
Z-independent (*"ZERO Z dependence: the climate/Voronoi fields are pure-XY"*). That is precisely the
|
||||||
|
justification `GSurfColCache` already rests on.
|
||||||
|
|
||||||
|
**Un-prepared safety:** `ColumnKey` starts at `InstanceId` rather than 0, because slots initialise to
|
||||||
|
`Key = 0` and a zero key would falsely hit the pristine slot at (0,0). Without `PrepareChunk` you
|
||||||
|
get per-instance caching — less sharing, still correct. Degrade, never lie.
|
||||||
|
|
||||||
|
**⚠️ This may not close the gap entirely, and I am not claiming it does.** Virtual dispatch (5 ops
|
||||||
|
per voxel) and the hashed lookup versus the original's direct-indexed box both remain. Those are
|
||||||
|
smaller than a 4× column recompute, but "smaller" is a guess until measured. **The next generation
|
||||||
|
timing is the measurement** — if it is still slower, the remaining suspects in order are: the box
|
||||||
|
cache's direct indexing vs. my hash, then per-voxel virtual calls.
|
||||||
|
|
||||||
|
**UNVERIFIED:** not compiled.
|
||||||
|
|
||||||
|
**Next single action:** build, regenerate, and compare generation time against the switch path with
|
||||||
|
the flag off. Then `VerticalShafts` (§6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -461,6 +461,15 @@ namespace
|
|||||||
// A unique, never-recycled id — assigned here only; the other ctor delegates.
|
// A unique, never-recycled id — assigned here only; the other ctor delegates.
|
||||||
static std::atomic<uint64> NextId{ 1 };
|
static std::atomic<uint64> NextId{ 1 };
|
||||||
InstanceId = NextId.fetch_add(1, std::memory_order_relaxed);
|
InstanceId = NextId.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
// Départ sur l'identité D'INSTANCE, pas sur 0 : les entrées du mémo s'initialisent à
|
||||||
|
// `Key = 0`, donc une clé nulle ferait FAUSSEMENT toucher le slot vierge en (0,0).
|
||||||
|
// `PrepareChunk` remplacera ceci par la clé partagée de strate ; sans lui, on garde un
|
||||||
|
// cache par instance — moins de partage, mais correct. Dégrader, jamais mentir.
|
||||||
|
// Starting at the INSTANCE id rather than 0: slots initialise to Key = 0, so a zero key
|
||||||
|
// would falsely hit the pristine slot at (0,0). PrepareChunk upgrades this to the shared
|
||||||
|
// strate key; without it we simply cache per instance. Degrade, never lie.
|
||||||
|
ColumnKey = InstanceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sans biomes — délègue, pour qu'il n'existe qu'UN corps de construction et UN compteur
|
/** Sans biomes — délègue, pour qu'il n'existe qu'UN corps de construction et UN compteur
|
||||||
@@ -498,23 +507,27 @@ namespace
|
|||||||
// structurels). Ce n'est pas « un peu plus lent », c'est un ordre de grandeur sur
|
// structurels). Ce n'est pas « un peu plus lent », c'est un ordre de grandeur sur
|
||||||
// l'archétype le plus cher du plugin.
|
// l'archétype le plus cher du plugin.
|
||||||
//
|
//
|
||||||
// Table à correspondance directe, 256 entrées, clé COMPLÈTE comparée sur touche : une
|
// Table à correspondance directe, clé COMPLÈTE comparée sur touche : une collision ne
|
||||||
// collision ne peut que coûter un recalcul, jamais rendre une mauvaise colonne.
|
// peut que coûter un recalcul, jamais rendre une mauvaise colonne.
|
||||||
//
|
//
|
||||||
// A single-entry memo is only correct-by-luck: it assumes the caller walks a Z column
|
// TAILLE : un chunk fait CHUNK_SIZE² colonnes (1024 à 32³). Les 256 entrées du premier
|
||||||
// before changing XY, which the mesher does not promise. Direct-mapped 256-entry table
|
// jet ne tenaient donc même pas UN chunk — la table se piétinait elle-même à
|
||||||
// with the FULL key compared on hit — a collision costs a recompute, never a wrong column.
|
// l'intérieur d'une seule tuile. 4096 entrées couvrent quatre chunks de front, pour
|
||||||
struct FSlot { uint64 Id; float X, Y; FColumn C; };
|
// ~150 Ko par worker : du même ordre qu'une boîte de `GSurfColCache` (~59 Ko × 6).
|
||||||
thread_local FSlot Slots[256] = {};
|
//
|
||||||
|
// 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.
|
||||||
|
struct FSlot { uint64 Key; float X, Y; FColumn C; };
|
||||||
|
thread_local FSlot Slots[4096] = {};
|
||||||
|
|
||||||
const uint32 HX = *reinterpret_cast<const uint32*>(&WorldX);
|
const uint32 HX = *reinterpret_cast<const uint32*>(&WorldX);
|
||||||
const uint32 HY = *reinterpret_cast<const uint32*>(&WorldY);
|
const uint32 HY = *reinterpret_cast<const uint32*>(&WorldY);
|
||||||
const uint32 Idx = (HX * 0x9E3779B9u ^ HY * 0x85EBCA6Bu) >> 24; // [0,255]
|
const uint32 Idx = ((HX * 0x9E3779B9u) ^ (HY * 0x85EBCA6Bu)) >> 20; // [0,4095]
|
||||||
|
|
||||||
FSlot& S = Slots[Idx];
|
FSlot& S = Slots[Idx];
|
||||||
if (S.Id != InstanceId || S.X != WorldX || S.Y != WorldY)
|
if (S.Key != ColumnKey || S.X != WorldX || S.Y != WorldY)
|
||||||
{
|
{
|
||||||
S.Id = InstanceId; S.X = WorldX; S.Y = WorldY;
|
S.Key = ColumnKey; S.X = WorldX; S.Y = WorldY;
|
||||||
FColumn& C = S.C;
|
FColumn& C = S.C;
|
||||||
|
|
||||||
C.TerrainZ = TerrainStack.EvalHeight(WorldX, WorldY);
|
C.TerrainZ = TerrainStack.EvalHeight(WorldX, WorldY);
|
||||||
@@ -601,9 +614,45 @@ namespace
|
|||||||
const FSurfaceGenerationParams& GetParams() const { return P; }
|
const FSurfaceGenerationParams& GetParams() const { return P; }
|
||||||
|
|
||||||
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
|
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
|
||||||
void PrepareChunk(const FVoxelOpContext&) override {}
|
|
||||||
bool IsXYPure() const override { return false; } // voir le bloc ci-dessus
|
bool IsXYPure() const override { return false; } // voir le bloc ci-dessus
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ C'EST ICI QUE SE JOUE LA PERF DE CET ARCHÉTYPE — corrigé 2026-07-27 après mesure.
|
||||||
|
*
|
||||||
|
* Le mémo de colonne était clé sur `InstanceId`, qui change à CHAQUE reconstruction de pile,
|
||||||
|
* c'est-à-dire à chaque chunk. Résultat : une strate haute de 4 chunks recalculait ses
|
||||||
|
* colonnes **4 fois**, resamples du cliff compris. Le chemin d'origine ne fait pas ça —
|
||||||
|
* `GSurfColCache` est clé sur `(boîte XY, StrateKey, Seed, LayoutVersion)` **SANS ChunkZ**,
|
||||||
|
* délibérément, « shared down the whole vertical strate stack ».
|
||||||
|
*
|
||||||
|
* Donc la clé devient la même identité : ce qui rend deux colonnes interchangeables, c'est
|
||||||
|
* la STRATE et la version de layout, pas le chunk. Le mémo étant `thread_local`, il SURVIT
|
||||||
|
* à la reconstruction de la pile — seule la clé l'invalidait.
|
||||||
|
*
|
||||||
|
* POURQUOI C'EST SÛR : les hauteurs sont XY-pures par construction (c'est tout l'objet de
|
||||||
|
* `VoxelHeightOp.h`, où le type n'a pas de Z), et le champ de biomes est documenté
|
||||||
|
* XY-pur — « ZERO Z dependence: the climate/Voronoi fields are pure-XY ». C'est exactement
|
||||||
|
* la justification sur laquelle `GSurfColCache` repose déjà.
|
||||||
|
*
|
||||||
|
* The memo was keyed on InstanceId, which changes every chunk, so a 4-chunk strate recomputed
|
||||||
|
* every column 4x. GSurfColCache deliberately omits ChunkZ and shares down the whole vertical
|
||||||
|
* stack; this now keys on the same identity. Safe because heights are XY-pure by type and the
|
||||||
|
* biome field is documented Z-independent.
|
||||||
|
*/
|
||||||
|
void PrepareChunk(const FVoxelOpContext& Ctx) override
|
||||||
|
{
|
||||||
|
// `StrateBottomWorldZ` est unique par strate empilée — la même valeur que
|
||||||
|
// `CP_StrateKey` utilise dans `GetDensityAt`. La version de layout entre dans la clé
|
||||||
|
// (AUDIT §C2) : une édition à chaud qui change les params sans déplacer la strate doit
|
||||||
|
// invalider, sinon on sert des colonnes périmées.
|
||||||
|
const uint32 A = (uint32)FMath::RoundToInt(Ctx.StrateBottomWorldZ);
|
||||||
|
const uint32 B = Ctx.LayoutVersion;
|
||||||
|
const uint32 C = Ctx.Seed;
|
||||||
|
ColumnKey = ((uint64)VoxelHash::Mix(A ^ VoxelHash::Mix(B)) << 32)
|
||||||
|
| (uint64)VoxelHash::Mix(C ^ VoxelHash::Mix(A));
|
||||||
|
if (ColumnKey == 0) { ColumnKey = 1; } // 0 = « jamais préparé »
|
||||||
|
}
|
||||||
|
|
||||||
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
|
void Eval(float WorldX, float WorldY, float WorldZ, FVoxelOpSample& InOut) const override
|
||||||
{
|
{
|
||||||
// ⚠️ PAS de mémo par colonne ICI, délibérément. Le cache T1.a existe déjà UN NIVEAU
|
// ⚠️ PAS de mémo par colonne ICI, délibérément. Le cache T1.a existe déjà UN NIVEAU
|
||||||
@@ -642,7 +691,8 @@ namespace
|
|||||||
TUniquePtr<IVoxelBiomeField> Field; // POSSÉDÉ (voir le constructeur)
|
TUniquePtr<IVoxelBiomeField> Field; // POSSÉDÉ (voir le constructeur)
|
||||||
TArray<TUniquePtr<IVoxelHeightOp>> PerBiomeStructural; // pente d'overhang par biome
|
TArray<TUniquePtr<IVoxelHeightOp>> PerBiomeStructural; // pente d'overhang par biome
|
||||||
|
|
||||||
uint64 InstanceId = 0;
|
uint64 InstanceId = 0; // unique, jamais recyclée — le repli quand PrepareChunk n'a pas eu lieu
|
||||||
|
uint64 ColumnKey = 0; // l'identité PARTAGÉE (strate + layout + seed) : voir PrepareChunk
|
||||||
};
|
};
|
||||||
|
|
||||||
//=========================================================================
|
//=========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user