Skip to content

Replace filter-and-scatter RowFn fallback with filtered valid-row execution - #9645

Draft
robert3005 wants to merge 2 commits into
developfrom
claude/rowfn-kernel-execution-fqw0ue
Draft

Replace filter-and-scatter RowFn fallback with filtered valid-row execution#9645
robert3005 wants to merge 2 commits into
developfrom
claude/rowfn-kernel-execution-fqw0ue

Conversation

@robert3005

@robert3005 robert3005 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Instead of performing a gather/execute/scatter add a filtered execute method that avoids majority of intermediate state

…cution

When a partially valid batch cannot execute directly over the original
inputs, batch execution previously filtered every input to the valid
rows, ran the dense kernel over the compact domain, and scattered the
compact output back with a nullable-index take.

Batch execution now dispatches a filtered valid-row execution instead:
it still filters the inputs (required when a representation cannot
decode null payloads), but the row loop reads consecutive compact rows
and writes each result directly at its original row index into a
full-length output, so the kernel output never needs a columnar
scatter. Owned outputs place default placeholders in skipped positions
and sinks run their skipped-row initializer, after which batch
execution masks the skipped rows exactly like direct skip-invalid
execution.

A sink without a skipped-row initializer can no longer execute a
partially valid batch, because every skip-invalid strategy now writes
into the original row domain. Both in-tree sinks already initialize
skipped rows.

Signed-off-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161JW9aU6W4zmzRQsnjbE3Q
@robert3005
robert3005 marked this pull request as draft August 26, 2026 18:13
@robert3005 robert3005 added the changelog/chore A trivial change label Aug 26, 2026
@robert3005

Copy link
Copy Markdown
Contributor Author

Need to go over this carefully but I think this is a better sparse execution mode

@codspeed-hq

codspeed-hq Bot commented Aug 26, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 0.95%

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

⚡ 2 improved benchmarks
❌ 2 regressed benchmarks
✅ 342 untouched benchmarks
⏩ 1890 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
WallTime mul_i8_nonnull_avx2 11.2 µs 12.8 µs -12.66%
WallTime mul_u8_nonnull_avx2 8.8 µs 9.8 µs -10.09%
WallTime multiply_shapes_neon[(128, PerRowPerRow)] 2.2 µs 2 µs +11.07%
WallTime mul_u32_nonnull_avx512 6.3 µs 5.7 µs +10.35%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/rowfn-kernel-execution-fqw0ue (813e082) with develop (7feec60)

Open in CodSpeed

Footnotes

  1. 1890 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Comment on lines +842 to +857
#[test]
fn test_partially_valid_batch_requires_sink_skipped_row_initializer() -> VortexResult<()> {
let input =
PrimitiveArray::new(vec![1_i64, 2], Validity::from_iter([true, false])).into_array();
let args = VecExecutionArgs::new(vec![input], 2);
let mut ctx = array_session().create_execution_ctx();

let error = execute_rows(&NoSkipIdentity, &EmptyOptions, &args, &mut ctx)
.expect_err("a sink without a skipped-row initializer must reject a partially valid batch");

assert!(
error.to_string().contains("cannot initialize skipped rows"),
"unexpected error: {error}",
);
Ok(())
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For an input like:

[1, null, 3]

the filter/scatter on develop does this:

  1. Remove the null row, producing [1, 3].
  2. Allocate a two-row sink.
  3. Write both rows.
  4. Expand the result back to [1, null, 3].

The sink never has an unwritten row, so it does not need a skipped-row initializer.

This PR removes that path. It allocates three output rows and writes only rows 0 and 2. The sink cannot initialize row 1, so execution fails.

The test is wrong because it treats that new failure as required behavior. The old compact path can still produce the correct result safely.


So I think we should just have both paths? This new filtered execution should be an additional fast path, not a replacement. It avoids the scatter when the sink
can initialize skipped rows, but sinks that cannot do that still need the compact filter-and-scatter fallback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

replace this test with:

#[test]
fn test_no_skip_sink_executes_partially_valid_batch() -> VortexResult<()> {
    let validity = Validity::from_iter([true, false]);
    let input = PrimitiveArray::new(vec![1_i64, 2], validity.clone()).into_array();
    let args = VecExecutionArgs::new(vec![input], 2);
    let mut ctx = array_session().create_execution_ctx();

    let actual = execute_rows(&NoSkipIdentity, &EmptyOptions, &args, &mut ctx)?;
    let expected = PrimitiveArray::new(vec![1_i64, 0], validity).into_array();

    assert_arrays_eq!(&actual, &expected, &mut ctx);
    Ok(())
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have pushed an update, this is a feature of the sink

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is what codex had to say:

Details

The review identifies two separate problems: an immediate merge-build failure and a deeper loss of OutputSink functionality.

The execution problem

Take a partially valid input:

input: [10, null, 30]
valid: [true, false, true]

The kernel must run only for 10 and 30, producing:

output: [f(10), null, f(30)]

There are three ways to execute this.

Strategy Input rows Sink rows Requirement
Direct valid-row execution 3 3 Null-tolerant input decoding and skipped-row initializer
New filtered execution 2 3 Skipped-row initializer
Compact filter-and-scatter 2 2, then scatter to 3 No skipped-row initializer

The new path filters the input down to [10, 30], but it does not make the output compact. In execute_sink_filtered, it:

  1. Calculates original_len = 3.
  2. Allocates a three-row sink at line 211.
  3. Initializes all three positions at line 222.
  4. Writes filtered row 0 into output row 0.
  5. Skips output row 1.
  6. Writes filtered row 1 into output row 2.
  7. Finishes the sink and only afterward masks row 1 as null.

Conceptually:

filtered input: [10, 30]
physical output before masking: [f(10), placeholder, f(30)]
logical output after masking:   [f(10), null,        f(30)]

That avoids the columnar scatter, which is useful. But the placeholder is essential. Sink::finish runs before the validity mask is applied, so the middle position must already contain something that the sink can legally finish.

Why initialization cannot be universally required

The patch changes the optional method into the required method at OutputSink::initialize_skipped_rows.

Its premise is effectively:

If a sink can allocate storage, it can always construct a legal placeholder value.

That does not follow. A sink might only be able to construct a value from the row callback’s inputs or prepared state. Its freshly allocated storage can be safe to drop while still being illegal to pass to finish until every slot has been written.

The old optional contract represented that distinction:

fn skipped_rows_initializer() -> Option<for<'a> fn(&mut Self::Rows<'a>)> {
    None
}
  • Some(initializer) meant the sink supports full-length execution with skipped positions.
  • None meant it does not, but it could still execute densely over a compact batch.

Making the initializer required prevents the second kind of sink from implementing OutputSink, even though filter-and-scatter can execute it correctly.

How filter-and-scatter handles that sink

The deleted fallback worked entirely in the valid-row domain:

filtered input:  [10, 30]
compact sink:    [f(10), f(30)]
nullable indices: [0, null, 1]
final output:    [f(10), null, f(30)]

The sink has only two positions, and the dense kernel writes both. There is no skipped sink position and therefore no placeholder requirement. The nullable take restores the original row positions afterward.

This makes filter-and-scatter a correctness fallback, not merely a slower implementation of the new path.

Why the merge result does not compile

The current base includes #9623, which added PolygonSink. It still implements:

fn skipped_rows_initializer() -> Option<...>

The PR removes that trait method and introduces required initialize_skipped_rows. When Git combines the changes:

  • E0407: PolygonSink::skipped_rows_initializer is no longer a trait member.
  • E0046: PolygonSink does not implement the new required initialize_skipped_rows.

PolygonSink itself can use an empty polygon as its placeholder, so adapting it would be easy. But doing so would only fix the compilation error. It would not fix the generic contract regression for sinks that return None.

Intended routing

The fix should preserve all three paths:

Partially valid batch
        |
        +-- Inputs support original-row decoding
        |       and sink initializer is Some
        |          -> direct valid-row execution
        |
        +-- Inputs must be filtered
        |       and sink initializer is Some
        |          -> new filtered-input/full-length-output path
        |
        +-- Sink initializer is None
                   -> compact filter-and-scatter

That also matches #9130, which says filter-and-scatter is used when either direct input decoding or skipped-row initialization declines, and #9129, which treats OutputSink as a downstream extension point.

A useful regression test would define a sink that leaves skipped_rows_initializer() at its default None, execute it on [1, null], and expect [1, null]. A test expecting an initializer-related error would only codify the regression.

Skipped-row initialization was an optional capability because the old
filter-and-scatter fallback could serve a sink without one by running
it densely at the compact length and scattering afterwards. With that
fallback gone, a sink without an initializer failed partially valid
batches at runtime.

A sink allocates its own storage, so it can always write placeholder
values into it, and the values are unobservable: callbacks overwrite
valid rows and batch execution masks skipped rows. Replace the
Option-returning skipped_rows_initializer with a required
initialize_skipped_rows method, removing the sink decline in direct
skip-invalid execution and the runtime error in filtered execution.

Signed-off-by: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161JW9aU6W4zmzRQsnjbE3Q
@connortsui20

Copy link
Copy Markdown
Member

I don't think we can make the skipped row initializer required, and if you rebase on the latest changes from #9623 that should explain why

This only is fine if we have types that work with completely arbitrary data that has been allocated, and the spatial types are a counterexample

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

Labels

changelog/chore A trivial change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants