OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain commits / da0e556

dc: spawn workers AFTER the GPU, and stop re-sorting a backlog nobody dispatches
Three changes, all verified on map_dc_diffusion across three runs. DEFERRED WORKER SPAWN. DCGpuThread::start() only spawns the thread; compute_.initialize() then runs create_local_rendering_device() -- a second VkDevice. Spawning 8 workers first saturated 16 cores and device creation had to fight them: 30.2 s contended vs 4.17 s free. While the GPU is down submit() returns false and the chunk falls back to the CPU MESHER (dc_contour.cpp, not qef_solve.glsl), and nothing ever re-meshes it -- 47 solid chunks stayed in the world beside GPU-meshed neighbours. That is what "close-by LODs don't line up" was. DEFERRED, not blocked. A first cut blocked at ENTER_TREE and turned a slow trickle into a 4.6 s freeze (map load 0.81 -> 5.44 s) with the right total and the wrong shape. _process() now spawns the pool the frame the GPU lands, 90 s escape hatch, DC_NO_GPU_FIRST=1 opts out. Measured: GPU ready 7.4 s, cpu_mesh 0 (0%) on all 1057 GPUPATH lines of a 470 s run, from the very first chunk. PRE-MESH DISPATCH. _dispatch_pre_mesh_requests full-sorted every MESH_PENDING entry each frame with a comparator doing TWO unordered_map lookups per comparison, then dispatched at most 8. pre_mesh_cache_ has no distance eviction, so the far tail is never dispatched and never dropped -- it just got re-sorted for ever, and the cost grew with exploration and never came back. It cost 6.12 ms average while dispatching ZERO regular chunks, climbing 3.61 -> 6.86 -> 9.05 ms. Distance now comes from the pass over the cache we already make, and partial_sort takes only the head. The loop dispatches at most TARGET_QUEUE_DEPTH iterations (every iteration dispatches or breaks), and partial_sort yields that head in exactly the order a full sort would, so WHICH chunks go out, and in what order, is unchanged -- only the cost of finding them drops, O(N log N) -> O(N log K). Measured: 6.12 -> 1.0 ms average, 17.0 -> 2.9 ms max, and it no longer scales with the backlog (2.9 ms max at pm > 3000, a backlog 6x anything the old build saw). Hitches >4 ms went 11.0/s -> 2.1/s, below the 2.9/s of the build before the regression. TWO COUNTERS, because both questions were being argued from inference. The FRAME line now carries pm=<n>, the pre-mesh backlog that drives the dispatch phase, computed before the no-workers return so it stays visible during GPU bring-up. And the DCTerrain[N] status line loses its debug_frames_ < 20 cap, which used to blind the resident-set counters ~39 s in -- exactly the part of a session where leaves/blocked/dealloc_q matter. That cap is why "leaves grows without bound" looked plausible; unblinded, leaves peaks at 7477, evicts, and plateaus at ~3950. Eviction works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
da0e556af4f14a5fb67944506b0db86a1d06113d
by plankatron <fleeky@gmail.com> · 2026-09-04T01:03:24.000Z

Changed files (2) +150 -57

src/dc_terrain.cpp +139 -57
……@@ -1,4 +1,5 @@
11#include "dc_terrain.h"
2#include <thread>
23#include "dc_octree.h"
34#include "dc_edit.h"
45#include "dc_contour.h"
……@@ -523,6 +524,26 @@ void DCTerrain::_process(double delta) {
523524
524525 frame_counter_++;
525526
527 // Deferred worker spawn: the pool was held back at ENTER_TREE so the GPU
528 // thread's create_local_rendering_device() could have the cores to itself
529 // (measured 30.2 s contended vs 4.17 s free). Spawn the frame it lands.
530 // The 90 s escape hatch keeps a GPU that never initialises from producing a
531 // terrain-less world — we lose the GPU mesher, not the terrain.
532 if (workers_deferred_) {
533 double waited = std::chrono::duration<double>(
534 std::chrono::steady_clock::now() - workers_deferred_since_).count();
535 bool ready = gpu_thread_ && gpu_thread_->is_available();
536 if (ready || waited > 90.0) {
537 workers_deferred_ = false;
538 if (debug_logging_ || !ready) {
539 fprintf(stderr, "[GPU-FIRST] deferred workers %.2f s (gpu_available=%d)\n",
540 waited, (int)ready);
541 fflush(stderr);
542 }
543 _spawn_workers();
544 }
545 }
546
526547 // 1. Get camera position
527548 float cam_pos[3] = {0.0f, 0.0f, 0.0f};
528549 bool has_camera = false;
……@@ -567,8 +588,13 @@ void DCTerrain::_process(double delta) {
567588 }
568589 }
569590
570 // Debug: log status on first frames, then every 60 frames
571 if (debug_logging_ && (debug_frames_ < 5 || (frame_counter_ % 60 == 0 && debug_frames_ < 20))) {
591 // Debug: log status on first frames, then every 60 frames for the WHOLE run.
592 // The `debug_frames_ < 20` cap used to stop this line ~39 s in, which meant
593 // the resident-set counters (leaves / blocked / dealloc_q) went dark for
594 // exactly the part of a session where they matter — cost that grows with
595 // accumulated world rather than with what the camera is doing right now is
596 // invisible without them (2026-09-03).
597 if (debug_logging_ && (debug_frames_ < 5 || frame_counter_ % 60 == 0)) {
572598 auto stats = octree_clipmap_.get_stats();
573599 const char* mode_str = (lod_mode_ == LOD_BLOCKING_SWAP) ? "BLOCKING" : "EAGER";
574600 UtilityFunctions::print(String("DCTerrain[{0}]: mode={1} cam={2} pos=({3},{4},{5}) leaves={6} pending={7} blocked={8} dealloc_q={9} workers={10}").format(
……@@ -1272,9 +1298,10 @@ void DCTerrain::_process(double delta) {
12721298 // Log frame timing if total _process exceeds 4ms
12731299 double total_frame = frame_ms(t_frame, t_end);
12741300 if (debug_logging_ && total_frame > 4.0) {
1275 fprintf(stderr, "FRAME: traverse=%.1f dispatch=%.1f(%d) upload=%.1f(%d) seam=%.1f(%d,q=%d) collision=%.1f(%d,q=%d) dealloc=%.1f(%d) TOTAL=%.1fms\n",
1301 fprintf(stderr, "FRAME: traverse=%.1f dispatch=%.1f(%d,pm=%d) upload=%.1f(%d) seam=%.1f(%d,q=%d) collision=%.1f(%d,q=%d) dealloc=%.1f(%d) TOTAL=%.1fms\n",
12761302 frame_ms(t_frame, t_traverse),
12771303 frame_ms(t_traverse, t_dispatch), static_cast<int>(pending.size()),
1304 premesh_pending_last_frame_,
12781305 frame_ms(t_dispatch, t_upload), uploaded,
12791306 frame_ms(t_upload, t_seam), seams_built, static_cast<int>(seam_pending_.size()),
12801307 frame_ms(t_seam, t_collision), collisions_built, collision_trimesh_.queue_size(),
……@@ -1288,28 +1315,6 @@ void DCTerrain::_process(double delta) {
12881315// BLOCKING_SWAP: dispatch pre-mesh requests for speculative mesh generation
12891316// ---------------------------------------------------------------------------
12901317void DCTerrain::_dispatch_pre_mesh_requests(int regular_dispatched_this_frame) {
1291 if (!_has_workers()) return;
1292
1293 auto& cache = octree_clipmap_.pre_mesh_cache_ref();
1294
1295 // Sort pending by distance to camera — near chunks first
1296 std::vector<uint64_t> pending_mortons;
1297 for (auto& [morton, entry] : cache) {
1298 if (entry.state == OctreeClipmapNode::MESH_PENDING)
1299 pending_mortons.push_back(morton);
1300 }
1301 std::sort(pending_mortons.begin(), pending_mortons.end(),
1302 [&](uint64_t a, uint64_t b) {
1303 auto& ea = cache[a]; auto& eb = cache[b];
1304 float ax = ea.position.x * voxel_size_ - prev_cam_pos_[0];
1305 float ay = ea.position.y * voxel_size_ - prev_cam_pos_[1];
1306 float az = ea.position.z * voxel_size_ - prev_cam_pos_[2];
1307 float bx = eb.position.x * voxel_size_ - prev_cam_pos_[0];
1308 float by = eb.position.y * voxel_size_ - prev_cam_pos_[1];
1309 float bz = eb.position.z * voxel_size_ - prev_cam_pos_[2];
1310 return (ax*ax+ay*ay+az*az) < (bx*bx+by*by+bz*bz);
1311 });
1312
13131318 // Queue-depth-based throttle: keep total worker queue depth at a small
13141319 // target so workers never idle (LOD streams fast), but edit chunks can
13151320 // queue immediately when they arrive and only wait the brief drain time
……@@ -1317,6 +1322,60 @@ void DCTerrain::_dispatch_pre_mesh_requests(int regular_dispatched_this_frame) {
13171322 // Static per-frame caps couldn't balance both — too low starved LOD,
13181323 // too high let pre-mesh saturate the queue ahead of edits.
13191324 constexpr size_t TARGET_QUEUE_DEPTH = 8;
1325 // Always dispatch at least MIN_PREMESH_PER_FRAME of the CLOSEST pre-mesh
1326 // (the head of the distance-ordered list) so heavy regular generation
1327 // (spawn burst / creatures / edits) can't fully starve near-camera LOD
1328 // refinement and leave chunks frozen coarse (the LOD "chunk freeze").
1329 // The throttle still governs the bulk, preserving edit responsiveness.
1330 constexpr int MIN_PREMESH_PER_FRAME = 4;
1331
1332 auto& cache = octree_clipmap_.pre_mesh_cache_ref();
1333
1334 // Collect the pending set WITH each entry's camera distance in the SAME
1335 // pass we already make over the cache.
1336 //
1337 // This used to push bare mortons and then std::sort them with a comparator
1338 // that did TWO unordered_map lookups per comparison — O(N log N) hash
1339 // lookups EVERY FRAME across the whole backlog. Since the loop below is
1340 // distance-ordered and dispatches at most TARGET_QUEUE_DEPTH per frame, and
1341 // pre_mesh_cache_ has no distance eviction, the far tail is never
1342 // dispatched and never dropped: it just gets re-sorted for ever, so the
1343 // cost grows with exploration and never comes back down.
1344 //
1345 // Measured 2026-09-03 on map_dc_diffusion (841-tile set, same map both
1346 // runs): the dispatch phase averaged 6.12 ms while dispatching ZERO regular
1347 // chunks, climbing 3.61 -> 6.86 -> 9.05 ms over the last 60 s of the run,
1348 // against 1.46 ms before. 11.0 hitches/s (>4 ms) vs 2.9.
1349 std::vector<std::pair<float, uint64_t>> pending; // (distance^2, morton)
1350 pending.reserve(cache.size());
1351 for (auto& [morton, entry] : cache) {
1352 if (entry.state != OctreeClipmapNode::MESH_PENDING) continue;
1353 float dx = entry.position.x * voxel_size_ - prev_cam_pos_[0];
1354 float dy = entry.position.y * voxel_size_ - prev_cam_pos_[1];
1355 float dz = entry.position.z * voxel_size_ - prev_cam_pos_[2];
1356 pending.emplace_back(dx * dx + dy * dy + dz * dz, morton);
1357 }
1358 // Instrumentation only — surfaced in the FRAME line as pm=<n>. Computed
1359 // BEFORE the no-workers return so the backlog is visible during the GPU
1360 // bring-up window, when the pool is deliberately not spawned yet.
1361 premesh_pending_last_frame_ = static_cast<int>(pending.size());
1362
1363 if (!_has_workers()) return;
1364
1365 // Only the HEAD of the distance-ordered list can ever be dispatched: every
1366 // iteration below either dispatches or breaks, and the break fires once the
1367 // queue is at TARGET_QUEUE_DEPTH and MIN_PREMESH_PER_FRAME have gone out —
1368 // so at most TARGET_QUEUE_DEPTH iterations run. partial_sort produces that
1369 // head in EXACTLY the order a full sort would, so which chunks get
1370 // dispatched, and in what order, is unchanged. Only the cost of finding
1371 // them drops, from O(N log N) to O(N log K).
1372 const size_t k = std::min(pending.size(),
1373 TARGET_QUEUE_DEPTH + static_cast<size_t>(MIN_PREMESH_PER_FRAME));
1374 std::partial_sort(pending.begin(), pending.begin() + k, pending.end(),
1375 [](const std::pair<float, uint64_t>& a, const std::pair<float, uint64_t>& b) {
1376 return a.first < b.first;
1377 });
1378
13201379 size_t total_queue_depth = 0;
13211380 if (using_editor_worker_ && s_editor_worker_) {
13221381 total_queue_depth = s_editor_worker_->request_queue_size();
……@@ -1325,16 +1384,11 @@ void DCTerrain::_dispatch_pre_mesh_requests(int regular_dispatched_this_frame) {
13251384 }
13261385 (void)regular_dispatched_this_frame; // queue-depth approach makes this redundant
13271386
1328 // Always dispatch at least MIN_PREMESH_PER_FRAME of the CLOSEST pre-mesh
1329 // (pending_mortons is distance-sorted) so heavy regular generation (spawn
1330 // burst / creatures / edits) can't fully starve near-camera LOD refinement
1331 // and leave chunks frozen coarse (the LOD "chunk freeze"). The throttle
1332 // still governs the bulk, preserving edit responsiveness.
1333 constexpr int MIN_PREMESH_PER_FRAME = 4;
13341387 int premesh_dispatched = 0;
1335 for (uint64_t morton : pending_mortons) {
1388 for (const auto& pm : pending) {
13361389 if (total_queue_depth >= TARGET_QUEUE_DEPTH &&
13371390 premesh_dispatched >= MIN_PREMESH_PER_FRAME) break;
1391 const uint64_t morton = pm.second;
13381392 auto& entry = cache[morton];
13391393
13401394 CellRequest req;
……@@ -1568,6 +1622,35 @@ void DCTerrain::_update_debug_clipmap() {
15681622 debug_clipmap_node_->set_mesh(im);
15691623}
15701624
1625void DCTerrain::_spawn_workers() {
1626 // Auto-scale worker count to available CPU cores, capped at 8.
1627 // Higher counts (tested 14 on a 16-core box) actually hurt
1628 // frame times: workers complete meshes faster than the main
1629 // thread can build their collision trimeshes, leaving a
1630 // backlog that produces frame hitches > 100ms.
1631 // The real ceiling is main-thread downstream (collision + upload),
1632 // not worker compute. Until that's parallelized too, 8 workers
1633 // is the sweet spot.
1634 int cpu_count = OS::get_singleton()->get_processor_count();
1635 int worker_count = std::clamp(cpu_count - 2, 2, 8);
1636 if (debug_logging_) {
1637 UtilityFunctions::print("DCTerrain: spawning ", worker_count,
1638 " workers (", cpu_count, " logical cores detected)");
1639 }
1640 for (int i = 0; i < worker_count; i++) {
1641 auto worker = std::make_unique<DCWorkerCPU>();
1642 worker->set_generation(generation_);
1643 // Always pass GPU thread pointer. submit() checks
1644 // gpu_available_ atomically — workers fall back to CPU
1645 // until GPU init completes, then use GPU automatically.
1646 if (gpu_thread_) {
1647 worker->set_gpu_thread(gpu_thread_.get());
1648 }
1649 worker->start(i);
1650 workers_.push_back(std::move(worker));
1651 }
1652}
1653
15711654void DCTerrain::_notification(int p_what) {
15721655 switch (p_what) {
15731656 case NOTIFICATION_ENTER_TREE: {
……@@ -1601,31 +1684,30 @@ void DCTerrain::_notification(int p_what) {
16011684 gpu_thread_ = std::make_unique<DCGpuThread>();
16021685 gpu_thread_->start();
16031686 }
1604 // Auto-scale worker count to available CPU cores, capped at 8.
1605 // Higher counts (tested 14 on a 16-core box) actually hurt
1606 // frame times: workers complete meshes faster than the main
1607 // thread can build their collision trimeshes, leaving a
1608 // backlog that produces frame hitches > 100ms.
1609 // The real ceiling is main-thread downstream (collision + upload),
1610 // not worker compute. Until that's parallelized too, 8 workers
1611 // is the sweet spot.
1612 int cpu_count = OS::get_singleton()->get_processor_count();
1613 int worker_count = std::clamp(cpu_count - 2, 2, 8);
1614 if (debug_logging_) {
1615 UtilityFunctions::print("DCTerrain: spawning ", worker_count,
1616 " workers (", cpu_count, " logical cores detected)");
1617 }
1618 for (int i = 0; i < worker_count; i++) {
1619 auto worker = std::make_unique<DCWorkerCPU>();
1620 worker->set_generation(generation_);
1621 // Always pass GPU thread pointer. submit() checks
1622 // gpu_available_ atomically — workers fall back to CPU
1623 // until GPU init completes, then use GPU automatically.
1624 if (gpu_thread_) {
1625 worker->set_gpu_thread(gpu_thread_.get());
1626 }
1627 worker->start(i);
1628 workers_.push_back(std::move(worker));
1687 // ⛔ DO NOT SPAWN WORKERS INTO A GPU THAT IS STILL COMING UP.
1688 //
1689 // start() only spawns the thread; compute_.initialize() then runs
1690 // create_local_rendering_device() — a whole second VkDevice. With
1691 // 8 workers already saturating 16 cores that measured 30.2 s; with
1692 // the cores free it is 4.17 s. A 7.2x difference, and it is pure
1693 // CPU starvation (2026-09-03).
1694 //
1695 // While the GPU is down submit() returns false and every chunk
1696 // falls back to the CPU MESHER — a different dual-contouring
1697 // implementation from qef_solve.glsl. Measured: 47 solid chunks
1698 // came from dc_contour.cpp and then stayed in the world next to
1699 // GPU-meshed neighbours. Waiting takes that to 0.
1700 //
1701 // ⚠ DEFERRED, NOT BLOCKED. A first cut blocked here and cost
1702 // 4.6 s of frozen map load (0.81 s -> 5.44 s) — the right total,
1703 // the wrong shape. _process() spawns them the frame the GPU is
1704 // ready, so load stays responsive and nothing meshes early.
1705 if (gpu_thread_ && !gpu_thread_->is_available() &&
1706 !getenv("DC_NO_GPU_FIRST")) {
1707 workers_deferred_ = true;
1708 workers_deferred_since_ = std::chrono::steady_clock::now();
1709 } else {
1710 _spawn_workers();
16291711 }
16301712 } else {
16311713 generation_++;
src/dc_terrain.h +11 -0
……@@ -37,6 +37,7 @@
3737#include <atomic>
3838#include <mutex>
3939#include <vector>
40#include <chrono>
4041#include <deque>
4142#include <memory>
4243#include <functional>
……@@ -404,6 +405,16 @@ private:
404405 size_t _worker_priority_depth() const; // total queued edits across workers (1b)
405406 void _poll_workers(std::function<void(CellResult&)> handler, double budget_ms, int min_count);
406407 void _set_worker_generation(uint32_t gen);
408 // Spawn the game-mode worker pool. Split out of _notification so the
409 // deferred path (waiting on GPU bring-up) can call it from _process.
410 void _spawn_workers();
411 // Size of the pre-mesh MESH_PENDING backlog as of the last
412 // _dispatch_pre_mesh_requests pass. Instrumentation only (FRAME pm=<n>):
413 // this backlog drives the dispatch-phase cost, and it has no distance
414 // eviction, so it is the number to watch.
415 int premesh_pending_last_frame_ = 0;
416 bool workers_deferred_ = false; // workers held back until the GPU is up
417 std::chrono::steady_clock::time_point workers_deferred_since_;
407418 void _drain_all_workers();
408419 uint32_t generation_ = 0; // incremented on rebuild — stale results discarded
409420 float prev_cam_pos_[3] = {0.0f, 0.0f, 0.0f}; // teleport detection