Skip to content

Commit c8f28da

Browse files
author
Rob Patro
committed
Merge pull request #19 from COMBINE-lab/line-aware-cli
2 parents e193c2b + 63382ae commit c8f28da

35 files changed

Lines changed: 4506 additions & 109 deletions

ARCHITECTURE.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,51 @@ across interior checkpoints, validates every trailer, and reports
119119
`DecoderPath::IndexedParallel`. The complete design is recorded in
120120
`docs/design/indexed-parallel-decode.md`.
121121

122+
## Ordered line counting and line seeking
123+
124+
Newline counting is decoder configuration, while index construction remains an
125+
explicit per-operation choice. `DecoderBuilder::count_lines(true)` adds one
126+
scalar scan over final ordered output and places the result in
127+
`DecodeReport::line_count`; the field is `Option<u64>`, so `DecodeReport`
128+
remains `Copy`. The disabled path keeps no counter and does not scan output.
129+
130+
Counting occurs at the `Output` boundary after predecessor markers have been
131+
resolved. Worker-local or speculative buffers are not valid inputs because an
132+
unresolved symbol may later become a newline. Push decodes wrap their final
133+
output sink in a decode-local counter. Pull-driven streaming uses the same
134+
counter immediately before publishing each final chunk. Strict indexed
135+
parallel decoding counts its ordered span handoff.
136+
137+
When indexing and counting are both requested, `IndexCollector` advances one
138+
cursor through the retained checkpoint vector as ordered byte ranges arrive.
139+
It annotates checkpoints in place, avoiding duplicate tree nodes and logarithmic
140+
lookup work per point. Checkpoints at decoded EOF are resolved during
141+
finalization. Empty gzip members may produce multiple checkpoints at the same
142+
output offset; they correctly share one line count. If any retained checkpoint
143+
was offered too late to resolve, finalization clears every checkpoint line
144+
field and the total rather than publishing partial metadata.
145+
146+
`IndexedReader::seek_to_line` selects the latest complete annotated checkpoint
147+
proven to precede the requested zero-based line's start, resumes through its
148+
exact predecessor window, and scans forward to the terminating newline. It
149+
uses a strictly smaller line count for nonzero targets because an equal-count
150+
checkpoint can sit inside a long line. The scan is bounded by the actual
151+
decoded distance to the previous retained checkpoint for an in-range target;
152+
external, streaming-built, or thinned indexes may have gaps larger than their
153+
recorded target spacing. A request beyond the final line lands at
154+
decoded EOF. The method rejects indexes without a total and per-point line
155+
counters. gztool version 1 is translated at the codec boundary because it
156+
stores one-based checkpoint line numbers, while the Rust API stores preceding
157+
newline counts.
158+
159+
The complete design and CLI integration are recorded in
160+
`docs/design/line-aware-cli.md`.
161+
162+
For strict full-stream decoding through an imported index, enabling line
163+
counting also authenticates every supplied checkpoint line offset and the
164+
total against final ordered bytes. Random-access seeking alone must trust those
165+
annotations because it intentionally does not scan the skipped prefix.
166+
122167
## Marker/window algorithm
123168

124169
Before the generic marker grid starts, path admission derives a useful worker

BENCHMARKING.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,43 @@ ratio between the two groups isolates indexing overhead on identical data.
1515
Record checkpoint count, serialized native-index size, and peak RSS alongside
1616
throughput when evaluating a release candidate.
1717

