alexeigorwork opened a new issue, #10953:
URL: https://github.com/apache/arrow-rs/issues/10953

   ## Is your feature request related to a problem or challenge?
   
   All 110 numeric↔numeric arms of `cast_with_options` route through
   `cast_numeric_arrays` (`arrow-cast/src/cast/mod.rs:2506`), which performs a 
per-element
   checked `num_traits::cast` — including for conversions that are total, such 
as
   `Int32 → Int64`. Neither `CastOptions::safe` setting avoids this:
   
   ```rust
   if cast_options.safe {
       Ok(Arc::new(numeric_cast::<FROM, TO>(from.as_primitive::<FROM>())))      
// unary_opt
   } else {
       Ok(Arc::new(try_numeric_cast::<FROM, TO>(from.as_primitive::<FROM>())?)) 
// try_unary
   }
   ```
   
   * `safe: true` → `unary_opt`, which allocates and **rebuilds a new validity 
bitmap**
     from each element's `Option`. For a widening cast the result is provably 
identical to
     the input's bitmap.
   * `safe: false` → `try_unary`, which clones the null buffer but still calls 
a fallible
     `op` per element and iterates through `nulls.try_for_each_valid_idx` — an
     index-by-index walk rather than a linear scan.
   
   For `Int32 → Int64` the range check can never fire, the rebuilt bitmap is 
redundant, and
   the per-element `Option`/`Result` prevents the compiler from emitting a 
widening SIMD
   instruction. The docs on the adjacent `try_unary_mut` already state the 
consequence:
   
   > Note: LLVM is currently unable to effectively vectorize fallible operations
   
   ### Measurements
   
   `i32 → i64`, Criterion medians. Apple M5 Pro, rustc 1.98.0 stable, `lto = 
"fat"`,
   `codegen-units = 1`, mimalloc.
   
   | n | nulls | `cast` (safe) | `cast` (safe=false) | `unary(\|v\| v as i64)` 
| gap |
   |---|---|---|---|---|---|
   | 1 000 | 0% | 0.205 µs | 0.202 µs | **0.101 µs** | 2.0× |
   | 100 000 | 0% | 10.8 µs | 11.7 µs | **9.07 µs** | 1.2× |
   | 10 000 000 | 0% | 1263.9 µs | 1236.0 µs | **940.9 µs** | 1.3× |
   | 1 000 | 10% | 0.563 µs | 0.534 µs | **0.100 µs** | 5.3× |
   | 100 000 | 10% | 53.4 µs | 52.1 µs | **8.57 µs** | 6.1× |
   | 10 000 000 | 10% | 5353.9 µs | 5329.5 µs | **934.3 µs** | 5.7× |
   
   The cost is concentrated in the null path: stock `cast` gets **4.3× slower** 
once a
   column has any nulls (1236 → 5330 µs at 10M), while the `unary` version is 
flat
   (941 → 934 µs). The ratio holds from n=1 000 to n=10 000 000, including at 
n=8 192.
   
   ### Downstream impact
   
   DataFusion 55.0.0 pins arrow 59.3.0 — the version measured above — so 
DataFusion can be
   built against the modified `arrow-cast` and the same query run both ways. The
   substitution was confirmed present in the resolved dependency graph before 
measuring.
   
   DataFusion's `sum` signature coerces `Int8/Int16/Int32 → Int64` explicitly, 
