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:
@@ -461,6 +461,15 @@ namespace
|
||||
// A unique, never-recycled id — assigned here only; the other ctor delegates.
|
||||
static std::atomic<uint64> NextId{ 1 };
|
||||
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
|
||||
@@ -498,23 +507,27 @@ namespace
|
||||
// structurels). Ce n'est pas « un peu plus lent », c'est un ordre de grandeur sur
|
||||
// l'archétype le plus cher du plugin.
|
||||
//
|
||||
// Table à correspondance directe, 256 entrées, clé COMPLÈTE comparée sur touche : une
|
||||
// collision ne peut que coûter un recalcul, jamais rendre une mauvaise colonne.
|
||||
// Table à correspondance directe, clé COMPLÈTE comparée sur touche : une collision ne
|
||||
// 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
|
||||
// before changing XY, which the mesher does not promise. Direct-mapped 256-entry table
|
||||
// with the FULL key compared on hit — a collision costs a recompute, never a wrong column.
|
||||
struct FSlot { uint64 Id; float X, Y; FColumn C; };
|
||||
thread_local FSlot Slots[256] = {};
|
||||
// TAILLE : un chunk fait CHUNK_SIZE² colonnes (1024 à 32³). Les 256 entrées du premier
|
||||
// jet ne tenaient donc même pas UN chunk — la table se piétinait elle-même à
|
||||
// l'intérieur d'une seule tuile. 4096 entrées couvrent quatre chunks de front, pour
|
||||
// ~150 Ko par worker : du même ordre qu'une boîte de `GSurfColCache` (~59 Ko × 6).
|
||||
//
|
||||
// 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 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];
|
||||
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;
|
||||
|
||||
C.TerrainZ = TerrainStack.EvalHeight(WorldX, WorldY);
|
||||
@@ -601,9 +614,45 @@ namespace
|
||||
const FSurfaceGenerationParams& GetParams() const { return P; }
|
||||
|
||||
EVoxelOpRole GetRole() const override { return EVoxelOpRole::FieldSource; }
|
||||
void PrepareChunk(const FVoxelOpContext&) override {}
|
||||
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
|
||||
{
|
||||
// ⚠️ 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)
|
||||
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