18+
The paired `bgzf_line_count` and `bgzf_line_count_with_index` groups use a
19+
32 MiB FASTQ-like stream split into 4 KiB BGZF blocks (more than eight thousand
20+
retained checkpoints). Their ratio isolates checkpoint annotation from SIMD
21+
newline counting, while peak RSS from the benchmark process exposes growth per
22+
checkpoint. The index builder annotates its checkpoint vector in place and
23+
must not recreate the former tree-node allocation per checkpoint.
24+
25+
For a larger peak-RSS diagnostic without Criterion's sampling allocations:
26+
27+
```bash
28+
cargo build --release -p rapidgzip-bench --bin line_index
29+
/usr/bin/time -v target/release/line_index count 8 128
30+
/usr/bin/time -v target/release/line_index index 8 128
31+
```
32+
33+
Both modes generate the same 128 MiB stored-BGZF fixture with 4 KiB blocks and
34+
discard generation scratch before timing. The index mode retains more than
35+
32,000 line-annotated checkpoints. Compare elapsed time and maximum resident
36+
set size across repeated, alternating invocations.
37+
38+
### 2026-08-03 line-index diagnostic
39+
40+
On the dual-socket Xeon E5-2699 v4 host described below, five alternating
41+
un-pinned runs of the command above at eight workers produced these medians:
42+
43+
| mode | decoded bytes | checkpoints | timed decode | peak RSS |
44+
|---|---:|---:|---:|---:|
45+
| line count | 134,217,728 | 0 | 48.362 ms | 136,700 KiB |
46+
| line count + index | 134,217,728 | 32,768 | 51.518 ms | 138,088 KiB |
47+
48+
In-place annotation therefore added 6.5% elapsed time and 1,388 KiB peak RSS,
49+
or about 43 bytes per retained checkpoint, on this deliberately dense index.
50+
The larger quick Criterion sweep measured count-plus-index overhead of 11.9%,
51+
11.9%, and 5.9% at 1, 4, and 16 workers respectively; those single-sample
52+
figures are retained as a routing diagnostic rather than a portable speed
53+
claim.
54+
1855
The `decoder_reader_deflate_formats` group compresses one identical payload as
1956
gzip, zlib, and raw DEFLATE, then decodes each through the public reader at the
2057
same worker budgets. It is a paired container-overhead regression check, not a
@@ -450,3 +487,23 @@ FASTQ contains many small internal DEFLATE blocks, so multi-worker throughput
450487
fell below 350 MiB/s despite synthetic success. Accumulating internal block
451488
results into configured output chunks restored scaling; a focused integration
452489
test now enforces a chunk-plus-span bound on writer handoffs.
490+
491+
## 2026-08-03 line-counting FASTQ diagnostic
492+
493+
The line-aware CLI successor was checked on the same public FASTQ file and
494+
dual-socket host described above. This was an unpinned implementation
495+
diagnostic, not a release parity matrix. The release CLI used a 16-worker
496+
budget, one warmup per mode, and five alternating `/usr/bin/time` runs. Both
497+
modes decoded and verified the full stream to a sink; the counted mode reported
498+
4,320,920 newline bytes.
499+
500+
| mode | five wall times (s) | median (s) | decoded MiB/s |
501+
|---|---|---:|---:|
502+
| counting disabled (`--test`) | 0.25, 0.27, 0.25, 0.25, 0.24 | 0.25 | 1,380.2 |
503+
| counting enabled (`--count-lines`) | 0.28, 0.28, 0.28, 0.27, 0.27 | 0.28 | 1,232.3 |
504+
505+
The first scalar implementation measured 0.52 seconds in the counted mode on
506+
the same warm cache, more than twice the 0.25-second control. Runtime-dispatched
507+
AVX2 with SSE2 and NEON baselines reduced the measured optional cost to roughly
508+
12%. The ordinary disabled path still performs no scan and only tests the
509+
decode-local enabled flag at each ordered output chunk.

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,40 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55

66
## [Unreleased]
77

8+
### Fixed
9+
10+
- Parallel task queues now publish their availability counters before making
11+
work visible, preventing a worker from consuming a task before the matching
12+
counter increment.
13+
- CLI index import now streams through the core allocation limits, requires an
14+
exact format length, and rejects trailing data instead of buffering an
15+
untrusted file or treating arbitrary bytes as an empty GZI.
16+
- Index export now serializes transactionally through a same-directory
17+
temporary file, preserving an existing destination after incompatibility or
18+
write failures. Normal decode output treats an early closed pipe as a
19+
successful consumer exit through wrapped decoder errors as well as direct
20+
I/O errors.
21+
- Output collision checks now compare the already-open input file with the
22+
destination by file identity on Unix and Windows, preventing hard-link and
23+
symlink aliases from truncating a forced input.
24+
825
### Changed
926