so
   `SUM(int32_col)` casts the whole column.
   
   Two queries over identical data (1220 × 8192 = 9,994,240 rows, 10% nulls,
   `target_partitions = 1`, DataFusion's default 8192-row batches). The only 
difference
   between them is the cast:
   
   | query | stock | patched | speedup |
   |---|---|---|---|
   | `SELECT SUM(c_i32) FROM t` | 7.426 ms | **3.349 ms** | **2.22×** |
   | `SELECT SUM(c_i64) FROM t` *(control)* | 2.097 ms | 2.205 ms | 0.95× |
   
   The cast component drops from **5.329 ms to 1.144 ms (4.66×)**, taking it 
from **72% to
   34%** of query time. The unchanged control shows nothing else moved.
   
   Both arms were run back to back in one session, using exactly the predicate 
shown below.
   An earlier run of a wider predicate (one that also covered `Float16` 
targets) gave
   2.24× / 4.60× — the same result within noise, as expected, since those arms 
are not
   reachable from an `Int32 → Int64` cast.
   
   DataFusion reaches this path from more than `SUM`: comparison against an 
`Int64`
   literal, join keys of differing width, `UNION` branch unification, 
`CASE`/`IN` result
   types, and schema unification across files with differing types.
   
   ## Describe the solution you'd like
   
   A fast path in `cast_numeric_arrays` for conversions where `num_cast` is 
total.
   `num_traits::cast::AsPrimitive` is already imported in the module, and since 
all 110
   call sites use concrete types, adding the bound needs no call-site changes:
   
   ```rust
   fn cast_numeric_arrays<FROM, TO>(from: &dyn Array, cast_options: 
&CastOptions)
       -> Result<ArrayRef, ArrowError>
   where
       FROM: ArrowPrimitiveType,
       TO: ArrowPrimitiveType,
       FROM::Native: NumCast + AsPrimitive<TO::Native>,
       TO::Native: NumCast,
   {
       if is_infallible_numeric_cast(&FROM::DATA_TYPE, &TO::DATA_TYPE) {
           return Ok(Arc::new(
               from.as_primitive::<FROM>().unary::<_, TO>(|v| v.as_()),
           ));
       }
       // ... existing safe / try paths unchanged
   }
   ```
   
   `unary` clones the input `NullBuffer` (a refcount bump — no scan, no 
allocation) and
   maps the closure over the values, which vectorizes.
   
   ### The predicate
   
   ```rust
   /// True when `num_cast::<FROM, TO>` is total — it can never return `None`, 
so the
   /// per-element check can never fire and the conversion is a plain `as`.
   fn is_infallible_numeric_cast(from: &DataType, to: &DataType) -> bool {
       use DataType::*;
       matches!(
           (from, to),
           (Int8,   Int16 | Int32 | Int64 | Float32 | Float64)
               | (Int16,  Int32 | Int64 | Float32 | Float64)
               | (Int32,  Int64 | Float32 | Float64)
               | (Int64,  Float32 | Float64)
               | (UInt8,  UInt16 | UInt32 | UInt64 | Int16 | Int32 | Int64 | 
Float32 | Float64)
               | (UInt16, UInt32 | UInt64 | Int32 | Int64 | Float32 | Float64)
               | (UInt32, UInt64 | Int64 | Float32 | Float64)
               | (UInt64, Float32 | Float64)
               | (Float32, Float64)
       )
   }
   ```
   
   **It costs nothing at runtime.** It is called with `FROM::DATA_TYPE` / 
`TO::DATA_TYPE`,
   which are associated constants rather than values read from the array.
   `cast_numeric_arrays` is monomorphised per concrete pair, so the `Int32 → 
Int64`
   instantiation sees `is_infallible_numeric_cast(&Int32, &Int64)`, folds it to 
`true`, and
   drops the branch and the checked path entirely. It reads like a runtime 
match; it is not
   one.
   
   **Why each group is total:**
   
   | group | reasoning |
   |---|---|
   | integer widening within a signedness (`i8→i16→i32→i64`, `u8→u16→u32→u64`) 
| the target range strictly contains the source range |
   | unsigned → strictly wider signed (`u8→i16`, `u16→i32`, `u32→i64`) | `i64` 
has 63 magnitude bits, so every `u32` fits. Note `u32→i32` is absent: same 
width, so it can overflow |
   | integer → float | `num_cast` never fails here, it rounds. Lossy above the 
mantissa width, but `as` rounds *identically*, so the substitution is 
value-preserving. Totality is the property that matters, not exactness |
   | `f32 → f64` | every `f32` is exactly representable, NaN and infinities 
included |
   
   **Every narrowing conversion is excluded**, where the check is load-bearing.
   
   **The predicate is fail-safe by construction.** `matches!` yields `false` 
for anything
   unlisted, and `false` means "use the existing checked path". An omission 
therefore costs
   performance, never correctness. A wrong *addition* would be a correctness 
bug, which is
   what the negative control below guards.
   
   **On `Float16`:** the version I benchmarked also included `Float16` targets. 
They are
   omitted above because the correctness test cannot cover them — `v as f16` is 
not a
   primitive cast, so the direct numeric proof does not apply, and their safety 
would rest
   on `half::f16`'s `NumCast` impl agreeing with saturation on values like 
`i64::MAX`.
   That is plausible but unproven, and the set above is already sufficient to 
produce the
   DataFusion result. `Float16` can be added later with a purpose-built test.
   
   ### Correctness
   
   The substitution is safe iff `num_cast::<FROM, TO>(v) == Some(v as TO)` for 
every value
   of `FROM`. That is a claim about the numbers, not about arrow, so it is 
worth testing
   directly rather than by comparing two arrow code paths:
   
   ```rust
   for v in samples!($from) {                      // MIN, MAX, 0, 1 + a 
65-point spread
       assert_eq!(num_cast::<$from, $to>(v), Some(v as $to));
   }
   ```
   
   This passes for every pair in the proposed set, with a negative control 
asserting that
   the narrowing pairs genuinely are *not* infallible (`num_cast::<i64, 
i32>(i64::MAX)` is
   `None`, whereas `i64::MAX as i32` is `-1` — which is exactly why those keep 
the check).
   
   The patched build also passes all **360 existing tests in `arrow-cast`**.
   
   I am happy to open a PR if the approach looks right.
   
   ## Describe alternatives you've considered
   
   * **`CastOptions { safe: false }`** — does not help. It swaps `unary_opt` for
     `try_unary`, keeping the per-element check; measured within 2% of the safe 
path.
     DataFusion already uses `safe: false` throughout
     (`datafusion-physical-expr/src/expressions/cast.rs:37`) and so cannot opt 
out.
   * **Callers using `unary` directly** — works, and is what I used to obtain 
the numbers,
     but it is not reachable through the public `cast` API. It also pushes the
     "is this conversion infallible?" decision onto every caller, which is 
exactly the
     knowledge `arrow-cast` already has.
   * **Leaving it to downstream** — the two closest prior issues (#7097, #7055) 
were
     resolved in the Parquet reader rather than in `cast_numeric_arrays`, so 
this function
     has already been worked around once from another direction.
   * **Expressing infallibility in the type system** rather than as a matched 
pair — an
     associated `const INFALLIBLE: bool` on a `CastFrom`-style trait would make 
it a
     compile-time fact instead of relying on the match being folded, and would 
make an
     unhandled pair a compile error rather than a silent slow path. It is more 
invasive,
     and it duplicates knowledge `num_traits` already encodes, but if 
maintainers prefer
     that shape I am happy to write it that way. The matched-pair version above 
was chosen
     because it needs no new trait and no call-site changes.
   
   ## Additional context
   
   ### Prior art in other Arrow implementations
   
   * **polars-compute** encodes the distinction in its dispatch table:
     `primitive_to_primitive_dyn` branches on `options.wrapped`, choosing
     `primitive_as_primitive` (`unary` + `AsPrimitive::as_`) for 48 arms tagged
     `as_options`, and the checked `primitive_to_primitive` otherwise.
   * **Arrow C++** already skips the check for a widening cast: 
`CastOptions::Unsafe()`
     measured identical to the safe default (23.9 vs 23.6 µs at 100 k), i.e. 
the safe path
     was not doing extra work to begin with.
   
   ### Why the existing benchmarks do not surface this
   
   `arrow/benches/cast_kernels.rs` builds its numeric inputs with 
`build_array::<T>(512)`.
   `cast int32 to int64 512` exists, but 512 × 4 bytes = 2 KB measures dispatch 
rather than
   the per-element conversion. There is also no `unary` reference arm in the 
suite —
   grepping `unary|try_unary|unary_opt` across all 63 bench files in the 
workspace matches
   only `array_iter.rs`, and not as a kernel comparison.
   
   Two suggestions, independent of whether the fix lands:
   
   * raise the numeric cast sizes in `cast_kernels.rs` to at least 64 Ki and 
sweep null
     density — the null path is where the cost concentrates
   * add `unary` as a reference arm so the cost of the checked conversion is 
visible
   
   ### Related issues (searched; none appear to duplicate this)
   
   * **#1918 — "Replace checked casts with `as` for performance"** (closed 
2022). Same
     words, different subject: `try_into().unwrap()` on offsets and indices in 
array-data
     internals, not `num_traits::cast` in the cast kernel.
   * **#7097 / #7055 — Parquet performance reading int8/int16** (closed). 
*Narrowing*
     casts (32 bits down to 8/16), where the range check is load-bearing. 
Opposite
     direction from this report.
   * **#10726 — "support unsafe casting with masked null values"** (open PR). 
Touches
     `safe: false` behaviour for string, list, struct and map casts; does not 
modify
     `cast_numeric_arrays`.
   * **#9789 — "Remove redundant benchmarks in `cast_kernels`"** (merged). 
Pruned
     redundant decimal cases to cut benchmark runtime; noted only because it 
bears on the
     benchmark suggestions above.
   
   ### Environment
   
   * arrow-rs 59.3.0, rustc 1.98.0 stable, `aarch64-apple-darwin`
   * Apple M5 Pro (6P + 12E), 48 GB, macOS Darwin 25.6.0
   * Criterion 1 s warm-up / 3 s measurement; the DataFusion arm built with
     `CARGO_PROFILE_BENCH_LTO=thin`, applied identically to both sides
   
   Numbers come from a laptop with no core pinning, so absolute values are 
directional;
   the ratios reproduced across two independent full runs.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to