The perf ladder: seven changes, 2.75x, bit-identical throughout - #65
The perf ladder: seven changes, 2.75x, bit-identical throughout#65asinghvi17 wants to merge 114 commits into
Conversation
…tored A figure is a million pixels and a level-13 IGEO7 tile is sixteen million hexagons, so past a point every extra cell lands under a pixel another cell already owns. `dggresample` descends the system's own hierarchy from its root cells, keeping only branches that are on screen and hold data, stops at the level whose cells come out about `cellpixels` across, and colours each of them by the leaf cell under its centre. Neither half of a frame is proportional to the cells handed in: the descent costs what is on screen and the resampling costs one point location per cell drawn. A build covers more than the viewport, so panning within the buffer and zooming within the hysteresis band cost nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kdrdes6CRmxPP1khvpkmyT
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kdrdes6CRmxPP1khvpkmyT
Measured on ubuntu-latest, not inferred: dggresample carries the whole hydrology pipeline at level 13 in 9.51 GiB with swap off. dggpoly at the same level reaches 14.56 GiB through everything except the last two figures, and dies drawing them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kdrdes6CRmxPP1khvpkmyT
Eight assertions fail on the 1.11 CI job and pass on both 1.12 jobs. Seven read `@allocated` over `neighbors`, `children` and the neighbourhood sweep, whose small containers 1.11 still heap-allocates (64, 80 and 16 bytes). The eighth reads `Base.summarysize` of an eager A5 halo engine, which 1.11 reports as 424 bytes at every depth while 1.12 grows it 368 -> 1168. Both are properties of the Julia version, so each of the eight carries `skip = VERSION < v"1.12"`. Nothing is dropped: on 1.12 every one of them still runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SwnXToXzkTdstjqfp8ovhH
Assert the allocation laws only on Julia 1.12
| # Speedup one weight build reaches on its own, per thread. Measured on the | ||
| # CopDEM GLO-90 -> IGeo7 L12 ladder: ConservativeRegridding's inner threading | ||
| # runs 8.7x on 12 threads, about 92% per-core efficiency, and the wave has to | ||
| # beat that to be worth choosing over it. | ||
| const _INNER_SPEEDUP_PER_THREAD = 0.73 |
There was a problem hiding this comment.
Don't know why this has to be a constant
There was a problem hiding this comment.
It didn't — one call site, no type-stability or isbits reason. It's now an innerspeedup::Float64 = 0.73 keyword on _wavesize (the only reader), and the const is gone from shared.jl; same default, so no behaviour change. 1633933 on claude/perf-ladder-review, merging into this branch once the live production run exits.
|
|
||
| """ | ||
| STI.child_indices_extents(cursor) -> Vector{Tuple{Int,SphericalCap{Float64}}} | ||
| LeafCells(cursor::BlockCursor) |
There was a problem hiding this comment.
storytelling and generally too deep in context
There was a problem hiding this comment.
Dropped the allocation-share figure and the rejected-designs section; kept the two invariants a caller can trip over (bounded by LEAF_CELLS, and each call must return its own value). 1b0923b on claude/perf-ladder-review, merging into this branch once the live production run exits.
…block All five [sources] blocks on this branch pinned GeometryOps at 2825c476647cfe6791dbb89fffb8592697b46995, two commits behind the tip of GO's `claude/perf-ladder-predicates` (GO PR #476). PR #65 further up the stack had already moved the ROOT block to 36c853e0 and left the other four behind, so merging the stack forward produced a workspace whose members disagree on which GeometryOps they want. Move all five to the tip so a fresh resolve of #62-#64 (and of #65 after the merge) sees one GeometryOps. GeometryOps 2825c476647cfe6791dbb89fffb8592697b46995 -> 36c853e0de865251e1395ff01e9d34cda9e9bfa8 in Project.toml, docs/, test/, lib/GlobalRegridding/, lib/DiscreteGlobalGridsConformanceTesting/ The two commits in between are the `spherical_orient` fast path and the `SphericalCap` squared-chord intersection - perf only, bit-identical results, measured during the perf-ladder campaign. ConservativeRegridding stays at 6a4b997ab2e66dea45b5b62b5b2f32f0a3b279b0, re-checked rather than changed: it is the current tip of CR `claude/budget-frontier`, reachable from that remote ref, and its tree does define `Trees.split_weight` (src/trees/interfaces.jl:52). The "a fresh resolve loses split_weight" report does not reproduce - the fresh depot below resolved and precompiled it. Evidence, throwaway depot, sharing nothing with ~/.julia: JULIA_DEPOT_PATH=<tmp> julia --project=. -e \ 'using Pkg; Pkg.instantiate(); Pkg.precompile("DiscreteGlobalGrids")' resolved both git sources from scratch (bare clones fetched fresh), wrote a Manifest carrying repo-rev 36c853e0.../6a4b997... beside Pkg's own git-tree-sha1, precompiled 92 dependencies including GeometryOps, ConservativeRegridding, GlobalRegridding and DiscreteGlobalGrids, and `isdefined(GeometryOps, :intersection_area)` and `isdefined(ConservativeRegridding.Trees, :split_weight)` both hold. Targeted suites on the new pins (julia -t 4): GlobalRegridding 2202 pass / 1 broken / 0 fail crosssystem/regrid.jl 116 pass / 0 broken / 0 fail crosssystem/regridding_conservation.jl 76 pass / 12 broken / 0 fail systems/CopernicusDEM 16162 pass / 3 broken / 0 fail crosssystem/regrid_acceptance.jl 22 pass / 0 broken / 0 fail Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
`dggpoly` gives every cell its own patch of one flat colour. `dggsurface` puts a vertex at each cell's centroid, gives it that cell's value, and joins the centroids with the triangles of the grid's dual, so the value varies continuously between cell centres. One vertex and about two triangles per cell against six and four, and a per-cell colour vector already *is* the vertex buffer, so recolouring costs nothing. The dual's triangles sit at the grid's corners, and the package has no verb that lists corners; cell boundaries are floating-point rings neighbours do not agree on bit for bit, so coordinates cannot be matched either. Adjacency is stated exactly, though, and the cells at a corner are precisely a set that all touch one another — a run of consecutive neighbours in each member's ring. Widening a consecutive pair to that run and emitting only from its smallest member gives each corner exactly one owner: no shared hash set, no post-hoc `unique`, nothing that would serialise the loop. Widening is also what makes one rule right for square cells as well as hexagons, where a four-cell corner is a fan of two triangles rather than four overlapping ones. On a map the outline is the triangle's three corners and nothing else — an edge is shared, and bending one copy and not the other opens a gap. Which way longitude sweeps along an edge is the sign of (a x b)^z, which is exact and anti-symmetric, so the triangle on the other side always agrees; it is exactly zero when the edge runs over a pole, and the pole corners go in. A triangle that encircles a pole is drawn as the cap it covers. Checked rather than asserted: the tests add up the signed spherical area of every triangle of a whole level and require 4pi, and repeat it in lon/lat against the map rectangle, for every system, levels 1-4, three cut meridians.
Two Codex sweeps over surface.jl, surface_recipe.jl and the CellRegion half of cellsets.jl, against the house style tessellate.jl and recipe.jl set. Cut the narrative, the repetition between module header, docstring and README, the section headers that named nothing, and the docstrings that only translated a one-line function into English. surface.jl goes 732 -> 641 lines, 112 -> 74 comment lines, which is tessellate.jl's density. The second sweep also caught three real defects the first round of cutting introduced or left: `emit_traced_polygon!` lost the clause that said why three turns and not two, `emit_polar_band!` claimed no meridian cuts a polar triangle when the point is that cutting it yields no cap, and the header gave corner sizes as run lengths in one sentence and cell counts in the next. No technical claim changed otherwise, and the suite and the signed-area tiling checks are unchanged.
Three review-fix commits on top of e2f90d1: - the _wavesize threshold becomes a keyword and its docstring shrinks to what the function does (lib/GlobalRegridding/src/lazy.jl, shared.jl); - the LeafCells docstring drops its provenance narrative (src/systems/CopernicusDEM/cursor.jl); - the MemoBlockCursor extent cache is explained as a mechanism (src/systems/CopernicusDEM/cursor_memo.jl). No overlap with the perf-ladder tip; merged without conflict.
|
Pushed
One conflict, in Verification: the driver loads and builds its synthetic config without starting a run (both knobs present, Note for whoever merges this: the split of |
Routine stack maintenance — the parent of this branch in the PR stack, merged down so PR #65 applies cleanly. The base brings the CapCachedTree `_ShiftedCaps` refactor, the editable-CONFIG script split, the ancestor-subzone store work and the GeometryOps pin bump to 36c853e0. Three files conflicted. In each the base's *structure* wins and this branch's *semantics* are carried into it; no behaviour from either side is dropped. scripts/copdem_production.jl The base split the 1370-line monolith into an editable `CONFIG` NamedTuple threaded through `main(config)`, plus `copdem_store.jl` and `copdem_synthetic.jl`, and renamed the destination work unit from "column" to "chunk". This branch had added four things to the monolith, all re-expressed against the split: - `gcguard` and the `allowsweeper` knob, refusing to start under Julia's concurrent page sweeper (`--gcthreads=N,1`), which released a page under a live object and killed the 2026-08-21 run. Now `gcguard(config)`, called first in `main`; the header's example command drops its `,1`. - `rssgib()` reading the CURRENT resident set from /proc/self/statm with `peakrssgib()` labelled separately, in the heartbeat and the final line. The old `Sys.maxrss()` high-water reading is what produced the false 83.7 GiB alarm. - `tunemalloc` and the `malloctrim` knob (default 32 MiB), freezing glibc's M_TRIM_THRESHOLD so it stops retaining ~3x the live heap. Reported at startup, right after the banner. - `workercount`, sizing the pool from a `cores` budget with an `:outer` / `:inner` shape, wired into `runchunks` (the renamed `runcolumns`) so the worker body binds `GR.OUTER_PARALLEL => outer`. `workers = 0` now means "size from cores"; the base's `threadsper` was already gone. Also carried over: `FAILURES` as a `Threads.Atomic{Int}` (workers report their own failures, so `+= 1` loses some) and the `GDALLOCK` serialising `readtile`. Knob names follow the base's NamedTuple idiom, so the shape is the symbol `:outer`, not the string "outer". src/cap_cached_tree.jl The base moved `CapCachedTree`'s offset field into a `_ShiftedCaps` wrapper, leaving a two-field struct; this branch had passed the offset as a third constructor argument and given the cursor the cached seam's leaf size with `_bucketed`. Composed: `CapCachedTree(_bucketed(cursor), _ShiftedCaps(...))`, which is exactly the shape `_cachedcelltree` auto-merged into one function above. src/systems/CopernicusDEM/cursor.jl Both sides trimmed the same `subcursor` docstring. Took the base's terser phrasing of the run test and kept this branch's `MemoBlockCursor` paragraph, which the base's version had dropped although the signature and the `_memoized` body both depend on it. Documentation only.
|
Follow-up: pushed
Verified after the base sync: driver dry check re-run against the split entry points — 26/26, including Staying draft until the pin blockers clear. |
dggsurface: draw a cell set as the field it samples
Both `[sources]` revisions had moved under us. `claude/budget-frontier`
resolved to `89ec5ca5` ("Bump version from 0.2.8 to 0.2.9"), which predates
`Trees.split_weight` and so cannot precompile GlobalRegridding;
`as/intersection_area` resolved to `02750768` ("Bump patch version to
v0.1.44"), which predates `intersection_area` itself and fails at the first
weight block with `UndefVarError: intersection_area not defined in
GeometryOps`. A fresh instantiate of main hits both.
Pin the two revisions the campaigns actually measured against:
CR `6a4b997` and GO `2825c476`. They are still temporary — the note at
`regrid-notes/cr-pin-state` still applies — but a SHA cannot move.
Also make `bench` a workspace member so it shares the root Manifest, and
give it the Zarr dependency the store work needs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
`GR.subtree(::DGGSpace, inds)` had two fast paths and both were shut for a Copernicus DEM source: `_iswholespace` is false for a chunk, and `_chunkcursor` requires `treeify(grid)` to be a `HierarchicalGridCursor`, which a `CopernicusDEMSystem` grid's is not. So every block build got a `GR.CellCapTree`, which bisects the linear index range: with row-major pixel order every node above about depth 11 of 17 is a band of complete pixel rows spanning the tile's whole degree of longitude, and its cap stays ~200 km wide whatever the band's height. Nothing prunes on longitude, and the dual-tree candidate search re-descends the destination at every one of those surplus nodes. Measured against a `RasterGrid` over the same pixels: `get_all_candidate_pairs` cost 228x more for the same clipping work. Add `subcursor(grid, inds)`, a grid seam returning the tree restricted to a run of grid positions, or `nothing`. The default is `nothing`; a hierarchical grid needs no method, since regridding already descends to the chunk's ancestor. `CopernicusDEM` implements it by reusing `_block_cursor`'s run-to-rectangle test over the window rather than over the whole grid, which also makes the test independent of whether the grid is complete: a partial holding of scattered tiles -- what Copernicus actually ships, land only -- gets the generic cursor for the whole grid and a windowed `BlockCursor` for each of its per-tile chunks. Ranges that are not one lattice rectangle keep the cap-tree fallback. A `BlockCursor` node is an index rectangle whose cap is an exact O(1) box and whose `Bisected` split takes the longer axis, which is what `RasterCellTree` does for the raster arm, so this closes the gap rather than narrowing it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
`CapCachedTree` existed only for the whole space: a chunk's `_chunkcursor` handed the dual-tree search a bare cursor, whose `node_extent` at a leaf and whose `child_indices_extents` both re-derive every cell cap from `Fallbacks.cell_cap` -> `cell_boundary`, an inverse projection per cell, once per opposing node. On the Copernicus source A/B that destination-side recomputation was 43 % of the whole block build, because the destination tree is re-descended once per source node. Carry an index offset in `CapCachedTree` so the same wrapper serves a chunk: position `p` reads `caps[p - offset]`, with `offset = 0` for the whole space and `first(inds) - 1` for a chunk. `subtree` wraps chunk cursors up to `_CHUNK_CAP_CACHE_MAX` cells; above that the fill costs more than the revisits it saves. On the lazy path the destination reaches this through `TileCells`, which keeps one tree per tile, so the caps are filled once and read by every source chunk paired with it. Caps, leaf entries and cells are the raw cursor's, bit for bit; the tests check that against `_chunkcursor` directly. Measured on the box:10,10.05,45,45.05 / IGEO7 L12 / ancestor 7 A/B, t8: the windowed-cursor arm 1.5 -> 1.4 s, and the forced cap-tree arm 61.9 -> 32.8 s, which is the 43 % showing up where it was diagnosed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
`_mergecaps` centred on the mean of its inputs' centres and took `max(distance + radius)`. That covers, but a node is inflated by the spread of its children and `_cellcapnode` then merges child EXTENTS bottom-up, so the inflation compounds level by level. Measured on one 1200x1200 GLO-90 tile at 45 deg N: root cap 274.9 km against a true 68.1 km, and a maximum still above 70 km at depth 17 of 17. Every factor of two in radius is about two levels of pruning depth. Fold the smallest cap containing two caps instead — the same construction `GO.UnitSpherical._merge` and `DGG.Fallbacks.merge_caps` use — so an internal node is minimal given its children rather than minimal-plus-spread. Same tile, same tree: root 94.6 km, depth 3 median 224.2 -> 45.9 km, depth 11 maximum 203.9 -> 39.4 km. What is left at ~39 km is the other defect: the split is on the linear index range, so a shallow node is a band of complete pixel rows spanning the tile's full degree of longitude, 39.4 km wide at 45 deg N however thin the band. That is a separate change. Caps only prune, so this is a speed change and not a semantic one: the Copernicus A/B's forced-cap-tree arm goes 32.8 -> 13.0 s at t8 and its output stays bit-identical to the windowed-cursor arm's. It is the fallback every system without a windowed cursor still takes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
Zarr chunks are uniform by format, so a chunk grid that follows the tree exactly is not expressible along a one-dimensional cell axis: an IGEO7 pentagon's subtree holds p(d) = (5*7^d + 1)/6 cells where a hexagon's holds 7^d, and no single chunk length lands on both. The ancestor-subzone layout buys tree-aligned chunking by spending a dimension on it — subzone position within one ancestor's subtree, then the ancestor, one chunk per column — so a chunk is a subtree, an ancestor nobody wrote is a chunk that was never stored, and a reader gets the tree's own irregular chunking back. This is the half that needs no store: `SubzoneLayout` and the mapping in both directions (`subzoneindex`, `positionindex`, `columnpositions`), the run planner that turns a cube's cell axis into whole columns and refuses a partially covered one, its inverse `subzone_cellvector`, and the attribute block a store carries. All of it is `ancestor`/`cellposition`/ `descendant_range`, which are O(level) digit arithmetic wherever `has_sorted_subtrees` holds, so a store of 10^12 cells is described by three integers and never has a level-L id vector materialized to be addressed. The vocabulary is a `dggs` object with everything the layout adds nested under `subzone_layout`, and no `zarr_conventions` declaration: this is not the one-dimensional layout that convention describes, and claiming to be it would send a convention-aware reader down a path that cannot open the store. Column order inside a subtree is what OGC API-DGGS calls sub-zone order. `subzonestore` and `dggwrite!` join `dggread`/`dggwrite` as stubs; their methods arrive with the Zarr extension. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
The Zarr half of the layout: `subzonestore` creates the group, its (capacity, ncolumns) arrays chunked one column each, and its attributes, once; `dggwrite!` fills columns afterwards, one ancestor cell's subtree or a whole cube at a time. `dggwrite(dest, cube; layout = :subzones, ancestor_level = k)` is those two calls and nothing else, so the incremental path is not a second implementation of the one-shot path. A column is one chunk is one file, and a column write rewrites nothing shared — not the attributes, not the consolidated metadata, not a manifest — so the production run may write disjoint columns from as many tasks as it likes with no coordination. The suite asserts that: one new file per column write, and every file that was there before untouched to the mtime. Reading fakes the two-dimensional store into the one-dimensional cell axis it stands for. `dggread` recognizes the layout from its own attribute before the conventions are asked, and hands back a `Cells` dimension over a `SubzoneCellArray` — a `DiskArrays.AbstractDiskArray` whose `eachchunk` is `IrregularChunks` of the real column lengths, 7^d for a hexagon ancestor and p(d) for the twelve pentagons. That is the chunking Zarr's own grid could not hold, and anything that reads by chunk now reads whole subtrees with the padding already dropped. A read inside one column is one chunk read; one spanning columns is one per column. The default view is the whole level, since a column nobody wrote is not absent from such a store — it reads back as fill; `ancestors` restricts it to the columns named. DiskArrays becomes a direct dependency: the wrapper lives in the extension, but an extension may only load what the package declares, and Zarr brings DiskArrays in either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
What the layout buys, what it costs — a column is written whole — and where each verb lives, alongside the one-dimensional layout it sits beside. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
`dggwrite(dest, stack; layout = :subzones)` goes through the same `subzonestore`/`dggwrite!` pair as the array form, one layer per element type, and nothing asserted that until now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
`_chunkwindows` visits every level-`a` ancestor to find the non-empty ones. That is the only way to know for a complete level or a scattered subset, but a ROOTED `PartialGrid` -- the shape `subtree` returns -- holds nothing outside its root's subtree, so only that root's level-`a` descendants can qualify and the rest of the level need never be visited. A root deeper than the chunk level puts the whole grid under one ancestor, which is arithmetic rather than a scan. The narrowing is exact, not a heuristic: `PartialGrid`'s constructor checks the endpoint ancestry, so a rooted grid really is confined. The test asserts the narrow and the wide answer agree ancestor for ancestor and range for range at every chunk level between the root and the grid. This is the destination side of the production CopDEM run, which builds one `DGGSpace` per level-5 column over ~60 000 columns. At La=5 the whole-level scan is 0.30 s a space (`regrid-notes/2026-08-20-la-choice.md`), i.e. ~5 CPU-hours of the run spent rediscovering that one ancestor of 168 072 is the one asked for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
One script that writes the whole land surface into ONE ancestor-subzone Zarr
store, from W worker tasks in one process.
What is real about the run is the tile LIST. Copernicus ships ~26 450 land tiles
of the 64 800 the 1x1-degree lattice has, and the source holding is exactly
those: `DGGSpace(PartialGrid(their pixels); chunklevel = 0)`, one chunk per
listed tile, over a lazy `TileIds` vector so 2.5e10 pixels cost an offsets table
of 26 475 integers and nothing else. A tile off the list does not exist, so an
over-covered open-ocean destination pairs with no source chunk at all and the
brief's skip-pruning is structural rather than a test: the covering is built FROM
the tiles, so a column with no source is never enqueued.
Within a listed tile, ocean pixels are NODATA. A land mask rasterised once from
Natural Earth coastlines -- a scanline even-odd fill with an active edge list,
0.5 s for the whole globe at 15 arcsec -- decides which posts are land, and the
rest are `NaN32`, which is the regridder's own invalid sentinel whatever produced
it. So the run exercises the missing-data machinery at its real scale: the
conservative weights renormalise over the land fraction of every coastal cell,
and a cell that is all ocean comes back NaN inside a column that is otherwise
written.
Shape, from the spike record's sections 10, 13 and 14 and the la-choice note:
* a work unit is ONE level-5 column, 7^7 = 823 543 level-12 cells, which is
also one Zarr chunk and one file -- so tasks writing different columns share
nothing and need no coordination;
* columns are queued in contiguous batches in canonical Z7 order, so a worker
walks a connected run and its tile cache stays hot, but workers PULL those
batches rather than being dealt a static share, because a polar column costs
~5x a mid-latitude one and a static split would let the tail set the wall;
* `GR.OUTER_PARALLEL` is set inside each worker body, which stops the nested
weight builds spawning against an outer loop that is already parallel;
* the tile cache is striped over 64 locks and a tile is built OUTSIDE its lock,
so twenty-one workers do not serialise on one mutex.
Resume is the default and takes the UNION of an append-only done log and the
store's own chunk listing, because neither is complete: the log can be lost, and
Zarr stores no chunk for a column that came out entirely NaN -- a normal outcome
on the ocean side of the covering -- so such a column leaves no file behind.
`checks=true` runs the synthetic oracle against what is ON DISK: unfed cells are
NaN, fully fed cells are `SYNTHETIC` at their centre, coastal cells lie inside
the field's own range across the cell, the real tiles are finite at their centres
including both S90 pole tiles, and a column nobody wrote reads back NaN.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
…re cached
`isleaf` only stops early when `bucket_size > 0`, and a grid's default is 0, so
the IGeo7 destination side of the dual search descended all seven levels of a
level-12 column and ended in 823543 single-cell leaves — 6/7 of the tree's nodes
in the bottom layer alone. Stopping at 49 stored cells deletes that layer: the
leaf hands its cells back through `child_indices_extents`, which reads them
straight out of the cap vector.
Measured on the production CopDEM GLO-90 -> IGeo7 L12 column regrid, core-seconds
per column, single-threaded, all outputs bit-identical:
column leaf 1 leaf 8 leaf 49 leaf 343
728 20.39 13.16 12.69 18.67
98241 (all NaN) 18.27 11.66 10.97 16.60
115426 (polar) 20.36 13.30 12.34 18.89
49 is the optimum. That sweep was taken with the two source-side changes that
now follow this commit already in place. Standing first on the base, this change
alone takes the eight reference columns from 184.60 to 84.13 core-seconds, -54%
-- the largest of the series, and the reason the two that follow measure smaller
than they did in isolation: all three attack the same tree walk, and this one
removes a whole layer of it rather than making the layer cheaper.
The obvious patch would be to change `PartialGrid`'s `bucket_size` default, and
that is the unsafe one. The win exists only because a `CapCachedTree` already
holds the leaf's cell extents; a bare `HierarchicalGridCursor` re-derives them —
an inverse projection per cell — on every visit, and the same sweep against one
costs +25% at leaf 50 and +441% at leaf 350. The sign of the change depends on
whether the caps are cached, so the leaf size is a property of the cache, not of
the grid. It is therefore attached to the two sites that return a
`CapCachedTree`, and every path that hands back a plain cursor — `GR.celltree`,
a `subcursor` window, `_cachedchunktree`'s oversized-chunk return, the
selection-cursor fallback in `_cachedcelltree` — keeps the leaf size it had.
`_bucketed` fills in only a `bucket_size` of 0, which is the grid default rather
than a request, so a caller that names its own leaf size still gets it.
The candidate pairs are unchanged either way: a node's cap covers its
descendants' caps, so stopping early can neither add nor drop a pair. The new
testset states that, alongside the bucket-size invariant on each path; the
existing cap-cache testset now compares shapes against an equally bucketed bare
cursor and keeps comparing weights against the single-cell-leaf one.
`STI.node_extent(::BlockCursor)` derives `_node_box` -> `_box_cap` — five `sincosd` pairs and four spherical distances — every time it is asked. The dual-tree join asks once per opposing node, and a tile's interior tree is re-walked for every source-block build, every destination column and every worker, over a lattice that never changes. The production profile put that one call at 47.7% of all CPU, of which the interior nodes are ~26%. `MemoBlockCursor` wraps the cursor with a per-task, direct-mapped table of 1024 slots in `task_local_storage`, keyed on the node's full rectangle `(r0, r1, q0, q1, j0, j1, i0, i1, inpixels)` and cleared when the task turns to another grid, system or level. The slot stores the whole key and compares it, so a collision is a miss that overwrites, never a wrong cap. Task-local means 21 workers share one source `DGGSpace` with no lock and no shared slot, and memory is O(slots) per task rather than O(tiles) — no 2.6 GB ceiling and no eviction policy, which is why this is the raster source's `MemoRasterTree` shape rather than the LRU the campaign spec sketched. `treeify` and `subcursor` hand back the wrap; `BlockCursor(grid)` still gives the bare cursor. Only interior geometry is memoized: a leaf's per-cell `child_indices_extents` entries are still rebuilt on every call. Measured on the eight reference columns, `-t 1`, load 46-55, against the frozen baseline in ladder-scratch/baseline-refcols.ndjson: total 184.60 -> 134.41 core-seconds -27.2% col 728 29.11 -> 21.56 -25.9% col 729 29.16 -> 20.64 -29.2% col 730 29.29 -> 21.28 -27.3% col 98241 27.19 -> 19.37 -28.8% col 115424 9.55 -> 7.34 -23.1% col 115425 1.59 -> 1.56 -1.9% col 115426 29.77 -> 21.35 -28.3% col 115427 28.94 -> 21.31 -26.4% All eight columns are bit-identical to the frozen baseline: the memo returns the same `_box_cap` bits, and `node_extent_is_expensive` only decides whether the search caches child extents in a vector, not what it computes. Rebaselined: those figures were taken against the unmodified base. With the destination-side bucket change now standing first, the layer of source-node visits this memo serves is largely gone before it runs, and the same eight columns move 84.13 -> 80.47 core-seconds, -4.3%. The memo still pays, and costs nothing when it does not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2
Matched 100-column driver A/B: allocation 285.1588 -> 242.2104 GiB (-15.06%), GC wall fraction 31.40% -> 26.25%, wall -7.03%, and mean cores 11.78 -> 12.35. Output remained bit-identical across 82,354,300 Float32 values (0 differences). The GlobalRegridding suite passed 3,703 tests with 0 failures and 1 expected broken test.
Keep the bare IGeo7 system as the subzone storage identity, stamp destination_geometry for authalic output, and reject reopen when the producer geometry tag differs. Set the full-run store and measured W40 core budget in the committed configuration.
Reuse the shakedown Profile peek settings at top level and write each 60-second report to the path named by COPDEM_PROFILE_REQUEST.
Wake libuv once per second while signal peeks are enabled so the completed SIGUSR1 capture can run its report callback even when the outer W40 wave occupies every default-pool thread.
Set the explicit worker override required by the measured outer/-t21/W40/gc4 configuration; cores=40 alone sizes to W38 through the driver heuristic.
Analysis of the completed 66,228-column synthetic authalic run (8.81 h, 1.72 M cells/s, 148.10 core-h integrated, 25.19 GiB peak). Two findings for the Phase-5 gate: - The countable W1 redundancy is 6.079 GiB per global-average 100 columns (2.51% of the 242.21 GiB matched anchor); W2's recovery cannot be quantified because no nnz was recorded. W1/W2 is worth measuring but cannot be promised a several-fold reduction. - The 13 uncredited demands are a real graph/executor coverage mismatch, not counter bookkeeping: an exact read-only reconstruction found 71 executor candidate pairs absent from the dependency graph (and 437 graph-only pairs), all 13 runtime sources among them. prefilter=false reproduces the same 71 misses, exonerating the latitude band. Analysis only; no source changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DuGKTmvs5B4EynwZddKMg
`chunk_dependency_graph` built its rows from `chunkextents(src_space)`. A space's chunk index does not have to test those caps, and CopernicusDEM's does not: `chunkextents` reports `node_extent(sys, id)`, the cap around the tile's own boundary ring, while the level-0 frontier cursor the executor descends reports `_box_cap(_node_box(...))`, whose east edge carries the whole tile width rather than stopping half a pixel short. Neither cap contains the other, so the two relations crossed instead of nesting: over the full CopDEM90 x IGeo7-L12 problem the graph held 437 pairs no read asked for and missed 71 pairs a read demanded. Refcounts taken from `consumersof` therefore retired tiles that were still going to be loaded. The shipped RasterGrid index has the same divergence, so this is not a Copernicus quirk. Rows now come from `candidatechunks!` on `chunkindex(src_space)` -- the same query, on the same index, that a lazy read issues -- which makes the graph a superset of executor demand by construction, for every space and with no per-space invariant to keep. The full-problem reconstruction goes from 71 missing pairs to 0, and the relation is now identical rather than merely nested. Build cost on that pair is 0.023 s -> 0.121 s at 8 threads, and the graph loses 366 spurious edges. `prefilter` becomes an accepted no-op: the latitude band belonged to the cap join. Two tests fail before this and pass after: a RasterGrid source in GlobalRegridding, and the CopDEM level-0 frontier against a complete IGeo7 level-3 destination -- all twelve pentagons, both poles, every tile reached -- in the DGG suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DuGKTmvs5B4EynwZddKMg
Querying the source space's own chunk index left no latitude band to switch off, so the keyword only documented a code path that no longer exists. Remove it from both methods and from the test that asserted its inertness. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DuGKTmvs5B4EynwZddKMg
`GR.subtree(::DGGSpace, inds)` can only reuse the grid hierarchy for an exact chunk range; every other contiguous window falls to the packed CellSpaceRTree. Scope out what closing that costs and buys. Reachability: no workload we run reaches the fallback. Production, the lazy smoke test, and the examples all land on case 1 or case 3, and PartialGrid- and A5-backed spaces reach it too. The one live route is the lazy executor's destination tiling, which production clears by 4.07x of headroom in `budget`. Below 2^28 that flips and every destination unit packs a 419k-cell R-tree. Cost: measured at IGeo7 L6, build plus the real `_intersectionareas` join. A windowed cursor wrapped in the existing cap-cached seam beats the fallback 1.54x at chunk scale and 2.42x at the cliff, at a flat 4.3x less memory, with identical nonzeros. A bare cursor is dominated everywhere -- the join costs 3-4x more without cached leaf caps. Fallback cost is contiguity-independent, which rules out a union-of-chunk-ranges variant. Design routes the window through a lazy PartialGrid over SubtreeIds, which already clamps descent to the window; the trap is that the position shift must be bidirectional. Prototype is exact on 19 windows including all 12 pentagon subtrees and both poles. Recommendation: fold into Task B4, which already owns this dispatch decision, and guard the cliff independently. Not a new card. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DuGKTmvs5B4EynwZddKMg
B4 already said to use the packed fallback "only when a native restricted cursor is unavailable". On the DGGS side that sentence is vacuous: `subcursor` has exactly one concrete method, CopernicusDEM, so every hierarchical system takes the `nothing` default and a non-chunk-aligned window packs an R-tree. Records the two traps the scope measured — the position shift must be bidirectional, and the window must carry cached leaf caps — and pulls the destination-tiling cliff guard forward, since that cliff rather than a steady-state win is what pays for the work. `_cachedcelltree`'s missing size guard is noted as reviewed and accepted, not folded in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DuGKTmvs5B4EynwZddKMg
Build the chunk dependency graph from the source space's own chunk index
The performance work in this branch was driven by profiles taken without a GUI: FlameGraphs' own data structures rather than ProfileView's window, `@allocated` and `code_native` size rather than a flame chart read by eye. Write that loop down as a skill so the next round starts from the same entry points -- warm up, profile, read the tree, then check dispatch and allocation -- and so it stays analysis-first: propose the optimisation, do not silently apply it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The degree bound was declarable on a system and readable nowhere else, so every caller holding a grid, a `CellVector` or a `CellLookup` had to fish the system back out to size a container. Give all three the forwarding method: a subset can only lose neighbours from the complete system's one-ring, never gain any, so the system-wide bound stays valid through every region wrapper. A standalone grid with no system keeps answering `nothing`. Covered by a cross-system law that the bound agrees across the complete level, a subtree, its `CellVector` and its `CellLookup`, and by an interface test that an unimplemented grid still says `nothing` rather than erroring. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_positions` always built a heap `Vector`, so `neighbors(lookup, p)` --
the shape every cube-indexed kernel calls -- allocated once per cell even
where the id form had just handed back a fixed-capacity `SmallVector`.
Give it a `SmallVector` method that builds into a `MutableSmallVector` and
freezes once; repeated immutable `push` copies the inline payload at every
surviving neighbour and loses to the heap path it replaces. The position
forms now preserve the id form's container family and change only the
element type, which the `neighbors`/`ring` contracts state and a
cross-system test pins for `CellLookup`.
A declared bound is not by itself a reason to specialise on it, so
`_capacity` now stops at `STATIC_RING_CAP = 64` elements and
`STATIC_RING_BYTES = 512`, past which the heap path runs as before. Both
are compile-time limits: `SmallVector{N,T}` is a distinct type per `N`, so
each `N` respecialises the whole neighbourhood stack. Part 4 of
`benchmark/maxneighbors.jl` measures emitted code per element and is where
the numbers in the docstring come from -- an 8-byte id steps 56% between
`N == 64` and `65` with no recovery above, while a 16-byte id shows no
step but costs 3.6x per element everywhere, which is the bound the byte
limit is for.
Also spells out why the `neighbors == vcat(rings...)` law has to be
written with `reduce` and an `init`: rings of different `k` can arrive in
containers of different capacity, and `vcat` takes `similar` from its
first argument.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`neighbors(grid, c, k)` and `ring(grid, c, k)` promise rotational order at every `k`, and the shell walk got it by measuring: a `cell_centroid` for every cell of every shell, plus a sort. But a frontier cell's one-ring is already a turn, so the cells it contributes come off it in that turn's order once the arc starts just past the inward neighbours, which are contiguous in the turn. Concatenating over a frontier that is itself in turn order gives the whole shell in turn order with no geometry at all. What that does not give is the shell's phase, so `_pin_phase!` rotates each shell to the spoke `_wind!` would have started it at. Phase rises along the turn and wraps once, so the start is the wrap point of a rotated sorted sequence and a binary search finds it in `O(log n)` centroids rather than one per member. The order is a system's own claim, so it is declared rather than inferred: `winding(sys, connectivity)` returns a `Winding`, and the walk carries the order only for `CounterClockwise` and `Clockwise`. `CustomOrder` says the one-ring is deterministic but its shells are not rotational copies of it, and `Unordered` -- the default, so nothing changes for a system that declares nothing -- promises no order at all; both keep the measured walk. A wrong declaration is a wrong answer rather than a slow one, which is why the default is the safe one. Declared here: `CounterClockwise` for IGeo7, H3 and S2; `CustomOrder` for A5 and ISEA4R, whose one-rings do measure counter-clockwise but whose rings grow 8, 18, 29, 39 and 25, 35 against the flat laws, so there is no outward order to carry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`maxneighbors` bounds the one-ring and says nothing about `k >= 2`, so every k-ring container is sized at run time even on a tiling whose rings are a scaled copy of that one-ring. `maxring(sys, k, connectivity)` is where a system writes that scaling law: `6k` on the two hexagonal systems, `8k` and `4k` on S2's quad lattice under the two connectivities. The generic method answers `k == 0` with 1 and `k == 1` with `maxneighbors`, and `nothing` beyond, so a system whose rings do not grow linearly declares nothing and keeps the run-time path. `maxneighbors(sys, k, connectivity)` is then derived rather than declared, since the disc is the concatenation of rings 1 through `k`: one method gives both bounds, and a linear ring law gives the quadratic disc bound for free. A5 and ISEA4R deliberately declare neither, for the reason recorded beside their `winding`: their rings do not scale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`morton_encode` and `morton_decode` walked one bit pair per iteration, so their cost grew with the refinement level -- and they sit under every quad-face system's id round trip, including HEALPix's NESTED codec. Swap both for the standard shift-and-mask bit spread, which is constant time and level-independent. Defined for coordinates below `2^31`, which covers every representable level, and the docstring now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four changes, all local to the codec and none to its answers: `nside^2` becomes `n * n`, avoiding the generic `^` on the pixel-count paths; `nested_to_xyf`'s `divrem` becomes a shift and a mask, since `nside` is a power of two there by precondition; and `point_to_xyf`'s `mod(phi * 2/pi, 4.0)` becomes a compare-and-add, which is bit-identical over `atan`'s `(-pi, pi]` range -- including `mod`'s normalisation of `-0.0` -- without the `fmod` call. The argument-error paths move behind `@noinline`. Their interpolated strings were more code than the arithmetic they guard, which kept the callers from being inlined at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A5's native neighbour code assembled every answer in a heap `Set` and a heap `Vector`, then sorted a fresh `collect`, so a one-ring cost several allocations before the system layer had seen a cell. A cell has at most 11 neighbours, which is a `SmallList` with dedup on push and `small_sort` at the end; the `_push_*!` helpers become value-returning `_push_*` to suit it. Two things had to change for that to actually stay on the stack. The delta tables have rows of different tuple lengths, so indexing the outer tuple with a computed row boxed the row -- each table now has a small dispatcher that keeps every row visible to inference. And `_is_neighbor`'s `any(==(relative), NEIGHBORS[flavor + 1])` had the same shape, so it gets the same treatment. The system-layer `one_ring` follows: it winds by sorting `(phase, cell)` pairs in a `SmallList` instead of calling `_wind!` on a heap vector, and builds its `SmallVector` in one construction rather than pushing. Pinned by an A5 test that both connectivities allocate nothing at levels 0, 1, a level-2 quintant/cross-face seam, and an ordinary deep cell -- the seam cells being the ones that took the boxing paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`lattice_neighbors` returned a heap `Vector{Int64}` that `one_ring` then
copied into a `SmallVector`, so every S2 one-ring allocated a vector that
existed only to be re-boxed. The builder now fills a bounded `SmallList`
of at most eight entries directly, parameterised on the element it should
produce, so `one_ring` asks for `LevelIndex` and gets its `SmallVector`
with no intermediate; the `Int64` form stays as the lattice-level entry
point.
Pinned at a whole root face, a cube-corner cell and a face-interior cell,
which are the distinct duplicate and seam paths through the builder.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`dggsurface` drew a flat interpolated surface and took its field only as colour, so relief -- the obvious thing to do with an elevation field on a DGGS -- was not expressible. It now takes `zs` the way Makie's `surface` does: one height per cell, in the geometry. On a flat map the height is the vertex's third coordinate, attached after the seam and polar cuts, which are still worked out in longitude and latitude alone. On a globe it is a height above the ellipsoid, and since height enters `globe_vertex` purely additively along the unit vector, raising a vertex moves it straight out -- a lift per cell with no normal per vertex. Left out, the heights are `ZeroHeights`, which stores its length and nothing else and is recognised by `triangulate`, so a flat surface keeps the two-dimensional vertex buffer it had. A one-dimensional `DimArray` over a cell dimension is both halves in one object -- its lookup names the cells, its values are the heights -- so `dggsurface(A)` draws `A` as relief. Colour stays separate: pass `color = A` to colour by the same field. `vertex_colors` unwraps a cube axis for the same reason the `zs` path does, since a `DimArray` survives Makie's colour conversion and the backends will not draw one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The flow-routing section built a full adjacency list, filtered it against a coverage mask, and indexed back into it per cell. `mapneighbors` walks each cell with its clipped one-ring as positioned handles that index the cube directly, which is both the shorter page and the API this branch made allocation-free, so `downhill` becomes a kernel over one cell. The plots move from `poly` over a materialised cell vector to `dggsurface` over the lookup, which draws the interpolated surface without the caller assembling the covered subset first, and the 3-D view of the same field is then a `dggsurface` with heights. Level selection uses `levelfor(sys, dem)` rather than a pinned 12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The geometry note, the ESMF survey, and the later API, Copernicus DEM, chunk-discovery and performance discussion each held part of the design and disagreed in places. Write the decisions down in one dated plan that owns the point-method redesign -- `BarycentricPoint` over the dual interpolation complex, stencils built independently of source chunking, and the rim, degeneracy and pole cases as explicit policy -- and mark it authoritative over the older note's tentative `sampleelement`, `patchsites` and `stencilreach` proposals, which it supersedes. Conservative regridding and the shared spatial infrastructure stay with the existing simplification plan; this one covers only the point methods. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`GuidedSchedule taper` failed intermittently in CI — PR #70 on 1.12-macos, PR #71 on 1.12-ubuntu, the same assertion, neither PR anywhere near the scheduler. The cursor was not the problem. The testset's tail-loop above already binds `b` in the testset scope: while (b = claim!(s)) !== nothing last = b end so the `b` in the concurrent loop below it is not a fresh local. It is that same binding, and because eight `Threads.@spawn` closures assign to it, lowering hoists it into one shared `Core.Box` they all capture. The optimized closure is explicit about the consequence: setfield!(box, :contents, %2) # my claim %5 = (%2 === nothing) # loop test reads the fresh value ... # seen[w], bounds-checked %32 = getfield(box, :contents) # append! re-reads the SHARED box append!(%26, %32) Between the store and the re-read, any of the other seven workers can store its own batch. The loser then appends the winner's range: one batch counted twice, its own dropped. The total stays 5000 and the ends stay clean, which is exactly the failure CI printed — the damage is in the elided middle. A worker that stores `nothing` on its way out can also hand a live worker `append!(seen[w], nothing)`. Reproduced by widening that store-to-load window with `--compile=min`: 13-16 mismatches per 200 trials, every one of them `ndup = ngap = 8`, plus task exceptions from the `nothing` case. 2200 trials of the fixed shape across 2, 4, 8 and 16 threads: zero. Hoisting the loop into `drain!` makes `b` a local of one call, so each task owns its own; the spawn body no longer assigns anything. `claim!` itself is correct and unchanged. Its CAS loop was stressed at 32.4M claimed positions over a grid of n, workers and maxbatch at 8 and 24 threads with tasks oversubscribed against workers: every run covered `1:n` exactly once. Production `runchunks` lowers with zero `Core.Box` — its `batch` is bound only inside the spawned closure — so the completed 66,228-column run was never exposed to this. Report the miscover in parts, too, so the next one names itself: count, duplicated, uncovered, stray, rather than two collections that print identically at both ends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DuGKTmvs5B4EynwZddKMg
Give each schedule worker its own claim variable
Raising a surface rebuilt all of it. The expensive half -- adjacency, a centroid per cell, the corner scan, the bulk projection -- does not depend on the heights at all, so it is now a `SurfaceTopology` the plot keeps, and `vertex_positions` is the only part the heights reach. A plot handed new heights writes over its old vertex buffer rather than allocating another, which is the difference between a redraw that allocates a hundred megabytes and one that allocates none. `samebuild` is what lets the topology survive: the compute graph reports every update as a change, so the surface asks for itself whether the cells it was built from are the cells it has. Vertices born of a cut carry barycentric weights rather than the tag of the nearer cell. A split vertex now takes the value the GPU would have interpolated at that position had the triangle not been cut, so the two halves of a cut triangle meet without a step -- in colour and, now that heights exist, in geometry. `spread` and `blend` do this for values of any kind; anything that cannot be mixed takes the corner it is most of. `ntasks` meant one thing in the recipe and another in the passes underneath, because `Threads.nthreads()` as a recipe attribute default is evaluated once when the `@recipe` block is read -- in a single-threaded precompile worker, which baked `1` into the image. The default is `Makie.automatic` now, and `inparallel` in `chunks.jl` is the one place that cuts `1:n` into blocks, so a plot's `ntasks` means the same thing everywhere it is honoured. `color = nothing` colours a surface by its own heights, the way Makie's `surface` does, and `cellset` reads a one-dimensional cube axis, so `dggpoly(A)` and `dggresample(A)` draw the cells `A` is indexed by. `GeometryBasics.mesh` hands the mesh over as a `GeometryBasics.Mesh`, for writing out or for anything else that speaks that type. Keyword arguments become vertex attributes: a value per cell is spread to the vertices, a value per vertex is taken as it is. Every method dispatches on a type this package owns -- a `CellRegion`, a `CellSet`, or one of the two meshes -- so whatever names the cells goes through `cellregion` or `cellset` first rather than through a pirate method. `dggresample` carries heights. The cells of a frame are all of one level, so they have adjacency and can be a surface; `surfaceframe` reads one out as a `PartialGrid` and reorders the pick index to match, which makes a height and a colour the same gather over the frame. Relief now has the same answer to "more cells than pixels" that a flat field does. `CellSet` and `CellRegion` compare by value. Julia's `==` for a struct is `===`, which compares a field that is not `isbits` by pointer, so a set rebuilt from the same cells read as a different set -- which is what a plot handed its arguments a second time gets. Both guards ask with `==` now: a window-backed `CellVector` answers in about a microsecond however many cells it holds, and `GridCells` compares its grid rather than walking it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The vertex positions were written over on a re-raise but the vertex colours were not: `spread` allocated a fresh vector every time the colour changed, which on a cut map is a buffer the size of the surface. `spread!` is the in-place half, the same split `vertex_positions!` already had, and the colour node now fills a buffer it keeps. A recolour of a level-5 grid -- 168,072 cells, 173,405 vertices after the seam and the poles -- allocates 0.3 KiB where it used to allocate 1,355 KiB, and a re-raise 6.1 KiB where a `Point3d` buffer is 4 MB. Which buffer a node may write over is now stated rather than inferred. `reusable` asked whether the value a node returned last time looked like something it could reuse, and had to be told which array not to touch; `ourbuffer!` holds the buffer the node allocated and nothing else, so the question does not arise. Both nodes need it: a flat map at no height draws the topology's own vertices, and a surface with nothing cut draws the caller's own colours, and neither may be written into. `flatvertices` and `spreadable` name those two cases where the plot decides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`dggresample` drew one leaf value per cell -- the one under the cell's own centre -- and nothing else was on offer, so a coarse view of noisy data showed a sample of the noise. `aggregate` takes any function of a vector, so `mean`, `sum`, `maximum` and `median` all work by being handed in, and no `Statistics` dependency is needed to say so. Which values lie under a drawn cell does not depend on what those values are, so it is settled once per frame and a recolour re-reduces the frame that is already there. Naming them costs nothing: where a system keeps a subtree together in its canonical order, `descendant_range` is the interval of leaf-level positions the subtree occupies, and a set stored in that order meets the interval in one run found by two binary searches. So a grouping is proportional to the cells drawn, whatever the subtrees under them hold, and only the reduction that follows reads the leaves. `LeafPositions` is the ascending position vector those searches run against, read one entry at a time rather than built. That much is a condition on summarising alone, and it is refused rather than worked around: a bare list of ids promises no order for a range to fall in, and A5 scatters a subtree through its level, so both say so and name the alternative. Nearest neighbour asks after one leaf at a time and is unchanged over every backing. What a reduction costs is a different matter, and the docstring now says it: reading every leaf under every drawn cell is proportional to the data rather than to the screen, which is the property the rest of the recipe exists to have. `draw` chooses which plot plays the frame -- `:patches`, `:surface`, or `automatic`, which is the surface exactly when heights were given, as before. A flat frame drawn as a surface interpolates between cell centres without being raised, and asking for patches *and* heights is the one pairing with no picture behind it, so it says so rather than quietly dropping one of the two. It is read when the plot is built, because a child plot cannot become a different recipe afterwards. The two `plot!` methods still dispatch on whether there are heights, but their bodies are now a `drawstyle` call and one of `framepatches!`/`framesurface!`, with everything a child gets either way in `childattributes`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stack position: 4 of 4 —
main← #62claude/dgg-source-subtree← #63claude/subzone-store← #64claude/copdem-production← #65claude/perf-ladder(draft)This is a stacked PR chain. Each PR's base is the previous branch, so each diff shows only its own commits. Do not squash-merge out of order; merge bottom-up and retarget children after each merge.
Headline
Over eight reference columns of the production run, single-threaded, bit-identical at every step:
184.60 → 67.0 core-seconds, 2.75× (three independent final runs: 66.66, 67.13, 67.35). Allocation 3.30 → 1.87 GiB per column, −43 %. Full suite 987,584 pass / 0 fail / 17 broken.
Projected production ETA at a 24-core budget, against the profile's 5.086e10 cells remaining:
Column 728 alone: 29.11 → 10.41 core-seconds, 2.80×; 28.2k → 79.1k cells per core-second.
Record:
regrid-notes/2026-08-20-perf-ladder.md(+.ndjson, 202 measurements). Spec:2026-08-20-perf-ladder-spec.md. Profile it was built from:2026-08-20-production-profile.md. Mid-campaign findings:2026-08-20-more-improvements.md.The ladder, in commit order
Every row at
-t 1on the eight reference columns (three mid-latitude fed, one single-source 100 %-NaN, four polar), against frozen bit-exact fingerprints — NaN count plus a hash of every value's bits.47961ea9e9e2c6c33715572391ebef43b0b-t 1224903bf868d7b7627ee39e9e2c6— N1: the destination tree descended one layer too farSTI.isleafforHierarchicalGridCursorstops early only whenbucket_size > 0, and the default is 0 — so a level-12 column rooted at level 5 ended in 823,543 single-cell leaves, and that bottom layer alone is 6/7 of the tree's nodes. Stopping at 49 stored cells (7², two refinement levels early) deletes it. Swept, every point bit-identical:Design call worth reading.
2026-08-20-more-improvements.mdproposes the principled home as a non-zerobucket_sizedefault onPartialGrid. That is unsafe, and its own data says so: against a bareHierarchicalGridCursor, which re-derives a leaf's cell caps on every visit, the same sweep costs +25 % at leaf 50 and +441 % at leaf 350. The sign of the change depends on whether the caps are cached, and three uncached paths exist today (GR.celltree,_cachedchunktreeabove_CHUNK_CAP_CACHE_MAX,_cachedcelltree's fallback). So the leaf size is attached to the cap-cached seam, not to the grid:_CACHED_BUCKET_SIZEis applied by_bucketedat exactly the two sites returning aCapCachedTree, and only when the cursor's ownbucket_sizeis 0 — an explicit caller choice still wins and no bare cursor is affected.c337155— c1: memoize the source cursor's derived node extentsSTI.node_extent(::BlockCursor)re-derived_node_box → _box_cap(fivesincosdpairs, four spherical distances) for every interior node, every block build, every column, every worker, over a lattice that never changes.MemoBlockCursorwraps it with a per-task, direct-mapped 1024-slot table intask_local_storage, keyed on(r0,r1,q0,q1,j0,j1,i0,i1,inpixels)and cleared when the task turns to another grid, system or level.Deviation from the spec (which asked for an LRU keyed by source chunk): task-local storage is lock-free for the 21 workers sharing one source
DGGSpace, memory is O(slots) not O(tiles), and there is no eviction policy to get wrong. Key correctness verified:_node_box/_leaf_padread onlysys,level,inpixelsand the eight rectangle fields, and the empty-slot sentinel cannot collide with a real key (its ninth field is −1 whileinpixelsis 0 or 1).−27.2 % standalone, −4.3 % once N1 stands first.
72391eb— c2: hand a leaf's cells back inlineThe leaf
(position, cap)call allocated a freshVector{Tuple{Int,Cap}}per leaf: 71.6 % of the base's 3.22 GiB per column.Latent-bug finding. The spec prescribed "a per-task reusable buffer". Reading the consumer showed that would be wrong: GeometryOps'
dual_depth_first_searchbinds both leaves' entry lists and nests the loops, andraster_tree_memo.jlretains the returned vector in a memo slot. A single shared buffer would silently corrupt any self-join — a Copernicus grid regridded onto a Copernicus grid, which is supported API. A lazy view was implemented and measured at only −0.9 %, because the join reads the inner leaf's entries once per cell of the outer leaf, so a view re-derives each cap up to nine times.What shipped is neither:
LeafCells <: AbstractVector, anisbitsstruct holdingNTuple{9,Tuple{Int,Cap}}plus a length. It lives in the caller's frame, never heap-allocates, is copied by value so aliasing is impossible, and derives each cap exactly once. −46 % allocation standalone; −9.4 % after N1, wall inside noise. Kept as an allocation change.LEAF_CELLS = 9was swept (4 / 9 / 16 / 25 / 49) with bracketing runs 0.9 % apart; wall rises monotonically to +14.9 % at 49 while allocation falls monotonically to −25 %. No change recommended — and the reason confirms N1's mechanism rather than contradicting it: the destination tree caches its leaf caps, the sourceBlockCursorderives them, so a fatter leaf is strictly more expensive per visit on the source side.ef43b0b— c3: let a narrow wave stand aside_fillwave!wrapped every spawned block build in@with OUTER_PARALLEL => true, suppressing CR's inner threading — measured at 8.7× on 12 threads, 92 % per-core efficiency — whenever a column had more than one source chunk, while the wave itself delivered only 1.05–2.26×. The fix is in the estimator, not the spawn:_fillwave!already has ani == jbranch that does not set the scoped value, so_wavesizereturning 1 reaches inner threading with no restructuring._wavesizenow estimates per-chunk cost asncells × (cap-overlap(tile, chunk) / chunk cap area)and keeps the wave only when its ideal speedup beats0.73 × nthreads.The estimator was validated before being trusted: Spearman 0.70–1.00 against nnz, where the naive cell-count proxy scores −0.70, anti-correlated. A 4× error in the wide-cap overlap formula was found and fixed against a 4M-sample Monte Carlo. Hazard handled: when a caller has already set
OUTER_PARALLEL(the production worker body does), inner threading is off and the wave is the only parallelism left, so_wavesizeconsults the scoped value and preserves today's behaviour.At
-t 12,OUTER_PARALLELunset:Bit-identical at
-t 1and-t 12. The honest cost: in the multi-worker regime the new shape spends ~8 % more CPU per unit of work (60k vs 65k cells/core-s), so at a fixed 24-core budget the old shape converts cores into cells slightly faster. c3's value is reaching the same cores with a third of the workers and two thirds of the memory, and being the only path past 24 cores. It is not counted in the ETA above.224903b— b2: workers from a core budgetworkers=21/threadsper=3described thread arithmetic the run never had.workercount()now derives W from acoresbudget, and the parallelshapeis selectable:outer(1.06 cores/worker, 65k cells/core-s, needs W ≈ cores) orinner(saturates at W ≈ nthreads/4 holding ~78 % of the pool, 60k cells/core-s).outeris the default because the standing budget is 24 cores;cores=24 shape=outergives W=23, independently reproducing the profile's own recommendation.f868d7b— N3: inline the Copernicus cell boundarycell_boundarywas the only heap polygon left on the hot path (IGeo7's equivalent already returned an inlineSmallList); nowisbitsend to end. Allocation −11.6 / −12.0 / −13.0 % on columns 728 / 98241 / 115426, beating the −9 % estimate. Wall: null — an A/B/A bracket (heap 78.44, 78.17; inline 79.80, 78.72) puts the claimed −2.57 % inside the noise. Kept on allocation alone.7627ee3— N2+N4: the GeometryOps repinTwo predicates paid for work they discarded.
spherical_orientnormalized a cross product and asserted unit-length inputs only to take the sign of a dot product: 36.5 → 4.5 ns (8.1×) once the degeneracy band is tested as(n·c)² < tol²‖n‖². TheSphericalCapintersection test calledspherical_distance— anatan2— where a squared chord answers: 16.5 → 3.4–7.4 ns. −15 % end-to-end, bit-identical, twice, allocation unchanged.Both were checked well past "the tests pass":
spherical_orientkeeps the exact-arithmetic and symbolic-perturbation fallback (the source report's patch deleted it), now guarded by exactly the criterionrobust_cross_productapplies internally. Over 22.2 M triples, zero sign flips. The 839,708 disagreements are all in deliberately constructed boundary suites and all within ±5 % of the tolerance, which is inherent (tol = 16 eps ≈ 3.55e-15against a three-term dot product's ~2e-16 rounding noise).spherical_distance'scross(p,q)cancels catastrophically for nearly-equal centers. Adjudicated in 256-bit arithmetic over the 111,514 knife-edge pairs where the two differ: the old test is wrong on all of them and the new one on none.c ≤ 1gate always missed; it was restructured to be entirelysqrt-free.GeometryOps' own suite is unchanged: 231,527 pass / 0 fail, per-testset identical on both sides. N6 was declined with cause (
find_orthogonaldoes not normalize, so the divisions in_sh_spherical_intersectionare load-bearing fora == b).What this campaign teaches, beyond the number
node_extentwas 47.7 % of CPU — a profile taken with the destination tree descending to 823,543 single-cell leaves. Once N1 stops that descent, most of the calls c1 memoizes never happen: c1 fell from −27.2 % to −4.3 %, and c2 fell to zero. Run in the specced order and stopped there, this campaign would have reported ~35 % and left a 2.19× sitting underneath it.Where the time goes now
Re-profiled (2 ms sampling, 21,896 samples, four mid-latitude column builds): pair-finding fell from 67.8 % → 29.0 % of CPU (−16.5 s of 19.7 s, −84 % absolute), and the source tree's interior-node geometry — formerly the single largest cost in the program at 47.7 % — is now 1.7 %. Sparse assembly /
BlockAreaOperatoris now 50.3 %, of which 32.2 % is the Sutherland–Hodgman spherical clipper (the useful work). Runtime dispatch stays at 0.0137 % self.The two clearest remaining levers, both measured and both out of scope here:
snyder_inv_xyz— 21.7 % of all CPU, the largest single item, reached from the destination-polygon memo (10.8 %) andcell_capunder_leafcaps(10.5 %). Not in either phase this campaign optimized.child_indices_extents(src/cap_cached_tree.jl:52): 5.63 % of CPU and ~0.75 GiB/column — ~40 % of all remaining allocation, ~675,000 allocations — from a heapVectorper leaf visit. Its caps already sit in a flat vector, so c2's treatment applies with no derivation at all.c4 was dropped as a measured null: the spec's own condition ("only if the re-profile still shows
child_indices_extentsas the next sink") is not met — it fell 21.3 % → 11.8 %, and a perfectly-free 100 %-hit-rate memo could not exceed −6.2 % against an original target of −10 to −15 %.Tests
Full suite 987,584 pass / 0 fail / 17 broken. Bit-exact fingerprints (NaN count + hash of every value's bits) held at every rung, and at
-t 12as well as-t 1. Reproduction harness and the fingerprint oracle are described inregrid-notes/2026-08-20-perf-ladder.md§6.The live production run and its store, log, done log and column cache were not touched at any point during the campaign.
Before merge
claude/budget-frontier(needs CR PR #137 merged and released).claude/perf-ladder-predicateswork upstream, then repin GO to a released version. Without this the −15 % from N2+N4 is not available.scripts/copdem_production.jl(carried from PR 3 in this stack).🤖 Generated with Claude Code
https://claude.ai/code/session_01AC7rbi9eqZypWseMjBGSJ2