Skip to content

Commit d5216e6

Browse files
SubhamSinghalalamb
andauthored
feat: add GroupColumn support for Decimal32/Decimal64 (#25470)
## Which issue does this PR close? - Part of #24268. ## Rationale for this change `Decimal32` and `Decimal64` group-by keys currently force the whole grouping onto the row-encoded `GroupValuesRows` fallback. `group_column_supported_type` is an all-or-nothing gate: if any one column in a multi-column key is missing from it, `new_group_values` drops the *entire* key to `GroupValuesRows`, so a `(Utf8, Decimal32)` key loses the column-wise path for its string column too: ``` (Utf8, Decimal32 ) -> GroupValuesRows (Utf8, Decimal64 ) -> GroupValuesRows (Utf8, Decimal128) -> GroupValuesColumn ``` DataFusion has supported these two widths since #17501, and #23849 added `Decimal256` to the allow-list, but the two narrow widths were never backfilled. Both implement `ArrowPrimitiveType`, so they reuse the existing `PrimitiveGroupValueBuilder` — no new builder is required, exactly like `Decimal128` and `Decimal256`. The gap is also internally inconsistent today: `Struct("a": Decimal32)` *is* already supported, because nested types reach the generic `RowsGroupColumn` fallback added in #23523. Only the bare top-level type is rejected. ## What changes are included in this PR? - Support `Decimal32` / `Decimal64` in `group_column_supported_type` and `make_group_column`. - `Dictionary(K, Decimal32 | Decimal64)` starts working as a side effect of the existing dictionary recursion in both functions — no extra code. - Extend the `group_column_supported_type_matches_make_group_column` biconditional test with the two scalar types and the two dictionary-wrapped variants. - Add `test_group_values_column_narrow_decimals`, which drives both widths through one body rather than only the first, asserting `supported_schema` routing, dedup including nulls, and that precision/scale survive `emit`. - Add a `bench_narrow_decimals` benchmark mirroring `bench_decimal256`. ## Are these changes tested? Yes. - The consistency test plus the new `test_group_values_column_narrow_decimals` round-trip test in `multi_group_by/mod.rs`. The round-trip test includes a value at each type's full width (`999_999_999` and `999_999_999_999_999_999`) so a truncating storage type would fail it. - Multi-column `Decimal32` / `Decimal64` GROUP BY with a NULL key in `group_by.slt`. These widths are not reachable from a SQL `DECIMAL(p, s)` declaration — that maps to `Decimal128` or `Decimal256` by precision — so the keys are built with `arrow_cast`, and each case is paired with an `arrow_typeof` assertion so it cannot silently degrade into more `Decimal128` coverage. `cargo test -p datafusion-physical-plan --lib` (1921 passed), `aggregate.slt group_by.slt dictionary.slt decimal.slt`, and `dev/rust_lint.sh` are all clean. ## Are there any user-facing changes? No. This only changes which `GroupValues` implementation is selected; grouping semantics and output types are unchanged. Co-authored-by: Andrew Lamb <andrew@nerdnetworks.org>
1 parent 57b830b commit d5216e6

3 files changed

Lines changed: 255 additions & 10 deletions

File tree

‎datafusion/physical-plan/benches/multi_group_by.rs‎

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@
2929
//! `(List<Int32>, Int32)` key to exercise the `ListGroupValueBuilder`.
3030
3131
use arrow::array::{
32-
ArrayRef, Decimal256Array, DurationMicrosecondArray, Float16Array, Int32Array,
33-
IntervalMonthDayNanoArray, ListArray, StringArray, UInt32Array,
32+
ArrayRef, Decimal32Array, Decimal64Array, Decimal256Array, DurationMicrosecondArray,
33+
Float16Array, Int32Array, IntervalMonthDayNanoArray, ListArray, StringArray,
34+
UInt32Array,
3435
};
3536
use arrow::buffer::OffsetBuffer;
3637
use arrow::compute::take;
@@ -1011,6 +1012,111 @@ fn bench_list_utf8(c: &mut Criterion) {
10111012
group.finish();
10121013
}
10131014

1015+
/// Generate `(Decimal32 | Decimal64, Int32)` batches with `num_distinct_groups`
1016+
/// distinct keys.
1017+
///
1018+
/// The decimal column is built by `make_keys` so one generator serves both
1019+
/// narrow widths. The `Int32` column is keyed identically so the combined
1020+
/// cardinality equals `num_distinct_groups`.
1021+
fn generate_narrow_decimal_batches(
1022+
num_distinct_groups: usize,
1023+
num_rows: usize,
1024+
batch_size: usize,
1025+
make_keys: fn(&[usize]) -> ArrayRef,
1026+
) -> Vec<Vec<ArrayRef>> {
1027+
let num_full_batches = num_rows / batch_size;
1028+
let remainder = num_rows % batch_size;
1029+
let num_batches = num_full_batches + if remainder > 0 { 1 } else { 0 };
1030+
1031+
(0..num_batches)
1032+
.map(|batch_idx| {
1033+
let batch_start = batch_idx * batch_size;
1034+
let current_batch_size = if batch_idx == num_batches - 1 && remainder > 0 {
1035+
remainder
1036+
} else {
1037+
batch_size
1038+
};
1039+
1040+
let group_ids: Vec<usize> = (0..current_batch_size)
1041+
.map(|row| (batch_start + row) % num_distinct_groups)
1042+
.collect();
1043+
1044+
let id: Int32Array = group_ids.iter().map(|&g| g as i32).collect();
1045+
1046+
vec![make_keys(&group_ids), Arc::new(id) as ArrayRef]
1047+
})
1048+
.collect()
1049+
}
1050+
1051+
/// Experiment 14: Group count sweep for `(Decimal32, Int32)` and
1052+
/// `(Decimal64, Int32)` keys.
1053+
///
1054+
/// Exercises the primitive `GroupColumn` builder for the two narrow decimal
1055+
/// widths (4-byte `i32` and 8-byte `i64` native) on the multi-column path
1056+
/// (previously such a schema fell back to `GroupValuesRows`).
1057+
fn bench_narrow_decimals(c: &mut Criterion) {
1058+
let cases: [(&str, DataType, fn(&[usize]) -> ArrayRef); 2] = [
1059+
("decimal32", DataType::Decimal32(9, 0), |group_ids| {
1060+
Arc::new(
1061+
Decimal32Array::from_iter_values(group_ids.iter().map(|&g| g as i32))
1062+
.with_precision_and_scale(9, 0)
1063+
.unwrap(),
1064+
) as ArrayRef
1065+
}),
1066+
("decimal64", DataType::Decimal64(18, 0), |group_ids| {
1067+
Arc::new(
1068+
Decimal64Array::from_iter_values(group_ids.iter().map(|&g| g as i64))
1069+
.with_precision_and_scale(18, 0)
1070+
.unwrap(),
1071+
) as ArrayRef
1072+
}),
1073+
];
1074+
1075+
for (name, data_type, make_keys) in cases {
1076+
let mut group = c.benchmark_group(name);
1077+
group.sample_size(15);
1078+
1079+
let schema: SchemaRef = Arc::new(Schema::new(vec![
1080+
Field::new("dec", data_type, false),
1081+
Field::new("id", DataType::Int32, false),
1082+
]));
1083+
1084+
for num_groups in [1_000, 1_000_000] {
1085+
let batches = generate_narrow_decimal_batches(
1086+
num_groups,
1087+
1_000_000,
1088+
DEFAULT_BATCH_SIZE,
1089+
make_keys,
1090+
);
1091+
1092+
for vectorized in [true, false] {
1093+
let label = if vectorized {
1094+
"vectorized"
1095+
} else {
1096+
"row_based"
1097+
};
1098+
group.bench_with_input(
1099+
BenchmarkId::new(label, format!("grp_{num_groups}")),
1100+
&batches,
1101+
|b, batches| {
1102+
b.iter_batched_ref(
1103+
|| {
1104+
(
1105+
create_group_values(&schema, vectorized),
1106+
Vec::<usize>::with_capacity(DEFAULT_BATCH_SIZE),
1107+
)
1108+
},
1109+
|(gv, groups)| bench_intern(gv, batches, groups),
1110+
criterion::BatchSize::LargeInput,
1111+
);
1112+
},
1113+
);
1114+
}
1115+
}
1116+
group.finish();
1117+
}
1118+
}
1119+
10141120
criterion_group!(
10151121
benches,
10161122
bench_issue_17850_regression,
@@ -1026,5 +1132,6 @@ criterion_group!(
10261132
bench_decimal256,
10271133
bench_list_int,
10281134
bench_list_utf8,
1135+
bench_narrow_decimals,
10291136
);
10301137
criterion_main!(benches);

‎datafusion/physical-plan/src/aggregates/group_values/multi_group_by/mod.rs‎

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ use crate::aggregates::group_values::multi_group_by::{
3838
};
3939
use arrow::array::{Array, ArrayRef, BooleanBufferBuilder, new_empty_array};
4040
use arrow::datatypes::{
41-
BinaryViewType, DataType, Date32Type, Date64Type, Decimal128Type, Decimal256Type,
42-
DurationMicrosecondType, DurationMillisecondType, DurationNanosecondType,
43-
DurationSecondType, Field, Float16Type, Float32Type, Float64Type, Int8Type,
44-
Int16Type, Int32Type, Int64Type, IntervalDayTimeType, IntervalMonthDayNanoType,
45-
IntervalUnit, IntervalYearMonthType, Schema, SchemaRef, StringViewType,
46-
Time32MillisecondType, Time32SecondType, Time64MicrosecondType, Time64NanosecondType,
47-
TimeUnit, TimestampMicrosecondType, TimestampMillisecondType,
41+
BinaryViewType, DataType, Date32Type, Date64Type, Decimal32Type, Decimal64Type,
42+
Decimal128Type, Decimal256Type, DurationMicrosecondType, DurationMillisecondType,
43+
DurationNanosecondType, DurationSecondType, Field, Float16Type, Float32Type,
44+
Float64Type, Int8Type, Int16Type, Int32Type, Int64Type, IntervalDayTimeType,
45+
IntervalMonthDayNanoType, IntervalUnit, IntervalYearMonthType, Schema, SchemaRef,
46+
StringViewType, Time32MillisecondType, Time32SecondType, Time64MicrosecondType,
47+
Time64NanosecondType, TimeUnit, TimestampMicrosecondType, TimestampMillisecondType,
4848
TimestampNanosecondType, TimestampSecondType, UInt8Type, UInt16Type, UInt32Type,
4949
UInt64Type,
5050
};
@@ -1036,6 +1036,12 @@ fn make_group_column(field: &Field) -> Result<Box<dyn GroupColumn>> {
10361036
instantiate_primitive!(nullable, IntervalMonthDayNanoType, data_type)
10371037
}
10381038
},
1039+
DataType::Decimal32(_, _) => {
1040+
instantiate_primitive!(nullable, Decimal32Type, data_type)
1041+
}
1042+
DataType::Decimal64(_, _) => {
1043+
instantiate_primitive!(nullable, Decimal64Type, data_type)
1044+
}
10391045
DataType::Decimal128(_, _) => {
10401046
instantiate_primitive!(nullable, Decimal128Type, data_type)
10411047
}
@@ -1333,7 +1339,9 @@ mod tests {
13331339
Int32Array, Int64Array, PrimitiveArray, RecordBatch, StringArray,
13341340
StringViewArray, UInt32Array,
13351341
};
1336-
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
1342+
use arrow::datatypes::{
1343+
DataType, Decimal32Type, Decimal64Type, Field, Schema, SchemaRef,
1344+
};
13371345
use arrow::{
13381346
compute::{concat_batches, take},
13391347
util::pretty::pretty_format_batches,
@@ -1633,6 +1641,8 @@ mod tests {
16331641
DataType::Float32,
16341642
DataType::Float64,
16351643
DataType::Float16,
1644+
DataType::Decimal32(9, 2),
1645+
DataType::Decimal64(18, 2),
16361646
DataType::Decimal128(38, 10),
16371647
DataType::Decimal256(76, 10),
16381648
DataType::Utf8,
@@ -1684,6 +1694,16 @@ mod tests {
16841694
)),
16851695
),
16861696
DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Float16)),
1697+
// The narrow decimals are reachable through the dictionary
1698+
// recursion too, not just as top-level keys.
1699+
DataType::Dictionary(
1700+
Box::new(DataType::Int32),
1701+
Box::new(DataType::Decimal32(9, 2)),
1702+
),
1703+
DataType::Dictionary(
1704+
Box::new(DataType::Int32),
1705+
Box::new(DataType::Decimal64(18, 2)),
1706+
),
16871707
];
16881708

