Files
rustfs/crates/ecstore/benches/erasure_benchmark.rs
T
Ramakrishna Chilaka 956bd19417 perf(ecstore): reuse erasure-decode shard buffers across stripes (#3482)
* perf(ecstore): reuse erasure-decode shard buffers across stripes

ParallelReader::read allocated and zero-filled a fresh
`vec![0u8; shard_size]` per shard on every erasure stripe. Erasure::decode
builds one ParallelReader and loops stripes, so those buffers can be
reused: BitrotReader::read overwrites buf[..n] and the caller truncates to
n, so leftover bytes from the prior stripe are never observed. The decode
loop now hands each stripe's shard buffers back to the reader, which
reuses them (resized to shard_size) on the next stripe. The heal path,
which consumes its buffers into Bytes, never hands them back and is
unchanged.

This path runs on every object GET. Streaming-decode benchmark
(Erasure::decode end-to-end, median):

  4+2 16MiB 1MiB-block:  -9.6% verify-on,  -23.8% verify-off
  6+3 24MiB 1MiB-block:  -9.7% verify-on,  -22.5% verify-off
  4+2  4MiB 64KiB-block: -5.5% verify-on,  -16.3% verify-off

Adds a streaming-decode benchmark covering the ParallelReader path and a
regression test that decodes a multi-stripe object with missing data
shards (so reconstructed buffers are recycled) with bitrot verification
both on and off, asserting byte-exact output.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* perf(ecstore): tighten decode buffer reuse and bench harness

Address PR review feedback on the erasure-decode buffer-reuse change:

- Only claim a recycled shard buffer inside the `Some(reader)` branch of
  `ParallelReader::read`, so the take is co-located with the read that
  uses it and missing shards no longer touch the recycle slot.
- Move per-shard `BitrotReader` construction into Criterion `iter_batched`
  setup so reader/UUID construction stays out of the timed decode path,
  and assert decode success plus full output length each iteration.

Re-measured with the cleaner harness (median, before -> after, all
p < 0.05): verify-on -5.3%..-11.7%, verify-off -15.8%..-25.3%.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* perf(ecstore): drop dead recycle slots and tidy decode bench

Address the second PR review pass:

- In `Erasure::decode`, clear recycled slots for missing-shard (None-reader)
  indices before handing buffers back. `ParallelReader::read` only reuses
  slots with an active reader, and `decode_data` always re-allocates the
  reconstructed shard, so retaining those buffers only held memory that could
  never be reused in degraded reads.
- Move the output sink into the benchmark's `iter_batched` setup and validate
  decode success/output length once per config instead of inside the timed
  loop, so neither sink construction nor assertions touch the measurement.

Re-measured with the final harness (median, before -> after, all p < 0.05):
verify-on -5.2%..-9.8%, verify-off -19.4%..-25.8%.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-15 22:28:39 +08:00

447 lines
17 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Reed-Solomon SIMD erasure coding performance benchmarks.
//!
//! This benchmark tests the performance of the high-performance SIMD Reed-Solomon implementation.
//!
//! ## Running Benchmarks
//!
//! ```bash
//! # Run all benchmarks
//! cargo bench
//!
//! # Run specific benchmark
//! cargo bench --bench erasure_benchmark
//!
//! # Generate HTML report
//! cargo bench --bench erasure_benchmark -- --output-format html
//!
//! # Test encoding performance only
//! cargo bench encode
//!
//! # Test decoding performance only
//! cargo bench decode
//! ```
//!
//! ## Test Configurations
//!
//! The benchmarks test various scenarios:
//! - Different data sizes: 1KB, 64KB, 1MB, 16MB
//! - Different erasure coding configurations: (4,2), (6,3), (8,4)
//! - Both encoding and decoding operations
//! - SIMD optimization for different shard sizes
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use rustfs_ecstore::erasure_coding::{BitrotReader, BitrotWriter, Erasure, calc_shard_size};
use rustfs_utils::HashAlgorithm;
use std::hint::black_box;
use std::io::Cursor;
use std::time::Duration;
use tokio::runtime::Runtime;
/// Benchmark configuration structure
#[derive(Clone, Debug)]
struct BenchConfig {
/// Number of data shards
data_shards: usize,
/// Number of parity shards
parity_shards: usize,
/// Test data size (bytes)
data_size: usize,
/// Block size (bytes)
block_size: usize,
/// Configuration name
name: String,
}
impl BenchConfig {
fn new(data_shards: usize, parity_shards: usize, data_size: usize, block_size: usize) -> Self {
Self {
data_shards,
parity_shards,
data_size,
block_size,
name: format!("{}+{}_{}KB_{}KB-block", data_shards, parity_shards, data_size / 1024, block_size / 1024),
}
}
}
/// Generate test data
fn generate_test_data(size: usize) -> Vec<u8> {
(0..size).map(|i| (i % 256) as u8).collect()
}
/// Benchmark: Encoding performance
fn bench_encode_performance(c: &mut Criterion) {
let configs = vec![
// Small data tests - 1KB
BenchConfig::new(4, 2, 1024, 1024),
BenchConfig::new(6, 3, 1024, 1024),
BenchConfig::new(8, 4, 1024, 1024),
// Medium data tests - 64KB
BenchConfig::new(4, 2, 64 * 1024, 64 * 1024),
BenchConfig::new(6, 3, 64 * 1024, 64 * 1024),
BenchConfig::new(8, 4, 64 * 1024, 64 * 1024),
// Large data tests - 1MB
BenchConfig::new(4, 2, 1024 * 1024, 1024 * 1024),
BenchConfig::new(6, 3, 1024 * 1024, 1024 * 1024),
BenchConfig::new(8, 4, 1024 * 1024, 1024 * 1024),
// Extra large data tests - 16MB
BenchConfig::new(4, 2, 16 * 1024 * 1024, 16 * 1024 * 1024),
BenchConfig::new(6, 3, 16 * 1024 * 1024, 16 * 1024 * 1024),
];
for config in configs {
let data = generate_test_data(config.data_size);
// Test SIMD encoding performance
let mut group = c.benchmark_group("encode_simd");
group.throughput(Throughput::Bytes(config.data_size as u64));
group.sample_size(10);
group.measurement_time(Duration::from_secs(5));
group.bench_with_input(BenchmarkId::new("simd_impl", &config.name), &(&data, &config), |b, (data, config)| {
let erasure = Erasure::new(config.data_shards, config.parity_shards, config.block_size);
b.iter(|| {
let shards = erasure.encode_data(black_box(data)).unwrap();
black_box(shards);
});
});
group.finish();
// Test direct reed-solomon-erasure implementation for large shards (>= 512 bytes)
let shard_size = calc_shard_size(config.data_size, config.data_shards);
if shard_size >= 512 && config.parity_shards > 0 {
use reed_solomon_erasure::galois_8::ReedSolomon;
let mut rse_group = c.benchmark_group("encode_rse_direct");
rse_group.throughput(Throughput::Bytes(config.data_size as u64));
rse_group.sample_size(10);
rse_group.measurement_time(Duration::from_secs(5));
if let Ok(rs) = ReedSolomon::new(config.data_shards, config.parity_shards) {
let total_shards = config.data_shards + config.parity_shards;
let per_shard_size = calc_shard_size(config.data_size, config.data_shards);
let need_total = per_shard_size * total_shards;
rse_group.bench_with_input(
BenchmarkId::new("rse_direct", &config.name),
&(&data, need_total, per_shard_size),
|b, (data, need_total, per_shard_size)| {
b.iter(|| {
let mut buffer = vec![0u8; *need_total];
let copy_len = data.len().min(buffer.len());
buffer[..copy_len].copy_from_slice(&data[..copy_len]);
let mut slices: Vec<&mut [u8]> = buffer.chunks_exact_mut(*per_shard_size).collect();
rs.encode(&mut slices).unwrap();
black_box(buffer);
});
},
);
}
rse_group.finish();
}
}
}
/// Benchmark: Decoding performance
fn bench_decode_performance(c: &mut Criterion) {
let configs = vec![
// Medium data tests - 64KB
BenchConfig::new(4, 2, 64 * 1024, 64 * 1024),
BenchConfig::new(6, 3, 64 * 1024, 64 * 1024),
// Large data tests - 1MB
BenchConfig::new(4, 2, 1024 * 1024, 1024 * 1024),
BenchConfig::new(6, 3, 1024 * 1024, 1024 * 1024),
// Extra large data tests - 16MB
BenchConfig::new(4, 2, 16 * 1024 * 1024, 16 * 1024 * 1024),
];
for config in configs {
let data = generate_test_data(config.data_size);
let erasure = Erasure::new(config.data_shards, config.parity_shards, config.block_size);
// Pre-encode data
let encoded_shards = erasure.encode_data(&data).unwrap();
// Test SIMD decoding performance
let mut group = c.benchmark_group("decode_simd");
group.throughput(Throughput::Bytes(config.data_size as u64));
group.sample_size(10);
group.measurement_time(Duration::from_secs(5));
group.bench_with_input(
BenchmarkId::new("simd_impl", &config.name),
&(&encoded_shards, &config),
|b, (shards, config)| {
let erasure = Erasure::new(config.data_shards, config.parity_shards, config.block_size);
b.iter(|| {
// Simulate data loss - lose one data shard and one parity shard
let mut shards_opt: Vec<Option<Vec<u8>>> = shards.iter().map(|shard| Some(shard.to_vec())).collect();
// Lose last data shard and first parity shard
shards_opt[config.data_shards - 1] = None;
shards_opt[config.data_shards] = None;
erasure.decode_data(black_box(&mut shards_opt)).unwrap();
black_box(&shards_opt);
});
},
);
group.finish();
// Test direct reed-solomon-erasure decoding for large shards
let shard_size = calc_shard_size(config.data_size, config.data_shards);
if shard_size >= 512 && config.parity_shards > 0 {
use reed_solomon_erasure::galois_8::ReedSolomon;
if let Ok(rs) = ReedSolomon::new(config.data_shards, config.parity_shards) {
let mut rse_group = c.benchmark_group("decode_rse_direct");
rse_group.throughput(Throughput::Bytes(config.data_size as u64));
rse_group.sample_size(10);
rse_group.measurement_time(Duration::from_secs(5));
rse_group.bench_with_input(
BenchmarkId::new("rse_direct", &config.name),
&(&encoded_shards, &config),
|b, (shards, config)| {
b.iter(|| {
let mut shards_opt: Vec<Option<Vec<u8>>> = shards.iter().map(|s| Some(s.to_vec())).collect();
shards_opt[config.data_shards - 1] = None;
shards_opt[config.data_shards] = None;
rs.reconstruct_data(&mut shards_opt).unwrap();
black_box(shards_opt);
});
},
);
rse_group.finish();
}
}
}
}
/// Benchmark: Impact of different shard sizes on performance
fn bench_shard_size_impact(c: &mut Criterion) {
let shard_sizes = vec![64, 128, 256, 512, 1024, 2048, 4096, 8192];
let data_shards = 4;
let parity_shards = 2;
let mut group = c.benchmark_group("shard_size_impact");
group.sample_size(10);
group.measurement_time(Duration::from_secs(3));
for shard_size in shard_sizes {
let total_data_size = shard_size * data_shards;
let data = generate_test_data(total_data_size);
group.throughput(Throughput::Bytes(total_data_size as u64));
// Test SIMD implementation
group.bench_with_input(BenchmarkId::new("simd", format!("shard_{shard_size}B")), &data, |b, data| {
let erasure = Erasure::new(data_shards, parity_shards, total_data_size);
b.iter(|| {
let shards = erasure.encode_data(black_box(data)).unwrap();
black_box(shards);
});
});
}
group.finish();
}
/// Benchmark: Impact of coding configurations on performance
fn bench_coding_configurations(c: &mut Criterion) {
let configs = vec![
(2, 1), // Minimal redundancy
(3, 2), // Medium redundancy
(4, 2), // Common configuration
(6, 3), // 50% redundancy
(8, 4), // 50% redundancy, more shards
(10, 5), // 50% redundancy, many shards
(12, 6), // 50% redundancy, very many shards
];
let data_size = 1024 * 1024; // 1MB test data
let data = generate_test_data(data_size);
let mut group = c.benchmark_group("coding_configurations");
group.throughput(Throughput::Bytes(data_size as u64));
group.sample_size(10);
group.measurement_time(Duration::from_secs(5));
for (data_shards, parity_shards) in configs {
let config_name = format!("{data_shards}+{parity_shards}");
group.bench_with_input(BenchmarkId::new("encode", &config_name), &data, |b, data| {
let erasure = Erasure::new(data_shards, parity_shards, data_size);
b.iter(|| {
let shards = erasure.encode_data(black_box(data)).unwrap();
black_box(shards);
});
});
}
group.finish();
}
/// Benchmark: Memory usage patterns
fn bench_memory_patterns(c: &mut Criterion) {
let data_shards = 4;
let parity_shards = 2;
let block_size = 1024 * 1024; // 1MB block
let mut group = c.benchmark_group("memory_patterns");
group.sample_size(10);
group.measurement_time(Duration::from_secs(5));
// Test reusing the same Erasure instance
group.bench_function("reuse_erasure_instance", |b| {
let erasure = Erasure::new(data_shards, parity_shards, block_size);
let data = generate_test_data(block_size);
b.iter(|| {
let shards = erasure.encode_data(black_box(&data)).unwrap();
black_box(shards);
});
});
// Test creating new Erasure instance each time
group.bench_function("new_erasure_instance", |b| {
let data = generate_test_data(block_size);
b.iter(|| {
let erasure = Erasure::new(data_shards, parity_shards, block_size);
let shards = erasure.encode_data(black_box(&data)).unwrap();
black_box(shards);
});
});
group.finish();
}
/// Benchmark: end-to-end streaming decode through `Erasure::decode`.
///
/// Unlike `bench_decode_performance` (which calls `decode_data` on in-memory
/// shards), this drives the async `ParallelReader` path that reads each shard
/// through a `BitrotReader` per erasure stripe. That is the path executed on
/// every object GET, and the one where per-stripe shard buffers are allocated.
fn bench_streaming_decode(c: &mut Criterion) {
let rt = Runtime::new().unwrap();
let hash_algo = HashAlgorithm::HighwayHash256;
// (data_shards, parity_shards, object_size, block_size)
let configs = vec![
(4usize, 2usize, 16 * 1024 * 1024usize, 1024 * 1024usize),
(6, 3, 24 * 1024 * 1024, 1024 * 1024),
(4, 2, 4 * 1024 * 1024, 64 * 1024),
];
for (data_shards, parity_shards, data_size, block_size) in configs {
let data = generate_test_data(data_size);
let erasure = Erasure::new(data_shards, parity_shards, block_size);
let shard_size = erasure.shard_size();
let total_len = data.len();
// Pre-encode the object into per-shard bitrot streams once (setup).
let total_shards = data_shards + parity_shards;
let shard_bufs: Vec<Vec<u8>> = rt.block_on(async {
let mut writers: Vec<BitrotWriter<Cursor<Vec<u8>>>> = (0..total_shards)
.map(|_| BitrotWriter::new(Cursor::new(Vec::new()), shard_size, hash_algo.clone()))
.collect();
let mut off = 0;
while off < data.len() {
let end = (off + block_size).min(data.len());
let shards = erasure.encode_data(&data[off..end]).unwrap();
for (i, shard) in shards.iter().enumerate() {
writers[i].write(shard).await.unwrap();
}
off = end;
}
writers.into_iter().map(|w| w.into_inner().into_inner()).collect()
});
// verify=true mirrors the production default (bitrot verification on);
// verify=false isolates the buffer-handling cost from the hash pass.
for verify in [true, false] {
let skip_verify = !verify;
let mut group = c.benchmark_group("streaming_decode");
group.throughput(Throughput::Bytes(total_len as u64));
group.sample_size(10);
group.measurement_time(Duration::from_secs(5));
let name = format!(
"{}+{}_{}KB_{}KB-block_verify-{}",
data_shards,
parity_shards,
data_size / 1024,
block_size / 1024,
verify
);
// Validate decode once per config (untimed) so a regression in
// success or output length fails the benchmark without putting
// assertions inside the measured loop.
{
let readers: Vec<Option<BitrotReader<Cursor<&[u8]>>>> = shard_bufs
.iter()
.map(|buf| Some(BitrotReader::new(Cursor::new(buf.as_slice()), shard_size, hash_algo.clone(), skip_verify)))
.collect();
let mut sink = tokio::io::sink();
let (written, err) = rt.block_on(erasure.decode(&mut sink, readers, 0, total_len, total_len));
assert!(err.is_none(), "decode failed: {err:?}");
assert_eq!(written, total_len, "decode wrote {written} of {total_len} bytes");
}
group.bench_function(BenchmarkId::new("decode", name), |b| {
b.iter_batched(
|| {
// Setup (untimed): per-shard readers positioned at the
// object start, plus the no-op sink, so reader/UUID and
// sink construction stay out of the timed decode path.
let readers: Vec<Option<BitrotReader<Cursor<&[u8]>>>> = shard_bufs
.iter()
.map(|buf| {
Some(BitrotReader::new(Cursor::new(buf.as_slice()), shard_size, hash_algo.clone(), skip_verify))
})
.collect();
(readers, tokio::io::sink())
},
|(readers, mut sink)| {
rt.block_on(async {
let (written, _err) = erasure.decode(black_box(&mut sink), readers, 0, total_len, total_len).await;
black_box(written);
});
},
criterion::BatchSize::SmallInput,
);
});
group.finish();
}
}
}
// Benchmark group configuration
criterion_group!(
benches,
bench_encode_performance,
bench_decode_performance,
bench_streaming_decode,
bench_shard_size_impact,
bench_coding_configurations,
bench_memory_patterns
);
criterion_main!(benches);