From ff3e4a30f587837c3486a80c4124adc769275ae0 Mon Sep 17 00:00:00 2001 From: Piotr Balwierz Date: Fri, 10 Jul 2026 12:05:24 +0200 Subject: [PATCH] perf(tabix): reuse one line buffer across records instead of per-record malloc TabixInputStream::fetch_next_line declared a fresh kstring_t on every call, so each record streamed through a bgzipped `-r` range query paid an htslib malloc + a free. Make the kstring a member reused across the whole scan (htslib's bgzf_getline resets its length and reallocs only when a line outgrows the buffer), freed once in the destructor. Output byte-identical (verified on a 1 M-record tabix BED); a small steady-state gain (~3% on that scan) and no per-record allocator churn. Suite 375. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 +++ main.cpp | 12 +++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74aca9a..fdd37ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). memoises decoded column arrays per block, so each is decoded once. ~50% less time on a wide `-r … --tsv` over a 1 M-row file; output byte-identical. The cache is region-mode only, so a sequential full scan is unaffected. +- **Tabix range queries reuse the line buffer.** `TabixInputStream` allocated and + freed an htslib `kstring` per record; it now keeps one buffer for the whole + scan (htslib reallocs only when a line outgrows it). Output unchanged. ### Fixed - **bigWig/bigBed misaligned read (aarch64 correctness).** libBigWig read each diff --git a/main.cpp b/main.cpp index a199bba..bd7da29 100644 --- a/main.cpp +++ b/main.cpp @@ -1640,6 +1640,10 @@ class TabixInputStream : public arrow::io::InputStream { size_t pos_ = 0; bool eof_ = false; bool closed_ = false; + // Reused across records: htslib's line reader (bgzf_getline) resets `l` and + // reallocs `s` only when a line outgrows it, so keeping one buffer for the + // whole scan avoids a malloc+free per record. Freed once in the destructor. + kstring_t ks_ = {0, 0, nullptr}; public: static std::string open(const std::string& path, const std::string& region, @@ -1697,6 +1701,7 @@ class TabixInputStream : public arrow::io::InputStream { for (auto* it : iters_) if (it) tbx_itr_destroy(it); if (tbx_) tbx_destroy(tbx_); if (fp_) hts_close(fp_); + if (ks_.s) free(ks_.s); } arrow::Status Close() override { closed_ = true; return arrow::Status::OK(); } @@ -1727,19 +1732,16 @@ class TabixInputStream : public arrow::io::InputStream { } private: bool fetch_next_line() { - kstring_t s = {0, 0, nullptr}; while (cur_iter_ < iters_.size()) { - int r = tbx_itr_next(fp_, tbx_, iters_[cur_iter_], &s); + int r = tbx_itr_next(fp_, tbx_, iters_[cur_iter_], &ks_); if (r >= 0) { - buf_.assign(s.s, s.l); + buf_.assign(ks_.s, ks_.l); buf_ += '\n'; pos_ = 0; - if (s.s) free(s.s); return true; } ++cur_iter_; } - if (s.s) free(s.s); eof_ = true; return false; }