Caves delayed writes - #285
Conversation
Zaldaryon
left a comment
There was a problem hiding this comment.
Reviewed by reconstructing all five changed files against their pristine baselines (three fork projects via direct git apply, the two VintagestoryLib files via --directory=baseline against the decompiled vanilla source, matching what scripts/bootstrap.sh does), then swapping the reconstruction into a bootstrapped tree and running dotnet build VintageStory.slnx -c Release: 0 errors, no new warnings.
The core mechanism is sound. The per-column buffers are genuine ThreadLocal<T> instances (StratumWorkerInstances<GenCaves>), StratumCopyStateFrom nulls the lists so a new worker never aliases the source's, every batch is flushed exactly once per GeneratePartial with no double-flush or leaked stale write, the index math is unchanged, and nothing inside a GeneratePartial reads back the blocks layer it's still buffering (fluids and heightmaps are written unbuffered on purpose, which is correct). The /dev gencaves rewrite is also a real fix for a real bug: the old command called initWorldGen() on the shared instance and reassigned airBlockId on it too, so any worker thread that lazily created its GenCaves during the command's window would permanently inherit airBlockId = <granite> and generate inverted caves for the rest of the server's life. The new isolated-instance-plus-copy approach genuinely avoids that.
Both remaining problems are concentrated in reusing that same buffered-write path for /dev gencaves specifically, against chunks that are already live and player-visible rather than still being generated.
ClearBlocksAndPrepare() also wipes the fluids and light layers, and the comment justifying it is wrong. GenCaves.cs's new lazy-init branch calls data.ClearBlocksAndPrepare() on a subchunk with no palette yet, with a comment claiming it "initializes palette/planes without destroying the fluids/light layers." It does destroy them: ClearBlocksAndPrepare() calls ClearBlocks(), which calls pool.FreeArraysAndReset(this), which nulls blocksLayer, lightLayer, and fluidsLayer together. /dev gencaves clears every subchunk's blocks layer up front (ClearChunkColumn), sets airBlockId to granite so buffered air writes are non-zero, and writes lava unbuffered for y below the lava line in geologically active regions. If subchunk 0 gets lava written before its first buffered granite/basalt flush in the same command run, that flush calls ClearBlocksAndPrepare() and the lava is silently gone, along with the light update already scheduled for it. Regular worldgen doesn't hit this in practice, since GenTerra always populates subchunk 0's palette before GenCaves runs, but the command path does.
The fix belongs in ChunkData.SetBlocksUnsafe itself, which already has the same palette-less-layer problem (SetManyUnsafe NREs on the first zero value if the layer was never initialized): create the layer and call PopulateWithAir() on it directly, without touching fluidsLayer/lightLayer at all. That also means HasBlocksLayer, the anyNonZero scan, and the ClearBlocksAndPrepare() call in GenCaves can go away entirely. One correction to how I initially read this: ClearBlocksAndPrepare() isn't only called from here, GenBlockLayersFlat also calls it, but only once at the very start of generating a chunk that has never had anything written to it, which is exactly the "clear everything, start fresh" use the method's contract implies. GenCaves is the first caller to invoke it mid-generation on a subchunk that may already carry writes from the same pass, which is the actual problem.
The buffered write path drops the reader/writer exclusion the old path had, and /dev gencaves is the first caller that reaches it on published chunks. The old chunkBlockData[index3d] = value goes through ChunkDataLayer.Set, which calls SetCore, wrapping the bit-plane update in readWriteLock.AcquireWriteLock()/ReleaseWriteLock(). The new SetBlocksUnsafe(indices, values) goes through SetManyUnsafe, which calls SetUnsafeCore, and only holds stratumWriteLock (serializes writers against each other, nothing else) and never touches readWriteLock. SetManyUnsafe already existed before this PR (added in "Read and write batching for lighting calculations"), and it was already safe, because its only caller generated chunks that weren't published yet. /dev gencaves changes that: GetChunkColumn fetches chunks through api.WorldManager.GetChunk, the same live/loaded chunks any player can be standing in, and MarkDirty broadcasts them to clients afterward. A concurrent reader, or the chunk-save/compression thread's CompressUsing (which does take readWriteLock.AcquireReadLock()), can now observe a bit-plane update mid-write and read a torn palette index, either the wrong block or a corrupted save.
This doesn't need to block the buffering optimization itself, since normal worldgen chunks aren't published until every generation stage finishes. It's specifically /dev gencaves reusing the same "unsafe" path against live chunks that's the problem. Either keep the dev command on the old locked Set() path (it's a debug command, the batching win doesn't matter there), or add real exclusion to SetManyUnsafe. The PR description's "one lock per subchunk instead of one lock per block" undersells this: it's a different lock with a different guarantee, not a coarser version of the same one.
Performance evidence is before/after screenshots only, no seed, world config, chunk count, or raw numbers in text. Per this repo's established bar, that's not enough to accept the performance claim as a completed gate; a reproducible command and the raw before/after numbers would close it.
One more worth a follow-up, not blocking: HasBlocksLayer's two "empty" implementations (NoChunkData, ProPickWorkSpace's DummyChunkData) are getter-only auto-properties that always return false, but for both of them SetBlocksUnsafe/SetBlockUnsafe are always safe to call (no-ops or a plain array respectively), so per the property's own documented meaning they should return true. Not reachable today since neither type is ever driven through GenCaves's flush path, but DummyChunkData.ClearBlocksAndPrepare() zeroes its whole backing array, so any future code that copies this PR's if (!HasBlocksLayer) ClearBlocksAndPrepare(); pattern against a DummyChunkData would silently erase a prospecting pick's rock column.
Also: git diff --check flags 20 trailing-whitespace lines this PR adds across the patch files (18 in GenCaves.cs.patch, one each in ProPickWorkSpace.cs.patch and NoChunkData.cs.patch). Worth a cleanup pass before merge.
Good work isolating the /dev gencaves state, that was a real bug and the fix is correct. The buffering mechanism itself checks out too. It's specifically pointing that command at the same unsynchronized write path used during generation that needs another look.
|
Thanks a lot for the thorough review and for catching both the All blocking issues have been addressed in the latest revision:
Let me know if anything else needs another look. |
This comment was marked as spam.
This comment was marked as spam.
Zaldaryon
left a comment
There was a problem hiding this comment.
Re-review of be01a14e0b730d35a1520fc0dbeb0642ca56435f after Fix 285.
The earlier ClearBlocksAndPrepare() and live-chunk SetManyUnsafe() findings are fixed in this head. Two blocking problems remain.
[P1] The submitted patch set cannot bootstrap
These commands fail before compilation:
git apply --check --whitespace=nowarn patches/VintagestoryApi/Common/API/IWorldChunk.cs.patch
returns error: corrupt patch at line 7.
git apply --check --whitespace=nowarn --directory=baseline patches/VintagestoryLib/Vintagestory.Common/ChunkData.cs.patch
returns error: corrupt patch at line 10.
Both files begin with a BOM, and the first hunk contains a raw { without the unified-diff context prefix and source indentation. scripts/bootstrap.sh applies these files directly, so a clean checkout stops before the build. Please regenerate both patch files and verify bootstrap from a clean checkout.
[P1] GenCaves shares an unsynchronized worldgen accessor across workers
GenCaves.StratumCopyStateFrom() copies worldgenBlockAccessor by reference. ServerSystemLoadAndSaveGame.GetBlockAccessor(false) supplies that accessor as a singleton. BlockAccessorWorldGen.ScheduleBlockLightUpdate() appends to ServerMapChunk.ScheduledBlockLightUpdates, while RunScheduledBlockLightUpdates() processes and clears the same list without synchronization.
This PR moves caves to concurrent TerrainLate workers, so neighboring generation requests can race on a map chunk's List<Vec4i>. Updates can be lost, or concurrent List<T> access can fail. cmdLock only serializes command invocations and does not exclude worldgen workers. Use a per-worker accessor or synchronize scheduling and processing with the worker lifecycle.
Performance evidence
The PR description now lists the command, seed, world setting, and chunk count, but both before and after timing values are blank. Please add the measured values before treating the performance gate as complete.
Verification note: after temporarily repairing only the two malformed patch lines and BOMs in a disposable reconstruction, dotnet build VintageStory.slnx -c Release completed with 0 errors and 168 warnings. The submitted patch set still fails during bootstrap.
Zaldaryon
left a comment
There was a problem hiding this comment.
Re-review of 00e26ce0fef7fcf2c9efa35550646cd00489e287 after Fix 285 v2.
The two previous blockers are fixed. Both patch files now apply against the pristine baselines, and the performance section contains three before and three after measurements. One blocking race remains.
[P1] GenCaves and GenLight still race on the shared light-update list
The new GenCaves.accessorLock protects only GenCaves' buffered light flush and the debug command. GenLight obtains the same singleton from GetBlockAccessor(false), then calls BeginColumn() and RunScheduledBlockLightUpdates() without that lock.
The scheduler keeps separate active counts for each stage and raises TerrainLate independently, so a TerrainLate cave worker can append to a map chunk's ScheduledBlockLightUpdates while the Vegetation light pass reads, processes, and clears the same List<Vec4i>. That can lose lava light updates or race List<T> access. The lock must be shared with GenLight, or the synchronization must move into BlockAccessorWorldGen around schedule and run operations. Please add a concurrent TerrainLate plus Vegetation regression test.
Verification: all five patch files applied against the pristine reconstruction with zero failures. dotnet restore and dotnet build VintageStory.slnx -c Release --no-restore completed with 0 errors and 168 warnings. No checks are reported for the caves-clean branch.
Minor cleanup: the build still reports the unused chunksizeSq local in GenCaves.cs, and git diff --check still reports trailing whitespace in the patch files.
|
Great catch on the shared worldgenBlockAccessor state. You were exactly right — I went with the "synchronize scheduling and processing" approach you suggested, using the same deferred-write pattern we established for blocks:
This keeps the hot path completely lock-free while strictly serializing the shared state mutations at the column boundary. Additionally, I ran new tests and produced a detailed report with numbers about this. |
Zaldaryon
left a comment
There was a problem hiding this comment.
Follow-up after tehtelev's 15:59:50Z comment.
The review was submitted after commit 00e26ce and after the benchmark values were added to the PR description. The comment came 20 seconds later and clarifies the intended GenCaves.accessorLock design. It does not cover the remaining call site below.
GenCaves.cs flushes buffered ScheduleBlockLightUpdate() calls under accessorLock. GenLight.cs gets the same singleton from GetBlockAccessor(false), then calls BeginColumn() and RunScheduledBlockLightUpdates() without that lock. The scheduler has separate stage limits and allows TerrainLate workers to run while the Vegetation stage processes another column. A TerrainLate flush can therefore append to ServerMapChunk.ScheduledBlockLightUpdates while GenLight reads, processes, and clears the same list. That can lose updates or race the List<Vec4i>.
Please move the synchronization into BlockAccessorWorldGen so scheduling and processing share it, or make GenLight use the same shared lock. Add a concurrent TerrainLate plus Vegetation regression test.
I am keeping the change request because this remains a correctness issue in the current head.
Verification: all five patch files apply to the pristine reconstruction. The reconstructed Release build completes with 0 errors and 168 warnings. No CI checks are reported for caves-clean.
|
I added locks to I'm not great at regression tests, so if you could do them, that would be great. |
There was a problem hiding this comment.
Finding
- P1: map-chunk saves can race with pending light updates.
BlockAccessorWorldGennow protects scheduling and detachingScheduledBlockLightUpdateswithlightUpdatesLock, butVintagestoryLib/Vintagestory.Server/ServerMapChunk.cs:329still passes the same mutableList<Vec4i>directly toFastSerializer.Write. That serializer enumerates the list, whileServerSystemLoadAndSaveGameserializes loaded map chunks on its save thread. A worldgenList.Addcan therefore race with save enumeration, causing an exception or inconsistent pending-light data. Please use the same shared lock or take an atomic snapshot under it, or provide an equivalent lifecycle guarantee. Add a concurrent regression test or equivalent evidence.
Verification
- Bootstrap applied all patches successfully at
4c7a9c8557bd846e296e3b2ebcc69584b0bda489. dotnet build VintageStory.slnx -c Releasecompleted with 0 errors and 169 warnings.make smokereachedRunGameandWorldReadywith the patched assemblies embedded.- GitHub reports no build checks for this PR.
Correction
I am withdrawing the earlier whitespace note. It is not a repository review gate and does not represent a blocking finding.
|
[P1 (save race vs pending light updates)] Resolved at the current head; the finding
I don't know how to run a regression test, but I can say that generation runs smoothly. Lava generates correctly. |
Zaldaryon
left a comment
There was a problem hiding this comment.
I reviewed the current head 79b01aa against the PR merge base. The v4 ConcurrentStack and serialization snapshot changes appear to address the earlier save-thread race, but I still need changes before approval.\n\n### [P1] Initialize the pending-light buffer on the first column\n\nStratumBeginColumn returns immediately after creating stratumPendIdx and stratumPendVal, while stratumPendLight is initialized only below that return. StratumCopyStateFrom resets the light list to null, and the first GeneratePartial can reach the lava path that calls stratumPendLight.Add(...). That causes a NullReferenceException and aborts cave generation for any first column that emits a lava light update. Move the light-list initialization before the early return and add a first-column lava regression test.\n\n### [P1] Complete the performance evidence\n\nThe PR description still leaves every After timing blank, so the claimed performance change cannot be evaluated. Please add the three raw After measurements, average, standard deviation, and reproducible output for the current head before checking the performance gate. GitHub also reports no checks for caves-clean.\n\nThe command issue is left as an inline finding below.
|
The two issues mentioned have been fixed. |
There was a problem hiding this comment.
The two v4 blockers are fixed. StratumBeginColumn now initializes stratumPendLight before both of its exits, so a worker's first column can reach the lava branch without a null reference, and CmdCaveGenTest now checks args.Caller.Player for null before touching Entity.Pos. I rebuilt the full patch set against pristine baseline (0 errors) and ran a smoke test with these files embedded; the server reaches WorldReady with no crash.
[P1] GeneratePartial and CmdCaveGenTest no longer call BeginColumn
Vanilla's GeneratePartial opens with worldgenBlockAccessor.BeginColumn(), and indev added a second call in CmdCaveGenTest right before its lighting recalculation, to reset the [ThreadStatic] chunk lookup cache in BlockAccessorWorldGen before that worker touches GetMapChunk again. This revision removes both calls. GeneratePartial now opens with StratumBeginColumn(chunks.Length) instead, which resets this file's own pending write buffers and has nothing to do with the accessor's cache.
The new ScheduleBlockLightUpdates and RunScheduledBlockLightUpdates that this PR's flush path calls both resolve their target through GetMapChunk, which returns whatever ServerMapChunk instance is cached under cachedChunkIndex2d for that column index, without checking that the cache is still current. If a worker thread's cache holds a chunk index whose ServerMapChunk object later gets replaced (a region reload, /wgen regen, or a second /dev gencaves run after an unload) and that same thread later processes that column again, the buffered light batch for that column is pushed onto the stale object instead of the live one. RunScheduledBlockLightUpdates never drains it, so every light update scheduled for that column, lava included, is silently dropped.
Please restore both BeginColumn() calls, or explain why GenCaves no longer needs the reset every other generator extending GenPartial still relies on, with a test that exercises a chunk reload or a repeated /dev gencaves run over the same coordinates.
One smaller item, not blocking: stratumIndicesBuf is written in StratumCopyStateFrom but never read anywhere in the file.
|
Thanks for the review. Here's what I've done against the pristine baseline:
|
Zaldaryon
left a comment
There was a problem hiding this comment.
Verified the BeginColumn fix directly against the [ThreadStatic] cache it targets. BlockAccessorWorldGen.GetMapChunk returns mapchunkCached whenever cachedChunkIndex2d matches the requested index and only re-resolves on a miss; only BeginColumn() (cachedChunkIndex2d = -1) forces that miss. ChunkServerThread.GetMapChunk really can hand back a different ServerMapChunk object for the same index over a thread's lifetime, so the staleness this closes is real, not theoretical. Both restored calls land correctly: GeneratePartial's call at the top of the method covers its own ScheduleBlockLightUpdates flush later in the same call, and CmdCaveGenTest's call sits directly before RunScheduledBlockLightUpdates, with nothing in between that could reseed the cache. I traced every other caller of ScheduleBlockLightUpdate(s) and RunScheduledBlockLightUpdates in the tree (GenStructures, GenDeposits, GenVegetationAndPatches, GenRivulets, GenPonds, GenSnowLayer, GenCreatures, GenTerraPostProcess, both GenLight implementations) and each already calls BeginColumn() at column start, same as vanilla. Nothing is left uncovered. stratumIndicesBuf's removal is clean too: zero remaining references anywhere, and StratumCopyStateFrom still nulls all three stratumPend* buffers the earlier NRE fix depends on. The v5 finding is fixed.
One new blocking issue surfaced while re-tracing this flush, in code that hasn't changed since an earlier round but that no prior pass caught. BlockAccessorWorldGen.RunScheduledBlockLightUpdates drains ServerMapChunk.stratumLightUpdateBatches (a ConcurrentStack<List<Vec4i>>) by popping every queued batch and merging them into the first one it pops, in place:
List<Vec4i> merged = null;
while (serverMapChunk.stratumLightUpdateBatches.TryPop(out List<Vec4i> batch))
{
if (merged == null) merged = batch;
else merged.AddRange(batch);
}ServerMapChunk.FastSerialize (the save path) reads the same stack concurrently without popping, on the strength of a comment a few lines above it: "Batches on the stack are immutable once pushed (producer nulls its reference after Push; Run only reads what it pops) ... the serializer therefore never enumerates a list another thread mutates." Run does not only read what it pops. Once there is more than one batch to merge, it appends to the first one. ConcurrentStack<T>'s enumerator is a moment-in-time snapshot of the node chain, not of the values inside it, so if FastSerialize's foreach (List<Vec4i> batch in stratumLightUpdateBatches) captures a reference to a batch before RunScheduledBlockLightUpdates pops that same batch and starts appending to it, both threads hold the same List<Vec4i> instance at the same time: one growing it, one reading it through AddRange. That is an unsynchronized concurrent mutation of a plain List<T>, and it can throw on the save thread or leave persistedLightUpdates with a torn read.
The reachable path is /dev gencaves. Its flush at GenCaves.cs:214 calls RunScheduledBlockLightUpdates against an already-loaded, player-visible map chunk, since GetMapChunk resolves server.loadedMapChunks first for a chunk in that state. The periodic save system serializes those same loaded chunks through SaveStratumDirtyMapChunksBudget, which takes no generatingLock, unlike the still-generating-column save path a few lines above it that does. Normal worldgen doesn't reach this: PopulateChunk holds generatingLock for the write while its matching save path takes the read lock, so the two never overlap there. The command bypasses that lock entirely, by design, since it operates on chunks that already finished generating.
The fix is one line: merged = new List<Vec4i>(batch); on the first pop instead of aliasing the popped list, so the list RunScheduledBlockLightUpdates grows is never one FastSerialize can also be enumerating. That also makes the invariant the ServerMapChunk comment states actually true.
Also stale: the comment at GenCaves.cs:210-212 says ScheduleBlockLightUpdate and RunScheduledBlockLightUpdates are "thread-safe (protected by lightUpdatesLock inside BlockAccessorWorldGen)." That field doesn't exist anywhere in the tree. The real mechanism is the lock-free ConcurrentStack, and the drain's own comment says explicitly that nothing on this path is locked. Not blocking by itself, but it's the stated justification for /dev gencaves calling into the shared chunkIlluminatorWorldGen instance without any external locking, so it is worth correcting alongside the batch fix rather than leaving a guarantee documented that nothing actually provides.
Build and smoke test both pass on this head (cf46a4d2d757563f6687e46a99dba78b33966a37): all eight patch files apply cleanly against pristine baseline, dotnet build VintageStory.slnx -c Release completes with 0 errors, and scripts/smoke-test.sh reaches WorldReady with these files embedded.
Zaldaryon
left a comment
There was a problem hiding this comment.
No blocking issues remain. The batch-mutation fix closes the race cleanly and nothing else in the eight-file diff moved. Build and smoke test both pass on this head.
Summary
A quick summary of what we've changed since the very first (original) code:
Sparse deferred writes
Was: Each carved block was written to the chunk immediately via the indexer (
chunkBlockData[index3d] = value), acquiring a lock and doing a palette lookup per block.Now: Writes are buffered into per‑subchunk lists of indices and values (
stratumPendIdx,stratumPendVal) and applied once per touched subchunk at the end ofGeneratePartial. Untouched subchunks cost nothing. Normal worldgen flushes through the batchedSetBlocksUnsafe/SetManyUnsafepath, which only serializes writers against each other — safe because those chunks are not published until generation finishes. The/dev gencavescommand flushes through the regular locked indexer path instead (see point 3).Safe lazy initialisation of the blocks layer
Was: The batched write path required an already‑initialised palette and NRE’d on a palette‑less layer; the earlier fix attempt called
ClearBlocksAndPrepare(), which frees the blocks, fluids and light layers together and silently dropped unbuffered lava writes.Now:
SetBlocksUnsafeis self‑initialising: if the blocks layer or its palette is missing, it creates the layer and fills it viaPopulateWithAir(), touching only the blocks layer –fluidsLayerandlightLayerare left completely untouched.GenCavesno longer callsClearBlocksAndPrepare()mid‑generation, so lava written earlier in the same pass survives the first blocks flush.Thread‑safe and isolated
/dev gencavescommandWas: The command called
initWorldGen()on the shared instance and reassignedairBlockIdon it, so any worker that lazily created itsGenCavesduring the command’s window would permanently generate inverted caves. It also wrote to live, player‑visible chunks through the lock‑free batched path, where a concurrent reader or the save/compression thread could observe a torn bit‑plane update.Now: The command builds a dedicated thread‑confined
GenCavescopy underlock (cmdLock)and never touches shared state. Because it operates on published chunks, its buffered writes are flushed through thereadWriteLock‑protectedSet()path (stratumLockedWrites = true), giving readers and writers the same exclusion the old code had. Normal worldgen keeps the fast batched path.No per‑block allocations (GC‑friendly)
Was: Per‑block lock acquisitions and temporary objects on the hot path.
Now: The buffering lists are allocated once per worker and reused via
Clear()between columns.Buffered block‑light updates
Was: Every light‑emitting block placed inside a cave called
ScheduleBlockLightUpdatedirectly, which added entries to a shared list owned by the map chunk. This list was accessed concurrently byGenCaves(fromTerrainLate) andGenLight(fromVegetation), leading to lost updates and corrupted lists.Now: All light‑update requests are also buffered per
GenCavesworker (stratumPendLightPos,stratumPendLightOld,stratumPendLightNew). At the end ofGeneratePartial, these are flushed to the map chunk using a global lock (lightUpdatesLock) insideBlockAccessorWorldGen. This guarantees that the shared list is never accessed concurrently by different worldgen stages, and all light updates are correctly applied.Synchronised light‑update processing
Was:
RunScheduledBlockLightUpdatestook the list from the map chunk, processed it, and cleared it – all without any locking, so two concurrent runs could process the same list twice or miss entries.Now: The method now uses
lightUpdatesLockto atomically take the list and set it tonull, then delegates actual processing to the newly introducedProcessScheduledBlockLightUpdatesin the illuminator. This prevents double‑processing and ensures thread‑safe handover between worldgen phases.Type
Checklist
.\scripts\extract-patches.ps1ran clean.dotnet build VintageStory.slnx -c Releaseis green.// Stratummarker.Performance numbers
Tested on the generation of 31417 chunk columns (6 threads) by
/stratum pregen start radius 100 16000 16000Seed - seed 1027995113.
World setting - default.
Three runs were performed before and after the changes using the Jetbrains dotTrace program.
Before
After
After optimization, the code runs on average ≈ 7.3% faster (about 4377 ms per run), while the spread of results (standard deviation) has increased from ~1554 ms to ~2462 ms, indicating less predictable and less stable performance. At the same time, we closed an important hole with light updates.