Adaptive decimation by default; fork the pre-3.2 uniform decimator into decimate-uniform/ - #296
Merged
Conversation
Contributor
There was a problem hiding this comment.
🟡 Human review recommended
It introduces a large, high-impact decimation pipeline (including new GPU and multi-block logic) and also contains at least one confirmed CLI behavior bug that should be fixed before merging.
Pull request overview
This PR introduces a new adaptive decimation path as the default (--decimate) while preserving the pre-3.2 decimator as a frozen, reproducible baseline under --decimate-uniform (src/lib/decimate-uniform/). It also refactors supporting infrastructure (KD-tree flattening, KNN querying, merge streaming) and adds extensive test coverage for parity, GPU re-costing, and multi-block behavior.
Changes:
- Add adaptive decimation building blocks: field‑L2 cost, re-costed greedy selection, forest KNN, block-local planning + global prefix allocation, and zero-copy merge streaming into chunk buffers.
- Fork the legacy decimator into
src/lib/decimate-uniform/and add CLI + library entry points to select it (--decimate-uniform,decimateSourceUniform), with parity tripwires. - Extend/adjust shared infrastructure: introduce
buildFlatKdTree, add shared WGSL chunk(s), and factor GPU compute boilerplate into a reusable helper.
File summaries
| File | Description |
|---|---|
| test/kd-tree.test.mjs | Updates KD-tree flattening test coverage to the new buildFlatKdTree layout + adds split-plane correctness test. |
| test/gpu-recost.test.mjs | New acceptance tests for GPU re-cost refresh parity and selection equality. |
| test/decimate.test.mjs | Updates degenerate/coincident-scene behavior expectations for the new re-costed selection path. |
| test/decimate-uniform-parity.test.mjs | New parity tripwires for --decimate-uniform (exact kNN set + pinned digest + GPU/CPU agreement). |
| test/decimate-source.test.mjs | Replaces legacy-statistics parity check with in-domain/finite aggregate invariants. |
| test/decimate-select.test.mjs | Updates selection tests to match cost-ordered agglomeration semantics and MAX_GROUP. |
| test/decimate-recost.test.mjs | New tests for stateless re-costed eval properties + selectMergesRecosted invariants. |
| test/decimate-priority.test.mjs | Updates priority-pass reference to new CPU edge-cost cache and adds persist-only mode test. |
| test/decimate-partition.test.mjs | Adds tests for deterministic split jitter and halo building behavior; validates residual marking. |
| test/decimate-multiblock.test.mjs | New multi-block adaptive-path tests (GPU requirement + scratch-plan cleanup). |
| test/decimate-merge-stream.test.mjs | Updates tests to the new “fill destination buffers” streaming protocol and new selection semantics. |
| test/decimate-knn.test.mjs | Reworks KNN tests to validate exact forest KNN and canonical neighbor ordering. |
| test/decimate-edge-cost.test.mjs | New unit tests for field‑L2 edge-cost properties (coincident ~0; size scaling). |
| test/decimate-block-plan.test.mjs | New tests for block planning, prefix allocation, replay, and scratch cleanup behavior. |
| test/cli.test.mjs | Adds CLI coverage for --decimate-uniform. |
| src/lib/workers/tasks.ts | Adds worker tasks for forest part building + forest KNN queries; updates KD flatten handler to new flat layout. |
| src/lib/spatial/kd-tree.ts | Introduces buildFlatKdTree and updates FlatKdTree layout (positions/children interleaving). |
| src/lib/index.ts | Exposes decimateSourceUniform and associated types in the public library entry. |
| src/lib/gpu/shaders/chunks/gaussian-l2.ts | New shared WGSL chunk for field‑L2 cost components used by GPU kernels. |
| src/lib/gpu/index.ts | Removes exported EdgeCostCache type from the GPU barrel export. |
| src/lib/gpu/gpu-kmeans.ts | Refactors compute boilerplate to use new shared makeKernel helper. |
| src/lib/gpu/compute-kernel.ts | New helper module for consistent WebGPU compute kernel setup (shader + bind groups). |
| src/lib/decimate/select.ts | Replaces legacy matching/closure selection with cost-ordered agglomeration and exports MAX_GROUP. |
| src/lib/decimate/select-recost.ts | New re-costed greedy selection implementation with optional GPU refresh waves (GpuRecost). |
| src/lib/decimate/partition.ts | Extends partitioning with deterministic jitter, residual marking, shared fence, and halo builder. |
| src/lib/decimate/merge-stream.ts | Changes merge streaming to fill consumer-provided destination buffers (enabling zero-copy output). |
| src/lib/decimate/knn-core.ts | Replaces block KNN with exact forest KNN query over flat KD parts. |
| src/lib/decimate/block-producer.ts | Updates block-producer source to drive the new destination-buffer streaming protocol. |
| src/lib/decimate/block-prepare.ts | New GPU block preparation step (core+halo gather + GPU KNN + canonical ordering). |
| src/lib/decimate/block-merge-stream.ts | New stream that replays selected block plan prefixes and emits block-local output into dest buffers. |
| src/lib/decimate/block-allocation.ts | New scratch-plan persistence + global heap allocation of plan prefixes across blocks. |
| src/lib/decimate-uniform/select.ts | New frozen copy of pre-3.2 selection implementation for --decimate-uniform. |
| src/lib/decimate-uniform/README.md | Documents the frozen baseline contract and allowed deviations for the uniform fork. |
| src/lib/decimate-uniform/partition.ts | New frozen copy of pre-3.2 partitioning implementation. |
| src/lib/decimate-uniform/merge-stream.ts | New frozen copy of pre-3.2 merge stream (payload-based, rolling scratch buffers). |
| src/lib/decimate-uniform/knn-core.ts | New frozen copy of pre-3.2 CPU block KNN querying. |
| src/lib/decimate-uniform/index.ts | Exposes decimateSourceUniform and its renamed option/spill types. |
| src/lib/decimate-uniform/edge-cost-cpu.ts | New frozen CPU edge-cost implementation for uniform path parity and CPU fallback. |
| src/lib/decimate-uniform/decimate-source.ts | New frozen orchestrator for uniform decimation path, including spill handling and device usage. |
| src/lib/decimate-uniform/block-producer.ts | New frozen payload-based block producer source for the uniform path. |
| src/cli/index.ts | Adds --decimate-uniform flag, decimator selection, and derives an internal memory budget ceiling from system RAM. |
| README.md | Updates user-facing CLI docs to explain adaptive vs uniform decimation. |
Review details
- Files reviewed: 53/54 changed files
- Comments generated: 1
- Review effort level: Low
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ships a new decimator and keeps the existing one intact beside it. The two are named for how they allocate removal, not as a ranking:
--decimate(adaptive) allocates by local error,--decimate-uniformremoves at a uniform rate everywhere. Both are supported and they win on different content, so the choice is the user's.Adaptive decimation (
--decimate, the default)The cost function is the principled L2 field error — the squared L2 norm between the emitted field of two original Gaussians and that of their moment-matched merge, in closed form over Gaussian–Gaussian products, plus a scale-free DC colour term. Unlike the scale-invariant KL cost it replaces, every term scales with Gaussian volume, so merging large distant Gaussians costs far more than a geometrically similar tiny merge and coarse structure survives deeper into the cascade. On
fr-skythis is worth roughly +9.6 to +11.6 dB over the uniform path at every level.Selection is re-costed rather than one-shot.
selectMergesconsumed the priority pass's pairwise costs in a single ordered walk, so costs went stale as groups formed;selectMergesRecostedre-evaluates a cluster's best edge after it changes, in commit/refresh waves, so every merge executes at a cost validated against current state. The evaluation kernel (recost-core.ts) is stateless over members — a cluster's moments are recomputed from its ≤MAX_GROUPimmutable cache rows on every evaluation, so no per-root float state is stored and a commit is pure integer structure updates.GpuRecostruns the refresh rounds on WebGPU (the CPU keeps the heap and commit decisions; the GPU replays the commit log against its own structure copy and bulk-evaluates queued roots), with an inline f64 path when there is no device or the buffers exceed the adapter's binding limits.Scenes too large for one resident block go through block-local planning instead of whole-scene selection:
block-prepare.tsgathers one core-plus-halo view and runs exact KNN over it on the GPU; the candidate rows are then fixed for the plan's lifetime.block-plan.tsruns the re-costed wave loop against that local view and emits an ordered plan of merge pairs plus marginal costs in commit order.block-allocation.tsspills each plan to scratch and allocates prefixes across blocks by a global merge-heap over plan costs, so the merges actually executed are globally cost-ordered even though planning was block-local.block-merge-stream.tsreplays the selected prefixes and emits one core block at a time, keeping selection arrays, gathered fields and moment-matching inputs block-local.Residency is policy-driven. A private ceiling of
min(48 GiB, half of system RAM)— not an allocation, and not exposed on the CLI — steers the core block size, the candidate K, and a per-generation gate that falls back to one-shot selection when the re-costed state would not fit alongside the base state, so a large scene regains re-costing as soon as the cascade shrinks under it. Multi-block adaptive decimation requires WebGPU and scratch storage and fails fast with an actionable message when either is missing. Spatially incoherent multi-block input is staged once to a KD-ordered PLY rather than paying scattered gathers every generation.Two operational notes for this path specifically: it writes per-block merge plans to
--scratch-dir(12 B per planned merge, per generation, removed as each generation is consumed) and, for incoherent input, one full staged copy of the scene — neither of which the previous decimator wrote. Its intermediate-generation RAM-vs-spill threshold also moved with the residency ceiling.The uniform decimator, forked whole (
--decimate-uniform)src/lib/decimate-uniform/is the 3.1.x decimator, copied file-for-file. It is kept because it wins: lower memory, and measurably better at depth on scenes of uniformly-sized Gaussians — it leads by up to ~1 dB at L3–L6 on both snow scenes inscenes/DECIMATION-RESULTS.md. It is also the reference baseline every comparison in that document is measured against, which is why bit-for-bit reproducibility matters.The fork imports nothing from
src/lib/decimate/exceptmoment-match.ts(which has no diff against 3.1.x and whose worker handler is shared) andspatial/kd-tree.ts(build and query paths unchanged, shared with k-means). Work on the adaptive path therefore cannot change uniform output. Each file is provable in one command —git diff main:src/lib/decimate/select.ts src/lib/decimate-uniform/select.tsis empty — and eight of eleven have zero non-import changes. The two deviations are documented in the directory README: repointed import paths, andgpu-knn.tsconsuming the current interleavedFlatKdTreeinstead of packing that same layout itself (buildFlatKdTreeis verified structurally identical to the 3.1.xKdTree.flatten()at every size, so the uploaded bytes are unchanged).Output equivalence is verified end to end against the installed 3.1.6 binary, not argued from inspection:
fr-sky(5.81M, 3 SH bands, multi-block) andfr-snow(26.1M, DC only, 13 blocks), six chained halvings each, every level byte-identical, with PSNR reproducing the publishedoldcolumns exactly. Wall clock matches too (41.6s vs 42.0s on sky, 123.4s vs 124.7s on snow).This is why the fork exists rather than sharing infrastructure. Sharing the new KNN and partition code with the uniform path cost 30% on
fr-skyand 65% onfr-snow, and produced different bytes — the global forest KNN is exact but orders equidistant neighbours differently, the partition's outlier-fence gate had changed for single-block scenes, and the block-size clamp was using the adaptive path's cache stride, which can select a different block size on devices where the binding limit bites.Shared infrastructure
knnForestQuerycarries top-K state across parts and culls a part outright when its AABB cannot improve the carried worst, making the result exact over the union with no halos and no verification pass. Parts build in parallel on the worker pool into SharedArrayBuffers so query tasks read them without copies, andGpuKnncompiles one kernel per part with the root index and AABB baked in as constants.kd-tree.tsgainsbuildFlatKdTree, which builds straight into the flat GPU layout with no intermediate pointer-node graph, and the layout is compacted tonodePositions/nodeChildrenwith denormalised positions so a tree walk does one read per visit instead of two.KdTree's own build and query paths are untouched.