feat: scale line index to gigabase single contigs#18
Conversation
The per-line SequenceIndex retained one LineEntry per physical FASTA line for the lifetime of the reader (~1 byte of heap per base), so a single gigabase-scale contig could exhaust multiple GB of heap before any sequence was read. Replace the per-line list with a LineIndex abstraction and a piecewise-regular representation: a record is stored as regular segments, each a run of lines sharing one base width and byte stride (only the final line of a run may be shorter). Byte offsets are computed arithmetically within a segment, so memory scales with the number of irregularities rather than the number of lines. A uniformly wrapped record collapses to a single segment (RegularLineIndex, four longs); irregular records use SegmentedLineIndex. The builder now segments lines in a single streaming pass (LineSegmenter), retaining only closed segments plus the open one, so index construction is bounded-memory for every input. Persisted index JSON changes accordingly (polymorphic kind: regular/segmented) and stays compact on disk.
Validated against the downstream consumer that surfaced this issue (gff3tools)This fix was validated end-to-end against MethodologyPublished this branch to Result
A Consumer compatibilityConfirmed this is a safe, non-breaking internal change from the consumer's point of view:
So for consumers, adopting this once released will be a plain dependency version bump. Minor note (not blocking)
|
Problem
SequenceIndexretained oneLineEntryper physical FASTA line for the lifetime of the reader — roughly 1 byte of JVM heap per base. A single gigabase-scale contig (e.g. individual wheat/axolotl/lungfish chromosomes >1 Gbp) could therefore exhaust multiple GB of heap andOutOfMemoryErrorbefore any sequence was read, regardless of how memory-efficient the consumer was. Full write-up indocs/2607081600_large_contig_line_index_memory_scaling.md(included in this branch).Approach
Introduce a
LineIndexabstraction with a piecewise-regular representation. A record is stored as a list of regular segments — each a run of lines sharing one base widthWand one byte strideS, with only the run's final line allowed to be shorter. Byte offsets are computed arithmetically within a segment, so memory scales with the number of irregularities, not the number of lines.RegularLineIndex— a uniformly wrapped record (the norm, including gigabase contigs) collapses to a single segment stored as four longs. The 2.5 Gbp case goes from ~2.3 GB to ~48 bytes for the line index.SegmentedLineIndex— records with blank lines, width changes, or mixed separators use a handful of segments; lookup binary-searches the (few) segments then computes arithmetically.LineSegmenter— the builder now segments lines in a single streaming pass, retaining only closed segments plus the open one, so index construction is bounded-memory for every input (no more per-line list, no two-pass fallback).Compatibility
FastaReader/SequenceReader/SequenceFormatReader) and byte-span semantics are unchanged.linesarray is replaced by a polymorphiclineIndex(kind: regular | segmented), which also keeps the on-disk index compact. Warrants a version bump.Tests
LineIndexTest— segmentation verified against a per-line source of truth (uniform → regular, short final line → still regular, stride/width changes → segmented, single-line, empty) plus JSON round-trip for both representations.SequenceIndexBuilderTest— builder picks the right representation (short-tail/CRLF stay regular; blank line / short middle line → segmented) and byte offsets match per-line lookup.