27+
- The `rapidgzip-rust` CLI now exposes rapidgzip-compatible decoding, counting,
28+
index import/export, range extraction, output, format, and reporting options.
29+
Imported indexes drive strict full-stream indexed decoding. Options whose
30+
semantics are not implemented, including disabled verification, sparse
31+
windows, and shared-cursor I/O strategies, are rejected instead of ignored.
32+
- The CLI's format-neutral index default is now native. Payload output can be
33+
combined with byte and line counting in one pass; reports move to stderr when
34+
stdout carries decoded bytes. Imported range extraction documents its
35+
partial-verification boundary, while `--verify` performs a complete strict
36+
pass and otherwise-ignored decoder options are rejected.
37+
- Line-aware index construction annotates the retained checkpoint vector in
38+
place instead of duplicating every point in ordered tree structures. Strict
39+
indexed decoding with line counting enabled now authenticates imported
40+
checkpoint and total line counters.
41+
1042
- Generic gzip, zlib, and raw-DEFLATE path selection now uses a bounded
1143
machine-, runtime-budget-, task-count-, and input-aware admission screen.
1244
It replaces a fixed low-thread marker/window policy, keeps short inputs and
@@ -20,6 +52,21 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2052

2153
### Added
2254

55+
- Opt-in newline counting through `DecoderBuilder::count_lines` and the new
56+
`DecodeReport::line_count` scalar, preserving `DecodeReport: Copy`. Combining
57+
counting with explicit index construction annotates every retained
58+
checkpoint and the index total on final ordered output. Concatenated and
59+
empty gzip members, BGZF, zlib, raw DEFLATE, streaming push/pull, marker
60+
decoding, and strict indexed decoding share the same semantics.
61+
- `DeflateIndex::checkpoint_at_or_before_line` and
62+
`IndexedReader::seek_to_line` for zero-based line access. gztool version 1
63+
import/export translates its one-based checkpoint numbering and refuses
64+
incomplete line metadata.
65+
- CLI byte and line ranges using rapidgzip's comma-separated `SIZE@OFFSET`
66+
syntax, including binary byte units, `L`, `inf`, overlaps, and ordered
67+
extraction. The CLI can read and write native, GZIDX, gztool, line-aware
68+
gztool, and BGZF `.gzi` indexes.
69+
2370
- Strict parallel full-stream reuse of caller-supplied `DeflateIndex` values
2471
through `Decoder::decode_from_index` and `Decoder::reader_from_index`. Every
2572
span must match its next compressed-bit and decompressed-byte checkpoint;

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ libz-rs-sys = "0.6.6"
2323
paraseq = { version = "0.4.14", default-features = false }
2424
proptest = "1.11.0"
2525
rapidgzip-core = { path = "crates/rapidgzip-core", version = "0.1.0" }
26+
same-file = "1.0.6"
2627

2728
[workspace.lints.rust]
2829
unsafe_op_in_unsafe_fn = "deny"

README.md

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ The project provides:
1616
stream suitable for parsers such as [paraseq];
1717
- opt-in random-access index construction, interoperable index formats, and a
1818
decoded-output `Read + Seek` adapter;
19+
- opt-in newline counting, line-annotated indexes, and indexed seeking by
20+
zero-based line number;
1921
- decoding of non-seekable compressed input such as standard input, a FIFO, a
2022
process substitution, or a socket.
2123

@@ -321,7 +323,51 @@ Format parsers apply explicit checkpoint and window-allocation limits through
321323
`IndexReadOptions`. The native format represents every container. `.gzi`
322324
export requires an index proven to come from BGZF; GZIDX and gztool export
323325
require gzip-family provenance; gztool line-aware export requires real line
324-
counters and never invents them.
326+
counters and never invents them. The CLI detects formats from a bounded prefix,
327+
streams index parsing, rejects trailing bytes, and writes exports through a
328+
same-directory temporary file so failed conversions cannot truncate an
329+
existing index.
330+
331+
Line metadata is collected only when requested. Enabling
332+
[`DecoderBuilder::count_lines`] adds `DecodeReport::line_count`; combining it
333+
with an explicit indexing operation also annotates every retained checkpoint:
334+
335+
```rust,no_run
336+
use rapidgzip_core::{Decoder, IndexOptions, IndexedReader};
337+
use std::fs::File;
338+
use std::io;
339+
340+
fn main() -> Result<(), Box<dyn std::error::Error>> {
341+
let decoder = Decoder::builder()
342+
.decoder_threads(8)
343+
.count_lines(true)
344+
.build()?;
345+
let source = File::open("reads.fastq.gz")?;
346+
let indexed = decoder.decode_with_index(
347+
&source,
348+
&mut io::sink(),
349+
IndexOptions::default(),
350+
)?;
351+
assert_eq!(indexed.decode.line_count, indexed.index.total_line_count());
352+
353+
let mut reader = IndexedReader::new(source, indexed.index)?;
354+
let byte_offset = reader.seek_to_line(1_000_000)?;
355+
println!("line 1000000 begins at decoded byte {byte_offset}");
356+
Ok(())
357+
}
358+
```
359+
360+
A line offset is the number of `b'\n'` bytes preceding a position. Line zero
361+
begins at decoded byte zero. A final unterminated line does not increase
362+
`line_count`, while `seek_to_line` can still reach it after scanning from the
363+
nearest checkpoint. Counting happens once on final ordered bytes, after marker
364+
resolution, and is disabled by default. `DecodeReport` remains `Copy` because
365+
the result is an optional scalar. A line-aware index is published only when
366+
every retained checkpoint received an exact count; partial metadata is never
367+
presented as complete. Strict full-stream decoding with line counting enabled
368+
also recomputes imported per-checkpoint and total line counts, rejecting
369+
structurally valid but incorrect navigation metadata. Without line counting,
370+
imported line offsets remain explicitly trusted navigation data.
325371

326372
`IndexedReader` validates the index and any recorded source size before use.
327373
Resuming at a gzip member or zlib-header checkpoint verifies that complete
@@ -346,6 +392,29 @@ rapidgzip-rust -P 16 reads.fastq.gz > reads.fastq
346392
# Verify every member and discard decoded output.
347393
rapidgzip-rust -P 16 --test reads.fastq.gz
348394

395+
# Count decoded bytes or newline bytes.
396+
rapidgzip-rust --count reads.fastq.gz
397+
rapidgzip-rust --count-lines reads.fastq.gz
398+
399+
# Decode and count in one pass. Counts use stderr when payload uses stdout.
400+
rapidgzip-rust -c --count --count-lines reads.fastq.gz > reads.fastq
401+
402+
# Build a native index, then use it for strict full-stream parallel decoding.
403+
rapidgzip-rust --test --export-index reads.rgzidx \
404+
--index-format native reads.fastq.gz
405+
rapidgzip-rust --import-index reads.rgzidx -c reads.fastq.gz > reads.fastq
406+
407+
# Extract byte and zero-based line ranges in the requested order.
408+
rapidgzip-rust --ranges '1KiB@4MiB,10L@1000L' -c reads.fastq.gz
409+
410+
# Authenticate the complete source before an imported random-access read.
411+
rapidgzip-rust --import-index reads.rgzidx --verify \
412+
--ranges '10L@1000L' -c reads.fastq.gz
413+
414+
# Export gztool version 1 with real line counters.
415+
rapidgzip-rust --count-lines --export-index reads.gzi \
416+
--index-format gztool-with-lines reads.fastq.gz
417+
349418
# Refuse to overwrite an existing output file.
350419
rapidgzip-rust -P 16 --output reads.fastq reads.fastq.gz
351420

