Skip to content

Plan assembly up front and rearrange data already in the target - #323

Open
folbricht wants to merge 5 commits into
masterfrom
inplace-seed
Open

folbricht wants to merge 5 commits into
masterfrom
inplace-seed

Conversation

@folbricht

@folbricht folbricht commented Mar 20, 2026 •

Copy link
Copy Markdown
Owner

Summary

Reworks assembly around a plan that decides where every chunk comes from before anything is written, and uses that to rearrange the data a target file already holds. Extracting a new version of a large image over the old one then moves the data that's still needed within the file instead of fetching it from the store.

  • Replaces the sequencer with an internal assemblePlan that resolves a source for every chunk position up front, then hands out the resulting steps as a DAG with explicit dependencies
  • A seed whose data file is the target itself, detected with os.SameFile, becomes in-place moves rather than a file seed that would read the file while it's being written
  • Orders the moves so each one reads its source before another overwrites it. Moves in a dependency cycle, such as two chunks swapping places, stash their source in memory instead: breakCycles runs a depth-first search and marks the tail of every back edge
  • Fills those stashes on demand, in the step that's about to overwrite the source, and releases them once the move wrote its chunk. The memory they may hold at one time is what's left under GOMEMLIMIT; a stash that doesn't fit is dropped and its move takes the chunk from the store
  • Covers the chunks of one contiguous shift with a single move, which then runs as one step: an index of 8187 chunks shifted by 16 MiB moves in 33 steps with 63 dependencies rather than 8187 with 8186. A run stays within the distance it travels, and runs that a merge-induced dependency cycle passes through are split again
  • Clones the runs of in-place moves when the filesystem supports reflinks and the source and destination have the same block alignment, copying only the unaligned head and tail. Moves of a single chunk are copied: cloning chunk by chunk locks and flushes the file for every one of them, which is slower than copying
  • Splits assembly sources into skipInPlace, inPlaceCopy, fileSeedSource, selfSeedSegment and copyFromStore, each of which knows how to execute itself, what to record in the stats, and what it does to the target: the range it writes and the range it reads, either the content from before assembly or what other steps write. All dependencies and stash fills are derived from those ranges in one pass: a step reading what others write waits for them, a step reading the old content runs before anything overwrites it or has its stash filled first
  • Finds the chunks already in the right place by hashing the target once, concurrently, and reuses that when planning again for other seeds
  • Takes chunks from a source that clones them before one that copies, and from the seeds before the target's own data
  • Validates the seed indexes against their files before assembly starts, instead of reading back and hashing what was written. A seed file that changes during assembly is noticed by its size and modification time; the output is then verified and the chunks that don't match are taken from the store again, counted in the new chunks-backfilled stat
  • Shrinks a target that's larger than the output after assembly, so data beyond the end can still be moved into place
  • Integration test exercising all source types together (in-place skip, in-place copy with cycles, file seed, self-seed, store fetch) with variable-size chunks, plus a randomized test that rearranges random layouts under several stash limits. A further test checks the order of every step against the ranges it reads and writes on 2000 random plans, working out those ranges independently of the sources' declarations

Performance

master (ad89f62) against this PR (1bfb50e) on a 512 MiB image of random data, with variants built from 16 MiB blocks. Local store, -n 4, btrfs with reflink support, 4 CPUs, warm page cache. Each figure is the median of 5 runs per build, with the builds interleaved; the times don't include writing back to disk.

At a glance

  • Nothing to reuse: wall time is unchanged, CPU is up 7–9%. Every chunk taken from the store is a step of its own, which the scheduler hands out and collects.
  • Reusing data: with a file seed or data repeated within the image, extraction is 1.6× to 4.6× faster and uses 1.6× to 19.5× less CPU. master validates the seed data before assembly and then reads back and hashes everything it wrote from a seed; this PR validates once. Repeated data is also written as one range per run rather than chunk by chunk.
  • In place: master fails when the seed's data is the target. With this PR, rearranging the regions of the image takes 509 of its 8694 chunks from the store, where extracting over the old file without the seed takes all 8694.
  • Reflinks: a plain file seed clones exactly the same bytes as on master. Where a chunk can be cloned from within the image but only copied from a seed, it's cloned: 447.7 MiB instead of 1.3 MiB, and 64 MiB written instead of 512 MiB.

Extract time

Wall and CPU time, master → this PR. Bold marks a difference where the ranges of the two builds across the runs don't overlap; the factor is in parentheses.

Nothing to reuse:

scenario wall CPU
everything from the store, 8.7k chunks 1.14 → 1.25 s 3.69 → 3.93 s (+7%)
everything from the store, 57k chunks 1.87 → 1.94 s 5.99 → 6.54 s (+9%)
target already holds the output 0.68 → 0.70 s 2.32 → 2.31 s
target holds the old version, no seed 1.89 → 1.79 s 5.89 → 5.97 s

Reusing data from a seed or from the image itself:

scenario wall CPU
file seed, regions rearranged 1.49 → 0.71 s (2.1× less) 3.05 → 1.48 s (2.1× less)
file seed, 16 MiB inserted at the front 3.71 → 1.27 s (2.9× less) 2.87 → 1.35 s (2.1× less)
file seed, 1000 bytes inserted at the front 3.07 → 1.95 s (1.6× less) 3.05 → 1.93 s (1.6× less)
no seed, data repeated within the image 1.75 → 0.38 s (4.6× less) 2.63 → 0.52 s (5.1× less)
chunks both in an unaligned seed and within the image 3.37 → 0.98 s (3.4× less) 5.45 → 0.28 s (19.5× less)

Updating in place

The old version's index as the seed of the target itself, which fails on master:

update wall CPU chunks from the store cloned written extents
16 MiB inserted at the front 2.24 s 4.83 s 242 of 8429 511.8 MiB 16.9 MiB 67
regions rearranged 1.48 s 5.31 s 509 of 8694 0 544.1 MiB 5
1000 bytes inserted at the front 1.45 s 4.77 s 1 of 8188 0 512.1 MiB 3

Only the chunks the old version doesn't have come from the store. The inserted block shifts everything behind it by a multiple of the block size, so those runs are cloned and almost nothing is written. Rearranged regions depend on each other and move chunk by chunk, and a 1000 byte shift overlaps every chunk with its own source; those moves are copied.

Disk layout

The data cloned, the data written and the extents of the result, master → this PR:

scenario cloned written extents
file seed, regions rearranged 511.7 → 511.7 MiB 32.4 → 32.4 MiB 6 → 6
file seed, 16 MiB inserted at the front 511.9 → 511.9 MiB 16.2 → 16.2 MiB 4 → 4
file seed, 1000 bytes inserted at the front 0 → 0 512.1 → 512.1 MiB 3 → 3
no seed, data repeated within the image 418.9 → 447.7 MiB 114.7 → 64.4 MiB 14713 → 18
chunks both in an unaligned seed and within the image 1.3 → 447.7 MiB 511.6 → 64.4 MiB 42 → 17

Cloning is tried before copying, so a chunk found both in a seed it can only be copied from and within the image at a cloneable offset is cloned. Writing repeated data as one range per run rather than per chunk also leaves the file in 18 extents instead of 14713.

Seeds that change during assembly

The data written from a seed isn't read back and hashed afterwards anymore; the seed indexes are validated against their files before assembly starts. To catch a seed file that's written to while it's read, its size and modification time are compared after assembly. When a seed changed, every chunk of the output is hashed and the ones that don't match are written from the store. The whole output is checked, since the self-seed may have copied a damaged chunk elsewhere by then.

Without a change this costs a stat per seed file. A triggered check reads the output once more, 4.3 s instead of about 1 s for the file seed case above. It's also triggered for seeds modified within two seconds of the extract, as some filesystems keep modification times to the second or two, and for block devices, whose modification time means nothing. A writer that restores the modification time, or writes through a memory map that isn't flushed yet, isn't noticed.

Closes #312

@folbricht
folbricht force-pushed the inplace-seed branch 2 times, most recently from 925fa94 to 234a6b1 Compare September 26, 2026 17:07
@folbricht folbricht changed the title Implement in-place seed for assembly pipeline Plan assembly up front and rearrange data already in the target Sep 26, 2026
Replace the sequencer with a plan that decides where every chunk of the
target comes from before anything is written, and runs the resulting
steps as a graph with explicit dependencies. Every kind of step declares
the range of the target it writes and the range it reads, either the
content from before assembly or what other steps write, and the order
of the steps is derived from that.

A seed whose data file is the target itself, found by os.SameFile, is
used in place: chunks already in position are skipped, chunks elsewhere
in the file are moved to where they belong, and only the rest comes
from the store. Moves are ordered so each reads its source before
anything overwrites it. Moves in a dependency cycle hold their source
in a buffer, filled on demand by the step about to overwrite it, within
the memory left under GOMEMLIMIT; a buffer that doesn't fit makes its
move take the chunk from the store. Consecutive chunks that shift
together become one move, split again only where a cycle runs through
it, and such runs are cloned on filesystems with reflinks.

Chunks are taken from a source that clones them before one that copies,
and from seeds before the target's own data. Seed indexes are validated
against their files before assembly instead of reading back what was
written; a seed file that changes during assembly is noticed by its
size and modification time, and the chunks of the output that don't
match are then written from the store again. A target larger than the
output is shrunk after assembly, once its data can't be needed anymore.

Closes #312
Workers that wait for a step exit without error when the context is
cancelled, so an assembly stopped by the dispatcher reported success with
an incomplete target, which could then be renamed over the output. Return
the context's error when steps remain.

The extract help still said a seed modified during extraction could
corrupt the target. The target is verified and backfilled from the store
in that case now.
Buffer also named the scratch memory moves copy through. Stash is what Android's block-based OTA updates call it. splitCycles becomes splitRunsInCycles, it splits runs, not cycles.
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.

desync: use destination chunks as an additional seed source

1 participant