Skip to content

[Experiment] Antalya 26.6: Parquet v3 constant column skip - #2181

Open
UnamedRus wants to merge 39 commits into
antalya-26.6from
parquet-v3-constant-column-skip-antalya
Open

[Experiment] Antalya 26.6: Parquet v3 constant column skip#2181
UnamedRus wants to merge 39 commits into
antalya-26.6from
parquet-v3-constant-column-skip-antalya

Conversation

@UnamedRus

Copy link
Copy Markdown
Collaborator

Changelog category (leave one):

  • Performance Improvement

Changelog entry (a user-readable short description of the changes that goes to CHANGELOG.md):

...

Documentation entry for user-facing changes

...

CI/CD Options

Exclude tests:

  • Fast test
  • Integration Tests
  • Stateless tests
  • Stateful tests
  • Performance tests
  • Aarch64 tests
  • All with ASAN
  • All with TSAN
  • All with MSAN
  • All with UBSAN
  • All with Coverage
  • All Regression
  • Disable CI Cache

Regression jobs to run:

  • Fast suites (mostly <1h)
  • Aggregate Functions (2h)
  • Alter (1.5h)
  • Benchmark (30m)
  • ClickHouse Keeper (1h)
  • Iceberg (2h)
  • LDAP (1h)
  • OAuth (5m)
  • Parquet (1.5h)
  • RBAC (1.5h)
  • SSL Server (1h)
  • S3 (2h)
  • S3 Export (2h)
  • Swarms (30m)
  • Tiered Storage (2h)

UnamedRus and others added 13 commits August 6, 2026 14:36
When a Parquet column chunk provably holds a single value in every row -
its min/max statistics have `min_value == max_value`, no nulls, and the
value is exact - the reader no longer fetches or decodes that chunk's
data pages. Instead `detectConstantColumn` records the value and
`decodePrimitiveColumn`/`formOutputColumn` materialize it directly.

This skips the offset index, column index, dictionary page and data page
reads for such chunks (the row group already passed the key condition via
its `min == max` hyperrectangle), which is a byte-level I/O win for wide
constant columns, plus the decode/decompression CPU.

Restricted to flat, top-level primitive columns with no element nulls.
For `BYTE_ARRAY`/`FIXED_LEN_BYTE_ARRAY` the writer may truncate min/max,
so `min == max` is trusted only when `is_min_value_exact` and
`is_max_value_exact` are both set; fixed-width numeric types are never
truncated.

The value is taken from `PageDecoderInfo::decodeField`, which yields it
in the final output (post-cast) domain - e.g. `DateTime` written as
`TIMESTAMP_MILLIS` decodes to seconds, not the raw millisecond
`decoded_type`. So the constant is materialized directly in the output
type, bypassing the `decoded_type` column and `castColumn`.

Gated by the new setting
`input_format_parquet_use_constant_column_optimization` (default on). A
new `ParquetConstantColumnChunks` ProfileEvent counts materialized
chunks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two independent scheduling fixes for the v3 reader on large remote files.

A. Split the per-stage memory budget from the thread budget. Previously
`Stage::memory_target_fraction` drove both a stage's memory watermark and
its thread count (`getLimitsPerReader`), and every active stage defaulted
to an equal 0.2 share. So `ColumnData` - the one stage that decodes large
row groups - was capped at 0.2 of both memory and threads, and on a
single large cross-region file only ~2 row groups were read/decoded
ahead, leaving the link idle. `Stage` now has a separate
`thread_target_fraction`; `getLimitsPerReader` takes both. `ColumnData`
gets the lion's share of memory and a larger thread share, while the
small, latency-bound index/bloom reads keep enough threads for parallel
small reads. The split is static and wants a perf run to tune; it still
couples prefetch depth to decode concurrency (decoupling those is a
separate, larger change).

B. Charge decoded output by its actual footprint. The memory reserved
for a subchunk before decoding was an estimate
(`estimateColumnMemoryBytesPerRow`) that undershoots for long strings and
skewed data, so real RAM overshot the watermark and the overshoot grew
with decode-ahead depth. After `decodePrimitiveColumn`, reconcile the
`MemoryUsageToken` up to the real `allocatedBytes` of the decoded column,
offsets and null maps (grow-only, fail-closed), so the stage counter is
honest and the scheduler stops decoding ahead before RAM exceeds the cap.

Both are internal scheduling/accounting changes with no query-result
change. NOT YET BUILT OR PERF-TESTED; the stage split numbers are a
starting point.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes the placement of the honest-accounting reconciliation from the
previous commit. It ran in `runTask` after `decodePrimitiveColumn`
returned, but for the common single-primitive column the function's tail
already `std::move`s `subchunk.column` into the output via
`formOutputColumn`, so the measurement saw a null column and was a no-op.

Thread `MemoryUsageDiff &` into `decodePrimitiveColumn` and reconcile the
`MemoryUsageToken` up to the real `allocatedBytes` of `subchunk.column`
(plus array offsets and the group null map) at the end of decoding, just
before the bookkeeping that may move the column out. Grow-only,
fail-closed. Matches the earlier `parquet/honest-memory-cap` prototype.

Still not built or perf-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two gate bugs made detectConstantColumn reject every column, so the
optimization never triggered on real files. Found by building and testing
against CH-written Parquet.

