From 2ff64632cd00a0eba320405333d57a5307c425e8 Mon Sep 17 00:00:00 2001 From: Kanthi Subramanian Date: Thu, 29 Oct 2026 15:31:33 +0100 Subject: [PATCH 1/2] Replace HashSet for parquet dictionary filter pruning with std::vector + binary search to improve performance --- .../Formats/Impl/Parquet/Reader.cpp | 126 +++++++++++------- .../Impl/Parquet/parquetBloomFilterHash.cpp | 50 +++++++ src/Storages/MergeTree/KeyCondition.cpp | 9 ++ src/Storages/MergeTree/KeyCondition.h | 4 + ...ictionary_filter_hash_set_budget.reference | 8 +- ...et_v3_dictionary_filter_hash_set_budget.sh | 51 ++++--- ...ictionary_filter_string_rounding_budget.sh | 11 +- ...t_dictionary_filter_large_in_set.reference | 12 ++ ..._parquet_dictionary_filter_large_in_set.sh | 57 ++++++++ 9 files changed, 253 insertions(+), 75 deletions(-) create mode 100644 tests/queries/0_stateless/05024_parquet_dictionary_filter_large_in_set.reference create mode 100755 tests/queries/0_stateless/05024_parquet_dictionary_filter_large_in_set.sh diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index b8c3750c1070d..d335453a4ba9f 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -15,7 +15,6 @@ #include #include #include -#include #include #include #include @@ -1691,10 +1690,54 @@ bool Reader::columnChunkCanUseDictionaryFilter(const parq::ColumnChunk & column_ return has_dictionary_data_page; } +/// The value set of one column chunk's dictionary, prepared for lookups: the hashes of all +/// dictionary values, sorted for binary search. `default_value_hash` stands for the values the +/// dictionary does not hold: nulls decoded as the type's default under `input_format_null_as_default` +/// (see `hashDictionaryValues`). It is kept out of `hashes` so the vector stays exactly the +/// allocation `parquetTryHashColumn` made, which the pruning-memory reservation accounts for; +/// appending would regrow the vector geometrically past the reserved amount. +struct DictionaryValueHashes +{ + std::vector hashes; + std::optional default_value_hash; + + /// Whether any of `probes` is among the dictionary's values. + /// + /// For a sorted probe sequence - which is what `KeyCondition::prepareBloomFilterData` produces - + /// this is an intersection of two sorted sequences rather than a sequence of independent binary + /// searches: every probe continues where the previous one stopped. A handful of query constants + /// then costs a binary search each, while a large probe set degrades to a linear merge of the two + /// sequences instead of `probes.size()` full-height searches over the whole vector. That matters + /// because a probe set is not always small: `prepareBloomFilterCondition` deliberately keeps + /// hashing `IN` sets that are over the bloom filter's cap, so that the exact dictionary filter can + /// still prune them, which means `findAnyHash` can be called with thousands of probes for one + /// column chunk. An out-of-order probe merely restarts the window, so the result does not depend + /// on the probes being sorted. + bool containsAny(const std::vector & probes) const + { + auto it = hashes.begin(); + UInt64 previous_probe = 0; + for (UInt64 probe : probes) + { + if (probe == default_value_hash) + return true; + if (probe < previous_probe) + it = hashes.begin(); + previous_probe = probe; + it = std::lower_bound(it, hashes.end(), probe); + /// `it` at the end means no dictionary value is >= this probe: every later probe that is + /// not smaller searches an empty range, and a smaller one restarts from the beginning. + if (it != hashes.end() && *it == probe) + return true; + } + return false; + } +}; + /// Hash all values of an already-decoded dictionary the same way query constants are hashed for /// bloom filters, so the two can be compared. Returns nullopt if the values can't be hashed (in /// which case the dictionary can't be used for filtering). -static std::optional> hashDictionaryValues( +static std::optional hashDictionaryValues( const parq::FileMetaData & file_metadata, const ReadOptions & options, Reader::ColumnChunk & column, const Reader::PrimitiveColumnInfo & column_info, const PruningMemoryReservation & reservation, size_t & held_pruning_bytes) @@ -1707,8 +1750,8 @@ static std::optional> hashDictionaryValues( /// on-disk dictionary page (`dictionary_filter_limit_bytes`, 1 MiB by default), not the decoded /// value set we are about to build here. A highly compressible dictionary can stay under that /// limit yet decode to many times more, and constructing the value set below (a materialized - /// column of all values, a vector of hashes, and a `HashSet` of them) then allocates that much - /// transient memory - potentially for several row groups in parallel during pruning. Charge it to + /// column of all values and a vector of their hashes) then allocates that much transient + /// memory - potentially for several row groups in parallel during pruning. Charge it to /// the shared pruning-stage budget (`reservation`): the reader's memory high watermark minus what /// the pruning stage already holds - the decoded dictionaries charged in `ReadManager::runTask`, /// plus the value sets already reserved by other dictionary lookups, whether earlier in this same @@ -1725,7 +1768,7 @@ static std::optional> hashDictionaryValues( /// `estimated_value_set_bytes` must be an upper bound on the peak transient memory allocated below, /// so that once the reservation succeeds the value set is guaranteed to stay within budget while it /// is built. The `hashes` vector (allocated at exactly `count` capacity by `parquetTryHashColumn`, so - /// exactly `count * sizeof(UInt64)`) and the resulting `value_hashes` HashSet are always built; the + /// exactly `count * sizeof(UInt64)`) is always built and sorted in place; the /// hashing itself allocates nothing on top - `parquetTryHashColumn` hashes string values in place /// from the column's buffers rather than copying each into a `Field` scratch string, and every other /// hashable type is stored inline in `Field`. When @@ -1738,21 +1781,6 @@ static std::optional> hashDictionaryValues( /// back by `count` understates a mixed-length string dictionary by up to `count` bytes. The /// `Mode::Column` path hashes the already-decoded (and already-charged) column in place, so it /// needs none of the materialization terms. - /// - /// The `HashSet` term cannot be approximated per value: `HashSet::reserve` picks a power-of-two - /// buffer with a maximum fill factor of 0.5 (`HashTableGrowerWithPrecalculation::set`), so the - /// table holds up to ~4 cells per inserted hash and never fewer than its initial 256 cells. - /// Compute the buffer size with the set's own growth rule so the reservation matches what - /// `reserve` really allocates, for the full insert cardinality: all `count` dictionary hashes - /// plus the one extra default-value hash possibly added under `input_format_null_as_default` - /// below (reserving for it up front also guarantees that insert never triggers a rehash past the - /// reservation). Add the set's initial constructor-allocated buffer, which can transiently - /// coexist with the resized one inside `realloc`. - using ValueHashSet = HashSet; - ValueHashSet::grower_type value_set_grower; - value_set_grower.set(count + 1); - size_t value_set_buffer_bytes = - (value_set_grower.bufSize() + ValueHashSet::grower_type::initial_count) * sizeof(ValueHashSet::cell_type); size_t per_value_bytes = sizeof(UInt64); /// `hashes` vector size_t materialized_payload_bytes = 0; if (column.dictionary.mode != Dictionary::Mode::Column) @@ -1767,12 +1795,12 @@ static std::optional> hashDictionaryValues( sizeof(UInt64) /// materialized `ColumnString` offsets (0 for fixed types; counted conservatively) + sizeof(UInt32); /// identity `indexes` } - size_t estimated_value_set_bytes = count * per_value_bytes + materialized_payload_bytes + value_set_buffer_bytes; + size_t estimated_value_set_bytes = count * per_value_bytes + materialized_payload_bytes; /// Reserve the peak footprint before allocating anything. If it does not fit, skip pruning. if (!reservation.tryReserve(estimated_value_set_bytes)) return std::nullopt; /// Release the whole reservation on every early-out below; only the successful path keeps the - /// persistent part reserved (reduced to the actual `HashSet` footprint) and hands it to the caller + /// persistent part reserved (reduced to the actual `hashes` buffer) and hands it to the caller /// via `held_pruning_bytes` (see `DictionaryLookup`, which releases it when the value set is freed). bool committed = false; SCOPE_EXIT({ if (!committed) reservation.release(estimated_value_set_bytes); }); @@ -1803,12 +1831,15 @@ static std::optional> hashDictionaryValues( } if (!hashes.has_value()) return std::nullopt; - ValueHashSet value_hashes; - /// +1 for the possible extra default-value hash below: when `hashes->size()` lands exactly on the - /// table's maximum fill, that late insert would otherwise rehash past the reserved buffer. - value_hashes.reserve(hashes->size() + 1); - for (UInt64 h : *hashes) - value_hashes.insert(h); + + DictionaryValueHashes value_hashes; + value_hashes.hashes = std::move(*hashes); + hashes.reset(); + /// Sort once so every lookup is a binary search. Sorting in place needs no extra memory, unlike + /// a hash table of the values, whose buffer (a power-of-two sized to a maximum fill factor of + /// 0.5) would hold up to ~4 cells per value on top of this vector - several times the footprint + /// for a value set that is built once per column chunk and probed a handful of times. + std::sort(value_hashes.hashes.begin(), value_hashes.hashes.end()); /// The dictionary holds only the non-null values of the column chunk, so we must account for how /// nulls are read into the output, mirroring the conservative null handling of the min/max path in @@ -1828,7 +1859,7 @@ static std::optional> hashDictionaryValues( /// If the default value can't be hashed, we can't rule out a match. if (!default_hash.has_value()) return std::nullopt; - value_hashes.insert(*default_hash); + value_hashes.default_value_hash = default_hash; } else { @@ -1839,23 +1870,23 @@ static std::optional> hashDictionaryValues( } } - /// Free the transient `hashes` vector (the `indexes`/`values` allocations were already freed by - /// leaving their scope above) before releasing the reservation that covers it: otherwise a - /// concurrent pruning task could observe the released budget as free while `hashes` is still - /// allocated here, transiently exceeding the watermark. - hashes.reset(); - /// The value set is kept alive (in its `DictionaryLookup`) until this whole row-group filter - /// evaluation finishes, so keep its persistent footprint - the real `HashSet` buffer - reserved + /// evaluation finishes, so keep its persistent footprint - the sorted `hashes` buffer - reserved /// against the shared budget and hand the amount to the caller to release when the value set is - /// freed. The transient `indexes`/`values`/`hashes` allocations were already freed above, so - /// release that part of the reservation now: a second dictionary-filtered column, or another row - /// group pruning in parallel, then sees only the persistent part still held, keeping the combined - /// footprint within the watermark without over-reserving for the transients. The estimate above - /// follows the set's own growth rule, so it never under-predicts the buffer; the grow branch is a - /// defensive backstop (mirroring `decodeDictionaryPage`) in case the two ever drift apart, falling - /// back to a full scan if the correction no longer fits the budget. - size_t persistent_bytes = value_hashes.getBufferSizeInBytes(); + /// freed. The transient `indexes`/`values` allocations were already freed by leaving their scope + /// above, so release that part of the reservation now: a second dictionary-filtered column, or + /// another row group pruning in parallel, then sees only the persistent part still held, keeping + /// the combined footprint within the watermark without over-reserving for the transients. The + /// estimate above covers the buffer exactly (`parquetTryHashColumn` reserves exactly `count` + /// elements); the grow branch is a defensive backstop (mirroring `decodeDictionaryPage`) in case + /// the two ever drift apart, falling back to a full scan if the correction no longer fits the + /// budget. + /// The vector must be exactly what `parquetTryHashColumn` allocated: the default value hash above + /// is kept in a separate field precisely so that nothing is appended here, which would grow the + /// vector geometrically past the reservation (and, for a dictionary whose reservation is nothing + /// but the vector - the `Mode::Column` path - past the whole budget). + chassert(value_hashes.hashes.size() == count); + size_t persistent_bytes = value_hashes.hashes.capacity() * sizeof(UInt64); if (persistent_bytes > estimated_value_set_bytes) { if (!reservation.tryReserve(persistent_bytes - estimated_value_set_bytes)) @@ -1884,7 +1915,7 @@ struct Reader::DictionaryLookup : public KeyCondition::BloomFilter size_t reserved_bytes = 0; bool computed = false; - std::optional> value_hashes; + std::optional value_hashes; /// Bloom filter of the same column chunk, kept as a fallback for when the exact dictionary value set /// can't be built within the pruning memory budget (see `hashDictionaryValues`). Without it such a @@ -1918,10 +1949,7 @@ bool Reader::DictionaryLookup::findAnyHash(const std::vector & hashes) /// fall back to the column chunk's bloom filter if it has one; otherwise we can't rule out a match. if (!value_hashes.has_value()) return bloom_fallback ? bloom_fallback->findAnyHash(hashes) : true; - for (UInt64 h : hashes) - if (value_hashes->contains(h)) - return true; - return false; + return value_hashes->containsAny(hashes); } bool Reader::applyBloomAndDictionaryFilters(RowGroup & row_group, PruningMemoryReservation reservation) diff --git a/src/Processors/Formats/Impl/Parquet/parquetBloomFilterHash.cpp b/src/Processors/Formats/Impl/Parquet/parquetBloomFilterHash.cpp index d07074524cf40..f120017389cd1 100644 --- a/src/Processors/Formats/Impl/Parquet/parquetBloomFilterHash.cpp +++ b/src/Processors/Formats/Impl/Parquet/parquetBloomFilterHash.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #if USE_PARQUET @@ -151,6 +152,36 @@ std::optional tryHashInt(const Field & field, const std::shared_ptr +static bool tryHashIntegerColumnTyped(const IColumn & column, std::vector & hashes) +{ + const auto * typed = checkAndGetColumn>(&column); + if (!typed) + return false; + parquet::XxHasher hasher; + for (T value : typed->getData()) + hashes.emplace_back(hasher.Hash(static_cast(value))); + return true; +} + +/// Hash a whole integer column the way `tryHashInt` hashes one query constant. Going through the +/// native data array must produce the same digests as going through `Field`: the `Field` path +/// widens the value to `Int64`/`UInt64` (sign- or zero-extending by the value's own signedness) +/// and then narrows to the physical type, which keeps exactly the low bits - the same result as +/// `static_cast`ing the native value to the physical type directly. +template +static bool tryHashIntegerColumn(const IColumn & column, std::vector & hashes) +{ + return tryHashIntegerColumnTyped(column, hashes) + || tryHashIntegerColumnTyped(column, hashes) + || tryHashIntegerColumnTyped(column, hashes) + || tryHashIntegerColumnTyped(column, hashes) + || tryHashIntegerColumnTyped(column, hashes) + || tryHashIntegerColumnTyped(column, hashes) + || tryHashIntegerColumnTyped(column, hashes) + || tryHashIntegerColumnTyped(column, hashes); +} + std::optional parquetTryHashField(const Field & field, const parquet::ColumnDescriptor * parquet_column_descriptor) { const auto physical_type = parquet_column_descriptor->physical_type(); @@ -214,6 +245,25 @@ std::optional> parquetTryHashColumn(const IColumn * data_c return hashes; } + /// Hash integer columns directly from their data arrays. The generic `Field` path below spends + /// most of its time constructing a `Field` and re-dispatching on the type for every value; for + /// dictionaries of hundreds of thousands of entries per row group that dominates the whole + /// pruning stage. The type check is the same one `tryHashInt` applies per value, hoisted out of + /// the loop; a column the dispatch below does not recognize (e.g. `IPv4`) falls through to the + /// generic path unchanged. + if (physical_type == parquet::Type::type::INT32 || physical_type == parquet::Type::type::INT64) + { + if (isParquetIntegerTypeSupportedForBloomFilters( + parquet_column_descriptor->logical_type(), parquet_column_descriptor->converted_type())) + { + bool done = physical_type == parquet::Type::type::INT32 + ? tryHashIntegerColumn(*column, hashes) + : tryHashIntegerColumn(*column, hashes); + if (done) + return hashes; + } + } + for (size_t i = 0u; i < column->size(); i++) { Field f; diff --git a/src/Storages/MergeTree/KeyCondition.cpp b/src/Storages/MergeTree/KeyCondition.cpp index 1a271a45dd73b..f84f1a31c3c7c 100644 --- a/src/Storages/MergeTree/KeyCondition.cpp +++ b/src/Storages/MergeTree/KeyCondition.cpp @@ -6788,6 +6788,15 @@ void KeyCondition::prepareBloomFilterData(std::function( continue; } + /// Sort and deduplicate the probe hashes once, here, instead of leaving that to every + /// lookup on every granule/row group: a filter can then intersect them with its own + /// sorted value set in one merge pass (see the Parquet `DictionaryLookup`), and the + /// duplicates that an `IN` set can produce - different values whose hashes collide, or + /// a tuple element repeated across set rows - are probed once instead of many times. + std::sort(hashes_for_column.begin(), hashes_for_column.end()); + hashes_for_column.erase( + std::unique(hashes_for_column.begin(), hashes_for_column.end()), hashes_for_column.end()); + hashes.emplace_back(hashes_for_column); key_columns_for_element.push_back(indexes_mapping[i].key_index); diff --git a/src/Storages/MergeTree/KeyCondition.h b/src/Storages/MergeTree/KeyCondition.h index 2de176f3cb068..9591402c1dbc7 100644 --- a/src/Storages/MergeTree/KeyCondition.h +++ b/src/Storages/MergeTree/KeyCondition.h @@ -105,6 +105,10 @@ class KeyCondition { virtual ~BloomFilter() = default; + /// `hashes` are the hashes of the query constants of one atom for one column. They are sorted + /// and deduplicated (see `prepareBloomFilterData`), which lets an implementation with a sorted + /// value set intersect the two sequences in one pass instead of searching for each hash + /// separately. Returns true if any of them may be present. virtual bool findAnyHash(const std::vector & hashes) = 0; }; diff --git a/tests/queries/0_stateless/04615_parquet_v3_dictionary_filter_hash_set_budget.reference b/tests/queries/0_stateless/04615_parquet_v3_dictionary_filter_hash_set_budget.reference index f825802820e5b..69a53c2ce01d1 100644 --- a/tests/queries/0_stateless/04615_parquet_v3_dictionary_filter_hash_set_budget.reference +++ b/tests/queries/0_stateless/04615_parquet_v3_dictionary_filter_hash_set_budget.reference @@ -1,6 +1,8 @@ generous memory budget: the dictionary filter prunes row group 1, only 16384 rows are read {"result":[{"count()":1}],"rows_read":16384} -watermark between the underestimated and the real HashSet footprint: pruning must be skipped, all 32768 rows are read +watermark that affords the hash vectors: the dictionary filter still prunes, only 16384 rows are read +{"result":[{"count()":1}],"rows_read":16384} +watermark just below what the value set needs: pruning must be skipped, all 32768 rows are read {"result":[{"count()":1}],"rows_read":32768} extreme memory budget (1 byte): pruning is skipped, result is still correct {"result":[{"count()":1}],"rows_read":32768} @@ -10,5 +12,9 @@ null_as_default: querying the default value reads the row group with nulls (256 {"result":[{"count()":256}],"rows_read":4352} null_as_default: a value only in row group 1 prunes row group 0 despite its default-extended value set {"result":[{"count()":1}],"rows_read":4352} +null_as_default, watermark that affords the hash vectors: the null-free row group is still pruned +{"result":[{"count()":256}],"rows_read":4352} +null_as_default, watermark just below what the value set needs: pruning is skipped, all 8704 rows are read +{"result":[{"count()":256}],"rows_read":8704} null_as_default results are identical regardless of the memory budget OK diff --git a/tests/queries/0_stateless/04615_parquet_v3_dictionary_filter_hash_set_budget.sh b/tests/queries/0_stateless/04615_parquet_v3_dictionary_filter_hash_set_budget.sh index 1ed543ebdebad..18b4d8bf9c586 100755 --- a/tests/queries/0_stateless/04615_parquet_v3_dictionary_filter_hash_set_budget.sh +++ b/tests/queries/0_stateless/04615_parquet_v3_dictionary_filter_hash_set_budget.sh @@ -2,18 +2,15 @@ # Tags: no-fasttest # no-fasttest: needs the Parquet format which is not built in fasttest. -# The value-set reservation in `hashDictionaryValues` must be a genuine upper bound on what the -# pruning path really allocates. Its trickiest term is the `HashSet` of dictionary-value hashes: -# `HashSet::reserve` picks a power-of-two buffer with a maximum fill factor of 0.5, so the table -# holds up to ~4 cells per inserted hash (not 2), never fewer than its initial 256 cells, and under -# `input_format_null_as_default` one extra default-value hash can be inserted after the reservation. -# This test pins `input_format_parquet_memory_high_watermark` inside the gap between an -# underestimated footprint (~2 cells per hash, as the reservation once computed) and the real -# `HashSet` buffer size, for a dictionary cardinality just above a power-of-two boundary where the -# discrepancy is maximal (16384 entries + the reserved extra default hash -> a 65536-cell table). -# With a correct upper bound the reservation does not fit and the filter falls back to a full scan; -# if the reservation ever regresses to an underestimate, it would consider the value set affordable -# and prune again, flipping `rows_read` and failing this test. +# The value-set reservation in `hashDictionaryValues` must track what the pruning path really +# allocates: the vector of dictionary-value hashes (sorted in place; exactly 8 bytes per entry), +# plus the extra default-value hash kept outside the vector under `input_format_null_as_default`. +# This test pins `input_format_parquet_memory_high_watermark` on both sides of the boundary where +# the value set stops fitting the pruning budget, for two row groups of 16384 distinct values each. +# If the reservation regresses to an underestimate (say, stops charging the vector), the tighter +# watermark would keep pruning; if it regresses to an overestimate (say, back to the hash-table +# model this replaced, ~4 cells per value plus the vector), the looser watermark would stop +# pruning. Either flip changes `rows_read` and fails this test. CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../shell_config.sh @@ -23,9 +20,7 @@ DATA_FILE="${CLICKHOUSE_TEST_UNIQUE_NAME}.parquet" NULLS_FILE="${CLICKHOUSE_TEST_UNIQUE_NAME}_nulls.parquet" # 2 row groups of 16384 rows. Every row has a distinct Int32 `category`, so each row group's -# dictionary holds 16384 entries - together with the extra default-value slot reserved up front, -# one past the 0.5-max-fill capacity of a 32768-cell table, so the value set reserves a 65536-cell -# (512 KiB) `HashSet` buffer while a 2-cells-per-hash underestimate would charge only 256 KiB. +# dictionary holds 16384 entries and its value set reserves a 128 KiB hash vector. # `output_format_parquet_max_dictionary_size` is raised so the writer keeps the column # dictionary-encoded; `max_block_size` / `output_format_parquet_row_group_size` pin the row group # boundaries deterministically. @@ -51,9 +46,12 @@ run() { echo "generous memory budget: the dictionary filter prunes row group 1, only 16384 rows are read" run 4000000000 "select count() from file('${DATA_FILE}', Parquet) where category = 5" -echo "watermark between the underestimated and the real HashSet footprint: pruning must be skipped, all 32768 rows are read" +echo "watermark that affords the hash vectors: the dictionary filter still prunes, only 16384 rows are read" run 6000000 "select count() from file('${DATA_FILE}', Parquet) where category = 5" +echo "watermark just below what the value set needs: pruning must be skipped, all 32768 rows are read" +run 4000000 "select count() from file('${DATA_FILE}', Parquet) where category = 5" + echo "extreme memory budget (1 byte): pruning is skipped, result is still correct" run 1 "select count() from file('${DATA_FILE}', Parquet) where category = 5" @@ -63,10 +61,9 @@ diff \ <(${CH} --input_format_parquet_memory_high_watermark=6000000 --query="select category, count(), sum(n) from file('${DATA_FILE}', Parquet) where category in (5, 100007, 100, 116383) group by category order by category") \ && echo "OK" -# The extra default-value hash inserted under `input_format_null_as_default`: row group 0 holds -# exactly 4096 distinct non-null values (precisely the 0.5 max fill of the reserved table, so the -# late extra insert would rehash if it were not reserved up front) plus 256 nulls; row group 1 holds -# 4352 distinct non-null values and no nulls. Values start at 1, so the type default 0 matches only +# The extra default-value hash added under `input_format_null_as_default`: row group 0 holds 4096 +# distinct non-null values plus 256 nulls; row group 1 holds 4352 distinct non-null values and no +# nulls. Values start at 1, so the type default 0 matches only # the nulls of row group 0: querying it must read row group 0 (its nulls decode to 0) while still # pruning the null-free row group 1, and querying a value that exists only in row group 1 must prune # row group 0 even though its value set was extended with the default. @@ -87,6 +84,20 @@ run 4000000000 "select count() from file('${NULLS_FILE}', Parquet, 'n UInt64, ca echo "null_as_default: a value only in row group 1 prunes row group 0 despite its default-extended value set" run 4000000000 "select count() from file('${NULLS_FILE}', Parquet, 'n UInt64, category Int32') where category = 100005" --input_format_null_as_default=1 +# The nullable file gets the same both-sides watermark check as the non-null one above, so the +# accounting of the nullable path (where the value set is extended with the default value hash) is +# pinned too, not just its results: a watermark that affords the hash vectors must keep pruning, and +# one below what they need must fall back to a full scan. Note that the "the default value hash stays +# out of the vector" invariant itself cannot be pinned by a watermark: for this `FixedSize` dictionary +# the reservation also covers the materialization terms, which leave enough headroom for a doubled +# vector, so appending would not change the behavior here. It is pinned by a `chassert` in +# `hashDictionaryValues` instead, which every debug and sanitizer build checks on this very query. +echo "null_as_default, watermark that affords the hash vectors: the null-free row group is still pruned" +run 1100000 "select count() from file('${NULLS_FILE}', Parquet, 'n UInt64, category Int32') where category = 0" --input_format_null_as_default=1 + +echo "null_as_default, watermark just below what the value set needs: pruning is skipped, all 8704 rows are read" +run 975000 "select count() from file('${NULLS_FILE}', Parquet, 'n UInt64, category Int32') where category = 0" --input_format_null_as_default=1 + echo "null_as_default results are identical regardless of the memory budget" diff \ <(${CH} --input_format_null_as_default=1 --input_format_parquet_memory_high_watermark=4000000000 --query="select count(), sum(n) from file('${NULLS_FILE}', Parquet, 'n UInt64, category Int32') where category in (0, 3, 100005)") \ diff --git a/tests/queries/0_stateless/04617_parquet_v3_dictionary_filter_string_rounding_budget.sh b/tests/queries/0_stateless/04617_parquet_v3_dictionary_filter_string_rounding_budget.sh index c993a7fd8b61c..bf3bd1161c522 100755 --- a/tests/queries/0_stateless/04617_parquet_v3_dictionary_filter_string_rounding_budget.sh +++ b/tests/queries/0_stateless/04617_parquet_v3_dictionary_filter_string_rounding_budget.sh @@ -10,9 +10,10 @@ # This test builds a dictionary of 19999 three-char strings plus one two-char string per row group, # so the mean value size is 2.99995: the floored estimate would charge `2 * 2 * 20000` bytes for the # materialized `chars` while the exact one charges `2 * 59999`. The watermark is pinned inside that -# gap: with the exact reservation the value set does not fit and the filter falls back to a full -# scan; a regression to the floored estimate would consider it affordable and prune again, flipping -# `rows_read` and failing this test. +# gap (the absolute value also covers the hash vector and the per-value materialization terms of the +# reservation): with the exact reservation the value set does not fit and the filter falls back to a +# full scan; a regression to the floored estimate would consider it affordable and prune again, +# flipping `rows_read` and failing this test. CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../shell_config.sh @@ -54,7 +55,7 @@ echo "generous memory budget: the dictionary filter prunes row group 1, only 200 run 4000000000 "select count() from file('${DATA_FILE}', Parquet) where category = '!a'" echo "watermark between the floored and the exact value-set footprint: pruning must be skipped, all 40000 rows are read" -run 9170000 "select count() from file('${DATA_FILE}', Parquet) where category = '!a'" +run 6615000 "select count() from file('${DATA_FILE}', Parquet) where category = '!a'" echo "extreme memory budget (1 byte): pruning is skipped, result is still correct" run 1 "select count() from file('${DATA_FILE}', Parquet) where category = '!a'" @@ -62,5 +63,5 @@ run 1 "select count() from file('${DATA_FILE}', Parquet) where category = '!a'" echo "results are identical regardless of the memory budget, including across row groups" diff \ <(${CH} --input_format_parquet_memory_high_watermark=4000000000 --query="select category, count(), sum(n) from file('${DATA_FILE}', Parquet) where category in ('!a', '!b', 'aaf', '45d') group by category order by category") \ - <(${CH} --input_format_parquet_memory_high_watermark=9170000 --query="select category, count(), sum(n) from file('${DATA_FILE}', Parquet) where category in ('!a', '!b', 'aaf', '45d') group by category order by category") \ + <(${CH} --input_format_parquet_memory_high_watermark=6615000 --query="select category, count(), sum(n) from file('${DATA_FILE}', Parquet) where category in ('!a', '!b', 'aaf', '45d') group by category order by category") \ && echo "OK" diff --git a/tests/queries/0_stateless/05024_parquet_dictionary_filter_large_in_set.reference b/tests/queries/0_stateless/05024_parquet_dictionary_filter_large_in_set.reference new file mode 100644 index 0000000000000..1bb446e75140d --- /dev/null +++ b/tests/queries/0_stateless/05024_parquet_dictionary_filter_large_in_set.reference @@ -0,0 +1,12 @@ +a set of 5000 values that no row group holds: every row group is pruned +{"result":[{"count()":0,"sum(n)":0,"min(category)":0,"max(category)":0}],"parquet_rows_read":0} +same result without the dictionary filter +a set of 5000 values of which one is in row group 2 only: only that row group is read +{"result":[{"count()":1,"sum(n)":16461,"min(category)":200077,"max(category)":200077}],"parquet_rows_read":8192} +same result without the dictionary filter +a set of 5000 values interleaved with every row group: nothing is pruned +{"result":[{"count()":5000,"sum(n)":67685000,"min(category)":0,"max(category)":302498}],"parquet_rows_read":32768} +same result without the dictionary filter +a set of 5000 values matching only the last value of the last row group +{"result":[{"count()":1,"sum(n)":32767,"min(category)":308191,"max(category)":308191}],"parquet_rows_read":8192} +same result without the dictionary filter diff --git a/tests/queries/0_stateless/05024_parquet_dictionary_filter_large_in_set.sh b/tests/queries/0_stateless/05024_parquet_dictionary_filter_large_in_set.sh new file mode 100755 index 0000000000000..7b54e9acbc361 --- /dev/null +++ b/tests/queries/0_stateless/05024_parquet_dictionary_filter_large_in_set.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Tags: no-fasttest +# no-fasttest: needs the Parquet format which is not built in fasttest. + +# `IN` sets larger than the bloom filter's set-size cap (`bloom_filter_max_set_size`, 100) are hashed +# only for the exact dictionary filter of the native Parquet reader, so `findAnyHash` gets thousands of +# probe hashes for one column chunk. The dictionary lookup intersects them with its own sorted vector +# of value hashes in one pass over both sequences, which only works if it is oblivious to how the +# probes and the values interleave. This test checks the pruning decisions and the results of that +# intersection: a large set that misses every row group, one that hits a single row group, and one +# that hits all of them, each compared against the same query with the dictionary filter disabled. + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +DATA_FILE="${CLICKHOUSE_TEST_UNIQUE_NAME}.parquet" + +# 4 row groups of 8192 distinct Int32 values each: row group `g` holds `g * 100000 + [0, 8192)`. +${CLICKHOUSE_CLIENT} --query=" + insert into function file('${DATA_FILE}', Parquet) + select number as n, toInt32(intDiv(number, 8192) * 100000 + (number % 8192)) as category + from numbers(32768) + settings output_format_parquet_row_group_size = 8192, output_format_parquet_max_dictionary_size = 100000000, engine_file_truncate_on_insert = 1, max_block_size = 1000000; +" + +CH="${CLICKHOUSE_CLIENT} --input_format_parquet_filter_push_down=0 --input_format_parquet_page_filter_push_down=0 --input_format_parquet_bloom_filter_push_down=0 --optimize_move_to_prewhere=0 --use_cache_for_count_from_files=0 --max_threads=1 --max_parsing_threads=1" + +# `$1` is the `IN` set expression: the same query runs with and without the dictionary filter. +check() { + local set_expression="$1" + local query="select count(), sum(n), min(category), max(category) from file('${DATA_FILE}', Parquet) where category in (${set_expression})" + local with_filter + local without_filter + # The `numbers(5000)` of the set subquery is counted in `rows_read` too, so subtract it to leave + # the number of rows read from the Parquet file (32768 in total, in 4 row groups of 8192). + with_filter=$(${CH} --input_format_parquet_dictionary_filter_push_down=100000000 --query="${query} FORMAT JSON" | jq -c '{result: .data, parquet_rows_read: (.statistics.rows_read - 5000)}') + without_filter=$(${CH} --input_format_parquet_dictionary_filter_push_down=0 --query="${query} FORMAT JSON" | jq -c '.data') + echo "${with_filter}" + if [ "$(echo "${with_filter}" | jq -c '.result')" = "${without_filter}" ]; then + echo "same result without the dictionary filter" + else + echo "MISMATCH: ${without_filter}" + fi +} + +echo "a set of 5000 values that no row group holds: every row group is pruned" +check "select 1000000 + number * 3 from numbers(5000)" + +echo "a set of 5000 values of which one is in row group 2 only: only that row group is read" +check "select if(number = 4000, 200077, 1000000 + number * 3) from numbers(5000)" + +echo "a set of 5000 values interleaved with every row group: nothing is pruned" +check "select intDiv(number, 1250) * 100000 + (number % 1250) * 2 from numbers(5000)" + +echo "a set of 5000 values matching only the last value of the last row group" +check "select if(number = 4999, 308191, 1000000 + number * 3) from numbers(5000)" From 84e16b266aa9e6aef7f398b94ac443dc5f7e1012 Mon Sep 17 00:00:00 2001 From: Kanthi Subramanian Date: Thu, 29 Oct 2026 21:28:48 +0100 Subject: [PATCH 2/2] Retune watermark for dicitionary filter tests --- ...15_parquet_v3_dictionary_filter_hash_set_budget.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/queries/0_stateless/04615_parquet_v3_dictionary_filter_hash_set_budget.sh b/tests/queries/0_stateless/04615_parquet_v3_dictionary_filter_hash_set_budget.sh index 18b4d8bf9c586..1dbfc383f6137 100755 --- a/tests/queries/0_stateless/04615_parquet_v3_dictionary_filter_hash_set_budget.sh +++ b/tests/queries/0_stateless/04615_parquet_v3_dictionary_filter_hash_set_budget.sh @@ -11,6 +11,11 @@ # watermark would keep pruning; if it regresses to an overestimate (say, back to the hash-table # model this replaced, ~4 cells per value plus the vector), the looser watermark would stop # pruning. Either flip changes `rows_read` and fails this test. +# +# NOTE: The pruning reservation only sees the BloomFilterBlocksOrDictionary stage's share of +# `input_format_parquet_memory_high_watermark` (about 10% on this branch; see `ReadManager`), not +# the whole watermark, so the "affords" values below are looser than the same numbers would be on +# a build with a larger stage fraction. CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../shell_config.sh @@ -47,7 +52,7 @@ echo "generous memory budget: the dictionary filter prunes row group 1, only 163 run 4000000000 "select count() from file('${DATA_FILE}', Parquet) where category = 5" echo "watermark that affords the hash vectors: the dictionary filter still prunes, only 16384 rows are read" -run 6000000 "select count() from file('${DATA_FILE}', Parquet) where category = 5" +run 10000000 "select count() from file('${DATA_FILE}', Parquet) where category = 5" echo "watermark just below what the value set needs: pruning must be skipped, all 32768 rows are read" run 4000000 "select count() from file('${DATA_FILE}', Parquet) where category = 5" @@ -58,7 +63,7 @@ run 1 "select count() from file('${DATA_FILE}', Parquet) where category = 5" echo "results are identical regardless of the memory budget" diff \ <(${CH} --input_format_parquet_memory_high_watermark=4000000000 --query="select category, count(), sum(n) from file('${DATA_FILE}', Parquet) where category in (5, 100007, 100, 116383) group by category order by category") \ - <(${CH} --input_format_parquet_memory_high_watermark=6000000 --query="select category, count(), sum(n) from file('${DATA_FILE}', Parquet) where category in (5, 100007, 100, 116383) group by category order by category") \ + <(${CH} --input_format_parquet_memory_high_watermark=10000000 --query="select category, count(), sum(n) from file('${DATA_FILE}', Parquet) where category in (5, 100007, 100, 116383) group by category order by category") \ && echo "OK" # The extra default-value hash added under `input_format_null_as_default`: row group 0 holds 4096 @@ -93,7 +98,7 @@ run 4000000000 "select count() from file('${NULLS_FILE}', Parquet, 'n UInt64, ca # vector, so appending would not change the behavior here. It is pinned by a `chassert` in # `hashDictionaryValues` instead, which every debug and sanitizer build checks on this very query. echo "null_as_default, watermark that affords the hash vectors: the null-free row group is still pruned" -run 1100000 "select count() from file('${NULLS_FILE}', Parquet, 'n UInt64, category Int32') where category = 0" --input_format_null_as_default=1 +run 2000000 "select count() from file('${NULLS_FILE}', Parquet, 'n UInt64, category Int32') where category = 0" --input_format_null_as_default=1 echo "null_as_default, watermark just below what the value set needs: pruning is skipped, all 8704 rows are read" run 975000 "select count() from file('${NULLS_FILE}', Parquet, 'n UInt64, category Int32') where category = 0" --input_format_null_as_default=1