ClickHouse Merges Columns One at a Time: The Vertical Merge Algorithm
ClickHouse's vertical merge writes a one-byte-per-row row-source stream while merging the key, then gathers each wide column separately from the source parts using that stream.
TL;DR: ClickHouse’s vertical merge splits a merge into two stages: merge the key and record where each output row came from (one byte per row), then gather each payload column separately. Peak memory drops from “all columns at once” to “one column at a time” — which is the difference between a merge that OOMs and one that doesn’t.
Every MergeTree table is a pile of immutable parts. Every part is a pile of immutable columns. Background merges take a contiguous range of parts and rewrite them as one bigger part. That sounds like a simple copy, and for a table with three narrow columns it is one.
Now consider a table with two hundred columns, one of which is a 40 MB String
of raw JSON and the rest are UInt64 counters.
The obvious merge implementation reads all two hundred columns of the source parts, merges the sorting key, and writes two hundred columns out. It has to hold, in memory at once, the merged key plus every payload column of the rows it is currently holding — which for a wide table means the peak memory of a merge is proportional to the sum of all column widths, not to the width of the widest column.
That is a bad trade. The payload columns are almost always much larger than the key, and they are almost always independent of the key: merge the keys first and separately, and you can then reconstruct each payload column without ever holding two payload columns in memory at the same time.
ClickHouse implements exactly that. It is called the vertical merge algorithm, and it is one of the more elegant pieces of engineering in the MergeTree code base. The trick that makes it work is a one-byte-per-row side-channel file that records where each output row came from.
The problem, stated precisely
Suppose we merge three parts, each sorted by the key k:
part A (10 rows) part B (10 rows) part C (10 rows)
k=1 a1=... k=1 b1=... k=2 c1=...
k=1 a2=... k=2 b2=... k=2 c2=...
k=3 a3=... k=2 b3=... k=5 c3=...
... ... ...A horizontal merge reads all three parts’ k columns, merges them into one
sorted stream, and then — because MergeTree guarantees that all rows with the
same key are adjacent and, within a key, appear in source-part order — it can
reconstruct every other column by a gather: walk the merged key stream, and
for each row, take the next value of that column from the part the row came
from.
The output order of the merged key is not the concatenation of the inputs; it is the k-way merge of them. So for each output row we need to know which input part it came from.
That is the entire information content of the vertical merge’s side channel.
And it is exactly one small integer per row.
The horizontal stage: merging the key and writing the row sources
The vertical merge is not a separate code path that replaces the normal merge.
It is a second stage that runs after the normal merge has merged the key. The
normal merge is called the “horizontal” stage, and the code says so explicitly —
the stage class is called ExecuteAndFinalizeHorizontalPart:
// src/Storages/MergeTree/MergeTask.h:63
/**
* Overview of the merge algorithm
*
* Each merge is executed sequentially block by block.
* The main idea is to make a merge not a subroutine which is executed
* in a thread pool and may occupy a thread for a period of time,
* but to make a merge a coroutine which can suspend the execution
* in some points and then resume the execution from this point.
*
* A perfect point where to suspend the execution is after the work over a block is finished.
* The task itself will be executed via BackgroundJobExecutor.
*
* The interface of the task is simple.
* The main method is `execute()` which will return true, if the task wants to be executed again and false otherwise.
*
* With this kind of task we can give a merge a priority.
* A priority is simple - the lower the size of the merge, the higher priority.
* So, if ClickHouse wants to merge some really big parts into a bigger part,
* then it will be executed for a long time, because the result of the merge is not really needed immediately.
* It is better to merge small parts as soon as possible.
*/Note the second half of that comment: the merge is a coroutine. It is resumed
by the background pool between blocks, which is what makes it possible to give
a small merge priority over a huge one without pinning a thread. The vertical
stage is expressed in the same idiom: a list of sub-tasks, each of which returns
true when it wants to be resumed.
The side channel is a real file, managed by a small class:
// src/Storages/MergeTree/MergeTask.cpp:257
/// Manages the "rows_sources" temporary file that is used during vertical merge.
class RowsSourcesTemporaryFile : public ITemporaryFileLookup
{
public:
/// A logical name of the temporary file under which it will be known to the plan steps that use it.
static constexpr auto FILE_ID = "rows_sources";
explicit RowsSourcesTemporaryFile(TemporaryDataOnDiskScopePtr temporary_data_on_disk_)
: temporary_data_on_disk(temporary_data_on_disk_->childScope({CurrentMetrics::TemporaryFilesForMerge}))
{
}and it is deliberately re-openable, so that several columns can be gathered in
parallel from independent file handles — getTemporaryFileForReading returns
tmp_data_buffer->read(), a fresh reader each time, with the comment “Reopen
the file for each read so that multiple reads can be performed in parallel and
there is no need to seek to the beginning.”
That matters: the row-source stream is consumed once per column, and each column’s gather starts from the beginning of the file. If the file were a single sequential stream, gathering column #2 would have to seek back to offset 0 and re-read column #1’s worth of bytes. Re-opening gives every column its own independent read cursor.
The encoding: one byte per row
The row source is not a struct with a source number and a flag in two fields. It is a single byte with two bit-fields packed into it:
// src/Processors/Transforms/ColumnGathererTransform.h:15
/// Tiny struct, stores number of a Part from which current row was fetched, and insertion flag.
struct RowSourcePart
{
UInt8 data = 0;
RowSourcePart() = default;
explicit RowSourcePart(size_t source_num, bool skip_flag = false)
{
static_assert(sizeof(*this) == 1, "Size of RowSourcePart is too big due to compiler settings");
setSourceNum(source_num);
setSkipFlag(skip_flag);
}
size_t getSourceNum() const { return data & MASK_NUMBER; }
/// In CollapsingMergeTree case flag means "skip this rows"
bool getSkipFlag() const { return (data & MASK_FLAG) != 0; }
void setSourceNum(size_t source_num)
{
data = (data & MASK_FLAG) | (static_cast<UInt8>(source_num) & MASK_NUMBER);
}
void setSkipFlag(bool flag)
{
data = flag ? data | MASK_FLAG : data & ~MASK_FLAG;
}
static constexpr size_t MAX_PARTS = 0x7F;
static constexpr UInt8 MASK_NUMBER = 0x7F;
static constexpr UInt8 MASK_FLAG = 0x80;
};The top bit is the “skip this row” flag (used when a lightweight delete mask removes rows during the merge), and the low seven bits are the source part number. Seven bits means at most 127 source parts per merge — and you can watch that limit propagate all the way into the merge-algorithm choice (see “When is it worth it?” below).
The static_assert is not decoration. If the compiler ever made this struct two
bytes, the row-source file would double in size and every write to it would
become a two-byte write. The assert turns a silent performance regression into a
build failure.
Who writes the bytes
The horizontal stage’s merge transform writes one byte per output row as it
emits it. For the Ordinary merging mode that is the plain k-way merge
algorithm:
// src/Processors/Merges/Algorithms/MergingSortedAlgorithm.cpp:444
void MergingSortedAlgorithm::insertRow(const SortCursorImpl & current)
{
auto write_row_source = [&](bool skipped)
{
if (out_row_sources_buf)
{
RowSourcePart row_source(current.order, skipped);
out_row_sources_buf->write(row_source.data);
}
};
size_t current_row = current.getRow();
if (const auto * mask = source_row_filter_masks[current.order])
{
if ((*mask)[current_row])
merged_data.insertRow(current.all_columns, current_row, current.rows);
write_row_source(!(*mask)[current_row]);
}
else
{
merged_data.insertRow(current.all_columns, current_row, current.rows);
write_row_source(false);
}
}current.order is the index of the source stream the row came from. The merged
algorithm already knows this — it is the whole reason it can merge — so
emitting it costs one byte write per row. One byte per row of overhead on a
merge that is about to rewrite gigabytes.
Note the ordering: merged_data.insertRow(...) happens first, then the row
source is written. The write buffer is only non-null for a vertical merge — the
merge step is constructed with the file name only when the vertical algorithm
was chosen:
// src/Storages/MergeTree/MergeTask.cpp:3654
auto merge_step = std::make_unique<MergePartsStep>(
merge_parts_query_plan.getCurrentHeader(),
sort_description,
partition_and_sorting_required_columns,
global_ctx->merging_params,
(is_vertical_merge ? RowsSourcesTemporaryFile::FILE_ID : ""), /// rows_sources' temporary file is used only for vertical merge
filter_column_name,
(*merge_tree_settings)[MergeTreeSetting::merge_max_block_size],
(*merge_tree_settings)[MergeTreeSetting::merge_max_block_size_bytes],
max_dynamic_subcolumns,
is_vertical_merge,
sorting_queue_strategy,
cleanup,
global_ctx->time_of_merge);so a horizontal merge pays nothing for the side channel.
The vertical stage: gathering one column at a time
Now the second stage. It does not merge anything. It gathers. For each non-key column, in turn:
- open a read stream for that column in every source part;
- union those streams;
- run a
ColumnGathererTransformover them, which uses the row-source file to decide which source’s value belongs at each output position.
Here is the step that wires the gatherer into the plan:
// src/Storages/MergeTree/MergeTask.cpp:2172
createReadFromPartStep(
MergeTreeSequentialSourceType::Merge,
*plan_for_part,
*global_ctx->data,
global_ctx->storage_snapshot,
RangesInDataPart(global_ctx->future_part->parts[part_num], nullptr, part_num, part_starting_offset),
global_ctx->alter_conversions[part_num],
global_ctx->merged_part_offsets,
Names{column_name},
global_ctx->input_rows_filtered,
apply_deleted_mask,
/*filter=*/ std::nullopt,
ctx->read_with_direct_io,
ctx->use_prefetch,
global_ctx->context,
getLogger("VerticalMergeStage"));Names{column_name} — exactly one column is read per part. The read plan for
a vertical merge is a column read plan, not a row read plan. Every source part
is opened, and only the column of interest is decoded.
Then the per-part streams are unioned, and a ColumnGathererStep is added to
the plan. The gatherer is a merging transform, so it plugs into the same
IMergingTransform machinery as the key merge: its input is one column per
source part, its output is one merged column, and the row-source file is handed
to it as a ReadBuffer by name.
The gather loop
The heart of it is ColumnGathererStream::gather. It reads the row-source bytes
and copies values out of the per-part source columns:
// src/Processors/Transforms/ColumnGathererTransform.h:131
template <typename Column>
void ColumnGathererStream::gather(Column & column_res)
{
row_sources_buf.nextIfAtEnd();
RowSourcePart * row_source_pos = reinterpret_cast<RowSourcePart *>(row_sources_buf.position());
RowSourcePart * row_sources_end = reinterpret_cast<RowSourcePart *>(row_sources_buf.buffer().end());
if (next_required_source == -1)
{
/// Start new column.
/// Actually reserve works only for fixed size columns.
/// So it's safe to ignore preferred size in bytes and call reserve for number of rows.
size_t size_to_reserve = std::min(static_cast<size_t>(row_sources_end - row_source_pos), block_preferred_size_rows);
column_res.reserve(size_to_reserve);
}
next_required_source = -1;
/// We use do ... while here to ensure there will be at least one iteration of this loop.
/// Because the column_res.byteSize() could be bigger than block_preferred_size_bytes already at this point.
do
{
if (row_source_pos >= row_sources_end)
break;
RowSourcePart row_source = *row_source_pos;
size_t source_num = row_source.getSourceNum();
Source & source = sources[source_num];
bool source_skip = row_source.getSkipFlag();
if (source.pos >= source.size) /// Fetch new block from source_num part
{
next_required_source = source_num;
return;
}
++row_source_pos;
/// Consecutive optimization. TODO: precompute lengths
size_t len = 1;
size_t max_len = std::min(static_cast<size_t>(row_sources_end - row_source_pos), source.size - source.pos); // interval should be in the same block
while (len < max_len && row_source_pos->data == row_source.data)
{
++len;
++row_source_pos;
}
row_sources_buf.position() = reinterpret_cast<char *>(row_source_pos);
if (!source_skip)
{
/// Whole block could be produced via copying pointer from current block
if (source.pos == 0 && source.size == len)
{
/// If current block already contains data, return it.
/// Whole column from current source will be returned on next read() iteration.
source_to_fully_copy = &source;
return;
}
if (len == 1)
column_res.insertFrom(*source.column, source.pos);
else
column_res.insertRangeFrom(*source.column, source.pos, len);
}
source.pos += len;
} while (column_res.size() < block_preferred_size_rows && column_res.byteSize() < block_preferred_size_bytes);
}Three optimizations are hiding in there.
Run-length coalescing. The inner while loop scans forward over
consecutive row-source bytes that are identical. Since the byte encodes “which
source part”, a run of identical bytes means “these N output rows came from the
same source part, consecutively” — which, because the source part’s own column
is read in order, means “these N output rows are exactly source part’s rows
[pos, pos+N)”. So the whole run is copied with a single insertRangeFrom call
instead of N single-element inserts. The comment
/// Consecutive optimization. TODO: precompute lengths is honest about the
remaining inefficiency: the run length is rediscovered for every column, N
times.
Whole-column pointer steal. If the run covers the entire remaining source
column (source.pos == 0 && source.size == len), the output column can simply
be the source column — no copy at all. The transform returns the source
ColumnPtr directly and sets source_to_fully_copy, and the merge() method
hands it out:
// src/Processors/Transforms/ColumnGathererTransform.cpp:114
else
{
res.addColumn(source_to_fully_copy->column);
}For a merge where one part dominates — the common case after a few rounds of merging, where you merge one big part with one small one — most columns of the big part are copied by pointer. The “merge” of such a column is essentially free.
Sparse output. The source_skip flag comes from the lightweight-delete
mask: rows deleted by a DELETE are recorded in the row-source stream with the
skip bit set, and the gatherer simply does not copy them out. The horizontal
stage does the same filtering, so the two stages agree on which rows exist
without either having to re-evaluate the delete predicate.
Writing the gathered column
The gathered column is written by MergedColumnOnlyOutputStream — the vertical
stage’s counterpart of MergedBlockOutputStream, except that it writes exactly
one column. Its constructor carries the invariant that makes the whole thing
safe:
// src/Storages/MergeTree/MergedColumnOnlyOutputStream.cpp:36
/// Granularity is never recomputed while writing only columns.
MergeTreeWriterSettings writer_settings(The horizontal stage has already decided where the granule boundaries are (it wrote the marks for the key columns), and the payload columns must use the same boundaries, or the part would be corrupt.
When is it worth it?
Vertical merging is not always a win. It has costs: an extra pass over the
row-source file per column, an extra file in the temporary space, and a pipeline
per column. So chooseMergeAlgorithm() gates it behind a set of conditions:
// src/Storages/MergeTree/MergeTask.cpp:3760
MergeAlgorithm MergeTask::ExecuteAndFinalizeHorizontalPart::chooseMergeAlgorithm() const
{
const size_t total_rows_count = global_ctx->merge_list_element_ptr->total_rows_count;
const size_t total_size_bytes_uncompressed = global_ctx->merge_list_element_ptr->total_size_bytes_uncompressed;
const auto & merge_tree_settings = global_ctx->data_settings;
if (global_ctx->deduplicate)
return MergeAlgorithm::Horizontal;
if ((*merge_tree_settings)[MergeTreeSetting::enable_vertical_merge_algorithm] == 0)
return MergeAlgorithm::Horizontal;
if (ctx->need_remove_expired_values)
{
/// `TTLTransform` stops reading after the first block when the rows TTL has expired for the whole part,
/// while a vertical merge reads every key row to write `rows_sources`.
if (global_ctx->future_part->merge_type == MergeType::TTLDrop && global_ctx->metadata_snapshot->hasRowsTTL())
return MergeAlgorithm::Horizontal;
if (!canVerticalTTLDelete(*global_ctx))
return MergeAlgorithm::Horizontal;
}
if (global_ctx->future_part->part_format.part_type != MergeTreeDataPartType::Wide)
return MergeAlgorithm::Horizontal;
if (global_ctx->cleanup)
return MergeAlgorithm::Horizontal;and then the quantitative thresholds:
// src/Storages/MergeTree/MergeTask.cpp:3793
bool is_supported_storage =
global_ctx->merging_params.mode == MergeTreeData::MergingParams::Ordinary ||
global_ctx->merging_params.mode == MergeTreeData::MergingParams::Collapsing ||
global_ctx->merging_params.mode == MergeTreeData::MergingParams::Replacing ||
global_ctx->merging_params.mode == MergeTreeData::MergingParams::VersionedCollapsing;
bool enough_ordinary_cols = global_ctx->gathering_columns.size() >= (*merge_tree_settings)[MergeTreeSetting::vertical_merge_algorithm_min_columns_to_activate];
bool enough_total_rows = total_rows_count >= (*merge_tree_settings)[MergeTreeSetting::vertical_merge_algorithm_min_rows_to_activate];
bool enough_total_bytes = total_size_bytes_uncompressed >= (*merge_tree_settings)[MergeTreeSetting::vertical_merge_algorithm_min_bytes_to_activate];
bool no_parts_overflow = global_ctx->future_part->parts.size() <= RowSourcePart::MAX_PARTS;
auto merge_alg = (is_supported_storage && enough_total_rows && enough_total_bytes && enough_ordinary_cols && no_parts_overflow) ?
MergeAlgorithm::Vertical : MergeAlgorithm::Horizontal;
return merge_alg;
}Read those conditions as a description of when the vertical algorithm is the right shape.
Wide parts only, because a Compact part stores all columns in one data file
and you cannot read one column of it without reading the others. No
deduplication, because deduplication compares whole rows and so needs all columns
at once. Only Ordinary/Collapsing/Replacing/VersionedCollapsing merging
modes, because Summing/Aggregating/Coalescing merge the values of equal
keys and so have to merge the payload rather than gather it. At least 11 non-key
columns (vertical_merge_algorithm_min_columns_to_activate) and at least
16·8192 rows (vertical_merge_algorithm_min_rows_to_activate) — below those, the
extra machinery is not worth it. And at most 127 source parts, because
RowSourcePart::MAX_PARTS is 0x7F and the one-byte encoding cannot express
more.
The default thresholds live in MergeTreeSettings:
enable_vertical_merge_algorithm = 1,
vertical_merge_algorithm_min_rows_to_activate = 16 * 8192,
vertical_merge_algorithm_min_bytes_to_activate = 0, and
vertical_merge_algorithm_min_columns_to_activate = 11.
Note the framing of the setting description: “potentially at the cost of merge speed”. Vertical merging is a memory optimization, not a speed one. It trades IO locality (one column at a time, so each source file is opened and read once per column rather than once for all columns) for peak memory. For a table where one column dominates the size, that trade is usually right; for a table with many equally-sized columns, the re-reading can dominate.
The Wide-only restriction, and the escape hatch
The Wide restriction is the interesting one. A Compact part interleaves all
columns of a row in a single .bin file, so “read only column X” is impossible
— you would have to decode the whole file. The sources may still be Compact
while the result is Wide, and that case is allowed by
allow_vertical_merges_from_compact_to_wide_parts (default true):
// src/Storages/MergeTree/MergeTask.cpp:3784
if (!(*merge_tree_settings)[MergeTreeSetting::allow_vertical_merges_from_compact_to_wide_parts])
{
for (const auto & part : global_ctx->future_part->parts)
{
if (!isWidePart(part))
return MergeAlgorithm::Horizontal;
}
}A Compact source is read once per column during the vertical stage, so its file gets decoded N times. That is expensive but it is bounded — and it buys the memory reduction for wide tables whose legacy parts happen to be Compact.
Progress accounting
A merge is a long-running background job, and system.merges shows a progress
bar. For a vertical merge, “progress” spans two axes: first the key columns are
merged (the horizontal stage), then each payload column is gathered (the
vertical stage). The horizontal stage is weighted by the size of the key
columns relative to the whole part; the vertical stage is weighted by the size
of each individual column:
// src/Storages/MergeTree/ColumnSizeEstimator.h:9
/* Allow to compute more accurate progress statistics */
class ColumnSizeEstimator
{
using ColumnToSize = std::map<String, UInt64>;
ColumnToSize map;
public:
/// Stores approximate size of columns in bytes
/// Exact values are not required since it used for relative values estimation (progress).
size_t sum_total = 0;
size_t sum_index_columns = 0;
size_t sum_ordinary_columns = 0;
ColumnSizeEstimator(ColumnToSize && map_, const NamesAndTypesList & key_columns, const NamesAndTypesList & ordinary_columns)
: map(std::move(map_))
{
for (const auto & [name, _] : key_columns)
if (!map.contains(name)) map[name] = 0;
for (const auto & [name, _] : ordinary_columns)
if (!map.contains(name)) map[name] = 0;
for (const auto & [name, _] : key_columns)
sum_index_columns += map.at(name);
for (const auto & [name, _] : ordinary_columns)
sum_ordinary_columns += map.at(name);
sum_total = std::max(static_cast<decltype(sum_index_columns)>(1), sum_index_columns + sum_ordinary_columns);
}
Float64 columnWeight(const String & column) const
{
return static_cast<Float64>(map.at(column)) / static_cast<Float64>(sum_total);
}
Float64 keyColumnsWeight() const
{
return static_cast<Float64>(sum_index_columns) / static_cast<Float64>(sum_total);
}
};The weights are proportional to bytes, not rows, so a merge that is 90% one
giant String column reports 90% progress for most of its duration and then
jumps. That’s the honest answer for a byte-oriented operation. The sizes come
from the parts themselves (accumulateColumnSizes, a cheap per-part
statistic), so the whole estimator costs one map construction per merge.
The horizontal stage’s contribution: the row count assertion
There is one more piece of rigor worth pointing out. After the horizontal stage finishes and the row-source file is closed, the code checks that the file contains exactly one byte per input row:
// src/Storages/MergeTree/MergeTask.cpp:2055
size_t rows_sources_count = ctx->rows_sources_temporary_file->finalizeWriting();
/// In special case, when there is only one source part, and no rows were skipped, we may have
/// skipped writing rows_sources file. Otherwise rows_sources_count must be equal to the total
/// number of input rows.
/// Note that only one byte index is written for each row, so number of rows is equals to the number of bytes written.
if ((rows_sources_count > 0 || global_ctx->future_part->parts.size() > 1) && sum_input_rows_exact != rows_sources_count + input_rows_filtered)
throw Exception(
ErrorCodes::LOGICAL_ERROR,
"Number of rows in source parts ({}) excluding filtered rows ({}) differs from number "
"of bytes written to rows_sources file ({}). It is a bug.",
sum_input_rows_exact, input_rows_filtered, rows_sources_count);This is a cheap, total consistency check on the side channel. If the horizontal stage and the vertical stage ever disagree about which rows exist, the merge fails loudly rather than producing a part whose payload columns are misaligned with its key — which would be a silent data corruption.
Note the subtlety in the condition: with exactly one source part and no filtered rows, the file may legitimately be empty (a single-source merge does not need to record where rows came from), so the check is skipped in that one case.
Prefetching the next column
Because the vertical stage reads one column at a time, and each column read is a separate pipeline over the source parts, the obvious optimization is to start reading column N+1 while column N is still being written. The code does exactly that, and only for remote filesystems, where the latency of the first read of a column is the dominant cost:
// src/Storages/MergeTree/MergeTask.cpp:2076
bool all_parts_on_remote_disks = std::ranges::all_of(global_ctx->future_part->parts, [](const auto & part) { return part->isStoredOnRemoteDisk(); });
ctx->use_prefetch = all_parts_on_remote_disks && storage_settings[MergeTreeSetting::vertical_merge_remote_filesystem_prefetch];
if (ctx->use_prefetch && ctx->it_name_and_type != global_ctx->gathering_columns.end())
ctx->prepared_pipeline = createPipelineForReadingOneColumn(ctx->it_name_and_type->name);The “prefetching” is simply that building the pipeline — which opens the parts
and issues the first read requests — starts the IO. It is not a separate
prefetch thread; it is eager pipeline construction, one column ahead
(prepareVerticalMergeForOneColumn builds the next column’s pipeline while the
current one runs).
The whole flow
source parts (Wide)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ k, a, b, c │ │ k, a, b, c │ │ k, a, b, c │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└────────────┬────┴─────────────────┘
│ HORIZONTAL STAGE
│ k-way merge of the key columns
│ emits: merged k + rows_sources (1 byte/row)
▼
┌──────────────────────────────────────────┐
│ rows_sources: 00 00 01 01 02 00 01 ... │
└──────────────────────────────────────────┘
│
┌─────────────────┼──────────────────┐
▼ ▼ ▼
gather col a gather col b gather col c VERTICAL STAGE
read a from read b from read c from one column at a time
each part, each part, each part,
copy by copy by copy by
rows_sources rows_sources rows_sources
│ │ │
▼ ▼ ▼
└────────────┬────┴──────────────────┘
▼
result part (Wide): k, a, b, cWhat the hack buys you, and what it costs
It buys you peak memory proportional to the widest column, not the sum of the
columns. For a table with a 40 MB String column and fifty UInt64 columns,
a horizontal merge holds the payload of all the columns at once; a vertical
merge holds one column at a time. That is the difference between a merge that
OOMs and one that does not — and it is what makes the “wide part” format
practical at all.
It costs you IO locality. Each column is a separate pass over the source
parts, so a Compact source file is decoded once per column — which is why
allow_vertical_merges_from_compact_to_wide_parts defaults to true but
carries a warning in its description.
It costs you a temporary file. One byte per row of scratch space: 100 MB for a 100-million-row merge. Trivial compared to the data being merged, but not zero.
It costs you the ability to merge every kind of table. Summing,
Aggregating and Coalescing merges have to combine the values of equal keys,
which is a merge, not a gather. They stay horizontal, permanently.
It is not automatically faster. The setting description says it plainly: “potentially at the cost of merge speed”. The vertical algorithm optimizes memory, and only optimizes IO when a whole column happens to come from a single source part and can be stolen by pointer.
Seeing it in action
You can see which algorithm a merge used from the profile events:
SELECT
event,
value
FROM system.events
WHERE event IN (
'MergeVerticalStageExecuteMilliseconds',
'MergeHorizontalStageTotalMilliseconds',
'MergeVerticalStageTotalMilliseconds'
);MergeVerticalStageExecuteMilliseconds is only non-zero for tables where the
vertical algorithm actually ran. If it is zero on your wide table, check
enable_vertical_merge_algorithm, the part format (Compact parts cannot be
vertically merged), and the vertical_merge_algorithm_min_* thresholds.
And to see the tables that would benefit:
SELECT
database,
table,
formatReadableSize(sum(bytes_on_disk)) AS size,
sum(rows) AS rows
FROM system.parts
WHERE active
GROUP BY database, table
ORDER BY sum(bytes_on_disk) DESC
LIMIT 10;A table with hundreds of columns and a large String payload is exactly the
shape the vertical merge was built for. A table with ten narrow columns is not,
and the thresholds will keep it horizontal.
Wrapping up
The one thing to remember: the vertical merge is a memory optimization, not a speed one. It trades IO locality for peak memory, and it wins big exactly when one column dominates your table’s width.
The part people get wrong: the whole thing hinges on a one-byte-per-row side channel. If you ever see a merge fail with “It is a bug”, that assertion is the row-count check, and it means the two stages disagreed about which rows exist.
What to go do about it: count your columns. Under 11, or under 16·8192 rows, the vertical algorithm will never kick in — and that is deliberate, not a bug.