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
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
moving draw submission to the GPU via compute. In the meantime, GPU
instancing and MDI made the CPU-side draw cost essentially free (10
`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 |
|--------|-------|
| FPS | 10 |
| Frame time | ~100 ms |
| FPS | 6.7 |
| Frame time | 149 ms |
| gl_draws | 10 |
| 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 |
|-------|--------|----------------|
| 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.** |
#### False lead: "the per-frame uploads are the bottleneck"
So the bottleneck is two `glNamedBufferSubData` calls per model per
frame uploading ~1.5 MB (visible list) + ~1.3 MB (indirect buffer).
3 MB/frame / 60 fps = 180 MB/s — trivial for the bus, but `glNamedBufferSubData`
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.
The first round of probes pointed at the two `glNamedBufferSubData`
calls per model per frame (visible list ~1.5 MB + indirect buffer
~1.3 MB):
### 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
`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.
#### What actually isolates the draw cost
Scope: ~80 lines across `ModelGpuData` + `cullAndUploadVisible` +
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).
Two diagnostic env vars now live in `render()`:
#### 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
`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).
Results on the profiled scene:
Scope: compute shader + atomic counter + BVH-traversal-on-GPU (or a
linear compute scan — simpler and still gains most of the win since
traversal isn't the bottleneck once upload is gone). Bigger change;
worth doing after 3A is measured, because 3A may be enough for a long
while.
| Probe | FPS | Frame time |
|-------|-----|-----------|
| baseline | 6.7 | 149 ms |
| `IFC_SKIP_MDI=1` | 62.5 | 16 ms |
| `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)
- **Screen-space contribution cull.** Reject instances whose projected
screen-space AABB is below a pixel threshold. Cheap CPU-side filter
that eliminates distant MEP detail. Big win on unfiltered plant-room
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.
- **Mesh shaders / meshlets.** Ceiling-raising, but overkill until the
above are exhausted and we've hit silicon limits on vertex/raster
throughput.
## Summary table
```
Scene size Bottleneck Fix
----------- ---------- ---
< 100k instances CPU cull scan Phase 1 only (current)
100k500k CPU cull scan BVH (Phase 2) — done
500k+ across many models visible/indirect Phase 3A mapped rings
buffer uploads (next)
--- --- ---
multi-million + occlusion-heavy fragment / overdraw HiZ occlusion + LOD
Scene size Bottleneck Fix
----------- ---------- ---
< 100k instances CPU cull scan Phase 1 only
100k500k CPU cull scan BVH (Phase 2) — done
500k+ tris / overview shot GPU vertex + raster Phase 3A contribution cull
(+ 3B LOD for close-ups)
multi-million + occluders redundant rasterisation Phase 3C HiZ occlusion
```
## Roadmap
@@ -444,10 +491,10 @@ multi-million + occlusion-heavy fragment / overdraw HiZ occlusion + LOD
- [x] Reflection-aware two-pass draw for mirrored placements
- [x] Backface culling (user-toggleable, default on)
- [x] `reorient-shells` enabled in iterator
- [ ] **Phase 3A — persistent-mapped ring buffers for visible + indirect** (next)
- [ ] Phase 3BGPU-side compute-shader culling
- [ ] Screen-space contribution culling
- [ ] Hierarchical-Z occlusion culling
- [ ] Distance-based LOD selection
- [x] Perf diagnostic env vars (`IFC_SKIP_MDI`, `IFC_MAX_SUBDRAWS`)
- [ ] **Phase 3Ascreen-space contribution culling** (next)
- [ ] Phase 3B — distance / contribution LOD
- [ ] Phase 3C — Hierarchical-Z occlusion culling
- [ ] Phase 3D — GPU-side compute-shader culling
- [ ] Vulkan/MoltenVK backend for macOS
- [ ] Embedded Python scripting console
+28 -5
View File
@@ -28,6 +28,7 @@
#include <QtOpenGL/QOpenGLVersionFunctionsFactory>
#include <cstring>
#include <cstdlib>
#include <cmath>
#include <algorithm>
#include <limits>
@@ -988,10 +989,32 @@ void ViewportWindow::render() {
gl_->glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, m.visible_ssbo);
gl_->glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m.indirect_buffer);
const uint32_t fwd = m.indirect_forward_count;
const uint32_t rev = m.indirect_command_count - fwd;
uint32_t fwd = m.indirect_forward_count;
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.
if (fwd > 0) {
if (fwd > 0 && !skip_mdi) {
gl_->glFrontFace(GL_CCW);
gl_->glMultiDrawElementsIndirect(
GL_TRIANGLES, GL_UNSIGNED_INT, nullptr,
@@ -1000,11 +1023,11 @@ void ViewportWindow::render() {
}
// Reverse pass: reflected instances — their world-space winding is
// 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_->glMultiDrawElementsIndirect(
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);
++gl_draw_calls_;
gl_->glFrontFace(GL_CCW);