Skip to content

Commit ca348be

Browse files
perf: field stats more performant for high volume ingestion (#1679)
1 parent cc0dd62 commit ca348be

2 files changed

Lines changed: 191 additions & 110 deletions

File tree

src/storage/field_stats.rs

Lines changed: 98 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -76,24 +76,17 @@ use std::collections::{HashMap, HashSet};
7676
use std::fmt::Debug;
7777
use std::fs::File;
7878
use std::path::Path;
79-
use std::sync::Arc;
8079
use std::time::Instant;
81-
use tokio::sync::Semaphore;
8280
use tracing::{debug, error, warn};
8381

8482
pub const DATASET_STATS_STREAM_NAME: &str = "pstats";
8583
const DATASET_STATS_CUSTOM_PARTITION: &str = "dataset_name";
86-
const MAX_CONCURRENT_FIELD_STATS: usize = 4;
87-
const PARALLEL_FIELD_STATS_MIN_FIELDS: usize = 16;
8884
const MIN_TRACKED_DISTINCT_VALUES_PER_FIELD: usize = 1024;
8985
const MAX_TRACKED_DISTINCT_VALUES_PER_FIELD: usize = 10_000;
9086
const HLL_PRECISION_BITS: u32 = 12;
9187
const HLL_REGISTER_COUNT: usize = 1 << HLL_PRECISION_BITS;
92-
static FIELD_STATS_QUERY_SEMAPHORE: Lazy<Arc<Semaphore>> =
93-
Lazy::new(|| Arc::new(Semaphore::new(MAX_CONCURRENT_FIELD_STATS)));
9488
static FIELD_STATS_RAYON_POOL: Lazy<ThreadPool> = Lazy::new(|| {
9589
ThreadPoolBuilder::new()
96-
.num_threads(MAX_CONCURRENT_FIELD_STATS)
9790
.thread_name(|index| format!("field-stats-{index}"))
9891
.build()
9992
.expect("field stats rayon pool should initialize")
@@ -139,7 +132,7 @@ pub async fn calculate_field_stats(
139132
tenant_id,
140133
)
141134
.await;
142-
log_stats_calculation_time(stream_name, parquet_path, started_at, result.is_ok());
135+
log_stats_calculation_time(stream_name, parquet_path, started_at, &result);
143136

144137
result
145138
}
@@ -218,20 +211,21 @@ fn log_stats_calculation_time(
218211
stream_name: &str,
219212
parquet_path: &Path,
220213
started_at: Instant,
221-
success: bool,
214+
result: &Result<bool, PostError>,
222215
) {
223216
let parquet_file = parquet_path
224217
.file_name()
225218
.and_then(|filename| filename.to_str())
226219
.unwrap_or("<unknown>");
227220
let elapsed_ms = started_at.elapsed().as_millis();
228-
if success {
221+
if result.is_ok() {
229222
debug!(
230223
"Field stats calculation completed for parquet file {parquet_file} in {elapsed_ms} ms. stream={stream_name}"
231224
);
232225
} else {
226+
let err = result.as_ref().expect_err("result checked as error");
233227
warn!(
234-
"Field stats calculation failed for parquet file {parquet_file} after {elapsed_ms} ms. stream={stream_name}"
228+
"Field stats calculation failed for parquet file {parquet_file} after {elapsed_ms} ms. stream={stream_name}: {err}"
235229
);
236230
}
237231
}
@@ -260,22 +254,11 @@ async fn collect_all_field_stats_from_parquet(
260254
schema: &Schema,
261255
max_field_statistics: usize,
262256
) -> Result<Vec<FieldStat>, PostError> {
263-
let permit = FIELD_STATS_QUERY_SEMAPHORE
264-
.clone()
265-
.acquire_owned()
266-
.await
267-
.map_err(|e| {
268-
PostError::Invalid(anyhow::anyhow!(
269-
"Failed to acquire field stats query permit: {}",
270-
e
271-
))
272-
})?;
273257
let stream_name = stream_name.to_string();
274258
let parquet_path = parquet_path.to_path_buf();
275259
let schema = schema.clone();
276260

277261
tokio::task::spawn_blocking(move || {
278-
let _permit = permit;
279262
collect_all_field_stats_from_parquet_blocking(
280263
&stream_name,
281264
&parquet_path,
@@ -335,14 +318,15 @@ fn collect_all_field_stats_from_parquet_blocking(
335318
}
336319
};
337320

338-
let batch_counts = if field_names.len() >= PARALLEL_FIELD_STATS_MIN_FIELDS {
339-
collect_batch_field_counts_parallel(&batch, &field_names)
340-
} else {
341-
collect_batch_field_counts_serial(&batch, &field_names)
342-
};
321+
let batch_field_counts = collect_batch_field_counts_parallel(
322+
stream_name,
323+
&batch,
324+
&field_names,
325+
max_field_statistics,
326+
);
343327

344-
for (field_name, counts) in batch_counts {
345-
merge_field_counts(&mut field_counts, &field_name, counts);
328+
for (field_name, batch_field_count) in batch_field_counts {
329+
merge_field_counts(&mut field_counts, &field_name, batch_field_count);
346330
}
347331
}
348332

@@ -364,53 +348,53 @@ fn collect_all_field_stats_from_parquet_blocking(
364348
.collect())
365349
}
366350

367-
fn collect_batch_field_counts_serial(
368-
batch: &arrow_array::RecordBatch,
369-
field_names: &[String],
370-
) -> Vec<(String, HashMap<String, i64>)> {
371-
field_names
372-
.iter()
373-
.filter_map(|field_name| collect_field_counts(batch, field_name))
374-
.collect()
375-
}
376-
377351
fn collect_batch_field_counts_parallel(
352+
stream_name: &str,
378353
batch: &arrow_array::RecordBatch,
379354
field_names: &[String],
380-
) -> Vec<(String, HashMap<String, i64>)> {
355+
max_field_statistics: usize,
356+
) -> Vec<(String, FieldCountState)> {
381357
FIELD_STATS_RAYON_POOL.install(|| {
382358
field_names
383359
.par_iter()
384-
.filter_map(|field_name| collect_field_counts(batch, field_name))
360+
.filter_map(|field_name| {
361+
collect_field_counts(stream_name, batch, field_name, max_field_statistics)
362+
})
385363
.collect()
386364
})
387365
}
388366

389367
fn collect_field_counts(
368+
stream_name: &str,
390369
batch: &arrow_array::RecordBatch,
391370
field_name: &str,
392-
) -> Option<(String, HashMap<String, i64>)> {
371+
max_field_statistics: usize,
372+
) -> Option<(String, FieldCountState)> {
393373
let array = batch.column_by_name(field_name)?;
394-
let mut counts = HashMap::new();
374+
let mut field_count = FieldCountState::new_for_batch(
375+
stream_name.to_string(),
376+
field_name.to_string(),
377+
max_field_statistics,
378+
);
395379

396380
for row_index in 0..array.len() {
397381
let value = format_arrow_value(array.as_ref(), row_index);
398-
*counts.entry(value).or_default() += 1;
382+
field_count.record_value(value);
399383
}
400384

401-
Some((field_name.to_string(), counts))
385+
Some((field_name.to_string(), field_count))
402386
}
403387

404388
fn merge_field_counts(
405389
field_counts: &mut HashMap<String, FieldCountState>,
406390
field_name: &str,
407-
counts: HashMap<String, i64>,
391+
batch_field_count: FieldCountState,
408392
) {
409393
let field_total = field_counts
410394
.get_mut(field_name)
411395
.expect("field_counts initialized for each field");
412396

413-
field_total.merge_counts(counts);
397+
field_total.merge_state(batch_field_count);
414398
}
415399

416400
struct FieldCountState {
@@ -421,10 +405,24 @@ struct FieldCountState {
421405
hll: HyperLogLog,
422406
max_tracked_values: usize,
423407
approximate: bool,
408+
log_cardinality_cap: bool,
424409
}
425410

426411
impl FieldCountState {
427412
fn new(stream_name: String, field_name: String, max_field_statistics: usize) -> Self {
413+
Self::new_with_logging(stream_name, field_name, max_field_statistics, true)
414+
}
415+
416+
fn new_for_batch(stream_name: String, field_name: String, max_field_statistics: usize) -> Self {
417+
Self::new_with_logging(stream_name, field_name, max_field_statistics, false)
418+
}
419+
420+
fn new_with_logging(
421+
stream_name: String,
422+
field_name: String,
423+
max_field_statistics: usize,
424+
log_cardinality_cap: bool,
425+
) -> Self {
428426
Self {
429427
stream_name,
430428
field_name,
@@ -433,38 +431,68 @@ impl FieldCountState {
433431
hll: HyperLogLog::new(),
434432
max_tracked_values: tracked_distinct_value_limit(max_field_statistics),
435433
approximate: false,
434+
log_cardinality_cap,
436435
}
437436
}
438437

438+
fn record_value(&mut self, value: String) {
439+
self.hll.add(&value);
440+
self.total_count += 1;
441+
self.track_value(value, 1);
442+
}
443+
444+
#[cfg(test)]
439445
fn merge_counts(&mut self, counts: HashMap<String, i64>) {
440446
for (value, count) in counts {
441447
self.hll.add(&value);
442448
self.total_count += count;
449+
self.track_value(value, count);
450+
}
451+
}
443452

444-
if let Some(existing_count) = self.counts.get_mut(&value) {
445-
*existing_count += count;
446-
continue;
447-
}
453+
fn merge_state(&mut self, state: FieldCountState) {
454+
self.hll.merge(&state.hll);
455+
self.total_count += state.total_count;
448456

449-
if self.counts.len() < self.max_tracked_values {
450-
self.counts.insert(value, count);
451-
continue;
452-
}
457+
if state.approximate {
458+
self.mark_approximate();
459+
}
453460

454-
if !self.approximate {
455-
self.approximate = true;
461+
for (value, count) in state.counts {
462+
self.track_value(value, count);
463+
}
464+
}
465+
466+
fn track_value(&mut self, value: String, count: i64) {
467+
if let Some(existing_count) = self.counts.get_mut(&value) {
468+
*existing_count += count;
469+
return;
470+
}
471+
472+
if self.counts.len() < self.max_tracked_values {
473+
self.counts.insert(value, count);
474+
return;
475+
}
476+
477+
self.mark_approximate();
478+
479+
if let Some((min_value, min_count)) = self.current_min_value()
480+
&& count > min_count
481+
{
482+
self.counts.remove(&min_value);
483+
self.counts.insert(value, count);
484+
}
485+
}
486+
487+
fn mark_approximate(&mut self) {
488+
if !self.approximate {
489+
self.approximate = true;
490+
if self.log_cardinality_cap {
456491
debug!(
457492
"Field stats cardinality cap reached for stream {} field {}. Tracking bounded top-value candidates with max_tracked_values={}",
458493
self.stream_name, self.field_name, self.max_tracked_values
459494
);
460495
}
461-
462-
if let Some((min_value, min_count)) = self.current_min_value()
463-
&& count > min_count
464-
{
465-
self.counts.remove(&min_value);
466-
self.counts.insert(value, count);
467-
}
468496
}
469497
}
470498

@@ -533,6 +561,12 @@ impl HyperLogLog {
533561
self.registers[register_index] = self.registers[register_index].max(rank);
534562
}
535563

564+
fn merge(&mut self, other: &Self) {
565+
for (register, other_register) in self.registers.iter_mut().zip(other.registers.iter()) {
566+
*register = (*register).max(*other_register);
567+
}
568+
}
569+
536570
fn estimate(&self) -> f64 {
537571
let register_count = HLL_REGISTER_COUNT as f64;
538572
let zero_registers = self

0 commit comments

Comments
 (0)