Skip to content

Commit f8e7cf0

Browse files
committed
Move ArrayStats to store Scalars instead.
interns the AggregateRef id to reduce the number of strings stored for the aggregates.
1 parent dbed086 commit f8e7cf0

13 files changed

Lines changed: 1492 additions & 387 deletions

File tree

‎vortex-array/src/expr/stats/mod.rs‎

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ pub use stat_bound::*;
2525

2626
use crate::aggregate_fn;
2727
use crate::aggregate_fn::AggregateFnRef;
28-
use crate::aggregate_fn::AggregateFnSatisfaction;
2928
use crate::aggregate_fn::AggregateFnVTable;
3029
use crate::aggregate_fn::AggregateFnVTableExt;
3130
use crate::aggregate_fn::EmptyOptions;
@@ -240,18 +239,6 @@ impl Stat {
240239
None
241240
}
242241

243-
pub fn from_aggregate_fn_partial(
244-
aggregate_fn: &AggregateFnRef,
245-
) -> Option<(Self, AggregateFnSatisfaction)> {
246-
Self::all().find_map(|stat| {
247-
let requested = stat.aggregate_fn()?;
248-
match aggregate_fn.can_satisfy(&requested) {
249-
AggregateFnSatisfaction::No => None,
250-
satisfaction => Some((stat, satisfaction)),
251-
}
252-
})
253-
}
254-
255242
pub fn name(&self) -> &str {
256243
match self {
257244
Self::IsConstant => "is_constant",

‎vortex-datafusion/src/persistent/format.rs‎

Lines changed: 26 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -45,24 +45,27 @@ use futures::stream;
4545
use object_store::ObjectMeta;
4646
use object_store::ObjectStore;
4747
use vortex::VortexSessionDefault;
48+
use vortex::aggregate_fn::AggregateFnVTableExt;
49+
use vortex::aggregate_fn::EmptyOptions;
50+
use vortex::aggregate_fn::NumericalAggregateOpts;
51+
use vortex::aggregate_fn::fns::is_constant::IsConstant;
52+
use vortex::aggregate_fn::fns::max::Max;
53+
use vortex::aggregate_fn::fns::min::Min;
54+
use vortex::aggregate_fn::fns::null_count::NullCount;
55+
use vortex::aggregate_fn::fns::uncompressed_size_in_bytes::UncompressedSizeInBytes;
4856
use vortex::array::memory::MemorySessionExt;
49-
use vortex::dtype::DType;
5057
use vortex::dtype::FieldPath;
51-
use vortex::dtype::Nullability;
52-
use vortex::dtype::PType;
5358
use vortex::error::VortexExpect;
5459
use vortex::error::VortexResult;
5560
use vortex::error::vortex_err;
5661
use vortex::expr::stats::Precision;
57-
use vortex::expr::stats::Stat;
5862
use vortex::file::EOF_SIZE;
5963
use vortex::file::MAX_POSTSCRIPT_SIZE;
6064
use vortex::file::OpenOptionsSessionExt;
6165
use vortex::file::VORTEX_FILE_EXTENSION;
6266
use vortex::io::object_store::ObjectStoreReadAt;
6367
use vortex::io::session::RuntimeSessionExt;
6468
use vortex::scalar::Scalar;
65-
use vortex::scalar::ScalarValue as VortexScalarValue;
6669
use vortex::session::VortexSession;
6770
use vortex_arrow::ArrowSessionExt;
6871

@@ -658,7 +661,7 @@ impl FileFormat for VortexFormat {
658661
for field in table_schema.fields().iter() {
659662
// If the column does not exist, continue. This can happen if the schema has evolved
660663
// but we have not yet updated the Vortex file.
661-
let Some((stats_set, stats_dtype)) =
664+
let Some((aggregates, _)) =
662665
file_stats.get_by_path(&FieldPath::from_name(field.name().as_str()))
663666
else {
664667
// The default sets all statistics to `Precision<Absent>`.
@@ -667,35 +670,33 @@ impl FileFormat for VortexFormat {
667670
};
668671

669672
// Update the total size in bytes.
670-
let column_size =
671-
stats_set.get_as::<usize>(Stat::UncompressedSizeInBytes, &PType::U64.into());
673+
let column_size = aggregates
674+
.get(&UncompressedSizeInBytes.bind(EmptyOptions))
675+
.and_then(|size| usize::try_from(&size).ok());
672676

673677
let min = scalar_stat_to_df(
674-
Stat::Min,
675-
stats_set.get(Stat::Min),
676-
stats_dtype,
678+
aggregates.get(&Min.bind(NumericalAggregateOpts::skip_nans())),
677679
field.data_type(),
678680
);
679681

680682
let max = scalar_stat_to_df(
681-
Stat::Max,
682-
stats_set.get(Stat::Max),
683-
stats_dtype,
683+
aggregates.get(&Max.bind(NumericalAggregateOpts::skip_nans())),
684684
field.data_type(),
685685
);
686686

687-
let null_count = stats_set.get_as::<usize>(Stat::NullCount, &PType::U64.into());
687+
let null_count = aggregates
688+
.get(&NullCount.bind(EmptyOptions))
689+
.and_then(|count| usize::try_from(&count).ok());
688690

689691
column_statistics.push(ColumnStatistics {
690692
null_count: null_count.to_df(),
691693
min_value: min,
692694
max_value: max,
693695
sum_value: DFPrecision::Absent,
694696
distinct_count: is_constant_to_distinct_count(
695-
stats_set.get_as::<bool>(
696-
Stat::IsConstant,
697-
&DType::Bool(Nullability::NonNullable),
698-
),
697+
aggregates
698+
.get(&IsConstant.bind(EmptyOptions))
699+
.and_then(|is_constant| bool::try_from(&is_constant).ok()),
699700
),
700701
byte_size: column_size.to_df(),
701702
})
@@ -768,23 +769,11 @@ impl FileFormat for VortexFormat {
768769
}
769770

770771
fn scalar_stat_to_df(
771-
stat: Stat,
772-
value: Precision<VortexScalarValue>,
773-
stats_dtype: &DType,
772+
value: Precision<Scalar>,
774773
target_dtype: &DataType,
775774
) -> DFPrecision<DFScalarValue> {
776-
let Some(stat_dtype) = stat.dtype(stats_dtype) else {
777-
return DFPrecision::Absent;
778-
};
779-
780775
value
781-
.and_then(|stat_value| {
782-
let scalar = Scalar::try_new(stat_dtype, Some(stat_value))
783-
.ok()?
784-
.try_to_df()
785-
.ok()?;
786-
scalar.cast_to(target_dtype).ok()
787-
})
776+
.and_then(|scalar| scalar.try_to_df().ok()?.cast_to(target_dtype).ok())
788777
.to_df()
789778
}
790779

@@ -849,26 +838,18 @@ mod tests {
849838
fn test_scalar_stat_to_df_cast(
850839
#[case] value: ScalarValue,
851840
#[case] expected: ScalarValue,
852-
#[values(Stat::Min, Stat::Max)] stat: Stat,
853841
#[values(true, false)] exact: bool,
854842
) -> VortexResult<()> {
855843
let session = VortexSession::default();
856844
let scalar = scalar_from_df(&value, &session)?;
857-
let value = scalar
858-
.value()
859-
.cloned()
860-
.ok_or_else(|| vortex_err!("expected non-null scalar"))?;
861845
let target_dtype = expected.data_type();
862846
let (value, expected) = if exact {
863-
(Precision::Exact(value), DFPrecision::Exact(expected))
847+
(Precision::Exact(scalar), DFPrecision::Exact(expected))
864848
} else {
865-
(Precision::Inexact(value), DFPrecision::Inexact(expected))
849+
(Precision::Inexact(scalar), DFPrecision::Inexact(expected))
866850
};
867851

868-
assert_eq!(
869-
scalar_stat_to_df(stat, value, scalar.dtype(), &target_dtype),
870-
expected
871-
);
852+
assert_eq!(scalar_stat_to_df(value, &target_dtype), expected);
872853
Ok(())
873854
}
874855

@@ -886,16 +867,11 @@ mod tests {
886867
fn test_scalar_stat_to_df_failed_cast(
887868
#[case] value: ScalarValue,
888869
#[case] target_dtype: DataType,
889-
#[values(Stat::Min, Stat::Max)] stat: Stat,
890870
) -> VortexResult<()> {
891871
let session = VortexSession::default();
892872
let scalar = scalar_from_df(&value, &session)?;
893-
let value = scalar
894-
.value()
895-
.cloned()
896-
.ok_or_else(|| vortex_err!("expected non-null scalar"))?;
897873
assert_eq!(
898-
scalar_stat_to_df(stat, Precision::Exact(value), scalar.dtype(), &target_dtype),
874+
scalar_stat_to_df(Precision::Exact(scalar), &target_dtype),
899875
DFPrecision::Absent
900876
);
901877
Ok(())

‎vortex-duckdb/src/column_statistics.rs‎

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

4-
use vortex::array::stats::StatsSet;
4+
use vortex::aggregate_fn::AggregateFnRef;
5+
use vortex::aggregate_fn::AggregateFnVTableExt;
6+
use vortex::aggregate_fn::EmptyOptions;
7+
use vortex::aggregate_fn::NumericalAggregateOpts;
8+
use vortex::aggregate_fn::fns::max::Max;
9+
use vortex::aggregate_fn::fns::min::Min;
10+
use vortex::aggregate_fn::fns::null_count::NullCount;
11+
use vortex::aggregate_fn::fns::uncompressed_size_in_bytes::UncompressedSizeInBytes;
512
use vortex::dtype::DType;
613
use vortex::error::VortexExpect as _;
714
use vortex::error::VortexResult;
815
use vortex::expr::stats::Precision;
9-
use vortex::expr::stats::Stat;
16+
use vortex::layout::layouts::file_stats::AggregateStats;
1017
use vortex::scalar::Scalar;
1118
use vortex::scalar::ScalarValue;
1219

@@ -62,29 +69,22 @@ pub struct ColumnStatisticsAggregate {
6269
}
6370

6471
impl ColumnStatisticsAggregate {
65-
pub fn new(stats: &StatsSet) -> Self {
66-
let min = match stats.get(Stat::Min) {
67-
Precision::Exact(min) => Some(min),
68-
_ => None,
69-
};
70-
let max = match stats.get(Stat::Max) {
71-
Precision::Exact(max) => Some(max),
72+
pub fn new(aggregates: &AggregateStats) -> Self {
73+
let exact = |aggregate_fn: AggregateFnRef| match aggregates.get(&aggregate_fn) {
74+
Precision::Exact(value) => value.into_value(),
7275
_ => None,
7376
};
7477

75-
let max_string_length =
76-
if let Precision::Exact(value) = stats.get(Stat::UncompressedSizeInBytes) {
77-
// DuckDB's string length is u32
78-
#[allow(clippy::cast_possible_truncation)]
79-
Some(value.as_primitive().as_u64().vortex_expect("not a u64") as u32)
80-
} else {
81-
None
82-
};
78+
let min = exact(Min.bind(NumericalAggregateOpts::skip_nans()));
79+
let max = exact(Max.bind(NumericalAggregateOpts::skip_nans()));
8380

84-
let has_null = match stats.get(Stat::NullCount) {
85-
Precision::Exact(cnt) => cnt.as_primitive().as_u64().vortex_expect("not a u64") > 0,
86-
_ => true,
87-
};
81+
// DuckDB's string length is u32
82+
#[allow(clippy::cast_possible_truncation)]
83+
let max_string_length = exact(UncompressedSizeInBytes.bind(EmptyOptions))
84+
.map(|value| value.as_primitive().as_u64().vortex_expect("not a u64") as u32);
85+
86+
let has_null = exact(NullCount.bind(EmptyOptions))
87+
.is_none_or(|count| count.as_primitive().as_u64().vortex_expect("not a u64") > 0);
8888

8989
Self {
9090
min,

‎vortex-duckdb/src/copy.rs‎

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ use object_store::ObjectStore;
1212
use object_store::registry::ObjectStoreRegistry;
1313
use parking_lot::Mutex;
1414
use static_assertions::assert_impl_all;
15+
use vortex::aggregate_fn::AggregateFnRef;
16+
use vortex::aggregate_fn::AggregateFnVTableExt;
17+
use vortex::aggregate_fn::EmptyOptions;
18+
use vortex::aggregate_fn::NumericalAggregateOpts;
19+
use vortex::aggregate_fn::fns::max::Max;
20+
use vortex::aggregate_fn::fns::min::Min;
21+
use vortex::aggregate_fn::fns::nan_count::NanCount;
22+
use vortex::aggregate_fn::fns::null_count::NullCount;
1523
use vortex::array::ArrayRef;
1624
use vortex::array::stream::ArrayStreamAdapter;
1725
use vortex::dtype::DType;
@@ -25,7 +33,6 @@ use vortex::error::VortexResult;
2533
use vortex::error::vortex_bail;
2634
use vortex::error::vortex_err;
2735
use vortex::expr::stats::Precision;
28-
use vortex::expr::stats::Stat;
2936
use vortex::file::CompressedFieldSizes;
3037
use vortex::file::FileStatistics;
3138
use vortex::file::WriteOptionsSessionExt;
@@ -289,16 +296,18 @@ fn column_stats_from_summary(
289296
stats_columns.len()
290297
)
291298
})?;
292-
let (stats, dtype) = file_stats.get(stats_index);
299+
let (aggregates, dtype) = file_stats.get(stats_index);
293300
let path = &file_stats.paths()[stats_index];
301+
let value =
302+
|aggregate_fn: AggregateFnRef| aggregates.get(&aggregate_fn).and_then(Scalar::into_value);
294303

295304
Ok(WrittenColumnStats {
296305
column_key: ducklake_column_key(path),
297-
min: exact_scalar_to_duckdb(stats.get(Stat::Min), dtype)?,
298-
max: exact_scalar_to_duckdb(stats.get(Stat::Max), dtype)?,
299-
null_count: exact_u64(stats.get(Stat::NullCount)),
306+
min: exact_scalar_to_duckdb(value(Min.bind(NumericalAggregateOpts::skip_nans())), dtype)?,
307+
max: exact_scalar_to_duckdb(value(Max.bind(NumericalAggregateOpts::skip_nans())), dtype)?,
308+
null_count: exact_u64(value(NullCount.bind(EmptyOptions))),
300309
// NaNCount is exact only for float columns, so this is emitted just for them (as in parquet).
301-
has_nan: exact_u64(stats.get(Stat::NaNCount)).map(|count| count > 0),
310+
has_nan: exact_u64(value(NanCount.bind(EmptyOptions))).map(|count| count > 0),
302311
num_values: summary.row_count(),
303312
// On-disk compressed size; excludes bytes not attributable to a column (e.g. struct validity).
304313
column_size_bytes: column_sizes.and_then(|sizes| sizes.get(path)),
@@ -377,25 +386,19 @@ pub fn copy_to_initialize_global(
377386
#[cfg(test)]
378387
mod tests {
379388
use vortex::array::IntoArray;
380-
use vortex::array::aggregate_fn::AggregateFnRef;
381389
use vortex::array::arrays::StructArray;
382390
use vortex::array::validity::Validity;
383391
use vortex::buffer::ByteBufferMut;
384392
use vortex::buffer::buffer;
393+
use vortex::expr::stats::Stat;
385394

386395
use super::*;
387396

388397
fn pruning_aggregate_fns() -> Vec<AggregateFnRef> {
389-
[
390-
Stat::Min,
391-
Stat::Max,
392-
Stat::Sum,
393-
Stat::NullCount,
394-
Stat::NaNCount,
395-
]
396-
.into_iter()
397-
.filter_map(|stat| stat.aggregate_fn())
398-
.collect()
398+
[Stat::Min, Stat::Max, Stat::NullCount, Stat::NaNCount]
399+
.into_iter()
400+
.filter_map(|stat| stat.aggregate_fn())
401+
.collect()
399402
}
400403

401404
/// Writes a one-column file and returns its summary, with `file_statistics` controlling which

‎vortex-file/flatbuffers/vortex-file/footer.fbs‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,24 +54,50 @@ table PostscriptSegment {
5454
// [postscript]
5555

5656
// [footer]
57+
/// An `AggregateSpec` describes an aggregate function recorded in the file statistics.
58+
table AggregateSpec {
59+
id: string (required);
60+
}
61+
62+
/// The accumulated state of one aggregate function over one file statistics entry.
63+
table AggregateState {
64+
/// Index of the aggregate function's ID in `FileStatistics.aggregate_specs`.
65+
aggregate_spec: uint16;
66+
/// The aggregate function's serialized options.
67+
options: [ubyte];
68+
/// Protobuf serialized `vortex.scalar.ScalarValue` of the aggregate's partial state, typed by
69+
/// the aggregate's partial dtype for the entry's dtype. A null value means no value was
70+
/// accumulated.
71+
partial: [ubyte] (required);
72+
}
73+
74+
/// The aggregate states of one file statistics entry.
75+
table FieldAggregates {
76+
aggregates: [AggregateState];
77+
}
78+
5779
/// The `FileStatistics` object contains file-level statistics for the Vortex file.
5880
table FileStatistics {
5981
/// Statistics for each top-level field in the root schema, in schema order. If the root schema
6082
/// is not a struct, there will be a single entry in this array. Does not recurse into nested
61-
/// struct fields; see `nested_field_stats` for that.
83+
/// struct fields; see `nested_field_aggregates` for that.
6284
///
6385
/// Kept in this legacy shape for backward compatibility with readers that predate
64-
/// `nested_field_stats`, which strictly validate this array's length against the number of
65-
/// top-level struct fields.
86+
/// `nested_field_aggregates`, which strictly validate this array's length against the number
87+
/// of top-level struct fields.
6688
field_stats: [ArrayStats];
67-
/// Statistics following a post-order walk of the root `DType` tree: each leaf field gets an
68-
/// entry, and each nullable struct additionally gets a trailing null-count entry inserted
69-
/// after its children's entries.
89+
/// Dictionary-encoded aggregate function IDs referenced by `nested_field_aggregates`, up to
90+
/// u16::MAX.
91+
aggregate_specs: [AggregateSpec];
92+
/// Aggregate states following a post-order walk of the root `DType` tree: each leaf field gets
93+
/// an entry, and each nullable struct additionally gets a trailing entry for its own null
94+
/// count, inserted after its children's entries.
7095
///
71-
/// Readers that know about this field should prefer it over `field_stats` when present. It is
72-
/// absent for files written before nested file stats were supported, and readers that don't
73-
/// know about it fall back to `field_stats`'s legacy top-level-fields-only layout.
74-
nested_field_stats: [ArrayStats];
96+
/// Readers that know about this field should prefer it over `field_stats` when present,
97+
/// skipping aggregates they don't recognize. It is absent for files written before nested
98+
/// file stats were supported, and readers that don't know about it fall back to
99+
/// `field_stats`'s legacy top-level-fields-only layout.
100+
nested_field_aggregates: [FieldAggregates];
75101
}
76102

77103
/// The `Registry` object stores dictionary-encoded configuration for segments,

0 commit comments

Comments
 (0)