1
0
mirror of https://github.com/facebook/rocksdb.git synced 2026-10-23 08:09:02 +03:00
Files
rocksdb/util/auto_tune_compressor.cc
Peter Dillinger fcf382c95c Integrated and ratio-aware compression auto-skip (#15079)
Summary:
Auto-skip compression (stop attempting compression on data that isn't compressing well, to save CPU) was previously an experimental CompressionManager wrapper (CreateAutoSkipCompressionManager) whose predictor lived in each per-thread working area. Besides being fragmented across parallel workers and reset on a tumbling window, it decided based on how OFTEN blocks failed to compress, ignoring how MUCH they compress -- so on mixed data it could give up substantial compression to save a little CPU (see performance results).

This makes auto-skip a first-class, optional, ratio-aware feature of the block-based table builder, controlled by two new CompressionOptions fields:
 - auto_skip (default false): the on/off switch. There is no separate threshold knob; the decision reuses max_compressed_bytes_per_kb (the existing per-block "minimum compression worth keeping" bar) as the bar for whether attempting compression on the stream is worthwhile.
 - auto_skip_min_sample_every: sampling interval that bounds the local impact around a transition back to compressible data. 0 selects an internal default (currently 64 skipped data blocks between forced samples).

The emit thread makes the skip/attempt decision once per data block (serial and parallel paths alike; data blocks only), so a skipped block is never dispatched to a compression worker. It is driven by a continuously updated, shared estimate q_hat of the aggregate stored/uncompressed ratio the builder would achieve by attempting -- each block counted at its achieved ratio, or 1.0 when stored raw: attempt while q_hat is at or below the bar, otherwise skip.

Because the estimate is payoff-weighted, a stream whose blocks each save little -- or where only a few blocks compress well -- does not clear the bar and is skipped, while a stream that compresses substantially is attempted. The compression given up per KB is bounded by (1024 - max_compressed_bytes_per_kb).

q_hat uses an asymmetric exponentially weighted moving average (EWMA), slightly faster toward better compression than toward worse; the fast side is deliberately gentle so a single marginally-compressible sample does not flip a whole file back to attempting. It is kept on its own cache line to avoid false sharing. Under parallel compression q_hat is updated with relaxed atomics, and the emit thread can decide from a value that lags the learning it drives by the in-flight depth -- tolerable concessions of accuracy and responsiveness for a coarse heuristic.

The first data block of each file is always attempted (the sample countdown starts at 0), and while skipping the builder keeps sampling (one block after a randomized gap) so a return to compressible data is detected. q_hat is carried across the files a flush/compaction thread emits via thread_local state keyed by preferred compression type, so a sustained region does not re-learn on every file.

The old CompressionManager-based AutoSkip is removed (CostAware is retained); db_stress, db_bench, and db_crashtest are migrated to the new option. db_bench is extended so it can generate the relevant regimes: --compressible_data_fraction and --compressible_data_run_length produce a mix of compressible/incompressible blocks -- i.i.d. per value (run length 1) or in alternating regions (large run length). The default (fraction 1.0) reproduces the historical single-pool behavior.

Pull Request resolved: https://github.com/facebook/rocksdb/pull/15079

Test Plan:
New/updated unit tests in `util/compression_test.cc` (`DBAutoSkipTest`):
- skips sustained incompressible data;
- never skips compressible data;
- disabled by default never bypasses a block;
- 4-thread parallel compression round-trips all data across alternating compressible/incompressible regions.

**Performance** (`db_bench fillseq`, release, zstd level 3; 100k values of 16 KiB each, roughly one value per data block; WAL and auto-compaction off; median of 5 runs on a quiet host). Auto-skip trades one cost for another, so each cell reports two costs, both lower-is-better: CPU cost as process CPU time (user+sys) per GB of uncompressed input (s/GB; ~1.64 GB uncompressed here), and storage cost as the resulting DB size. Auto-skip does not appear to change parallelizability, so wall-clock s/GB tracks CPU s/GB (the two agree here); CPU s/GB is reported. Percentages are relative to the no-auto-skip baseline: negative CPU = cheaper, positive size = more storage (shown only where storage grows).

| Workload | baseline (off) | (a) old wrapper | (b) draft | (c) this version |
|---|---|---|---|---|
| Incompressible | 2.33 s/GB, 1568 MB | 0.96 (-59%), 1568 MB | 0.82 (-65%), 1568 MB | 0.82 (-65%), 1568 MB |
| Compressible | 3.55 s/GB, 710 MB | 3.54 (~0%), 710 MB | 3.56 (~0%), 710 MB | 3.55 (~0%), 710 MB |
| Marginal-payoff mix | 3.80 s/GB, 1376 MB | 1.54 (-59%), 1523 MB (+11%) | 3.81 (~0%), 1376 MB | 1.43 (-62%), 1531 MB (+11%) |
| High-payoff mix | 2.20 s/GB, 929 MB | 1.14 (-48%), 1417 MB (+53%) | 2.16 (~0%), 929 MB | 2.17 (~0%), 929 MB |

A strategy wins by cutting CPU cost without paying much storage cost. On incompressible data all three cut CPU ~60% at no storage cost; on compressible data none skip (within run-to-run noise). The two mixes (50% incompressible, 50% compressible per block) separate the strategies:
 - Marginal-payoff mix: the compressible half barely clears the per-block bar, so the stream is not worth compressing -- skipping is right. (c) and (a) cut CPU ~60% for ~11% more storage. That extra storage is compression (c) intentionally declines because its aggregate benefit is below the configured worthwhile threshold (max_compressed_bytes_per_kb, ~12.5% per KB); it stays within the (1024 - max_compressed_bytes_per_kb) bound. The ratio-unaware draft (b) cannot see the low payoff, keeps compressing, and saves no CPU.
 - High-payoff mix: the compressible half compresses hard, so compressing is right. (c) and (b) keep full compression at baseline storage; the old wrapper (a) cuts CPU 48% but inflates storage +53% -- far beyond that bound -- giving up worthwhile compression.

Only (c) makes the right call on both mixes: it never gives up worthwhile compression (unlike (a)) and does not waste CPU on low-payoff data (unlike (b)).

Reviewed By: xingbowang

Differential Revision: D115444567

Pulled By: pdillinger

fbshipit-source-id: 05e122d25de95ddcd0edeb0dd07c9d25bead7293
2026-08-11 14:29:51 -07:00

201 lines
8.5 KiB
C++

// Copyright (c) Meta Platforms, Inc. and affiliates.
// This source code is licensed under both the GPLv2 (found in the
// COPYING file in the root directory) and Apache 2.0 License
// (found in the LICENSE.Apache file in the root directory).
//
#include "util/auto_tune_compressor.h"
#include "options/options_helper.h"
#include "rocksdb/advanced_compression.h"
#include "test_util/sync_point.h"
#include "util/random.h"
#include "util/stop_watch.h"
namespace ROCKSDB_NAMESPACE {
const std::vector<std::vector<int>> CostAwareCompressor::kCompressionLevels{
{0}, // KSnappyCompression
{}, // kZlibCompression
{}, // kBZip2Compression
{-1, -4, -9}, // kLZ4Compression (negative => LZ4 fast acceleration)
{1, 4, 9}, // kLZ4HCCompression
{}, // kXpressCompression
{1, 15, 22} // kZSTD
};
CostAwareCompressor::CostAwareCompressor(const CompressionOptions& opts)
: opts_(opts) {
// Creates compressor supporting all the compression types and levels as per
// the compression levels set in vector CompressionLevels
auto builtInManager = GetBuiltinV2CompressionManager();
const auto& compressions = GetSupportedCompressions();
for (size_t i = 0; i < kCompressionLevels.size(); i++) {
CompressionType type = static_cast<CompressionType>(i + 1);
if (type == kNoCompression) {
continue;
}
if (kCompressionLevels[type - 1].size() == 0) {
allcompressors_.emplace_back();
continue;
} else {
// if the compression type is not supported, then skip and remove
// compression levels from the supported compression level list
if (std::find(compressions.begin(), compressions.end(), type) ==
compressions.end()) {
allcompressors_.emplace_back();
continue;
}
std::vector<std::unique_ptr<Compressor>> compressors_diff_levels;
for (size_t j = 0; j < kCompressionLevels[type - 1].size(); j++) {
auto level = kCompressionLevels[type - 1][j];
CompressionOptions new_opts = opts;
new_opts.level = level;
compressors_diff_levels.push_back(
builtInManager->GetCompressor(new_opts, type));
allcompressors_index_.emplace_back(i, j);
}
allcompressors_.push_back(std::move(compressors_diff_levels));
}
}
}
const char* CostAwareCompressor::Name() const { return "CostAwareCompressor"; }
std::unique_ptr<Compressor> CostAwareCompressor::Clone() const {
return std::make_unique<CostAwareCompressor>(opts_);
}
Compressor::DictConfig CostAwareCompressor::GetDictGuidance(
CacheEntryRole block_type) const {
auto idx = allcompressors_index_.back();
return allcompressors_[idx.first][idx.second]->GetDictGuidance(block_type);
}
Slice CostAwareCompressor::GetSerializedDict() const {
auto idx = allcompressors_index_.back();
return allcompressors_[idx.first][idx.second]->GetSerializedDict();
}
CompressionType CostAwareCompressor::GetPreferredCompressionType() const {
return kZSTD;
}
std::unique_ptr<Compressor> CostAwareCompressor::MaybeCloneSpecialized(
CacheEntryRole block_type, DictConfigArgs&& dict_config) const {
// TODO: full dictionary compression support. Currently this just falls
// back on a non-multi compressor when asked to use a dictionary.
auto idx = allcompressors_index_.back();
return allcompressors_[idx.first][idx.second]->MaybeCloneSpecialized(
block_type, std::move(dict_config));
}
Status CostAwareCompressor::CompressBlock(Slice uncompressed_data,
char* compressed_output,
size_t* compressed_output_size,
CompressionType* out_compression_type,
ManagedWorkingArea* wa) {
// Check if the managed working area is provided or owned by this object.
// If not, bypass compressor logic since the working area lacks a predictor
if (allcompressors_.size() == 0) {
return Status::NotSupported("No compression type supported");
}
if (wa == nullptr || wa->owner() != this) {
// highest compression level of Zstd
size_t choosen_compression_type = 6;
size_t compression_level_ptr = 2;
return allcompressors_[choosen_compression_type][compression_level_ptr]
->CompressBlock(uncompressed_data, compressed_output,
compressed_output_size, out_compression_type, wa);
}
auto local_wa = static_cast<CostAwareWorkingArea*>(wa->get());
std::pair<size_t, size_t> choosen_index(6, 2);
size_t choosen_compression_type = choosen_index.first;
size_t compresion_level_ptr = choosen_index.second;
return CompressBlockAndRecord(choosen_compression_type, compresion_level_ptr,
uncompressed_data, compressed_output,
compressed_output_size, out_compression_type,
local_wa);
}
Compressor::ManagedWorkingArea CostAwareCompressor::ObtainWorkingArea() {
auto wrap_wa = allcompressors_.back().back()->ObtainWorkingArea();
auto wa = new CostAwareWorkingArea(std::move(wrap_wa));
// Create cost predictors for each compression type and level
wa->cost_predictors_.reserve(allcompressors_.size());
for (size_t i = 0; i < allcompressors_.size(); i++) {
CompressionType type = static_cast<CompressionType>(i + 1);
if (allcompressors_[type - 1].size() == 0) {
wa->cost_predictors_.emplace_back();
continue;
} else {
std::vector<IOCPUCostPredictor*> predictors_diff_levels;
predictors_diff_levels.reserve(kCompressionLevels[type - 1].size());
for (size_t j = 0; j < kCompressionLevels[type - 1].size(); j++) {
predictors_diff_levels.emplace_back(new IOCPUCostPredictor(10));
}
wa->cost_predictors_.emplace_back(std::move(predictors_diff_levels));
}
}
return ManagedWorkingArea(wa, this);
}
void CostAwareCompressor::ReleaseWorkingArea(WorkingArea* wa) {
// remove all created cost predictors
for (auto& prdictors_diff_levels :
static_cast<CostAwareWorkingArea*>(wa)->cost_predictors_) {
for (auto& predictor : prdictors_diff_levels) {
delete predictor;
}
}
delete static_cast<CostAwareWorkingArea*>(wa);
}
Status CostAwareCompressor::CompressBlockAndRecord(
size_t choosen_compression_type, size_t compression_level_ptr,
Slice uncompressed_data, char* compressed_output,
size_t* compressed_output_size, CompressionType* out_compression_type,
CostAwareWorkingArea* wa) {
assert(choosen_compression_type < allcompressors_.size());
assert(compression_level_ptr <
allcompressors_[choosen_compression_type].size());
assert(choosen_compression_type < wa->cost_predictors_.size());
assert(compression_level_ptr <
wa->cost_predictors_[choosen_compression_type].size());
StopWatchNano<> timer(Env::Default()->GetSystemClock().get(), true);
Status status =
allcompressors_[choosen_compression_type][compression_level_ptr]
->CompressBlock(uncompressed_data, compressed_output,
compressed_output_size, out_compression_type,
&(wa->wrapped_));
std::pair<size_t, size_t> measured_data(timer.ElapsedMicros(),
*compressed_output_size);
auto predictor =
wa->cost_predictors_[choosen_compression_type][compression_level_ptr];
auto output_length = measured_data.second;
auto cpu_time = measured_data.first;
predictor->CPUPredictor.Record(cpu_time);
predictor->IOPredictor.Record(output_length);
TEST_SYNC_POINT_CALLBACK(
"CostAwareCompressor::CompressBlockAndRecord::GetPredictor",
wa->cost_predictors_[choosen_compression_type][compression_level_ptr]);
return status;
}
const char* CostAwareCompressorManager::Name() const {
// should have returned "CostAwareCompressorManager" but we currently have an
// error so for now returning name of the wrapped container
return wrapped_->Name();
}
std::unique_ptr<Compressor> CostAwareCompressorManager::GetCompressorForSST(
const FilterBuildingContext& context, const CompressionOptions& opts,
CompressionType preferred) {
assert(GetSupportedCompressions().size() > 1);
(void)context;
(void)preferred;
return std::make_unique<CostAwareCompressor>(opts);
}
std::shared_ptr<CompressionManagerWrapper> CreateCostAwareCompressionManager(
std::shared_ptr<CompressionManager> wrapped) {
return std::make_shared<CostAwareCompressorManager>(
wrapped == nullptr ? GetBuiltinV2CompressionManager() : wrapped);
}
} // namespace ROCKSDB_NAMESPACE