16891709
for dt in &supported_cases {
@@ -1792,6 +1812,82 @@ mod tests {
17921812
assert_eq!(actual.value(2), 20);
17931813
}
17941814

1815+
// `Decimal32` / `Decimal64` group keys stay on the `GroupValuesColumn` fast
1816+
// path, dedup (including nulls), and round-trip with their declared
1817+
// precision and scale preserved -- not widened to `Decimal128`, and not
1818+
// emitted as the bare i32 / i64 storage type.
1819+
#[test]
1820+
fn test_group_values_column_narrow_decimals() {
1821+
// Both narrow widths go through the same primitive builder, so drive
1822+
// them from one body rather than testing only the first.
1823+
fn check<T>(data_type: DataType, values: [T::Native; 3])
1824+
where
1825+
T: arrow::datatypes::DecimalType + arrow::datatypes::ArrowPrimitiveType,
1826+
{
1827+
let schema = Arc::new(Schema::new(vec![
1828+
Field::new("d", data_type.clone(), true),
1829+
Field::new("i", DataType::Int64, true),
1830+
]));
1831+
assert!(supported_schema(&schema), "{data_type} not on column path");
1832+
let mut group_values =
1833+
GroupValuesColumn::<false>::try_new(Arc::clone(&schema)).unwrap();
1834+
1835+
let (DataType::Decimal32(p, s) | DataType::Decimal64(p, s)) = data_type
1836+
else {
1837+
unreachable!("this test only covers the narrow decimals")
1838+
};
1839+
// Row 3 repeats row 0 and row 4 repeats the null pair. Row 5 carries
1840+
// a value at the storage type's full width.
1841+
let d: ArrayRef = Arc::new(
1842+
[
1843+
Some(values[0]),
1844+
None,
1845+
Some(values[1]),
1846+
Some(values[0]),
1847+
None,
1848+
Some(values[2]),
1849+
]
1850+
.into_iter()
1851+
.collect::<PrimitiveArray<T>>()
1852+
.with_precision_and_scale(p, s)
1853+
.unwrap(),
1854+
);
1855+
let i: ArrayRef = Arc::new(Int64Array::from(vec![
1856+
Some(1),
1857+
None,
1858+
Some(2),
1859+
Some(1),
1860+
None,
1861+
Some(3),
1862+
]));
1863+
let mut groups = Vec::new();
1864+
group_values.intern(&[d, i], &mut groups).unwrap();
1865+
assert_eq!(groups, vec![0, 1, 2, 0, 1, 3]);
1866+
1867+
let emitted = group_values.emit(EmitTo::All).unwrap();
1868+
assert_eq!(emitted.len(), 2);
1869+
// Precision and scale survive the round trip.
1870+
assert_eq!(emitted[0].data_type(), &data_type);
1871+
let actual = emitted[0]
1872+
.as_any()
1873+
.downcast_ref::<PrimitiveArray<T>>()
1874+
.expect("emitted column should keep its decimal type");
1875+
// Four groups in first-seen order.
1876+
assert_eq!(actual.len(), 4);
1877+
assert_eq!(actual.value(0), values[0]);
1878+
assert!(actual.is_null(1));
1879+
assert_eq!(actual.value(2), values[1]);
1880+
// The full-width value is not truncated by the narrower storage.
1881+
assert_eq!(actual.value(3), values[2]);
1882+
}
1883+
1884+
check::<Decimal32Type>(DataType::Decimal32(9, 2), [1000, 2000, 999_999_999]);
1885+
check::<Decimal64Type>(
1886+
DataType::Decimal64(18, 2),
1887+
[1000, 2000, 999_999_999_999_999_999],
1888+
);
1889+
}
1890+
17951891
// `(Float16, Int32)` keys: ±0.0 collapse (stored as +0.0), NaNs collapse, and
17961892
// the Int32 key keeps `(0.0, 4)` distinct from `(±0.0, 3)`.
17971893
#[test]

‎datafusion/sqllogictest/test_files/group_by.slt‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5880,3 +5880,45 @@ select k, d, count(*) from decimal256_multi_group group by k, d order by k, d;
58805880

58815881
statement ok
58825882
drop table decimal256_multi_group;
5883+
5884+
# Test multi group by int + Decimal32 / Decimal64
5885+
#
5886+
# Unlike the Decimal128 / Decimal256 cases above, these two widths are not
5887+
# reachable from a SQL `DECIMAL(p, s)` declaration -- that maps to Decimal128 or
5888+
# Decimal256 by precision -- so the key has to be built with arrow_cast. The
5889+
# arrow_typeof queries pin that the group key really is the narrow type, so
5890+
# these cases cannot silently degrade into more Decimal128 coverage.
5891+
statement ok
5892+
create table narrow_decimal_multi_group (k int, d decimal(9, 2)) as values
5893+
(1, 100.00), (1, 100.00), (1, 250.00), (2, 100.00), (2, NULL);
5894+
5895+
query T
5896+
select distinct arrow_typeof(arrow_cast(d, 'Decimal32(9, 2)')) from narrow_decimal_multi_group;
5897+
----
5898+
Decimal32(9, 2)
5899+
5900+
query IRI
5901+
select k, arrow_cast(d, 'Decimal32(9, 2)'), count(*)
5902+
from narrow_decimal_multi_group group by 1, 2 order by 1, 2;
5903+
----
5904+
1 100.00 2
5905+
1 250.00 1
5906+
2 100.00 1
5907+
2 NULL 1
5908+
5909+
query T
5910+
select distinct arrow_typeof(arrow_cast(d, 'Decimal64(18, 2)')) from narrow_decimal_multi_group;
5911+
----
5912+
Decimal64(18, 2)
5913+
5914+
query IRI
5915+
select k, arrow_cast(d, 'Decimal64(18, 2)'), count(*)
5916+
from narrow_decimal_multi_group group by 1, 2 order by 1, 2;
5917+
----
5918+
1 100.00 2
5919+
1 250.00 1
5920+
2 100.00 1
5921+
2 NULL 1
5922+
5923+
statement ok
5924+
drop table narrow_decimal_multi_group;

0 commit comments

Comments
 (0)