Repository navigation
Conversation
RLE decoding:
- Add read_batch_int32/int64 to Int32/Int64RleDecoder: RLE runs fill the
output directly, bit-packed groups memcpy out of the already-unpacked
buffer; Float/DoubleRLEDecoder get read_batch_float/double.
- Replace the per-byte read_ui8 copy in read_bit_packing_buffer with one
read_buf call.
- Decode 59/61/62/63-bit groups through a 128-bit byte buffer instead of
bit-by-bit (NaN raw-bit values widen a column to 63 bits).
SIMD:
- New common/simd_dispatch.h plus __attribute__((target("avx2"))) twins of
the int64/int32 bit-unpack kernels, selected at runtime with
__builtin_cpu_supports. Baseline builds never add -mavx2, so simde was
emitting its portable fallback and the "SIMD" path measured ~6x slower
than real AVX2.
Index loading:
- get_timeseries_indexes now collects the device's leaf entries, reads the
whole contiguous timeseries-index range with one pread and deserializes
each index straight into the caller's arena, instead of a binary search,
pread, temp-arena decode and clone per measurement.
On a 20-feature x 5214-stock x 240-min/month wide table, a 5-feature x
4500-stock x 1-month query drops from 5.98s to ~1.35s.
A time range that covers only part of a chunk marked every page BOUNDARY, so get_next_page_multi_impl fell into the row-by-row scatter loop: one RowAppender::append per value plus a per-timestamp filter re-check. That slow path measured 8.6% of a 1-week wide-table query, and made queries that read less data (1 week) as slow as a full-month query. A BOUNDARY page under a contiguous time filter (TimeBetween with not_between=false) has a fully matching [row_begin, row_end] range with no interior holes, so it can use the same bulk memcpy path as FULL_PASS: the page is decoded whole, so predecoded_values is row-indexed at row_begin. - Add Filter::is_contiguous_time_filter() (default false; TimeBetween returns !not_). - Let can_bulk accept a BOUNDARY page when that predicate holds and no value column carries nulls (predecoded_count == row_count, the condition that keeps predecoded_values row-aligned). - Discrete filters (TimeIn, NOT BETWEEN) keep the slow path. 1-week 5f x 4500-stock query: 1.63s -> ~1.05s.
- read_length_and_bitwidth: wrap byte_cache_ directly over the caller's
wrapped buffer instead of copying the whole RLE pattern into tmp_buf_;
fall back to the copy for paged or short streams. Removes a per-pattern
memcpy of the encoded payload.
- TS_2DIFF: add __attribute__((target("avx2"))) twins of
simd_decode_4_i64/i32 and dispatch through common::cpu_has_avx2(), so the
gather/shuffle kernels run as real AVX2 instead of simde's portable
fallback in baseline builds (the library does not compile with -mavx2).
TS_2DIFF round-trip (smooth / jumpy / small / constant columns) and RLE
round-trip both pass; 1-day/1-week wide-table queries ~8-10% faster.
ColinLeeo
force-pushed
the
perf/wide-table-read
branch
from
November 5, 2026 10:50
480bd21 to
a74b8af
Compare
This was referenced Nov 5, 2026
[Perf][C++] Load a device's timeseries indexes with one pread instead of per-measurement reads
#1074
Open
A device-ordered scan creates a SingleDeviceTsBlockReader for every device it visits - and with it a TsFileSeriesScanIterator and an output TsBlock plus one buffer per column - then throws all of it away. On a wide table every device rebuilds the same time+value layout, so the allocation and first-touch cost of those buffers shows up as system time and page faults: 20 devices of a 4501-column table churn several GB of anonymous memory per query. Let the enclosing DeviceOrderedTsBlockReader own one ReusableTsBlock and hand it to each device's iterator through a small factory. The factory keeps the block when the column count, value types and row capacity match, refreshing the column names in place, and only rebuilds it when the layout actually changes. Iterators without a factory keep the previous allocate-per-series behaviour, so the prepared-series and tree paths are untouched.
…k copies When the whole page plan of a chunk fits into the current TsBlock, value pages are decoded straight into the destination column buffer instead of a staging buffer that is copied over afterwards. Each page's destination is a prefix sum of the retained rows, so only pages whose retained range starts at row 0 and covers the whole page are written in place; pages with nulls, partial ranges or offset pushdown keep the staging path, and the two can be mixed within a chunk. TsBlock columns store non-null values compactly (RowIterator advances offsets only for non-null rows), so the write cursor is the column's byte size rather than a row index. Also fixes two bugs in the same bulk path: - a BOUNDARY page bounded by a FIELD predicate mask can still reject rows inside [row_begin, row_end), so only contiguous time filters may bulk copy; - a column without a value page for this page decodes to an empty slot whose predecoded count equals its zero row count, which passed the bulk check and memcpy'd from a null buffer (SIGSEGV in WriteDataWithEmptyField and in the aligned field-filter regression suite).
Two problems found by running the suite under AddressSanitizer. AlignedChunkReader is placement-new'd into an arena and torn down with destroy() + mem_free(), so its destructor never runs. prepare_direct_decode() keeps a std::vector<size_t> of per-column write offsets, and its heap buffer was never released; every reader leaked it (44 LeakSanitizer reports). Free it in destroy() like the other heap-owning members. The AVX2 bit-unpack gathers 8 bytes per value, so the last group of a buffer may read up to 7 bytes past its end. The RLE decoders already zero-pad their input by 8 bytes for that reason, but the four Sprintz decoders allocate exactly bit_width_ bytes and the packer tests pass exact-size buffers, so both read out of bounds (3 AddressSanitizer SEGVs, one of them on the production FloatSprintzDecoder path). Pad those buffers, document the contract on unpack_8values(), and make the multi-block tests pass the packed length rather than the padded vector size.
MSVC has no 128-bit integer, so the wide-buffer scalar path used for the RLE
bit widths that can cross a ninth byte (59/61/62/63) failed to compile
("error C4235: nonstandard extension used: '__int128'"). Keep the 128-bit path
for compilers that define __SIZEOF_INT128__ and restore the portable bit-by-bit
loop otherwise.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Optimize the C++ read path for wide tables — single tables with thousands of columns (measured with a 20-tag × 5214-column table, 240 rows/minute, 3 months of data). Against parquet (one file per tag per month), the 4500-column queries went from ~3–4× slower to at parity or faster.
Changes
RLE decoding
read_batch_int32/int64to the RLE decoders so runs fill the output directly and bit-packed groups memcpy out;Float/DoubleRLEDecodergetread_batch_float/double.read_ui8copy inread_bit_packing_bufferwith oneread_bufcall.byte_cache_directly over the caller's buffer instead of memcpy-ing the whole pattern intotmp_buf_.SIMD
common/simd_dispatch.h,__builtin_cpu_supports("avx2")) plustarget("avx2")intrinsic twins of the RLE and TS_2DIFF bit-unpack kernels. Baseline builds do not compile with-mavx2, so simde was emitting its portable fallback — measured ~6× slower than real AVX2 on the gather/shuffle kernels.Index loading
get_timeseries_indexescollects the device's leaf entries, reads the whole contiguous timeseries-index range with one pread and deserializes each index straight into the caller's arena, instead of a binary search + pread + clone per measurement.Reader
Performance (wide table: 20 tags × 5214 columns × 240 rows/min × 3 months)
4500-column queries are faster than parquet in 7 of 9 measured cases; the rest are at parity or slightly slower.
Testing