From 77700745033d6560297e43a177b0c2786d283e98 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Mon, 19 Oct 2026 19:16:14 +0200 Subject: [PATCH] wip(assembler): round L4 -- lazy per-thread caches: 17.3 MiB -> 43 KiB per thread, view 7% faster VoxelForgeCoreThreadLocal::FLazy (new public header ThreadLocalStorage.h) makes every large thread_local cache a pointer that heap-allocates on that thread's first use (still worker-private, lock-free). Static TLS 17.33 MiB -> 43 KiB (initialised image 5.8 MiB -> 21 KiB); worker start 5.2 -> 0.12 ms; idle worker RSS 17.7 MiB -> 52 KiB. Linux workers back to 8 MiB stacks. World unchanged (L1 hashes Clang off/GCC default, W1/W12; FloatingIslands seed 0). Reviewer interleaved W12: 2.256 -> 2.091 s. MSVC/UBT not compiled here. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ED33VnqepD4YY6gDAAA832 --- ARCHITECTURE.md | 1 + CODEMAP.md | 1 + .../VoxelForgeCore/ThreadLocalStorage.h | 33 +++++ Standalone/Core/src/CoreNamespacePreamble.h | 1 + Standalone/Core/src/FloatingIslandsInternal.h | 15 ++- Standalone/Core/src/WorldAssemblerInternal.h | 25 ++-- .../WorldAssemblerM3/Linux4/REPORT.md | 114 +++++++++++++++++ .../WorldAssemblerM3/Linux4/TlsProbeMain.cpp | 11 ++ Standalone/Source/StandaloneTlsProbe.h | 118 +++++++++++++++++- Standalone/Source/StandaloneWorkerThread.h | 6 +- Standalone/Source/StreamBenchmarkHeadless.cpp | 14 ++- .../Private/VoxelCaveMorphology.cpp | 8 +- .../Private/VoxelDensityOpStack.cpp | 24 ++-- .../VoxelForge/Private/VoxelGenerator.cpp | 33 +++-- .../VoxelForge/Private/VoxelStrateManager.cpp | 5 +- 15 files changed, 361 insertions(+), 48 deletions(-) create mode 100644 Standalone/Core/include/VoxelForgeCore/ThreadLocalStorage.h create mode 100644 Standalone/Research/WorldAssemblerM3/Linux4/REPORT.md create mode 100644 Standalone/Research/WorldAssemblerM3/Linux4/TlsProbeMain.cpp diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0e9eda6..9990369 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -648,6 +648,7 @@ driven by `EditorBrush*` props. two global settings through `FVoxelOpContext` once per chunk, while `ClassifyBox`/`ClassifyTile` share the radial proof. This adds no cache, state, RNG, or clipmap stream, and does not touch the density grid's two-pass MC loop or any existing `thread_local` cache. +- **Large `thread_local` caches are lazy** (`VoxelForgeCoreThreadLocal::FLazy`, `Standalone/Core/include/VoxelForgeCore/ThreadLocalStorage.h`, round Linux4): the per-thread handle is a pointer; the table is heap-allocated on that thread's first use and freed at thread exit. Still worker-private and lock-free. It keeps the static TLS block at ~43 KiB instead of 17.3 MiB (glibc carves static TLS out of each thread's stack, and a Linux server pays it on every thread). New cache tables over a few KiB go through `FLazy`, never a bare `thread_local` array. - **Biome cache** (`ResolveBiomeSampleAt`/`FChunkBiomeCache`, §8.14): validity is a world-XY BOX + ChunkZ + Seed, NOT a chunk key — same reason as the SDF cache. The cell classification is noise-heavy; a chunk-key would thrash it on gradient-normal / +X/+Y boundary samples. Keep diff --git a/CODEMAP.md b/CODEMAP.md index ec282cd..d924f54 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -158,6 +158,7 @@ remains `0`, and lateral regions remain gated off. | `VoxelForge.Build.cs` | Deps: Core, CoreUObject, Engine, **GameplayTags**, **RealtimeMeshComponent**, `VoxelForgeCore`. `AVoxelWorld::bUseVoxelForgeCore` opts into the Core runtime route; it defaults off. | | `../../VoxelForgeCore/VoxelForgeCore.Build.cs` | C++20 runtime module. Reads `Standalone/Core/core_sources.txt`, validates one include shim per canonical source, and fails UBT on missing, extra, or mismatched shims. No unity/PCH; private copied implementation stays under `vfcore::detail`. | | `../../VoxelForgeCore/Private/Manifest/VFCore_*.cpp` | Eighteen one-source UBT shims that include canonical files under `Standalone/`; they do not copy Core implementation. The API-boundary row includes `Core/src/VoxelForgeCore.cpp` directly. | +| `../../Standalone/Core/include/VoxelForgeCore/ThreadLocalStorage.h` | `VoxelForgeCoreThreadLocal::FLazy`: thread-local handle that heap-allocates a worker-private cache on first use. All large Core and legacy caches use it (HeightfieldColumnCache, IslandColumnCache, GFloorReliefCache, GRoomGraphCache, GSurfColCache, tunnel/diff caches). See ARCHITECTURE §8.10. | | `../../Standalone/Core/src/CoreNamespacePreamble.h` | First include of every Core TU. Forces FP contraction off per compiler (Clang/MSVC/GCC pragmas; unknown compiler = `#error`) and defines `vfcore::fp_contract::selfTest`, which `World::compile` runs before building: a fused multiply-add (e.g. Clang `-ffp-contract=fast`) refuses world generation. The world depends on contraction (Linux2/Linux3 reports), so never remove either. | | `../../VoxelForgeCore/Private/ModuleEntry/` | Unreal module entry and an isolated `CoreMinimal.h` shim for the actual engine header. | | `../../Standalone/Core/include/VoxelForgeCore/VoxelForgeCore.h` | Standard-library-only public Core API. `VOXELFORGECORE_API` exports the API from the Unreal module and is empty for standalone builds. | diff --git a/Standalone/Core/include/VoxelForgeCore/ThreadLocalStorage.h b/Standalone/Core/include/VoxelForgeCore/ThreadLocalStorage.h new file mode 100644 index 0000000..22bc4bc --- /dev/null +++ b/Standalone/Core/include/VoxelForgeCore/ThreadLocalStorage.h @@ -0,0 +1,33 @@ +#pragma once + +// Keep large worker caches out of each thread's eager ELF TLS image. Each handle is itself +// thread-local; its payload is allocated on the first use by that thread and released when the +// thread exits. The payload is never shared and this type has no synchronization. +namespace VoxelForgeCoreThreadLocal +{ + template + class FLazy + { + public: + FLazy() noexcept = default; + FLazy(const FLazy&) = delete; + FLazy& operator=(const FLazy&) = delete; + + ~FLazy() + { + delete Value; + } + + T& Get() + { + if (Value == nullptr) + { + Value = new T{}; + } + return *Value; + } + + private: + T* Value = nullptr; + }; +} diff --git a/Standalone/Core/src/CoreNamespacePreamble.h b/Standalone/Core/src/CoreNamespacePreamble.h index 4e517cd..7dfdaff 100644 --- a/Standalone/Core/src/CoreNamespacePreamble.h +++ b/Standalone/Core/src/CoreNamespacePreamble.h @@ -54,6 +54,7 @@ // value tree shared lets the viewer resolve its reference world and Core snapshot from one parse. #include "Json.h" #include "VoxelForgeCore/VoxelForgeCore.h" +#include "VoxelForgeCore/ThreadLocalStorage.h" // Keep Core's expression-level FMA behavior fixed regardless of the toolchain setting. // Clang's "fast" mode ignores the pragma, so Core's runtime self-test rejects that build. diff --git a/Standalone/Core/src/FloatingIslandsInternal.h b/Standalone/Core/src/FloatingIslandsInternal.h index 481e32b..3e54f35 100644 --- a/Standalone/Core/src/FloatingIslandsInternal.h +++ b/Standalone/Core/src/FloatingIslandsInternal.h @@ -1,5 +1,6 @@ #pragma once +#include "VoxelForgeCore/ThreadLocalStorage.h" #include "Json.h" #include "VoxelForgeCore/VoxelForgeCore.h" @@ -441,7 +442,9 @@ namespace floating_island_detail constexpr std::size_t Ways=4; constexpr std::size_t SetCount=2048; static_assert((SetCount&(SetCount-1))==0); - static thread_local std::array Cache{}; + static thread_local VoxelForgeCoreThreadLocal::FLazy< + std::array> Cache; + auto& cache=Cache.Get(); const std::int64_t SampleX=static_cast(x); const std::int64_t SampleY=static_cast(y); std::uint64_t IndexKey=island.stableId @@ -452,10 +455,10 @@ namespace floating_island_detail IndexKey*=0xff51afd7ed558ccdull; IndexKey^=IndexKey>>33; const std::size_t Base=static_cast(IndexKey&(SetCount-1))*Ways; - FIslandColumnCacheEntry* Replacement=&Cache[Base+((IndexKey>>11)&(Ways-1))]; + FIslandColumnCacheEntry* Replacement=&cache[Base+((IndexKey>>11)&(Ways-1))]; for(std::size_t Way=0;Way Cache{}; + static thread_local VoxelForgeCoreThreadLocal::FLazy< + std::array> Cache; + auto& cache=Cache.Get(); const std::uint64_t IndexKey=island.stableId^(island.stableId>>33) ^(static_cast(recipe.seed)<<17) ^(static_cast(static_cast(rootIndex))*0x9e3779b97f4a7c15ull); - FRootLobeCacheEntry& Entry=Cache[static_cast(IndexKey)&(CacheSize-1)]; + FRootLobeCacheEntry& Entry=cache[static_cast(IndexKey)&(CacheSize-1)]; const bool bKeyMatches=Entry.bValid&&Entry.Seed==recipe.seed &&Entry.StableId==island.stableId&&Entry.RootIndex==rootIndex &&Entry.GeometryVersion==recipe.geometryVersion diff --git a/Standalone/Core/src/WorldAssemblerInternal.h b/Standalone/Core/src/WorldAssemblerInternal.h index 2152086..09118f1 100644 --- a/Standalone/Core/src/WorldAssemblerInternal.h +++ b/Standalone/Core/src/WorldAssemblerInternal.h @@ -1,5 +1,6 @@ #pragma once +#include "VoxelForgeCore/ThreadLocalStorage.h" #include "Json.h" #include "VoxelForgeCore/VoxelForgeCore.h" @@ -1367,9 +1368,10 @@ namespace world_assembly_detail // qui partagent les bits faibles du hash. constexpr std::size_t HeightfieldColumnCacheWays=4; constexpr std::size_t HeightfieldColumnCacheSetCount=65536/HeightfieldColumnCacheWays; - inline thread_local std::array HeightfieldColumnCache{}; - inline thread_local std::array - HeightfieldColumnCacheNextWay{}; + inline thread_local VoxelForgeCoreThreadLocal::FLazy< + std::array> HeightfieldColumnCache; + inline thread_local VoxelForgeCoreThreadLocal::FLazy< + std::array> HeightfieldColumnCacheNextWay; inline thread_local HeightfieldColumnCacheStats HeightfieldColumnStats{}; inline thread_local bool HeightfieldColumnCacheLegacyDirectMapped=false; @@ -1504,6 +1506,8 @@ namespace world_assembly_detail inline HeightfieldPointSample CachedColumn(const WorldAssemblyRecipeV1& recipe, const WorldAssemblyStratumPlanV1& plan,std::int64_t x,std::int64_t y) { + auto& cache=HeightfieldColumnCache.Get(); + auto& nextWay=HeightfieldColumnCacheNextWay.Get(); HeightfieldColumnCacheEntry* replacement=nullptr; if(HeightfieldColumnCacheLegacyDirectMapped) { @@ -1513,8 +1517,7 @@ namespace world_assembly_detail ^std::rotl(static_cast(y)*0xC2B2AE3D27D4EB4Full,23) ^static_cast(reinterpret_cast(&recipe)) ^std::rotl(static_cast(reinterpret_cast(&plan)),41); - replacement=&HeightfieldColumnCache[static_cast(mixed) - &(HeightfieldColumnCache.size()-1)]; + replacement=&cache[static_cast(mixed)&(cache.size()-1)]; if(replacement->recipe==&recipe&&replacement->plan==&plan &&replacement->recipeHash==recipe.contentHash &&replacement->planHash==plan.contentHash&&replacement->x==x&&replacement->y==y) @@ -1528,7 +1531,7 @@ namespace world_assembly_detail std::size_t replacementWay=HeightfieldColumnCacheWays; for(std::size_t way=0;way( + nextWay[set]=static_cast( (replacementWay+1)%HeightfieldColumnCacheWays); - replacement=&HeightfieldColumnCache[first+replacementWay]; + replacement=&cache[first+replacementWay]; } auto& entry=*replacement; const auto start=std::chrono::steady_clock::now(); @@ -2036,9 +2039,9 @@ void Heightfield2D::resetThreadColumnCacheStats(bool captureCoordinates, bool cl // Les sondes de mesure peuvent vider les entrees d'une tuile avant de comparer deux grilles. if(clearCache) { - world_assembly_detail::HeightfieldColumnCache.fill( + world_assembly_detail::HeightfieldColumnCache.Get().fill( world_assembly_detail::HeightfieldColumnCacheEntry{}); - world_assembly_detail::HeightfieldColumnCacheNextWay.fill(0); + world_assembly_detail::HeightfieldColumnCacheNextWay.Get().fill(0); } auto& Stats=world_assembly_detail::HeightfieldColumnStats; world_assembly_detail::HeightfieldColumnOctaveDrop=octaveDrop; diff --git a/Standalone/Research/WorldAssemblerM3/Linux4/REPORT.md b/Standalone/Research/WorldAssemblerM3/Linux4/REPORT.md new file mode 100644 index 0000000..fdbb1b7 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux4/REPORT.md @@ -0,0 +1,114 @@ +# WorldAssemblerM3 Linux4 — lazy worker caches + +## Change + +Added `VoxelForgeCoreThreadLocal::FLazy`: each cache now has a thread-local pointer, allocates its private table on first use, and deletes it when that thread exits. The same helper is used by the namespaced Core and legacy source copies. No cache locks or cross-thread cache sharing were added. The L3 FP-contract preamble and self-test are unchanged. + +The change covers the heightfield and island caches in Core, plus the large surface, relief, room-graph, tunnel-density, and diff caches in the shared generator sources. Tunnel-density lookup is now restricted to its `TunnelNetwork` and `Underwater` routes, which are the only routes that populate that table. The standalone TLS probe reports spawn time, RSS and per-thread minor faults, and can touch synthetic SurfaceWorld or FloatingIslands samples. Worker stacks are now 8 MiB. + +## Where the baseline TLS came from + +Sizes are from the S14 / `046f2af` Clang binary's TLS symbols. The two raw and namespaced source copies are both linked into the standalone executable; both occupy TLS even though a Core call uses the namespaced copy and the legacy/reference route uses the raw copy. The raw legacy cache is not used by the Core API route. `Standalone/Core/core_sources.txt` describes this dual build. + +| Cache | Section | Bytes per copy | Copies | Total bytes | Route / use | +|---|---:|---:|---:|---:|---| +| `HeightfieldColumnCache` | `.tbss` | 6,815,744 | 1 | 6,815,744 | Core SurfaceWorld height samples; S14 hot path, any LOD that queries the heightfield | +| `GFloorReliefCache` | `.tdata` | 1,310,720 | 2 | 2,621,440 | Cave room/tunnel/landing floor-relief queries | +| `GRoomGraphCache` | `.tdata` | 970,752 | 2 | 1,941,504 | Room-graph SDF windows; fine/coarse region banks | +| `GSurfColCache` | `.tbss` | 827,000 | 2 | 1,654,000 | `ClassifyTile` surface-column pass; classifier is opt-in in runtime streaming | +| `FSurfaceColumnSource` column table | `.tbss` | 826,856 | 2 | 1,653,712 | Surface operator-stack integer XY columns | +| `PassageReliefCache` | `.tdata` | 655,360 | 2 | 1,310,720 | Passage-relief samples when that feature is queried | +| `IslandColumnCache` | `.tbss` | 1,114,112 | 1 | 1,114,112 | Core FloatingIslands column density, all LOD sample paths | +| `CP_TunnelDensityCache` | `.tbss` | 401,408 | 2 | 802,816 | Native TunnelNetwork/Underwater density chunks | +| `RootLobeCache` | `.tdata` | 155,648 | 1 | 155,648 | Core FloatingIslands root density | +| `HeightfieldColumnCacheNextWay` | `.tbss` | 16,384 | 1 | 16,384 | Heightfield cache replacement metadata | +| `DiffSlots` | `.tdata` | 10,752 | 2 | 21,504 | Only when a sampled chunk has player modifications | +| `RoomGraphCacheSlots` | `.tdata` | 10,240 | 2 | 20,480 | Room-graph generator route | + +The listed `.tdata` cache symbols total 6,071,296 bytes of the 6,093,024-byte image; the remaining 21,728 bytes are small TLS state. Their nonzero defaults explain the initialized image: for example, room-graph keys start at `INT32_MIN` / `0xFFFFFFFF`, passage and floor-relief keys use `INDEX_NONE`, diff slots use `INT32_MAX` chunk coordinates, and root-lobe radii default to `1.0`. Zero-default large arrays such as the heightfield and island column tables account for the listed `.tbss` bytes. The listed `.tbss` symbols total 12,056,768 bytes; 21,997 bytes remain for small TLS state and alignment. + +The caches have no universal per-LOD allocation: a thread allocates a table only if it reaches that route. Heightfield and island tables are keyed by recipe/plan or island identity and sampled coordinates; surface columns can be reused by repeated XY queries. Room-graph and classifier paths carry region/tile data in their keys, so coarse and fine requests can occupy distinct entries. Relief, tunnel and diff caches are likewise conditional on those features or modified chunks. All preserve the existing thread-local ownership and eviction behavior. + +## TLS and per-thread cost + +`PT_TLS` was read with `readelf -lW`. The baseline is the committed `046f2af` build; the after builds use this tree. + +| Build | `PT_TLS` FileSiz (initialized) | `PT_TLS` MemSiz (per-thread block) | +|---|---:|---:| +| Clang 19 before | 6,093,024 B (5.81 MiB) | 18,171,789 B (17.33 MiB) | +| Clang 19 after | 21,728 B (21.2 KiB) | 43,977 B (42.95 KiB) | +| GCC 14 before | 6,060,396 B (5.78 MiB) | 18,172,504 B (17.33 MiB) | +| GCC 14 after | 9,580 B (9.36 KiB) | 44,696 B (43.65 KiB) | + +Serial 12-worker no-touch, SurfaceWorld, and FloatingIslands probes compared baseline and L4. Spawn timing and resident delta use 32 MiB stacks on both sides to isolate TLS cost; the separately tested L4 tool uses 8 MiB stacks. Spawn medians/ranges use the per-worker samples; first-touch fault medians/ranges use each worker's `getrusage(RUSAGE_THREAD)` delta. + +| Measurement | Before | After | +|---|---:|---:| +| Median time to start one worker | 5.175 ms | 0.119 ms | +| Median RSS added per new worker, no cache query | 17,756 KiB | 52 KiB | +| 12-worker no-touch probe peak RSS | 268,168 KiB | 32,328 KiB | +| First SurfaceWorld use: RSS increase per worker | 20 KiB | 6,691 KiB | +| First SurfaceWorld use: minor faults per worker | median 1 (range 1–4) | median 331 (range 8–649) | +| First FloatingIslands use: RSS increase per worker | 27–30 KiB | 1,283 KiB | +| First FloatingIslands use: minor faults per worker | median 0 (range 0–7) | median 314 (range 313–319) | + +The baseline's first-use deltas are small because its large static TLS cost was already incurred at worker creation. After L4, a SurfaceWorld first use adds about 6.54 MiB per worker, while a FloatingIslands first use adds about 1.25 MiB. The no-touch worker increase is about 99.7% lower. These first-use probes call Core directly with four synthetic samples per worker; the full-view benchmark below measures the hot generation path. + +The baseline binary's first `pthread_create` with an 8 MiB worker stack fails with `pthread_create: Invalid argument`; the 17.33 MiB static TLS block cannot fit that stack configuration. Baseline probes succeeded with 32 MiB stacks. L4 passed the 8 MiB worker-stack probe and the L1 checks, so the 32 MiB wrapper is no longer needed. Keep the normal 8 MiB worker stack; these runs provide no reason to retain 32 MiB as a safety margin. + +## Generation speed and output + +Three interleaved W12 whole-view runs per binary used the 2,553-tile L1 view. `Before` is the exact `046f2af` Clang-off executable. + +| Build | Whole-view times | Median (range) | +|---|---|---:| +| `046f2af` baseline | 2.347, 2.399, 2.432 s | 2.399 s (2.347–2.432) | +| L4 Clang 19 off | 2.139, 2.197, 2.207 s | 2.197 s (2.139–2.207) | + +L4 is 0.202 s (8.4%) faster at the median; no generation-speed regression was measured. The speed runs also changed the worker stack from 32 to 8 MiB, so the timing difference is not attributed solely to the cache indirection. Peak process RSS was 550,856 KiB before and 410,052 KiB after. + +The L1 hashes match at W1 and W12 for both requested compiler settings: + +| Build | Density / mesh / payload / topology | +|---|---| +| Clang 19 off, W1 and W12 | `c10697a8f48380c7 / e75c9119a1498cd5 / 49a9a8d1835a6c8c / edcb23288b5e8724` | +| GCC 14 default, W1 and W12 | same | + +FloatingIslands seed 0 also matches at W1 and W12: `7fd1e3389642e6f6 / 577e52a5ba8d96be / 7c6ffe9f5bbbb450 / b4d308de116f4657` (density / mesh / payload / topology). It covers 27 tiles, with 9 nonempty mesh tiles and 20,740 indexed triangles; the existing L3 seed-0 hashes match. + +## Unreal Linux TLS: documented and inferred + +**Documented by glibc:** Its dynamic-linker guidance describes the `dlopen`/TLS compatibility issue and recommends global-dynamic TLS for general shared-object use. In glibc's current `dl-tls.c`, DTV slots for dynamically loaded TLS modules are marked for deferred allocation; `tls_get_addr_tail` performs the allocation when that thread first resolves the TLS address. The new-thread path calls `_dl_allocate_tls_init`, which also records deferred allocation for dynamically loaded modules. Thus, for the dynamic path, the large module block is allocated on first access, per thread that touches the module; a thread that never accesses it does not get that module's payload block. [glibc dynamic-linker guidance](https://www.sourceware.org/glibc/manual/latest/html_node/Dynamic-Linker-Hardening.html), [glibc TLS implementation](https://codebrowser.dev/glibc/glibc/elf/dl-tls.c.html#L599), [glibc thread creation path](https://codebrowser.dev/glibc/glibc/nptl/allocatestack.c.html#L141). + +**Inference for Unreal:** This machine did not build or inspect the owner's UE `.so`, so the Core module's TLS model and load timing are unverified. GCC documents global-dynamic as the default model with `-fPIC`, but compiler/linker relaxation and UBT's exact flags can change the emitted access sequence. If Unreal loads Core after engine threads exist and the module uses general-dynamic TLS, any engine thread that first accesses a Core TLS symbol can incur the module's whole static TLS block once; it is not limited to generation workers. The L4 pointer handles reduce that block to about 43 KiB, with each large cache allocated only when its own route runs. Verify the actual `.so` `PT_TLS`, TLS relocations, and UBT compile commands on the owner's Linux build; `LD_DEBUG=tls` can show loader TLS activity. [GCC TLS model options](https://gcc.gnu.org/onlinedocs/gcc/Code-Gen-Options.html#Code-Gen-Options). + +## Windows/MSVC to-do + +1. Build the updated standalone: `powershell -ExecutionPolicy Bypass -File Standalone\Build.ps1 -Target All -CompileWorkers 12`. Confirm the Core response file still carries `/fp:precise` and the shared header is included. +2. Run the L1 view at W1 and W12 with `--world-assembler-prototype --stream-benchmark --stream-pipeline --stream-hash --force-generate --clip-radius=3 --max-clip-level=8 --camera=0,-35,-487.94,0,-22 --far-horizon-lod8-step=0`. Require the four L1 hashes above and no self-test error. +3. Run the seed-0 FloatingIslands check at W1 and W12 with mesh coverage enabled; require matching density/mesh/payload/topology hashes and nonempty mesh coverage. +4. Build the owner's UE 5.7 Windows target with the plugin, inspect the actual `cl.exe` options, and exercise worker creation/use/shutdown. MSVC-specific risks are `/fp:precise` versus any `/fp:fast` or `/fp:contract` override, `thread_local` destructor handling on engine task threads, and allocator/container construction and destruction when a thread first touches or exits a cache. Linux ELF sizes do not predict Windows TLS costs. + +## Machine and run record + +Builds and benchmarks used CPUs 0–11, at most 12 build jobs, a 6 GiB address-space limit, and normal priority. Governor remained `powersave`; EPP remained `balance_performance`. Clang build: 29.06 s, peak RSS 431,732 KiB. GCC build: 46.14 s, peak RSS 896,076 KiB. Largest benchmark RSS was 550,856 KiB. Logs are in `Saved/Linux/linux4-*.log`; artifacts are under `Standalone/Build/linux-*` and `Standalone/bin/linux/`. + +Failed or retried runs: + +- The first baseline probes with an 8 MiB worker stack failed for `none`, `surface-world`, and `floating-islands` (`pthread_create: Invalid argument`, exit `-6`, three attempts each). They were rerun serially at 32 MiB and passed. L4 passed all three probes at 8 MiB. +- An early before/after probe batch ran the six probe types concurrently, so its timings and RSS were discarded. Each type was rerun serially; the final before/after comparison uses those serial logs. The discarded logs are retained with `linux4-discarded-parallel-` names. +- No build or world/hash run failed. Both Clang and GCC final builds succeeded. + +## Note for the owner + +The shared cache change reduces standalone static TLS from 17.33 MiB to about 43 KiB and preserves the L1 and FloatingIslands hashes; the W12 median moved from 2.399 s to 2.197 s. Please run the listed standalone and Unreal checks on Windows/MSVC before merging, especially worker-thread TLS destruction and the actual UBT FP/TLS options. + +## Orchestrator review (Claude) + +- Re-run by me: interleaved W12, L1 view: `046f2af` (L3) 2.212/2.256/2.307 s vs L4 2.063/2.091/2.125 s + (medians 2.256 → 2.091 s, -7%). Density `c10697a8f48380c7` throughout. L4 `PT_TLS` MemSiz 0xabc9 = 43,977 B. +- Checked the tunnel-cache route gate in `VoxelGenerator.cpp`: entries are only *stored* when `CP_GenType` + is TunnelNetwork/Underwater, so for any other route the old lookup could only miss. Gating skips + guaranteed misses, so no output change is possible. The legacy cave route is not exercised by the + headless tool, so this rests on code reading, not a hash. +- Windows: `thread_local` destructors on UE task threads are the MSVC risk. Luna's to-do list covers it. diff --git a/Standalone/Research/WorldAssemblerM3/Linux4/TlsProbeMain.cpp b/Standalone/Research/WorldAssemblerM3/Linux4/TlsProbeMain.cpp new file mode 100644 index 0000000..ff3acd3 --- /dev/null +++ b/Standalone/Research/WorldAssemblerM3/Linux4/TlsProbeMain.cpp @@ -0,0 +1,11 @@ +#include "StandaloneTlsProbe.h" + +#include +#include + +int main(int argc,char** argv) +{ + const int workers=argc>1?std::atoi(argv[1]):12; + const std::string_view touch=argc>2?argv[2]:"none"; + return StandaloneTlsProbe::Run(workers,touch); +} diff --git a/Standalone/Source/StandaloneTlsProbe.h b/Standalone/Source/StandaloneTlsProbe.h index 323d0b3..fa8635d 100644 --- a/Standalone/Source/StandaloneTlsProbe.h +++ b/Standalone/Source/StandaloneTlsProbe.h @@ -1,6 +1,7 @@ #pragma once #include "StandaloneWorkerThread.h" +#include "VoxelForgeCore/VoxelForgeCore.h" #include #include @@ -10,12 +11,15 @@ #include #include #include +#include #include +#include #include #include #if defined(__linux__) #include +#include #endif namespace StandaloneTlsProbe @@ -38,14 +42,58 @@ namespace StandaloneTlsProbe residentPages*static_cast(pageSize)/1024}; } - inline int Run(int workerCount) + inline int Run(int workerCount,std::string_view touchKind="none") { workerCount=std::clamp(workerCount,0,12); + VoxelForgeCore::WorldAssemblyRecipeV1 surfaceRecipe; + VoxelForgeCore::WorldAssemblyStratumPlanV1 surfacePlan; + VoxelForgeCore::Heightfield2D surfaceField(surfaceRecipe,surfacePlan); + if(touchKind=="surface-world") + { + VoxelForgeCore::WorldAssemblyBiomeProfileV1 biome; + biome.stableId=1; + surfaceRecipe.biomes.push_back(biome); + surfacePlan.strateIndex=0; + surfacePlan.topChunkZ=1; + surfacePlan.bottomChunkZ=-1; + surfaceField=VoxelForgeCore::Heightfield2D(surfaceRecipe,surfacePlan); + if(!surfaceField.isValid()) + { + std::fprintf(stderr,"[TlsProbe] invalid synthetic SurfaceWorld cache probe input\n"); + return 2; + } + } + VoxelForgeCore::FloatingIslandRecipeV1 islandRecipe; + VoxelForgeCore::FloatingIslandPlanV1 islandPlan; + for(std::uint32_t index=0;index(index)*64*256,0,0}; + island.radiusSubvoxels=20*256; + island.topHalfSubvoxels=20*256; + island.topZSubvoxels=0; + island.bottomZSubvoxels=-80*256; + island.taperEndQ16=islandRecipe.taperEndMinimumQ16; + islandPlan.islands.push_back(island); + } + VoxelForgeCore::FloatingIslandFieldV1 islandField(islandRecipe,islandPlan); + if(touchKind=="floating-islands"&&!islandField.isValid()) + { + std::fprintf(stderr,"[TlsProbe] invalid synthetic FloatingIslands cache probe input\n"); + return 2; + } std::mutex mutex; std::condition_variable changed; bool release=false; + bool touchRequested=false; std::atomic ready{0}; + std::atomic touchReady{0}; std::vector workers; + std::vector startupMinorFaults(static_cast(workerCount),0); + std::vector firstTouchMinorFaults(static_cast(workerCount),0); + std::vector checksums(static_cast(workerCount),0); workers.reserve(static_cast(workerCount)); FMemorySample previous=ReadMemory(); std::printf("[TlsProbe] workers=0 create_ms=0.000 rss_kib=%llu rss_delta_kib=0 vms_kib=%llu stack_bytes=%zu\n", @@ -55,10 +103,47 @@ namespace StandaloneTlsProbe for(int index=1;index<=workerCount;++index) { const auto start=std::chrono::steady_clock::now(); - workers.emplace_back([&] + workers.emplace_back([&,index] { +#if defined(__linux__) + rusage startupUsage{}; + getrusage(RUSAGE_THREAD,&startupUsage); + startupMinorFaults[static_cast(index-1)]=startupUsage.ru_minflt; +#endif ready.fetch_add(1,std::memory_order_release); changed.notify_all(); + { + std::unique_lock lock(mutex); + changed.wait(lock,[&]{return touchRequested||release;}); + } + if(touchRequested) + { +#if defined(__linux__) + rusage beforeTouch{}; + rusage afterTouch{}; + getrusage(RUSAGE_THREAD,&beforeTouch); +#endif + std::int64_t checksum=0; + if(touchKind=="surface-world") + { + for(int sample=0;sample<4;++sample) + checksum+=surfaceField.point((index*4+sample)*256,0,0).densitySubvoxels; + } + else if(touchKind=="floating-islands") + { + for(int sample=0;sample<4;++sample) + checksum+=islandField.densitySubvoxels( + (index*4+sample)*256,0,-20*256); + } + checksums[static_cast(index-1)]=checksum; +#if defined(__linux__) + getrusage(RUSAGE_THREAD,&afterTouch); + firstTouchMinorFaults[static_cast(index-1)]= + afterTouch.ru_minflt-beforeTouch.ru_minflt; +#endif + touchReady.fetch_add(1,std::memory_order_release); + changed.notify_all(); + } std::unique_lock lock(mutex); changed.wait(lock,[&]{return release;}); }); @@ -76,6 +161,33 @@ namespace StandaloneTlsProbe static_cast(rssDelta),static_cast(current.virtualKiB)); previous=current; } + const FMemorySample beforeTouch=ReadMemory(); + std::printf("[TlsProbeTouch] kind=%.*s rss_before_kib=%llu\n", + static_cast(touchKind.size()),touchKind.data(), + static_cast(beforeTouch.residentKiB)); + if(touchKind!="none") + { + { + std::lock_guard lock(mutex); + touchRequested=true; + } + changed.notify_all(); + { + std::unique_lock lock(mutex); + changed.wait(lock,[&]{return touchReady.load(std::memory_order_acquire)==workerCount;}); + } + const FMemorySample afterTouch=ReadMemory(); + std::printf("[TlsProbeTouch] kind=%.*s rss_after_kib=%llu rss_delta_kib=%lld checksum_sum=%lld\n", + static_cast(touchKind.size()),touchKind.data(), + static_cast(afterTouch.residentKiB), + static_cast(static_cast(afterTouch.residentKiB) + -static_cast(beforeTouch.residentKiB)), + static_cast(std::accumulate(checksums.begin(),checksums.end(),std::int64_t{0}))); + for(int index=0;index(index)], + firstTouchMinorFaults[static_cast(index)]); + } { std::lock_guard lock(mutex); release=true; @@ -86,7 +198,7 @@ namespace StandaloneTlsProbe return 0; } #else - inline int Run(int) + inline int Run(int,std::string_view="none") { std::fprintf(stderr,"TLS probe is Linux-only.\n"); return 2; diff --git a/Standalone/Source/StandaloneWorkerThread.h b/Standalone/Source/StandaloneWorkerThread.h index 7ef8cf4..aecbe8a 100644 --- a/Standalone/Source/StandaloneWorkerThread.h +++ b/Standalone/Source/StandaloneWorkerThread.h @@ -12,13 +12,13 @@ #include #endif -// Linux's static TLS image is larger than the default pthread stack in this standalone build. -// Give only host-owned generation workers enough stack for that TLS plus normal call frames. +// Keep host-owned Linux workers on the ordinary 8 MiB stack size. Core's large caches now live +// behind lazy thread-local handles, so they no longer need to be hidden by oversized stacks. #if defined(__linux__) class FStandaloneWorkerThread { public: - static constexpr std::size_t StackSizeBytes=32u*1024u*1024u; + static constexpr std::size_t StackSizeBytes=8u*1024u*1024u; FStandaloneWorkerThread()=default; diff --git a/Standalone/Source/StreamBenchmarkHeadless.cpp b/Standalone/Source/StreamBenchmarkHeadless.cpp index 44316ef..6934a00 100644 --- a/Standalone/Source/StreamBenchmarkHeadless.cpp +++ b/Standalone/Source/StreamBenchmarkHeadless.cpp @@ -44,6 +44,7 @@ namespace bool FloatingIslandsRecipe=false; bool TlsProbeRequested=false; int TlsProbeWorkers=0; + std::string TlsProbeTouch="none"; bool HasCamera=false; bool AutoSurfaceCamera=false; FVector CameraMeters=FVector::ZeroVector; @@ -90,6 +91,7 @@ namespace " --mesh-coverage (report non-empty tiles and indexed triangles)\n" " --render-distance-chunks=N (0 disables the extra far ring)\n" " --tls-probe=N (Linux worker TLS/stack measurement; N=0..12)\n" + " --tls-probe-touch=none|surface-world|floating-islands (first-use probe)\n" " --process-affinity=MASK --process-priority=Normal --frame-limit=FPS (viewer compatibility only)\n", argv[0]); return false; @@ -124,6 +126,13 @@ namespace {std::fprintf(stderr,"--tls-probe must be in 0..12\n");return false;} options.TlsProbeRequested=true; } + else if(arg.starts_with("--tls-probe-touch=")) + { + const std::string_view touch=arg.substr(std::string_view("--tls-probe-touch=").size()); + if(touch!="none"&&touch!="surface-world"&&touch!="floating-islands") + {std::fprintf(stderr,"Invalid --tls-probe-touch value: %.*s\n",static_cast(touch.size()),touch.data());return false;} + options.TlsProbeTouch=touch; + } else if(arg.starts_with("--camera=")) { if(!ParseCamera(arg.substr(9),options.CameraMeters)) @@ -154,6 +163,8 @@ namespace options.ClipRadius=std::clamp(options.ClipRadius,1,64); options.MaxClipLevel=std::clamp(options.MaxClipLevel,0,8); options.FarHorizonLOD8InteriorStepVoxels=std::max(0,options.FarHorizonLOD8InteriorStepVoxels); + if(!options.TlsProbeRequested&&options.TlsProbeTouch!="none") + {std::fprintf(stderr,"--tls-probe-touch requires --tls-probe=N\n");return false;} if(options.TlsProbeRequested)return true; if(!options.WorldAssemblerPrototype||!options.StreamBenchmark||!options.StreamPipeline) { @@ -170,7 +181,8 @@ int main(int argc,char** argv) const auto coldStart=Clock::now(); FOptions options; if(!ParseOptions(argc,argv,options))return 2; - if(options.TlsProbeRequested)return StandaloneTlsProbe::Run(options.TlsProbeWorkers); + if(options.TlsProbeRequested) + return StandaloneTlsProbe::Run(options.TlsProbeWorkers,options.TlsProbeTouch); std::printf("[HeadlessConfig] data=%s workers=%d clip_radius=%d max_clip_level=%d max_level=%d hash=%s column_overlap=%s force_generate=%s recipe=%s seed_override=%s render_distance_override=%s\n", options.DataPath.string().c_str(),options.WorkerCount,options.ClipRadius,options.MaxClipLevel, options.MaxLevel,options.StreamHash?"on":"off",options.ProfileColumnOverlap?"on":"off", diff --git a/Standalone/Source/VoxelForge/Private/VoxelCaveMorphology.cpp b/Standalone/Source/VoxelForge/Private/VoxelCaveMorphology.cpp index 04f62f7..a90258a 100644 --- a/Standalone/Source/VoxelForge/Private/VoxelCaveMorphology.cpp +++ b/Standalone/Source/VoxelForge/Private/VoxelCaveMorphology.cpp @@ -27,6 +27,7 @@ // - STORE region (tight): only rooms/tunnels that can actually reach a voxel in // this chunk are kept for the per-voxel loop, so hot-path cost stays low. +#include "VoxelForgeCore/ThreadLocalStorage.h" #include "VoxelCaveMorphology.h" #include "VoxelDensityProfile.h" #include "VoxelDensityPrimitives.h" @@ -265,7 +266,8 @@ namespace } }; - static thread_local FVFFloorReliefCache GFloorReliefCache; + static thread_local VoxelForgeCoreThreadLocal::FLazy + GFloorReliefCache; FORCEINLINE float VF_ComputeFloorReliefNoiseDouble( double X, double Y, uint32 FloorSeed, float Frequency) @@ -300,7 +302,7 @@ namespace int32 FeatureIndex, const FVFFloorReliefColumnKey& Column) { - return GFloorReliefCache.GetOrCompute( + return GFloorReliefCache.Get().GetOrCompute( X, Y, FloorSeed, Frequency, CacheIdentity, Feature, FeatureIndex, Column, &VF_ComputeFloorReliefNoiseDouble); @@ -313,7 +315,7 @@ namespace int32 FeatureIndex, const FVFFloorReliefColumnKey& Column) { - return GFloorReliefCache.GetOrCompute( + return GFloorReliefCache.Get().GetOrCompute( static_cast(X), static_cast(Y), FloorSeed, Frequency, CacheIdentity, Feature, FeatureIndex, Column, diff --git a/Standalone/Source/VoxelForge/Private/VoxelDensityOpStack.cpp b/Standalone/Source/VoxelForge/Private/VoxelDensityOpStack.cpp index 87a09b8..9b8b7df 100644 --- a/Standalone/Source/VoxelForge/Private/VoxelDensityOpStack.cpp +++ b/Standalone/Source/VoxelForge/Private/VoxelDensityOpStack.cpp @@ -8,6 +8,7 @@ // Convention de signe INTERNE : positif = solide ; la pile négative une fois pour le MC. // Internal sign convention: positive = solid; the stack negates once for MC. +#include "VoxelForgeCore/ThreadLocalStorage.h" #include "VoxelDensityOpStack.h" #include "VoxelStrateComposer.h" @@ -1148,7 +1149,8 @@ namespace } }; - thread_local FColumnCache Cache = {}; + thread_local VoxelForgeCoreThreadLocal::FLazy Cache; + FColumnCache& columnCache = Cache.Get(); thread_local FColumn DirectColumn = {}; // The production mesher and the exact-lattice classifier use integer XY. Fractional @@ -1172,7 +1174,7 @@ namespace // Acquire vérifie la clé uint64 complète et les bornes exactes avant de dériver CI. // Acquire checks the exact uint64 key and exact bounds before deriving CI. - FColumnBox& Box = Cache.Acquire(IX, IY, ColumnKey); + FColumnBox& Box = columnCache.Acquire(IX, IY, ColumnKey); AcquiredBox = &Box; CI = (IY - Box.BaseY) * FColumnBox::Dim + (IX - Box.BaseX); @@ -3158,8 +3160,10 @@ namespace FChunkSDFCache Cache; }; - static thread_local FRoomGraphCacheEntry GRoomGraphCache[ - RoomGraphCacheBankCount][RoomGraphCacheSlotCount]; + using FRoomGraphCacheStorage = std::array< + std::array, RoomGraphCacheBankCount>; + static thread_local VoxelForgeCoreThreadLocal::FLazy + GRoomGraphCache; static thread_local uint32 GRoomGraphCacheClock = 0; FORCEINLINE int32 RoomGraphCacheBank(int32 RegionSize) @@ -3190,6 +3194,7 @@ namespace uint64 ManagerLifetimeId, const TArray* TerrainOps, int32 RegionMinX, int32 RegionMinY, int32 RegionSize) { + auto& RoomGraphCache = GRoomGraphCache.Get(); auto SelectForReuse = [](FRoomGraphCacheEntry* Selected, uint32 Use) -> FRoomGraphCacheEntry* { @@ -3219,7 +3224,7 @@ namespace FRoomGraphCacheEntry* LeastRecentlyUsed = nullptr; for (int32 Probe = 0; Probe < RoomGraphCacheSlotCount; ++Probe) { - FRoomGraphCacheEntry& Candidate = GRoomGraphCache[Bank][ + FRoomGraphCacheEntry& Candidate = RoomGraphCache[Bank][ (StartSlot + Probe) & (RoomGraphCacheSlotCount - 1)]; if (!Candidate.bValid) { @@ -3251,7 +3256,7 @@ namespace FRoomGraphCacheEntry* Selected = ReuseEntry != nullptr ? ReuseEntry : LeastRecentlyUsed; return SelectForReuse( - Selected != nullptr ? Selected : &GRoomGraphCache[Bank][StartSlot], Use); + Selected != nullptr ? Selected : &RoomGraphCache[Bank][StartSlot], Use); } // Classification is dispatched across many workers before most of those workers have @@ -8740,11 +8745,12 @@ void VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint() uint64 LargestEntryBytes = 0; uint64 ValidEntries = 0; VoxelDensityProfile::FCacheMemoryBreakdown Breakdown; + auto& RoomGraphCache = GRoomGraphCache.Get(); for (int32 Bank = 0; Bank < RoomGraphCacheBankCount; ++Bank) { for (int32 Index = 0; Index < RoomGraphCacheSlotCount; ++Index) { - const FRoomGraphCacheEntry& Entry = GRoomGraphCache[Bank][Index]; + const FRoomGraphCacheEntry& Entry = RoomGraphCache[Bank][Index]; if (!Entry.bValid) { continue; @@ -8760,12 +8766,12 @@ void VoxelDensityOps::ReportWorkerRoomGraphCacheFootprint() LargestEntryBytes = FMath::Max(LargestEntryBytes, FullEntryBytes); } } - Breakdown.SlotStorageBytes = static_cast(sizeof(GRoomGraphCache)); + Breakdown.SlotStorageBytes = static_cast(sizeof(RoomGraphCache)); VoxelDensityProfile::SetWorkerRoomGraphCacheFootprint( RoomGraphCacheBankCount * RoomGraphCacheSlotCount, ValidEntries, - static_cast(sizeof(GRoomGraphCache)), + static_cast(sizeof(RoomGraphCache)), DynamicBytes, EntryBytes, LargestEntryBytes, diff --git a/Standalone/Source/VoxelForge/Private/VoxelGenerator.cpp b/Standalone/Source/VoxelForge/Private/VoxelGenerator.cpp index 7f63c64..c41f146 100644 --- a/Standalone/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Standalone/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -4,6 +4,7 @@ // Convention INTERNE: positif = solide, négatif = air (plus lisible). // Convention de SORTIE (marching cubes): on négate → négatif = solide. +#include "VoxelForgeCore/ThreadLocalStorage.h" #include "VoxelGenerator.h" #include "StandaloneResearchTunnels.h" #include "VoxelSettings.h" @@ -388,7 +389,7 @@ struct FSurfaceColumnCache // Cache de colonnes par thread de ClassifyTile (T1.d) : les pré-passes et passes complètes // successives d'une même tuile, et les tuiles voisines, réutilisent leurs colonnes. // Per-thread column cache for ClassifyTile: successive passes and neighbouring tiles reuse columns. -static thread_local FSurfaceColumnCache GSurfColCache; +static thread_local VoxelForgeCoreThreadLocal::FLazy GSurfColCache; //============================================================================= // WORM N1 BLOCK PROOF (worker-local, scalar GetDensityAt hand-off) @@ -3023,7 +3024,9 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, thread_local FVoxelOpStack CP_OpStack; thread_local bool CP_UseOpStack = false; thread_local uint64 CP_ManagerLifetimeId = 0; - thread_local FTunnelNetworkDensityCacheEntry CP_TunnelDensityCache[TunnelDensityCacheSlotCount]; + thread_local VoxelForgeCoreThreadLocal::FLazy< + std::array> + CP_TunnelDensityCache; thread_local FTunnelNetworkDensityCacheEntry* CP_ActiveTunnelDensityCache = nullptr; // Cooked-season recipes and editor candidate recipes share this immutable worker-local // hand-off. Recipe materialisation is runtime; rolling/selection remains editor-only. @@ -3080,12 +3083,13 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, auto FindTunnelDensityCacheEntry = [&](const FIntVector& QueryChunk, FTunnelNetworkDensityCacheEntry*& OutEntry) -> bool { + auto& TunnelDensityCache = CP_TunnelDensityCache.Get(); const int32 StartSlot = TunnelDensityCacheSlot(TunnelCacheKey); FTunnelNetworkDensityCacheEntry* ReuseEntry = nullptr; for (int32 Probe = 0; Probe < TunnelDensityCacheSlotCount; ++Probe) { const int32 Slot = (StartSlot + Probe) & (TunnelDensityCacheSlotCount - 1); - FTunnelNetworkDensityCacheEntry& Candidate = CP_TunnelDensityCache[Slot]; + FTunnelNetworkDensityCacheEntry& Candidate = TunnelDensityCache[Slot]; if (!Candidate.bValid || Candidate.OwnerId != DensityCacheOwnerId) { if (ReuseEntry == nullptr) { ReuseEntry = &Candidate; } @@ -3107,11 +3111,14 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, } OutEntry = ReuseEntry != nullptr ? ReuseEntry - : &CP_TunnelDensityCache[StartSlot]; + : &TunnelDensityCache[StartSlot]; return false; }; - if (!bIsGapChunk) + const bool bTunnelDensityCacheRoute = + QueryGeneratorType == ECaveGeneratorType::TunnelNetwork + || QueryGeneratorType == ECaveGeneratorType::Underwater; + if (!bIsGapChunk && bTunnelDensityCacheRoute) { if (VoxelDensityProfile::AreCountersEnabled()) { @@ -3387,7 +3394,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, { FTunnelNetworkDensityCacheEntry& CachedEntry = TunnelDensityCacheEntryForWrite ? *TunnelDensityCacheEntryForWrite - : CP_TunnelDensityCache[TunnelDensityCacheSlot(ChunkCoord)]; + : CP_TunnelDensityCache.Get()[TunnelDensityCacheSlot(ChunkCoord)]; CachedEntry.bValid = false; CachedEntry.OwnerId = DensityCacheOwnerId; CachedEntry.ManagerLifetimeId = ManagerLifetimeId; @@ -3406,7 +3413,7 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, CP_ActiveTunnelDensityCache = &CachedEntry; ActiveOpStack = &CachedEntry.OpStack; ActiveTunnelCoreCache = &CachedEntry.TunnelCore; - ReportTunnelDensityCacheFootprint(CP_TunnelDensityCache); + ReportTunnelDensityCacheFootprint(CP_TunnelDensityCache.Get().data()); } else if (!bLoadedTunnelDensityCache) { @@ -4023,11 +4030,13 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, uint32 Version = 0; TArray Mods; }; - static thread_local FDiffSlot DiffSlots[64]; + static thread_local VoxelForgeCoreThreadLocal::FLazy> + DiffSlots; + auto& diffSlots = DiffSlots.Get(); const uint64 DiffLayerOwnerId = DiffLayer->GetCacheLifetimeId(); const uint32 V = DiffLayer->GetModsVersion(); - FDiffSlot& Slot = DiffSlots[(ChunkCoord.X & 3) | ((ChunkCoord.Y & 3) << 2) | ((ChunkCoord.Z & 3) << 4)]; + FDiffSlot& Slot = diffSlots[(ChunkCoord.X & 3) | ((ChunkCoord.Y & 3) << 2) | ((ChunkCoord.Z & 3) << 4)]; if (Slot.OwnerId != DiffLayerOwnerId || Slot.Chunk != ChunkCoord || Slot.Version != V) { Slot.OwnerId = DiffLayerOwnerId; @@ -4233,7 +4242,8 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo uint64 LastUse = 0; bool bValid = false; }; - static thread_local std::array RoomGraphCacheSlots{}; + static thread_local VoxelForgeCoreThreadLocal::FLazy< + std::array> RoomGraphCacheStorage; static thread_local uint64 RoomGraphCacheUseSerial = 0; VF_ParseRoomGraphCacheSlots(); // The envelope proof is constant for a cache-policy key. Do not run the complete room/edge @@ -4417,6 +4427,7 @@ float UVoxelGenerator::GetDensityWithParams(float WorldX, float WorldY, float Wo const int32 ActiveCacheSlots = FMath::Max( FMath::Clamp(GVoxelForgeRoomGraphCacheSlots, 1, 4), bNeedsTwoStrataWindows ? 2 : 1); + auto& RoomGraphCacheSlots = RoomGraphCacheStorage.Get(); FRoomGraphCacheSlot* CacheSlot = nullptr; FRoomGraphCacheSlot* EvictionSlot = &RoomGraphCacheSlots[0]; for (int32 SlotIndex = 0; SlotIndex < ActiveCacheSlots; ++SlotIndex) @@ -7349,7 +7360,7 @@ EVoxelTileClass UVoxelGenerator::ClassifyTileUncached( { FSurfSlot& S = Slots[s]; if (S.InteriorZ.Num() == 0) continue; - FSurfaceColumnBox& Box = GSurfColCache.Acquire( + FSurfaceColumnBox& Box = GSurfColCache.Get().Acquire( Xi, Yi, S.StrateKey, Seed, TC_LayoutVersion, TC_OwnerId, TC_ManagerLifetimeId); const int32 CI = (Yi - Box.BaseY) * FSurfaceColumnBox::Dim + (Xi - Box.BaseX); diff --git a/Standalone/Source/VoxelForge/Private/VoxelStrateManager.cpp b/Standalone/Source/VoxelForge/Private/VoxelStrateManager.cpp index e3bc290..817dee9 100644 --- a/Standalone/Source/VoxelForge/Private/VoxelStrateManager.cpp +++ b/Standalone/Source/VoxelForge/Private/VoxelStrateManager.cpp @@ -1,6 +1,7 @@ // VoxelStrateManager.cpp // Runtime strate layout generation and queries. +#include "VoxelForgeCore/ThreadLocalStorage.h" #include "VoxelStrateManager.h" #include "VoxelSettings.h" #include "VoxelSeasonAsset.h" @@ -435,8 +436,8 @@ namespace FVoxelPassageReliefCache& VF_GetPassageReliefCache() { - thread_local FVoxelPassageReliefCache Cache; - return Cache; + thread_local VoxelForgeCoreThreadLocal::FLazy Cache; + return Cache.Get(); } FORCEINLINE float VF_ComputePassageReliefNoise(