Skip to content

Commit e06dd2b

Browse files
Huameng (Michael) Jiangmeta-codesync[bot]
authored andcommitted
feat(selective): SIMD filterCache evaluation for dictionary index reading with filters (#17954)
Summary: X-link: facebookincubator/nimble#916 Pull Request resolved: #17954 Dictionary-encoded string columns in the selective Nimble reader emit a `DictionaryVector` backed by a single alphabet merged across all chunks of a read range. A filter pushed down on such a column cannot be evaluated inside the encoding's per-row decode: the merged alphabet (and therefore the per-alphabet-index `filterCache`) is not known until every chunk has been read, and the inner encoding's `bulkScan` would apply a string filter directly to raw `int32` dictionary indices. The filter is therefore suppressed during the bulk `materializeIndices` read and applied post-hoc on the merged alphabet. This diff replaces the previous in-encoding (`populateScanState` / framework `StringDictionaryColumnVisitor`) filter evaluation with that post-hoc path, makes it SIMD-accelerated, and removes the now-dead in-encoding code: - Shared SIMD kernel. Add a free function `filterDictionaryRunSimd<kFilterOnly>(...)` in `velox/dwio/common/ColumnVisitors.h` that gathers each index's cached verdict from `filterCache`, resolves cache misses through the filter (recording the result back), and SIMD-compacts the passing rows (and, unless `kFilterOnly`, the passing indices). Nimble's `filterByCache` calls it directly. DWRF's `StringDictionaryColumnVisitor::processRun` is left unchanged; it still holds an equivalent inline loop, marked with a TODO to adopt the shared kernel in a follow-up diff (kept separate to isolate the DWRF change from this Nimble feature). - Post-hoc filter in `StringColumnReader`. `readWithDictionary` saves and clears the scan-spec filter so the bulk index read runs, then `filterDictionaryIndices` applies the filter on the merged alphabet via `filterByCache`, compacting `rawValues_`/`outputRows_` to the passing rows. `ensureFilterCache` lazily sizes the per-alphabet-index cache, which grows and clears together with the alphabet. - Output-layout null realignment. Pre-compacting `rawValues_`/`outputRows_` makes the framework's `compactScalarValues` a no-op (`rows.size() == numValues_`), which skips its null move, so `filterDictionaryIndices` realigns the result nulls itself in three cases: (1) no nulls — filter in place; (2) value filter rejects nulls — filter in place and mark the compacted output all-non-null; (3) IS NULL accepts nulls — merge null rows with the passing non-null rows in row order. `ensureWritableResultNulls` mirrors the framework's `shouldMoveNulls` by switching off the dense `returnReaderNulls_` fast path and allocating an output-indexed null bitmap. - Delete dead code. With `StringColumnReader` the sole owner of dictionary filtering, the per-encoding `kHasFilter` branches and the `prepareResultNullsForDenseFilter` helper in `encodings/common/Encoding.h` (and their call sites in the `Constant`, `Dictionary`, `MainlyConstant`, and `RLE` encodings) are unreachable and removed, along with `StringColumnReader::populateScanState`. Performance. On a simulated filter workload (`parallel_reader` over a dictionary-encoded string column, `l_returnflag='R'`, opt mode, `batch_size=1024`, concurrency 16, `read_count=500`, P50), the SIMD `filterCache` path reads at ~52 ms, versus ~70 ms for the parent diff's scalar per-row filter (−26%) and 130 ms for a no-dictionary flat read (−60%). The gain comes from evaluating the byte filter once per distinct dictionary value (`filterCache` memoization) and SIMD-gathering/compacting survivors, instead of testing the filter per row. (Parent/flat figures are from the 2026-06-05 A/B; the SIMD figure was reconfirmed on the current stack on 2026-06-13: P50 wall 52/52/54 ms across 3 runs.) No behavior change for non-dictionary columns or for dictionary columns read without a pushed-down filter. Reviewed By: Yuhta Differential Revision: D102273283 fbshipit-source-id: 6ff65058a06af4be9f04056e6508062b3297793b
1 parent 6577a09 commit e06dd2b

3 files changed

Lines changed: 405 additions & 2 deletions

File tree

velox/dwio/common/ColumnVisitors.h

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -617,8 +617,6 @@ ColumnVisitor<T, TFilter, ExtractValues, isDense, hasBulkPath>::addOutputRow(
617617
reader_->addOutputRow(row);
618618
}
619619

620-
enum FilterResult { kUnknown = 0x40, kSuccess = 0x80, kFailure = 0 };
621-
622620
namespace detail {
623621

624622
template <typename T, typename A>
@@ -1387,6 +1385,11 @@ class StringDictionaryColumnVisitor
13871385
}
13881386
constexpr bool filterOnly =
13891387
std::is_same_v<typename super::Extract, DropValues>;
1388+
// TODO: This inline SIMD loop duplicates the shared
1389+
// filterDictionaryRunSimd() free function in DecoderUtil.h; rewire
1390+
// processRun to call it in a follow-up diff. The dedup is split out to keep
1391+
// this DWRF change separate from the Nimble post-hoc dictionary filter
1392+
// feature.
13901393
constexpr int32_t kWidth = xsimd::batch<int32_t>::size;
13911394
for (auto i = 0; i < numInput; i += kWidth) {
13921395
auto indices = xsimd::load_unaligned(input + i);

velox/dwio/common/DecoderUtil.h

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#pragma once
1818

1919
#include <algorithm>
20+
#include "velox/common/base/Portability.h"
2021
#include "velox/common/memory/RawVector.h"
2122
#include "velox/common/process/ProcessBase.h"
2223
#include "velox/dwio/common/StreamUtil.h"
@@ -33,6 +34,115 @@ static bool applyFilter(TFilter& filter, T value);
3334

3435
namespace facebook::velox::dwio::common {
3536

37+
// Per-dictionary-index verdict cached by the dictionary filter path. The byte
38+
// values are chosen so the SIMD kernel below tests them from the gathered
39+
// int32: bit 7 (kSuccess) is the sign bit and bit 6 (kUnknown) selects
40+
// unknowns.
41+
enum FilterResult { kUnknown = 0x40, kSuccess = 0x80, kFailure = 0 };
42+
43+
/// SIMD dictionary-filter kernel shared by the DWRF framework
44+
/// StringDictionaryColumnVisitor and the Nimble dictionary filter path. Reads a
45+
/// run of materialized dictionary indices, consults the per-index 'filterCache'
46+
/// to skip indices already known to pass or fail, resolves cache-unknown
47+
/// indices by invoking 'testIndex' (which the kernel then records into
48+
/// 'filterCache' as kSuccess/kFailure), and compacts the passing entries. For
49+
/// each passing entry the corresponding 'rows' value is appended to
50+
/// 'filterHits'; unless 'kFilterOnly' is set, the passing dictionary index is
51+
/// also appended to 'values'. The caller supplies 'rows' already offset to the
52+
/// current position. Returns the updated number of output values.
53+
///
54+
/// Caller-side buffer contracts -- the kernel reads and writes a full SIMD
55+
/// width at the tail and gathers the cache through a misaligned load, none of
56+
/// which is bounds-checked:
57+
/// - 'input' and 'rows' must each have at least kWidth
58+
/// (= xsimd::batch<int32_t>::size) readable elements beyond 'numInput'.
59+
/// - 'filterHits', and unless 'kFilterOnly' also 'values', must have room for
60+
/// at least 'numValues' + 'numInput' + kWidth entries.
61+
/// - 'filterCache' must have >= 3 bytes of valid leading padding, because each
62+
/// verdict is gathered via 'filterCache + index - 3'. The Nimble and Velox
63+
/// callers satisfy this with raw_vector<uint8_t>, whose data() is offset by
64+
/// velox::simd::kPadding (>= 16).
65+
template <bool kFilterOnly, typename TestIndex>
66+
int32_t filterDictionaryRunSimd(
67+
const int32_t* input,
68+
int32_t numInput,
69+
const int32_t* rows,
70+
uint8_t* filterCache,
71+
int32_t* filterHits,
72+
int32_t* values,
73+
int32_t numValues,
74+
TestIndex&& testIndex) {
75+
constexpr int32_t kWidth = xsimd::batch<int32_t>::size;
76+
for (auto i = 0; i < numInput; i += kWidth) {
77+
auto indices = xsimd::load_unaligned(input + i);
78+
xsimd::batch<int32_t> cache;
79+
// The gather reads each cache byte as the high byte of an int32 via a
80+
// misaligned load at 'filterCache + index - 3', so for index 0 it touches
81+
// up to 3 bytes before 'filterCache'. The param is a raw uint8_t*, so this
82+
// is a caller contract: 'filterCache' must point into a buffer with >= 3
83+
// bytes of valid leading padding. Both callers satisfy it with
84+
// raw_vector<uint8_t> (Nimble's DictionaryState::filterCache and velox's
85+
// ScanState::filterCache), whose data() is always offset by
86+
// velox::simd::kPadding (>= 16). Mirrors the StringDictionaryColumnVisitor
87+
// filterCache() - 3 idiom in ColumnVisitors.h.
88+
auto base = reinterpret_cast<const int32_t*>(filterCache - 3);
89+
if (i + kWidth > numInput) {
90+
cache = simd::maskGather<int32_t, int32_t, 1>(
91+
xsimd::broadcast<int32_t>(0),
92+
simd::leadingMask<int32_t>(numInput - i),
93+
base,
94+
indices);
95+
} else {
96+
cache = simd::gather<int32_t, int32_t, 1>(base, indices);
97+
}
98+
#ifdef SVE_BITS
99+
auto unknowns = simd::toBitMask(
100+
simd::reinterpretBatch<uint32_t>((cache & (kUnknown << 24)) << 1) !=
101+
xsimd::batch<uint32_t>(0));
102+
auto passed = simd::toBitMask(
103+
(simd::reinterpretBatch<uint32_t>(cache) & xsimd::batch<uint32_t>(1)) !=
104+
xsimd::batch<uint32_t>(0));
105+
#else
106+
auto unknowns = simd::toBitMask(
107+
xsimd::batch_bool<int32_t>(
108+
simd::reinterpretBatch<uint32_t>((cache & (kUnknown << 24)) << 1)));
109+
auto passed = simd::toBitMask(
110+
xsimd::batch_bool<int32_t>(simd::reinterpretBatch<uint32_t>(cache)));
111+
#endif
112+
if (UNLIKELY(unknowns)) {
113+
uint16_t bits = unknowns;
114+
while (bits) {
115+
int index = bits::getAndClearLastSetBit(bits);
116+
int32_t value = input[i + index];
117+
if (testIndex(value)) {
118+
filterCache[value] = FilterResult::kSuccess;
119+
passed |= 1 << index;
120+
} else {
121+
filterCache[value] = FilterResult::kFailure;
122+
}
123+
}
124+
}
125+
if (!passed) {
126+
continue;
127+
} else if (passed == (1 << kWidth) - 1) {
128+
xsimd::load_unaligned(rows + i).store_unaligned(filterHits + numValues);
129+
if (!kFilterOnly) {
130+
indices.store_unaligned(values + numValues);
131+
}
132+
numValues += kWidth;
133+
} else {
134+
const int32_t numBits = __builtin_popcount(passed);
135+
simd::filter(xsimd::load_unaligned(rows + i), passed)
136+
.store_unaligned(filterHits + numValues);
137+
if (!kFilterOnly) {
138+
simd::filter(indices, passed).store_unaligned(values + numValues);
139+
}
140+
numValues += numBits;
141+
}
142+
}
143+
return numValues;
144+
}
145+
36146
inline int32_t firstNullIndex(const uint64_t* nulls, int32_t numRows) {
37147
int32_t first = -1;
38148
bits::testUnsetBits(nulls, 0, numRows, [&](int32_t row) {

0 commit comments

Comments
 (0)