1. Null gate. The CH writer omits `null_count` for physically non-nullable
   (`REQUIRED`) columns, but the gate required `null_count` to be present
   and zero, rejecting every non-nullable column. A `REQUIRED` column can
   have no nulls regardless of the statistic, so only require zero
   `null_count` when the column is physically nullable (max definition
   level > 0).

2. Structural gate. `levels[0]` is a synthetic root sentinel with
   `is_array = true`, so a flat `REQUIRED` column's `levels.back()` IS that
   root (`size() == 1`, `is_array == true`) and a `Nullable` column has
   `size() == 2`. The old `levels.size() == 1 && !levels.back().is_array`
   test therefore rejected exactly the flat columns it meant to accept.
   Replace with the correct flatness signals (`levels.back().rep == 0` and
   `max_array_def == 0`), keep the `group_nullable` exclusion, and require
   the output column to be primitive (top-level, not a Tuple/Map/Array
   leaf) via `output_columns`.

Verified on a CH-written file (1M rows, constant Int64/String/DateTime/
Nullable columns): the optimization now fires (`ParquetConstantColumnChunks`
= columns x row groups), results are correct (including the
TIMESTAMP_MILLIS -> DateTime seconds path), reading the constant columns
drops `ParquetPrefetcherReadRandomRead` 10 -> 1 and `ParquetDecodingTasks`
96 -> 64, and a varying column is correctly not treated as constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tch stage)

Adds a ColumnDataPrefetch pipeline stage between OffsetIndex and
ColumnData. It runs `determinePagesToPrefetch` and issues the compressed
data-page reads (`startPrefetch`) but does not decode; ColumnData then
only decodes from the already-in-flight buffers.

The two stages have separate memory budgets: ColumnDataPrefetch gets a
large share (compressed row groups are cheap, ~tens of MB) so many row
groups can have their reads outstanding, while ColumnData gets a bounded
share (decoded row groups are large, ~hundreds of MB) that caps how many
are decoded/resident at once. Because row groups are independent and
their reads run in the Prefetcher's own io pool (not the parsing
threads), fetch depth is now decoupled from decode-ahead depth - the
fetch-deep / decode-shallow mode that finding #5 called for. Within a row
group subgroups stay sequential, so `determinePagesToPrefetch`'s in-order
requirement is preserved.

