From 3b7ce77f3ff273e3f88cdc0c57e3066e8568e417 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Tue, 25 Aug 2026 21:22:56 +0300 Subject: [PATCH 01/14] Parquet v3: skip reading constant column chunks via statistics The Parquet v3 reader now detects column chunks that provably hold a single value in every row and materializes that value without fetching or decoding any of the chunk's data pages. On wide tables with low-cardinality or defaulted columns this removes both the I/O and the decode cost of those columns. A chunk is treated as constant when its column statistics prove it: - min == max with no nulls: materialize the decoded min value; - null_count == num_values (physically-nullable leaf): the whole chunk is null, materialized as Null for a Nullable output or the column default under input_format_null_as_default (a non-nullable output without null substitution is left to the normal decode path). The result is emitted as a ColumnConst rather than an expanded column: O(1) memory instead of O(rows), and the const-ness propagates downstream so a PREWHERE/WHERE predicate is evaluated from the single value and GROUP BY / aggregation get a const key. An all-null chunk additionally records every row in block_missing_values so input_format_null_as_default still applies. Correctness of min == max is gated on the physical type. Only fixed-width numeric physical types (BOOLEAN, INT32, INT64, INT96, FLOAT, DOUBLE), whose statistics are never truncated, are trusted unconditionally; BYTE_ARRAY, FIXED_LEN_BYTE_ARRAY and any future physical type require the writer's is_min_value_exact / is_max_value_exact flags, because a truncated min/max could make two distinct values compare equal. This is an allowlist, so an unrecognized type fails closed (treated as possibly-truncated). Date, Time and Timestamp logical types ride on the trusted integer physical types; large Decimals stored as FIXED_LEN_BYTE_ARRAY correctly fall to the flag-gated path. Controlled by input_format_parquet_use_constant_column_optimization (default on) and counted by the ParquetConstantColumnChunks profile event. Only flat, top-level primitive columns are eligible (a parquet value maps 1:1 to an output row); arrays, physically-nullable structs, and leaves nested in Tuple/Map/Array outputs are excluded. Signed-off-by: UnamedRus Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Common/ProfileEvents.cpp | 1 + src/Core/FormatFactorySettings.h | 3 + src/Core/SettingsChangesHistory.cpp | 1 + src/Formats/FormatFactory.cpp | 1 + src/Formats/FormatSettings.h | 1 + .../Formats/Impl/Parquet/Reader.cpp | 195 +++++++++++++++++- src/Processors/Formats/Impl/Parquet/Reader.h | 32 +++ ...uet_constant_column_optimization.reference | 12 ++ ...11_parquet_constant_column_optimization.sh | 69 +++++++ 9 files changed, 308 insertions(+), 7 deletions(-) create mode 100644 tests/queries/0_stateless/04811_parquet_constant_column_optimization.reference create mode 100755 tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index 33875c64a7b7..d5ca3f30fd7d 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -1476,6 +1476,7 @@ The server successfully detected this situation and will download merged part fr \ M(ParquetReadRowGroups, "The total number of row groups read from parquet data", ValueType::Number) \ M(ParquetPrunedRowGroups, "The total number of row groups pruned from parquet data", ValueType::Number) \ + M(ParquetConstantColumnChunks, "The total number of parquet column chunks materialized from a single value in their min/max statistics, without reading their data pages", ValueType::Number) \ M(ParquetDecodingTasks, "Tasks issued by parquet reader", ValueType::Number) \ M(ParquetDecodingTaskBatches, "Task groups sent to a thread pool by parquet reader", ValueType::Number) \ M(ParquetPrefetcherReadRandomRead, "The total number of reads with ReadMode::RandomRead by DB::Parquet::Prefetcher", ValueType::Number) \ diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index 70efdcb163ae..29aba2dc725c 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -212,6 +212,9 @@ Skip pages using min/max values from column index. )", 0) \ DECLARE(Bool, input_format_parquet_use_offset_index, true, R"( Minor tweak to how pages are read from parquet file when no page filtering is used. +)", 0) \ + DECLARE(Bool, input_format_parquet_use_constant_column_optimization, true, R"( +When a Parquet column chunk provably holds a single value in every row (according to its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages. )", 0) \ DECLARE(Bool, input_format_parquet_verify_checksums, true, R"( Verify page checksums when reading parquet files. diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index f1b2bab3ac3a..6322d7c3b017 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -42,6 +42,7 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() addSettingsChanges(settings_changes_history, "26.6.2.20001.altinityantalya", { {"use_puffin_files_cache", false, true, "Enables cache of parsed Puffin file content such as deletion vectors."}, + {"input_format_parquet_use_constant_column_optimization", false, true, "New setting: when a Parquet column chunk provably holds a single value in every row (per its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages (reader v3)."}, }); addSettingsChanges(settings_changes_history, "26.6", diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index d6c30363c0d2..517f72ac56ef 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -219,6 +219,7 @@ FormatSettings getFormatSettings(const ContextPtr & context, const Settings & se format_settings.parquet.bloom_filter_push_down = settings[Setting::input_format_parquet_bloom_filter_push_down]; format_settings.parquet.page_filter_push_down = settings[Setting::input_format_parquet_page_filter_push_down]; format_settings.parquet.use_offset_index = settings[Setting::input_format_parquet_use_offset_index]; + format_settings.parquet.use_constant_column_optimization = settings[Setting::input_format_parquet_use_constant_column_optimization]; format_settings.parquet.enable_json_parsing = settings[Setting::input_format_parquet_enable_json_parsing]; format_settings.parquet.memory_low_watermark = settings[Setting::input_format_parquet_memory_low_watermark]; diff --git a/src/Formats/FormatSettings.h b/src/Formats/FormatSettings.h index 745898c0c751..9e8e321c4ff8 100644 --- a/src/Formats/FormatSettings.h +++ b/src/Formats/FormatSettings.h @@ -349,6 +349,7 @@ struct FormatSettings bool bloom_filter_push_down = true; bool page_filter_push_down = true; bool use_offset_index = true; + bool use_constant_column_optimization = true; bool enable_json_parsing = true; bool preserve_order = false; diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index a3c91158f695..3f51b7ad0b69 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -42,6 +43,7 @@ namespace ProfileEvents { extern const Event ParquetRowsFilterExpression; extern const Event ParquetColumnsFilterExpression; + extern const Event ParquetConstantColumnChunks; } namespace DB::Parquet @@ -456,6 +458,8 @@ void Reader::prefilterAndInitRowGroups(const std::optionalmeta_data.statistics.__isset.null_count && column.meta->meta_data.statistics.null_count == 0; column.need_null_map = is_nullable && !null_count_is_known_to_be_zero; + + detectConstantColumn(column, primitive_columns[column_idx]); } } @@ -578,7 +582,9 @@ void Reader::initializePrefetches() /// Dictionary page. size_t dict_page_length = 0; - if (column.meta->meta_data.__isset.dictionary_page_offset) + /// A constant column chunk is materialized without reading any pages (data or + /// dictionary), so don't prefetch its dictionary page either. + if (column.meta->meta_data.__isset.dictionary_page_offset && !column.is_constant) { /// We assume that the dictionary page is immediately followed by the first data page. size_t start = size_t(column.meta->meta_data.dictionary_page_offset); @@ -626,8 +632,13 @@ void Reader::initializePrefetches() max_header_length, /*likely_to_be_used=*/ true); } + /// A constant column chunk is materialized without reading any of its pages (see + /// detectConstantColumn and decodePrimitiveColumn), so it needs neither the offset index + /// nor the column index nor the data pages. The row group already passed the key + /// condition via its min == max hyperrectangle, so page-level pruning is redundant here. + /// Offset index. - if (use_offset_index && + if (use_offset_index && !column.is_constant && column.meta->__isset.offset_index_offset && column.meta->__isset.offset_index_length) { column.offset_index_prefetch = prefetcher.registerRange( @@ -636,7 +647,8 @@ void Reader::initializePrefetches() } /// Column index. - column.use_column_index = primitive_columns[column_idx].column_index_condition + column.use_column_index = !column.is_constant + && primitive_columns[column_idx].column_index_condition && column.offset_index_prefetch && column.meta->__isset.column_index_offset && column.meta->__isset.column_index_length; if (column.use_column_index) @@ -656,10 +668,11 @@ void Reader::initializePrefetches() if (file_metadata.created_by == "parquet-mr" && !column.meta->meta_data.__isset.dictionary_page_offset && !column.meta->__isset.offset_index_offset) data_pages_extra_bytes = std::min(100ul, prefetcher.getFileSize() - size_t(column.meta->meta_data.data_page_offset) - column.data_pages_bytes); - column.data_pages_prefetch = prefetcher.registerRange( - size_t(column.meta->meta_data.data_page_offset), - column.data_pages_bytes + data_pages_extra_bytes, - /*likely_to_be_used=*/ true); + if (!column.is_constant) + column.data_pages_prefetch = prefetcher.registerRange( + size_t(column.meta->meta_data.data_page_offset), + column.data_pages_bytes + data_pages_extra_bytes, + /*likely_to_be_used=*/ true); } } @@ -1224,6 +1237,8 @@ void Reader::decodeOffsetIndex(ColumnChunk & column, const RowGroup & row_group) void Reader::determinePagesToPrefetch(ColumnChunk & column, const RowSubgroup & row_subgroup, const RowGroup & row_group, std::vector & out) { chassert(row_subgroup.filter.rows_pass > 0); + if (column.is_constant) + return; // constant column: data pages are never read if (column.offset_index.page_locations.empty()) return; // no offset index, can't prefetch individual pages @@ -1361,8 +1376,136 @@ double Reader::estimateColumnMemoryBytesPerRow(const ColumnChunk & column, const return res; } +bool Reader::isConstantColumnCandidate(const PrimitiveColumnInfo & column_info) const +{ + if (!options.format.parquet.use_constant_column_optimization) + return false; + /// We rely on column chunk min/max statistics being both present and decodable. + if (!column_info.decoder.allow_stats) + return false; + + /// Only flat, top-level primitive columns, so that one parquet value maps 1:1 to one output row + /// and formOutputColumn can materialize the value directly. Exclude: + /// - arrays (leaf repetition level > 0, or any array level: max_array_def > 0), + /// - physically-nullable structs read as Nullable(Tuple(...)) (group_nullable), + /// - leaves nested inside a Tuple/Map/Array output column (the output column is not primitive). + /// A plain Nullable(T) is fine: it adds a definition level but no repetition, and its output + /// column is still primitive; the no-nulls check below and the output_nullable wrap handle it. + if (column_info.levels.back().rep != 0 || column_info.max_array_def != 0 || column_info.group_nullable) + return false; + if (column_info.idx_in_output_block >= sample_block_to_output_columns_idx.size()) + return false; + const auto & output_idx = sample_block_to_output_columns_idx.at(column_info.idx_in_output_block); + return output_idx.has_value() && output_columns[output_idx.value()].is_primitive; +} + +void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info) const +{ + if (!isConstantColumnCandidate(column_info)) + return; + + const auto & meta_data = column.meta->meta_data; + if (!meta_data.__isset.statistics) + return; + const auto & stats = meta_data.statistics; + const bool physically_nullable = column_info.levels.back().def > 0; + + /// Case 1 - all-null chunk: every row is null (null_count == num_values). Provable only for a + /// physically nullable leaf (a REQUIRED column can have no nulls) whose writer emitted null_count. + /// No value is decoded, so this skips the min/max exactness/truncation checks below. Materialize + /// Null for a Nullable output, or the output default when null_as_default substitutes nulls for a + /// non-nullable output; a non-nullable output without null substitution cannot represent the + /// result, so leave the chunk to the normal decode path (which errors on the null). + /// formOutputColumn records every row in block_missing_values for this case. + if (physically_nullable && stats.__isset.null_count && stats.null_count == meta_data.num_values + && meta_data.num_values > 0) + { + const bool null_as_default = options.format.null_as_default && !column_info.output_nullable; + if (column_info.output_nullable) + column.constant_value = Null{}; + else if (null_as_default) + column.constant_value = column_info.output_type->getDefault(); + else + return; + + column.is_constant = true; + column.is_all_null = true; + ProfileEvents::increment(ProfileEvents::ParquetConstantColumnChunks); + return; + } + + /// Case 2 - single-valued chunk: min == max with no nulls. A physically nullable leaf must prove + /// zero nulls via null_count (a REQUIRED leaf, definition level 0, cannot have any, and writers + /// commonly omit null_count for it). A chunk mixing the value with nulls has two distinct logical + /// values and still needs a null map, so it is not constant. + if (physically_nullable && (!stats.__isset.null_count || stats.null_count != 0)) + return; + if (!stats.__isset.min_value || !stats.__isset.max_value || stats.min_value != stats.max_value) + return; + + /// A writer may store truncated min/max for variable- or opaque-length physical types (BYTE_ARRAY, + /// FIXED_LEN_BYTE_ARRAY), which could make two different values compare equal. Allowlist only the + /// fixed-width numeric physical types, whose min/max are never truncated, as unconditionally + /// trustworthy; anything else (BYTE_ARRAY, FIXED_LEN_BYTE_ARRAY, and any physical type added in the + /// future) must present the writer's is_*_value_exact flags before min == max is trusted. Fails + /// closed: an unrecognized type is treated as possibly-truncated rather than blindly trusted. + const bool never_truncated = + meta_data.type == parq::Type::BOOLEAN + || meta_data.type == parq::Type::INT32 + || meta_data.type == parq::Type::INT64 + || meta_data.type == parq::Type::INT96 + || meta_data.type == parq::Type::FLOAT + || meta_data.type == parq::Type::DOUBLE; + /// is_*_value_exact is an optional thrift bool; guard on __isset so an absent flag fails closed + /// (treated as not-exact) rather than reading a possibly-uninitialized value and trusting a + /// truncated min/max. + const bool min_max_marked_exact = + stats.__isset.is_min_value_exact && stats.is_min_value_exact + && stats.__isset.is_max_value_exact && stats.is_max_value_exact; + /// min == max means "single value" only if the compared bytes are the whole value, not a + /// truncated stand-in: true when the type is never truncated, or the writer marked both exact. + if (!(never_truncated || min_max_marked_exact)) + return; + + /// Decode the value into the column's input (decoded) domain; formOutputColumn casts it to the + /// output type if they differ. decodeField leaves `value` Null when the physical type is + /// unsupported for stats, in which case the optimization does not fire. + Field value; + column_info.decoder.decodeField(stats.min_value, /*is_max=*/ false, value); + if (value.isNull()) + return; + + column.is_constant = true; + column.constant_value = std::move(value); + ProfileEvents::increment(ProfileEvents::ParquetConstantColumnChunks); +} + void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info, ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup, MemoryUsageDiff & diff) { + if (column.is_constant) + { + /// This chunk provably holds a single value in every row (see detectConstantColumn), and its + /// data pages were never fetched. Skip all decoding and hand the already-decoded value to + /// formOutputColumn, which materializes it directly in the final output type. The value is + /// in the output (post-cast) domain, so it must not go through the decoded_type column and + /// castColumn path. We still run the per-output-column bookkeeping below so the output column + /// is formed once the last of its primitive columns is done. + subchunk.is_constant = true; + subchunk.constant_value = column.constant_value; + subchunk.is_all_null = column.is_all_null; + + OutputColumnState & state = row_subgroup.output.at(column_info.idx_in_output_block); + chassert(!state.column); + size_t prev_count = state.primitive_columns_remaining.fetch_sub(1); + chassert(prev_count > 0); + if (prev_count == 1) + { + const auto & output_idx = sample_block_to_output_columns_idx.at(column_info.idx_in_output_block); + state.column = formOutputColumn(row_subgroup, output_idx.value(), row_subgroup.filter.rows_pass); + } + return; + } + /// Allocate columns for values, null map, and array offsets. size_t output_num_values_estimate = 0; @@ -2234,6 +2377,44 @@ MutableColumnPtr Reader::formOutputColumn(RowSubgroup & row_subgroup, size_t out chassert(output_info.primitive_start + 1 == output_info.primitive_end); size_t primitive_idx = output_info.primitive_start; ColumnSubchunk & subchunk = row_subgroup.columns.at(primitive_idx); + + if (subchunk.is_constant) + { + /// Constant column chunk (see detectConstantColumn): materialize the single value as a + /// ColumnConst rather than an expanded column. O(1) instead of O(rows), and the const-ness + /// propagates downstream: a PREWHERE/WHERE predicate computes its result from the value + /// without expanding the stored column, and GROUP BY / aggregation get a const key. + ColumnPtr result; + if (subchunk.is_all_null) + { + /// constant_value was synthesized directly in the output domain (Null, or the output + /// default under null_as_default), so no cast applies. + MutableColumnPtr single_value = output_info.output_type->createColumn(); + single_value->insert(subchunk.constant_value); + result = ColumnConst::create(std::move(single_value), num_rows); + + /// An all-null chunk records every row in block_missing_values, matching the normal + /// decode path (which records nulls from the null map); needed for null_as_default. + if (output_info.idx_in_output_block.has_value() + && *output_info.idx_in_output_block < row_subgroup.block_missing_values.getNumColumns()) + row_subgroup.block_missing_values.setBits(*output_info.idx_in_output_block, num_rows); + } + else + { + /// constant_value came from decodeField, i.e. the input (decoded) domain. Build the + /// const in input_type and apply the same castColumn the per-row decode path uses when + /// the output type differs (e.g. Enum by name, Decimal rescale, LowCardinality). + /// Casting a ColumnConst is O(1) and preserves const-ness. + MutableColumnPtr single_value = output_info.input_type->createColumn(); + single_value->insert(subchunk.constant_value); + result = ColumnConst::create(std::move(single_value), num_rows); + if (output_info.needs_cast) + result = castColumn({result, output_info.input_type, output_info.name}, output_info.output_type); + } + + return IColumn::mutate(std::move(result)); + } + res = std::move(subchunk.column); if (output_info.idx_in_output_block.has_value() && diff --git a/src/Processors/Formats/Impl/Parquet/Reader.h b/src/Processors/Formats/Impl/Parquet/Reader.h index 105cb07a3061..6f8c3d006a1b 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.h +++ b/src/Processors/Formats/Impl/Parquet/Reader.h @@ -311,6 +311,18 @@ struct Reader bool use_column_index = false; bool need_null_map = false; + /// This column chunk provably holds a single repeated value in every row (see + /// detectConstantColumn). When set, we skip prefetching and decoding the data pages and + /// materialize `constant_value` directly instead. `constant_value` is the already-decoded + /// value (not the raw parquet-encoded bytes). + bool is_constant = false; + Field constant_value; + /// Sub-case of is_constant: the chunk is provably all-null (null_count == num_values). + /// constant_value is Null for a Nullable output, or the output default under null_as_default; + /// formOutputColumn also records every row in block_missing_values (the plain constant case + /// has no nulls). + bool is_all_null = false; + /// Prefetches. /// TODO [parquet]: Check that all handles and tokens are reset after correct stages. PrefetchHandle bloom_filter_header_prefetch; @@ -361,6 +373,15 @@ struct Reader /// Primitive column. MutableColumnPtr column; + /// Set by decodePrimitiveColumn when the source column chunk is constant (see + /// ColumnChunk::is_constant): `column` is then left empty and formOutputColumn materializes + /// `constant_value` directly in the final output type. `constant_value` is in the output + /// (post-cast) domain. + bool is_constant = false; + Field constant_value; + /// Mirror of ColumnChunk::is_all_null (see there). + bool is_all_null = false; + MutableColumnPtr null_map; /// For a leaf of a physically-nullable struct read as Nullable(Tuple(...)) (see @@ -534,6 +555,17 @@ struct Reader void decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info, ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup, MemoryUsageDiff & diff); + /// Shape/eligibility gate for detectConstantColumn: a flat, top-level primitive column whose + /// chunk statistics we can read (excludes arrays, physically-nullable structs, and leaves nested + /// in a Tuple/Map/Array output). Only such a column maps one parquet value 1:1 to one output row. + bool isConstantColumnCandidate(const PrimitiveColumnInfo & column_info) const; + + /// If the column chunk provably holds one repeated value in every row, sets column.is_constant + /// and column.constant_value. Uses column chunk min/max statistics (Tier 1). Only applies to + /// flat, top-level primitive columns with no element nulls; see the implementation for the + /// exact conditions. + void detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info) const; + /// Returns mutable column because some of the recursive calls require it, /// e.g. ColumnArray::create does assumeMutable() on the nested columns. /// Moves the column out of ColumnSubchunk-s, leaving nullptrs in ColumnSubchunk::column. diff --git a/tests/queries/0_stateless/04811_parquet_constant_column_optimization.reference b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.reference new file mode 100644 index 000000000000..3328031ec73e --- /dev/null +++ b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.reference @@ -0,0 +1,12 @@ +-- values, optimization on +42 hello 2020-01-02 03:04:05 7 1000 +-- values, optimization off (must be identical) +42 hello 2020-01-02 03:04:05 7 1000 +-- the varying column is read correctly (not treated as constant) +499500 0 999 1000 +-- filters on a constant column still work +1000 +0 +-- optimization fired only when enabled +1 +1 diff --git a/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh new file mode 100755 index 000000000000..2a059de02187 --- /dev/null +++ b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Tags: no-fasttest + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +USER_FILES_PATH=$($CLICKHOUSE_CLIENT_BINARY --query "select _path,_file from file('nonexist.txt', 'CSV', 'val1 char')" 2>&1 | grep Exception | awk '{gsub("/nonexist.txt","",$9); print $9}') +WORKING_DIR="${USER_FILES_PATH}/${CLICKHOUSE_TEST_UNIQUE_NAME}" +mkdir -p "${WORKING_DIR}" +DATA_FILE="${WORKING_DIR}/const.parquet" + +# 1000 rows, 100 rows per row group => 10 row groups. `k` varies; the other four columns each hold a +# single value in every row, so their per-chunk min/max statistics have min == max and no nulls. +# `c_dt` is written as TIMESTAMP_MILLIS and read back with a DateTime hint, exercising the +# milliseconds -> seconds stats conversion (the value is in the post-cast output domain). +${CLICKHOUSE_CLIENT} -q " + INSERT INTO FUNCTION file('${DATA_FILE}', Parquet) + SELECT + number AS k, + 42::Int64 AS c_int, + 'hello' AS c_str, + toDateTime('2020-01-02 03:04:05') AS c_dt, + 7::Nullable(Int64) AS c_nullable + FROM numbers(1000) + SETTINGS engine_file_truncate_on_insert = 1, output_format_parquet_row_group_size = 100 +" + +STRUCTURE="k UInt64, c_int Int64, c_str String, c_dt DateTime, c_nullable Nullable(Int64)" + +qid_on="${CLICKHOUSE_TEST_UNIQUE_NAME}_on" +qid_off="${CLICKHOUSE_TEST_UNIQUE_NAME}_off" + +echo "-- values, optimization on" +${CLICKHOUSE_CLIENT} --query_id="${qid_on}" -q " + SELECT c_int, c_str, c_dt, c_nullable, count() + FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') + GROUP BY 1, 2, 3, 4 +" + +echo "-- values, optimization off (must be identical)" +${CLICKHOUSE_CLIENT} --query_id="${qid_off}" -q " + SELECT c_int, c_str, c_dt, c_nullable, count() + FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') + GROUP BY 1, 2, 3, 4 + SETTINGS input_format_parquet_use_constant_column_optimization = 0 +" + +echo "-- the varying column is read correctly (not treated as constant)" +${CLICKHOUSE_CLIENT} -q "SELECT sum(k), min(k), max(k), count() FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}')" + +echo "-- filters on a constant column still work" +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') WHERE c_int = 42" +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') WHERE c_int = 43" + +echo "-- optimization fired only when enabled" +${CLICKHOUSE_CLIENT} -q " + SYSTEM FLUSH LOGS query_log; + SELECT ProfileEvents['ParquetConstantColumnChunks'] > 0 + FROM system.query_log + WHERE event_date >= yesterday() AND event_time >= now() - 600 + AND query_id = '${qid_on}' AND type = 'QueryFinish' AND current_database = currentDatabase(); + SELECT ProfileEvents['ParquetConstantColumnChunks'] = 0 + FROM system.query_log + WHERE event_date >= yesterday() AND event_time >= now() - 600 + AND query_id = '${qid_off}' AND type = 'QueryFinish' AND current_database = currentDatabase(); +" + +rm -rf "${WORKING_DIR}" From 486a3e3039c01ab1837937aa8d6c0e62cea58add Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Wed, 26 Aug 2026 16:37:51 +0300 Subject: [PATCH 02/14] Parquet v3: do not treat FLOAT/DOUBLE column chunks as constant `min == max` in column chunk statistics does not prove a single value for floating point columns: parquet.thrift says NaN values must not be written to min/max, and both arrow and our own writer drop NaN when computing them, so a chunk like `[1.0, NaN, 1.0]` gets `min == max == 1.0` with `null_count == 0`. The spec also allows `min = +0.0` to hide `-0.0` rows, which are distinct `GROUP BY` keys in ClickHouse. Remove `FLOAT` and `DOUBLE` from the never-truncated allowlist in `detectConstantColumn`; they cannot fall back to the `is_*_value_exact` path either, because exactness says nothing about NaN. Revisit when `Statistics::nan_count` (parquet-format 2.11) is available. Extend `04811_parquet_constant_column_optimization` with a chunk mixing `1.0` with `nan` and `0.0` with `-0.0`. Co-Authored-By: Claude Fable 5 Signed-off-by: UnamedRus --- src/Processors/Formats/Impl/Parquet/Reader.cpp | 15 +++++++++++---- ...arquet_constant_column_optimization.reference | 2 ++ ...04811_parquet_constant_column_optimization.sh | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 3f51b7ad0b69..e27488956a97 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1445,17 +1445,24 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf /// A writer may store truncated min/max for variable- or opaque-length physical types (BYTE_ARRAY, /// FIXED_LEN_BYTE_ARRAY), which could make two different values compare equal. Allowlist only the - /// fixed-width numeric physical types, whose min/max are never truncated, as unconditionally + /// fixed-width integer physical types, whose min/max are never truncated, as unconditionally /// trustworthy; anything else (BYTE_ARRAY, FIXED_LEN_BYTE_ARRAY, and any physical type added in the /// future) must present the writer's is_*_value_exact flags before min == max is trusted. Fails /// closed: an unrecognized type is treated as possibly-truncated rather than blindly trusted. + /// + /// FLOAT and DOUBLE are deliberately excluded even though they are fixed-width. parquet.thrift + /// says NaN values are not written to min/max ("When looking for NaN values, min and max should + /// be ignored"), and both arrow and our own writer drop NaN when computing them, so a chunk like + /// [1.0, NaN, 1.0] has min == max == 1.0 with null_count == 0 and is not constant. Also + /// "if the min is +0, the row group may contain -0 values as well", and -0.0 is a distinct + /// GROUP BY key in ClickHouse. No statistic in the thrift version we ship proves the absence of + /// NaN; revisit once `Statistics::nan_count` (parquet-format 2.11) is available: require + /// nan_count == 0 and a nonzero decoded value. const bool never_truncated = meta_data.type == parq::Type::BOOLEAN || meta_data.type == parq::Type::INT32 || meta_data.type == parq::Type::INT64 - || meta_data.type == parq::Type::INT96 - || meta_data.type == parq::Type::FLOAT - || meta_data.type == parq::Type::DOUBLE; + || meta_data.type == parq::Type::INT96; /// is_*_value_exact is an optional thrift bool; guard on __isset so an absent flag fails closed /// (treated as not-exact) rather than reading a possibly-uninitialized value and trusting a /// truncated min/max. diff --git a/tests/queries/0_stateless/04811_parquet_constant_column_optimization.reference b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.reference index 3328031ec73e..1019a28c9acb 100644 --- a/tests/queries/0_stateless/04811_parquet_constant_column_optimization.reference +++ b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.reference @@ -10,3 +10,5 @@ -- optimization fired only when enabled 1 1 +-- float chunks are never treated as constant: NaN and -0.0 are invisible to min/max statistics +1 1 1 100 diff --git a/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh index 2a059de02187..e0b5341a95ae 100755 --- a/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh +++ b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh @@ -66,4 +66,20 @@ ${CLICKHOUSE_CLIENT} -q " AND query_id = '${qid_off}' AND type = 'QueryFinish' AND current_database = currentDatabase(); " +echo "-- float chunks are never treated as constant: NaN and -0.0 are invisible to min/max statistics" +NAN_FILE="${WORKING_DIR}/nan.parquet" +${CLICKHOUSE_CLIENT} -q " + INSERT INTO FUNCTION file('${NAN_FILE}', Parquet) + SELECT + if(number = 1, nan, 1.0)::Float64 AS f, + if(number = 1, -0.0, 0.0)::Float64 AS z, + if(number = 1, nan, 1.0)::Float32 AS f32 + FROM numbers(100) + SETTINGS engine_file_truncate_on_insert = 1, output_format_parquet_row_group_size = 100 +" +${CLICKHOUSE_CLIENT} -q " + SELECT countIf(isNaN(f)), countIf(toString(z) = '-0'), countIf(isNaN(f32)), count() + FROM file('${NAN_FILE}', Parquet, 'f Float64, z Float64, f32 Float32') +" + rm -rf "${WORKING_DIR}" From 370e3ac831225a1d12eaa4c9545e50d75649746c Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 27 Aug 2026 00:08:02 +0300 Subject: [PATCH 03/14] Parquet v3: choose Const / Sparse / Nullable materialization from chunk statistics Replace the `is_constant` / `is_all_null` flags with `Reader::ConstantKind`, chosen once per column chunk by the pure `chooseConstantKind` from the chunk statistics and the final output type: - `Const`: `null_count == 0`, `min == max`. `ColumnConst`, data pages not read. A constant equal to the type default stays `Const` (const propagation). - `AllDefault`: `null_count == num_values`. `ColumnSparse` with no non-default rows, data pages not read. Sparse rather than const because `ColumnConst` silently drops inserts, which broke `AddingDefaultsTransform` for `DEFAULT` columns (`File` engine, `INSERT ... FROM INFILE`) when `block_missing_values` was set. `block_missing_values` is now set only under `null_as_default`; a `Nullable` output holds real NULLs that must not be replaced by defaults. - `SparseNulls`: `min == max` plus nulls, null ratio >= the new setting `input_format_parquet_constant_column_sparse_ratio` (default 0.9375). `ColumnSparse` with the value at the non-null rows; the data pages are read for definition levels only, values are neither decompressed (`DATA_PAGE_V2`) nor decoded. - `DenseNulls`: `min == max` plus few nulls. `ColumnNullable` filled with the value plus the decoded null map; same page handling as `SparseNulls`. Fix a domain bug in the constant path: `decodeField` yields the value in the final output type's domain (the statistics converter is chosen from the type hint), not in `decoded_type`, so `TIMESTAMP_MILLIS` read as `DateTime` failed with `Bad get: has UInt64, requested Decimal64`. All kinds are now materialized directly in the final output type without `castColumn`. Reject `FLOAT` / `DOUBLE` before the `is_*_value_exact` fallback: our own writer marks every type exact, which let floats bypass the NaN exclusion. `estimateColumnMemoryBytesPerRow` returns 0 for kinds that skip the pages and O(non-null rows) for `SparseNulls`. New profile event `ParquetConstantColumnChunksWithNulls`. Test `04812_parquet_constant_column_kinds` covers each kind, on/off equality, `null_as_default`, `DEFAULT` columns over a `File` table, `LowCardinality` fallback and the profile events. The tests now take `user_files_path` from `system.server_settings`, since parsing the exception text of a `send_logs_level`-enabled client picks up the echoed log line. Co-Authored-By: Claude Fable 5 Signed-off-by: UnamedRus --- src/Common/ProfileEvents.cpp | 1 + src/Core/FormatFactorySettings.h | 5 +- src/Core/SettingsChangesHistory.cpp | 1 + src/Formats/FormatFactory.cpp | 1 + src/Formats/FormatSettings.h | 1 + .../Formats/Impl/Parquet/Reader.cpp | 291 ++++++++++++------ src/Processors/Formats/Impl/Parquet/Reader.h | 74 +++-- ...11_parquet_constant_column_optimization.sh | 2 +- ...12_parquet_constant_column_kinds.reference | 25 ++ .../04812_parquet_constant_column_kinds.sh | 92 ++++++ 10 files changed, 373 insertions(+), 120 deletions(-) create mode 100644 tests/queries/0_stateless/04812_parquet_constant_column_kinds.reference create mode 100755 tests/queries/0_stateless/04812_parquet_constant_column_kinds.sh diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index d5ca3f30fd7d..88ded38d07d5 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -1477,6 +1477,7 @@ The server successfully detected this situation and will download merged part fr M(ParquetReadRowGroups, "The total number of row groups read from parquet data", ValueType::Number) \ M(ParquetPrunedRowGroups, "The total number of row groups pruned from parquet data", ValueType::Number) \ M(ParquetConstantColumnChunks, "The total number of parquet column chunks materialized from a single value in their min/max statistics, without reading their data pages", ValueType::Number) \ + M(ParquetConstantColumnChunksWithNulls, "The total number of parquet column chunks holding a single value plus nulls (per their statistics), for which only the definition levels were decoded and the value was taken from the statistics", ValueType::Number) \ M(ParquetDecodingTasks, "Tasks issued by parquet reader", ValueType::Number) \ M(ParquetDecodingTaskBatches, "Task groups sent to a thread pool by parquet reader", ValueType::Number) \ M(ParquetPrefetcherReadRandomRead, "The total number of reads with ReadMode::RandomRead by DB::Parquet::Prefetcher", ValueType::Number) \ diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index 29aba2dc725c..59e560236a32 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -214,7 +214,10 @@ Skip pages using min/max values from column index. Minor tweak to how pages are read from parquet file when no page filtering is used. )", 0) \ DECLARE(Bool, input_format_parquet_use_constant_column_optimization, true, R"( -When a Parquet column chunk provably holds a single value in every row (according to its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages. +When a Parquet column chunk provably holds a single value in every row (according to its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages. Also covers chunks that are all null (materialized as a sparse column) and chunks holding a single value plus nulls (only the definition levels are decoded). +)", 0) \ + DECLARE(Float, input_format_parquet_constant_column_sparse_ratio, 0.9375, R"( +For `input_format_parquet_use_constant_column_optimization`: a Parquet column chunk holding a single value plus nulls is materialized as a sparse column (memory proportional to the non-null rows) when the fraction of nulls is at least this ratio, and as a dense `Nullable` column otherwise. `1` disables sparse materialization for such chunks. )", 0) \ DECLARE(Bool, input_format_parquet_verify_checksums, true, R"( Verify page checksums when reading parquet files. diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 6322d7c3b017..e78c710de3f7 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -43,6 +43,7 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() { {"use_puffin_files_cache", false, true, "Enables cache of parsed Puffin file content such as deletion vectors."}, {"input_format_parquet_use_constant_column_optimization", false, true, "New setting: when a Parquet column chunk provably holds a single value in every row (per its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages (reader v3)."}, + {"input_format_parquet_constant_column_sparse_ratio", 1.0, 0.9375, "New setting: a Parquet column chunk holding a single value plus nulls is materialized as a sparse column when the fraction of nulls is at least this ratio (reader v3)."}, }); addSettingsChanges(settings_changes_history, "26.6", diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index 517f72ac56ef..c0a7c4afef64 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -220,6 +220,7 @@ FormatSettings getFormatSettings(const ContextPtr & context, const Settings & se format_settings.parquet.page_filter_push_down = settings[Setting::input_format_parquet_page_filter_push_down]; format_settings.parquet.use_offset_index = settings[Setting::input_format_parquet_use_offset_index]; format_settings.parquet.use_constant_column_optimization = settings[Setting::input_format_parquet_use_constant_column_optimization]; + format_settings.parquet.constant_column_sparse_ratio = settings[Setting::input_format_parquet_constant_column_sparse_ratio]; format_settings.parquet.enable_json_parsing = settings[Setting::input_format_parquet_enable_json_parsing]; format_settings.parquet.memory_low_watermark = settings[Setting::input_format_parquet_memory_low_watermark]; diff --git a/src/Formats/FormatSettings.h b/src/Formats/FormatSettings.h index 9e8e321c4ff8..21d778d063f2 100644 --- a/src/Formats/FormatSettings.h +++ b/src/Formats/FormatSettings.h @@ -350,6 +350,7 @@ struct FormatSettings bool page_filter_push_down = true; bool use_offset_index = true; bool use_constant_column_optimization = true; + float constant_column_sparse_ratio = 0.9375f; bool enable_json_parsing = true; bool preserve_order = false; diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index e27488956a97..69ecbcc8dcbc 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -44,6 +45,7 @@ namespace ProfileEvents extern const Event ParquetRowsFilterExpression; extern const Event ParquetColumnsFilterExpression; extern const Event ParquetConstantColumnChunks; + extern const Event ParquetConstantColumnChunksWithNulls; } namespace DB::Parquet @@ -582,9 +584,9 @@ void Reader::initializePrefetches() /// Dictionary page. size_t dict_page_length = 0; - /// A constant column chunk is materialized without reading any pages (data or + /// A Const/AllDefault column chunk is materialized without reading any pages (data or /// dictionary), so don't prefetch its dictionary page either. - if (column.meta->meta_data.__isset.dictionary_page_offset && !column.is_constant) + if (column.meta->meta_data.__isset.dictionary_page_offset && !constantKindSkipsDataPages(column.constant_kind)) { /// We assume that the dictionary page is immediately followed by the first data page. size_t start = size_t(column.meta->meta_data.dictionary_page_offset); @@ -632,13 +634,14 @@ void Reader::initializePrefetches() max_header_length, /*likely_to_be_used=*/ true); } - /// A constant column chunk is materialized without reading any of its pages (see + /// A Const/AllDefault column chunk is materialized without reading any of its pages (see /// detectConstantColumn and decodePrimitiveColumn), so it needs neither the offset index - /// nor the column index nor the data pages. The row group already passed the key - /// condition via its min == max hyperrectangle, so page-level pruning is redundant here. + /// nor the column index nor the data pages. Page-level pruning would be redundant: every + /// page of the chunk holds the same value (or only nulls), so the key condition has already + /// been decided at the row group level. /// Offset index. - if (use_offset_index && !column.is_constant && + if (use_offset_index && !constantKindSkipsDataPages(column.constant_kind) && column.meta->__isset.offset_index_offset && column.meta->__isset.offset_index_length) { column.offset_index_prefetch = prefetcher.registerRange( @@ -647,7 +650,7 @@ void Reader::initializePrefetches() } /// Column index. - column.use_column_index = !column.is_constant + column.use_column_index = !constantKindSkipsDataPages(column.constant_kind) && primitive_columns[column_idx].column_index_condition && column.offset_index_prefetch && column.meta->__isset.column_index_offset && column.meta->__isset.column_index_length; @@ -668,7 +671,7 @@ void Reader::initializePrefetches() if (file_metadata.created_by == "parquet-mr" && !column.meta->meta_data.__isset.dictionary_page_offset && !column.meta->__isset.offset_index_offset) data_pages_extra_bytes = std::min(100ul, prefetcher.getFileSize() - size_t(column.meta->meta_data.data_page_offset) - column.data_pages_bytes); - if (!column.is_constant) + if (!constantKindSkipsDataPages(column.constant_kind)) column.data_pages_prefetch = prefetcher.registerRange( size_t(column.meta->meta_data.data_page_offset), column.data_pages_bytes + data_pages_extra_bytes, @@ -1237,8 +1240,8 @@ void Reader::decodeOffsetIndex(ColumnChunk & column, const RowGroup & row_group) void Reader::determinePagesToPrefetch(ColumnChunk & column, const RowSubgroup & row_subgroup, const RowGroup & row_group, std::vector & out) { chassert(row_subgroup.filter.rows_pass > 0); - if (column.is_constant) - return; // constant column: data pages are never read + if (constantKindSkipsDataPages(column.constant_kind)) + return; // Const/AllDefault column: data pages are never read if (column.offset_index.page_locations.empty()) return; // no offset index, can't prefetch individual pages @@ -1356,6 +1359,19 @@ double Reader::estimateAverageStringLengthPerRow(const ColumnChunk & column, con double Reader::estimateColumnMemoryBytesPerRow(const ColumnChunk & column, const RowGroup & row_group, const PrimitiveColumnInfo & column_info) const { + /// Const/AllDefault chunks are materialized in O(1) memory; SparseNulls in O(non-null rows) + /// (8-byte offset + value each). Estimating them at full size would make the memory scheduler + /// under-parallelize exactly the files this optimization targets. + if (constantKindSkipsDataPages(column.constant_kind)) + return 0; + if (column.constant_kind == ConstantKind::SparseNulls) + { + const auto & stats = column.meta->meta_data.statistics; + double non_null_ratio = 1. - static_cast(stats.null_count) / static_cast(std::max(1, column.meta->meta_data.num_values)); + double value_size = column_info.output_type->haveMaximumSizeOfValue() ? static_cast(column_info.output_type->getMaximumSizeOfValueInMemory()) : 32.; + return non_null_ratio * (8. + value_size); + } + double res = 0; if (column_info.output_type->haveMaximumSizeOfValue()) /// Fixed-size values, e.g. numbers or FixedString. @@ -1399,6 +1415,41 @@ bool Reader::isConstantColumnCandidate(const PrimitiveColumnInfo & column_info) return output_idx.has_value() && output_columns[output_idx.value()].is_primitive; } +Reader::ConstantKind Reader::chooseConstantKind(const PrimitiveColumnInfo & column_info, const DataTypePtr & final_output_type, Int64 num_values, std::optional null_count, bool single_value) const +{ + if (num_values <= 0) + return ConstantKind::None; + const bool physically_nullable = column_info.levels.back().def > 0; + /// A REQUIRED leaf (definition level 0) cannot hold nulls, and writers commonly omit null_count + /// for it. A physically nullable leaf must prove its null count. + const Int64 nulls = physically_nullable ? null_count.value_or(-1) : 0; + if (nulls < 0 || nulls > num_values) + return ConstantKind::None; + + if (nulls == 0) + return single_value ? ConstantKind::Const : ConstantKind::None; + + /// Nulls are present. A non-nullable output can only take them under null_as_default, where + /// they become the type default; otherwise the normal decode path reports the error. + const bool null_as_default = options.format.null_as_default && !column_info.output_nullable; + if (!column_info.output_nullable && !null_as_default) + return ConstantKind::None; + /// Sparse kinds are materialized directly in the final output type (see decodePrimitiveColumn + /// and formOutputColumn), so that type - not the decoder's - must support being sparse; e.g. + /// LowCardinality does not. + const bool can_be_sparse = final_output_type->canBeInsideSparseColumns(); + + if (nulls == num_values) + return can_be_sparse ? ConstantKind::AllDefault : ConstantKind::None; + + if (!single_value) + return ConstantKind::None; + const double null_ratio = static_cast(nulls) / static_cast(num_values); + if (can_be_sparse && null_ratio >= static_cast(options.format.parquet.constant_column_sparse_ratio)) + return ConstantKind::SparseNulls; + return ConstantKind::DenseNulls; +} + void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info) const { if (!isConstantColumnCandidate(column_info)) @@ -1408,41 +1459,12 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf if (!meta_data.__isset.statistics) return; const auto & stats = meta_data.statistics; - const bool physically_nullable = column_info.levels.back().def > 0; - - /// Case 1 - all-null chunk: every row is null (null_count == num_values). Provable only for a - /// physically nullable leaf (a REQUIRED column can have no nulls) whose writer emitted null_count. - /// No value is decoded, so this skips the min/max exactness/truncation checks below. Materialize - /// Null for a Nullable output, or the output default when null_as_default substitutes nulls for a - /// non-nullable output; a non-nullable output without null substitution cannot represent the - /// result, so leave the chunk to the normal decode path (which errors on the null). - /// formOutputColumn records every row in block_missing_values for this case. - if (physically_nullable && stats.__isset.null_count && stats.null_count == meta_data.num_values - && meta_data.num_values > 0) - { - const bool null_as_default = options.format.null_as_default && !column_info.output_nullable; - if (column_info.output_nullable) - column.constant_value = Null{}; - else if (null_as_default) - column.constant_value = column_info.output_type->getDefault(); - else - return; - - column.is_constant = true; - column.is_all_null = true; - ProfileEvents::increment(ProfileEvents::ParquetConstantColumnChunks); - return; - } - - /// Case 2 - single-valued chunk: min == max with no nulls. A physically nullable leaf must prove - /// zero nulls via null_count (a REQUIRED leaf, definition level 0, cannot have any, and writers - /// commonly omit null_count for it). A chunk mixing the value with nulls has two distinct logical - /// values and still needs a null map, so it is not constant. - if (physically_nullable && (!stats.__isset.null_count || stats.null_count != 0)) - return; - if (!stats.__isset.min_value || !stats.__isset.max_value || stats.min_value != stats.max_value) - return; + std::optional null_count; + if (stats.__isset.null_count) + null_count = stats.null_count; + /// Is min == max, and can we trust that to mean "one value"? + /// /// A writer may store truncated min/max for variable- or opaque-length physical types (BYTE_ARRAY, /// FIXED_LEN_BYTE_ARRAY), which could make two different values compare equal. Allowlist only the /// fixed-width integer physical types, whose min/max are never truncated, as unconditionally @@ -1450,7 +1472,7 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf /// future) must present the writer's is_*_value_exact flags before min == max is trusted. Fails /// closed: an unrecognized type is treated as possibly-truncated rather than blindly trusted. /// - /// FLOAT and DOUBLE are deliberately excluded even though they are fixed-width. parquet.thrift + /// FLOAT and DOUBLE are deliberately excluded (below) even though they are fixed-width. parquet.thrift /// says NaN values are not written to min/max ("When looking for NaN values, min and max should /// be ignored"), and both arrow and our own writer drop NaN when computing them, so a chunk like /// [1.0, NaN, 1.0] has min == max == 1.0 with null_count == 0 and is not constant. Also @@ -1469,37 +1491,54 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf const bool min_max_marked_exact = stats.__isset.is_min_value_exact && stats.is_min_value_exact && stats.__isset.is_max_value_exact && stats.is_max_value_exact; - /// min == max means "single value" only if the compared bytes are the whole value, not a - /// truncated stand-in: true when the type is never truncated, or the writer marked both exact. - if (!(never_truncated || min_max_marked_exact)) - return; + /// Floats are rejected outright: the exactness flags say nothing about NaN, so they must not + /// reopen the door that the allowlist closes. + const bool is_float = meta_data.type == parq::Type::FLOAT || meta_data.type == parq::Type::DOUBLE; + const bool single_value = !is_float + && stats.__isset.min_value && stats.__isset.max_value + && stats.min_value == stats.max_value + && (never_truncated || min_max_marked_exact); - /// Decode the value into the column's input (decoded) domain; formOutputColumn casts it to the - /// output type if they differ. decodeField leaves `value` Null when the physical type is - /// unsupported for stats, in which case the optimization does not fire. - Field value; - column_info.decoder.decodeField(stats.min_value, /*is_max=*/ false, value); - if (value.isNull()) + const auto & output_idx = sample_block_to_output_columns_idx.at(column_info.idx_in_output_block); + const OutputColumnInfo & output_info = output_columns.at(output_idx.value()); + ConstantKind kind = chooseConstantKind(column_info, output_info.output_type, meta_data.num_values, null_count, single_value); + if (kind == ConstantKind::None) return; - column.is_constant = true; - column.constant_value = std::move(value); - ProfileEvents::increment(ProfileEvents::ParquetConstantColumnChunks); + if (kind != ConstantKind::AllDefault) + { + /// decodeField yields the value in the FINAL output type's domain, not decoded_type's: + /// SchemaConverter picks the statistics converter from the type hint (e.g. TIMESTAMP_MILLIS + /// read as DateTime decodes to seconds although decoded_type is DateTime64(3)), and sets + /// allow_stats only when that conversion is exact. So the value is inserted straight into a + /// column of output_info.output_type, bypassing decoded_type and castColumn. decodeField + /// leaves `value` Null when the physical type is unsupported for stats, in which case the + /// optimization does not fire. + Field value; + column_info.decoder.decodeField(stats.min_value, /*is_max=*/ false, value); + if (value.isNull()) + return; + column.constant_value = std::move(value); + } + + column.constant_kind = kind; + if (constantKindSkipsDataPages(kind)) + ProfileEvents::increment(ProfileEvents::ParquetConstantColumnChunks); + else + ProfileEvents::increment(ProfileEvents::ParquetConstantColumnChunksWithNulls); } void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info, ColumnSubchunk & subchunk, const RowGroup & row_group, RowSubgroup & row_subgroup, MemoryUsageDiff & diff) { - if (column.is_constant) - { - /// This chunk provably holds a single value in every row (see detectConstantColumn), and its - /// data pages were never fetched. Skip all decoding and hand the already-decoded value to - /// formOutputColumn, which materializes it directly in the final output type. The value is - /// in the output (post-cast) domain, so it must not go through the decoded_type column and - /// castColumn path. We still run the per-output-column bookkeeping below so the output column - /// is formed once the last of its primitive columns is done. - subchunk.is_constant = true; + if (constantKindSkipsDataPages(column.constant_kind)) + { + /// This chunk is provably one value in every row, or all null (see detectConstantColumn), + /// and its data pages were never fetched. Skip all decoding and hand the already-decoded + /// value to formOutputColumn, which materializes it directly as ColumnConst / ColumnSparse. + /// We still run the per-output-column bookkeeping below so the output column is formed once + /// the last of its primitive columns is done. + subchunk.constant_kind = column.constant_kind; subchunk.constant_value = column.constant_value; - subchunk.is_all_null = column.is_all_null; OutputColumnState & state = row_subgroup.output.at(column_info.idx_in_output_block); chassert(!state.column); @@ -1538,9 +1577,11 @@ void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnIn subchunk.null_map->reserve(output_num_values_estimate); } + const bool skip_values = constantKindSkipsValues(column.constant_kind); subchunk.column = column_info.decoded_type->createColumn(); - subchunk.column->reserve(output_num_values_estimate); - if (auto * string_column = typeid_cast(subchunk.column.get())) + if (!skip_values) + subchunk.column->reserve(output_num_values_estimate); + if (auto * string_column = typeid_cast(subchunk.column.get()); string_column && !skip_values) { double avg_len = estimateAverageStringLengthPerRow(column, row_group); size_t bytes_to_reserve = size_t(1.2 * avg_len * static_cast(row_subgroup.filter.rows_pass)); @@ -1643,6 +1684,53 @@ void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnIn throw Exception(ErrorCodes::INCORRECT_DATA, "Invalid repetition/definition levels for arrays in column {}", column_info.name); } + if (skip_values) + { + /// Value decoding was skipped for this chunk (see readRowsInPage): the single non-null value + /// is known from the statistics and only the definition levels were read, into `null_map`. + /// `constant_value` is in the FINAL output type's domain (see detectConstantColumn), so the + /// column is built directly as output_info.output_type - Nullable wrapper, LowCardinality and + /// all - and formOutputColumn skips the decoded_type -> output_type cast for it. The null map + /// is kept only under null_as_default, where formOutputColumn feeds it to block_missing_values + /// (a Nullable output holds real NULLs, which are not "missing"). + chassert(subchunk.null_map); + chassert(subchunk.column->empty()); + const auto & output_idx = sample_block_to_output_columns_idx.at(column_info.idx_in_output_block); + const OutputColumnInfo & output_info = output_columns.at(output_idx.value()); + const auto & null_map = assert_cast(*subchunk.null_map).getData(); + const size_t num_rows = null_map.size(); + const size_t non_null_count = num_rows - countBytesInFilter(null_map.data(), 0, num_rows); + + if (column.constant_kind == ConstantKind::SparseNulls) + { + /// ColumnSparse: `values` holds the type default (NULL for a Nullable output; the default + /// under null_as_default) at index 0 followed by one copy of the constant per non-null row, + /// `offsets` lists the non-null rows. O(non-null rows) memory. + MutableColumnPtr values = output_info.output_type->createColumn(); + values->insertDefault(); + values->insertMany(column.constant_value, non_null_count); + auto offsets = ColumnUInt64::create(); + auto & offsets_data = offsets->getData(); + offsets_data.reserve(non_null_count); + for (size_t i = 0; i < num_rows; ++i) + if (!null_map[i]) + offsets_data.push_back(i); + MutableColumnPtr offsets_ptr = std::move(offsets); + subchunk.column = ColumnSparse::create(std::move(values), std::move(offsets_ptr), num_rows); + } + else + { + /// Dense: one copy of the constant per non-null row, then expand() inserts the type default + /// (NULL for a Nullable output) at the null positions. + subchunk.column = output_info.output_type->createColumn(); + subchunk.column->insertMany(column.constant_value, non_null_count); + subchunk.column->expand(null_map, /*inverted*/ true); + } + if (column_info.output_nullable) + subchunk.null_map.reset(); + subchunk.constant_kind = column.constant_kind; + } + if (subchunk.null_map && !column_info.output_nullable && !column_info.group_nullable && !options.format.null_as_default) { const auto & null_map = assert_cast(*subchunk.null_map).getData(); @@ -1653,7 +1741,7 @@ void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnIn subchunk.null_map = nullptr; } - if (subchunk.null_map) + if (subchunk.null_map && !skip_values) { const auto & null_map = assert_cast(*subchunk.null_map).getData(); /// Fill defaults at null rows so the column reaches full size. For a group_nullable leaf, @@ -1676,7 +1764,7 @@ void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnIn if (subchunk.arrays_offsets.empty() && subchunk.column->size() != row_subgroup.filter.rows_pass) throw Exception(ErrorCodes::LOGICAL_ERROR, "Unexpected number of rows in column subchunk {} {}", subchunk.column->size(), row_subgroup.filter.rows_pass); - if (column_info.output_nullable) + if (column_info.output_nullable && !skip_values) { if (!subchunk.null_map) subchunk.null_map = ColumnUInt8::create(subchunk.column->size(), false); @@ -1684,7 +1772,7 @@ void Reader::decodePrimitiveColumn(ColumnChunk & column, const PrimitiveColumnIn subchunk.null_map.reset(); } - chassert(subchunk.column->getDataType() == column_info.output_type->getColumnType()); + chassert(skip_values || subchunk.column->getDataType() == column_info.output_type->getColumnType()); /// The scheduleTask charge was an estimate; reconcile up to the actual decoded footprint here, /// before formOutputColumn (below) moves `subchunk.column`, so the scheduler stops decoding ahead @@ -2272,7 +2360,8 @@ void Reader::readRowsInPage(size_t end_row_idx, ColumnSubchunk & subchunk, Colum /// See if we can decompress the whole page directly into IColumn's memory. /// Skip when filter is set: direct read bypasses decode and would write all values without applying the filter. const bool has_filter = row_subgroup && !row_subgroup->filter.filter.empty(); - if (!has_filter && !page.is_dictionary_encoded && prev_value_idx == 0 && page.value_idx == page.num_values && + const bool skip_values = constantKindSkipsValues(column.constant_kind); + if (!has_filter && !skip_values && !page.is_dictionary_encoded && prev_value_idx == 0 && page.value_idx == page.num_values && page.codec != parq::CompressionCodec::UNCOMPRESSED) { std::span span; @@ -2285,7 +2374,10 @@ void Reader::readRowsInPage(size_t end_row_idx, ColumnSubchunk & subchunk, Colum } } - if (encoded_values_to_read > 0) + /// For SparseNulls/DenseNulls the single non-null value is known from the statistics, so only the + /// definition levels (processed above) are needed; skip decompressing (DATA_PAGE_V2) and decoding + /// the values. decodePrimitiveColumn fills the column from `constant_value` and the null map. + if (encoded_values_to_read > 0 && !skip_values) { decompressPageIfCompressed(page); if (!page.decoder) @@ -2340,6 +2432,7 @@ MutableColumnPtr Reader::formOutputColumn(RowSubgroup & row_subgroup, size_t out const OutputColumnInfo & output_info = output_columns.at(output_column_idx); MutableColumnPtr res; + bool already_output_type = false; if (output_info.is_missing_column) { @@ -2385,44 +2478,45 @@ MutableColumnPtr Reader::formOutputColumn(RowSubgroup & row_subgroup, size_t out size_t primitive_idx = output_info.primitive_start; ColumnSubchunk & subchunk = row_subgroup.columns.at(primitive_idx); - if (subchunk.is_constant) + if (constantKindSkipsDataPages(subchunk.constant_kind)) { - /// Constant column chunk (see detectConstantColumn): materialize the single value as a - /// ColumnConst rather than an expanded column. O(1) instead of O(rows), and the const-ness - /// propagates downstream: a PREWHERE/WHERE predicate computes its result from the value - /// without expanding the stored column, and GROUP BY / aggregation get a const key. ColumnPtr result; - if (subchunk.is_all_null) + if (subchunk.constant_kind == ConstantKind::AllDefault) { - /// constant_value was synthesized directly in the output domain (Null, or the output - /// default under null_as_default), so no cast applies. - MutableColumnPtr single_value = output_info.output_type->createColumn(); - single_value->insert(subchunk.constant_value); - result = ColumnConst::create(std::move(single_value), num_rows); - - /// An all-null chunk records every row in block_missing_values, matching the normal - /// decode path (which records nulls from the null map); needed for null_as_default. - if (output_info.idx_in_output_block.has_value() + /// All-null chunk: every row is the output type's default (Null for a Nullable output, + /// the type default under null_as_default). ColumnSparse with no non-default rows is + /// O(1) and, unlike ColumnConst, stays writable for AddingDefaultsTransform (see + /// ConstantKind). chooseConstantKind guarantees the type can be inside a sparse column. + auto sparse = ColumnSparse::create(output_info.output_type->createColumn()); + sparse->insertManyDefaults(num_rows); + result = std::move(sparse); + + /// Under null_as_default the rows are "missing" for AddingDefaultsTransform, as the + /// normal decode path records them from the null map. A Nullable output holds real + /// NULLs, which must not be replaced by column defaults. + const bool null_as_default = options.format.null_as_default && !output_info.output_type->isNullable(); + if (null_as_default && output_info.idx_in_output_block.has_value() && *output_info.idx_in_output_block < row_subgroup.block_missing_values.getNumColumns()) row_subgroup.block_missing_values.setBits(*output_info.idx_in_output_block, num_rows); } else { - /// constant_value came from decodeField, i.e. the input (decoded) domain. Build the - /// const in input_type and apply the same castColumn the per-row decode path uses when - /// the output type differs (e.g. Enum by name, Decimal rescale, LowCardinality). - /// Casting a ColumnConst is O(1) and preserves const-ness. - MutableColumnPtr single_value = output_info.input_type->createColumn(); + /// Single non-null value in every row: ColumnConst, so the const-ness propagates + /// downstream (a PREWHERE/WHERE predicate is computed from the one value, GROUP BY gets + /// a const key). constant_value is already in the final output type's domain (see + /// detectConstantColumn), so no castColumn is applied. + MutableColumnPtr single_value = output_info.output_type->createColumn(); single_value->insert(subchunk.constant_value); result = ColumnConst::create(std::move(single_value), num_rows); - if (output_info.needs_cast) - result = castColumn({result, output_info.input_type, output_info.name}, output_info.output_type); } return IColumn::mutate(std::move(result)); } res = std::move(subchunk.column); + /// SparseNulls/DenseNulls columns were built directly in output_type (see decodePrimitiveColumn). + if (constantKindSkipsValues(subchunk.constant_kind)) + already_output_type = true; if (output_info.idx_in_output_block.has_value() && *output_info.idx_in_output_block < row_subgroup.block_missing_values.getNumColumns() && @@ -2481,6 +2575,9 @@ MutableColumnPtr Reader::formOutputColumn(RowSubgroup & row_subgroup, size_t out res = ColumnNullable::create(std::move(res), std::move(nullable_group_null_map)); } + if (already_output_type) + return res; + chassert(res->getDataType() == output_info.input_type->getColumnType()); if (output_info.needs_cast) diff --git a/src/Processors/Formats/Impl/Parquet/Reader.h b/src/Processors/Formats/Impl/Parquet/Reader.h index 6f8c3d006a1b..568ec0f6724e 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.h +++ b/src/Processors/Formats/Impl/Parquet/Reader.h @@ -302,6 +302,39 @@ struct Reader MutableColumnPtr indices_column; // if is_dictionary_encoded; ColumnUInt32 }; + /// How a column chunk whose statistics prove (almost) all of its content is materialized, chosen + /// once per chunk by detectConstantColumn from the chunk statistics alone. Each kind maps to an + /// existing column representation that every downstream consumer already handles: + /// Const - ColumnConst: one non-null value in every row (null_count == 0, min == max). + /// Const-ness propagates to WHERE/GROUP BY. Data pages are not read. + /// AllDefault - ColumnSparse with no non-default rows: every row is null (null_count == + /// num_values), which is the type default (Null for Nullable, or the output type's + /// default under null_as_default). Data pages are not read. Sparse rather than Const + /// because a sparse column stays writable (ColumnConst silently drops inserts), which + /// matters when block_missing_values makes AddingDefaultsTransform mix defaults in. + /// SparseNulls - ColumnSparse: nulls dominate (null_count / num_values >= constant_column_sparse_ratio) + /// and the non-null rows all hold one value. Data pages are read only for their + /// definition levels (the null map); values are neither decompressed (DATA_PAGE_V2) + /// nor decoded. Memory is O(non-null rows). + /// DenseNulls - ColumnNullable: nulls are present but few; same page handling as SparseNulls, + /// materialized as a dense column filled with the value plus the decoded null map. + /// Invariant: Const <=> a single non-default or default value with no nulls; sparse kinds <=> the + /// default (null) dominates; a non-null constant equal to the type default is still Const, since + /// const propagation beats sparse there and there is no null map to mix. + enum class ConstantKind : UInt8 + { + None, + Const, + AllDefault, + SparseNulls, + DenseNulls, + }; + + /// Kinds for which the chunk's data pages are neither prefetched nor decoded. + static bool constantKindSkipsDataPages(ConstantKind kind) { return kind == ConstantKind::Const || kind == ConstantKind::AllDefault; } + /// Kinds for which the data pages are read for definition levels only (value decoding skipped). + static bool constantKindSkipsValues(ConstantKind kind) { return kind == ConstantKind::SparseNulls || kind == ConstantKind::DenseNulls; } + struct ColumnChunk { const parq::ColumnChunk * meta{}; @@ -311,17 +344,12 @@ struct Reader bool use_column_index = false; bool need_null_map = false; - /// This column chunk provably holds a single repeated value in every row (see - /// detectConstantColumn). When set, we skip prefetching and decoding the data pages and - /// materialize `constant_value` directly instead. `constant_value` is the already-decoded - /// value (not the raw parquet-encoded bytes). - bool is_constant = false; + /// See ConstantKind and detectConstantColumn. For Const/SparseNulls/DenseNulls, + /// `constant_value` is the single non-null value, already decoded (not the raw parquet-encoded + /// bytes) and in the FINAL output type's domain (OutputColumnInfo::output_type, not + /// decoded_type - see detectConstantColumn). Unused for None/AllDefault. + ConstantKind constant_kind = ConstantKind::None; Field constant_value; - /// Sub-case of is_constant: the chunk is provably all-null (null_count == num_values). - /// constant_value is Null for a Nullable output, or the output default under null_as_default; - /// formOutputColumn also records every row in block_missing_values (the plain constant case - /// has no nulls). - bool is_all_null = false; /// Prefetches. /// TODO [parquet]: Check that all handles and tokens are reset after correct stages. @@ -373,14 +401,13 @@ struct Reader /// Primitive column. MutableColumnPtr column; - /// Set by decodePrimitiveColumn when the source column chunk is constant (see - /// ColumnChunk::is_constant): `column` is then left empty and formOutputColumn materializes - /// `constant_value` directly in the final output type. `constant_value` is in the output - /// (post-cast) domain. - bool is_constant = false; + /// Mirror of ColumnChunk::constant_kind, set by decodePrimitiveColumn. For Const/AllDefault + /// `column` is left empty and formOutputColumn materializes the chunk directly from + /// `constant_value`. For SparseNulls/DenseNulls `column` is materialized by + /// decodePrimitiveColumn directly in the final output type (no cast needed). `constant_value` + /// is in the final output type's domain, as in ColumnChunk. + ConstantKind constant_kind = ConstantKind::None; Field constant_value; - /// Mirror of ColumnChunk::is_all_null (see there). - bool is_all_null = false; MutableColumnPtr null_map; @@ -560,12 +587,17 @@ struct Reader /// in a Tuple/Map/Array output). Only such a column maps one parquet value 1:1 to one output row. bool isConstantColumnCandidate(const PrimitiveColumnInfo & column_info) const; - /// If the column chunk provably holds one repeated value in every row, sets column.is_constant - /// and column.constant_value. Uses column chunk min/max statistics (Tier 1). Only applies to - /// flat, top-level primitive columns with no element nulls; see the implementation for the - /// exact conditions. + /// If the column chunk statistics prove its content (one value, all null, or one value plus + /// nulls), sets column.constant_kind and column.constant_value. Uses column chunk min/max and + /// null_count statistics (Tier 1). Only applies to flat, top-level primitive columns; see the + /// implementation and ConstantKind for the exact conditions and representations. void detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInfo & column_info) const; + /// Pure decision from statistics + output type: which ConstantKind to use for a chunk with + /// `num_values` values of which `null_count` are null and whose non-null values are all equal + /// (`single_value`). Returns None when nothing applies. + ConstantKind chooseConstantKind(const PrimitiveColumnInfo & column_info, const DataTypePtr & final_output_type, Int64 num_values, std::optional null_count, bool single_value) const; + /// Returns mutable column because some of the recursive calls require it, /// e.g. ColumnArray::create does assumeMutable() on the nested columns. /// Moves the column out of ColumnSubchunk-s, leaving nullptrs in ColumnSubchunk::column. diff --git a/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh index e0b5341a95ae..1e338bf2c2ac 100755 --- a/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh +++ b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh @@ -5,7 +5,7 @@ CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=../shell_config.sh . "$CUR_DIR"/../shell_config.sh -USER_FILES_PATH=$($CLICKHOUSE_CLIENT_BINARY --query "select _path,_file from file('nonexist.txt', 'CSV', 'val1 char')" 2>&1 | grep Exception | awk '{gsub("/nonexist.txt","",$9); print $9}') +USER_FILES_PATH=$(${CLICKHOUSE_CLIENT} -q "SELECT value FROM system.server_settings WHERE name = 'user_files_path'" | sed 's|/$||') WORKING_DIR="${USER_FILES_PATH}/${CLICKHOUSE_TEST_UNIQUE_NAME}" mkdir -p "${WORKING_DIR}" DATA_FILE="${WORKING_DIR}/const.parquet" diff --git a/tests/queries/0_stateless/04812_parquet_constant_column_kinds.reference b/tests/queries/0_stateless/04812_parquet_constant_column_kinds.reference new file mode 100644 index 000000000000..0a6c21d76bdf --- /dev/null +++ b/tests/queries/0_stateless/04812_parquet_constant_column_kinds.reference @@ -0,0 +1,25 @@ +-- optimization on (sparse + dense kinds) +13606684125528126333 0 10 10 10 990 10 499500 +-- optimization on, sparse disabled via ratio = 1 (dense kind only) +13606684125528126333 0 10 10 10 990 10 499500 +-- optimization off (must be identical) +13606684125528126333 0 10 10 10 990 10 499500 +-- filters and aggregation over sparse / all-null columns +10 +990 +7 x 10 +\N \N 990 +0 \N 7 \N x +1 \N \N 7 \N +100 \N 7 \N x +999 \N \N 7 \N +-- null_as_default with non-nullable output types +0 70 990 6930 990 10 +-- DEFAULT expressions fill the null rows (AddingDefaultsTransform over sparse columns) +5000 8980 7040 990 10 +-- LowCardinality output cannot be sparse: falls back to dense / normal decode with the same result +0 10 10 10 10 +-- profile events: all-null chunks skip pages; single-value-plus-nulls chunks skip values; nothing when disabled +10 30 +10 30 +0 0 diff --git a/tests/queries/0_stateless/04812_parquet_constant_column_kinds.sh b/tests/queries/0_stateless/04812_parquet_constant_column_kinds.sh new file mode 100755 index 000000000000..b789a7a6846f --- /dev/null +++ b/tests/queries/0_stateless/04812_parquet_constant_column_kinds.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Tags: no-fasttest + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +USER_FILES_PATH=$(${CLICKHOUSE_CLIENT} -q "SELECT value FROM system.server_settings WHERE name = 'user_files_path'" | sed 's|/$||') +REL_DIR="${CLICKHOUSE_TEST_UNIQUE_NAME}" +WORKING_DIR="${USER_FILES_PATH}/${REL_DIR}" +mkdir -p "${WORKING_DIR}" +DATA_FILE="${WORKING_DIR}/kinds.parquet" + +# 1000 rows, 100 rows per row group => 10 row groups. Per chunk: +# n_all - every row NULL -> all-default (sparse), pages not read +# n_sparse - 7 in 1 row of 100, NULL otherwise -> single value + 99% nulls -> sparse, values not decoded +# n_dense - NULL in 1 row of 100, 7 otherwise -> single value + 1% nulls -> dense Nullable, values not decoded +# s_sparse - 'x' in 1 row of 100, NULL otherwise -> BYTE_ARRAY with exact min/max flags -> sparse +# k - varies -> normal decode +${CLICKHOUSE_CLIENT} -q " + INSERT INTO FUNCTION file('${DATA_FILE}', Parquet) + SELECT + number AS k, + NULL::Nullable(Int64) AS n_all, + if(number % 100 = 0, 7, NULL)::Nullable(Int64) AS n_sparse, + if(number % 100 = 0, NULL, 7)::Nullable(Int64) AS n_dense, + if(number % 100 = 0, 'x', NULL)::Nullable(String) AS s_sparse + FROM numbers(1000) + SETTINGS engine_file_truncate_on_insert = 1, output_format_parquet_row_group_size = 100 +" + +STRUCTURE="k UInt64, n_all Nullable(Int64), n_sparse Nullable(Int64), n_dense Nullable(Int64), s_sparse Nullable(String)" +FULL_HASH="SELECT sum(cityHash64(k, ifNull(n_all, -1), ifNull(n_sparse, -1), ifNull(n_dense, -1), ifNull(s_sparse, ''))), count(n_all), countIf(n_sparse = 7), count(n_sparse), countIf(n_dense IS NULL), count(n_dense), countIf(s_sparse = 'x'), sum(k) FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}')" + +qid_on="${CLICKHOUSE_TEST_UNIQUE_NAME}_on" +qid_dense="${CLICKHOUSE_TEST_UNIQUE_NAME}_dense" +qid_off="${CLICKHOUSE_TEST_UNIQUE_NAME}_off" + +echo "-- optimization on (sparse + dense kinds)" +${CLICKHOUSE_CLIENT} --query_id="${qid_on}" -q "${FULL_HASH}" +echo "-- optimization on, sparse disabled via ratio = 1 (dense kind only)" +${CLICKHOUSE_CLIENT} --query_id="${qid_dense}" -q "${FULL_HASH} SETTINGS input_format_parquet_constant_column_sparse_ratio = 1" +echo "-- optimization off (must be identical)" +${CLICKHOUSE_CLIENT} --query_id="${qid_off}" -q "${FULL_HASH} SETTINGS input_format_parquet_use_constant_column_optimization = 0" + +echo "-- filters and aggregation over sparse / all-null columns" +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') WHERE n_sparse = 7" +${CLICKHOUSE_CLIENT} -q "SELECT count() FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') WHERE n_sparse IS NULL AND n_all IS NULL" +${CLICKHOUSE_CLIENT} -q "SELECT n_sparse, s_sparse, count() FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') GROUP BY 1, 2 ORDER BY 1, 2" +${CLICKHOUSE_CLIENT} -q "SELECT k, n_all, n_sparse, n_dense, s_sparse FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') WHERE k IN (0, 1, 100, 999) ORDER BY k" + +echo "-- null_as_default with non-nullable output types" +${CLICKHOUSE_CLIENT} -q " + SELECT sum(n_all), sum(n_sparse), countIf(n_sparse = 0), sum(n_dense), countIf(s_sparse = ''), countIf(s_sparse = 'x') + FROM file('${DATA_FILE}', Parquet, 'k UInt64, n_all Int64, n_sparse Int64, n_dense Int64, s_sparse String') + SETTINGS input_format_null_as_default = 1 +" + +echo "-- DEFAULT expressions fill the null rows (AddingDefaultsTransform over sparse columns)" +${CLICKHOUSE_CLIENT} -q "DROP TABLE IF EXISTS t_kinds_file" +${CLICKHOUSE_CLIENT} -q " + CREATE TABLE t_kinds_file (k UInt64, n_all Int64 DEFAULT 5, n_sparse Int64 DEFAULT 9, n_dense Int64 DEFAULT 11, s_sparse String DEFAULT 'd') + ENGINE = File(Parquet, '${REL_DIR}/kinds.parquet') +" +${CLICKHOUSE_CLIENT} -q "SELECT sum(n_all), sum(n_sparse), sum(n_dense), countIf(s_sparse = 'd'), countIf(s_sparse = 'x') FROM t_kinds_file SETTINGS input_format_null_as_default = 1" +${CLICKHOUSE_CLIENT} -q "DROP TABLE t_kinds_file" + +echo "-- LowCardinality output cannot be sparse: falls back to dense / normal decode with the same result" +${CLICKHOUSE_CLIENT} -q " + SELECT count(n_all), countIf(n_sparse = 7), count(n_sparse), countIf(s_sparse = 'x'), count(s_sparse) + FROM file('${DATA_FILE}', Parquet, 'k UInt64, n_all LowCardinality(Nullable(Int64)), n_sparse LowCardinality(Nullable(Int64)), n_dense LowCardinality(Nullable(Int64)), s_sparse LowCardinality(Nullable(String))') + SETTINGS allow_suspicious_low_cardinality_types = 1 +" + +echo "-- profile events: all-null chunks skip pages; single-value-plus-nulls chunks skip values; nothing when disabled" +${CLICKHOUSE_CLIENT} -q " + SYSTEM FLUSH LOGS query_log; + SELECT ProfileEvents['ParquetConstantColumnChunks'], ProfileEvents['ParquetConstantColumnChunksWithNulls'] + FROM system.query_log + WHERE event_date >= yesterday() AND event_time >= now() - 600 + AND query_id = '${qid_on}' AND type = 'QueryFinish' AND current_database = currentDatabase(); + SELECT ProfileEvents['ParquetConstantColumnChunks'], ProfileEvents['ParquetConstantColumnChunksWithNulls'] + FROM system.query_log + WHERE event_date >= yesterday() AND event_time >= now() - 600 + AND query_id = '${qid_dense}' AND type = 'QueryFinish' AND current_database = currentDatabase(); + SELECT ProfileEvents['ParquetConstantColumnChunks'], ProfileEvents['ParquetConstantColumnChunksWithNulls'] + FROM system.query_log + WHERE event_date >= yesterday() AND event_time >= now() - 600 + AND query_id = '${qid_off}' AND type = 'QueryFinish' AND current_database = currentDatabase(); +" + +rm -rf "${WORKING_DIR}" From 5f1c14355805bfc8102acf3ec231d303f03cc515 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 27 Aug 2026 00:32:59 +0300 Subject: [PATCH 04/14] Expand sparse input columns in insertNullAsDefaultIfNeeded Input formats may now produce sparse columns (the Parquet reader for chunks that are all null or mostly null), and they survive the Native protocol when the client parses `INSERT ... FORMAT Parquet`. `insertNullAsDefaultIfNeeded` `assert_cast`s a Nullable-typed input column to `ColumnNullable`, which on a `ColumnSparse` raised the logical error `Bad cast from type DB::IColumn const* to DB::ColumnNullable const*` (caught by `04140_parquet_types_roundtrip`). Convert sparse input to a full column first, as `AddingDefaultsTransform` already does with `removeSpecialRepresentations`. Extend `04812_parquet_constant_column_kinds` with an `INSERT ... FORMAT Parquet` through the client under `input_format_null_as_default`. Co-Authored-By: Claude Fable 5 Signed-off-by: UnamedRus --- src/Formats/insertNullAsDefaultIfNeeded.cpp | 6 ++++++ .../04812_parquet_constant_column_kinds.reference | 2 ++ .../0_stateless/04812_parquet_constant_column_kinds.sh | 7 +++++++ 3 files changed, 15 insertions(+) diff --git a/src/Formats/insertNullAsDefaultIfNeeded.cpp b/src/Formats/insertNullAsDefaultIfNeeded.cpp index d62719375d61..887ee81d92b5 100644 --- a/src/Formats/insertNullAsDefaultIfNeeded.cpp +++ b/src/Formats/insertNullAsDefaultIfNeeded.cpp @@ -16,6 +16,12 @@ namespace DB bool insertNullAsDefaultIfNeeded(ColumnWithTypeAndName & input_column, const ColumnWithTypeAndName & header_column, size_t column_i, BlockMissingValues * block_missing_values) { + /// Input formats may produce sparse columns (e.g. the Parquet reader for chunks that are all + /// null or mostly null), and they survive the Native protocol from the client. The casts below + /// expect the concrete Nullable / Array / Tuple / Map columns, so expand first. + if (input_column.column->isSparse()) + input_column.column = input_column.column->convertToFullColumnIfSparse(); + if (isArray(input_column.type) && isArray(header_column.type)) { ColumnWithTypeAndName nested_input_column; diff --git a/tests/queries/0_stateless/04812_parquet_constant_column_kinds.reference b/tests/queries/0_stateless/04812_parquet_constant_column_kinds.reference index 0a6c21d76bdf..ae73a5468a3c 100644 --- a/tests/queries/0_stateless/04812_parquet_constant_column_kinds.reference +++ b/tests/queries/0_stateless/04812_parquet_constant_column_kinds.reference @@ -17,6 +17,8 @@ 0 70 990 6930 990 10 -- DEFAULT expressions fill the null rows (AddingDefaultsTransform over sparse columns) 5000 8980 7040 990 10 +-- INSERT ... FORMAT Parquet through the client: sparse columns travel over the Native protocol +0 70 6930 990 10 1000 -- LowCardinality output cannot be sparse: falls back to dense / normal decode with the same result 0 10 10 10 10 -- profile events: all-null chunks skip pages; single-value-plus-nulls chunks skip values; nothing when disabled diff --git a/tests/queries/0_stateless/04812_parquet_constant_column_kinds.sh b/tests/queries/0_stateless/04812_parquet_constant_column_kinds.sh index b789a7a6846f..98038c3e74a4 100755 --- a/tests/queries/0_stateless/04812_parquet_constant_column_kinds.sh +++ b/tests/queries/0_stateless/04812_parquet_constant_column_kinds.sh @@ -65,6 +65,13 @@ ${CLICKHOUSE_CLIENT} -q " ${CLICKHOUSE_CLIENT} -q "SELECT sum(n_all), sum(n_sparse), sum(n_dense), countIf(s_sparse = 'd'), countIf(s_sparse = 'x') FROM t_kinds_file SETTINGS input_format_null_as_default = 1" ${CLICKHOUSE_CLIENT} -q "DROP TABLE t_kinds_file" +echo "-- INSERT ... FORMAT Parquet through the client: sparse columns travel over the Native protocol" +${CLICKHOUSE_CLIENT} -q "DROP TABLE IF EXISTS t_kinds_ins" +${CLICKHOUSE_CLIENT} -q "CREATE TABLE t_kinds_ins (k UInt64, n_all Int64, n_sparse Int64, n_dense Int64, s_sparse String) ENGINE = Memory" +${CLICKHOUSE_CLIENT} --input_format_null_as_default=1 -q "INSERT INTO t_kinds_ins FORMAT Parquet" < "${DATA_FILE}" +${CLICKHOUSE_CLIENT} -q "SELECT sum(n_all), sum(n_sparse), sum(n_dense), countIf(s_sparse = ''), countIf(s_sparse = 'x'), count() FROM t_kinds_ins" +${CLICKHOUSE_CLIENT} -q "DROP TABLE t_kinds_ins" + echo "-- LowCardinality output cannot be sparse: falls back to dense / normal decode with the same result" ${CLICKHOUSE_CLIENT} -q " SELECT count(n_all), countIf(n_sparse = 7), count(n_sparse), countIf(s_sparse = 'x'), count(s_sparse) From 5682c6910788a9aa584669167a33ae762daf8968 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 27 Aug 2026 00:46:13 +0300 Subject: [PATCH 05/14] Materialize const and sparse columns where format output is accumulated with insertRangeFrom Input formats may now return `ColumnConst` / `ColumnSparse` (the Parquet reader for column chunks whose statistics prove a single value or all nulls). Pipelines handle those, but a few places accumulate format output by hand with `insertRangeFrom` into a full column, which `assert_cast`s the source to the concrete column type. `01429_empty_arrow_and_parquet` hit this in `StreamingFormatExecutor::insertChunk` (asynchronous inserts) with the logical error `Bad cast from type DB::ColumnConst to DB::ColumnVector`. Convert const/sparse columns to full first there, in `AsynchronousInsertQueue` for pre-parsed blocks, and in the Iceberg equality-delete file reader. Co-Authored-By: Claude Fable 5 Signed-off-by: UnamedRus --- src/Interpreters/AsynchronousInsertQueue.cpp | 7 ++++++- src/Processors/Executors/StreamingFormatExecutor.cpp | 8 +++++++- .../ObjectStorage/DataLakes/Iceberg/IcebergMetadata.cpp | 6 +++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Interpreters/AsynchronousInsertQueue.cpp b/src/Interpreters/AsynchronousInsertQueue.cpp index 88207be6ba60..2ff4d0cd69dd 100644 --- a/src/Interpreters/AsynchronousInsertQueue.cpp +++ b/src/Interpreters/AsynchronousInsertQueue.cpp @@ -1414,7 +1414,12 @@ Chunk AsynchronousInsertQueue::processPreprocessedEntries( auto columns = block_to_insert.getColumns(); for (size_t i = 0, s = columns.size(); i < s; ++i) - result_columns[i]->insertRangeFrom(*columns[i], 0, columns[i]->size()); + { + /// Blocks may carry ColumnConst / ColumnSparse (e.g. from an input format that materializes + /// provably-constant column chunks); insertRangeFrom needs the concrete column type. + auto full_column = columns[i]->convertToFullColumnIfConst()->convertToFullColumnIfSparse(); + result_columns[i]->insertRangeFrom(*full_column, 0, full_column->size()); + } total_rows += block_to_insert.rows(); diff --git a/src/Processors/Executors/StreamingFormatExecutor.cpp b/src/Processors/Executors/StreamingFormatExecutor.cpp index 67f93053e158..15bfcb86d9d0 100644 --- a/src/Processors/Executors/StreamingFormatExecutor.cpp +++ b/src/Processors/Executors/StreamingFormatExecutor.cpp @@ -149,7 +149,13 @@ size_t StreamingFormatExecutor::insertChunk(Chunk chunk, size_t num_bytes) auto columns = chunk.detachColumns(); for (size_t i = 0, s = columns.size(); i < s; ++i) - result_columns[i]->insertRangeFrom(*columns[i], 0, columns[i]->size()); + { + /// Input formats may produce ColumnConst / ColumnSparse (e.g. the Parquet reader for column + /// chunks whose statistics prove a single value or all nulls); insertRangeFrom into the full + /// result column requires the concrete column type. + auto full_column = columns[i]->convertToFullColumnIfConst()->convertToFullColumnIfSparse(); + result_columns[i]->insertRangeFrom(*full_column, 0, full_column->size()); + } return chunk_rows; } diff --git a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.cpp b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.cpp index 24930b88462a..3f6ffff65b9d 100644 --- a/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.cpp +++ b/src/Storages/ObjectStorage/DataLakes/Iceberg/IcebergMetadata.cpp @@ -1471,7 +1471,11 @@ void IcebergMetadata::addDeleteTransformers( Columns delete_columns = delete_chunk.detachColumns(); for (size_t i = 0; i < equality_indexes_delete_file.size(); i++) { - mutable_columns_for_set[i]->insertRangeFrom(*delete_columns[equality_indexes_delete_file[i]], 0, rows); + /// The format may return ColumnConst / ColumnSparse for column chunks whose + /// statistics prove a single value or all nulls; insertRangeFrom needs the + /// concrete column type. + auto full_column = delete_columns[equality_indexes_delete_file[i]]->convertToFullColumnIfConst()->convertToFullColumnIfSparse(); + mutable_columns_for_set[i]->insertRangeFrom(*full_column, 0, rows); } } block_for_set.setColumns(std::move(mutable_columns_for_set)); From e85505c041c64f8542cc419fdd52e2f94f2e5a4b Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 27 Aug 2026 00:59:36 +0300 Subject: [PATCH 06/14] Parquet v3: apply the constant-chunk shortcut only when no cast is involved `decodeField` converts a statistics value according to the requested type so that it can be compared against key conditions; that conversion is not guaranteed to match decoding the page and `castColumn`-ing it to the requested type. `00900_long_parquet` (`FIXED_LEN_BYTE_ARRAY` read as `String` keeps its zero padding in the statistics but not in the decoded column) and `04006_parquet_date_to_enum_insert` (`Date32` read as `Enum8` is accepted for statistics but rejected by the cast with `CANNOT_CONVERT_TYPE`) showed the difference. Require `!needs_cast` in `isConstantColumnCandidate`, so the decoded type is the output type and the domains coincide. A skipped data page cannot be checksum-verified, like pages pruned by the page index; `03408_parquet_checksums` builds a single-row (hence provably constant) column chunk, so it disables the shortcut for its corrupt-page probe. Co-Authored-By: Claude Fable 5 Signed-off-by: UnamedRus --- .../Formats/Impl/Parquet/Reader.cpp | 22 ++++++++++++------- .../0_stateless/03408_parquet_checksums.sh | 6 +++-- ...11_parquet_constant_column_optimization.sh | 5 +++-- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 69ecbcc8dcbc..252f412a89de 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -1412,7 +1412,15 @@ bool Reader::isConstantColumnCandidate(const PrimitiveColumnInfo & column_info) if (column_info.idx_in_output_block >= sample_block_to_output_columns_idx.size()) return false; const auto & output_idx = sample_block_to_output_columns_idx.at(column_info.idx_in_output_block); - return output_idx.has_value() && output_columns[output_idx.value()].is_primitive; + if (!output_idx.has_value() || !output_columns[output_idx.value()].is_primitive) + return false; + /// The value comes from decodeField, whose conversions are chosen from the requested type for + /// the purpose of comparing against statistics (allow_stats) and are not guaranteed to match + /// what decoding a page and then castColumn-ing it to the requested type would produce: e.g. + /// FIXED_LEN_BYTE_ARRAY read as String keeps its zero padding in the statistics but not in the + /// decoded column, and Date32 read as Enum8 is accepted for statistics but rejected by the cast. + /// So only take the shortcut when no cast is involved, i.e. the decoded type is the output type. + return !output_columns[output_idx.value()].needs_cast; } Reader::ConstantKind Reader::chooseConstantKind(const PrimitiveColumnInfo & column_info, const DataTypePtr & final_output_type, Int64 num_values, std::optional null_count, bool single_value) const @@ -1507,13 +1515,11 @@ void Reader::detectConstantColumn(ColumnChunk & column, const PrimitiveColumnInf if (kind != ConstantKind::AllDefault) { - /// decodeField yields the value in the FINAL output type's domain, not decoded_type's: - /// SchemaConverter picks the statistics converter from the type hint (e.g. TIMESTAMP_MILLIS - /// read as DateTime decodes to seconds although decoded_type is DateTime64(3)), and sets - /// allow_stats only when that conversion is exact. So the value is inserted straight into a - /// column of output_info.output_type, bypassing decoded_type and castColumn. decodeField - /// leaves `value` Null when the physical type is unsupported for stats, in which case the - /// optimization does not fire. + /// decodeField yields the value in the requested (output) type's domain, which + /// isConstantColumnCandidate guarantees to be the decoded type as well (no cast involved), so + /// the value is inserted straight into a column of output_info.output_type. decodeField leaves + /// `value` Null when the physical type is unsupported for stats, in which case the optimization + /// does not fire. Field value; column_info.decoder.decodeField(stats.min_value, /*is_max=*/ false, value); if (value.isNull()) diff --git a/tests/queries/0_stateless/03408_parquet_checksums.sh b/tests/queries/0_stateless/03408_parquet_checksums.sh index d527e606252f..bcddc4ae49c4 100755 --- a/tests/queries/0_stateless/03408_parquet_checksums.sh +++ b/tests/queries/0_stateless/03408_parquet_checksums.sh @@ -24,9 +24,11 @@ ${CLICKHOUSE_LOCAL} -q " select * from file('$F');" corrupt_file +# The single-row column chunk is provably constant from its statistics, so the reader would not read +# (and therefore could not verify) its data page at all; disable that shortcut to exercise the checksum. ${CLICKHOUSE_LOCAL} -q " - select * from file('$F') settings input_format_parquet_verify_checksums=1 + select * from file('$F') settings input_format_parquet_verify_checksums=1, input_format_parquet_use_constant_column_optimization=0 " 2>&1 | grep -o 'CRC checksum verification failed' || echo 'got no checksum error, unexpected' ${CLICKHOUSE_LOCAL} -q " @@ -42,5 +44,5 @@ ${CLICKHOUSE_LOCAL} -q " corrupt_file ${CLICKHOUSE_LOCAL} -q " - select * from file('$F') settings input_format_parquet_verify_checksums=1 + select * from file('$F') settings input_format_parquet_verify_checksums=1, input_format_parquet_use_constant_column_optimization=0 " 2>&1 | grep -o 'CRC checksum verification failed' || echo 'no checksum error, as expected' diff --git a/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh index 1e338bf2c2ac..87c53ee3a3cb 100755 --- a/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh +++ b/tests/queries/0_stateless/04811_parquet_constant_column_optimization.sh @@ -12,8 +12,9 @@ DATA_FILE="${WORKING_DIR}/const.parquet" # 1000 rows, 100 rows per row group => 10 row groups. `k` varies; the other four columns each hold a # single value in every row, so their per-chunk min/max statistics have min == max and no nulls. -# `c_dt` is written as TIMESTAMP_MILLIS and read back with a DateTime hint, exercising the -# milliseconds -> seconds stats conversion (the value is in the post-cast output domain). +# `c_dt` is written as TIMESTAMP_MILLIS and read back with a DateTime hint; that needs a cast from the +# decoded DateTime64(3), so the optimization deliberately does not apply to it and it goes through the +# normal decode path (the result must still be identical). ${CLICKHOUSE_CLIENT} -q " INSERT INTO FUNCTION file('${DATA_FILE}', Parquet) SELECT From 36783c7c10d0b7990d81b89796ec37595767ef8f Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 27 Aug 2026 03:40:48 +0300 Subject: [PATCH 07/14] Parquet v3: size reads from the file layout instead of the seek threshold On object storage the reader's concurrency came out of how ranges happened to coalesce rather than from anything chosen. Measured on a 271 MB single-file scan (59-column projection, 23 row groups of ~11.8 MB), the default settings produced twelve 22.6 MB reads - fewer than one read per row group - and a 4-thread pool, which left ~3 reads in flight and most of each read's latency on the critical path. Anything that perturbed coalescing swung the result by a factor of two. Changes: * `bytes_per_read_task` now bounds the task. It was compared against the distance from the seed range in each direction independently, so a task could reach `seed + 2 * bytes_per_read_task`; setting it below the natural coalescing width did nothing at all. Compare against the resulting span instead. * A read never spans a row group. `getRangeData` waits for a whole task - there is no partial completion - so a read covering the tail of one row group and the head of the next made the earlier one wait for the later one's bytes, serializing in-order delivery. `Reader` now hands the Prefetcher the row group boundaries; if the metadata is unusable or row groups are not in ascending order, coalescing is left unconstrained. * Read size adapts to how busy the IO pool is. While the pool has spare capacity, smaller reads fill it faster; once it is busy, larger reads amortize the round trip. Hysteresis keeps the size from flapping at the threshold. * The IO pool is sized from the query. `max_download_threads` (default 4) was picked for the URL engine and is usually too small here, so decoding threads end up running reads themselves or waiting for them. * New settings, all defaulting to the previous behaviour except the pool size: `input_format_parquet_bytes_per_read_task`, `input_format_parquet_max_io_threads`, and `input_format_parquet_max_active_files`, which bounds how many files read ahead at once so each active one runs at a useful depth instead of every file crawling. A file without a slot still reads the row group it must deliver next, so a query cannot stall on it. * Profile events to make this visible without a profiler: `ParquetReadTasks`, `ParquetReadTaskBytes` and `ParquetPrefetchStarvation`. Dividing read tasks by `ParquetReadRowGroups` gives reads per row group; below 1 means reads span row groups. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: UnamedRus --- src/Common/ProfileEvents.cpp | 3 + src/Core/FormatFactorySettings.h | 24 ++++++ src/Formats/FormatFactory.cpp | 3 + src/Formats/FormatParserSharedResources.cpp | 18 ++++ src/Formats/FormatParserSharedResources.h | 11 +++ src/Formats/FormatSettings.h | 6 ++ .../Formats/Impl/Parquet/Prefetcher.cpp | 84 +++++++++++++++++-- .../Formats/Impl/Parquet/Prefetcher.h | 29 +++++++ .../Formats/Impl/Parquet/ReadManager.cpp | 16 ++++ .../Formats/Impl/Parquet/ReadManager.h | 5 ++ .../Formats/Impl/Parquet/Reader.cpp | 41 +++++++++ .../Impl/ParquetV3BlockInputFormat.cpp | 23 ++++- 12 files changed, 255 insertions(+), 8 deletions(-) diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index 88ded38d07d5..d13b2511cfdb 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -1480,6 +1480,9 @@ The server successfully detected this situation and will download merged part fr M(ParquetConstantColumnChunksWithNulls, "The total number of parquet column chunks holding a single value plus nulls (per their statistics), for which only the definition levels were decoded and the value was taken from the statistics", ValueType::Number) \ M(ParquetDecodingTasks, "Tasks issued by parquet reader", ValueType::Number) \ M(ParquetDecodingTaskBatches, "Task groups sent to a thread pool by parquet reader", ValueType::Number) \ + M(ParquetReadTasks, "The total number of coalesced read tasks created by DB::Parquet::Prefetcher. Divide by `ParquetReadRowGroups` to get reads per row group; below 1 means a single read spans several row groups, which serializes their in-order delivery", ValueType::Number) \ + M(ParquetReadTaskBytes, "The total number of bytes covered by the read tasks counted in `ParquetReadTasks`, including bytes read incidentally to close short gaps between requested ranges", ValueType::Bytes) \ + M(ParquetPrefetchStarvation, "The number of times a decoding thread asked for a range whose read had not finished yet, and had to run it inline or wait for it. High values relative to `ParquetReadTasks` mean read-ahead is too shallow", ValueType::Number) \ M(ParquetPrefetcherReadRandomRead, "The total number of reads with ReadMode::RandomRead by DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetPrefetcherReadSeekAndRead, "The total number of reads with ReadMode::SeekAndRead by DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetPrefetcherReadEntireFile, "The total number of read with ReadMode::EntireFileIsInMemory by DB::Parquet::Prefetcher", ValueType::Number) \ diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index 59e560236a32..88dcee7a0171 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -256,6 +256,30 @@ Min bytes required for local read (file) to do seek, instead of read with ignore )", 0) \ DECLARE(Bool, input_format_parquet_enable_row_group_prefetch, true, R"( Enable row group prefetching during parquet parsing. Currently, only single-threaded parsing can prefetch. +)", 0) \ + DECLARE(UInt64, input_format_parquet_max_io_threads, 0, R"( +Size of the thread pool that issues reads for the Parquet reader, shared by all files read by the +query. `0` means derive it from `max_download_threads` and `max_parsing_threads`. + +The reader needs enough reads in flight to cover the storage's response time; with too few, decoding +threads end up running the reads themselves or waiting for them. The default of `max_download_threads` +(4) was chosen for the URL engine and is usually too small for object storage. +)", 0) \ + DECLARE(UInt64, input_format_parquet_max_active_files, 0, R"( +How many Parquet files may prefetch ahead at the same time when a query reads many files. `0` means +no limit, which is the historical behaviour. + +All files share one IO pool, so with many files each gets too few reads in flight to cover the +storage's response time and none of them finish early. Limiting the number of files that read ahead +lets each active one run at a useful depth and release its buffers sooner; the rest are admitted as +those drain. Files that do not hold a slot still read the row group they must deliver next. +)", 0) \ + DECLARE(UInt64, input_format_parquet_bytes_per_read_task, 0, R"( +Target size of a single read issued by the Parquet reader. Nearby column chunks are coalesced into one +read up to this size. `0` means derive it from the min-bytes-for-seek of the underlying storage. + +A read is never allowed to span two row groups regardless of this setting, because row groups are +delivered in order and a read completes as a whole. )", 0) \ DECLARE(Bool, input_format_arrow_allow_missing_columns, true, R"( Allow missing columns while reading Arrow input formats diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index c0a7c4afef64..1ef378ee2786 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -250,6 +250,9 @@ FormatSettings getFormatSettings(const ContextPtr & context, const Settings & se format_settings.parquet.bloom_filter_bits_per_value = settings[Setting::output_format_parquet_bloom_filter_bits_per_value]; format_settings.parquet.bloom_filter_flush_threshold_bytes = settings[Setting::output_format_parquet_bloom_filter_flush_threshold_bytes]; format_settings.parquet.local_read_min_bytes_for_seek = settings[Setting::input_format_parquet_local_file_min_bytes_for_seek]; + format_settings.parquet.max_io_threads = settings[Setting::input_format_parquet_max_io_threads]; + format_settings.parquet.bytes_per_read_task = settings[Setting::input_format_parquet_bytes_per_read_task]; + format_settings.parquet.max_active_files = settings[Setting::input_format_parquet_max_active_files]; format_settings.parquet.enable_row_group_prefetch = settings[Setting::input_format_parquet_enable_row_group_prefetch]; format_settings.parquet.verify_checksums = settings[Setting::input_format_parquet_verify_checksums]; format_settings.parquet.local_time_as_utc = settings[Setting::input_format_parquet_local_time_as_utc]; diff --git a/src/Formats/FormatParserSharedResources.cpp b/src/Formats/FormatParserSharedResources.cpp index bdf4d6ffca43..47bb70911db7 100644 --- a/src/Formats/FormatParserSharedResources.cpp +++ b/src/Formats/FormatParserSharedResources.cpp @@ -27,6 +27,24 @@ FormatParserSharedResourcesPtr FormatParserSharedResources::singleThreaded(const } +bool FormatParserSharedResources::tryAcquirePrefetchSlot(size_t max_active) +{ + if (max_active == 0) + return true; // unlimited + size_t cur = active_prefetch_readers.load(std::memory_order_relaxed); + while (cur < max_active) + { + if (active_prefetch_readers.compare_exchange_weak(cur, cur + 1, std::memory_order_acq_rel, std::memory_order_relaxed)) + return true; + } + return false; +} + +void FormatParserSharedResources::releasePrefetchSlot() +{ + active_prefetch_readers.fetch_sub(1, std::memory_order_release); +} + void FormatParserSharedResources::finishStream() { num_streams.fetch_sub(1, std::memory_order_relaxed); diff --git a/src/Formats/FormatParserSharedResources.h b/src/Formats/FormatParserSharedResources.h index 8cfadffa026a..a112843eab77 100644 --- a/src/Formats/FormatParserSharedResources.h +++ b/src/Formats/FormatParserSharedResources.h @@ -23,6 +23,7 @@ struct FormatParserSharedResources const size_t max_io_threads = 0; std::atomic num_streams{0}; + std::atomic active_prefetch_readers{0}; ThreadPoolCallbackRunnerFast parsing_runner; ThreadPoolCallbackRunnerFast io_runner; @@ -35,6 +36,16 @@ struct FormatParserSharedResources void finishStream(); + /// Caps how many files prefetch ahead at the same time (input_format_parquet_max_active_files). + /// With many files sharing one IO pool, spreading it evenly leaves every file with too few reads + /// in flight to cover the storage's response time, and none of them finish early. Letting a few + /// files run at full depth and admitting the rest as those drain reads the same bytes with the + /// same bandwidth, but frees each file's read-ahead buffers sooner. + /// A reader that doesn't hold a slot still reads the row group it must deliver next, so a query + /// cannot stall on this. + bool tryAcquirePrefetchSlot(size_t max_active); + void releasePrefetchSlot(); + size_t getParsingThreadsPerReader() const; size_t getIOThreadsPerReader() const; diff --git a/src/Formats/FormatSettings.h b/src/Formats/FormatSettings.h index 21d778d063f2..4021a95cfdd1 100644 --- a/src/Formats/FormatSettings.h +++ b/src/Formats/FormatSettings.h @@ -361,6 +361,12 @@ struct FormatSettings UInt64 max_block_size = DEFAULT_BLOCK_SIZE; size_t prefer_block_bytes = DEFAULT_BLOCK_SIZE * 256; size_t local_read_min_bytes_for_seek = 8192; + /// 0 = derive from max_download_threads / max_parsing_threads. + size_t max_io_threads = 0; + /// 0 = derive from the storage's min-bytes-for-seek. + size_t bytes_per_read_task = 0; + /// 0 = no limit on how many files prefetch ahead concurrently. + size_t max_active_files = 0; size_t memory_low_watermark = 2ul << 20; size_t memory_high_watermark = 4ul << 30; /// Reader scheduler knobs: share of the column-data memory budget given to compressed diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp index 1141cfe870a2..1bb83f93f84b 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp @@ -19,6 +19,9 @@ namespace DB::ErrorCodes namespace ProfileEvents { extern const Event ParquetFetchWaitTimeMicroseconds; + extern const Event ParquetReadTasks; + extern const Event ParquetReadTaskBytes; + extern const Event ParquetPrefetchStarvation; extern const Event ParquetPrefetcherReadRandomRead; extern const Event ParquetPrefetcherReadSeekAndRead; extern const Event ParquetPrefetcherReadEntireFile; @@ -31,11 +34,50 @@ void Prefetcher::init(ReadBuffer * reader_, const ReadOptions & options, FormatP { min_bytes_for_seek = options.min_bytes_for_seek; bytes_per_read_task = options.bytes_per_read_task; + /// While the IO pool has spare capacity we issue smaller reads so more of them can run at once. + /// Not smaller than `min_bytes_for_seek`, below which a read stops amortizing its round trip. + min_bytes_per_read_task = std::max(min_bytes_for_seek, bytes_per_read_task / 4); parser_shared_resources = parser_shared_resources_; + if (parser_shared_resources) + io_concurrency_target = std::max(size_t(1), parser_shared_resources->max_io_threads); determineReadModeAndFileSize(reader_, options); range_sets.resize(1); } +void Prefetcher::setRowGroupBounds(std::vector bounds) +{ + std::lock_guard lock(mutex); + row_group_bounds = std::move(bounds); +} + +std::pair Prefetcher::rowGroupBoundsFor(size_t offset) const +{ + /// Called with `mutex` held (from pickRangesAndCreateTaskIfNotExists). + if (row_group_bounds.size() < 2) + return {0, std::numeric_limits::max()}; + /// First boundary strictly greater than `offset` ends the row group containing it. + auto hi = std::upper_bound(row_group_bounds.begin(), row_group_bounds.end(), offset); + if (hi == row_group_bounds.begin()) + return {0, *hi}; // before the first row group (metadata) + if (hi == row_group_bounds.end()) + return {row_group_bounds.back(), std::numeric_limits::max()}; // after the last one + return {*(hi - 1), *hi}; +} + +size_t Prefetcher::currentReadTaskBudget() const +{ + if (min_bytes_per_read_task >= bytes_per_read_task) + return bytes_per_read_task; + /// Hysteresis: split finely until the pool is full, and only go back to large reads once it is + /// comfortably busy, so the size doesn't flap around the threshold. + size_t in_flight = tasks_in_flight.load(std::memory_order_relaxed); + if (in_flight >= io_concurrency_target * 2) + return bytes_per_read_task; + if (in_flight < io_concurrency_target) + return min_bytes_per_read_task; + return (min_bytes_per_read_task + bytes_per_read_task) / 2; +} + Prefetcher::~Prefetcher() { shutdown->shutdown(); @@ -307,13 +349,25 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, size_t end_idx = range_idx + 1; size_t total_length_of_covered_ranges = end_offset - start_offset; + /// How big this task is allowed to get. `bytes_per_read_task` used to be compared against the + /// distance from the *seed* range in each direction independently, so a task could grow to + /// `seed length + 2 * bytes_per_read_task`; measured request sizes came out ~1.4x the setting, + /// and setting it below the natural coalescing width did nothing at all. Compare against the + /// resulting span instead, so the value means what its name says. + const size_t task_budget = currentReadTaskBudget(); + + /// Don't let one read span two row groups. `getRangeData` waits for the whole task - there is no + /// partial completion - so a consumer of the earlier row group would otherwise block until the + /// bytes of the next one have also arrived, serializing their in-order delivery. + const auto [row_group_lo, row_group_hi] = rowGroupBoundsFor(start_offset); + /// Go left. - size_t initial_offset = start_offset; for (size_t idx = range_idx; idx > 0; --idx) { const RangeState & r = ranges[idx - 1]; if (r.end + min_bytes_for_seek <= start_offset || // short gap - r.start + bytes_per_read_task <= initial_offset || // task not too big + r.start < row_group_lo || // would reach into the previous row group + end_offset - std::min(r.start, start_offset) > task_budget || // task not too big !r.request->allow_incidental_read.load(std::memory_order_relaxed)) // range wants to be coalesced break; @@ -343,12 +397,12 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, } /// Go right. - initial_offset = end_offset; for (size_t idx = range_idx + 1; idx < ranges.size(); ++idx) { const RangeState & r = ranges[end_idx]; if (end_offset + min_bytes_for_seek <= r.start || - initial_offset + bytes_per_read_task <= r.end || + r.end > row_group_hi || // would reach into the next row group + std::max(r.end, end_offset) - start_offset > task_budget || !r.request->allow_incidental_read.load(std::memory_order_relaxed)) break; @@ -370,8 +424,11 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, /// Create task. Task & task = tasks.emplace_back(); + task.owner = this; task.offset = start_offset; task.length = end_offset - task.offset; + ProfileEvents::increment(ProfileEvents::ParquetReadTasks); + ProfileEvents::increment(ProfileEvents::ParquetReadTaskBytes, task.length); task.memory_amplification = 1. * static_cast(task.length) / static_cast(total_length_of_covered_ranges); size_t initial_refcount = end_idx - start_idx + 1; task.refcount.store(initial_refcount); @@ -406,15 +463,24 @@ void Prefetcher::decreaseTaskRefcount(Task * task, size_t amount) if (c != amount) return; - if (task->state.exchange(Task::State::Deallocated) != Task::State::Running) + const auto prev = task->state.exchange(Task::State::Deallocated); + if (prev != Task::State::Running) { task->buf = {}; task->cached_region.reset(); } + /// Cancelled before any thread picked it up, so nothing else will account for it. A task that is + /// already Running is still counted by whoever is running it; one that is Done was counted out + /// when it finished. + if (prev == Task::State::Scheduled && task->owner) + task->owner->tasks_in_flight.fetch_sub(1, std::memory_order_relaxed); } void Prefetcher::scheduleTask(Task * task) { + /// Counted from the moment the read is queued, not from when a thread picks it up: a queued read + /// is already committed work, and read-task sizing should react to it right away. + tasks_in_flight.fetch_add(1, std::memory_order_relaxed); if (parser_shared_resources && !parser_shared_resources->io_runner.isDisabled()) parser_shared_resources->io_runner([this, task, _shutdown = shutdown] { @@ -435,6 +501,11 @@ std::span Prefetcher::getRangeData(const PrefetchHandle & request) { Stopwatch wait_time; + /// The read this range needs hasn't finished. Either it was never started and this thread + /// has to run it inline (losing the thread to IO), or it is in flight and this thread parks. + /// Both mean read-ahead didn't stay far enough in front of decoding. + ProfileEvents::increment(ProfileEvents::ParquetPrefetchStarvation); + if (s == Task::State::Scheduled) { s = runTask(task); @@ -542,6 +613,9 @@ Prefetcher::Task::State Prefetcher::runTask(Task * task) task->cached_region.reset(); } + /// The read is over, whichever way it ended; stop counting it against the IO pool. + tasks_in_flight.fetch_sub(1, std::memory_order_relaxed); + task->completion.notify(); return s; diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.h b/src/Processors/Formats/Impl/Parquet/Prefetcher.h index 40796dd10342..29558e8789cd 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.h +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.h @@ -41,6 +41,12 @@ class Prefetcher /// Called at most once, after all registerRange calls and before all enqueue/getRangeData calls. void finalizeRanges(); + /// Tells the Prefetcher where row groups start and end, so that one read never covers parts of + /// two of them. `bounds` must be sorted and hold the start offset of each row group followed by + /// the end offset of the last one. Called after the file metadata is parsed; reads issued before + /// that (footer, metadata) are unconstrained. + void setRowGroupBounds(std::vector bounds); + /// Replace a requested range with a set of disjoint smaller ranges contained within it. /// `subranges` must be sorted. std::vector splitRange( @@ -144,6 +150,11 @@ class Prefetcher }; std::optional cached_region; + /// The Prefetcher that owns this task. Needed because `decreaseTaskRefcount` is static (it is + /// called from PrefetchHandle, which doesn't know the Prefetcher) but has to account for a + /// task cancelled before any thread ran it. + Prefetcher * owner = nullptr; + std::atomic state {State::Scheduled}; /// How many RequestState-s in HasTask state point to this Task. std::atomic refcount {}; @@ -179,6 +190,18 @@ class Prefetcher size_t min_bytes_for_seek{}; size_t bytes_per_read_task{}; + /// Sorted file offsets at which row groups start, followed by the end of the last row group. + /// Empty until `setRowGroupBounds` is called (metadata reads happen before that and are not + /// constrained). Used to keep a single read from spanning two row groups. + std::vector row_group_bounds; + + /// How many reads are running or queued. Drives the read-task size: while the IO pool is not + /// busy we prefer more, smaller reads (they fill it faster); once it is busy we prefer fewer, + /// larger ones (each extra read costs a round trip to the storage). + std::atomic tasks_in_flight {0}; + size_t io_concurrency_target = 1; + size_t min_bytes_per_read_task{}; + std::shared_ptr shutdown = std::make_shared(); /// Locked when creating a Task. @@ -194,6 +217,12 @@ class Prefetcher /// (One mutex for all tasks because it's not used frequently.) std::mutex exception_mutex; + /// The half-open range [lo, hi) of the row group containing `offset`, or the whole file if the + /// row group layout isn't known yet (metadata reads). + std::pair rowGroupBoundsFor(size_t offset) const; + /// Size limit for a read task, adapted to how busy the IO pool is (see `tasks_in_flight`). + size_t currentReadTaskBudget() const; + void determineReadModeAndFileSize(ReadBuffer * reader_, const ReadOptions & options); /// Creates and starts a Task covering this request and possibly other nearby ranges. /// diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp index b6375ccb96c2..4fb14e5c84e2 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp @@ -130,6 +130,8 @@ void ReadManager::init(FormatParserSharedResourcesPtr parser_shared_resources_, ReadManager::~ReadManager() { shutdown->shutdown(); + if (holds_prefetch_slot.exchange(false) && parser_shared_resources) + parser_shared_resources->releasePrefetchSlot(); } void ReadManager::cancel() noexcept @@ -649,6 +651,20 @@ void ReadManager::scheduleTasksIfNeeded(ReadStage stage_idx) if (!can_schedule && !is_privileged) break; + /// Read ahead only while this file holds one of the active-file slots. Files without a slot + /// still read the row group they must deliver next (`is_privileged`), so this bounds how many + /// files compete for the IO pool without being able to stall the query. + if (stage_idx == ReadStage::ColumnDataPrefetch && !is_privileged + && reader.options.format.parquet.max_active_files != 0 + && !holds_prefetch_slot.load(std::memory_order_relaxed)) + { + bool expected = false; + if (!parser_shared_resources->tryAcquirePrefetchSlot(reader.options.format.parquet.max_active_files)) + break; + if (!holds_prefetch_slot.compare_exchange_strong(expected, true)) + parser_shared_resources->releasePrefetchSlot(); // another thread got one first + } + if (!stage.schedulable_row_groups.unset(row_group_idx, std::memory_order_acquire)) { LOG_TEST(getLogger("ParquetReadManager"), "scheduleTasksIfNeeded: another thread got row group {}", row_group_idx); diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.h b/src/Processors/Formats/Impl/Parquet/ReadManager.h index 7073492d2174..9262be740471 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.h +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.h @@ -33,6 +33,11 @@ class ReadManager public: Reader reader; + /// Whether this reader is one of the files currently allowed to prefetch ahead + /// (input_format_parquet_max_active_files). Without a slot the reader still reads the row group + /// it must deliver next, so progress never depends on getting one. + std::atomic holds_prefetch_slot {false}; + /// To initialize ReadManager: /// 1. call manager.reader.prefetcher.init /// 2. call manager.reader.init diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 252f412a89de..5bb925fc716d 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -468,6 +468,47 @@ void Reader::prefilterAndInitRowGroups(const std::optional bounds; + bounds.reserve(file_metadata.row_groups.size() + 1); + for (const auto & rg : file_metadata.row_groups) + { + size_t start = std::numeric_limits::max(); + size_t end = 0; + for (const auto & col : rg.columns) + { + /// A column chunk starts at its dictionary page when it has one, otherwise at its + /// first data page. Some writers leave dictionary_page_offset unset even when a + /// dictionary is present, in which case data_page_offset already points at it. + size_t col_start = size_t(col.meta_data.data_page_offset); + if (col.meta_data.__isset.dictionary_page_offset && col.meta_data.dictionary_page_offset > 0) + col_start = std::min(col_start, size_t(col.meta_data.dictionary_page_offset)); + start = std::min(start, col_start); + end = std::max(end, col_start + size_t(col.meta_data.total_compressed_size)); + } + if (start == std::numeric_limits::max() || end <= start) + continue; // unusable metadata, leave the layout unconstrained + if (!bounds.empty() && start < bounds.back()) + { + /// Row groups are expected to be laid out in order; if they aren't, don't guess. + bounds.clear(); + break; + } + bounds.push_back(start); + bounds.push_back(end); + } + if (!bounds.empty()) + { + /// Collapse to a sorted list of boundaries: adjacent row groups share an offset. + std::sort(bounds.begin(), bounds.end()); + bounds.erase(std::unique(bounds.begin(), bounds.end()), bounds.end()); + prefetcher.setRowGroupBounds(std::move(bounds)); + } + } + if (options.format.parquet.bloom_filter_push_down && format_filter_info->key_condition) prepareBloomFilterCondition(); diff --git a/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp b/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp index 6a238834caec..206874782eae 100644 --- a/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp +++ b/src/Processors/Formats/Impl/ParquetV3BlockInputFormat.cpp @@ -57,7 +57,13 @@ ParquetV3BlockInputFormat::ParquetV3BlockInputFormat( , object_with_metadata(object_with_metadata_) { read_options.min_bytes_for_seek = min_bytes_for_seek; - read_options.bytes_per_read_task = min_bytes_for_seek * 4; + /// How much of the file one read covers. Derived from the storage's min-bytes-for-seek unless it + /// is set explicitly: the two answer different questions - min_bytes_for_seek says when it is + /// cheaper to read across a gap than to start another request, while this says how big a single + /// request should get, which is bounded by how many requests we want in flight at once. + read_options.bytes_per_read_task = format_settings.parquet.bytes_per_read_task != 0 + ? format_settings.parquet.bytes_per_read_task + : min_bytes_for_seek * 4; if (!format_filter_info) format_filter_info = std::make_shared(); @@ -70,9 +76,20 @@ void ParquetV3BlockInputFormat::initializeIfNeeded() format_filter_info->initKeyConditionOnce(getPort().getHeader()); parser_shared_resources->initOnce([&] { - if (format_settings.parquet.enable_row_group_prefetch && parser_shared_resources->max_io_threads > 0) + /// Size of the pool that issues reads. `max_download_threads` defaults to 4, a value + /// picked for the URL engine; on object storage that is rarely enough to keep the + /// decoding threads fed, and they end up running the reads themselves or waiting. + /// Give the pool room to cover the storage's response time, but not so much that the + /// extra requests cost more than the concurrency buys. + size_t io_threads = format_settings.parquet.max_io_threads; + if (io_threads == 0) + io_threads = std::max( + parser_shared_resources->max_io_threads, + std::min(parser_shared_resources->max_parsing_threads, 16)); + if (format_settings.parquet.enable_row_group_prefetch && io_threads > 0 + && parser_shared_resources->max_io_threads > 0) parser_shared_resources->io_runner.initThreadPool( - getFormatParsingThreadPool().get(), parser_shared_resources->max_io_threads, ThreadName::PARQUET_PREFETCH, CurrentThread::getGroup()); + getFormatParsingThreadPool().get(), io_threads, ThreadName::PARQUET_PREFETCH, CurrentThread::getGroup()); /// Unfortunately max_parsing_threads setting doesn't have a value for /// "do parsing in the same thread as the rest of query processing From 1f6f96c082728821aa06e463fde123167baf0bb1 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 27 Aug 2026 11:46:35 +0300 Subject: [PATCH 08/14] Register the new Parquet read-path settings in SettingsChangesHistory Fixes the 02995_new_settings_history failure in Fast test: every new setting has to appear in SettingsChangesHistory.cpp. All three default to 0, so the settings themselves change no behavior; the note on max_io_threads records that its derived value is larger than the previous hard-coded max_download_threads. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: UnamedRus --- src/Core/SettingsChangesHistory.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index e78c710de3f7..3789ea3d3b56 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -44,6 +44,9 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"use_puffin_files_cache", false, true, "Enables cache of parsed Puffin file content such as deletion vectors."}, {"input_format_parquet_use_constant_column_optimization", false, true, "New setting: when a Parquet column chunk provably holds a single value in every row (per its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages (reader v3)."}, {"input_format_parquet_constant_column_sparse_ratio", 1.0, 0.9375, "New setting: a Parquet column chunk holding a single value plus nulls is materialized as a sparse column when the fraction of nulls is at least this ratio (reader v3)."}, + {"input_format_parquet_max_io_threads", 0, 0, "New setting: size of the thread pool that issues reads for the Parquet reader (v3). 0 derives it from `max_download_threads` and `max_parsing_threads`; the derived value is larger than the previous hard-coded `max_download_threads`, which defaults to 4 and was chosen for the URL engine."}, + {"input_format_parquet_bytes_per_read_task", 0, 0, "New setting: target size of a single read issued by the Parquet reader (v3). 0 derives it from the min-bytes-for-seek of the underlying storage."}, + {"input_format_parquet_max_active_files", 0, 0, "New setting: how many Parquet files may read ahead at the same time when a query reads many of them (v3). 0 means no limit, which is the previous behavior."}, }); addSettingsChanges(settings_changes_history, "26.6", From e77bc3c8895e94783a70d63e826e6a0f753b01a8 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 27 Aug 2026 14:46:49 +0300 Subject: [PATCH 09/14] Stop naming the reader version in the new read-path setting descriptions v3 is the default reader now, matching 6ba63a166fe which already removed it from the other reader setting descriptions. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: UnamedRus --- src/Core/SettingsChangesHistory.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 3789ea3d3b56..0ad42a2f4424 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -44,9 +44,9 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"use_puffin_files_cache", false, true, "Enables cache of parsed Puffin file content such as deletion vectors."}, {"input_format_parquet_use_constant_column_optimization", false, true, "New setting: when a Parquet column chunk provably holds a single value in every row (per its min/max statistics), materialize that value directly instead of reading and decoding the column's data pages (reader v3)."}, {"input_format_parquet_constant_column_sparse_ratio", 1.0, 0.9375, "New setting: a Parquet column chunk holding a single value plus nulls is materialized as a sparse column when the fraction of nulls is at least this ratio (reader v3)."}, - {"input_format_parquet_max_io_threads", 0, 0, "New setting: size of the thread pool that issues reads for the Parquet reader (v3). 0 derives it from `max_download_threads` and `max_parsing_threads`; the derived value is larger than the previous hard-coded `max_download_threads`, which defaults to 4 and was chosen for the URL engine."}, - {"input_format_parquet_bytes_per_read_task", 0, 0, "New setting: target size of a single read issued by the Parquet reader (v3). 0 derives it from the min-bytes-for-seek of the underlying storage."}, - {"input_format_parquet_max_active_files", 0, 0, "New setting: how many Parquet files may read ahead at the same time when a query reads many of them (v3). 0 means no limit, which is the previous behavior."}, + {"input_format_parquet_max_io_threads", 0, 0, "New setting: size of the thread pool that issues reads for the Parquet reader. 0 derives it from `max_download_threads` and `max_parsing_threads`; the derived value is larger than the previous hard-coded `max_download_threads`, which defaults to 4 and was chosen for the URL engine."}, + {"input_format_parquet_bytes_per_read_task", 0, 0, "New setting: target size of a single read issued by the Parquet reader. 0 derives it from the min-bytes-for-seek of the underlying storage."}, + {"input_format_parquet_max_active_files", 0, 0, "New setting: how many Parquet files may read ahead at the same time when a query reads many of them. 0 means no limit, which is the previous behavior."}, }); addSettingsChanges(settings_changes_history, "26.6", From 76ae223c6d903dc1d06941e58faea29c50cfd246 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 27 Aug 2026 14:52:44 +0300 Subject: [PATCH 10/14] Trim the comments on the read-path change Keep only what the code cannot say: why the task budget is compared against the span rather than per direction, why a read must not cross a row group, the writer quirk around dictionary_page_offset, why Task::owner exists, and the exactly-once accounting of tasks_in_flight. Drop the restatements. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: UnamedRus --- src/Common/ProfileEvents.cpp | 6 +-- src/Core/FormatFactorySettings.h | 24 +++++------- src/Formats/FormatParserSharedResources.h | 10 ++--- .../Formats/Impl/Parquet/Prefetcher.cpp | 39 +++++++------------ .../Formats/Impl/Parquet/Prefetcher.h | 23 ++++------- .../Formats/Impl/Parquet/ReadManager.cpp | 5 +-- .../Formats/Impl/Parquet/ReadManager.h | 4 +- .../Formats/Impl/Parquet/Reader.cpp | 16 +++----- .../Impl/ParquetV3BlockInputFormat.cpp | 13 ++----- 9 files changed, 50 insertions(+), 90 deletions(-) diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index d13b2511cfdb..cc7106ff3113 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -1480,9 +1480,9 @@ The server successfully detected this situation and will download merged part fr M(ParquetConstantColumnChunksWithNulls, "The total number of parquet column chunks holding a single value plus nulls (per their statistics), for which only the definition levels were decoded and the value was taken from the statistics", ValueType::Number) \ M(ParquetDecodingTasks, "Tasks issued by parquet reader", ValueType::Number) \ M(ParquetDecodingTaskBatches, "Task groups sent to a thread pool by parquet reader", ValueType::Number) \ - M(ParquetReadTasks, "The total number of coalesced read tasks created by DB::Parquet::Prefetcher. Divide by `ParquetReadRowGroups` to get reads per row group; below 1 means a single read spans several row groups, which serializes their in-order delivery", ValueType::Number) \ - M(ParquetReadTaskBytes, "The total number of bytes covered by the read tasks counted in `ParquetReadTasks`, including bytes read incidentally to close short gaps between requested ranges", ValueType::Bytes) \ - M(ParquetPrefetchStarvation, "The number of times a decoding thread asked for a range whose read had not finished yet, and had to run it inline or wait for it. High values relative to `ParquetReadTasks` mean read-ahead is too shallow", ValueType::Number) \ + M(ParquetReadTasks, "Coalesced read tasks created by the Parquet reader. Divided by `ParquetReadRowGroups`, values below 1 mean one read spans several row groups, which serializes their delivery", ValueType::Number) \ + M(ParquetReadTaskBytes, "Bytes covered by `ParquetReadTasks`, including bytes read to close short gaps between requested ranges", ValueType::Bytes) \ + M(ParquetPrefetchStarvation, "Times a decoding thread asked for a range whose read had not finished. High relative to `ParquetReadTasks` means read-ahead is too shallow", ValueType::Number) \ M(ParquetPrefetcherReadRandomRead, "The total number of reads with ReadMode::RandomRead by DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetPrefetcherReadSeekAndRead, "The total number of reads with ReadMode::SeekAndRead by DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetPrefetcherReadEntireFile, "The total number of read with ReadMode::EntireFileIsInMemory by DB::Parquet::Prefetcher", ValueType::Number) \ diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index 88dcee7a0171..6cb5d04f3a93 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -259,27 +259,23 @@ Enable row group prefetching during parquet parsing. Currently, only single-thre )", 0) \ DECLARE(UInt64, input_format_parquet_max_io_threads, 0, R"( Size of the thread pool that issues reads for the Parquet reader, shared by all files read by the -query. `0` means derive it from `max_download_threads` and `max_parsing_threads`. +query. `0` derives it from `max_download_threads` and `max_parsing_threads`. -The reader needs enough reads in flight to cover the storage's response time; with too few, decoding -threads end up running the reads themselves or waiting for them. The default of `max_download_threads` -(4) was chosen for the URL engine and is usually too small for object storage. +With too few reads in flight to cover the storage's response time, decoding threads end up running +the reads themselves or waiting for them. )", 0) \ DECLARE(UInt64, input_format_parquet_max_active_files, 0, R"( -How many Parquet files may prefetch ahead at the same time when a query reads many files. `0` means -no limit, which is the historical behaviour. +How many Parquet files may read ahead at the same time when a query reads many files. `0` means no +limit, which is the previous behaviour. -All files share one IO pool, so with many files each gets too few reads in flight to cover the -storage's response time and none of them finish early. Limiting the number of files that read ahead -lets each active one run at a useful depth and release its buffers sooner; the rest are admitted as -those drain. Files that do not hold a slot still read the row group they must deliver next. +All files share one IO pool, so with many files each gets too few reads in flight and none finish +early. Files that do not hold a slot still read the row group they must deliver next. )", 0) \ DECLARE(UInt64, input_format_parquet_bytes_per_read_task, 0, R"( -Target size of a single read issued by the Parquet reader. Nearby column chunks are coalesced into one -read up to this size. `0` means derive it from the min-bytes-for-seek of the underlying storage. +Target size of a single read issued by the Parquet reader; nearby column chunks are coalesced up to +this size. `0` derives it from the min-bytes-for-seek of the underlying storage. -A read is never allowed to span two row groups regardless of this setting, because row groups are -delivered in order and a read completes as a whole. +A read never spans two row groups regardless of this setting. )", 0) \ DECLARE(Bool, input_format_arrow_allow_missing_columns, true, R"( Allow missing columns while reading Arrow input formats diff --git a/src/Formats/FormatParserSharedResources.h b/src/Formats/FormatParserSharedResources.h index a112843eab77..e75996780221 100644 --- a/src/Formats/FormatParserSharedResources.h +++ b/src/Formats/FormatParserSharedResources.h @@ -36,13 +36,9 @@ struct FormatParserSharedResources void finishStream(); - /// Caps how many files prefetch ahead at the same time (input_format_parquet_max_active_files). - /// With many files sharing one IO pool, spreading it evenly leaves every file with too few reads - /// in flight to cover the storage's response time, and none of them finish early. Letting a few - /// files run at full depth and admitting the rest as those drain reads the same bytes with the - /// same bandwidth, but frees each file's read-ahead buffers sooner. - /// A reader that doesn't hold a slot still reads the row group it must deliver next, so a query - /// cannot stall on this. + /// See input_format_parquet_max_active_files. Spreading one IO pool across many files leaves + /// each with too few reads in flight to cover the storage's response time and none finishing + /// early. A reader without a slot still reads what it must deliver next, so this cannot stall. bool tryAcquirePrefetchSlot(size_t max_active); void releasePrefetchSlot(); diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp index 1bb83f93f84b..743436386a17 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp @@ -34,8 +34,7 @@ void Prefetcher::init(ReadBuffer * reader_, const ReadOptions & options, FormatP { min_bytes_for_seek = options.min_bytes_for_seek; bytes_per_read_task = options.bytes_per_read_task; - /// While the IO pool has spare capacity we issue smaller reads so more of them can run at once. - /// Not smaller than `min_bytes_for_seek`, below which a read stops amortizing its round trip. + /// Below `min_bytes_for_seek` a read stops amortizing its round trip. min_bytes_per_read_task = std::max(min_bytes_for_seek, bytes_per_read_task / 4); parser_shared_resources = parser_shared_resources_; if (parser_shared_resources) @@ -52,15 +51,14 @@ void Prefetcher::setRowGroupBounds(std::vector bounds) std::pair Prefetcher::rowGroupBoundsFor(size_t offset) const { - /// Called with `mutex` held (from pickRangesAndCreateTaskIfNotExists). + /// Called with `mutex` held. if (row_group_bounds.size() < 2) return {0, std::numeric_limits::max()}; - /// First boundary strictly greater than `offset` ends the row group containing it. auto hi = std::upper_bound(row_group_bounds.begin(), row_group_bounds.end(), offset); if (hi == row_group_bounds.begin()) - return {0, *hi}; // before the first row group (metadata) + return {0, *hi}; // metadata, before the first row group if (hi == row_group_bounds.end()) - return {row_group_bounds.back(), std::numeric_limits::max()}; // after the last one + return {row_group_bounds.back(), std::numeric_limits::max()}; return {*(hi - 1), *hi}; } @@ -68,8 +66,7 @@ size_t Prefetcher::currentReadTaskBudget() const { if (min_bytes_per_read_task >= bytes_per_read_task) return bytes_per_read_task; - /// Hysteresis: split finely until the pool is full, and only go back to large reads once it is - /// comfortably busy, so the size doesn't flap around the threshold. + /// Hysteresis, so the size doesn't flap around the threshold. size_t in_flight = tasks_in_flight.load(std::memory_order_relaxed); if (in_flight >= io_concurrency_target * 2) return bytes_per_read_task; @@ -349,16 +346,12 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, size_t end_idx = range_idx + 1; size_t total_length_of_covered_ranges = end_offset - start_offset; - /// How big this task is allowed to get. `bytes_per_read_task` used to be compared against the - /// distance from the *seed* range in each direction independently, so a task could grow to - /// `seed length + 2 * bytes_per_read_task`; measured request sizes came out ~1.4x the setting, - /// and setting it below the natural coalescing width did nothing at all. Compare against the - /// resulting span instead, so the value means what its name says. + /// Compared against the resulting span, not the distance from the seed range in each direction: + /// the latter let a task reach `seed length + 2 * bytes_per_read_task`. const size_t task_budget = currentReadTaskBudget(); - /// Don't let one read span two row groups. `getRangeData` waits for the whole task - there is no - /// partial completion - so a consumer of the earlier row group would otherwise block until the - /// bytes of the next one have also arrived, serializing their in-order delivery. + /// One read must not span two row groups: `getRangeData` waits for the whole task, so the + /// earlier row group would wait for the later one's bytes and delivery would serialize. const auto [row_group_lo, row_group_hi] = rowGroupBoundsFor(start_offset); /// Go left. @@ -469,17 +462,15 @@ void Prefetcher::decreaseTaskRefcount(Task * task, size_t amount) task->buf = {}; task->cached_region.reset(); } - /// Cancelled before any thread picked it up, so nothing else will account for it. A task that is - /// already Running is still counted by whoever is running it; one that is Done was counted out - /// when it finished. + /// Cancelled before any thread picked it up, so nothing else will account for it. Running is + /// accounted by whoever runs it; Done was accounted when it finished. if (prev == Task::State::Scheduled && task->owner) task->owner->tasks_in_flight.fetch_sub(1, std::memory_order_relaxed); } void Prefetcher::scheduleTask(Task * task) { - /// Counted from the moment the read is queued, not from when a thread picks it up: a queued read - /// is already committed work, and read-task sizing should react to it right away. + /// Counted from queueing, not from when a thread picks it up: it is already committed work. tasks_in_flight.fetch_add(1, std::memory_order_relaxed); if (parser_shared_resources && !parser_shared_resources->io_runner.isDisabled()) parser_shared_resources->io_runner([this, task, _shutdown = shutdown] @@ -501,9 +492,8 @@ std::span Prefetcher::getRangeData(const PrefetchHandle & request) { Stopwatch wait_time; - /// The read this range needs hasn't finished. Either it was never started and this thread - /// has to run it inline (losing the thread to IO), or it is in flight and this thread parks. - /// Both mean read-ahead didn't stay far enough in front of decoding. + /// Read-ahead didn't stay in front of decoding: this thread either runs the read inline or + /// parks until it lands. ProfileEvents::increment(ProfileEvents::ParquetPrefetchStarvation); if (s == Task::State::Scheduled) @@ -613,7 +603,6 @@ Prefetcher::Task::State Prefetcher::runTask(Task * task) task->cached_region.reset(); } - /// The read is over, whichever way it ended; stop counting it against the IO pool. tasks_in_flight.fetch_sub(1, std::memory_order_relaxed); task->completion.notify(); diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.h b/src/Processors/Formats/Impl/Parquet/Prefetcher.h index 29558e8789cd..8df2db6d0fde 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.h +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.h @@ -41,10 +41,8 @@ class Prefetcher /// Called at most once, after all registerRange calls and before all enqueue/getRangeData calls. void finalizeRanges(); - /// Tells the Prefetcher where row groups start and end, so that one read never covers parts of - /// two of them. `bounds` must be sorted and hold the start offset of each row group followed by - /// the end offset of the last one. Called after the file metadata is parsed; reads issued before - /// that (footer, metadata) are unconstrained. + /// Keeps one read from covering parts of two row groups. `bounds` is sorted: the start offset of + /// each row group, then the end of the last. Reads issued before this (metadata) are unconstrained. void setRowGroupBounds(std::vector bounds); /// Replace a requested range with a set of disjoint smaller ranges contained within it. @@ -150,9 +148,7 @@ class Prefetcher }; std::optional cached_region; - /// The Prefetcher that owns this task. Needed because `decreaseTaskRefcount` is static (it is - /// called from PrefetchHandle, which doesn't know the Prefetcher) but has to account for a - /// task cancelled before any thread ran it. + /// `decreaseTaskRefcount` is static but has to account for a task cancelled before it ran. Prefetcher * owner = nullptr; std::atomic state {State::Scheduled}; @@ -190,14 +186,11 @@ class Prefetcher size_t min_bytes_for_seek{}; size_t bytes_per_read_task{}; - /// Sorted file offsets at which row groups start, followed by the end of the last row group. - /// Empty until `setRowGroupBounds` is called (metadata reads happen before that and are not - /// constrained). Used to keep a single read from spanning two row groups. + /// See setRowGroupBounds. Empty until it is called. std::vector row_group_bounds; - /// How many reads are running or queued. Drives the read-task size: while the IO pool is not - /// busy we prefer more, smaller reads (they fill it faster); once it is busy we prefer fewer, - /// larger ones (each extra read costs a round trip to the storage). + /// Reads running or queued. Drives read-task size: smaller reads fill an idle pool faster, larger + /// ones amortize the round trip once it is busy. std::atomic tasks_in_flight {0}; size_t io_concurrency_target = 1; size_t min_bytes_per_read_task{}; @@ -217,10 +210,8 @@ class Prefetcher /// (One mutex for all tasks because it's not used frequently.) std::mutex exception_mutex; - /// The half-open range [lo, hi) of the row group containing `offset`, or the whole file if the - /// row group layout isn't known yet (metadata reads). + /// [lo, hi) of the row group containing `offset`, or the whole file if the layout isn't known yet. std::pair rowGroupBoundsFor(size_t offset) const; - /// Size limit for a read task, adapted to how busy the IO pool is (see `tasks_in_flight`). size_t currentReadTaskBudget() const; void determineReadModeAndFileSize(ReadBuffer * reader_, const ReadOptions & options); diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp index 4fb14e5c84e2..e1e702adb018 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp @@ -651,9 +651,8 @@ void ReadManager::scheduleTasksIfNeeded(ReadStage stage_idx) if (!can_schedule && !is_privileged) break; - /// Read ahead only while this file holds one of the active-file slots. Files without a slot - /// still read the row group they must deliver next (`is_privileged`), so this bounds how many - /// files compete for the IO pool without being able to stall the query. + /// Read ahead only while this file holds an active-file slot. Files without one still read + /// the row group they must deliver next, so this cannot stall the query. if (stage_idx == ReadStage::ColumnDataPrefetch && !is_privileged && reader.options.format.parquet.max_active_files != 0 && !holds_prefetch_slot.load(std::memory_order_relaxed)) diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.h b/src/Processors/Formats/Impl/Parquet/ReadManager.h index 9262be740471..e2ba32213676 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.h +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.h @@ -33,9 +33,7 @@ class ReadManager public: Reader reader; - /// Whether this reader is one of the files currently allowed to prefetch ahead - /// (input_format_parquet_max_active_files). Without a slot the reader still reads the row group - /// it must deliver next, so progress never depends on getting one. + /// See input_format_parquet_max_active_files. Progress never depends on holding a slot. std::atomic holds_prefetch_slot {false}; /// To initialize ReadManager: diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index 5bb925fc716d..f7df73fc0357 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -468,9 +468,7 @@ void Reader::prefilterAndInitRowGroups(const std::optional bounds; bounds.reserve(file_metadata.row_groups.size() + 1); @@ -480,9 +478,8 @@ void Reader::prefilterAndInitRowGroups(const std::optional 0) col_start = std::min(col_start, size_t(col.meta_data.dictionary_page_offset)); @@ -490,11 +487,10 @@ void Reader::prefilterAndInitRowGroups(const std::optional::max() || end <= start) - continue; // unusable metadata, leave the layout unconstrained + continue; // unusable metadata if (!bounds.empty() && start < bounds.back()) { - /// Row groups are expected to be laid out in order; if they aren't, don't guess. - bounds.clear(); + bounds.clear(); // not laid out in order; don't guess break; } bounds.push_back(start); @@ -502,7 +498,7 @@ void Reader::prefilterAndInitRowGroups(const std::optionalinitKeyConditionOnce(getPort().getHeader()); parser_shared_resources->initOnce([&] { - /// Size of the pool that issues reads. `max_download_threads` defaults to 4, a value - /// picked for the URL engine; on object storage that is rarely enough to keep the - /// decoding threads fed, and they end up running the reads themselves or waiting. - /// Give the pool room to cover the storage's response time, but not so much that the - /// extra requests cost more than the concurrency buys. + /// `max_download_threads` defaults to 4, picked for the URL engine; on object storage + /// that rarely keeps the decoding threads fed. size_t io_threads = format_settings.parquet.max_io_threads; if (io_threads == 0) io_threads = std::max( From 4a08161e0b16337529fb01de6c17d8ea8220d72e Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 27 Aug 2026 16:04:32 +0300 Subject: [PATCH 11/14] Parquet: issue a subgroup's reads as one unit, and one subgroup ahead Two changes to how data-page reads get issued. Issuance is one unit of work per subgroup instead of one task per column. The work already ran on the scheduling thread (scheduleTask) and the task body was empty, so a task per column bought a thread-pool round trip each and an N-wide barrier before decoding could start. New setting `input_format_parquet_read_ahead_subgroups` (default 0, previous behavior) issues the next subgroup's reads while the current one decodes. Until now a subgroup's reads were issued only after its predecessor was decoded and delivered, so the storage's response time was paid again on every subgroup instead of overlapping with work. Read-ahead skips filtered-out subgroups and claims a subgroup with a CAS, so losing the race to the normal path is harmless. That CAS exposed a latent defect in the sequential-admission loop: it took the expected value from its own load, so it succeeded whatever the current stage was and guarded nothing. It only worked because nothing else moved a subgroup out of NotStarted. It now compares against NotStarted explicitly, which is required for read-ahead not to admit a subgroup twice. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: UnamedRus --- src/Core/FormatFactorySettings.h | 9 ++ src/Core/SettingsChangesHistory.cpp | 1 + src/Formats/FormatFactory.cpp | 1 + src/Formats/FormatSettings.h | 2 + .../Formats/Impl/Parquet/ReadManager.cpp | 113 +++++++++++++----- .../Formats/Impl/Parquet/ReadManager.h | 5 + 6 files changed, 98 insertions(+), 33 deletions(-) diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index 6cb5d04f3a93..5231bf6ad839 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -270,6 +270,15 @@ limit, which is the previous behaviour. All files share one IO pool, so with many files each gets too few reads in flight and none finish early. Files that do not hold a slot still read the row group they must deliver next. +)", 0) \ + DECLARE(UInt64, input_format_parquet_read_ahead_subgroups, 0, R"( +How many row subgroups ahead the Parquet reader may issue reads for. `0` keeps the previous behaviour, +where a subgroup's reads are issued only after its predecessor has been decoded and delivered, so the +reader waits out the storage's response time on every subgroup. `1` issues the next subgroup's reads +while the current one decodes. Values above `1` are not supported yet and are treated as `1`. + +Read-ahead is opportunistic: it is skipped when the compressed read-ahead budget +(`input_format_parquet_prefetch_memory_fraction`) is already used up. )", 0) \ DECLARE(UInt64, input_format_parquet_bytes_per_read_task, 0, R"( Target size of a single read issued by the Parquet reader; nearby column chunks are coalesced up to diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index 0ad42a2f4424..b27dadfe14a0 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -46,6 +46,7 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"input_format_parquet_constant_column_sparse_ratio", 1.0, 0.9375, "New setting: a Parquet column chunk holding a single value plus nulls is materialized as a sparse column when the fraction of nulls is at least this ratio (reader v3)."}, {"input_format_parquet_max_io_threads", 0, 0, "New setting: size of the thread pool that issues reads for the Parquet reader. 0 derives it from `max_download_threads` and `max_parsing_threads`; the derived value is larger than the previous hard-coded `max_download_threads`, which defaults to 4 and was chosen for the URL engine."}, {"input_format_parquet_bytes_per_read_task", 0, 0, "New setting: target size of a single read issued by the Parquet reader. 0 derives it from the min-bytes-for-seek of the underlying storage."}, + {"input_format_parquet_read_ahead_subgroups", 0, 0, "New setting: how many row subgroups ahead the Parquet reader may issue reads for. 0 keeps the previous behavior of issuing a subgroup's reads only after its predecessor was decoded and delivered."}, {"input_format_parquet_max_active_files", 0, 0, "New setting: how many Parquet files may read ahead at the same time when a query reads many of them. 0 means no limit, which is the previous behavior."}, }); diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index 1ef378ee2786..acfe0a774c7d 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -253,6 +253,7 @@ FormatSettings getFormatSettings(const ContextPtr & context, const Settings & se format_settings.parquet.max_io_threads = settings[Setting::input_format_parquet_max_io_threads]; format_settings.parquet.bytes_per_read_task = settings[Setting::input_format_parquet_bytes_per_read_task]; format_settings.parquet.max_active_files = settings[Setting::input_format_parquet_max_active_files]; + format_settings.parquet.read_ahead_subgroups = settings[Setting::input_format_parquet_read_ahead_subgroups]; format_settings.parquet.enable_row_group_prefetch = settings[Setting::input_format_parquet_enable_row_group_prefetch]; format_settings.parquet.verify_checksums = settings[Setting::input_format_parquet_verify_checksums]; format_settings.parquet.local_time_as_utc = settings[Setting::input_format_parquet_local_time_as_utc]; diff --git a/src/Formats/FormatSettings.h b/src/Formats/FormatSettings.h index 4021a95cfdd1..2ad6e9c05459 100644 --- a/src/Formats/FormatSettings.h +++ b/src/Formats/FormatSettings.h @@ -367,6 +367,8 @@ struct FormatSettings size_t bytes_per_read_task = 0; /// 0 = no limit on how many files prefetch ahead concurrently. size_t max_active_files = 0; + /// 0 = issue a subgroup's reads only after its predecessor finished. + size_t read_ahead_subgroups = 0; size_t memory_low_watermark = 2ul << 20; size_t memory_high_watermark = 4ul << 30; /// Reader scheduler knobs: share of the column-data memory budget given to compressed diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp index e1e702adb018..6c54c82aaeb7 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp @@ -330,9 +330,8 @@ void ReadManager::addTasksToReadColumns(size_t row_group_idx, size_t row_subgrou LOG_TEST(getLogger("ParquetReadManager"), "addTasksToReadColumns: not added due locations empty i={} step_idx={} row_group_idx={} row_subgroup_idx={}", i, step_idx, row_group_idx, row_subgroup_idx); } } - else + else if (stage == ReadStage::ColumnData) { - /// `stage` is ColumnDataPrefetch (issue reads) or ColumnData (decode). LOG_TEST(getLogger("ParquetReadManager"), "addTasksToReadColumns: added {}: i={} step_idx={} row_group_idx={} row_subgroup_idx={}", magic_enum::enum_name(stage), i, step_idx, row_group_idx, row_subgroup_idx); add_tasks.push_back(Task { .stage = stage, @@ -343,6 +342,20 @@ void ReadManager::addTasksToReadColumns(size_t row_group_idx, size_t row_subgrou } } + if (stage == ReadStage::ColumnDataPrefetch) + { + /// Issuing this subgroup's reads is one unit of work for the whole subgroup, not one per + /// column: it runs on the scheduling thread (see scheduleTask) and its runTask is empty, + /// so a task per column only bought a thread-pool round trip each and a wider barrier. + chassert(add_tasks.empty()); + add_tasks.push_back(Task { + .stage = stage, + .step_idx = step_idx, + .row_group_idx = row_group_idx, + .row_subgroup_idx = row_subgroup_idx, + .column_idx = UINT64_MAX}); + } + if (add_tasks.empty() && is_offset_index) { /// Don't need to read offset index, move on to the next stage (ColumnDataPrefetch). @@ -371,6 +384,32 @@ void ReadManager::addTasksToReadColumns(size_t row_group_idx, size_t row_subgrou } } +void ReadManager::startSubgroupReadAhead(size_t row_group_idx, size_t current_subgroup_idx, MemoryUsageDiff & diff) +{ + if (reader.options.format.parquet.read_ahead_subgroups == 0) + return; + + RowGroup & row_group = reader.row_groups[row_group_idx]; + size_t next_idx = current_subgroup_idx + 1; + if (next_idx >= row_group.subgroups.size()) + return; + + RowSubgroup & next_subgroup = row_group.subgroups[next_idx]; + /// Leave filtered-out subgroups to the normal path: it also deallocates them, and + /// determinePagesToPrefetch requires rows_pass > 0. + if (next_subgroup.filter.rows_pass == 0) + return; + + /// Only claim a subgroup nobody has started. Losing this race is fine - it means the normal path + /// got there first. + ReadStage expected = ReadStage::NotStarted; + if (!next_subgroup.stage.compare_exchange_strong(expected, ReadStage::OffsetIndex)) + return; + + size_t first_step = reader.steps.empty() ? 0 : 1; + addTasksToReadColumns(row_group_idx, next_idx, ReadStage::OffsetIndex, first_step, diff); +} + void ReadManager::finishRowSubgroupStage(size_t row_group_idx, size_t row_subgroup_idx, ReadStage stage, size_t step_idx, MemoryUsageDiff & diff) { RowGroup & row_group = reader.row_groups[row_group_idx]; @@ -466,6 +505,9 @@ void ReadManager::finishRowSubgroupStage(size_t row_group_idx, size_t row_subgro { /// Data-page reads issued (in flight in the Prefetcher's io pool); now decode. addTasksToReadColumns(row_group_idx, row_subgroup_idx, ReadStage::ColumnData, step_idx, diff); + /// Get the next subgroup's reads moving while this one decodes, so the storage's response + /// time overlaps decoding instead of being paid again after it. + startSubgroupReadAhead(row_group_idx, row_subgroup_idx, diff); return; } case ReadStage::Deallocated: @@ -483,7 +525,10 @@ void ReadManager::finishRowSubgroupStage(size_t row_group_idx, size_t row_subgro while (main_ptr < row_group.subgroups.size()) { RowSubgroup & next_subgroup = row_group.subgroups[main_ptr]; - ReadStage next_subgroup_stage = next_subgroup.stage.load(); + /// Only a subgroup nobody has started may be admitted. The CAS below takes its expected value + /// from the load, so it succeeds whatever the current stage is - without this check a subgroup + /// already admitted by read-ahead would be admitted a second time. + ReadStage next_subgroup_stage = ReadStage::NotStarted; if (!next_subgroup.stage.compare_exchange_strong( next_subgroup_stage, ReadStage::OffsetIndex)) break; @@ -750,7 +795,35 @@ void ReadManager::scheduleTask(Task task, bool is_first_in_group, MemoryUsageDif std::vector prefetches; RowGroup & row_group = reader.row_groups[task.row_group_idx]; ssize_t memory_before = diff.by_stage[size_t(diff.cur_stage)]; - if (task.column_idx != UINT64_MAX) + + if (task.stage == ReadStage::ColumnDataPrefetch) + { + /// Queue every column's data-page reads for this subgroup. Charged to the ColumnDataPrefetch + /// budget, which is separate from the decode budget and bounds how far read-ahead may run. + RowSubgroup & row_subgroup = row_group.subgroups.at(task.row_subgroup_idx); + if (row_subgroup.filter.rows_pass > 0) + { + for (size_t i = 0; i < reader.primitive_columns.size(); ++i) + { + if (reader.primitive_columns[i].first_step_to_calculate != task.step_idx) + continue; + ColumnChunk & column = row_group.columns.at(i); + reader.determinePagesToPrefetch(column, row_subgroup, row_group, prefetches); + + /// Side note: would be nice to avoid reading the dictionary if all dictionary-encoded + /// pages were filtered out (e.g. if it's a 100 MB column chunk with unique long strings, + /// typically only the first ~1 MB would be dictionary-encoded; if we only need a few + /// rows, we likely won't hit that 1 MB). But AFAICT parquet metadata doesn't have + /// enough information for that (there's no page encoding in offset/column indexes). + if (!column.dictionary.isInitialized() && column.dictionary_page_prefetch) + prefetches.push_back(&column.dictionary_page_prefetch); + + if (column.data_pages.empty()) + prefetches.push_back(&column.data_pages_prefetch); + } + } + } + else if (task.column_idx != UINT64_MAX) { ColumnChunk & column = row_group.columns.at(task.column_idx); switch (task.stage) @@ -773,32 +846,6 @@ void ReadManager::scheduleTask(Task task, bool is_first_in_group, MemoryUsageDif case ReadStage::OffsetIndex: prefetches.push_back(&column.offset_index_prefetch); break; - case ReadStage::ColumnDataPrefetch: - { - RowSubgroup & row_subgroup = row_group.subgroups.at(task.row_subgroup_idx); - if (row_subgroup.filter.rows_pass == 0) - break; - /// Queue this subgroup's data-page reads; startPrefetch (below) issues them and charges - /// compressed bytes to the ColumnDataPrefetch budget, separate from the decode budget, - /// so many row groups prefetch ahead while only a few decode at once. - reader.determinePagesToPrefetch(column, row_subgroup, row_group, prefetches); - - /// Side note: would be nice to avoid reading the dictionary if all dictionary-encoded - /// pages were filtered out (e.g. if it's a 100 MB column chunk with unique long strings, - /// typically only the first ~1 MB would be dictionary-encoded; if we only need a few - /// rows, we likely won't hit that 1 MB). But AFAICT parquet metadata doesn't have - /// enough information for that (there's no page encoding in offset/column indexes). - if (!column.dictionary.isInitialized() && column.dictionary_page_prefetch) - { - prefetches.push_back(&column.dictionary_page_prefetch); - } - - if (column.data_pages.empty()) - { - prefetches.push_back(&column.data_pages_prefetch); - } - break; - } case ReadStage::ColumnData: { RowSubgroup & row_subgroup = row_group.subgroups.at(task.row_subgroup_idx); @@ -812,6 +859,7 @@ void ReadManager::scheduleTask(Task task, bool is_first_in_group, MemoryUsageDif subchunk.column_and_offsets_memory = MemoryUsageToken(column_memory, &diff); break; } + case ReadStage::ColumnDataPrefetch: // handled above, for the whole subgroup at once case ReadStage::NotStarted: case ReadStage::Deliver: case ReadStage::Deallocated: @@ -831,9 +879,8 @@ void ReadManager::scheduleTask(Task task, bool is_first_in_group, MemoryUsageDif reader.prefetcher.startPrefetch(prefetches, &diff); /// Group tiny tasks to reduce scheduling overhead, using predicted memory as a proxy for run time. - /// Exception: ColumnDataPrefetch does its work (startPrefetch) here and has an empty runTask, so - /// its run time is ~0 no matter how many compressed bytes it charges; report cost 0 so these tasks - /// collapse into one batch instead of being split across many no-op thread-pool dispatches. + /// ColumnDataPrefetch is the exception: its work happened above and its runTask is empty, so the + /// compressed bytes it charges say nothing about how long it takes to run. ssize_t memory_after = diff.by_stage[size_t(diff.cur_stage)]; task.cost_estimate_bytes = task.stage == ReadStage::ColumnDataPrefetch ? 0 diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.h b/src/Processors/Formats/Impl/Parquet/ReadManager.h index e2ba32213676..9d9c7e91764b 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.h +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.h @@ -36,6 +36,11 @@ class ReadManager /// See input_format_parquet_max_active_files. Progress never depends on holding a slot. std::atomic holds_prefetch_slot {false}; + /// Admits the subgroup after `current_subgroup_idx` so its reads are issued while its predecessor + /// decodes. Opportunistic: does nothing when read-ahead is off, the subgroup is filtered out, or + /// another thread already started it. See input_format_parquet_read_ahead_subgroups. + void startSubgroupReadAhead(size_t row_group_idx, size_t current_subgroup_idx, MemoryUsageDiff & diff); + /// To initialize ReadManager: /// 1. call manager.reader.prefetcher.init /// 2. call manager.reader.init From c0ffe68147bd804fe20f76991ddb192973f34a35 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 27 Aug 2026 16:54:56 +0300 Subject: [PATCH 12/14] Parquet: issue read-ahead from the previous subgroup's prefetch task instead of admitting the next subgroup early The previous read-ahead admitted subgroup N+1 into the stage machine while N was still decoding. `finishRowSubgroupStage` then issued N+1's `ColumnData` tasks as soon as its `ColumnDataPrefetch` finished, so two subgroups of one row group decoded concurrently. `Reader::ColumnChunk` has a single sequential page cursor (`page`, `next_page_offset`, `data_pages_idx`) and a lazily initialised dictionary shared by all subgroups of the row group, so this corrupted decoding; it also collided on the one-slot-per-(stage, row group) task queue in `setTasksToSchedule` and could regress `read_ptr`, calling `clearColumnChunk` while a subgroup was still being decoded. Now the `ColumnDataPrefetch` task of subgroup N also issues the first-step data-page reads of subgroups N+1..N+k. Nothing about admission or decode order changes: the next subgroup is admitted by the normal path once N's main step is done, and skips its own `ColumnDataPrefetch` stage (`reads_issued_ahead`) because there is nothing left to issue. This is idempotent by construction: `determinePagesToPrefetch` advances `data_pages_prefetch_idx` past the pages it handed out, and `startPrefetch` skips handles that already have a task. Own reads are issued before read-ahead reads so N keeps priority in coalescing. `input_format_parquet_read_ahead_subgroups` values above 1 now work. Read-ahead bytes are charged to a new accounting-only stage `ColumnDataReadAhead` with its own share of the prefetch budget (`input_format_parquet_read_ahead_memory_fraction`, default 0.25 of the prefetch share), so read-ahead cannot eat the `ColumnDataPrefetch` budget that keeps other row groups moving. `flushMemoryUsageDiff` skips scheduling on it. New profile event `ParquetReadAheadSubgroups`. Test `04813_parquet_read_ahead_subgroups` reads a file with many subgroups per row group and several pages per subgroup under a low decode watermark, with read-ahead 0/1/3, filtered and single-threaded, and checks the profile event. Co-Authored-By: Claude Fable 5 Signed-off-by: UnamedRus --- src/Common/ProfileEvents.cpp | 1 + src/Core/FormatFactorySettings.h | 21 ++-- src/Core/SettingsChangesHistory.cpp | 3 +- src/Formats/FormatFactory.cpp | 1 + src/Formats/FormatSettings.h | 2 + .../Formats/Impl/Parquet/ReadCommon.h | 4 + .../Formats/Impl/Parquet/ReadManager.cpp | 112 ++++++++++++------ .../Formats/Impl/Parquet/ReadManager.h | 6 +- src/Processors/Formats/Impl/Parquet/Reader.h | 5 + ...813_parquet_read_ahead_subgroups.reference | 20 ++++ .../04813_parquet_read_ahead_subgroups.sh | 57 +++++++++ 11 files changed, 185 insertions(+), 47 deletions(-) create mode 100644 tests/queries/0_stateless/04813_parquet_read_ahead_subgroups.reference create mode 100755 tests/queries/0_stateless/04813_parquet_read_ahead_subgroups.sh diff --git a/src/Common/ProfileEvents.cpp b/src/Common/ProfileEvents.cpp index cc7106ff3113..fc8c5635f6e1 100644 --- a/src/Common/ProfileEvents.cpp +++ b/src/Common/ProfileEvents.cpp @@ -1482,6 +1482,7 @@ The server successfully detected this situation and will download merged part fr M(ParquetDecodingTaskBatches, "Task groups sent to a thread pool by parquet reader", ValueType::Number) \ M(ParquetReadTasks, "Coalesced read tasks created by the Parquet reader. Divided by `ParquetReadRowGroups`, values below 1 mean one read spans several row groups, which serializes their delivery", ValueType::Number) \ M(ParquetReadTaskBytes, "Bytes covered by `ParquetReadTasks`, including bytes read to close short gaps between requested ranges", ValueType::Bytes) \ + M(ParquetReadAheadSubgroups, "Row subgroups whose data-page reads the Parquet reader issued ahead, while the previous subgroup of the row group was still decoding (see `input_format_parquet_read_ahead_subgroups`)", ValueType::Number) \ M(ParquetPrefetchStarvation, "Times a decoding thread asked for a range whose read had not finished. High relative to `ParquetReadTasks` means read-ahead is too shallow", ValueType::Number) \ M(ParquetPrefetcherReadRandomRead, "The total number of reads with ReadMode::RandomRead by DB::Parquet::Prefetcher", ValueType::Number) \ M(ParquetPrefetcherReadSeekAndRead, "The total number of reads with ReadMode::SeekAndRead by DB::Parquet::Prefetcher", ValueType::Number) \ diff --git a/src/Core/FormatFactorySettings.h b/src/Core/FormatFactorySettings.h index 5231bf6ad839..3cbd383c8d46 100644 --- a/src/Core/FormatFactorySettings.h +++ b/src/Core/FormatFactorySettings.h @@ -272,13 +272,20 @@ All files share one IO pool, so with many files each gets too few reads in fligh early. Files that do not hold a slot still read the row group they must deliver next. )", 0) \ DECLARE(UInt64, input_format_parquet_read_ahead_subgroups, 0, R"( -How many row subgroups ahead the Parquet reader may issue reads for. `0` keeps the previous behaviour, -where a subgroup's reads are issued only after its predecessor has been decoded and delivered, so the -reader waits out the storage's response time on every subgroup. `1` issues the next subgroup's reads -while the current one decodes. Values above `1` are not supported yet and are treated as `1`. - -Read-ahead is opportunistic: it is skipped when the compressed read-ahead budget -(`input_format_parquet_prefetch_memory_fraction`) is already used up. +How many row subgroups ahead the Parquet reader may issue data-page reads for within a row group. `0` +keeps the previous behaviour, where a subgroup's reads are issued only after its predecessor has been +decoded, so the reader waits out the storage's response time on every subgroup. `1` issues the next +subgroup's reads while the current one decodes. Subgroups are still decoded in order. + +Read-ahead is opportunistic: it stops when its memory budget +(`input_format_parquet_read_ahead_memory_fraction`) is used up. Only helps for files whose row groups +are split into several subgroups (see `input_format_parquet_max_block_size`) and that have an offset +index; otherwise a row group's data is already read as one range. +)", 0) \ + DECLARE(Double, input_format_parquet_read_ahead_memory_fraction, 0.25, R"( +Share of the Parquet reader's prefetch memory budget (`input_format_parquet_prefetch_memory_fraction`) +reserved for data pages read ahead within a row group (`input_format_parquet_read_ahead_subgroups`). +Range `[0, 1]`. The rest of the prefetch budget keeps other row groups reading ahead. )", 0) \ DECLARE(UInt64, input_format_parquet_bytes_per_read_task, 0, R"( Target size of a single read issued by the Parquet reader; nearby column chunks are coalesced up to diff --git a/src/Core/SettingsChangesHistory.cpp b/src/Core/SettingsChangesHistory.cpp index b27dadfe14a0..6f8efb878299 100644 --- a/src/Core/SettingsChangesHistory.cpp +++ b/src/Core/SettingsChangesHistory.cpp @@ -46,7 +46,8 @@ const VersionToSettingsChangesMap & getSettingsChangesHistory() {"input_format_parquet_constant_column_sparse_ratio", 1.0, 0.9375, "New setting: a Parquet column chunk holding a single value plus nulls is materialized as a sparse column when the fraction of nulls is at least this ratio (reader v3)."}, {"input_format_parquet_max_io_threads", 0, 0, "New setting: size of the thread pool that issues reads for the Parquet reader. 0 derives it from `max_download_threads` and `max_parsing_threads`; the derived value is larger than the previous hard-coded `max_download_threads`, which defaults to 4 and was chosen for the URL engine."}, {"input_format_parquet_bytes_per_read_task", 0, 0, "New setting: target size of a single read issued by the Parquet reader. 0 derives it from the min-bytes-for-seek of the underlying storage."}, - {"input_format_parquet_read_ahead_subgroups", 0, 0, "New setting: how many row subgroups ahead the Parquet reader may issue reads for. 0 keeps the previous behavior of issuing a subgroup's reads only after its predecessor was decoded and delivered."}, + {"input_format_parquet_read_ahead_subgroups", 0, 0, "New setting: how many row subgroups ahead the Parquet reader may issue data-page reads for within a row group. 0 keeps the previous behavior of issuing a subgroup's reads only after its predecessor was decoded."}, + {"input_format_parquet_read_ahead_memory_fraction", 0.25, 0.25, "New setting: share of the Parquet prefetch memory budget reserved for data pages read ahead within a row group."}, {"input_format_parquet_max_active_files", 0, 0, "New setting: how many Parquet files may read ahead at the same time when a query reads many of them. 0 means no limit, which is the previous behavior."}, }); diff --git a/src/Formats/FormatFactory.cpp b/src/Formats/FormatFactory.cpp index acfe0a774c7d..3c6f2854372b 100644 --- a/src/Formats/FormatFactory.cpp +++ b/src/Formats/FormatFactory.cpp @@ -254,6 +254,7 @@ FormatSettings getFormatSettings(const ContextPtr & context, const Settings & se format_settings.parquet.bytes_per_read_task = settings[Setting::input_format_parquet_bytes_per_read_task]; format_settings.parquet.max_active_files = settings[Setting::input_format_parquet_max_active_files]; format_settings.parquet.read_ahead_subgroups = settings[Setting::input_format_parquet_read_ahead_subgroups]; + format_settings.parquet.read_ahead_memory_fraction = settings[Setting::input_format_parquet_read_ahead_memory_fraction]; format_settings.parquet.enable_row_group_prefetch = settings[Setting::input_format_parquet_enable_row_group_prefetch]; format_settings.parquet.verify_checksums = settings[Setting::input_format_parquet_verify_checksums]; format_settings.parquet.local_time_as_utc = settings[Setting::input_format_parquet_local_time_as_utc]; diff --git a/src/Formats/FormatSettings.h b/src/Formats/FormatSettings.h index 2ad6e9c05459..fa25006eb167 100644 --- a/src/Formats/FormatSettings.h +++ b/src/Formats/FormatSettings.h @@ -369,6 +369,8 @@ struct FormatSettings size_t max_active_files = 0; /// 0 = issue a subgroup's reads only after its predecessor finished. size_t read_ahead_subgroups = 0; + /// Share of the prefetch memory budget reserved for read-ahead within a row group. + double read_ahead_memory_fraction = 0.25; size_t memory_low_watermark = 2ul << 20; size_t memory_high_watermark = 4ul << 30; /// Reader scheduler knobs: share of the column-data memory budget given to compressed diff --git a/src/Processors/Formats/Impl/Parquet/ReadCommon.h b/src/Processors/Formats/Impl/Parquet/ReadCommon.h index cbaf7f095ec6..4701af2432b7 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadCommon.h +++ b/src/Processors/Formats/Impl/Parquet/ReadCommon.h @@ -92,6 +92,10 @@ enum class ReadStage /// row groups prefetch ahead while only a few decode at once. Decouples fetch from decode depth. ColumnDataPrefetch, ColumnData, + /// Accounting only, never has tasks: compressed data pages issued ahead for later row subgroups + /// of a row group (see input_format_parquet_read_ahead_subgroups). Gets its own memory budget so + /// read-ahead cannot eat the ColumnDataPrefetch share that keeps other row groups moving. + ColumnDataReadAhead, Deliver, diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp index 6c54c82aaeb7..adf309e6f52a 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp @@ -26,6 +26,7 @@ namespace ProfileEvents { extern const Event ParquetDecodingTasks; extern const Event ParquetDecodingTaskBatches; + extern const Event ParquetReadAheadSubgroups; extern const Event ParquetReadRowGroups; extern const Event ParquetPrunedRowGroups; } @@ -82,10 +83,14 @@ void ReadManager::init(FormatParserSharedResourcesPtr parser_shared_resources_, /// prefetch_memory_fraction splits the 0.75 data-memory budget prefetch/decode; decode_thread_fraction /// is decode's thread share (issuers split the rest). Defaults preserve the old hard-coded fractions. const double prefetch_memory_fraction = reader.options.format.parquet.prefetch_memory_fraction; + const double read_ahead_memory_fraction = reader.options.format.parquet.read_ahead_memory_fraction; const double decode_thread_fraction = reader.options.format.parquet.decode_thread_fraction; if (!(prefetch_memory_fraction >= 0 && prefetch_memory_fraction <= 1)) throw Exception(ErrorCodes::BAD_ARGUMENTS, "input_format_parquet_prefetch_memory_fraction must be in [0, 1], got {}", prefetch_memory_fraction); + if (!(read_ahead_memory_fraction >= 0 && read_ahead_memory_fraction <= 1)) + throw Exception(ErrorCodes::BAD_ARGUMENTS, + "input_format_parquet_read_ahead_memory_fraction must be in [0, 1], got {}", read_ahead_memory_fraction); if (!(decode_thread_fraction >= 0 && decode_thread_fraction <= 1)) throw Exception(ErrorCodes::BAD_ARGUMENTS, "input_format_parquet_decode_thread_fraction must be in [0, 1], got {}", decode_thread_fraction); @@ -102,7 +107,10 @@ void ReadManager::init(FormatParserSharedResourcesPtr parser_shared_resources_, set_fractions(ReadStage::BloomFilterBlocksOrDictionary, 0.10, issuer_thread_fraction); set_fractions(ReadStage::ColumnIndexAndOffsetIndex, 0.05, issuer_thread_fraction); set_fractions(ReadStage::OffsetIndex, 0.05, issuer_thread_fraction); - set_fractions(ReadStage::ColumnDataPrefetch, data_memory_fraction * prefetch_memory_fraction, issuer_thread_fraction); + /// Read-ahead is carved out of the prefetch share, so enabling it doesn't change the decode budget. + const double prefetch_memory = data_memory_fraction * prefetch_memory_fraction; + set_fractions(ReadStage::ColumnDataPrefetch, prefetch_memory * (1.0 - read_ahead_memory_fraction), issuer_thread_fraction); + set_fractions(ReadStage::ColumnDataReadAhead, prefetch_memory * read_ahead_memory_fraction, 0); set_fractions(ReadStage::ColumnData, data_memory_fraction * (1.0 - prefetch_memory_fraction), decode_thread_fraction); set_fractions(ReadStage::Deliver, 0, 0); @@ -177,6 +185,7 @@ void ReadManager::finishRowGroupStage(size_t row_group_idx, ReadStage stage, Mem case ReadStage::NotStarted: case ReadStage::ColumnDataPrefetch: case ReadStage::ColumnData: + case ReadStage::ColumnDataReadAhead: case ReadStage::Deliver: chassert(false); break; @@ -342,6 +351,15 @@ void ReadManager::addTasksToReadColumns(size_t row_group_idx, size_t row_subgrou } } + if (stage == ReadStage::ColumnDataPrefetch && row_subgroup.reads_issued_ahead && step_idx == firstStep()) + { + /// The previous subgroup's ColumnDataPrefetch already issued this subgroup's first-step + /// reads (read-ahead); nothing left to issue, go straight to decoding. + chassert(add_tasks.empty()); + stage = ReadStage::ColumnData; + continue; + } + if (stage == ReadStage::ColumnDataPrefetch) { /// Issuing this subgroup's reads is one unit of work for the whole subgroup, not one per @@ -384,30 +402,10 @@ void ReadManager::addTasksToReadColumns(size_t row_group_idx, size_t row_subgrou } } -void ReadManager::startSubgroupReadAhead(size_t row_group_idx, size_t current_subgroup_idx, MemoryUsageDiff & diff) +size_t ReadManager::firstStep() const { - if (reader.options.format.parquet.read_ahead_subgroups == 0) - return; - - RowGroup & row_group = reader.row_groups[row_group_idx]; - size_t next_idx = current_subgroup_idx + 1; - if (next_idx >= row_group.subgroups.size()) - return; - - RowSubgroup & next_subgroup = row_group.subgroups[next_idx]; - /// Leave filtered-out subgroups to the normal path: it also deallocates them, and - /// determinePagesToPrefetch requires rows_pass > 0. - if (next_subgroup.filter.rows_pass == 0) - return; - - /// Only claim a subgroup nobody has started. Losing this race is fine - it means the normal path - /// got there first. - ReadStage expected = ReadStage::NotStarted; - if (!next_subgroup.stage.compare_exchange_strong(expected, ReadStage::OffsetIndex)) - return; - - size_t first_step = reader.steps.empty() ? 0 : 1; - addTasksToReadColumns(row_group_idx, next_idx, ReadStage::OffsetIndex, first_step, diff); + /// 1 if there are prewhere steps, 0 otherwise. + return reader.steps.empty() ? 0 : 1; } void ReadManager::finishRowSubgroupStage(size_t row_group_idx, size_t row_subgroup_idx, ReadStage stage, size_t step_idx, MemoryUsageDiff & diff) @@ -424,8 +422,7 @@ void ReadManager::finishRowSubgroupStage(size_t row_group_idx, size_t row_subgro { case ReadStage::NotStarted: { - /// 1 if there are prewhere steps, 0 otherwise - size_t first_step = reader.steps.empty() ? 0 : 1; + size_t first_step = firstStep(); if (first_step < reader.steps.size() + 1) { addTasksToReadColumns(row_group_idx, row_subgroup_idx, ReadStage::OffsetIndex, first_step, diff); @@ -505,11 +502,9 @@ void ReadManager::finishRowSubgroupStage(size_t row_group_idx, size_t row_subgro { /// Data-page reads issued (in flight in the Prefetcher's io pool); now decode. addTasksToReadColumns(row_group_idx, row_subgroup_idx, ReadStage::ColumnData, step_idx, diff); - /// Get the next subgroup's reads moving while this one decodes, so the storage's response - /// time overlaps decoding instead of being paid again after it. - startSubgroupReadAhead(row_group_idx, row_subgroup_idx, diff); return; } + case ReadStage::ColumnDataReadAhead: case ReadStage::Deallocated: chassert(false); break; @@ -525,9 +520,7 @@ void ReadManager::finishRowSubgroupStage(size_t row_group_idx, size_t row_subgro while (main_ptr < row_group.subgroups.size()) { RowSubgroup & next_subgroup = row_group.subgroups[main_ptr]; - /// Only a subgroup nobody has started may be admitted. The CAS below takes its expected value - /// from the load, so it succeeds whatever the current stage is - without this check a subgroup - /// already admitted by read-ahead would be admitted a second time. + /// Only a subgroup nobody has started may be admitted. ReadStage next_subgroup_stage = ReadStage::NotStarted; if (!next_subgroup.stage.compare_exchange_strong( next_subgroup_stage, ReadStage::OffsetIndex)) @@ -535,8 +528,7 @@ void ReadManager::finishRowSubgroupStage(size_t row_group_idx, size_t row_subgro if (next_subgroup.filter.rows_pass > 0) { - size_t first_step = reader.steps.empty() ? 0 : 1; - addTasksToReadColumns(row_group_idx, main_ptr, ReadStage::OffsetIndex, first_step, diff); + addTasksToReadColumns(row_group_idx, main_ptr, ReadStage::OffsetIndex, firstStep(), diff); break; } else @@ -630,6 +622,10 @@ void ReadManager::flushMemoryUsageDiff(MemoryUsageDiff && diff) stages[i].memory_usage.fetch_add(d, std::memory_order_relaxed); } + /// Accounting only; nothing is ever scheduled on it. + if (i == size_t(ReadStage::ColumnDataReadAhead)) + continue; + bool should_schedule = (diff.stages_to_schedule & (1ul << i)) != 0; if (!should_schedule && d < 0) { @@ -799,7 +795,7 @@ void ReadManager::scheduleTask(Task task, bool is_first_in_group, MemoryUsageDif if (task.stage == ReadStage::ColumnDataPrefetch) { /// Queue every column's data-page reads for this subgroup. Charged to the ColumnDataPrefetch - /// budget, which is separate from the decode budget and bounds how far read-ahead may run. + /// budget, which is separate from the decode budget and bounds how many row groups prefetch. RowSubgroup & row_subgroup = row_group.subgroups.at(task.row_subgroup_idx); if (row_subgroup.filter.rows_pass > 0) { @@ -860,6 +856,7 @@ void ReadManager::scheduleTask(Task task, bool is_first_in_group, MemoryUsageDif break; } case ReadStage::ColumnDataPrefetch: // handled above, for the whole subgroup at once + case ReadStage::ColumnDataReadAhead: case ReadStage::NotStarted: case ReadStage::Deliver: case ReadStage::Deallocated: @@ -878,6 +875,50 @@ void ReadManager::scheduleTask(Task task, bool is_first_in_group, MemoryUsageDif reader.prefetcher.startPrefetch(prefetches, &diff); + /// Read-ahead: also issue the next subgroups' first-step reads now, so the storage's + /// response time overlaps this subgroup's decoding instead of being paid again after it. + /// Subgroups of a row group are decoded strictly in order and share one page cursor per + /// column chunk, so we only issue reads here; the next subgroup is admitted and decoded by + /// the normal path (finishRowSubgroupStage), which then skips its own ColumnDataPrefetch + /// stage (`reads_issued_ahead`). Idempotent by construction: determinePagesToPrefetch + /// advances `data_pages_prefetch_idx` past the pages it handed out, and startPrefetch + /// skips handles that already have a task. Charged to the ColumnDataReadAhead budget. + /// Without an offset index there is one range per column chunk and this is a no-op. + if (task.stage == ReadStage::ColumnDataPrefetch && task.step_idx == firstStep() + && row_group.subgroups.at(task.row_subgroup_idx).filter.rows_pass > 0) + { + std::vector read_ahead_prefetches; + const Stage & read_ahead_stage = stages[size_t(ReadStage::ColumnDataReadAhead)]; + const auto read_ahead_limits = SharedResourcesExt::getLimitsPerReader( + *parser_shared_resources, read_ahead_stage.memory_target_fraction, /*thread_fraction=*/ 0); + const size_t max_ahead = reader.options.format.parquet.read_ahead_subgroups; + for (size_t k = 1; k <= max_ahead && task.row_subgroup_idx + k < row_group.subgroups.size(); ++k) + { + size_t read_ahead_memory = read_ahead_stage.memory_usage.load(std::memory_order_relaxed) + + size_t(std::max(0, diff.by_stage[size_t(ReadStage::ColumnDataReadAhead)])); + if (read_ahead_memory >= read_ahead_limits.memory_high_watermark) + break; // read-ahead budget used up + + RowSubgroup & next_subgroup = row_group.subgroups[task.row_subgroup_idx + k]; + if (next_subgroup.filter.rows_pass == 0) + continue; // skipped by the normal path, nothing to read + for (size_t i = 0; i < reader.primitive_columns.size(); ++i) + { + if (reader.primitive_columns[i].first_step_to_calculate != task.step_idx) + continue; + reader.determinePagesToPrefetch(row_group.columns.at(i), next_subgroup, row_group, read_ahead_prefetches); + } + next_subgroup.reads_issued_ahead = true; + + /// Charge as we go so the budget check above sees this subgroup's bytes. + const ReadStage saved_stage = std::exchange(diff.cur_stage, ReadStage::ColumnDataReadAhead); + reader.prefetcher.startPrefetch(read_ahead_prefetches, &diff); + diff.cur_stage = saved_stage; + ProfileEvents::increment(ProfileEvents::ParquetReadAheadSubgroups); + read_ahead_prefetches.clear(); + } + } + /// Group tiny tasks to reduce scheduling overhead, using predicted memory as a proxy for run time. /// ColumnDataPrefetch is the exception: its work happened above and its runTask is empty, so the /// compressed bytes it charges say nothing about how long it takes to run. @@ -989,6 +1030,7 @@ void ReadManager::runTask(Task task, bool last_in_batch, MemoryUsageDiff & diff) break; } case ReadStage::NotStarted: + case ReadStage::ColumnDataReadAhead: case ReadStage::Deliver: case ReadStage::Deallocated: chassert(false); diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.h b/src/Processors/Formats/Impl/Parquet/ReadManager.h index 9d9c7e91764b..f31c7dc623d4 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.h +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.h @@ -36,10 +36,8 @@ class ReadManager /// See input_format_parquet_max_active_files. Progress never depends on holding a slot. std::atomic holds_prefetch_slot {false}; - /// Admits the subgroup after `current_subgroup_idx` so its reads are issued while its predecessor - /// decodes. Opportunistic: does nothing when read-ahead is off, the subgroup is filtered out, or - /// another thread already started it. See input_format_parquet_read_ahead_subgroups. - void startSubgroupReadAhead(size_t row_group_idx, size_t current_subgroup_idx, MemoryUsageDiff & diff); + /// Index of the first PREWHERE step (1), or 0 when there are no steps. + size_t firstStep() const; /// To initialize ReadManager: /// 1. call manager.reader.prefetcher.init diff --git a/src/Processors/Formats/Impl/Parquet/Reader.h b/src/Processors/Formats/Impl/Parquet/Reader.h index 568ec0f6724e..5a841f6a9a25 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.h +++ b/src/Processors/Formats/Impl/Parquet/Reader.h @@ -464,6 +464,11 @@ struct Reader std::atomic stage {ReadStage::NotStarted}; std::atomic stage_tasks_remaining {0}; + + /// Set when the previous subgroup's ColumnDataPrefetch task also issued this subgroup's + /// first-step data-page reads (read-ahead), so this subgroup skips its own ColumnDataPrefetch + /// stage. Written before this subgroup is admitted (its `stage` CAS orders the read). + bool reads_issued_ahead = false; }; struct RowGroup diff --git a/tests/queries/0_stateless/04813_parquet_read_ahead_subgroups.reference b/tests/queries/0_stateless/04813_parquet_read_ahead_subgroups.reference new file mode 100644 index 000000000000..efc23e6ff9f0 --- /dev/null +++ b/tests/queries/0_stateless/04813_parquet_read_ahead_subgroups.reference @@ -0,0 +1,20 @@ +-- read_ahead_subgroups = 0 +300000 44999850000 149850000 13518539653315467104 9900000 100000 +20000 2998470000 268166841057690648 990000 +20000 2998470000 268166841057690648 990000 +-- read_ahead_subgroups = 1 +300000 44999850000 149850000 13518539653315467104 9900000 100000 +20000 2998470000 268166841057690648 990000 +20000 2998470000 268166841057690648 990000 +-- read_ahead_subgroups = 3 +300000 44999850000 149850000 13518539653315467104 9900000 100000 +20000 2998470000 268166841057690648 990000 +20000 2998470000 268166841057690648 990000 +-- read_ahead_memory_fraction = 0 disables read-ahead even when subgroups > 0 +300000 44999850000 149850000 13518539653315467104 9900000 100000 +-- read-ahead fired only when enabled and budgeted (ParquetReadAheadSubgroups > 0) +filtered_1 1 +full_0 0 +full_1 1 +full_3 1 +nobudget 0 diff --git a/tests/queries/0_stateless/04813_parquet_read_ahead_subgroups.sh b/tests/queries/0_stateless/04813_parquet_read_ahead_subgroups.sh new file mode 100755 index 000000000000..2eb119eeac9d --- /dev/null +++ b/tests/queries/0_stateless/04813_parquet_read_ahead_subgroups.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Tags: no-fasttest + +CUR_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=../shell_config.sh +. "$CUR_DIR"/../shell_config.sh + +USER_FILES_PATH=$(${CLICKHOUSE_CLIENT} -q "SELECT value FROM system.server_settings WHERE name = 'user_files_path'" | sed 's|/$||') +WORKING_DIR="${USER_FILES_PATH}/${CLICKHOUSE_TEST_UNIQUE_NAME}" +mkdir -p "${WORKING_DIR}" +DATA_FILE="${WORKING_DIR}/ra.parquet" + +# 3 row groups of 100k rows, small data pages and a page index, so that each row group is read as many +# subgroups (input_format_parquet_max_block_size below) with several pages per subgroup and column. +${CLICKHOUSE_CLIENT} -q " + INSERT INTO FUNCTION file('${DATA_FILE}', Parquet) + SELECT + number AS k, + number * 7 % 1000 AS v, + toString(number % 5000) AS s, + if(number % 3 = 0, NULL, number % 100)::Nullable(UInt8) AS n + FROM numbers(300000) + SETTINGS engine_file_truncate_on_insert = 1, output_format_parquet_row_group_size = 100000, + output_format_parquet_data_page_size = 8192, output_format_parquet_write_page_index = 1 +" + +STRUCTURE="k UInt64, v UInt64, s String, n Nullable(UInt8)" +# Small subgroups and a low decode watermark, so the scheduler throttles non-first row groups and +# subgroups queue up behind each other (the situation read-ahead is for). +COMMON="input_format_parquet_max_block_size = 4096, input_format_parquet_prefer_block_bytes = 0, input_format_parquet_memory_high_watermark = 4194304, input_format_parquet_memory_low_watermark = 1048576" + +FULL="SELECT count(), sum(k), sum(v), sum(cityHash64(s)), sum(n), countIf(n IS NULL) FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}')" +FILTERED="SELECT count(), sum(k), sum(cityHash64(s)), sum(n) FROM file('${DATA_FILE}', Parquet, '${STRUCTURE}') WHERE v < 100 AND n IS NOT NULL" + +for ahead in 0 1 3; do + echo "-- read_ahead_subgroups = ${ahead}" + ${CLICKHOUSE_CLIENT} --query_id="${CLICKHOUSE_TEST_UNIQUE_NAME}_full_${ahead}" -q "${FULL} SETTINGS ${COMMON}, input_format_parquet_read_ahead_subgroups = ${ahead}" + ${CLICKHOUSE_CLIENT} --query_id="${CLICKHOUSE_TEST_UNIQUE_NAME}_filtered_${ahead}" -q "${FILTERED} SETTINGS ${COMMON}, input_format_parquet_read_ahead_subgroups = ${ahead}" + # Single-threaded parsing exercises the same-thread scheduling path. + ${CLICKHOUSE_CLIENT} -q "${FILTERED} SETTINGS ${COMMON}, input_format_parquet_read_ahead_subgroups = ${ahead}, max_parsing_threads = 1, max_threads = 1" +done + +echo "-- read_ahead_memory_fraction = 0 disables read-ahead even when subgroups > 0" +${CLICKHOUSE_CLIENT} --query_id="${CLICKHOUSE_TEST_UNIQUE_NAME}_nobudget" -q "${FULL} SETTINGS ${COMMON}, input_format_parquet_read_ahead_subgroups = 1, input_format_parquet_read_ahead_memory_fraction = 0" + +echo "-- read-ahead fired only when enabled and budgeted (ParquetReadAheadSubgroups > 0)" +${CLICKHOUSE_CLIENT} -q " + SYSTEM FLUSH LOGS query_log; + SELECT replaceOne(query_id, '${CLICKHOUSE_TEST_UNIQUE_NAME}_', ''), ProfileEvents['ParquetReadAheadSubgroups'] > 0 + FROM system.query_log + WHERE event_date >= yesterday() AND event_time >= now() - 600 AND type = 'QueryFinish' + AND current_database = currentDatabase() + AND query_id IN ('${CLICKHOUSE_TEST_UNIQUE_NAME}_full_0', '${CLICKHOUSE_TEST_UNIQUE_NAME}_full_1', '${CLICKHOUSE_TEST_UNIQUE_NAME}_full_3', '${CLICKHOUSE_TEST_UNIQUE_NAME}_filtered_1', '${CLICKHOUSE_TEST_UNIQUE_NAME}_nobudget') + ORDER BY 1 +" + +rm -rf "${WORKING_DIR}" From 062538f39fe8d750f3a2d72a380dad86983b64d9 Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 27 Aug 2026 19:36:28 +0300 Subject: [PATCH 13/14] Parquet: fix deadlock when PREWHERE drops every row of a subgroup `finishRowSubgroupStage` handles a subgroup whose `rows_pass` became 0 after `applyPrewhere` by breaking out of the `ColumnData` case without advancing `read_ptr`, and relying on the "start next subgroup" loop below to revisit the current subgroup, deallocate it and move on. That loop's compare-exchange took its expected value from `stage.load()`, so it always succeeded. The read-path change replaced the expected value with `NotStarted` to keep read-ahead from admitting a subgroup twice; for the fully-filtered current subgroup the exchange now failed, nothing advanced, and `read` hit the deadlock detector: `Logical error: Deadlock in Parquet::ReadManager (thread pool)`. Read-ahead no longer admits subgroups early, so the original expectation is restored. Only one subgroup of a row group is in progress at a time, so the exchange cannot hit anything else. Failed in `03596_parquet_prewhere_page_skip_bug` (server abort): https://altinity-build-artifacts.s3.amazonaws.com/json.html?PR=2275&sha=0df5ce6be0296b179730e6b422e9dddc9751abd4&name_0=PR PR: https://github.com/Altinity/ClickHouse/pull/2275 Co-Authored-By: Claude Fable 5 Signed-off-by: UnamedRus --- src/Processors/Formats/Impl/Parquet/ReadManager.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp index adf309e6f52a..84e20822f2ca 100644 --- a/src/Processors/Formats/Impl/Parquet/ReadManager.cpp +++ b/src/Processors/Formats/Impl/Parquet/ReadManager.cpp @@ -520,8 +520,12 @@ void ReadManager::finishRowSubgroupStage(size_t row_group_idx, size_t row_subgro while (main_ptr < row_group.subgroups.size()) { RowSubgroup & next_subgroup = row_group.subgroups[main_ptr]; - /// Only a subgroup nobody has started may be admitted. - ReadStage next_subgroup_stage = ReadStage::NotStarted; + /// `main_ptr` is either a subgroup nobody has started, or the current subgroup itself when + /// PREWHERE just dropped all of its rows (the ColumnData case above breaks out with + /// rows_pass == 0 without advancing read_ptr); the branch below then deallocates it and + /// moves on. Only one subgroup of a row group is in progress at a time, so the exchange + /// cannot hit anything else. + ReadStage next_subgroup_stage = next_subgroup.stage.load(); if (!next_subgroup.stage.compare_exchange_strong( next_subgroup_stage, ReadStage::OffsetIndex)) break; From ca07675b1360ec2a52ef471a1a2ba2b8b38ea00d Mon Sep 17 00:00:00 2001 From: UnamedRus Date: Thu, 27 Aug 2026 19:36:29 +0300 Subject: [PATCH 14/14] Parquet: let metadata ranges coalesce with a row group's reads The row-group bound on read coalescing treated every byte of the file as belonging to some row group, so the page indexes and footer after the last row group could not join that row group's data read. For a small file this cost one extra request: `03723_parquet_prefetcher_read_big_at` expected 2 random reads and got 3. The rule is now stated in terms of row groups, not offsets: a task may cover ranges from at most one row group, and ranges outside every row group (page indexes, bloom filters, footer) may join whichever row group the task already covers. `Prefetcher::setRowGroupRanges` takes the [start, end) ranges instead of a flattened boundary list. Failed in `03723_parquet_prefetcher_read_big_at`: https://altinity-build-artifacts.s3.amazonaws.com/json.html?PR=2275&sha=0df5ce6be0296b179730e6b422e9dddc9751abd4&name_0=PR PR: https://github.com/Altinity/ClickHouse/pull/2275 Co-Authored-By: Claude Fable 5 Signed-off-by: UnamedRus --- .../Formats/Impl/Parquet/Prefetcher.cpp | 44 ++++++++++++------- .../Formats/Impl/Parquet/Prefetcher.h | 16 ++++--- .../Formats/Impl/Parquet/Reader.cpp | 20 +++------ 3 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp index 743436386a17..ac693ebb4fbe 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.cpp @@ -43,23 +43,26 @@ void Prefetcher::init(ReadBuffer * reader_, const ReadOptions & options, FormatP range_sets.resize(1); } -void Prefetcher::setRowGroupBounds(std::vector bounds) +void Prefetcher::setRowGroupRanges(std::vector> ranges) { + chassert(std::is_sorted(ranges.begin(), ranges.end())); std::lock_guard lock(mutex); - row_group_bounds = std::move(bounds); + row_group_ranges = std::move(ranges); } -std::pair Prefetcher::rowGroupBoundsFor(size_t offset) const +std::optional Prefetcher::rowGroupIndexFor(size_t offset) const { - /// Called with `mutex` held. - if (row_group_bounds.size() < 2) - return {0, std::numeric_limits::max()}; - auto hi = std::upper_bound(row_group_bounds.begin(), row_group_bounds.end(), offset); - if (hi == row_group_bounds.begin()) - return {0, *hi}; // metadata, before the first row group - if (hi == row_group_bounds.end()) - return {row_group_bounds.back(), std::numeric_limits::max()}; - return {*(hi - 1), *hi}; + if (row_group_ranges.empty()) + return std::nullopt; + /// First range starting after `offset`; the candidate is the one before it. + auto hi = std::upper_bound(row_group_ranges.begin(), row_group_ranges.end(), offset, + [](size_t off, const std::pair & range) { return off < range.first; }); + if (hi == row_group_ranges.begin()) + return std::nullopt; + const auto & range = *(hi - 1); + if (offset >= range.second) + return std::nullopt; // gap between row groups, or after the last one + return size_t(hi - 1 - row_group_ranges.begin()); } size_t Prefetcher::currentReadTaskBudget() const @@ -352,14 +355,21 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, /// One read must not span two row groups: `getRangeData` waits for the whole task, so the /// earlier row group would wait for the later one's bytes and delivery would serialize. - const auto [row_group_lo, row_group_hi] = rowGroupBoundsFor(start_offset); + /// Bytes outside every row group (page indexes, bloom filters, footer) don't count: they may join + /// whichever row group the task already covers. `claimed_row_group` is the one it covers so far. + std::optional claimed_row_group = rowGroupIndexFor(start_offset); + auto other_row_group = [&](size_t offset) + { + auto rg = rowGroupIndexFor(offset); + return rg.has_value() && claimed_row_group.has_value() && *rg != *claimed_row_group; + }; /// Go left. for (size_t idx = range_idx; idx > 0; --idx) { const RangeState & r = ranges[idx - 1]; if (r.end + min_bytes_for_seek <= start_offset || // short gap - r.start < row_group_lo || // would reach into the previous row group + other_row_group(r.start) || // would reach into another row group end_offset - std::min(r.start, start_offset) > task_budget || // task not too big !r.request->allow_incidental_read.load(std::memory_order_relaxed)) // range wants to be coalesced break; @@ -368,6 +378,8 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, if (s == RequestState::State::HasRange) { /// Include this range in the task. + if (!claimed_row_group.has_value()) + claimed_row_group = rowGroupIndexFor(r.start); start_idx = idx - 1; total_length_of_covered_ranges += r.length(); start_offset = std::min(start_offset, r.start); @@ -394,7 +406,7 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, { const RangeState & r = ranges[end_idx]; if (end_offset + min_bytes_for_seek <= r.start || - r.end > row_group_hi || // would reach into the next row group + (r.end > r.start && other_row_group(r.end - 1)) || // would reach into another row group std::max(r.end, end_offset) - start_offset > task_budget || !r.request->allow_incidental_read.load(std::memory_order_relaxed)) break; @@ -402,6 +414,8 @@ void Prefetcher::pickRangesAndCreateTaskIfNotExists(RequestState * initial_req, const auto s = r.request->state.load(std::memory_order_relaxed); if (s == RequestState::State::HasRange) { + if (!claimed_row_group.has_value() && r.end > r.start) + claimed_row_group = rowGroupIndexFor(r.end - 1); end_idx = idx + 1; total_length_of_covered_ranges += r.length(); end_offset = std::max(end_offset, r.end); diff --git a/src/Processors/Formats/Impl/Parquet/Prefetcher.h b/src/Processors/Formats/Impl/Parquet/Prefetcher.h index 8df2db6d0fde..f82e3e127a02 100644 --- a/src/Processors/Formats/Impl/Parquet/Prefetcher.h +++ b/src/Processors/Formats/Impl/Parquet/Prefetcher.h @@ -41,9 +41,10 @@ class Prefetcher /// Called at most once, after all registerRange calls and before all enqueue/getRangeData calls. void finalizeRanges(); - /// Keeps one read from covering parts of two row groups. `bounds` is sorted: the start offset of - /// each row group, then the end of the last. Reads issued before this (metadata) are unconstrained. - void setRowGroupBounds(std::vector bounds); + /// Keeps one read from covering parts of two row groups. `ranges` are the [start, end) byte + /// ranges of the row groups, sorted by start. Bytes outside every range (footer, page indexes, + /// bloom filters) belong to no row group and may be coalesced with either neighbour. + void setRowGroupRanges(std::vector> ranges); /// Replace a requested range with a set of disjoint smaller ranges contained within it. /// `subranges` must be sorted. @@ -186,8 +187,8 @@ class Prefetcher size_t min_bytes_for_seek{}; size_t bytes_per_read_task{}; - /// See setRowGroupBounds. Empty until it is called. - std::vector row_group_bounds; + /// See setRowGroupRanges. Empty until it is called. + std::vector> row_group_ranges; /// Reads running or queued. Drives read-task size: smaller reads fill an idle pool faster, larger /// ones amortize the round trip once it is busy. @@ -210,8 +211,9 @@ class Prefetcher /// (One mutex for all tasks because it's not used frequently.) std::mutex exception_mutex; - /// [lo, hi) of the row group containing `offset`, or the whole file if the layout isn't known yet. - std::pair rowGroupBoundsFor(size_t offset) const; + /// Index of the row group whose byte range contains `offset`, or nullopt if `offset` is outside + /// every row group (metadata) or the layout isn't known yet. Called with `mutex` held. + std::optional rowGroupIndexFor(size_t offset) const; size_t currentReadTaskBudget() const; void determineReadModeAndFileSize(ReadBuffer * reader_, const ReadOptions & options); diff --git a/src/Processors/Formats/Impl/Parquet/Reader.cpp b/src/Processors/Formats/Impl/Parquet/Reader.cpp index f7df73fc0357..e8e279da5482 100644 --- a/src/Processors/Formats/Impl/Parquet/Reader.cpp +++ b/src/Processors/Formats/Impl/Parquet/Reader.cpp @@ -468,10 +468,10 @@ void Reader::prefilterAndInitRowGroups(const std::optional bounds; - bounds.reserve(file_metadata.row_groups.size() + 1); + std::vector> bounds; + bounds.reserve(file_metadata.row_groups.size()); for (const auto & rg : file_metadata.row_groups) { size_t start = std::numeric_limits::max(); @@ -488,21 +488,15 @@ void Reader::prefilterAndInitRowGroups(const std::optional::max() || end <= start) continue; // unusable metadata - if (!bounds.empty() && start < bounds.back()) + if (!bounds.empty() && start < bounds.back().second) { - bounds.clear(); // not laid out in order; don't guess + bounds.clear(); // not laid out in order, or overlapping; don't guess break; } - bounds.push_back(start); - bounds.push_back(end); + bounds.emplace_back(start, end); } if (!bounds.empty()) - { - /// Adjacent row groups share an offset. - std::sort(bounds.begin(), bounds.end()); - bounds.erase(std::unique(bounds.begin(), bounds.end()), bounds.end()); - prefetcher.setRowGroupBounds(std::move(bounds)); - } + prefetcher.setRowGroupRanges(std::move(bounds)); } if (options.format.parquet.bloom_filter_push_down && format_filter_info->key_condition)