Skip to content

Commit ac25c24

Browse files
zjw1111claude
andcommitted
feat(spill): optimize external sort with leveled merger to reduce write amplification
Introduce LeveledMerger (LSM-tree-like structure) to manage spill files in levels, reducing read/write amplification from O(N/K) to O(log_K(N)). Also adds dynamic estimation of spill parameters based on actual row sizes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 79917cc commit ac25c24

12 files changed

Lines changed: 651 additions & 56 deletions

src/paimon/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ set(PAIMON_CORE_SRCS
253253
core/mergetree/merge_tree_writer.cpp
254254
core/mergetree/in_memory_sort_buffer.cpp
255255
core/mergetree/external_sort_buffer.cpp
256+
core/mergetree/leveled_merger.cpp
256257
core/mergetree/write_buffer.cpp
257258
core/mergetree/levels.cpp
258259
core/mergetree/lookup_file.cpp
@@ -648,6 +649,7 @@ if(PAIMON_BUILD_TESTS)
648649
core/mergetree/merge_tree_writer_test.cpp
649650
core/mergetree/write_buffer_test.cpp
650651
core/mergetree/sort_buffer_test.cpp
652+
core/mergetree/leveled_merger_test.cpp
651653
core/mergetree/sorted_run_test.cpp
652654
core/mergetree/spill_channel_manager_test.cpp
653655
core/mergetree/spill_reader_writer_test.cpp

src/paimon/core/core_options.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,11 @@ struct CoreOptions::Impl {
498498
// Parse local-sort.max-num-file-handles - spill file handle cap for local merge
499499
PAIMON_RETURN_NOT_OK(parser.Parse(Options::LOCAL_SORT_MAX_NUM_FILE_HANDLES,
500500
&local_sort_max_num_file_handles));
501+
if (local_sort_max_num_file_handles < kLocalSortFileHandlesMinimalLimit) {
502+
return Status::Invalid(fmt::format(
503+
"invalid '{}': {}, must be at least {}", Options::LOCAL_SORT_MAX_NUM_FILE_HANDLES,
504+
local_sort_max_num_file_handles, kLocalSortFileHandlesMinimalLimit));
505+
}
501506
// Parse spill-compression - compression codec for spill files
502507
PAIMON_RETURN_NOT_OK(
503508
parser.Parse(Options::SPILL_COMPRESSION, &spill_compress_options.compress));

src/paimon/core/core_options.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ class PAIMON_EXPORT CoreOptions {
181181

182182
const std::map<std::string, std::string>& ToMap() const;
183183

184+
static constexpr int32_t kLocalSortFileHandlesMinimalLimit = 2;
185+
184186
private:
185187
std::optional<std::string> GetDataFileExternalPaths() const;
186188
std::optional<std::string> GetGlobalIndexExternalPath() const;

src/paimon/core/disk/file_io_channel.h

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
#pragma once
1818

1919
#include <cstdint>
20-
#include <memory>
2120
#include <random>
2221
#include <string>
2322

@@ -68,4 +67,9 @@ class PAIMON_EXPORT FileIOChannel {
6867
};
6968
};
7069

70+
struct FileChannelInfo {
71+
FileIOChannel::ID channel_id;
72+
int64_t file_size;
73+
};
74+
7175
} // namespace paimon

src/paimon/core/mergetree/external_sort_buffer.cpp

Lines changed: 77 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
#include "paimon/core/mergetree/external_sort_buffer.h"
1818

19+
#include <algorithm>
1920
#include <cassert>
2021
#include <utility>
2122

@@ -80,9 +81,13 @@ ExternalSortBuffer::ExternalSortBuffer(
8081
user_defined_seq_comparator_(user_defined_seq_comparator),
8182
write_schema_(SpecialFields::CompleteSequenceAndValueKindField(value_schema)),
8283
options_(options),
83-
spill_channel_manager_(std::make_shared<SpillChannelManager>(
84-
options_.GetFileSystem(), options_.GetLocalSortMaxNumFileHandles())),
85-
spill_channel_enumerator_(spill_channel_enumerator) {}
84+
max_fan_in_(options.GetLocalSortMaxNumFileHandles()),
85+
spill_channel_manager_(
86+
std::make_shared<SpillChannelManager>(options_.GetFileSystem(), max_fan_in_)),
87+
leveled_merger_(std::make_unique<LeveledMerger>(max_fan_in_)),
88+
spill_channel_enumerator_(spill_channel_enumerator),
89+
actual_max_fan_in_(max_fan_in_),
90+
spill_batch_size_(options_.GetWriteBatchSize()) {}
8691

8792
ExternalSortBuffer::~ExternalSortBuffer() {
8893
DoClear();
@@ -94,7 +99,10 @@ bool ExternalSortBuffer::HasSpilledData() const {
9499

95100
void ExternalSortBuffer::DoClear() {
96101
in_memory_buffer_->Clear();
97-
CleanupSpillFiles();
102+
103+
spill_channel_manager_->Reset();
104+
total_spill_disk_bytes_ = 0;
105+
leveled_merger_->Clear();
98106
}
99107

100108
void ExternalSortBuffer::Clear() {
@@ -105,18 +113,42 @@ uint64_t ExternalSortBuffer::GetMemorySize() const {
105113
return in_memory_buffer_->GetMemorySize();
106114
}
107115

116+
void ExternalSortBuffer::EstimateSpillParameters() {
117+
int64_t estimated_row_size = in_memory_buffer_->GetEstimateMemoryUseForEachRow();
118+
if (estimated_row_size <= 0) {
119+
return;
120+
}
121+
122+
const int32_t max_batch_size = options_.GetWriteBatchSize();
123+
const int32_t min_batch_size = std::min(kSpillMinBatchSize, max_batch_size);
124+
const int64_t merge_budget = options_.GetWriteBufferSize();
125+
const int64_t max_memory_use_per_handle = merge_budget / max_fan_in_;
126+
127+
spill_batch_size_ = max_memory_use_per_handle / estimated_row_size;
128+
spill_batch_size_ = std::clamp(spill_batch_size_, min_batch_size, max_batch_size);
129+
130+
actual_max_fan_in_ = merge_budget / (spill_batch_size_ * estimated_row_size);
131+
actual_max_fan_in_ =
132+
std::clamp(actual_max_fan_in_, CoreOptions::kLocalSortFileHandlesMinimalLimit, max_fan_in_);
133+
134+
// Re-derive spill_batch_size_ from the clamped actual_max_fan_in_ to stay within merge_budget.
135+
spill_batch_size_ = merge_budget / (actual_max_fan_in_ * estimated_row_size);
136+
spill_batch_size_ = std::clamp(spill_batch_size_, 1, max_batch_size);
137+
138+
leveled_merger_->SetMaxFanIn(actual_max_fan_in_);
139+
}
140+
108141
Result<bool> ExternalSortBuffer::FlushMemory() {
109142
if (!in_memory_buffer_->HasData()) {
110143
return true;
111144
}
112145

113-
int64_t max_spill_disk_size = options_.GetWriteBufferSpillMaxDiskSize();
114-
146+
EstimateSpillParameters();
115147
PAIMON_ASSIGN_OR_RAISE(std::vector<std::unique_ptr<KeyValueRecordReader>> memory_buffer_readers,
116148
in_memory_buffer_->CreateReaders());
117149
PAIMON_RETURN_NOT_OK(SpillMemoryBuffer(std::move(memory_buffer_readers)));
118150
in_memory_buffer_->Clear();
119-
return total_spill_disk_bytes_ < max_spill_disk_size;
151+
return total_spill_disk_bytes_ < options_.GetWriteBufferSpillMaxDiskSize();
120152
}
121153

122154
Result<bool> ExternalSortBuffer::Write(std::unique_ptr<RecordBatch>&& batch) {
@@ -128,11 +160,17 @@ Result<bool> ExternalSortBuffer::Write(std::unique_ptr<RecordBatch>&& batch) {
128160
}
129161

130162
Result<std::vector<std::unique_ptr<KeyValueRecordReader>>> ExternalSortBuffer::CreateReaders() {
131-
PAIMON_ASSIGN_OR_RAISE(std::vector<std::unique_ptr<KeyValueRecordReader>> readers,
132-
CollectSpillReaders());
133163
PAIMON_ASSIGN_OR_RAISE(std::vector<std::unique_ptr<KeyValueRecordReader>> memory_readers,
134164
in_memory_buffer_->CreateReaders());
165+
if (!HasSpilledData()) {
166+
return memory_readers;
167+
}
135168

169+
int32_t max_spill_files = actual_max_fan_in_ - 1;
170+
PAIMON_RETURN_NOT_OK(
171+
leveled_merger_->RunFinalCleanupIfNeeded(max_spill_files, CreateMergeFn()));
172+
PAIMON_ASSIGN_OR_RAISE(std::vector<std::unique_ptr<KeyValueRecordReader>> readers,
173+
CreateSpillReaders(leveled_merger_->GetAllFiles()));
136174
readers.insert(readers.end(), std::make_move_iterator(memory_readers.begin()),
137175
std::make_move_iterator(memory_readers.end()));
138176
return readers;
@@ -142,26 +180,20 @@ bool ExternalSortBuffer::HasData() const {
142180
return in_memory_buffer_->HasData() || HasSpilledData();
143181
}
144182

145-
void ExternalSortBuffer::CleanupSpillFiles() {
146-
spill_channel_manager_->Reset();
147-
total_spill_disk_bytes_ = 0;
148-
}
149-
150-
Result<std::vector<std::unique_ptr<KeyValueRecordReader>>> ExternalSortBuffer::CollectSpillReaders()
151-
const {
183+
Result<std::vector<std::unique_ptr<KeyValueRecordReader>>> ExternalSortBuffer::CreateSpillReaders(
184+
const std::vector<FileChannelInfo>& files) const {
152185
std::vector<std::unique_ptr<KeyValueRecordReader>> readers;
153-
const auto& channel_ids = spill_channel_manager_->GetChannels();
154-
readers.reserve(channel_ids.size());
155-
for (const auto& channel_id : channel_ids) {
156-
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<SpillReader> spill_reader,
186+
readers.reserve(files.size());
187+
for (const auto& file : files) {
188+
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<SpillReader> reader,
157189
SpillReader::Create(options_.GetFileSystem(), key_schema_,
158-
value_schema_, pool_, channel_id));
159-
readers.push_back(std::move(spill_reader));
190+
value_schema_, pool_, file.channel_id));
191+
readers.push_back(std::move(reader));
160192
}
161193
return readers;
162194
}
163195

164-
Result<int64_t> ExternalSortBuffer::SpillToDisk(
196+
Result<FileChannelInfo> ExternalSortBuffer::SpillToDisk(
165197
std::vector<std::unique_ptr<KeyValueRecordReader>>&& readers, int32_t write_batch_size) {
166198
const auto& spill_compress_options = options_.GetSpillCompressOptions();
167199
PAIMON_ASSIGN_OR_RAISE(
@@ -202,41 +234,37 @@ Result<int64_t> ExternalSortBuffer::SpillToDisk(
202234
PAIMON_RETURN_NOT_OK(spill_writer->Close());
203235
PAIMON_ASSIGN_OR_RAISE(int64_t spilled_file_size, spill_writer->GetFileSize());
204236
cleanup_guard.Release();
205-
return spilled_file_size;
237+
return FileChannelInfo{spill_writer->GetChannelId(), spilled_file_size};
206238
}
207239

208240
Status ExternalSortBuffer::SpillMemoryBuffer(
209241
std::vector<std::unique_ptr<KeyValueRecordReader>>&& readers) {
210-
PAIMON_ASSIGN_OR_RAISE(int64_t spill_file_size,
211-
SpillToDisk(std::move(readers), options_.GetWriteBatchSize()));
212-
total_spill_disk_bytes_ += spill_file_size;
213-
214-
if (options_.GetLocalSortMaxNumFileHandles() > 0 &&
215-
static_cast<int32_t>(spill_channel_manager_->GetChannels().size()) >=
216-
options_.GetLocalSortMaxNumFileHandles()) {
217-
PAIMON_RETURN_NOT_OK(MergeSpilledFiles());
218-
}
219-
return Status::OK();
242+
PAIMON_ASSIGN_OR_RAISE(FileChannelInfo file_info,
243+
SpillToDisk(std::move(readers), spill_batch_size_));
244+
total_spill_disk_bytes_ += file_info.file_size;
245+
leveled_merger_->AddFile(file_info);
246+
return leveled_merger_->RunCompactionIfNeeded(CreateMergeFn());
220247
}
221248

222-
Status ExternalSortBuffer::MergeSpilledFiles() {
223-
if (spill_channel_manager_->GetChannels().size() < 2) {
224-
return Status::OK();
225-
}
226-
auto spill_channel_ids_before_merge = spill_channel_manager_->GetChannels();
227-
auto cleanup_guard = ScopeGuard([&]() {
228-
for (const auto& spill_channel_id : spill_channel_ids_before_merge) {
229-
[[maybe_unused]] auto status = spill_channel_manager_->DeleteChannel(spill_channel_id);
230-
}
231-
});
249+
LeveledMerger::MergeFn ExternalSortBuffer::CreateMergeFn() {
250+
return [this](const std::vector<FileChannelInfo>& files) -> Result<FileChannelInfo> {
251+
return MergeAndReplaceFiles(files);
252+
};
253+
}
232254

255+
Result<FileChannelInfo> ExternalSortBuffer::MergeAndReplaceFiles(
256+
const std::vector<FileChannelInfo>& files) {
233257
PAIMON_ASSIGN_OR_RAISE(std::vector<std::unique_ptr<KeyValueRecordReader>> readers,
234-
CollectSpillReaders());
235-
PAIMON_ASSIGN_OR_RAISE(int64_t merged_file_size,
236-
SpillToDisk(std::move(readers), options_.GetWriteBatchSize()));
237-
total_spill_disk_bytes_ = merged_file_size;
258+
CreateSpillReaders(files));
259+
PAIMON_ASSIGN_OR_RAISE(FileChannelInfo output,
260+
SpillToDisk(std::move(readers), spill_batch_size_));
261+
total_spill_disk_bytes_ += output.file_size;
238262

239-
return Status::OK();
263+
for (const auto& file : files) {
264+
[[maybe_unused]] auto status = spill_channel_manager_->DeleteChannel(file.channel_id);
265+
total_spill_disk_bytes_ -= file.file_size;
266+
}
267+
return output;
240268
}
241269

242270
} // namespace paimon

src/paimon/core/mergetree/external_sort_buffer.h

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
#include "paimon/core/core_options.h"
2626
#include "paimon/core/disk/file_io_channel.h"
2727
#include "paimon/core/mergetree/in_memory_sort_buffer.h"
28+
#include "paimon/core/mergetree/leveled_merger.h"
2829
#include "paimon/core/mergetree/sort_buffer.h"
2930
#include "paimon/record_batch.h"
3031
#include "paimon/result.h"
@@ -63,14 +64,18 @@ class ExternalSortBuffer : public SortBuffer {
6364
bool HasData() const override;
6465

6566
private:
67+
static constexpr int32_t kSpillMinBatchSize = 256;
68+
6669
void DoClear();
70+
void EstimateSpillParameters();
6771
bool HasSpilledData() const;
68-
Result<std::vector<std::unique_ptr<KeyValueRecordReader>>> CollectSpillReaders() const;
69-
Result<int64_t> SpillToDisk(std::vector<std::unique_ptr<KeyValueRecordReader>>&& readers,
70-
int32_t write_batch_size);
71-
Status MergeSpilledFiles();
72+
Result<std::vector<std::unique_ptr<KeyValueRecordReader>>> CreateSpillReaders(
73+
const std::vector<FileChannelInfo>& files) const;
74+
Result<FileChannelInfo> SpillToDisk(
75+
std::vector<std::unique_ptr<KeyValueRecordReader>>&& readers, int32_t write_batch_size);
76+
LeveledMerger::MergeFn CreateMergeFn();
77+
Result<FileChannelInfo> MergeAndReplaceFiles(const std::vector<FileChannelInfo>& files);
7278
Status SpillMemoryBuffer(std::vector<std::unique_ptr<KeyValueRecordReader>>&& readers);
73-
void CleanupSpillFiles();
7479

7580
ExternalSortBuffer(std::unique_ptr<InMemorySortBuffer>&& in_memory_buffer,
7681
const std::shared_ptr<arrow::Schema>& key_schema,
@@ -90,10 +95,14 @@ class ExternalSortBuffer : public SortBuffer {
9095
const std::shared_ptr<FieldsComparator> user_defined_seq_comparator_;
9196
const std::shared_ptr<arrow::Schema> write_schema_;
9297
const CoreOptions options_;
98+
const int32_t max_fan_in_;
9399
const std::shared_ptr<SpillChannelManager> spill_channel_manager_;
94100

101+
std::unique_ptr<LeveledMerger> leveled_merger_;
95102
std::shared_ptr<FileIOChannel::Enumerator> spill_channel_enumerator_;
96103
int64_t total_spill_disk_bytes_ = 0;
104+
int32_t actual_max_fan_in_;
105+
int32_t spill_batch_size_;
97106
};
98107

99108
} // namespace paimon

src/paimon/core/mergetree/in_memory_sort_buffer.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ InMemorySortBuffer::InMemorySortBuffer(int64_t last_sequence_number,
5353
void InMemorySortBuffer::Clear() {
5454
buffered_batches_.clear();
5555
current_memory_in_bytes_ = 0;
56+
total_row_count_ = 0;
5657
}
5758

5859
uint64_t InMemorySortBuffer::GetMemorySize() const {
@@ -82,7 +83,11 @@ Result<bool> InMemorySortBuffer::Write(std::unique_ptr<RecordBatch>&& moved_batc
8283
buffered_batch.struct_array = std::move(value_struct_array);
8384
buffered_batch.row_kinds = batch->GetRowKind();
8485
next_sequence_number_ += buffered_batch.struct_array->length();
86+
total_row_count_ += buffered_batch.struct_array->length();
8587
buffered_batches_.push_back(std::move(buffered_batch));
88+
if (total_row_count_ > 0) {
89+
estimated_memory_use_for_each_row_ = current_memory_in_bytes_ / total_row_count_;
90+
}
8691
return current_memory_in_bytes_ < write_buffer_size_;
8792
}
8893

@@ -107,6 +112,10 @@ bool InMemorySortBuffer::HasData() const {
107112
return !buffered_batches_.empty();
108113
}
109114

115+
uint64_t InMemorySortBuffer::GetEstimateMemoryUseForEachRow() const {
116+
return estimated_memory_use_for_each_row_;
117+
}
118+
110119
// TODO(jinli.zjw): Consider making the memory estimation more accurate.
111120
// https://github.com/alibaba/paimon-cpp/pull/206#discussion_r3021325389
112121
Result<int64_t> InMemorySortBuffer::EstimateMemoryUse(const std::shared_ptr<arrow::Array>& array) {

src/paimon/core/mergetree/in_memory_sort_buffer.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,12 @@ class InMemorySortBuffer : public SortBuffer {
6363
Result<bool> Write(std::unique_ptr<RecordBatch>&& batch) override;
6464
Result<std::vector<std::unique_ptr<KeyValueRecordReader>>> CreateReaders() override;
6565
bool HasData() const override;
66+
uint64_t GetEstimateMemoryUseForEachRow() const;
6667

68+
private:
6769
/// Estimate memory usage of an Arrow array.
6870
static Result<int64_t> EstimateMemoryUse(const std::shared_ptr<arrow::Array>& array);
6971

70-
private:
7172
const std::shared_ptr<MemoryPool> pool_;
7273
const std::shared_ptr<arrow::DataType> value_type_;
7374
const std::vector<std::string> trimmed_primary_keys_;
@@ -78,6 +79,8 @@ class InMemorySortBuffer : public SortBuffer {
7879

7980
std::vector<BufferedWriteBatch> buffered_batches_;
8081
uint64_t current_memory_in_bytes_ = 0;
82+
uint64_t estimated_memory_use_for_each_row_ = 0;
83+
int64_t total_row_count_ = 0;
8184
int64_t next_sequence_number_ = 0;
8285
};
8386

0 commit comments

Comments
 (0)