External DISTINCT - #116569
Conversation
|
|
||
| /// Estimates peak additional hash-table buffer memory for `additional_keys` new keys, excluding | ||
| /// arena growth. Requires an initialized set. | ||
| size_t estimateGrowthMemory(size_t additional_keys) const; |
There was a problem hiding this comment.
DistinctSetFilter::estimateGrowthMemory() now drives the pre-insert safety decision in both ExternalDistinctTransform::consumeHashing() and the new preliminary pass-through branch in DistinctTransform::transform(), but its contract still excludes arena growth. For the key_string / serialized methods, a populated set can already have enough hash-table capacity that this returns 0 even though the next chunk of unique wide String keys still has to duplicate them into string_pool. In that state both callers keep hashing, and the following filter() can still throw MEMORY_LIMIT_EXCEEDED before external DISTINCT or preliminary pass-through gets a chance to engage.
The current growth coverage does not exercise this path: gtest_distinct_set_filter measures only numeric-key growth, and 05139_external_distinct_growth.sh is also numeric-only. Please either include variable-width key storage in this estimate, or make these pre-insert guards conservatively spill / drop the preliminary set whenever the chosen method keeps keys in the arena, and add a focused memory-limit repro for wide String / serialized keys.
DISTINCTkeeps a hash set of all the distinct keys, so its memory usage grows with cardinality: a query with many distinct values can fail withMEMORY_LIMIT_EXCEEDED. This PR lets the finalDISTINCTspill to disk, the same way external aggregation and external sort already do.Settings.
max_bytes_before_external_distinct(absolute,0= unset) andmax_bytes_ratio_before_external_distinct(a ratio of the available memory,0.5by default — enabled by default). Both thresholds combine by minimum, mirroringAggregator::Params::getMaxBytesBeforeExternalGroupBy. The thresholds are compared with the tracked memory usage of the whole query. The available memory is what remains, when the execution pipeline is built, under the strictest applicable server or user memory limit; when neither supplies a limit, the ratio has no effect and only the absolute threshold applies.max_memory_usagedoes not participate in this calculation. Setting both thresholds to0disables externalDISTINCTand its associated preliminary memory shedding.How the spill works (
ExternalDistinctTransform). The spill reuses the external sort pipeline ofMergeSortingTransform. Sorted chunks are accumulated, merged into runs that are written to temporary files, and merged back at the end. The pipeline is expanded at runtime, and the file sink/source pair is extracted into the sharedBufferingFileTransforms. Each of the three sorting stages is replaced by a variant that also deduplicates. Duplicates are therefore dropped wherever the rows are touched anyway, and they never reach the disk. Sorting first and deduplicating once would write every duplicate and read it back, which on skewed input is most of the data.DistinctTransform.DistinctSetFilter::prepareForInsertestimates the additional hash-buffer allocations that growth could require. It treats the input rows as potential new keys. If those allocations would leave insufficient server/user memory for preparing a spill, spilling starts before insertion. The pending chunk is retained and processed through the external path. The estimate uses the actual hash-table grower, and fixed-size key tables report no growth.DistinctSetFilter::extractKeys. A resumable extractor prepares sorted suppression runs with an approximately 16 MiB byte target. Each of them carries an “already emitted” flag. Extraction waits for each run to finish writing before preparing another, and the set is freed after its last keys are extracted. Only the keys are needed for these rows, since they are never emitted again. Suppression runs are sorted withsortBlockand written as they are. Keys that the hash set distinguishes may compare equal in the sort order, and all of them have to suppress. Key shapes that the in-memoryDISTINCTstores only as a 128-bit hash use theserializedset method in the spilling transform. The set keeps the serialized keys in its arena, so they can be recovered without retaining previously emitted chunks.sortBlockon every incoming chunk. The spill runssortBlockAndDeduplicateinstead, which computes the stable permutation and compacts the equal key ranges in the same pass. A chunk therefore carries no duplicates into the accumulated chunks. The chunks are accumulated and written out as further runs when memory exceeds the threshold. Ordinary runs have a minimum accumulated size to avoid writing a file for every small chunk.MergeSorter. The spill usesMergeSorter::Mode::MergeUniqueChunks, which relies on the chunks being unique within themselves. Only the first row of a batch can then repeat the last emitted key, and the surviving ranges are copied directly. Every run on disk is unique on the keys. The final in-memory tail is merged the same way. The runs contain only the non-constant input columns, and constants are re-attached from the header after the merge.MergingSortedTransform. The spill usesDistinctSortedTransform, which fuses merging, deduplication, and suppression in one stage. Runs are ordered by distinct keys followed by the already-emitted flag descending, so suppression rows precede equal ordinary rows independently of their input position. Ordinary runs retain arrival precedence through the input-index tie-break, which preserves the first payload among equal keys. Surviving ranges are copied together, and whole chunks are forwarded when possible.ORDER BYis preserved across the spill. The finalDISTINCTof a query withORDER BYat the same level runs above the sort, so it has to return the rows in the sorted order. When it spills, every row carries its arrival number into the runs, and after the merge and deduplication the rows are sorted back by it (PartialSortingTransform+MergeSortingTransform, which can use the disk as well, under the same thresholds).DistinctStepcarries this requirement as a flag: the planners set it for the query’s ownORDER BY, and theapplyOrderoptimization sets it whenever it propagates a global sort order through the step, because the steps above may rely on that order. The flag is serialized with the query plan under the plan version that also carries the externalDISTINCTsettings (and omitted from the hash-table cache key), so a remote server executing a serialized plan preserves the order too;EXPLAIN PLAN actions = 1shows it asPreserve input order: 1.The preliminary
DISTINCTnever spills. It is best-effort, so under the same threshold it frees its hash set and switches to pass-through, leaving exact deduplication to the downstream step. It can also release the set before projected hash-table growth exceeds available server/user memory. This policy is independent of whether the downstream step uses hashing or sorted input; the switch is counted by theDistinctTransformsSwitchedToPassThroughprofile event.Shared deduplication core. The hash-set filtering, the
LowCardinalityfast path, and size-limit enforcement are extracted fromDistinctTransformintoDistinctSetFilter, shared by both transforms, so a semantic change to hashing is a change in one place.DistinctSpillLayoutowns the column conversions, headers, and service-column positions, whileExternalDistinctTransformschedules extraction, writing, and merging. Already-emitted flags remain constant columns during run preparation and local sorting. Serialization scratch is reused after each value is copied into its destination column.Scope and semantics:
DISTINCT, and only when at least one key column is non-constant. Every key type is supported: a key column whose type is not comparable (e.g.AggregateFunction) is spilled as its serialized values in aStringcolumn of the same name, so the runs are sorted and merged by comparing the bytes, and it is deserialized back after the merge. The existing sorted-streamDISTINCTpaths remain unchanged.0.and-0.,NaNpayloads) may be deduplicated as one value once the data is spilled — the same equalityDISTINCTover sorted data uses; a value class fully processed in memory keeps the binary distinction.DISTINCT ... LIMITover an unbounded stream no longer terminates early once a spill happened: after the spill nothing can be emitted until the input is exhausted, so the limit cannot short-circuit reading, as with external sort.max_bytes_in_distinctalso counts the bitmaps of theLowCardinalityfast path. Size limits are checked even when a chunk adds no new keys.LIMIThint, including when both are crossed in the same chunk.BREAKretains chunk-level behavior.ExternalDistinctWritePart,ExternalDistinctMerge,ExternalDistinctCompressedBytes,ExternalDistinctUncompressedBytesandDistinctTransformsSwitchedToPassThroughprofile events and theTemporaryFilesForDistinctmetric; temporary files respect the existing temporary-data size limits,min_free_disk_space_for_temporary_data,temporary_files_codec,temporary_files_buffer_size, and temporary data disk policies.Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
DISTINCTcan now spill data to disk, like external aggregation and external sort: the new settingsmax_bytes_before_external_distinctandmax_bytes_ratio_before_external_distinctset the threshold; by default, spilling is triggered once query memory usage exceeds half the available memory under applicable server or user limits. Before growing the hash table, ClickHouse checks whether enough memory would remain to write its contents to disk. If not, it spills first. ADISTINCTthat follows anORDER BYkeeps the sorted order when it spills.Workflow [PR]
Sync PR [sync-upstream/pr/116569]