From ab6e0196cd2279d77e74080af82cf9439b81c564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Thu, 16 Apr 2026 20:44:38 +0200 Subject: [PATCH 1/5] perf(parquet): vectorise dict-index bounds check in `RleDecoder::get_batch_with_dict` Replace `idx_chunk.iter().all(|&i| (i as usize) < dict_len)` with a u32 max-reduction (`fold(0u32, |acc, &i| acc.max(i as u32))`). `.all` short-circuits and so blocks autovectorisation; on aarch64 the old form compiled to eight serialised `ldrsw` + `cmp` + `b.ls` pairs per 8-index chunk, followed by eight separate scalar gather loads. The max-reduction has no early exit, so LLVM now lowers the check to a single `ldp q1, q0` + `umax.4s` + `umaxv.4s` + one `cmp` + `b.ls`, then reuses the loaded NEON registers for the gather that follows. Negative `i32` values cast to `u32` become large, so the bounds check still rejects them. Co-Authored-By: Claude Opus 4.7 (1M context) --- parquet/src/encodings/rle.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/parquet/src/encodings/rle.rs b/parquet/src/encodings/rle.rs index ea236f652a5d..351b41b359b0 100644 --- a/parquet/src/encodings/rle.rs +++ b/parquet/src/encodings/rle.rs @@ -520,8 +520,13 @@ impl RleDecoder { let idx_chunks = idx.chunks_exact(8); for (out_chunk, idx_chunk) in out_chunks.by_ref().zip(idx_chunks) { let dict_len = dict.len(); + // u32 max-reduction instead of `.all(|&i| ..)`: `.all` + // short-circuits and blocks autovectorisation. Negative + // i32 cast to u32 becomes a large value so the bounds + // check still rejects it. + let max_idx = idx_chunk.iter().fold(0u32, |acc, &i| acc.max(i as u32)); assert!( - idx_chunk.iter().all(|&i| (i as usize) < dict_len), + (max_idx as usize) < dict_len, "dictionary index out of bounds" ); for (b, i) in out_chunk.iter_mut().zip(idx_chunk.iter()) { From 0ca4c50121d761fbe7ae2115f55ef90aa4696413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Fri, 17 Apr 2026 07:28:38 +0200 Subject: [PATCH 2/5] perf(parquet): widen dict bounds check to 16-index chunks and cold panic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to ab6e0196cd. Two changes on the same RLE dict-gather loop: - `CHUNK = 16` (was 8): lets LLVM widen the `umax.4s` + `umaxv.4s` reduction to cover the whole chunk with one vector max per pair of loads, keeping the ratio of (cheap) bounds check to (cheap) gather in the loop's favour. - `#[cold] #[inline(never)] fn oob` replaces `assert!`: `max_idx` no longer has to stay live on the hot path for the panic format string, removing a per-chunk stack spill. Panic behaviour on out-of-bounds indices is preserved. Measured on aarch64 (Apple Silicon) with `cargo bench -p parquet --bench arrow_reader -- \ "dictionary encoded, mandatory, no NULLs"`: | bench | chunk 8 | chunk 16 + cold oob | Δ | | ------------------------ | ------- | ------------------- | ------- | | BinaryViewArray | 101 µs | 88 µs | -13.0% | | StringViewArray | 101 µs | 88 µs | -12.6% | | INT64 Decimal128 | 98 µs | 94 µs | -3.5% | | INT32 Decimal128 | 87 µs | 84 µs | -2.9% | | UInt8Array | 52 µs | 51 µs | -1.8% | View arrays see the biggest win because the inner copy is cheaper, so the bounds check is a larger share of the loop. Co-Authored-By: Claude Opus 4.7 (1M context) --- parquet/src/encodings/rle.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/parquet/src/encodings/rle.rs b/parquet/src/encodings/rle.rs index 351b41b359b0..ed4595f4bab3 100644 --- a/parquet/src/encodings/rle.rs +++ b/parquet/src/encodings/rle.rs @@ -514,21 +514,28 @@ impl RleDecoder { break; } { + #[cold] + #[inline(never)] + fn oob(max_idx: u32, dict_len: usize) -> ! { + panic!( + "dictionary index out of bounds: the len is {dict_len} but the index is {max_idx}" + ) + } + const CHUNK: usize = 16; let out = &mut buffer[values_read..values_read + num_values]; let idx = &index_buf[..num_values]; - let mut out_chunks = out.chunks_exact_mut(8); - let idx_chunks = idx.chunks_exact(8); + let dict_len = dict.len(); + let mut out_chunks = out.chunks_exact_mut(CHUNK); + let idx_chunks = idx.chunks_exact(CHUNK); for (out_chunk, idx_chunk) in out_chunks.by_ref().zip(idx_chunks) { - let dict_len = dict.len(); // u32 max-reduction instead of `.all(|&i| ..)`: `.all` // short-circuits and blocks autovectorisation. Negative // i32 cast to u32 becomes a large value so the bounds // check still rejects it. let max_idx = idx_chunk.iter().fold(0u32, |acc, &i| acc.max(i as u32)); - assert!( - (max_idx as usize) < dict_len, - "dictionary index out of bounds" - ); + if (max_idx as usize) >= dict_len { + oob(max_idx, dict_len); + } for (b, i) in out_chunk.iter_mut().zip(idx_chunk.iter()) { // SAFETY: all indices checked above to be in bounds b.clone_from(unsafe { dict.get_unchecked(*i as usize) }); @@ -537,7 +544,7 @@ impl RleDecoder { for (b, i) in out_chunks .into_remainder() .iter_mut() - .zip(idx.chunks_exact(8).remainder().iter()) + .zip(idx.chunks_exact(CHUNK).remainder().iter()) { b.clone_from(&dict[*i as usize]); } From abc64f78d8a15b97bb650caafe25f84ef3dfb6a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Sat, 18 Apr 2026 13:46:57 +0200 Subject: [PATCH 3/5] fix(parquet): return error instead of panic on OOB dict index in `RleDecoder::get_batch_with_dict` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `panic!` in the cold `oob` helper and the unchecked `dict[..]` indexing in the RLE and remainder paths with `ParquetError` returns. The vectorised hot loop (SIMD max-reduction + 16-way gather) is unchanged — only the error/cold paths differ. Co-Authored-By: Claude Opus 4.7 (1M context) --- parquet/src/encodings/rle.rs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/parquet/src/encodings/rle.rs b/parquet/src/encodings/rle.rs index ed4595f4bab3..55ea2f7aa19d 100644 --- a/parquet/src/encodings/rle.rs +++ b/parquet/src/encodings/rle.rs @@ -484,7 +484,13 @@ impl RleDecoder { if self.rle_left > 0 { let num_values = cmp::min(max_values - values_read, self.rle_left as usize); let dict_idx = self.current_value.unwrap() as usize; - let dict_value = dict[dict_idx].clone(); + let dict_value = dict.get(dict_idx).ok_or_else(|| { + general_err!( + "dictionary index out of bounds: the len is {} but the index is {}", + dict.len(), + dict_idx + ) + })?.clone(); buffer[values_read..values_read + num_values].fill(dict_value); @@ -516,9 +522,11 @@ impl RleDecoder { { #[cold] #[inline(never)] - fn oob(max_idx: u32, dict_len: usize) -> ! { - panic!( - "dictionary index out of bounds: the len is {dict_len} but the index is {max_idx}" + fn oob(max_idx: u32, dict_len: usize) -> ParquetError { + general_err!( + "dictionary index out of bounds: the len is {} but the index is {}", + dict_len, + max_idx ) } const CHUNK: usize = 16; @@ -534,7 +542,7 @@ impl RleDecoder { // check still rejects it. let max_idx = idx_chunk.iter().fold(0u32, |acc, &i| acc.max(i as u32)); if (max_idx as usize) >= dict_len { - oob(max_idx, dict_len); + return Err(oob(max_idx, dict_len)); } for (b, i) in out_chunk.iter_mut().zip(idx_chunk.iter()) { // SAFETY: all indices checked above to be in bounds @@ -546,7 +554,12 @@ impl RleDecoder { .iter_mut() .zip(idx.chunks_exact(CHUNK).remainder().iter()) { - b.clone_from(&dict[*i as usize]); + let dict_idx = *i as usize; + if dict_idx >= dict_len { + return Err(oob(*i as u32, dict_len)); + } + // SAFETY: bounds checked above + b.clone_from(unsafe { dict.get_unchecked(dict_idx) }); } } self.bit_packed_left -= num_values as u32; From 6ea52b5f87eb68a8128fabce07bc20c2d322f6c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Sat, 18 Apr 2026 14:33:29 +0200 Subject: [PATCH 4/5] Fmt --- parquet/src/encodings/rle.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/parquet/src/encodings/rle.rs b/parquet/src/encodings/rle.rs index 55ea2f7aa19d..6b8f1f2041e9 100644 --- a/parquet/src/encodings/rle.rs +++ b/parquet/src/encodings/rle.rs @@ -484,13 +484,17 @@ impl RleDecoder { if self.rle_left > 0 { let num_values = cmp::min(max_values - values_read, self.rle_left as usize); let dict_idx = self.current_value.unwrap() as usize; - let dict_value = dict.get(dict_idx).ok_or_else(|| { - general_err!( - "dictionary index out of bounds: the len is {} but the index is {}", - dict.len(), - dict_idx - ) - })?.clone(); + let dict_value = dict + .get(dict_idx) + .ok_or_else(|| { + general_err!( + "dictionary index out of bounds: the len is {} but the index is {}", + dict.len(), + dict_idx + ) + })? + .clone(); + buffer[values_read..values_read + num_values].fill(dict_value); From 963f2a267e0ad1fa40c5dc4511525df7ae176caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Heres?= Date: Sun, 19 Apr 2026 10:50:42 +0200 Subject: [PATCH 5/5] style(parquet): drop extra blank line after clone() Co-Authored-By: Claude Opus 4.7 (1M context) --- parquet/src/encodings/rle.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/parquet/src/encodings/rle.rs b/parquet/src/encodings/rle.rs index 6b8f1f2041e9..937be1dd2cfc 100644 --- a/parquet/src/encodings/rle.rs +++ b/parquet/src/encodings/rle.rs @@ -495,7 +495,6 @@ impl RleDecoder { })? .clone(); - buffer[values_read..values_read + num_values].fill(dict_value); self.rle_left -= num_values as u32;