C++/CUDA bench drivers for the head-to-head decode comparison reported in the STRIDE paper §5.3. Three benchmarks live here:
| Source | Output | Used in |
|---|---|---|
stride_dup_decode_bench.cu |
stride_dup_decode_bench |
STRIDE GPU decode throughput (Table 6). |
dgf_decode_bench.cu |
dgf_decode_bench |
DGF GPU decode throughput (Table 6). |
meshopt_shim.cpp |
meshopt_shim.{dll,so} |
meshopt CPU decode timings (Table 6). |
stride_decode_bench.cu |
stride_decode_bench |
Earlier (non-dup) STRIDE variant; kept for reproducibility. |
- CUDA Toolkit 12.x with
nvcconPATH. - A host compiler compatible with your CUDA toolkit:
- Windows: MSVC 2019 or 2022 (
cl.exe) via Visual Studio Build Tools. - Linux: gcc ≥ 11 or clang ≥ 14.
- Windows: MSVC 2019 or 2022 (
- The two third-party submodules populated:
git submodule update --init --recursive cmake -B third_party/DGF-SDK/build -S third_party/DGF-SDK && \ cmake --build third_party/DGF-SDK/build --config Release cmake -B third_party/corto/build -S third_party/corto && \ cmake --build third_party/corto/build --config Release
cd bench_cpp
build_dgf_bench.bat
build_meshopt_shim.batstride_dup_decode_bench and stride_decode_bench build alongside DGF.
cd bench_cpp
./build.shThe script invokes nvcc directly for the CUDA binaries and g++ for the
meshopt shim. Adjust CUDA_HOME and CXX at the top of build.sh if your
toolchain lives elsewhere.
Each benchmark accepts a list of pre-encoded blobs as input. The Python
drivers (scripts/bench_stride_decode_sweep.py, scripts/bench_dgf_decode_sweep.py)
encode the eight-mesh corpus first, dump the blobs under bench_cpp/blobs/,
and then invoke the native binaries. See the per-script docstring for the
exact CLI.
python scripts/bench_stride_decode_sweep.py
# emits bench_stride_decode_sweep_q12bbox.csv at the repo root
python scripts/bench_dgf_decode_sweep.py
# emits bench_dgf_decode_sweep.csvOne 64-thread block (two warps) decodes one meshlet. For v4_optimized_wind
blobs (version 17, LAYOUT 2) warp 0 decodes the bit-plane connectivity
(restart / ec / isFirst planes, one lane per triangle, plus the Rice distance
stream) while warp 1 reads the residual header and decodes the six bit-plane
residual streams; the two warps join, then all 64 threads pack the residuals
into walk order, thread 0 runs the predictor chain, and all 64 threads emit
vertices and triangles. Bit-plane streams are decoded symbol-parallel: each
lane emits one symbol, locating its terminator bit via a popcount scan of the
plane words and __fns. Blob versions 4 and 16 (LAYOUT 0/1) decode serial
connectivity and their residuals on warp 0.
- Packed predictor: when every axis uses the canonical parallelogram
(
pred_n = (1,1,-1),pred_K = 0) andg_bits <= 13, codes and residuals are held as biased 16-bit fields of oneu64and the prediction is one branch-free 64-bit expression on thread 0. When every axis is affine (n0 + n1 + n2 = 2^K,K <= 14) the chain runs planar instead: lanes 0..2 of warp 0 each own one axis as an i32 plane whose residual slot holds(r << K) + h, so a step is three IMADs and a shift ((n0 a + n1 b + n2 c + (r << K) + h) >> K), refs are extracted with one PRMT each. Otherwise the generic predictor runs. The exe printspacked predictor: canonical|affine|off. - Static schedule: when the launch grid covers every selected meshlet,
block
idecodes meshletiand the work-steal counter is skipped. - Build knobs (timing / A/B only):
-DPLANE_WORDPAR=1selects the older word-parallel plane decoder;-DSTAGE_SMEM=1copies each meshlet's bytes into shared memory before decoding (used only when every meshlet fits 2048 B; slower on large meshes, so off by default);-DSKIP_CONN/RESID/PRED=1compile a phase out forscripts/bench_phase_split.py(garbage output). Predictor-chain knobs (defaults in parentheses):-DPLANAR_PRED=0|1|2(1: affine planar, canonical packed on thread 0; 2: canonical planar too);-DPLANE32=0|1(1: i32 planes with pre-shifted residuals, aliased on the codes/residual pool so shared memory does not grow; 0: legacy i16 planes anda + ((n1 (b-a) + n2 (c-a) + h) >> K));-DREF_PRMT=0|1(1: one PRMT per ref; 0: shift + mask + IMAD);-DADDR_TABLE=0|1(1: pack-pass u16 index table for the refs, +4.5 KB shared memory, measured 1.4x slower from the occupancy loss; 0);-DPRED_UNROLL=n(4).
Golden parity gate — every kernel change must keep decoder output byte-identical:
python scripts/verify_kernel_golden.py [--exe bench_cpp/<exe>]
It dumps every version 20 blob in blobs/stride/ and compares SHA-256
hashes with bench_cpp/golden_hashes.json (the stale dragon / dragon_metis
blobs are excluded; the pre-optimisation kernel already faults on them).
Re-record with --record only after an intentional output change or after
re-dumping blobs. Time two builds against each other with
python scripts/bench_kernel_ab.py --a <old.exe> --b <new.exe> --out <csv>:
the exes alternate over --rounds (default 30) process invocations of
20 warmup + 100 timed launches each, and the CSV reports the min, median and
min-of-min per-launch times with their ratios. Only ratios from one
interleaved run are comparable; separate runs drift ~15 % with GPU clock state.
shaders/dgf_bench_decode.hlsl includes the SDK's unmodified
samples/SampleFramework/shaders/DGFDecompression.hlsl (v1.2.0) and dispatches
it the way DGFBVHBuildDecode.hlsl does: 64 threads per 128-byte block,
DGFGetTriangle_BitScan_Wave + DGFGetVertex. It differs from the SDK's
shader in four ways:
- Vertex base: read from a prefix table (SRV t1) instead of the optional
userDataheader field. - Block id: taken from a selection list (SRV t2), so random subsets of blocks can be decoded.
- Dispatch shape: 2D grid with a
GroupsXroot/push constant. D3D12 allows at most 65535 groups per dimension, and tank/dragon have more blocks than that. The selection list is padded with the last id (≤ 2 duplicate blocks). - Mask fix:
GetTriangleBitsWaveBallotbuilds its backtrack mask as(1 << numTriangles) - 1. That is 0 for a 64-triangle block, because HLSL shift counts are taken mod 64. The wrapper's copy of that one function uses~0 >> (64 - numTriangles). Without the fix, 64-triangle blocks that contain a backtrack decode to wrong triangles (e.g. 1,153 on Monkey).
Build (needs VS 2022, Windows SDK, %VULKAN_SDK%; uses the SDK's own DXC):
bench_cpp\build_dgf_hlsl_bench.bat
Run (same CLI and CSV line as dgf_decode_bench.exe):
bench_cpp\dgf_d3d12_decode_bench.exe bench_cpp\blobs\dgf\xyzrgb_dragon.dgfblob 20 100 --repeats 3
bench_cpp\dgf_vulkan_decode_bench.exe bench_cpp\blobs\dgf\xyzrgb_dragon.dgfblob 20 100 --repeats 3
The shader binaries (dgf_bench_decode.dxil/.rs/.spv) must sit next to the exes.
Validate all three DGF decoders (CUDA port, D3D12, Vulkan) against DGFLib:
python scripts/verify_dgf_reference.py.
build_r1_harness.bat builds into r1_build\ (the bench_cpp\*.exe behind
committed results are left alone): the STRIDE CUDA harness, the DGF D3D12
harness, the full-decode shader, a compact-output shader
(dgf_bench_decode_compact.dxil, -D DGF_COMPACT=1) and
r1_spatial_query_test.exe. The Vulkan harness is not changed.
Both harnesses accept (after the positional warmup/runs):
| Flag | Meaning |
|---|---|
--units <ids.u32> |
little-endian u32 unit ids (STRIDE meshlets / DGF blocks), strictly increasing; an empty file is an empty selection (no kernel launch or dispatch). Duplicates, unsorted or out-of-range ids exit non-zero. |
--units-batch <file> |
records [u32 count][count x u32 ids]; one process runs every selection (warmup, then runs x repeats launches each). |
--dump-compact <prefix> |
per selection k: <prefix>-<k>.units.bin (u32 x 5 per unit: unit_id, vert_offset, vert_count, tri_offset, tri_count), .verts.bin (f32 xyz), .tris.bin (u32 x 3, indices into the compact vertex array: unit vert_offset + local index). |
--launch-csv <path> |
one row per timed launch: selection,repeat,launch,units,verts,tris,empty,submit_us,decode_us,readback_us,total_us,gather_us,upload_us,upload_bytes. |
--streamed |
the compressed file stays in host memory: each launch copies the selected units' bytes (STRIDE: plus their rebased table entries, 40 B each; DGF: the 128-byte blocks) into one pinned/upload buffer, uploads it with one copy and decodes it with the unchanged kernel/shader. Also applies to --query-boxes. Output is byte-identical to the resident path. |
Every launch is one complete query: upload the selection and its compact
offset tables, decode, copy the compact outputs to host memory, synchronize
once. decode_us and readback_us are GPU timers around the kernel/dispatch
and the device-to-host copy, upload_us around the host-to-device copy of the
launch's upload (upload_bytes; built on the host in gather_us); submit_us and total_us are host timers from
launch start to "all work queued" and to "outputs readable in host memory".
Output buffers are sized to the largest selection of the process; nothing is
cleared or read back beyond the selected units. The STRIDE kernel reads compact
offsets by selection slot through a COMPACT template flag; the DGF compact
shader reads (vertex base, triangle base) per selection slot. Without these
flags both harnesses run the unchanged full-decode path.
r1_spatial_query.h (header-only, no GPU dependencies) loads .stride.bvh / .dgf.bvh
sidecars with the Python validation, selects units for a closed box, and filters
triangles with the float SAT plus exact integer fallback of scripts/r1_spatial.py.
scripts/r1_partial_validate.py checks compact outputs against the full decode
and the CPU reference, unit-list rejection, and the header against the Python
queries (tests/test_r1_partial.py runs it on fandisk).
stride_dup_decode_bench.exe and dgf_d3d12_decode_bench.exe (after the positional
warmup/runs) and r1_competitor_query_bench.exe query <draco|corto|meshopt> <file> run the
402-box manifest of scripts/r1_spatial_prep.py end to end:
| Flag | Meaning |
|---|---|
--query-boxes <boxes.f64> |
6 x f64 per box in manifest order: 4 per surface position (0.001, 0.01, 0.1, 0.5 of the bbox volume), full-bounds, empty |
--sidecar <bvh> |
.stride.bvh / .dgf.bvh bound to the blob (GPU harnesses; required) |
--query-mode |
independent, growing, warm or all (default) |
--query-warmup / --query-launches |
per box-size cell, default 100 / 300 (round-robin over the cell's boxes) |
--query-sequence-repeats |
growing-mode sequence repeats, default 3 (cache cleared before each) |
--query-csv <path> |
one row per timed launch: mode,query,cell,repeat,launch,selected_units,units_decoded,cache_hits,bytes_accessed,tris_decoded,tris_examined,tris_returned,cache_bytes,lookup_us,submit_us,decode_us,readback_us,cache_us,filter_us,total_us,gather_us,upload_us,upload_bytes |
--query-answers <path> |
per mode, per box: selected unit ids and returned triangle ids of the first timed launch |
total_us is the host clock from before the sidecar/BVH lookup to after the exact
triangle/box filter; it includes the compact upload, decode, readback, synchronization and
cache insertion. GPU timers (decode_us, readback_us) are resolved outside the rows.
The competitor harness treats the whole mesh as one unit: independent decodes the file on
every launch and scans every triangle; growing/warm retain the decoded mesh plus a
triangle BVH (r1_triangle_bvh.h), whose build is charged to cache_us of the first
query. Each run ends with one r1_query_summary key=value ... stdout line.
Compiled binaries, the blobs/ cache, dump_out/ and golden/ are excluded from the
repository via the top-level .gitignore. Re-running the build scripts
regenerates them.