Skip to content

vello_cpu: Avoid continuously splatting positions#1728

Draft
LaurenzV wants to merge 3 commits into
mainfrom
laurenz/img
Draft

vello_cpu: Avoid continuously splatting positions#1728
LaurenzV wants to merge 3 commits into
mainfrom
laurenz/img

Conversation

@LaurenzV

@LaurenzV LaurenzV commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

For our image and gradient painters, the overall flow right now is that we iterate over a chunk of pixels, keep track of the current x/y position using a scalar f64, at the start of each pixel chunk we calculate our current position and store it in a SIMD vector. At the end, we advance the scalar position value just to redo the same position splatting in the next iteration.

This is wasteful, because splat_pos is quite expensive. Apart from that, it also doesn't allow the compiler to retain the positions in the registers between iterations, even though this is possible in most cases. Therefore, this PR changes it such that we calculate the start position only once in the beginning, and then advance it using normal SIMD arithmetics at the end of each iteration. The benchmark improvements are rather astonishing, see below!

However, I suppose there is no free lunch. This approach does have the problem that we are doing the accumulation using f32 instead of f64, which has less precision, but since we our pixmap is limited to u16::MAX, this is still more than enough. Nearly all tests just had a max channel delta of 1 and only very few changed pixels. However, there is one test which now has a very large diff due to slight differences for some pixels and can therefore not be directly compared against Vello hybrid anymore. :( Open to suggestions for how to best handle this. EDIT: See comment further velow.

image

On NEON:

fine/gradient/linear/opaque_u8_neon
                        time:   [486.11 ns 486.57 ns 487.09 ns]
                        change: [-5.0218% -4.4800% -4.0164%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 7 outliers among 100 measurements (7.00%)
  1 (1.00%) low mild
  3 (3.00%) high mild
  3 (3.00%) high severe

fine/gradient/radial/opaque_u8_neon
                        time:   [545.20 ns 545.56 ns 545.94 ns]
                        change: [-16.351% -16.162% -15.949%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 8 outliers among 100 measurements (8.00%)
  4 (4.00%) low mild
  2 (2.00%) high mild
  2 (2.00%) high severe

fine/gradient/sweep/opaque_u8_neon
                        time:   [983.06 ns 987.58 ns 993.97 ns]
                        change: [-18.883% -18.540% -18.116%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 12 outliers among 100 measurements (12.00%)
  8 (8.00%) high mild
  4 (4.00%) high severe

fine/image/quality/low_u8_neon
                        time:   [489.14 ns 491.19 ns 493.94 ns]
                        change: [-50.408% -49.440% -48.815%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 11 outliers among 100 measurements (11.00%)
  1 (1.00%) low mild
  5 (5.00%) high mild
  5 (5.00%) high severe

fine/image/quality/medium_u8_neon
                        time:   [2.0899 µs 2.0922 µs 2.0948 µs]
                        change: [-12.864% -12.685% -12.498%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 9 outliers among 100 measurements (9.00%)
  1 (1.00%) low mild
  3 (3.00%) high mild
  5 (5.00%) high severe

fine/image/quality/high_u8_neon
                        time:   [11.309 µs 11.317 µs 11.325 µs]
                        change: [-4.5941% -4.4504% -4.3054%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 7 outliers among 100 measurements (7.00%)
  5 (5.00%) high mild
  2 (2.00%) high severe

On AVX2:

fine/gradient/linear/opaque_u8_avx2
                        time:   [655.34 ns 656.77 ns 658.40 ns]
                        change: [-8.5243% -8.1884% -7.8411%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 6 outliers among 100 measurements (6.00%)
  3 (3.00%) high mild
  3 (3.00%) high severe

fine/gradient/radial/opaque_u8_avx2
                        time:   [837.93 ns 842.61 ns 847.57 ns]
                        change: [-12.437% -11.957% -11.444%] (p = 0.00 < 0.05)
                        Performance has improved.

fine/gradient/sweep/opaque_u8_avx2
                        time:   [1.1985 µs 1.2029 µs 1.2077 µs]
                        change: [-25.284% -24.722% -24.298%] (p = 0.00 < 0.05)
                        Performance has improved.

fine/rounded_blurred_rect/with_transform_u8_avx2
                        time:   [24.885 µs 24.918 µs 24.963 µs]
                        change: [-0.6523% -0.1137% +0.3697%] (p = 0.68 > 0.05)
                        No change in performance detected.
Found 10 outliers among 100 measurements (10.00%)
  2 (2.00%) high mild
  8 (8.00%) high severe

fine/rounded_blurred_rect/no_transform_u8_avx2
                        time:   [25.538 µs 25.602 µs 25.713 µs]
                        change: [-0.6839% -0.3351% -0.0210%] (p = 0.05 < 0.05)
                        Change within noise threshold.
Found 10 outliers among 100 measurements (10.00%)
  4 (4.00%) high mild
  6 (6.00%) high severe

fine/image/quality/low_u8_avx2
                        time:   [763.53 ns 764.94 ns 766.68 ns]
                        change: [-8.8568% -8.4816% -8.1611%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 9 outliers among 100 measurements (9.00%)
  3 (3.00%) high mild
  6 (6.00%) high severe

fine/image/quality/medium_u8_avx2
                        time:   [4.4152 µs 4.4215 µs 4.4288 µs]
                        change: [-5.0771% -4.6674% -4.3389%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 1 outliers among 100 measurements (1.00%)
  1 (1.00%) high mild

fine/image/quality/high_u8_avx2
                        time:   [20.545 µs 20.574 µs 20.610 µs]
                        change: [-5.7259% -4.9316% -4.3680%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 7 outliers among 100 measurements (7.00%)
  4 (4.00%) high mild
  3 (3.00%) high severe

This PR was done with assistance of GPT 5.5, high.

@LaurenzV

LaurenzV commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

I have resolved the issue mentioned above by just increasing the (already-existing!) threshold for now, I hope this is okay.

@LaurenzV LaurenzV changed the title vello_cpu: Avoid continuously recalculating positions vello_cpu: Avoid continuously splatting positions Jul 8, 2026
@LaurenzV
LaurenzV marked this pull request as ready for review July 8, 2026 07:50
@LaurenzV
LaurenzV requested a review from grebmeg July 8, 2026 07:55
Comment thread sparse_strips/vello_cpu/src/fine/mod.rs Outdated
Comment on lines +1165 to +1168
pub(crate) fn advance(&mut self) {
self.x_pos += self.x_step;
self.y_pos += self.y_step;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since we're now accumulating the position in f32 (pos += step each chunk), the rounding error compounds over the span — which I think is what pushed gradient_linear_with_y_repeat from diff_pixels = 2 to 80. Would deriving each position from a fixed base with an FMA instead of mutating it avoid the drift while staying register-resident? Something like:

// keep x_base/x_step splatted once, plus a lane-uniform chunk counter `i`
fn current(&self) -> V {
    // single rounding, error doesn't accumulate
    self.i.mul_add(self.x_step, self.x_base)
}
fn advance(&mut self) {
    // single rounding, error doesn't accumulate
    self.i += 1.0;
}

That should keep the error bounded regardless of span width (so the threshold could maybe go back to ~2?). Might be worth a quick check.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I tried this, and overall doesn't seem to impact performance, so I think I like it! It reduces the number of diff pixels (from about ~50 to ~12), but still doesn't completetly eliminate them. I feel like this is acceptable?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think it's much better now. Would it still be possible to investigate the reason for the discrepancies? It could be that we're applying an additional compensation offset in hybrid, so we might be able to adjust or remove it.

Comment thread sparse_strips/vello_cpu/src/fine/mod.rs Outdated
}
}

impl<S: Simd> PositionIterator<S> for PaintPositions<S, f32x8<S>> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Small thing: PositionIterator methods here forward to the inherent methods of the same name via Self::advance(self), which only works because inherent methods win resolution over trait ones. Could we fold the logic into a single blanket impl over V and drop the inherent new/advance, so there's only one definition? e.g.:

impl<S, V> PositionIterator<S> for PaintPositions<S, V>
where
    S: Simd,
    V: PosExt<S> + SimdBase<S, Element = f32> + core::ops::AddAssign,
{
    fn advance(&mut self) {
        self.x_pos += self.x_step;
        self.y_pos += self.y_step;
    }
    // ...
}

The f32x4 image painters would then get the trait for free too. Does that work, or was the split intentional for some reason I'm missing?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yup makes sense, thanks!

// color is sampled. This threshold was previously only 2, but had to be increased due to
// a change in vello_cpu (see https://github.com/linebender/vello/pull/1728) which changed
// the accumulation behavior and leads to higher discrepancies compared to Vello Hybrid.
#[vello_test(diff_pixels = 80)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

One of the ideas I mentioned above could help us avoid using such a high threshold, but I think we should try to minimize these kinds of discrepancies with hybrid/cpu as much as possible. Would it be possible to investigate the accumulation behaviour further and see if we can fix it?

@LaurenzV
LaurenzV marked this pull request as draft July 13, 2026 12:16
pull Bot pushed a commit to Mu-L/vello that referenced this pull request Jul 13, 2026
~~Based on top of linebender#1728.~~

A painter is supposed to be initialized and then only used once, since
each time we change the location we need to reinitialize it. This PR
changes the signature so this intention is properly encoded in the
`paint_*` methods.

This PR was done with assistance of GPT 5.6-Sol.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants