Skip to content

Build an index from sorted files without merging them - #33

Open
tamirms wants to merge 1 commit into
sorted-filesfrom
build-from-sorted-files
Open

tamirms wants to merge 1 commit into
sorted-filesfrom
build-from-sorted-files

Conversation

@tamirms

@tamirms tamirms commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

Building one index from many sorted files used to mean merging them into a single stream and feeding it to a SortedBuilder. The merge is serial, and so is the one goroutine that calls AddKey. In stellar-rpc, where a window index covers up to 90 billion keys in 1,000 files, the merge capped the build at about 29M keys/s, so a full window took about an hour. A single feeder caps any design of that shape at about 70M keys/s.

The build doesn't need a merge. streamhash only needs each block's keys together and the blocks in order, and the order inside a block doesn't change the index. A sorted file holds any key range as one contiguous run. So BuildFromSortedFiles makes the stripe, a run of consecutive blocks holding about a hundred thousand keys, the unit of work:

  • One gatherer reads the files in order and copies each stripe's runs into a pooled buffer. Each file is read once, sequentially, with direct reads where the filesystem allows them.
  • Workers each take a whole stripe, sort its keys into their blocks with a counting sort, and solve the blocks, exactly as AddKey would have fed them.
  • The existing writer commits the blocks in order.

The index is byte-identical to one built by adding the same keys to a SortedBuilder in block order. Equivalence tests pin that across both algorithms, payload and fingerprint sizes, worker counts, stripe sizes and file layouts.

func BuildFromSortedFiles(ctx context.Context, output string, inputs []string, opts ...BuildOption) error

The options are NewSortedBuilder's, and WithWorkers is the only one that tunes speed. The payload size comes from the files. The build refuses a file that is truncated or out of order, a duplicate key, mismatched payload sizes, and an output path that is one of its inputs. A failed or canceled build removes its partial output. Memory is fixed at about 256 MiB, half read buffers and half stripes in flight, plus each worker's block state, however many keys or files there are.

Measured on a 32-vCPU machine with local NVMe, 1.2B keys with a 3-byte payload and a 1-byte fingerprint, two builds each:

Workers 1,000 files 48 files Peak RSS
8 61.9M keys/s 65.8M keys/s 241 to 270 MiB
16 105.6M keys/s 107.9M keys/s 271 to 302 MiB
24 118.6M keys/s 125.2M keys/s 286 to 311 MiB

At 24 workers, reads and index writes together reach the disk's limit. The existing builders are unchanged: sorted AddKey builds of 100M keys ran at 77.8M keys/s before and 78.1M after, and unsorted ones at 39.3M and 39.4M.

Tuning the merge instead (two-way nodes, a loser tree) measured 20 to 38% faster and stays serial. Exposing prefix bits so callers can feed blocks themselves hands the caller streamhash's internals and keeps one feeder.

🤖 Generated with Claude Code

BuildFromSortedFiles builds an index from any number of sorted files (spec
§3.9). It splits the blocks into stripes of consecutive blocks holding
about 32K to 256K keys each. One goroutine gathers each stripe's run out of
every file, through aligned buffers that a pool of read goroutines fills
ahead of it, and each worker takes whole stripes: it sorts a stripe's
records by block with a counting sort, solves its blocks and hands them to
the existing writer, which commits them in block order. Nothing merges,
memory is about 256 MiB whatever the number of keys or files, and the index
is byte-identical to a SortedBuilder's for the same keys. A build whose
output is one of its inputs fails before creating it.

The parallel pipeline now starts with the worker it is given
(startPipeline), runWorker's per-block work is shared with the stripe
workers (loadBlock, blockSolver.build, sendResult), and its drain refuses
to finalize once the pipeline's context is canceled, so a producer that
stops early never leaves a short index. newBuildConfig folds the options
once, and WithPayload records that it was given, so the build can reject
one that disagrees with the files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjQed1VX9uGU9PWpAjBiz7
@tamirms
tamirms added this pull request to stack #36 October 26, 2026 08:00
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.

1 participant