Read performance tuning #312
Replies: 7 comments 1 reply
-
|
Starting with the baseline measurements from the benchmark suite introduced in #308 vcztools benchmark — full-suite findingsDate: 2026-04-23 Median elapsed_s
Backend geomean across all 8 tasks
Key findings1. icechunk wins on metadata-heavy scansIcechunk is the fastest backend on every task except two ties. The lead is largest where the cost is chunk-plan assembly rather than bulk decompression:
Icechunk's virtual chunk manifest means opening an array and planning reads doesn't pay per-chunk filesystem or metadata costs. 2. local-http is consistently slowest
3. FMT-dominated tasks are backend-agnostic
4. Zarr v2 vs v3 directory is nearly a wash
5. obstore over
|
| Status | Tasks |
|---|---|
| In band, all backends | region_info_and_format, filter_info_dp_gt_80, region_filter_format_gq_gt_50 |
| Below the floor (intentional noise-level probes) | iter_no_fields, region_variant_position, region_and_sample_subset |
In band except on local-http |
iter_info_only (34.1 s on HTTP) |
| Above the ceiling on every backend | subset_10_samples (51–62 s) |
subset_10_samples reads one samples-chunk (10 % of the samples axis) across every variant-chunk — ~10 GB of uncompressed genotype data. Staying above the band is inherent at 100 k × 496 k.
Peak memory
Peak RSS stayed consistent across backends; the heaviest tasks are the ones that materialise large FMT chunks:
| Task | Peak RSS (max across backends) |
|---|---|
region_info_and_format |
~1.7 GB |
region_filter_format_gq_gt_50 |
~1.0 GB |
subset_10_samples |
~640 MB |
| Others | ≤ 650 MB |
Beta Was this translation helpful? Give feedback.
-
|
This is the updated version after #313 landed. vcztools benchmark — full-suite findingsDate: 2026-04-23 (rerun with Median wall time (
|
| Task | local-dir | local-dir-zv3 | local-zip | local-http | obstore-file | icechunk |
|---|---|---|---|---|---|---|
iter_no_fields |
0.010 | 0.008 | 0.009 | 0.018 | 0.008 | 0.005 |
iter_info_only |
21.38 | 17.67 | 9.94 | 35.24 | 21.61 | 7.00 |
region_info_and_format |
10.18 | 10.72 | 10.51 | 10.23 | 10.08 | 10.14 |
first_samples_chunk |
19.91 | 20.19 | 20.39 | 22.61 | 21.03 | 18.13 |
first_variant_chunks |
17.39 | 19.28 | 16.96 | 15.65 | 16.34 | 22.41 |
region_variant_position |
0.069 | 0.062 | 0.056 | 0.088 | 0.062 | 0.051 |
filter_info_dp_gt_80 |
10.94 | 11.24 | 6.85 | 18.37 | 10.63 | 3.59 |
region_filter_format_gq_gt_50 |
0.808 | 0.783 | 0.751 | 0.831 | 0.793 | 0.799 |
region_and_sample_subset |
0.082 | 0.082 | 0.080 | 0.087 | 0.081 | 0.076 |
Median data rate (data_rate_mib_s)
MiB/s of returned data (the user-visible output of variant_chunks). Reading pattern differences show up as wide spreads across the three ~1 GB-returning tasks.
| Task | Bytes returned | local-dir | local-dir-zv3 | local-zip | local-http | obstore-file | icechunk |
|---|---|---|---|---|---|---|---|
iter_info_only |
21.8 MB | 0.97 | 1.18 | 2.10 | 0.59 | 0.96 | 2.98 |
region_info_and_format |
496.5 MB | 46.5 | 44.2 | 45.1 | 46.3 | 46.96 | 46.7 |
first_samples_chunk |
992.8 MB | 47.6 | 46.9 | 46.4 | 41.9 | 45.0 | 52.2 |
first_variant_chunks |
1000.0 MB | 54.8 | 49.5 | 56.2 | 60.9 | 58.4 | 42.6 |
filter_info_dp_gt_80 |
0.59 MB | 0.05 | 0.05 | 0.08 | 0.03 | 0.05 | 0.16 |
region_filter_format_gq_gt_50 |
99.3 MB | 117.2 | 121.0 | 126.1 | 114.0 | 119.4 | 118.6 |
region_and_sample_subset |
1.99 MB | 23.1 | 23.1 | 23.6 | 21.7 | 23.5 | 24.8 |
CPU/wall ratio
Ratio above 1.0 indicates multi-threaded blosc decompression; well below 1.0 indicates I/O wait. On our 100k × 496k dataset:
| Task | local-dir | local-dir-zv3 | local-zip | local-http | obstore-file | icechunk |
|---|---|---|---|---|---|---|
iter_info_only |
0.59 | 0.51 | 0.59 | 0.63 | 0.81 | 0.98 |
region_info_and_format |
0.99 | 0.99 | 1.00 | 0.98 | 0.99 | 1.01 |
first_samples_chunk |
0.87 | 0.83 | 0.83 | 0.81 | 0.88 | 1.02 |
first_variant_chunks |
0.98 | 0.98 | 1.00 | 0.99 | 1.00 | 1.02 |
filter_info_dp_gt_80 |
0.72 | 0.53 | 0.53 | 0.65 | 0.82 | 0.91 |
region_filter_format_gq_gt_50 |
0.97 | 0.95 | 0.98 | 0.93 | 0.97 | 0.98 |
region_and_sample_subset |
0.88 | 0.81 | 0.83 | 0.83 | 0.88 | 1.03 |
Peak RSS (MB, max across 3 repeats)
| Task | local-dir | local-dir-zv3 | local-zip | local-http | obstore-file | icechunk |
|---|---|---|---|---|---|---|
iter_info_only |
135 | 134 | 151 | 147 | 147 | 149 |
region_info_and_format |
1692 | 1693 | 1707 | 1696 | 1702 | 1706 |
first_samples_chunk |
403 | 403 | 406 | 406 | 406 | 407 |
first_variant_chunks |
1275 | 1276 | 1295 | 1276 | 1278 | 1279 |
region_filter_format_gq_gt_50 |
846 | 852 | 875 | 877 | 860 | 857 |
Backend geomean ranking
Geometric mean of elapsed_s across all 9 tasks (lower is better):
| Rank | Backend | Geomean elapsed_s |
|---|---|---|
| 1 | icechunk | 1.056 |
| 2 | local-zip | 1.249 |
| 3 | local-dir-zv3 | 1.429 |
| 4 | obstore-file | 1.441 |
| 5 | local-dir | 1.495 |
| 6 | local-http | 1.857 |
Beta Was this translation helpful? Give feedback.
-
|
This is awesome @jeromekelleher! Interesting that Icechunk does so well - intuitively it has an extra layer of indirection, but that can actually help for some styles of query. Zip files look great as a read-only format (as long as they are not too big I suppose).
Is it using fsspec or just Python file handling? This benchmark doesn't test obstore for remote files, but we might want to make it the default for those at some point. |
Beta Was this translation helpful? Give feedback.
-
vcztools
|
Beta Was this translation helpful? Give feedback.
-
vcztools — current benchmark numbersSnapshot of the post-perf-work benchmark matrix. Setup
Headline gains vs the 2026-04-23 baseline
¹ Median elapsed time (seconds)
Median throughput (MiB/s of returned data)Tasks whose output is below ~1 MB are omitted (throughput is dominated by metadata overhead there).
CPU/wall ratio (4 cores available; ceiling ≈ 4)
Peak RSS (MB)Bulk tasks land in the ~600 MB – 2 GB range. Readahead budget is 256 MiB of in-flight prefetched blocks; the rest is assembled chunks held briefly plus interpreter / Zarr overhead. Well within single-machine budgets.
Backend ranking (geomean median elapsed across all 11 tasks)
|
Beta Was this translation helpful? Give feedback.
-
vcztools benchmark snapshot —
|
| task | local-dir | local-dir-zv3 | local-zip | local-http | obstore-file | icechunk |
|---|---|---|---|---|---|---|
| iter_no_fields | 0.45ms / — | 0.39ms / — | 0.47ms / — | 0.54ms / — | 0.37ms / — | 0.34ms / — |
| iter_info_only | 2.45s / 9 | 2.72s / 8 | 10.05s / 2 | 4.40s / 5 | 2.10s / 10 | 1.88s / 11 |
| region_info_and_format | 1.16s / 407 | 1.15s / 410 | 1.29s / 368 | 2.03s / 234 | 855ms / 554 | 937ms / 505 |
| first_samples_chunk | 3.72s / 2547 | 3.60s / 2632 | 4.29s / 2210 | 5.12s / 1849 | 3.69s / 2568 | 3.77s / 2512 |
| first_samples_chunk_slice | 3.45s / 2748 | 3.52s / 2693 | 3.76s / 2516 | 4.85s / 1950 | 3.48s / 2720 | 3.48s / 2719 |
| first_variant_chunks | 942ms / 1012 | 984ms / 970 | 1.07s / 890 | 1.09s / 871 | 1.02s / 939 | 1.04s / 913 |
| fmt_fields | 736ms / 3888 | 752ms / 3807 | 1.41s / 2028 | 1.20s / 2393 | 1.17s / 2448 | 983ms / 2911 |
| fmt_fields_filtered | 8.17s / 210 | 8.58s / 200 | 8.43s / 204 | 8.70s / 197 | 8.45s / 203 | 8.03s / 214 |
| region_variant_position | 9.0ms / — | 9.9ms / — | 2.9ms / 1 | 18.0ms / — | 11.0ms / — | 5.7ms / 1 |
| filter_info_dp_gt_80 | 838ms / 1 | 921ms / 1 | 3.82s / — | 1.62s / — | 839ms / 1 | 722ms / 1 |
| filter_info_dp_gt_80_genotypes | 4.29s / 44 | 4.55s / 41 | 8.93s / 21 | 10.50s / 18 | 4.31s / 44 | 4.20s / 45 |
| region_filter_format_gq_gt_50 | 714ms / 133 | 837ms / 113 | 849ms / 112 | 733ms / 129 | 689ms / 137 | 685ms / 138 |
| region_and_sample_subset | 33.3ms / 57 | 31.8ms / 60 | 33.6ms / 56 | 42.7ms / 44 | 34.5ms / 55 | 28.8ms / 66 |
Output (local-dir)
| task | elapsed | output_rate (MiB/s) |
|---|---|---|
| output_vcf | 8.56s | 107 |
| output_plink | 6.15s | 77 |
| output_bed_stream | 5.52s | 85 |
| output_bgen_l0 | 8.36s | 169 |
| output_bgen_l1 | 9.05s | 7 |
| output_bgen_l6 | 33.73s | 1 |
Observations — wide
- Bulk genotype reads cluster around 2.5–2.7 GiB/s on the four fast backends (
local-dir,local-dir-zv3,obstore-file,icechunk) forfirst_samples_chunkandfirst_samples_chunk_slice.local-zipandlocal-httptrail at ~1.8–2.2 GiB/s —local-zipserialises decompression on the single zip handle, andlocal-httpadds per-chunk request overhead. fmt_fieldsis the throughput peak at 3.9 GiB/s onlocal-dir. Pulling three pre-allocated call fields (call_genotype,call_GQ,call_DP) end-to-end keeps the 4-core blosc pool saturated.- Tiny-output tasks favour fast lookups.
iter_no_fields,region_variant_position,region_and_sample_subsetare all sub-50 ms across every backend.local-zipdoes the variant-position lookup fastest (2.9 ms) because the entire field fits in a single read. iter_info_onlyis the slow scalar-only task at 1.9–10 s. It iterates the whole 496 414-variant axis pulling six INFO scalars — bytes-light but Python-loop-heavy.local-zipis 3–5× slower than the rest because the small reads serialise on the zip handle.fmt_fields_filteredis filter-bound, not I/O-bound. All six backends finish in 8.0–8.7 s with data rates 197–214 MiB/s — the FMT-scopeGQ>50predicate dominates, leaving the bulk-read path idle. This matches the known follow-up inperformance/summary.md.filter_info_dp_gt_80_genotypesseparates the backends. Once the filter rejects 80 % of variants and only their genotypes are read, the fast backends do 4.2–4.6 s at 41–45 MiB/s whilelocal-httpfalls to 10.5 s / 18 MiB/s.output_bgencompression is the dominant cost. Level-0 (no compression) sustains 169 MiB/s; level-1 collapses to 7 MiB/s and level-6 to 1 MiB/s. The current default forview-bgenshould be chosen with this curve in mind.- Peak RSS: median 828 MB across all wide tasks, max 2.3 GB — bounded by the 256 MiB readahead byte budget plus per-task materialised arrays.
Long dataset (10 samples × 28 156 764 variants, proportional variant-only chunks)
Retrieval
| task | local-dir | local-dir-zv3 | local-zip | local-http | obstore-file | icechunk |
|---|---|---|---|---|---|---|
| iter_no_fields | 20.1ms / — | 17.9ms / — | 33.3ms / — | 20.2ms / — | 19.1ms / — | 24.2ms / — |
| iter_info_only | 3.05s / 388 | 3.64s / 324 | 3.02s / 391 | 2.97s / 398 | 3.29s / 359 | 2.77s / 426 |
| region_info_and_format | 192ms / 17 | 206ms / 16 | 790ms / 4 | 1.19s / 3 | 202ms / 17 | 152ms / 22 |
| first_variant_chunks | 17.0ms / 6 | 15.8ms / 6 | 27.4ms / 3 | 29.6ms / 3 | 17.3ms / 6 | 19.9ms / 5 |
| region_variant_position | 43.2ms / 5 | 42.7ms / 5 | 36.1ms / 6 | 53.7ms / 4 | 43.2ms / 5 | 18.7ms / 11 |
| filter_info_dp_gt_80 | 241ms / 133 | 230ms / 138 | 542ms / 59 | 302ms / 105 | 286ms / 112 | 309ms / 103 |
| region_filter_format_gq_gt_50 | 87.1ms / 4 | 95.9ms / 4 | 114.5ms / 3 | 1.12s / — | 83.0ms / 5 | 57.3ms / 7 |
| full_genotypes | 30.25s / 18 | 33.64s / 16 | 20.63s / 26 | 54.37s / 10 | 28.90s / 19 | 31.58s / 17 |
Output (local-dir)
| task | elapsed | output_rate (MiB/s) |
|---|---|---|
| full_output_vcf | 4.4 min | 17 |
| full_output_plink | 2.7 min | 4 |
| full_output_bed_stream | 58.11s | 1 |
Observations — long
- Backend ordering inverts vs the wide dataset for
full_genotypes.local-zipis the fastest backend at 26 MiB/s (20.6 s wall), ahead ofobstore-file(19),local-dir(18),icechunk(17),local-dir-zv3(16), andlocal-http(10). With only 10 samples, each variant chunk is small enough that zip's single-handle penalty disappears and its compact on-disk layout wins. iter_info_onlyruns at 324–426 MiB/s — the dominant work is the read loop, not the bytes. It iterates 28 M variants reading six scalar INFO fields. Because the proportional-chunked variant fields have pairwise-coprime chunk sizes (e.g. 327 000 and 2 621 000 share GCD 1 000), the stream chunk size collapses to the GCD, which multiplies the number of iterations relative to the homogeneous-chunk case. This is the architectural cost of heterogeneous chunk layouts and the current single biggest opportunity for long-dataset retrieval.- Region tasks are fast on a sparse field. The shared 0.2 % region resolves to a tiny absolute slice on the 28 M-variant dataset;
region_info_and_formatis 152–202 ms on the fast backends, climbing to 790 ms / 1.19 s onlocal-zip/local-http. first_variant_chunksis dominated by overhead. Reading 5000 variants × 10 samples is sub-30 ms everywhere; the absolute data volume (~150 KB) is too small to spread across backends.- Output is encoder-bound and the spread is dramatic. VCF text encoding finishes in 4.4 min at 17 MiB/s; PLINK BED via the full encoder (which also writes
.bim/.famand runs allele recoding) takes 2.7 min at 4 MiB/s; the streamingBedEncoderdrains the same 28 M variants in 58 s but its 1 MiB/s reported output rate is anomalous — likely an accounting issue in thebytes_writtenfield foroutput_bed_stream-class tasks. - Peak RSS is highest in the long-output pipeline.
full_output_plinkpeaks at 4.1 GB (median 4.1 GB),full_output_bed_streamat 2.9 GB,full_output_vcfat 1.4 GB. Retrieval tasks on long are bounded by 1.1 GB (full_genotypes).
Cross-shape highlights
- Bulk genotype throughput on small samples × long variants (
full_genotypes, 10 samples) is ~25× lower in MiB/s than on 100 000 samples × moderate variants (first_samples_chunk). The wide bulk task pulls 2.5 GiB/s; the long one ~18 MiB/s. Wide benefits from larger contiguous decompression buffers per chunk; long pays per-chunk overhead on a thin sample axis. iter_info_onlyis the one task where the dominant cost differs between shapes. On wide it is bytes-light Python looping (~10 MiB/s); on long with proportional chunking it iterates many more stream chunks because the GCD of the per-field chunk sizes is small. Both end up in the 2–4 s band but for different reasons.output_bgen_l0is the fastest single-file writer measured (169 MiB/s on wide). Compression dominates the remaining BGEN cost;output_vcf(107 MiB/s) andoutput_bed_stream(85 MiB/s) are the next closest.local-httpis the slowest backend on every retrieval task it runs. Per-chunk HTTP request overhead is unavoidable for small-record tasks (region_*) and significant for bulk tasks (first_samples_chunk1.85 vs 2.5–2.7 GiB/s elsewhere).
Reproduce
uv run python performance/benchmarks.py run \
--dataset performance/wide_bench.vcz \
--shape wide \
--output performance/results/wide-more-perf-runs.jsonl
uv run python performance/benchmarks.py run \
--dataset performance/long_bench.vcz \
--shape long \
--output performance/results/long-more-perf-runs.jsonl
Beta Was this translation helpful? Give feedback.
-
|
These are the latest perf numbers reflecting 0.2.0 release (at #430) Note that these benchmarks were run on different hardware to the above, so shouldn't be over interpreted. EnvironmentRun on host Wide dataset (496,414 variants × 100,000 diploid samples)This is the headline workload. Retrieval throughput is reported as Retrieval — MiB/s (median of 3)
The very low MiB/s rows ( Retrieval — elapsed seconds (median of 3)
Output tasks (local-dir backend)
VCF text encode runs at ~219 MiB/s; plink and the BED stream sit at ~100–115 MiB/s. BGEN is dominated by zlib: stored/level-0 hits ~300–490 MiB/s, but level 1 drops to ~22 MiB/s and level 6 to ~5 MiB/s — the expected compression-vs-speed tradeoff, which is why level 9 is excluded from the default sweep. Peak RSS for the wide output tasks is ~1.8–3.1 GiB; this is the largest memory consumer in the suite and worth keeping an eye on. Encoder microbenchmark sweep (1000 variants × 100,000 samples, 190.7 MiB input)This isolates encoder throughput from storage I/O and sweeps worker threads against the per-block byte target. MiB/s is reported on the output-byte basis (median of 10).
On the input-byte basis plink reaches ~6.7 GiB/s at 4 threads with the 1 MiB block target. Across all encoders, throughput saturates at 4 threads (this is a 4-core box) and the 8-thread column shows no further gain — often a slight regression from oversubscription. The 40 MiB block-bytes column consistently underperforms for plink and the python BGEN encoder (fewer, larger blocks reduce parallelism), confirming the smaller 1–10 MiB block targets are the right default on this core count. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
This discussion is to document some read performance benchmarks and to discuss results of various perf approaches.
Beta Was this translation helpful? Give feedback.
All reactions