The compressed-read memory is charged to the ColumnDataPrefetch stage via
startPrefetch and released when ColumnData resets the prefetch handles
(the handle records its allocating stage, so the release credits the
right budget regardless of which stage's diff performs it).

Verified on the Debug build: reads are correct with and without PREWHERE
(3M-row multi-row-group file), the constant-column optimization still
fires, and there is no deadlock. Perf/prefetch-depth gains need a
high-RTT remote (S3) run to observe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds read back-pressure on the ColumnDataPrefetch stage: once the
compressed data already in flight covers more than
`input_format_parquet_prefetch_bandwidth_hide_seconds` of the measured
read throughput, stop prefetching further ahead. Beyond that point the
storage link is already fed, so extra compressed buffering only wastes
memory without improving throughput (the "+21 GB RAM for +3.6 s" case in
finding #4).

The Prefetcher now tracks completed-read throughput (bytes since init /
elapsed, `averageThroughputBytesPerSec`). The scheduler compares it
against the ColumnDataPrefetch stage's in-flight compressed bytes (that
stage's memory usage) and stops admitting more prefetch tasks when the
in-flight bytes exceed throughput x hide_seconds. The privileged-task
escape (lowest incomplete row group is always schedulable) still applies,
so back-pressure can never deadlock.

Defaults to 0 (disabled): the throughput heuristic and the hide-seconds
target can only be validated on a high-RTT remote (S3) run, and on local
fast IO the estimate is meaningless, so it is opt-in for cluster tuning.
With C's ColumnDataPrefetch memory budget already providing a static
cap on compressed buffering, this setting makes that cap adaptive.

Verified on the Debug build: results are correct with the setting off
(default) and with an aggressively small value (0.001s) that forces heavy
throttling - no deadlock, identical results.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Port adaptation: antalya-26.6 predates the Nullable(Tuple) support on
master, so PrimitiveColumnInfo::group_nullable and
ColumnSubchunk::group_null_map do not exist there. Remove those
references from the constant-column detection gate and the decoded-memory
reconciliation. Both are safe: nested-in-nullable-struct leaves are
already excluded from the constant-column optimization by the
`output_columns[...].is_primitive` guard, and the group null map (when it
would exist) is negligible in the memory reconciliation.

Also resolved during the rebase onto antalya-26.6:
- ProfileEvents/Reader: keep only the new ParquetConstantColumnChunks
  event (antalya has no ParquetPrunedPages).
- Column index uses antalya's singular `column_index_condition`.
- Dropped the master-only `pruningMemoryReservation` (antalya's
  applyBloomAndDictionaryFilters takes no reservation).

NOT YET COMPILED against antalya-26.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: c6b70b3
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: 34816a3
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: f260506
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: 2a20ab9
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: 114640e
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: a019cd7
I, UnamedRus <dtitmoav@gmail.com>, hereby add my Signed-off-by to this commit: a3ee936

Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Commit c6b70b3 accidentally added a `unique_key_probe_implementation`
entry to `SettingsChangesHistory.cpp` under version 26.8. That setting is
not registered anywhere in `Settings.cpp`, so `02324_compatibility_setting`
failed with `UNKNOWN_SETTING` when applying `compatibility` to old versions.
The entry is unrelated to the parquet constant-column work; remove it.

CI: https://github.com/Altinity/ClickHouse/actions/runs/31099889198/job/92610989464

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Extends the constant-column optimization to the all-null case. When a
Parquet column chunk provably holds only nulls - its `null_count` statistic
equals `num_values` on a physically nullable leaf - the reader no longer
fetches or decodes that chunk's dictionary or data pages. `detectConstantColumn`
marks the chunk constant (and `is_all_null`), and `formOutputColumn`
materializes the result directly: `Null` for a Nullable output, or the output
default when `input_format_null_as_default` substitutes nulls for a
non-nullable output. A non-nullable output without null substitution cannot
represent the result, so such a chunk is left to the normal decode path.

Unlike the single-value case this needs no value decode, so it sidesteps the
min/max exactness and `BYTE_ARRAY` truncation checks entirely; it only reads
the `null_count` count. `formOutputColumn` records every row of an all-null
chunk in `block_missing_values` (the single-value case has no nulls and records
nothing), matching the normal decode path's null-map bookkeeping so
`input_format_null_as_default` stays correct.

All-null wide chunks are common in schema-evolved files (a column added later
is all-null in older row groups), so this skips dictionary, data page, offset
index and column index reads for a frequent real-world shape. Reuses the
existing `input_format_parquet_use_constant_column_optimization` setting and
the `ParquetConstantColumnChunks` ProfileEvent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Commit c6b70b3 fabricated two entire version blocks (26.8 and 26.7) in
SettingsChangesHistory.cpp holding ~80 setting entries pulled in by a bad
merge. 63 of them reference settings that are not registered on this branch
and 10 duplicate entries recorded elsewhere, so applying `compatibility` to an
older version threw `UNKNOWN_SETTING` (e.g. `s3_base`,
`unique_key_probe_implementation`), failing 02324_compatibility_setting.

The only entry that belongs to this parquet commit is
`input_format_parquet_use_constant_column_optimization`. Remove both fabricated
blocks and keep just that setting, moved into the pre-existing 26.6 block. The
resulting history equals the commits parent plus that single line.

Follow-up to 77c2c72 which removed only the first offending entry.

CI: https://github.com/Altinity/ClickHouse/actions/runs/31105816361/job/92630866726

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…ds in history

The setting was added by a019cd7 but never given a SettingsChangesHistory
entry, so 02995_new_settings_history reported it as an undocumented new setting
(the failure surfaced once 02324_compatibility_setting stopped failing first).
Add it to the 26.6 block next to input_format_parquet_use_constant_column_optimization;
default 0 disables the back-pressure and matches the pre-existing behavior.

Reproduced the full 02995 check locally (registered settings minus both baseline
TSVs minus recent-version history): this was the only missing setting.

CI: https://github.com/Altinity/ClickHouse/actions/runs/31110878761/job/92648476492

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
The previous run failed in Config Workflow / Set up job with
"Failed to resolve action download info. Error: Service Unavailable" - a
transient GitHub Actions outage, not a code failure. Empty commit to re-run CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Workflow [PR], commit [8249162]

UnamedRus and others added 7 commits August 7, 2026 12:55
Adds the DataLakeCatalog `namespaces` setting: a comma-separated list of
allowed namespaces for `rest`, `glue` and `unity` catalog types, so a
DataLake database exposes only the selected namespaces. `rest` supports nested
rules (`foo`, `foo.bar`, `foo.*`) via `RestCatalog::AllowedNamespaces`;
`glue`/`unity` use a flat allow-set. Default `*` allows everything
(pre-existing behavior).

Ported from the merged antalya-25.8 PR onto antalya-26.6. Cross-version
adaptations:
- Catalog construction moved into `DatabaseDataLake` in 26.6, so the namespaces
  are threaded through `CatalogSettings` / the catalog constructors there; the
  25.8 inline construction in `DataLakeConfiguration::getCatalog` (and its
  `catalog_namespaces` plumbing) is obsolete and dropped, leaving
  `DataLakeConfiguration.h` unchanged.
- `CATALOG_NAMESPACE_DISABLED` error code renumbered 757 -> 779 (757..778 are
  already taken on this branch).
- The namespace filter checks were merged into 26.6-refactored code paths
  (threadpool-based `RestCatalog::getTables`, extracted Glue credentials
  provider, 26.6 `resolveMetadataPathFromTableLocation`).
- The delegating 6-arg `RestCatalog` constructor (used by OneLake/BigLake,
  which do not support the filter) defaults `allowed_namespaces` to `*`.
- Added explicit `<boost/algorithm/string.hpp>` and `<unordered_map>`/
  `<unordered_set>` includes (the upstream PR relied on transitive includes).

PR: #1337

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
The ported AllowedNamespaces block opened a public: section for the nested
class and closed with private:, which downgraded every RestCatalog member
after it (loadConfig, getAuthHeaders, retrieveAccessToken, ...) from protected
to private. Subclasses (OneLake/BigLake/Paimon REST catalogs) call those, so
Build failed with "is a private member of DataLake::RestCatalog". Restore the
trailing access specifier to protected; AllowedNamespaces stays public (the
gtest references it).

CI: https://github.com/Altinity/ClickHouse/actions/runs/31171012685/job/92846936566
PR: #2181

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Adding the namespaces_ parameter to the primary RestCatalog constructor broke
the pre-existing unit test gtest_rest_catalog.cpp, which constructed a
RestCatalog without it (no matching constructor). Pass namespaces = "*"
(allow all) in the new argument slot, before the context argument.

CI: https://github.com/Altinity/ClickHouse/actions/runs/31176356154/job/92863225108
PR: #2181

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
formOutputColumn previously expanded a constant column chunk with
insertMany(Field, num_rows) - an O(rows) per-row Field-dispatch fill that
showed up as a consistent ~20% UserTime increase in profiling (the baseline
decoded the same near-constant chunk cheaply via RLE/dictionary).

Emit a ColumnConst instead: O(1) to build, and the const-ness propagates
through the pipeline. A PREWHERE/WHERE predicate computes its result from the
value without expanding the stored column, and GROUP BY / aggregation over the
column get a const key. The value is already in the output (post-cast) domain.
The all-null block_missing_values bookkeeping is unchanged.

The reader path preserves the const: getOrFormOutputColumn returns it as-is,
ColumnConst::size() == rows_pass satisfies the delivery checks, and
ColumnConst::filter keeps it const through multistage PREWHERE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Extends the constant-column optimization from whole-chunk (tier 1, footer
statistics) to per-row-subgroup granularity using the Column Index per-page
min/max/null_pages, which the reader already loads for predicate push-down.
Catches columns constant over a run of pages without the whole row group being
constant (common in sorted/clustered data), with no change to subgroup/chunk
sizing.

- constColumnMaterializationEligible: shared flat-top-level-primitive gate,
  factored out of detectConstantColumn.
- applyColumnIndex: retain per-page constant info (value / is_const / all_null)
  in ColumnChunk::page_const_info. Fixed-width numeric/date/time only for value
  constants (the Column Index has no per-page exactness flag, so a truncated
  BYTE_ARRAY min==max is untrustworthy); all_null is a plain flag, always safe.
- detectConstantSubchunk: a column is constant for a subgroup iff every page
  overlapping the subgroup row range is all-null, or all hold the same value.
- Wired into intersectColumnIndexResultsAndInitSubgroups; sets the subchunk
  constant fields so decodePrimitiveColumn skips decode and formOutputColumn
  materializes a ColumnConst. New ParquetConstantColumnSubchunks ProfileEvent.

Tier 1 stays the always-on baseline and the only detector for BYTE_ARRAY. Tier 2
is opportunistic: only where the Column Index is already loaded (predicate
push-down columns), never force-fetched.

Deferred (follow-up): skipping the prefetch of constant subgroups pages (the I/O
win). Currently those pages are still fetched and skipped forward by the next
subgroups skipToRowOrNextPage; correct but does not yet save the read.

Design: docs/design/parquet-v3-page-level-constant-column.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Completes tier-2: a per-subgroup constant column (detectConstantSubchunk) now
also skips fetching its data pages, not just decoding them. In
determinePagesToPrefetch, a constant subchunk claims no pages; a page fully
inside it is released (never fetched), while a page shared with a neighbouring
non-constant subgroup is left for that subgroup to claim.

Safe because tier 2 only exists when the Column Index (hence Offset Index) is
loaded: a constant subgroup never calls skipToRowOrNextPage, and a non-constant
subgroup jumps directly via the offset index to its own claimed pages, so a
released constant page between them is never accessed. The whole-chunk
data_pages_prefetch is still split (likely_to_be_used=false), so only claimed
pages are actually read.

This turns the tier-2 CPU/memory win into an I/O win as well (fewer S3 GETs /
ParquetPrefetcherReadRandomRead), matching tier 1 but at page granularity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Adds input_format_parquet_use_column_index_for_constant_columns (default off).
By default tier-2 per-subgroup constant detection only runs where the Column
Index is already loaded (columns with a predicate). This setting force-loads the
Column Index (and Offset Index) for eligible read columns without a predicate,
so tier-2 can skip single-valued page runs on them too - worthwhile for sorted /
low-cardinality columns, at the cost of a small extra (tail-contiguous,
coalesced) index read.

A force-loaded column takes the same load path as a predicate column
(use_column_index = true); applyColumnIndex records per-page constant info but
skips page-level predicate pruning when the column has no condition (prev_row_idx
stays 0 -> whole chunk selected -> no restriction).

Setting is declared in FormatFactorySettings, plumbed through FormatSettings /
FormatFactory, and recorded in SettingsChangesHistory (26.6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
@UnamedRus

Copy link
Copy Markdown
Collaborator Author

RowGroup level + push ColumnConst to transforms

┌───────────────────────────────────┬──────────┬─────────────┬───────────────┐
│              Metric               │ baseline │ ColumnConst │       Δ       │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ DefaultImplementationForNullsRows │ 3.367B   │ 0.718B      │ −78.7%        │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ RealTimeMicroseconds (wall)       │ 333.5M   │ 243.1M      │ −27.1%        │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ UserTimeMicroseconds              │ 21.17M   │ 18.22M      │ −13.9%        │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ OSCPUVirtualTimeMicroseconds      │ 23.93M   │ 21.04M      │ −12.1%        │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ S3GetObject                       │ 663      │ 467         │ −196 (−29.6%) │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ ParquetPrefetcherReadRandomRead   │ 662      │ 466         │ −196 (−29.6%) │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ ParquetFetchWaitTimeMicroseconds  │ 104.5M   │ 69.0M       │ −34%          │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ ReadBufferFromS3InitMicroseconds  │ 63.1M    │ 37.0M       │ −41%          │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ MemoryAllocatedWithoutCheckBytes  │ 486.8M   │ 359.3M      │ −26.2%        │
├───────────────────────────────────┼──────────┼─────────────┼───────────────┤
│ SelectedBytes                     │ 2214.1M  │ 2115.7M     │ −4.4%         │
└───────────────────────────────────┴──────────┴─────────────┴───────────────┘

UnamedRus and others added 6 commits August 7, 2026 19:57
…off]

Approach B: when only PART of a row subgroup is single-valued (a constant run
shorter than, or straddling, a subgroup), fill those pages from the per-page
Column Index while decoding only the varying pages - producing a full column
where whole-subgroup tier-2 cannot make a ColumnConst.

Gated behind input_format_parquet_fill_constant_pages (default off). Additional
gates keep it correct and simple:
- output column must not need a post-decode cast (we fill the decoded_type
  column with the Column Index value, valid only when decoded == output value
  type);
- no predicate on the column (so no page pruning; data_pages == all pages);
- no prewhere filtering in the subgroup (avoids filter/range intersection);
- at least one single-value page and no all-null page in the subgroup (all-null
  pages fall back to the standard decode).

fillConstantPagesAndDecodeRest walks the subgroup page by page: a single-value
page is filled via insertMany (+ null-map zeros) without reading it; a varying
page is decoded with the existing skipToRowOrNextPage + readRowsInPage, which
jumps over the filled pages via the offset index without loading them.

First cut is decode-fill only: constant pages are still prefetched (correct,
some wasted I/O). Skipping their prefetch needs cross-subgroup coordination and
is a follow-up.

Design: docs/design/parquet-v3-page-level-constant-column.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Completes the mixed-topology fill (input_format_parquet_fill_constant_pages,
default off): a subgroup that fills its single-value pages from the Column Index
no longer prefetches them. determinePagesToPrefetch consults
willFillConstantPages (the same deterministic predicate the decode path uses) and,
for a fill subgroup, does not claim its is_const pages - so a page overlapped
only by fill subgroups is released and never read, while a page also decoded
normally by another subgroup is still fetched.

Also adds a no-prewhere / no-row-level-filter gate to willFillConstantPages so
rows_pass == rows_total holds identically at prefetch time and decode time,
keeping the two decisions consistent.

This turns the mixed-topology fill from a decode-CPU-only win into an I/O win
too (fewer page reads), matching tier-2 at sub-subgroup granularity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
min_value == max_value provably means a single exact value for every physical
type, including BYTE_ARRAY / FIXED_LEN_BYTE_ARRAY. Statistics and Column Index
bounds are always valid (min_value <= every value <= max_value) and truncation
only ever widens them, so a truncated value - or any page/chunk with two
distinct values - yields min_value < max_value. Equality therefore requires a
single value short enough to be stored exactly; the is_*_value_exact flags are
implied and, for the Column Index (which has no per-page exact flag), never
needed.

Remove the BYTE_ARRAY/FIXED_LEN exclusion from tier 1 (detectConstantColumn) and
the per-page recording in tier 2 (applyColumnIndex), so constant string columns
and string constant page runs get the optimization too. Also fixes tier 1 for
writers that omit is_*_value_exact (previously any such BYTE_ARRAY constant was
skipped even when short and exact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Previously a mixed-topology subgroup (input_format_parquet_fill_constant_pages)
bailed to normal decode if any overlapping page was all-null. Now fill them too:
an all-null page appends no non-null values and marks its rows null in the null
map; the existing expand() + Nullable-wrap / null_as_default tail finalizes them
exactly as the normal decode does (the column holds compact non-null values, the
null map covers all rows). determinePagesToPrefetch also skips prefetching
all-null pages, matching the fill.

willFillConstantPages now accepts all-null pages as fillable, but still bails
when an all-null page is present and the output can represent neither null
(non-Nullable output) nor a default (no null_as_default) - the standard decode
raises the usual not-null error there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Drop the column_index_condition gate from willFillConstantPages. A page-pruning
predicate on the column (column_index_condition) is safe for the fill: subgroups
are built only over contiguous surviving row ranges, so a pruned page never
overlaps a subgroup and the fill never walks it; the prefetch-skip indexes
page_const_info by each page global position (page.meta into page_locations),
which is correct regardless of the data_pages subset. A column carrying an
actual prewhere/row-level filter is still blocked by the no-prewhere gate.

Gate 2 (output needs_cast) is intentionally left in place: whether decodeField
yields the decoded or the output value domain is ambiguous from static reading
(SchemaConverter/convertField vs the tier-1 is_constant branch), and guessing
wrong is silent wrong data. It needs a build + a needs_cast test to resolve
(and that test would also confirm tier-1/tier-2 on hint-cast columns).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
The mixed-topology fill now walks the rows that pass the filter
(row_subgroup.filter) instead of the whole subgroup range, so it works under
PREWHERE / row-level filters: it iterates passing ranges (like the standard
row-range decode) and, within each, fills constant / all-null pages for their
overlapping passing rows and decodes varying pages for the contiguous passing
sub-range. It therefore produces exactly rows_pass values for any filter, and
willFillConstantPages no longer depends on rows_pass - so the decision is
identical at prefetch time and decode time and the prefetch-skip can never drop
a page the decode needs.

Removes the no-prewhere and rows_pass == rows_total gates from
willFillConstantPages. The only remaining gate is needs_cast (build-gated; see
the decodeField value-domain question).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
UnamedRus and others added 13 commits August 7, 2026 21:53
formOutputColumn now builds the single-value constant in input_type and applies
the same castColumn the per-row decode uses when needs_cast is set, instead of
inserting the value straight into an output_type column and skipping the cast.

Today this is a no-op: the optimization only fires when the stats decoder needs
no value-transforming conversion (SchemaConverter gates allow_stats on that), so
input_type == output_type for every constant column. The change keeps the
constant path correct if allow_stats is ever generalized to allow transforming
casts (e.g. decimal rescale) - the constant would then go through the same cast
as the normal decode. Casting a ColumnConst is O(1) (one value) and preserves
const-ness, so no cost.

The all-null constant is synthesized in the output domain (Null / output
default) and is not cast. The mixed fill already casts via the normal path, so
it is future-safe too. The allow_stats / SchemaConverter logic is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
readFileMetaData now takes a footer size hint (bytes); when set it sizes the
initial tail read instead of the fixed 64 KiB speculative read. ReadOptions
carries footer_metadata_size_hint (default 0 = current behavior), threaded
through every readFileMetaData caller. Adds estimateParquetFooterSize(num_columns,
num_row_groups, bounds_bytes), a pure clamped estimator a data lake can feed from
per-file stats.

Only affects read count, never correctness: an undershoot falls back to the
existing second read, an overshoot reads a slightly larger (clamped) tail. No
behavior change until a caller supplies a non-zero hint - the Iceberg wiring that
computes it from the manifest (column count, row groups from split_offsets,
summed lower/upper_bounds sizes) is a follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Wire the footer-size hint end to end so an Iceberg data file fetches its parquet
FileMetaData in a single tail read instead of the blind 64 KiB (then a second
read for wide / many-row-group files).

- RelativePathWithMetadata gains an optional footer_size_hint that rides through
  the existing object_info -> object_with_metadata copy into the format creator.
- IcebergDataObjectInfo computes it in its manifest ctor via
  Parquet::estimateParquetFooterSize(num_columns, num_row_groups, bounds_bytes):
  num_columns from the manifest column stats, bounds_bytes summed from the
  per-column value_bounds, and num_row_groups estimated from record_count
  (split_offsets are not parsed). Parquet files only; guarded by USE_PARQUET.
- ParquetV3BlockInputFormat reads object_with_metadata->footer_size_hint into
  read_options.footer_metadata_size_hint in its constructor.

Only affects read count, never correctness: an underestimate falls back to the
existing second read, an overestimate reads a slightly larger clamped tail. Also
avoids the S3 HEAD indirectly since file_size_in_bytes already flows via
getFileSizeHint. No change for non-Iceberg reads (hint stays unset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Tier-2 per-subgroup constant detection (`detectConstantSubchunk`, gated by
`input_format_parquet_use_column_index_for_constant_columns`) never fired for
required (non-nullable) columns.

`applyColumnIndex` computed `can_be_null` purely from the Column Index
`null_counts`: `!__isset.null_counts || null_counts[page] != 0`. ClickHouse's
own Parquet writer emits `null_counts` only for nullable columns (`Write.cpp`,
`has_null_count = max_def == 1 && max_rep == 0`), so for a required column
`null_counts` is absent and `can_be_null` was spuriously `true` on every page.
That disabled the per-page `is_const` branch, so `page_const_info` was never
populated and tier-2 detection never triggered - exactly for the common case of
a non-nullable constant column.

A required column (max def level 0) can never contain nulls regardless of
whether the Column Index carries `null_counts`, so fold the schema `nullable`
flag into `can_be_null`. `adjustRangeFromIndexIfNeeded` already gates its own
use on `nullable && can_be_null`, so required-column predicate-pruning behavior
is unchanged.

Verified on a real build (sha 60841f9): an otherwise identical file with a
nullable `run` column yields `ParquetConstantColumnSubchunks = 22` while the
non-nullable variant yielded 0; this is the same null_count-omission pattern
already fixed for tier-1 whole-chunk detection.

Signed-off-by: UnamedRus <dtitmoav@gmail.com>
The Column Index and Offset Index live just below the `FileMetaData` footer, so
their bytes are already inside the tail chunk that `readFileMetaData` reads to
parse the footer. That read went into a throwaway local buffer the `Prefetcher`
did not remember, so the later `registerRange` for each index issued a fresh
read - a redundant object read (a separate `S3GetObject` on remote storage) for
bytes already in memory.

Retain that tail in the `Prefetcher` and serve any range fully contained in it
without a read, reusing the existing zero-copy `cached_region` path: a range
inside the retained window is attached to a one-off `Task` pre-marked `Done`
whose `cached_region` points into the retained buffer, so `getRangeData` returns
the span directly. `retained_tail` is a `Prefetcher` member that outlives all
handles, so the region needs no keep-alive.

The retain is a no-op for `EntireFileIsInMemory` (nothing to save) and falls
through to the normal read path for any range not fully contained, so it is
strictly additive. It is single-threaded (before any prefetching) and read-only
afterwards, so it needs no locking. New ProfileEvent
`ParquetPrefetcherServedFromRetainedTail` counts the reads avoided.

This works best together with `footer_metadata_size_hint`, which widens the
initial tail read to span the whole footer + index region of wide /
many-row-group files: the hint sizes the read, this change serves the index
from it. Together the footer and its index cost one read instead of two or
three on remote object storage.

Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…prefetch

`createReadBuffer` eagerly prefetches the head of "small" objects (up to
`2 * max_download_buffer_size`). That heuristic was added for many-small-files
streaming formats (CSV/JSON), where reading starts at the beginning of the file,
so a from-start read-ahead is exactly what will be consumed.

Column-oriented random-access formats (Parquet/ORC/Arrow) instead read the
`FileMetaData` footer at the *tail* first and drive their own prefetcher. For an
object larger than one download buffer the from-start prefetch cannot cover the
footer, so it is dropped on the first positioned read - a wasted object read
(`S3GetObject`) that transfers bytes nobody consumes.

Gate the eager prefetch on the format's access pattern: for random-access input
formats only prefetch when the whole object fits a single download buffer (then
it degenerates to one whole-file read the format serves entirely from memory,
footer included); above that, leave reading to the format's tail-first
prefetcher. Streaming formats keep the previous `2x` threshold, so the original
small-files optimization is unchanged.

`FormatFactory::checkIfFormatIsRandomAccessInput` reports whether a format is
registered via `registerRandomAccessInputFormat[WithMetadata]`. The
`createReadBuffer` flag defaults to false, so metadata / manifest / delete-file
reads keep their current behaviour.

Signed-off-by: UnamedRus <dtitmoav@gmail.com>
The `IcebergMetadataFilesCache` for table metadata is keyed on
`(table_uuid, metadata_file_path)`, but the very first read of a table's
metadata - the bootstrap in `initializePersistentTableComponents` - is issued
with no `table_uuid`, because the Iceberg `table-uuid` is only learned by
parsing that very file. `getMetadataJSONObject` therefore takes the uncached
branch and stores nothing, so the next read (query state, now with the uuid)
misses the cache and re-reads the same metadata file - two reads of one small
file per cold table open.

Seed the cache from the bootstrap read itself: after parsing the metadata,
extract its `table-uuid` and store the already-read JSON under
`getKey(table_uuid, path)`, so subsequent reads of the same file hit. No extra
I/O - the buffered string is reused via `getOrSet`'s load function, which only
runs when the entry is absent.

This is safe and preserves the table-recreate guard: the entry is keyed by the
uuid parsed from this exact file, and an immutable metadata file always yields
the same `(uuid, path)`; a dropped-and-recreated table has a different
`table-uuid` and thus a different key. Only the top-level table metadata carries
`table-uuid`; other JSON is left untouched.

Signed-off-by: UnamedRus <dtitmoav@gmail.com>
A data-lake catalog (Iceberg REST, Glue, ...) already parses the Iceberg
`table-uuid` out of its `LoadTable` response (`RestCatalog::setTableUUID`), but
it was only used to build the ClickHouse `StorageID` - it never reached the
Iceberg metadata layer. So the bootstrap metadata read still ran with no uuid
and could not key the metadata files cache, forcing a redundant re-read of the
same `metadata.json` on the following query-state read.

Forward the catalog's `table-uuid` as the `iceberg_metadata_table_uuid` storage
setting (`DatabaseDataLake`), and have `initializePersistentTableComponents`
read it and pass it to `getLatestOrExplicitMetadataFileAndVersion` and
`getMetadataJSONObject` instead of `std::nullopt`. The bootstrap read then keys
the cache on `(table_uuid, path)` on the first read, and the later read hits.

Resolution is unchanged: the catalog also sets `iceberg_metadata_file_path`, so
`getLatestOrExplicitMetadataFileAndVersion` still takes the explicit-path branch
(the uuid argument is inert there). For access paths with no catalog (bare-path
`iceberg()` table function, Hadoop / version-hint tables) the setting stays
unset and the uuid is still learned from the file - handled by the cache seeding
in `getMetadataJSONObject`.

Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…e HEAD

Opening an object went through a HEAD (getObjectMetadata) on every read to learn
its size and ETag, and the result was not cached process-wide, so a scan
re-HEADed every candidate file on every query (traced: ~1 S3 HeadObject per
candidate file, e.g. ~1900 HEADs to read ~550 files). The HEAD is also
mandatory-before-cache-lookup, because the filesystem / page / parquet-metadata
caches key on (path, etag) and the etag comes from the HEAD.

Introduce a process-wide identity cache and a single-request metadata fetch:

* `S3::getObjectIdentity` issues one `GetObjectAttributes` request (size + ETag +
  multipart part sizes) and falls back to a plain HEAD if the API is
  unsupported, denied, or errors - safe against S3-compatible stores. The ETag
  is quote-normalized so the GetObjectAttributes and HEAD paths yield the same
  identity (they are used as cache keys). Adds the `GetObjectAttributes` wrapper
  to `S3::Client` (mirrors `GetObjectTagging`) and the request alias.

* `ObjectStorageIdentityCache` (experimental singleton) maps path ->
  {etag, size, part_offsets}. `S3ObjectStorage::getObjectMetadata` consults it on
  the identity path (no tags requested): a hit skips the request entirely; a miss
  fetches via `getObjectIdentity` and populates it. The tags path and the
  credentials-refresh retry are unchanged.

* `ObjectMetadata` gains `part_offsets` (cumulative multipart part offsets),
  derived from the per-part sizes; consumers use it to align reads.

* `IcebergDataObjectInfo` no longer pre-populates object metadata from the
  manifest, so Iceberg data files also go through this single identity path and
  get the real ETag and multipart layout instead of a synthesized identity.

New ProfileEvents: `S3GetObjectAttributes`, `ObjectStorageIdentityCacheHits`,
`ObjectStorageIdentityCacheMisses`.

Note: the identity path returns no user-metadata attributes (GetObjectAttributes
does not provide them); callers that need them still use the tags path. The
cache is an experimental singleton (not Context-managed / not SYSTEM DROP-able)
and should be promoted before productionization.

Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Experimental, off by default. When the object's multipart-upload layout is known
(from GetObjectAttributes, via the object-storage identity cache), constrain the
Prefetcher's range coalescing to a single part window so one read never straddles
two parts - an AWS byte-range best practice for multipart-uploaded objects.

* `ReadOptions.multipart_part_offsets` carries the part boundaries; populated in
  `ParquetV3BlockInputFormat` from `ObjectMetadata.part_offsets`, gated by the new
  setting so it can be A/B-compared.
* `Prefetcher` keeps the offsets and, in `pickRangesAndCreateTaskIfNotExists`,
  stops extending a task past the boundaries of the part containing the initial
  range. This only constrains coalescing; a single requested range larger than a
  part is left as is (would require splitting).
* New setting `input_format_parquet_align_reads_to_multipart_boundaries`
  (default false) and ProfileEvent `ParquetPrefetcherPartAlignedTasks` (counts
  tasks whose coalescing was cut at a part boundary) for measurement.

Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…ent guard)

Generalizes the experimental multipart read alignment so it can be tuned and used
without probing per-file layout.

* input_format_parquet_read_alignment_bytes (UInt64, 0=off): align coalesced reads
  to a fixed byte grid (e.g. the writer's multipart part size - 10 MiB for
  delta-rs, 64 MiB for Spark/S3A) so no read straddles a multiple of it. Needs no
  GetObjectAttributes / identity cache, so alignment can be measured in isolation.
* input_format_parquet_read_alignment_min_bytes (UInt64, default 1 MiB):
  anti-fragmentation guard - don't cut a read at a boundary when the aligned
  segment would be smaller than this; allow the straddle instead of emitting a
  tiny extra request.

The Prefetcher derives the boundary window from the real per-file part offsets
when known (input_format_parquet_align_reads_to_multipart_boundaries), else from
the fixed stride; the min-bytes guard disables the cut for tiny segments. New
ProfileEvent ParquetPrefetcherAlignmentSkippedSmall counts those skips
(ParquetPrefetcherPartAlignedTasks already counts constrained tasks).

Signed-off-by: UnamedRus <dtitmoav@gmail.com>
…include

Two compile fixes surfaced by a local build:

* The new `ObjectMetadata::part_offsets` field (added with the object-storage
  identity cache) broke designated-initializer sites under
  `-Werror,-Wmissing-designated-field-initializers`. Add `.part_offsets = {}` to
  the `ObjectMetadata{...}` initializers in the S3, HDFS and Azure object
  storages.
* `IcebergWrites.cpp` uses `getDecimalScale` (for the Time64 branch) but did not
  include `<DataTypes/DataTypesDecimal.h>`, where it is declared - add the
  include.

Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Standard S3 GET has a fat tail (p50 ~5 ms vs p99 100+ ms), so a single slow read
on the critical path stalls a decode task (visible as large
ParquetFetchWaitTimeMicroseconds). Hedging: if a read a consumer is blocked on
doesn't finish within a threshold, issue a duplicate read and use whichever
returns first (S3 GET is idempotent).

Phase A (this commit) runs the hedge synchronously on the already-blocked
consumer thread: getRangeData waits up to the threshold via a new
CompletionNotification::wait_for, then reads the same range into task->hedge_buf
and serves from it, notifying so sharers wake. This has no async-lifetime hazard
- the hedge finishes before the PrefetchHandle is released. A fully async race
(Phase B) is deferred: it would need decreaseTaskRefcount to defer freeing
hedge_buf until an in-flight hedge finishes (the refcount/Deallocated path
assumes a single reader), which risks a use-after-free if done naively.

Scope: remote (RandomRead) reads only, no larger than a size cap (latency, not
throughput), one hedge per task, bounded by a concurrency cap (cost control).

Settings (all experimental, off by default):
* input_format_parquet_hedged_read_threshold_ms (0 = off)
* input_format_parquet_hedged_read_max_bytes (default 4 MiB)
* input_format_parquet_hedged_read_max_inflight (default 4)

ProfileEvents: ParquetPrefetcherHedgedReads, ParquetPrefetcherHedgedWins.
Signed-off-by: UnamedRus <dtitmoav@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants