andygrove commented on code in PR #26533:
URL: https://github.com/apache/datafusion/pull/26533#discussion_r4329483242
##########
datafusion/functions-window/src/nth_value.rs:
##########
@@ -525,27 +527,26 @@ impl NthValueEvaluator {
}
}
- fn valid_index_with_nulls(&self, nulls: &NullBuffer, offset: usize) ->
Option<usize> {
+ /// Returns the index within the frame of the value to return, given the
frame's validity
+ /// bits.
+ fn valid_index_with_nulls(&self, valid: &BooleanBuffer) -> Option<usize> {
match self.state.kind {
- NthValueKind::First => nulls.valid_indices().next().map(|idx| idx
+ offset),
- NthValueKind::Last => nulls.valid_indices().last().map(|idx| idx +
offset),
+ NthValueKind::First => valid.set_indices().next(),
+ NthValueKind::Last => last_set_bit(valid),
NthValueKind::Nth => {
match self.n.cmp(&0) {
Ordering::Greater => {
// SQL indices are not 0-based.
let index = (self.n as usize) - 1;
- nulls.valid_indices().nth(index).map(|idx| idx +
offset)
+ valid.set_indices().nth(index)
}
Ordering::Less => {
let reverse_index = (-self.n) as usize;
- let valid_indices_len = nulls.len() -
nulls.null_count();
+ let valid_indices_len = valid.count_set_bits();
if reverse_index > valid_indices_len {
return None;
}
- nulls
- .valid_indices()
- .nth(valid_indices_len - reverse_index)
- .map(|idx| idx + offset)
+ valid.set_indices().nth(valid_indices_len -
reverse_index)
Review Comment:
This arm regresses `NTH_VALUE(expr, -n) IGNORE NULLS` on frames that have no
NULLs while the argument still has a validity buffer. On `main` such a frame
failed the `null_count() > 0` check and fell through to the O(1)
`Some(range.end - reverse_index)` path. Here every frame takes the bitmap path,
so `set_indices().nth(valid_indices_len - reverse_index)` steps through every
valid row of the frame. That's O(frame) bit iteration per row, where `main` did
an O(frame / 64) popcount.
That's the common case with sparse NULLs: most frames have none, but nearly
every batch carries a validity buffer. Negative `n` also comes from the
planner, not just from users who write it. `get_best_fitting_window` reverses a
window to reuse an existing sort order, and `partition_evaluator` then flips
the sign of `n` (`is_reversed()`). So a sliding `NTH_VALUE(x, 3)` can run as
`-3`.
From the `nth_value` bench on an Apple M3 Max. The first three rows are
cases I added locally: "0.1% nulls" is `create_primitive_array(8192, 0.001)`,
and "null at end" is 8192 rows where only the last one is NULL. Medians of 4
alternating runs (3 for the last column):
| Benchmark | `main` | This PR | With `nth_set_bit_from_end` below |
|---|---:|---:|---:|
| `nth_value_neg10_sliding_100/0.1%_nulls` | 336 µs | 559 µs | 179 µs |
| `nth_value_neg10_sliding_1000/0.1%_nulls` | 2.13 ms | 3.53 ms | 194 µs |
| `nth_value_neg10_expanding/null_at_end` | 395 µs | 13.6 ms | 176 µs |
| `nth_value_neg10_expanding/30%_nulls` | 14.3 ms | 14.2 ms | 186 µs |
| `nth_value_neg10_expanding/50%_nulls` | 7.66 ms | 8.58 ms | 196 µs |
| `last_value_expanding/50%_nulls` | 9.37 ms | 144 µs | 156 µs |
| `last_value_sliding_100/50%_nulls` | 723 µs | 143 µs | 154 µs |
Generalizing `last_set_bit` to the n-th set bit from the end fixes this, and
it also covers the second follow-up in the description:
```rust
/// Returns the index of the `n`th set bit from the end of `bits`, where `n
= 1` is the last
/// set bit, or `None` if fewer than `n` bits are set.
///
/// The bits are read a 64-bit word at a time from the end, so the cost
grows with the distance
/// from the end to that bit rather than with the length of `bits`.
Enumerating the set bits
/// from the start instead is quadratic for a frame that grows with every
row.
fn nth_set_bit_from_end(bits: &BooleanBuffer, n: usize) -> Option<usize> {
debug_assert!(n > 0, "n is 1-based");
let chunk = UnalignedBitChunk::new(bits.values(), bits.offset(),
bits.len());
// The words hold `lead_padding` unset bits before `bits` and
`trailing_padding` after it.
let mut word_start = chunk.lead_padding() + bits.len() +
chunk.trailing_padding();
let mut remaining = n;
for mut word in chunk.iter().rev() {
word_start -= 64;
let ones = word.count_ones() as usize;
if ones < remaining {
remaining -= ones;
continue;
}
// Clear the `remaining - 1` highest set bits, leaving the one we
want on top
for _ in 1..remaining {
word ^= 1 << word.ilog2();
}
return Some(word_start + word.ilog2() as usize -
chunk.lead_padding());
}
None
}
```
Call it as `NthValueKind::Last => nth_set_bit_from_end(valid, 1)` and
`Ordering::Less => nth_set_bit_from_end(valid, (-self.n) as usize)`. I checked
it three ways:
- your unit test extended to `n` in `[1, 2, 3, 63, 64, 65, 130]` against
`(0..len).rev().filter(|&i| slice.value(i)).nth(n - 1)`
- the differential test described in the review summary
- `window.slt`
For `LAST_VALUE` the extra popcount costs about 1.4 ns per row (the last two
rows above). If you'd rather not pay that, `Last` can keep calling
`last_set_bit`. I used `ilog2` because clippy's `manual_ilog2` fires on `63 -
word.leading_zeros()` inside the shift. In `last_set_bit` the `as usize` cast
only hides the lint.
If you'd rather keep this PR to `LAST_VALUE`, the smallest fix is to restore
`main`'s shortcut in this arm. This also passes the differential test:
```rust
if valid_indices_len == valid.len() {
return Some(valid.len() - reverse_index);
}
```
<details><summary>Bench cases I added, with the updated
<code>criterion_group!</code></summary>
```rust
fn bench_sparse_nulls(c: &mut Criterion) {
let mut group = c.benchmark_group("nth_value_sparse_nulls");
// ~8 NULLs in 8192 rows: a validity buffer exists, but most sliding
frames have no NULLs
let sparse =
Arc::new(create_primitive_array::<Int64Type>(ARRAY_SIZE, 0.001)) as
ArrayRef;
// Only the last row is NULL: every expanding frame but the last has no
NULLs
let null_at_end = Arc::new(
(0..ARRAY_SIZE as i64)
.map(|i| (i != ARRAY_SIZE as i64 - 1).then_some(i))
.collect::<arrow::array::Int64Array>(),
) as ArrayRef;
for window_size in [100usize, 1000] {
group.bench_function(
BenchmarkId::new(
format!("nth_value_neg10_sliding_{window_size}"),
"0.1%_nulls",
),
|b| {
b.iter(|| {
let mut evaluator =
create_evaluator(NthValueKind::Nth, true, Some(-10));
let values_slice = slice::from_ref(&sparse);
for i in 0..sparse.len() {
let start = i.saturating_sub(window_size - 1);
let range = Range { start, end: i + 1 };
black_box(evaluator.evaluate(values_slice,
&range).unwrap());
}
})
},
);
}
group.bench_function(
BenchmarkId::new("nth_value_neg10_expanding", "null_at_end"),
|b| {
b.iter(|| {
let mut evaluator = create_evaluator(NthValueKind::Nth,
true, Some(-10));
let values_slice = slice::from_ref(&null_at_end);
for i in 0..null_at_end.len() {
let range = Range {
start: 0,
end: i + 1,
};
black_box(evaluator.evaluate(values_slice,
&range).unwrap());
}
})
},
);
group.finish();
}
criterion_group!(benches, bench_nth_value_ignore_nulls, bench_sparse_nulls);
```
</details>
--
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]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]