Skip to content

perf(cpp): speed up wide-table reads (RLE SIMD, batch index loading, BOUNDARY bulk) - #1072

Open
ColinLeeo wants to merge 7 commits into
apache:developfrom
ColinLeeo:perf/wide-table-read
Open

ColinLeeo wants to merge 7 commits into
apache:developfrom
ColinLeeo:perf/wide-table-read

Conversation

@ColinLeeo

Copy link
Copy Markdown
Contributor

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

  • Add read_batch_int32/int64 to the RLE decoders so runs fill the output directly and bit-packed groups memcpy out; 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).
  • Wrap byte_cache_ directly over the caller's buffer instead of memcpy-ing the whole pattern into tmp_buf_.

SIMD

  • Add runtime AVX2 dispatch (common/simd_dispatch.h, __builtin_cpu_supports("avx2")) plus target("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_indexes 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 + clone per measurement.

Reader

  • Bulk-copy contiguous BOUNDARY pages: a time range that covers only part of a chunk used to fall into the row-by-row scatter loop; a BOUNDARY page under a contiguous time filter (TimeBetween) with no nulls now takes the bulk path.

Performance (wide table: 20 tags × 5214 columns × 240 rows/min × 3 months)

query parquet TsFile speedup
5 tag × 100 col × 1 month 0.352s 0.055s 6.4×
20 tag × 100 col × 1 month 1.664s 0.188s 8.9×
5 tag × 4500 col × 1 day 1.202s 0.987s 1.22×
20 tag × 4500 col × 1 week 4.940s 3.396s 1.45×
5 tag × 4500 col × 1 month 1.308s 1.517s 0.86×

4500-column queries are faster than parquet in 7 of 9 measured cases; the rest are at parity or slightly slower.

Testing

  • RLE round-trip over all bit widths 1–64 / 1–32: pass.
  • TS_2DIFF round-trip (smooth / jumpy / small / constant columns): pass.
  • Column-level value comparison against parquet over 4500 columns: 0 mismatch.
  • Non-aligned (tree-model) multi-device read: pass.
  • Full Python test suite: 604 passed.

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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant