Skip to content

[Variant] Align cast logic for from/to_decimal for variant#9689

Open
klion26 wants to merge 2 commits intoapache:mainfrom
klion26:variant-cast-decimal
Open

[Variant] Align cast logic for from/to_decimal for variant#9689
klion26 wants to merge 2 commits intoapache:mainfrom
klion26:variant-cast-decimal

Conversation

@klion26
Copy link
Copy Markdown
Member

@klion26 klion26 commented Apr 10, 2026

Which issue does this PR close?

What changes are included in this PR?

  • Extract some logic in arrow-cast
  • Reuse the extracted logic in arrow-cast and parquet-variant

Are these changes tested?

Reuse the existing tests in arrow-test

Are there any user-facing changes?

Yes, changed the docs

@github-actions github-actions bot added arrow Changes to the arrow crate parquet-variant parquet-variant* crates labels Apr 10, 2026
Copy link
Copy Markdown
Member Author

@klion26 klion26 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@scovich @sdf-jkl Please help to review this when you're free, thanks

.ok()
.and_then(|x: i32| x.try_into().ok()),
Variant::ShortString(v) => {
parse_string_to_decimal_native::<Decimal32Type>(v.as_str(), 0usize)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

@scovich scovich left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 _,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Suggested change
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 _)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +1193 to +1194
.map(|(_, frac)| frac.len())
.unwrap_or(0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Suggested change
.map(|(_, frac)| frac.len())
.unwrap_or(0);
.map_or_else(0, |(_, frac)| frac.len());

or even

Suggested change
.map(|(_, frac)| frac.len())
.unwrap_or(0);
.map_or_default(|(_, frac)| frac.len());

Comment on lines +1198 to +1200
parse_string_to_decimal_native::<D>(input, scale_usize)
.ok()
.and_then(|raw| VD::try_new(raw, scale).ok())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Suggested change
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>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: If you swap the template order, I callers would be a tad more readable, e.g.:

convert_string_to_decimal::<Decimal32Type, _>

Comment on lines +1331 to +1332
.as_num::<i64>()
.map(|x| (x as i128).try_into().ok())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arrow Changes to the arrow crate parquet-variant parquet-variant* crates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Align cast logic for from/to_decimal for variant to cast kernel

2 participants