Repository navigation
Browser engine: Engine.materialize writes exactly the box, stitched from the served chunks (extent: "box", the default) - #41
Merged
Conversation
…'s Run tab wants a region saved as that region and not as the whole level with the region's chunks in it: extent "box" (the default, "level", is the array of the level's whole shape with the box's chunks rounded out to whole chunks, as before) makes the array hi − lo voxels (the box clipped to the level and rounded out to whole voxels, not to chunks), its translation the level's moved by lo voxels (layout.ts: a dataset's lo, so voxel 0's centre lands on the level's voxel lo), its chunks the served chunk shape counted from lo, those at its far edge stored whole with the fill value past the box, as zarr v3's regular grid says and zarr v2's one chunk shape implies (zarrita, zarr-python 3.4 and tensorstore read such a store back, and the last two refuse an edge chunk cut short); where lo is off the served chunks' edges each of the box's chunks is stitched from the served chunks it overlaps, up to two along each axis, and since a chunk only materialize asked for is not kept (ac41da8) each served chunk is asked for once: writeChunks walks them in C order through the same background queue and stitcher() copies each into the box's chunks it overlaps, a box chunk written once its last part is in and a buffer made only when a part holds a value not zero, so at most a slab of the box's chunks (one chunk thick along the first axis on which lo is off the edges), a row and one more are held, none where lo is on chunk edges, the served chunks then written as they are; a box of the whole level is the level's extent byte for byte; progress gains computed and needed (the served chunks computed, of those the box covers) beside done and total (the array's chunks), each only growing; a served chunk that fails in a box is named as the engine names it (view/level/z.y.x); the tests cover the box's metadata and translation, aligned, unaligned, cropped and smaller-than-a-chunk boxes, edge chunks, both formats read back by zarrita, channel axes, skipEmpty, abort, failure, each served chunk asked for once and the chunks held; the formats, caching and design pages and the CHANGELOG say so
…slab, as the chunkmirage session asked in its review of 431499b: extent defaults to "box", since a page that passes a box expects an array of that box, and "level" stays for several boxes written into one store (a box of the whole level is the same store, byte for byte, either way); the served chunks are walked axis by axis (walkOrder, chunkIndices taking an order of the axes), the axes the box does not split (splitAxes: lo on the chunk edges there, or the box within one chunk) outermost in C order, then the split ones, the one with the most of the box's chunks first, so the box's chunks held at once are a slab across the other split axes, a row and one more (n2 × n3 + n3 + 1 with n1 ≥ n2 ≥ n3 chunks along three split axes, n2 + 1 along two, 1 along one, none along none), the fewest of any order of the axes: a wide, shallow box in C order held a slab across its two wide axes (a 512 × 2048 × 2048 box of 3-channel float32 64³ chunks, 1057 chunks, 3.1 GB, now 265, 795 MiB; on a chunk edge along z, 33); the level's extent splits none, so it is walked in C order as before; the bytes of a value come from asread's TYPED, not a table of their own; the Python side says it writes the level's extent only, for now (formats, cli); the tests ask for the level's extent by name where they are of it, and check the default, the order of the axes, the asks in it and the chunks held for each alignment and order; formats, design, caching and the CHANGELOG say so
davidackerman
marked this pull request as ready for review
October 16, 2026 19:45
This was referenced Oct 16, 2026
davidackerman
added a commit
to janelia-cellmap/cellmap-flow
that referenced
this pull request
Oct 16, 2026
davidackerman
added a commit
that referenced
this pull request
Oct 16, 2026
…ed, what materialize will write and the most bytes of the box's chunks it will hold, as the chunkmirage session asked in its second review of #41, so a page can warn, or offer to put the box on the chunk grid along an axis, before it starts: plan(region(...)) in materialize.ts gives the array's shape and chunk shape (the channel axes the ops add first), its chunks, the served chunks it computes, the axes it splits and the walk's order, and peakChunks with its bytes, peakBytes (chunkBytes each, besides the served chunks on their way); peakChunks is exactly what the walk holds, the documented bound (a slab across the split axes inside the outermost, n2 × n3 + n3 + 1 along three, n2 + 1 along two, 1 along one, none along none) where each split axis has three chunks or more, and where one has fewer (one, or two the last of which stays within one served chunk), every axis inside it has two at most and they are counted one by one, so it is never more than the walk holds (the bound alone overcounted 20 of 3000 random boxes), and it takes a tenth of a millisecond for a 10k³ box; the Region keeps the channel axes it adds (added); Engine.materialize and materializePlan share the region's making (regionOf); the tests check the plan of a box with channels and of the level's extent, that the projected peak is what the walk holds for each alignment (three, two, one and no split axes), for the wide box and for 400 random boxes; formats, design and the CHANGELOG say so, and that a smaller peak, should it ever matter, comes from computing the plane of served chunks two slabs share twice along the outermost split axis, not from compressing chunks while they wait or refusing a box; two over-long lines the commit before left in caching.md and design.md are rewrapped
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.
Why
cellmap-flow's browser app (its Run tab) uses
Engine.materializeto save a model's prediction over a box the user draws. Until now the saved array always had the whole level's shape. Only the box's chunks were present, the box was rounded out to whole chunks, and the level's translation was kept. A 300³ box on a large level became a level-sized array with a few chunks in it, plus up to a chunk of extra voxels on each side. The user asked: "write out only the box, also don't do it in whole chunks, you can just truncate the writing of the last chunks."There are two commits:
"box"becomes the default, the walk order is chosen to keep the buffered slab small, and the byte widths come fromTYPED.The API
One new option, in
web/src/materialize.ts:"box"(the default). A page that passes a box expects an array of that box. The array ishi − lovoxels on the spatial axes. The box is clipped to the level and rounded out to whole voxels, not to chunks. Its translation is the level's, moved bylovoxels. Its chunks have the served chunk shape, counted fromlo. Channel axes added by the ops stay whole and first."level". The array has the level's whole shape and the level's translation, and the box's chunks are written into it, rounded out to whole chunks. This is what every call did before, and it is what several boxes written into one store need.lo/hi(tested).MaterializeProgressgainscomputedandneeded: the served chunks computed so far, out of those the box covers.done,total,writtenandbytesstill count the written array's chunks.onProgressis called once after the metadata, then once per served chunk, after the array chunks it completes have been written. With"level"that is once per chunk, as before."box",donecan trailcomputedby about a slab, socomputed/neededlets a progress bar move during the first slab.Materializedis unchanged.A served chunk that fails during a
"box"write is named the way the engine's own log names it:chunk v/0/2.0.0: <why>."level"keepschunk 0/0/0/1 of v: <why>.Smaller additions:
materialize.tsexportsvoxelBox,splitAxes,walkOrder,stitcher,chunkBytesandCompleted.chunkIndices(lo, hi, order?)takes an axis order.chunkRangenow usesvoxelBoxand gives the same results.Regiongains the array's geometry.layout.tsgainsDataset.lo?,translation(v, level, lo?),array3(..., { shape })andarray2(..., compressor, shape). They default to the level's shape, so the routes are unchanged.asread.ts'sTYPED(BYTES_PER_ELEMENT).What it writes
origin + lo × voxel(formats.md says "a translation is where voxel 0's centre is";ome.tsdoes the same for a region). The scale is the level's, and the dataset path stays"0".[12, 28, 20]for origin 4, voxel 8,lo = [1, 3, 2].Stitching: each served chunk computed once
A chunk that only materialize asked for is not kept in the engine's chunk cache (ac41da8). Asking for the served chunks again for each box chunk they overlap would therefore compute each one up to 8 times. Instead:
writeChunkswalks the served chunks the box covers once each, inwalkOrder(below), through the sameproduce. On the page that is the existing background claim: background claims, the bounded queue, slots−1 and promotion by a client are untouched. Background jobs at one level run in submission order, so the queue follows the walk.stitcher(r).add(index, bytes)copies the served chunk into each box chunk it overlaps, up to 2 per axis, a row at a time. A box chunk counts the served chunks it is made of. When the last one arrives, the box chunk is returned and written at once (gzip,put), then forgotten."level", and every chunk of a box that starts on chunk edges.skipEmpty.Walk order and the bound on memory
Only box chunks that have been started but are not complete are held, plus the served chunks in flight.
The walk order. The served chunks are walked axis by axis:
lois off the chunk edges there; a box lying within one chunk along an axis doesn't split it.The bound. The most box chunks held is a slab across the split axes except the outermost one, plus a row and one more. Let n₁ ≥ n₂ ≥ n₃ be the box's chunk counts along the split axes. Then:
concurrency> 1 can add a few more, because chunks arrive out of order. The level's extent splits nothing, so it is walked in C order as before. The tests assert the count held, exactly, for each alignment and for several orders.A note on the review's wording. The review asked for "the thinnest misaligned axis outermost" to keep the slab small. The slab spans the axes other than the outermost split axis, so the smallest slab comes from putting the split axis with the most chunks outermost. Putting the one with the fewest outermost is the worst order. I implemented the stated goal, the smallest slab. For 3-channel float32 affinities in 64³ chunks (3 MiB each), with the box off the chunk edges on every axis:
loon a z chunk edgeSo the reorder fixes a wide, shallow box. A box split on all three axes still holds about a cross-section of the box's chunks, about 800 MiB in the review's 2048 × 2048 × 512 example, whatever the order. No order of the axes holds fewer. What shrinks it further is putting
loon chunk edges along an axis. Box chunks that are all background take nothing, so these figures are upper bounds.What changes for existing callers
"box". A call that passeslo/hiwithoutextentnow gets an array of exactly the box. A call with neither gets the same store as before, byte for byte. Passextent: "level"for the old array. cellmap-flow, the only known caller, wants"box"."level"is today's write. Its metadata, chunk keys and bytes, skip rule, abort and failure behaviour, error messages and C-order walk are all unchanged.Engine.materialize's code is unchanged, apart from its doc comment. Producer, queue, the kept-chunks rule and background scheduling are as before.chunkmirage materialize --boxandchunkmirage.materialize(box=)round out to whole chunks in an array of the level's shape. formats.md and cli.md now say the exact box is not supported there yet. The sameextentoption, defaulting to"box", is planned there after this merges.Docs
docs/concepts/formats.md:docs/design.md: the options list, why each served chunk is asked for once, and the walk order.docs/concepts/caching.md: next to the not-kept rule, why materialize asks for each chunk once.docs/reference/cli.md: the--boxrow says the exact box is not supported there yet.CHANGELOG.md: the unreleasedEngine.materializebullet is amended.materialize.ts,layout.tsandengine.ts.Tests
web/test/materialize.test.tshas 14 new tests, sonpm testgoes from 56 to 70. Existing tests of the old behaviour now passextent: "level"by name. Their progress checks include the two new fields, and the C-order test also covers a different axis order.[20, 4, 36]on level 1, with 16 nm voxels and a 4 nm origin). Also clipping, rounding to whole voxels, the default, channel axes, and the level's extent unchanged.lobut ending inside chunks short of the level's end is cropped.Open questions
loon chunk edges along one axis when the box is large? Or should materialize also bound it, for example by compressing partial chunks while they wait, or refusing above a budget?Engine.materializeitself still has no automated test, because Node can't importengine.ts(its JSON import). Its code path is unchanged here, and the parts this PR changes run innpm testover a fake producer.Settled in the review: fractional boxes are rounded out to whole voxels;
Materializedgets nocomputed; nothing past the level's edge is forced to zero.Checks:
npm run checkinweb/passes: both typechecks, 70/70 tests, and the build.mkdocs build --strictpasses.pytest -q tests/test_docs.pypasses (6).ruff check src tests examples webpasses.pytest -qpassed (313 passed, 9 skipped). No Python changed after that.