Pivot Phase 3: diagnose as draw-bound, not upload-bound

Earlier probes pointed at per-frame glNamedBufferSubData uploads as the
bottleneck (60 fps when those two calls were commented out).  That was a
false reading — zeroing the uploads also emptied the indirect buffer, so
MDI drew nothing.  "No upload" and "no draw" were indistinguishable.

Two new diagnostic env vars in render() isolate the real costs:

  IFC_SKIP_MDI=1       keep cull + upload + binds, skip only the MDI
                       draws.  Gives 62 fps with everything else running,
                       confirming the non-draw path fits in ~16 ms.
  IFC_MAX_SUBDRAWS=N   cap each MDI's drawcount.  67k -> 30k sub-draws
                       saves 0 ms, confirming sub-draw count itself is
                       not the bottleneck; the long tail of sub-draws
                       carries ~no triangles.

On a GTX 1650 with 128 M triangles in view, nvidia-smi sits at 95 %
GPU util and FPS scales with triangle work, not sub-draw count.  The
card is simply rasterising at ~850 M tri/s.  No CPU-side or upload
trick recovers it.

Revised Phase 3 is therefore shedding triangles, not bytes:
  3A screen-space contribution culling (next)
  3B LOD
  3C HiZ occlusion
  3D GPU-side compute culling

README Phase 3 section rewritten around the diagnosis, including the
false lead, so future work doesn't re-tread the upload path.  The
aborted staging+resident ring-buffer implementation was reverted (the
uncommitted working tree is gone — pure glNamedBufferSubData retained
for the visible + indirect buffers, which we now know is fine).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-04-13 09:33:21 +10:00
parent cd77c557e9
commit d3c21d7a81
2 changed files with 145 additions and 75 deletions
+117 -70
View File
@@ -333,101 +333,148 @@ buffer. The renderer issues MDI twice: fwd with `glFrontFace(GL_CCW)`,
rev with `glFrontFace(GL_CW)`. `GL_CULL_FACE` stays on and does the rev with `glFrontFace(GL_CW)`. `GL_CULL_FACE` stays on and does the
right thing in both passes. right thing in both passes.
### Current bottleneck — Phase 3 as designed is already obsolete ### Current bottleneck — draw-bound, not upload-bound
The original README's Phase 3 ("GPU-driven indirect draw") described The original README's Phase 3 ("GPU-driven indirect draw") described
moving draw submission to the GPU via compute. In the meantime, GPU moving draw submission to the GPU via compute. In the meantime, GPU
instancing and MDI made the CPU-side draw cost essentially free (10 instancing and MDI made the CPU-side draw cost essentially free (10
`glMultiDrawElementsIndirect` calls per frame for 10 models). **That `glMultiDrawElementsIndirect` calls per frame for 10 models). **That
goal is met.** The real Phase 3 problem is different. goal is met.** The real ceiling lies elsewhere, and it took a couple of
bad hypotheses to pin down.
#### Diagnosed on a 10-model / 379 k-instance / 128 M-triangle scene #### Profiled scene
Observed numbers (everything in view, no movement): 10 models / 379 k instances / 128 M triangles, everything in view, no
camera motion, GTX 1650 (PCIe dGPU, 4 GB VRAM):
| Metric | Value | | Metric | Value |
|--------|-------| |--------|-------|
| FPS | 10 | | FPS | 6.7 |
| Frame time | ~100 ms | | Frame time | 149 ms |
| gl_draws | 10 | | gl_draws | 10 |
| Sub-draws packed in indirect buffers | 67 037 | | Sub-draws packed in indirect buffers | 67 037 |
Elimination experiments: `nvidia-smi` reports 95 % GPU utilisation during render — the GPU is
the thing that's pinned.
| Probe | Result | Interpretation | #### False lead: "the per-frame uploads are the bottleneck"
|-------|--------|----------------|
| Camera off-screen (nothing visible) | → 60 fps | GPU is idle; CPU path is cheap |
| Resize window to 1/4 area | no change | Not fragment/raster bound |
| `setSamples(4)``setSamples(1)` | no change | Not MSAA/resolve bound |
| Comment out the two `glNamedBufferSubData` in `cullAndUploadVisible` | → 60 fps (screen blank) | **The per-frame uploads are the bottleneck.** |
So the bottleneck is two `glNamedBufferSubData` calls per model per The first round of probes pointed at the two `glNamedBufferSubData`
frame uploading ~1.5 MB (visible list) + ~1.3 MB (indirect buffer). calls per model per frame (visible list ~1.5 MB + indirect buffer
3 MB/frame / 60 fps = 180 MB/s — trivial for the bus, but `glNamedBufferSubData` ~1.3 MB):
against a buffer the GPU is still reading forces the driver to stall
the CPU or orphan/reallocate the backing store, and we're hitting that
on 20 buffers per frame.
### Phase 3 (proposed) — Eliminate per-frame upload stalls | Probe | Result | Initial interpretation |
|-------|--------|------------------------|
| Camera off-screen (nothing visible) | 60 fps | GPU idle → CPU path cheap |
| Comment out the two `glNamedBufferSubData` | 60 fps, blank screen | Uploads are the bottleneck |
Two ways to attack it, in ascending order of effort: This led to an aborted Phase 3A implementation of persistent-mapped
triple-buffered rings (and then staging + VRAM-resident with
`glCopyNamedBufferSubData`). Neither moved the FPS needle — both still
sat at 6.7 fps.
#### 3A. Persistent mapped ring buffers (near-term) The probe was wrong: **commenting out the uploads emptied the indirect
buffer, so MDI drew zero triangles. "No upload" and "no draw" were
indistinguishable in the test.**
Allocate each of the per-frame-written buffers with #### What actually isolates the draw cost
`glBufferStorage(GL_MAP_PERSISTENT_BIT | GL_MAP_COHERENT_BIT | GL_MAP_WRITE_BIT)`
at 3× the needed size. Keep one `void*` from `glMapBufferRange` forever.
Each frame, write the CPU-side data into slice `frame % 3` and bind
that slice via `glBindBufferRange`. The GPU reads slice N1 while the
CPU writes slice N — no driver sync, no orphan, no stall.
Scope: ~80 lines across `ModelGpuData` + `cullAndUploadVisible` + Two diagnostic env vars now live in `render()`:
binding in `render()` / `renderPickPass()`. No algorithmic change, no
shader change. Expected result on the stats scene: 10 fps → ~60 fps
(the measured ceiling once uploads are removed).
#### 3B. GPU-side culling (longer-term) - `IFC_SKIP_MDI=1` — keep everything (cull, upload, binds) but skip the
actual `glMultiDrawElementsIndirect` calls.
- `IFC_MAX_SUBDRAWS=N` — truncate each MDI's drawcount to N while still
running the rest of the frame.
Push culling itself to the GPU. A compute shader reads the Results on the profiled scene:
`InstanceCpu`-equivalent SSBO + frustum planes, builds the visible list
and indirect commands in-place via atomics. Zero CPU→GPU per-frame
bytes. Also lays the foundation for occlusion and contribution culling
(both want to run on the GPU anyway, with access to the depth buffer
or screen-space projection).
Scope: compute shader + atomic counter + BVH-traversal-on-GPU (or a | Probe | FPS | Frame time |
linear compute scan — simpler and still gains most of the win since |-------|-----|-----------|
traversal isn't the bottleneck once upload is gone). Bigger change; | baseline | 6.7 | 149 ms |
worth doing after 3A is measured, because 3A may be enough for a long | `IFC_SKIP_MDI=1` | 62.5 | 16 ms |
while. | `IFC_MAX_SUBDRAWS=30000` | 6.7 | 149 ms |
| `IFC_MAX_SUBDRAWS=10000` | 7.5 | 133 ms |
| `IFC_MAX_SUBDRAWS=1000` | 20.2 | 49 ms |
Readings:
1. `SKIP_MDI` gives 62 fps with all upload/bind machinery still running
— the non-draw path fits in ~16 ms easily. **Not upload-bound.**
2. Halving the sub-draw count (67 k → 30 k) saves 0 ms. If per-sub-draw
command-processor overhead were material, dropping 37 k sub-draws
would save measurable time no matter which sub-draws were dropped.
It doesn't. **67 k sub-draws is not the bottleneck** — the long tail
carries almost no triangles, and the heavyweights dominate.
3. Time only starts coming down once the cap is low enough to shed bulk
triangle work (1000 sub-draws → 49 ms). The curve is consistent with
a long-tailed distribution: a handful of very big meshes × instance
counts do most of the rasterisation.
**Conclusion: the GTX 1650 is rasterising 128 M triangles at ~850 M
tri/s, and that eats ~133 ms of the 149 ms frame.** No CPU-side or
upload-side work will recover it. The only way forward is to draw
fewer triangles.
### Phase 3 (revised) — Shed triangles, not bytes
In order of effort/payoff for BIM workloads:
#### 3A. Screen-space contribution culling (near-term)
Project each visible-instance AABB to screen space during BVH
traversal. Reject instances whose projected size is below a threshold
(~4 px). In BIM this is the single biggest win: at viewer zoom levels
that encompass a whole building, most MEP fittings, fixings, furniture
legs, door hardware etc. occupy < 1 px and contribute nothing.
Scope: a projection + pixel-area test inside
`ViewportWindow::cullAndUploadVisible`. Zero new GPU state. Expect
1030× reduction in drawn triangles on plant/MEP-dense scenes; full
buildings viewed in overview should approach 60 fps.
#### 3B. Distance / contribution LOD (medium-term)
Pre-simplify unique representations at ingress time (store LOD 0 / 1 /
2 meshes in the VBO/EBO with offsets), select LOD per instance per
frame by the same projected-size metric as 3A. The visible-SSBO
plumbing and MDI structure don't change — only `firstIndex`/`count` in
the indirect command does. Ingress side needs a decimation pass
(`meshoptimizer` or similar); GPU side is nearly free.
#### 3C. Hierarchical-Z occlusion culling (longer-term)
Render large occluders first, build a depth pyramid, test instance
AABBs against it. In dense BIM most geometry is behind other geometry
from any given interior viewpoint; historically a 310× reduction in
drawn instances. Most valuable *after* 3A+3B, which together handle
the far-away and small-detail cases. Pairs naturally with GPU-side
culling (a compute shader doing the HiZ test and writing the visible
list + indirect buffer in place).
#### 3D. GPU-side culling via compute (longer-term)
Push the cull loop to a compute shader reading the per-instance SSBO +
frustum planes + HiZ pyramid, emitting the visible list and indirect
commands with atomic counters. Eliminates all CPU→GPU per-frame bytes
and lets 3C scale to millions of instances. Worth doing once 3A3C
have stabilised the CPU-side algorithm we'd be porting.
### Planned follow-ups (post-Phase-3) ### Planned follow-ups (post-Phase-3)
- **Screen-space contribution cull.** Reject instances whose projected - **Mesh shaders / meshlets.** Ceiling-raising, but overkill until the
screen-space AABB is below a pixel threshold. Cheap CPU-side filter above are exhausted and we've hit silicon limits on vertex/raster
that eliminates distant MEP detail. Big win on unfiltered plant-room throughput.
scenes.
- **Hierarchical-Z occlusion culling.** Render large occluders, build a
depth pyramid, test BVH / instance AABBs against it. In dense BIM,
most geometry is behind other geometry from any given viewpoint; this
is historically a 310× reduction in drawn instances.
- **Distance / contribution LOD.** Unique meshes pre-simplified at load
time; compute shader selects an LOD per instance per frame based on
screen-space size. Same visible-SSBO plumbing, different `firstIndex`.
- **Mesh shaders / meshlets.** Ceiling-raising but overkill until the
above are exhausted.
## Summary table ## Summary table
``` ```
Scene size Bottleneck Fix Scene size Bottleneck Fix
----------- ---------- --- ----------- ---------- ---
< 100k instances CPU cull scan Phase 1 only (current) < 100k instances CPU cull scan Phase 1 only
100k500k CPU cull scan BVH (Phase 2) — done 100k500k CPU cull scan BVH (Phase 2) — done
500k+ across many models visible/indirect Phase 3A mapped rings 500k+ tris / overview shot GPU vertex + raster Phase 3A contribution cull
buffer uploads (next) (+ 3B LOD for close-ups)
--- --- --- multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion
multi-million + occlusion-heavy fragment / overdraw HiZ occlusion + LOD
``` ```
## Roadmap ## Roadmap
@@ -444,10 +491,10 @@ multi-million + occlusion-heavy fragment / overdraw HiZ occlusion + LOD
- [x] Reflection-aware two-pass draw for mirrored placements - [x] Reflection-aware two-pass draw for mirrored placements
- [x] Backface culling (user-toggleable, default on) - [x] Backface culling (user-toggleable, default on)
- [x] `reorient-shells` enabled in iterator - [x] `reorient-shells` enabled in iterator
- [ ] **Phase 3A — persistent-mapped ring buffers for visible + indirect** (next) - [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`)
- [ ] Phase 3BGPU-side compute-shader culling - [ ] **Phase 3Ascreen-space contribution culling** (next)
- [ ] Screen-space contribution culling - [ ] Phase 3B — distance / contribution LOD
- [ ] Hierarchical-Z occlusion culling - [ ] Phase 3C — Hierarchical-Z occlusion culling
- [ ] Distance-based LOD selection - [ ] Phase 3D — GPU-side compute-shader culling
- [ ] Vulkan/MoltenVK backend for macOS - [ ] Vulkan/MoltenVK backend for macOS
- [ ] Embedded Python scripting console - [ ] Embedded Python scripting console
+28 -5
View File
@@ -28,6 +28,7 @@
#include <QtOpenGL/QOpenGLVersionFunctionsFactory> #include <QtOpenGL/QOpenGLVersionFunctionsFactory>
#include <cstring> #include <cstring>
#include <cstdlib>
#include <cmath> #include <cmath>
#include <algorithm> #include <algorithm>
#include <limits> #include <limits>
@@ -988,10 +989,32 @@ void ViewportWindow::render() {
gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo); gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo);
gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.indirect_buffer); gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.indirect_buffer);
const uint32_t fwd = m.indirect_forward_count; uint32_t fwd = m.indirect_forward_count;
const uint32_t rev = m.indirect_command_count - fwd; uint32_t rev = m.indirect_command_count - fwd;
// Perf diagnostics (confirmed 2026-04 on GTX 1650 @ 128M tris:
// draw-bound, not upload-bound — see README Phase 3):
// IFC_SKIP_MDI=1 skip the actual MDI draws (keeps cull +
// upload + binds). FPS jump == draw-bound.
// IFC_MAX_SUBDRAWS=N truncate drawcount to N per MDI. Lets
// you distinguish per-subdraw command-
// processor overhead from raw tri work.
static const bool skip_mdi = []{
const char* e = std::getenv("IFC_SKIP_MDI");
return e && e[0] == '1';
}();
static const uint32_t max_subdraws = []{
const char* e = std::getenv("IFC_MAX_SUBDRAWS");
return (e && *e) ? static_cast<uint32_t>(std::atoi(e))
: std::numeric_limits<uint32_t>::max();
}();
if (max_subdraws < m.indirect_command_count) {
// Keep the fwd/rev ratio so the workload mix is preserved.
const uint32_t total = m.indirect_command_count;
fwd = static_cast<uint32_t>((uint64_t)fwd * max_subdraws / total);
rev = max_subdraws - fwd;
}
// Forward pass: non-reflected instances, standard CCW winding. // Forward pass: non-reflected instances, standard CCW winding.
if (fwd > 0) { if (fwd > 0 && !skip_mdi) {
gl_->glFrontFace(GL_CCW); gl_->glFrontFace(GL_CCW);
gl_->glMultiDrawElementsIndirect( gl_->glMultiDrawElementsIndirect(
GL_TRIANGLES, GL_UNSIGNED_INT, nullptr, GL_TRIANGLES, GL_UNSIGNED_INT, nullptr,
@@ -1000,11 +1023,11 @@ void ViewportWindow::render() {
} }
// Reverse pass: reflected instances — their world-space winding is // Reverse pass: reflected instances — their world-space winding is
// flipped, so telling GL the front is CW keeps cull-back working. // flipped, so telling GL the front is CW keeps cull-back working.
if (rev > 0) { if (rev > 0 && !skip_mdi) {
gl_->glFrontFace(GL_CW); gl_->glFrontFace(GL_CW);
gl_->glMultiDrawElementsIndirect( gl_->glMultiDrawElementsIndirect(
GL_TRIANGLES, GL_UNSIGNED_INT, GL_TRIANGLES, GL_UNSIGNED_INT,
reinterpret_cast<const void*>(fwd * sizeof(DrawElementsIndirectCommand)), reinterpret_cast<const void*>(m.indirect_forward_count * sizeof(DrawElementsIndirectCommand)),
static_cast<GLsizei>(rev), 0); static_cast<GLsizei>(rev), 0);
++gl_draw_calls_; ++gl_draw_calls_;
gl_->glFrontFace(GL_CCW); gl_->glFrontFace(GL_CCW);