@@ -356,12 +425,36 @@ cat reads.fastq.gz | rapidgzip-rust - > reads.fastq
356425
rapidgzip-rust <(some_producer) > reads.fastq
357426
```
358427

359-
`-P`/`--threads` is a maximum decoder-worker budget. Parallel paths bootstrap
428+
The CLI auto-detects gzip or zlib by default; raw DEFLATE requires
429+
`--format raw-deflate`. `--chunk-size` controls the decoded handoff size in
430+
KiB. Output defaults to stdout when redirected; at a terminal, a regular input
431+
derives a safe output filename using case-insensitive compression suffixes.
432+
Existing files require `--force`. Index export defaults to the native,
433+
format-neutral representation; gzip-specific interoperable formats must be
434+
selected explicitly.
435+
436+
`-P`/`--decoder-parallelism` (`--threads` is an alias) is a maximum
437+
decoder-worker budget. Parallel paths bootstrap
360438
from the smaller of the affinity-visible processors and this requested budget,
361439
then create more workers only while measurements justify them. They may retain
362440
fewer active workers when the input exposes less parallel work, the consumer is
363441
backpressured, or additional concurrency reduces throughput.
364442

443+
Imported indexes are never advisory. Full-stream operations use
444+
`decode_from_index`, and malformed, incomplete, or source-mismatched indexes
445+
fail without falling back. Range extraction uses `IndexedReader`; a line range
446+
requires complete line metadata in an imported index or builds a line-aware
447+
index first. A seek from an interior DEFLATE checkpoint cannot authenticate
448+
the skipped prefix. For that reason, `--verify` on an imported range performs
449+
a complete strict indexed decode before extraction. Decoder options that would
450+
otherwise be ignored by an unverified imported range are rejected and explain
451+
that `--verify` is required. `--no-verify`, `--sparse-windows`, and the `sequential` and
452+
`locked-read` I/O methods are rejected because the current implementation
453+
cannot honor their semantics. Outside imported ranges, complete decode paths
454+
already verify their selected framing. `--no-sparse-windows`, `-d`, and `-k`
455+
remain compatibility aliases. This is a deliberately compatible subset, not a
456+
claim that every rapidgzip CLI option is implemented.
457+
365458
## Correctness and resource behavior
366459

367460
- Every accepted gzip member is terminated by an actual final DEFLATE block
@@ -439,7 +532,8 @@ The integration suite covers gzip, zlib, raw DEFLATE, multi-member streams,
439532
BGZF, corruption, format detection across short/interrupted reads, false header
440533
candidates, output limits and exact sizes, index construction, indexed
441534
full-stream parallel decode, seeking, cancellation, one-byte consumer buffers,
442-
and direct paraseq consumption. Generated benchmark corpora and large
535+
line counting and seeking, CLI index/range workflows, and direct paraseq
536+
consumption. Generated benchmark corpora and large
443537
sequencing files are deliberately not stored in the repository.
444538

445539
## Releasing
@@ -481,6 +575,7 @@ MIT. See [LICENSE-BSD-3-CLAUSE] and [LICENSE-MIT].
481575
[`DecoderBuilder::input_page_size`]: https://docs.rs/rapidgzip-core/latest/rapidgzip_core/struct.DecoderBuilder.html#method.input_page_size
482576
[`DecoderBuilder::output_limit`]: https://docs.rs/rapidgzip-core/latest/rapidgzip_core/struct.DecoderBuilder.html#method.output_limit
483577
[`DecoderBuilder::expected_uncompressed_size`]: https://docs.rs/rapidgzip-core/latest/rapidgzip_core/struct.DecoderBuilder.html#method.expected_uncompressed_size
578+
[`DecoderBuilder::count_lines`]: https://docs.rs/rapidgzip-core/latest/rapidgzip_core/struct.DecoderBuilder.html#method.count_lines
484579
[`DecoderReader`]: https://docs.rs/rapidgzip-core/latest/rapidgzip_core/struct.DecoderReader.html
485580
[`DecoderStats::path`]: https://docs.rs/rapidgzip-core/latest/rapidgzip_core/struct.DecoderStats.html#structfield.path
486581
[`DecoderStats::configured_workers`]: https://docs.rs/rapidgzip-core/latest/rapidgzip_core/struct.DecoderStats.html#structfield.configured_workers

SAFETY.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,19 @@ boundary or bypasses full member inflation and trailer verification.
8787
The AArch64 NEON implementation similarly guards each 16-byte input load and
8888
stores its comparison lanes only into a live local 16-byte array.
8989

90+
## SIMD newline counting
91+
92+
Optional line counting scans only final ordered byte slices. On x86-64, AVX2
93+
is called only after runtime detection; otherwise the SSE2 implementation is
94+
valid because SSE2 is part of the architecture baseline. Their unaligned loads
95+
are guarded by `offset + width <= bytes.len()`, and comparison masks are only
96+
counted, never used for memory access. On AArch64, Advanced SIMD is baseline and
97+
the NEON loop applies the same 16-byte bound before each unaligned load. Every
98+
implementation sends its shorter tail through safe scalar Rust and is
99+
differentially tested against that scalar implementation across vector edges,
100+
randomized contents, and every starting-address displacement from zero through
101+
63 bytes.
102+
90103
## Native DEFLATE bit loads
91104

92105
The hot bit reader and Huffman peek perform an unaligned `u64` load only after

0 commit comments

Comments
 (0)