[Variant] Align cast logic for from/to_decimal for variant#9689
[Variant] Align cast logic for from/to_decimal for variant#9689klion26 wants to merge 2 commits intoapache:mainfrom
Conversation
parquet-variant/src/variant.rs
Outdated
| .ok() | ||
| .and_then(|x: i32| x.try_into().ok()), | ||
| Variant::ShortString(v) => { | ||
| parse_string_to_decimal_native::<Decimal32Type>(v.as_str(), 0usize) |
There was a problem hiding this comment.
Use v.as_str() instead of v because we use match *self, if we change to match self then we need to derefer self in other match arms, seems there is litter benefit gained, so stick to the current match *self and use v.as_str() here.
scovich
left a comment
There was a problem hiding this comment.
General approach looks reasonable, but needs some tweaks to avoid regressing performance. Do we have benchmarks we can throw at this to verify?
| let v = cast_single_decimal_to_integer::<D, T::Native>( | ||
| array.value(i), | ||
| div, | ||
| scale as _, |
There was a problem hiding this comment.
Why are we casting? Isn't it a trivial i16 -> i16 cast?
(again below)
| } else { | ||
| match cast_options.safe { | ||
| true => { | ||
| let v = cast_single_decimal_to_integer::<D, T::Native>( |
There was a problem hiding this comment.
The original code hoisted checks for scale < 0 (mul_checked vs. div_checked) and cast_options.safe (NULL vs. error) outside the loop, producing four simple loop bodies. This was presumably done for performance reasons (minimizing branching inside the loop).
The new code pushes the cast_options.safe check inside a single loop and pushes scale < 0 check all the way down inside cast_single_decimal_to_integer. That triples the number of branches inside the loop body (the null check is per-row and so is always stuck inside the loop). Performance will almost certainly be impacted, possibly significantly.
It would be safer to just preserve the replication (even tho it duplicates logic with the new helper), and rely on the compiler's inlining and "jump threading" optimizations to eliminate that redundancy:
code snippet
if scale < 0 {
if cast_options.safe {
for i in 0..array.len() {
if array.is_null(i) {
value_builder.append_null();
} else {
let v = cast_single_decimal_to_integer::<D, T::Native>(...);
value_builder.append_option(v.ok());
}
}
} else {
for i in 0..array.len() {
if array.is_null(i) {
value_builder.append_null();
} else {
let v = cast_single_decimal_to_integer::<D, T::Native>(...);
value_builder.append_value(v?);
}
}
}
} else {
if cast_options.safe {
for i in 0..array.len() {
if array.is_null(i) {
value_builder.append_null();
} else {
let v = cast_single_decimal_to_integer::<D, T::Native>(...);
value_builder.append_option(v.ok());
}
}
} else {
for i in 0..array.len() {
if array.is_null(i) {
value_builder.append_null();
} else {
let v = cast_single_decimal_to_integer::<D, T::Native>(...);
value_builder.append_value(v?);
}
}
}
}If you wanted to simplify a bit, you could define and use a local macro inside this function:
// Helper macro for emitting nearly the same loop every time, so we can hoist branches out.
// The compiler will specialize the resulting code (inlining and jump threading)
macro_rules! cast_loop {
(|$v:ident| $body:expr) => {{
for i in 0..array.len() {
if array.is_null(i) {
value_builder.append_null();
} else {
let $v = cast_single_decimal_to_integer::<D, T::Native>(...);
$body
}
}
}};
}
if scale < 0 {
if cast_options.safe {
cast_loop!(|v| value_builder.append_option(v.ok()));
} else {
cast_loop!(|v| value_builder.append_value(v?));
}
} else {
if cast_options.safe {
cast_loop!(|v| value_builder.append_option(v.ok()));
} else {
cast_loop!(|v| value_builder.append_value(v?));
}
}Note that the four loop bodies are almost syntactically identical -- differing only in whether they append_option(v.ok()) or append_value(v?) -- but the inlined body of cast_single_decimal_to_integer inside each loop will be specialized based on the scale < 0 check we already performed. Result: stand-alone calls to the helper function are always safe, but we still get maximum performance here.
| D: DecimalType, | ||
| F: Fn(D::Native) -> f64, | ||
| { | ||
| f(x) / 10_f64.powi(scale) |
There was a problem hiding this comment.
fmul is drastically cheaper than fdiv on every architecture I know of. As in, 5-10x higher ops/second.
Any reason we shouldn't switch?
| f(x) / 10_f64.powi(scale) | |
| f(x) * 10_f64.powi(-scale) |
| }), | ||
| Float64 => cast_decimal_to_float::<D, Float64Type, _>(array, |x| { | ||
| as_float(x) / 10_f64.powi(*scale as i32) | ||
| single_decimal_to_float_lossy::<D, F>(&as_float, x, *scale as _) |
There was a problem hiding this comment.
If we're anyway changing the code, i32::from(*scale) makes clear that this is a lossless conversion
(a bunch more similarly lossless as _ below)
| Variant::Decimal16(d) => Self::cast_decimal_to_num::<Decimal128Type, T, _>( | ||
| d.integer(), | ||
| d.scale(), | ||
| |x: i128| x as f64, |
There was a problem hiding this comment.
I'm a bit surprised those type annotations are necessary when the first arg takes D::Native and should thus constrain the third arg's `F: fn(D::Native) -> f64?
| .map(|(_, frac)| frac.len()) | ||
| .unwrap_or(0); |
There was a problem hiding this comment.
nit
| .map(|(_, frac)| frac.len()) | |
| .unwrap_or(0); | |
| .map_or_else(0, |(_, frac)| frac.len()); |
or even
| .map(|(_, frac)| frac.len()) | |
| .unwrap_or(0); | |
| .map_or_default(|(_, frac)| frac.len()); |
| parse_string_to_decimal_native::<D>(input, scale_usize) | ||
| .ok() | ||
| .and_then(|raw| VD::try_new(raw, scale).ok()) |
There was a problem hiding this comment.
nit
| parse_string_to_decimal_native::<D>(input, scale_usize) | |
| .ok() | |
| .and_then(|raw| VD::try_new(raw, scale).ok()) | |
| let raw = parse_string_to_decimal_native::<D>(input, scale_usize).ok()?; | |
| VD::try_new(raw, scale).ok() |
| self.as_num() | ||
| } | ||
|
|
||
| fn convert_string_to_decimal<VD, D>(input: &str) -> Option<VD> |
There was a problem hiding this comment.
nit: If you swap the template order, I callers would be a tad more readable, e.g.:
convert_string_to_decimal::<Decimal32Type, _>| .as_num::<i64>() | ||
| .map(|x| (x as i128).try_into().ok()) |
There was a problem hiding this comment.
Out of curiosity, why not just as_num::<i128> directly?
But if you must keep the double cast, at least do i128::from(x) to make clear it's lossless.
Which issue does this PR close?
What changes are included in this PR?
Are these changes tested?
Reuse the existing tests in arrow-test
Are there any user-facing changes?
Yes, changed the docs