Skip to content

Re-land separable pyramid compose (recover #153 perf) - #228

Merged
Alek99 merged 1 commit into
mainfrom
claude/pyramid-compose-separable
Jul 23, 2026
Merged

Re-land separable pyramid compose (recover #153 perf)#228
Alek99 merged 1 commit into
mainfrom
claude/pyramid-compose-separable

Conversation

@adhami3310

Copy link
Copy Markdown
Member

Re-land the separable pyramid compose (perf recovery)

Follow-up to #217. That PR shipped the #153 banding fix (area-weighted compose), but the separable perf-recovery commit was dropped before merge, so main currently runs the one-pass 2D-scatter form — 4 scattered read-modify-writes per source cell.

This restores the separable implementation (Alek's original commit, authorship preserved): resample each source row into a w-wide scratch row via pull-based per-bin taps transposed from axis_weights, then distribute that row into its 1–2 output rows with contiguous, vectorizable scale-and-add loops.

Why it's a strict win

  • Same result. Pure reindexing of the same weights, same per-axis summation order → the cell-aligned bit-exactness contract against bin_2d holds. All 10 tiles tests pass unchanged; the vertical "banding" when zooming in through LoD chart #153 banding stays fixed.

  • ~2.3× faster wall-clock. On the test_pyramid_compose shape (2.1M points, 512×384 from the 1024 level):

    version wall-clock
    pre-vertical "banding" when zooming in through LoD chart #153 nearest baseline 0.919 ms
    one-pass 2D scatter (current main) 2.22 ms
    separable (this PR) 0.968 ms

    Back to baseline parity.

  • CodSpeed: this compares against current main (which already has the one-pass area-weighting), and the separable form executes fewer instructions than the one-pass 2D scatter — so it should read as an improvement rather than the regression seen in Area-weight pyramid compose to remove interim zoom banding (#153) #217 (where the base was pre-area-weighting main).

No API/ABI change; compose signature and all callers are untouched.

The one-pass area-weighted downsample cost 4 scattered read-modify-writes
per source cell (vs 1 for the old center-only assignment), regressing
test_pyramid_compose ~40% on CodSpeed. Resample separably instead: pull
each source row into a w-wide scratch row via per-bin taps transposed
from axis_weights (independent bins, no store-forwarding chains), then
distribute the scratch row into its 1-2 output rows with contiguous
scale-and-add loops the compiler vectorizes.

Same weights, same summation order along each axis: the cell-aligned
bit-exactness contract against bin_2d holds unchanged (all weights are
exactly 1.0 there), and all tiles tests pass as-is. Local walltime for
the CodSpeed shape (2.1M points, 512x384 from the 1024 level): 0.63ms
pre-#153 baseline, 0.91ms one-pass area weighting, 0.53ms separable -
faster than the baseline it regressed from.
@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR restores the separable pyramid composition path for area-weighted resampling. The main changes are:

  • Transposes horizontal scatter weights into ordered per-bin taps.
  • Accumulates each source row into a reusable scratch row.
  • Distributes the scratch row through contiguous vertical loops.
  • Preserves the existing compose API and test expectations.

Confidence Score: 5/5

This looks safe to merge.

No blocking issues found in the changed code.

T-Rex T-Rex Logs

What T-Rex did

  • The test suite was executed and exited with code 0, reporting 11 tests passed, 0 failed, 0 ignored, and 95 filtered out.
  • The proof confirms the transcript captures the exact command, working directory, exit status, and named test results for the run.
  • A test run log artifact was prepared to enable reviewers to inspect the run details.

View all artifacts

T-Rex Ran code and verified through T-Rex

Important Files Changed

Filename Overview
src/tiles.rs Replaces two-dimensional scattering with horizontal tap gathering and contiguous vertical distribution, adds the tap-transpose helper, and reformats existing tests.

Reviews (3): Last reviewed commit: "Make area-weighted compose separable to ..." | Re-trigger Greptile

@codspeed-hq

codspeed-hq Bot commented Jul 23, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 10.5%

❌ 1 regressed benchmark
✅ 101 untouched benchmarks
⏩ 1 skipped benchmark1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_pyramid_compose 9.8 ms 10.9 ms -10.5%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/pyramid-compose-separable (1947528) with main (aa0e602)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

@adhami3310
adhami3310 force-pushed the claude/pyramid-compose-separable branch from 4a82659 to 1947528 Compare July 23, 2026 16:42
@adhami3310

Copy link
Copy Markdown
Member Author

CodSpeed status: recommend acknowledge — the metric can't see this optimization

Short version: this PR is 2.4× faster in real wall-clock but CodSpeed (simulation/cachegrind mode) scores it ~10% slower, because the win is a CPU-pipeline effect a cachegrind cost model has no concept of.

Measured on the test_pyramid_compose shape (2.1M pts, 512×384 from the 1024 level):

version CodSpeed simulated real wall-clock (local)
pre-#153 nearest 5.9 ms ~0.63 ms
one-pass 2D scatter (current main) 9.8 ms 2.22 ms
separable (this PR) 10.9 ms 0.91 ms

Why the metric is inverted here. The one-pass form does out[...] += … where adjacent source cells land on the same output bin ~40% of the time (source→output ratio ≈1.6), creating store-to-load-forwarding dependency chains that serialize on real silicon. The separable pull sums each bin independently in a register, so it pipelines — hence 2.4× faster wall-clock. Cachegrind models instructions + cache but not store-forwarding latency, so it can't see the stall being removed; it only sees the pull structure's slightly higher instruction count and scores it worse.

I tried to make it cheap on both metrics (fused the y-distribute into the pull loop to drop the scratch-row buffer round-trip) — it didn't move the simulated number (stayed 10.9 ms) and was marginally slower in wall-clock, so I reverted it. The conclusion: area-weighting's simulated cost is irreducible relative to the one-pass; no code arrangement wins on a metric blind to the actual bottleneck.

Note: main already carries the one-pass area-weighting (#217), i.e. the 5.9→9.8 ms simulated jump was already accepted. This PR trades a further +11% simulated for a 2.4× real speedup.

Recommendation: acknowledge this benchmark on CodSpeed. Longer term, test_pyramid_compose is a candidate to move to CodSpeed walltime mode (the repo already segregates wall-clock-sensitive benches out of simulation) so it rewards the real optimization — happy to open that as a follow-up if we want it.

@Alek99
Alek99 merged commit dcf3781 into main Jul 23, 2026
84 of 86 checks passed
masenf added a commit that referenced this pull request Jul 23, 2026
…y-mean-color

Both sides added a function after axis_weights: #228's transpose_to_taps
(the separable compose's x-pass taps) and this branch's compose_color.
Keep both. compose_color is unaffected by the count pass going separable:
its count grid comes from compose() itself (bit-identical by delegation),
and its color planes weight by the same axis_weights either way; it keeps
the one-pass 2D splat, noted in its doc comment.
masenf added a commit that referenced this pull request Jul 24, 2026
Most conflicts were squash artifacts: main's #226 is the same mean-color
density work this branch already carries as commits (main's conflicted
spec/client/kernel/test files are byte-identical to this branch's
pre-work state), so those resolve to this branch's extended versions.
Real incoming changes kept from main:

- src/tiles.rs: separable area-weighted compose (#228) — taken wholesale
  (this branch made no tiles.rs changes beyond the shared #226 content);
  kernels.rs/lib.rs wasm panic-abort hardening (#200) auto-merged.
- README.md: the #230 adoption refresh replaced the inline benchmark
  table (this branch's only README edit) with links to the launch
  report, so main's version stands; the 0.1.0 launch-baseline report
  keeps its historical "density + sample" labels — it records what that
  release did.

Verified on the merged tree: cargo tests, ABI smoke, render smoke, and
the full pytest+benchmarks suite — the only failures are 21 doc-contract
tests (test_docs_examples/test_verify_local/pyplot perf guardrail) that
fail identically on pristine origin/main in this environment: the #230
README rewrite removed maintainer-shortcut text its guardrail tests
still assert. Pre-existing on main, not introduced here.
Alek99 added a commit that referenced this pull request Jul 24, 2026
#228 made the area-weighted downsample separable, which cut wall-clock
~1.7x but left CodSpeed's simulation gate red: the pull pass indexed a CSR
prefix per output bin, so every one of the w bins re-derived both slice
ends and paid a range check before summing its 1-3 taps. That cost more
instructions than the one-pass scatter it replaced (+24% locally under
callgrind), which is what the gate measures.

Bins own consecutive runs of the tap array, so hand the resample a cursor
instead: `transpose_to_taps` now returns per-bin counts (it already built
them before the prefix sum) and the loop splits `n` taps off the front per
bin. Same taps, same order, same weights — pure bookkeeping.

Measured on the benchmark's shape (2.1M points, 512x384 from the 1024
level), interleaved min-of-9 vs a build of the previous implementation:

  instructions (callgrind)  22.17M -> 17.97M   -19.0%
  wall-clock (arm64 mac)    0.552ms -> 0.420ms -23.9%

That puts instructions back at the pre-#228 count (17.88M) while keeping
the wall-clock win, so the gate and the renderer agree again.

Output is bit-identical: composed grids fingerprint the same across the
benchmark window, the full domain, a cell-aligned subwindow, an offset
unaligned window, and an upsample refusal. The cell-aligned bit-exactness
contract against bin_2d is untouched.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants