Skip to content

Comments

sparse strips: Lazily push wide tile layer buffers#1414

Open
tomcur wants to merge 14 commits intolinebender:mainfrom
tomcur:lazy-push-buf
Open

sparse strips: Lazily push wide tile layer buffers#1414
tomcur wants to merge 14 commits intolinebender:mainfrom
tomcur:lazy-push-buf

Conversation

@tomcur
Copy link
Member

@tomcur tomcur commented Feb 3, 2026

This continues on #1403, and is based on/closes #1383.

In coarse.rs we are doing O(viewport_width x viewport_size) work for pushing and popping buffers for each layer needing buffers. PR #1403 already ensures we aren't actually sending commands for buffer blending through to rendering anymore for empty buffers. This PR makes command generation lazy so that we also don't do the push_buf work for the entire viewport anymore, but only for the wide tiles that actually get drawn on.

Some operations still fall back to pushing buffers for the full viewport, like filter layers and destructive blends. We may be able to do away with some of that in the future, but for now, those operations require buffers even if they don't have content (doing away or tightening the condition causes various tests to fail or asserts to be hit).

@tomcur tomcur added the C-sparse-strips Applies to sparse strips variants of vello in general label Feb 3, 2026
Copy link
Member Author

@tomcur tomcur left a comment

Choose a reason for hiding this comment

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

I've done a bit of micro-optimizing to keep the cost low for the worst-case where we're actually drawing a (nearly) dense layer.

Benchmarks in the case of layers look as follows. The first group times wide when drawing the content three layers deep at the content's natural size. Ghostscript Tiger is nearly completely dense, whereas Paris-30k is quite sparse. The tiger regresses as we're now doing the ensure_layer_stack_bufs and end up doing the work for almost all tiles, so it would've been faster to just push all the buffers beforehand, but Paris-30k is much improved.

The second group does the same but draws at 4k. That's much bigger than Tiger's natural size, and so this case also improves a lot. (Paris-30k is still better, but becomes relatively more dense than at its natural, larger size).

coarse_with_layer/Ghostscript_Tiger
                        time:   [5.8025 µs 5.8357 µs 5.8685 µs]
                        change: [+5.3922% +5.8851% +6.4095%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 2 outliers among 50 measurements (4.00%)
  2 (4.00%) high mild
coarse_with_layer/paris-30k
                        time:   [620.76 µs 623.32 µs 626.41 µs]
                        change: [-54.967% -52.845% -50.795%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 4 outliers among 50 measurements (8.00%)
  1 (2.00%) high mild
  3 (6.00%) high severe

coarse_with_layer_4k/Ghostscript_Tiger
                        time:   [107.11 µs 107.33 µs 107.58 µs]
                        change: [-73.934% -73.739% -73.575%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 6 outliers among 50 measurements (12.00%)
  5 (10.00%) high mild
  1 (2.00%) high severe
coarse_with_layer_4k/paris-30k
                        time:   [340.78 µs 342.86 µs 345.49 µs]
                        change: [-40.644% -39.706% -38.685%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 4 outliers among 50 measurements (8.00%)
  1 (2.00%) high mild
  3 (6.00%) high severe

In case there are no layers at all, we end up never having to push buffers, so there we also needlessly pay for every call. In the sparse Paris-30k case it's not too bad.

coarse/Ghostscript_Tiger
                        time:   [3.2046 µs 3.2230 µs 3.2462 µs]
                        change: [+3.2502% +4.2128% +5.1102%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 3 outliers among 50 measurements (6.00%)
  2 (4.00%) high mild
  1 (2.00%) high severe
coarse/paris-30k        time:   [356.05 µs 357.00 µs 358.15 µs]
                        change: [+0.5005% +1.5403% +2.4935%] (p = 0.00 < 0.05)
                        Change within noise threshold.
Found 3 outliers among 50 measurements (6.00%)
  2 (4.00%) high mild
  1 (2.00%) high severe

@tomcur
Copy link
Member Author

tomcur commented Feb 19, 2026

(Getting rid of the subtraction (n_bufs - n_clip) makes no substantial difference.)

@LaurenzV
Copy link
Collaborator

Hmm, I guess some of those gains do look pretty nice. If we decide to merge some variant of #1454, the "no layer" case would also become irrelevant since we would completely skip coarse rasterization (at least for vello_hybrid).

However, would like to hear what @taj-p or @grebmeg think before reviewing this PR more deeply, as it does add some more complexity.

@laurenz-canva
Copy link
Contributor

Wow, I just benchmarked this more carefully and this is a huge win!! On a scene with 750 rectangles, each wrapped in an opacity layer, rendering for 150 frames gives me the following number:

Before:
image

After:
image

Since the no-layer case will be optimized to completely circumvent coarse rasterization anyway, I think that's a trade-off worth making!

Copy link
Collaborator

@LaurenzV LaurenzV left a comment

Choose a reason for hiding this comment

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

.

/// This is a small wrapper around [`Vec`] so we can keep the
/// [`LayerKindAndOccupiedTiles::occupied_tiles`] allocations around.
#[derive(Debug, Default)]
struct NeedsBufLayerStack {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Not for this PR, but just a drive-by comment, I realized something like this would also be useful for the scratch buffers that are allocated in vello_cpu. It might be worth turning this into a small vector abstraction so that it can be reused in multiple places.

#[inline(always)]
fn ensure_layer_stack_bufs(&mut self, tile_idx: usize) {
let tile = &mut self.tiles[tile_idx];
let layer_bufs = tile.n_bufs - tile.n_clip;
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think a comment would be good we why need to do - tile.n_clip here

Comment on lines 608 to 610
let idx = self.get_idx(wtile_x, strip_y);
self.ensure_layer_stack_bufs(idx);
self.tiles[idx].strip(cmd, current_layer_id);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this (as well as other places) is pretty unfortunate, because it's pretty easy to miss that you need to call ensure_layer_stack_bufs before calling any method on the wide tile. Would it not be possible to move that method into WideTile, and then change each method on the wide tile to take a &mut NeedsBufLayerStack such that it becomes impossible to miss this?

Copy link
Member Author

@tomcur tomcur Feb 24, 2026

Choose a reason for hiding this comment

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

I've done this refactor, and this should actually be slightly more performant in some cases, because the wide tile itself has a bit more information on whether buffers need pushing (e.g., if it's in a zero clip, a fill doesn't need buffers). One unfortunate ergonomic aspect remains in that knowing the wide tile's index is required, so we still need to do a little let idx = ...; self.tiles[idx].fill(idx, ...) dance, instead of being able to do self.get_mut(...).fill(...). (A wrapper could work, but perhaps that's just even more mental overhead.)

// Optimization: If no drawing happened since the last `PushBuf`, then we don't
// need to do any masking or buffer-wide opacity work. The same holds for
// blending, unless it is destructive blending.
let has_draw_commands = !matches!(tile.cmds.last().unwrap(), &Cmd::PushBuf(_));
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can this even happen now that layers are pushed lazily?

Copy link
Member Author

@tomcur tomcur Feb 24, 2026

Choose a reason for hiding this comment

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

Yes, e.g. destructive blends still need to eagerly push buffers (e.g. copy blending has to copy (0,0,0,0) from empty source tiles to the destination).

I've clarified the comment a bit

Comment on lines 916 to 920
if self.clipped_filter_layer_depth == 0 {
for tile in &mut self.tiles {
tile.in_clipped_filter_layer = false;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Am I correct that this isn't related to this PR specifically, but a bug you found as part of your work?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, but it seems this isn't necessary anymore for tests to pass, but I believe it was in an earlier version of this. It may be best to leave it out for now. Do you know if the bool is intended to be true precisely when the layer itself is a clipped filter layer, or also when it is any other kind of layer nested in a clipped filter layer?

This continues on linebender#1403,
and is based on/closes linebender#1383.

In `coarse.rs` we are doing `O(viewport_width x viewport_size)` work for
pushing and popping buffers for each layer needing buffers. PR linebender#1403
already ensures we aren't actually sending commands for buffer blending
through to fine rendering for empty buffers anymore. This PR makes
command generation lazy so that we also don't do the `push_buf` work for
the entire viewport anymore, but only for the wide tiles that actually
get drawn on.

Some operations still fall back to pushing buffers for the full
viewport, like filter layers and destructive blends. We may be able to
do away with some of that in the future, but for now, those operations
require buffers even if they don't have content (doing away or
tightening the condition causes various tests to fail or asserts to be
hit).
@tomcur tomcur requested a review from LaurenzV February 24, 2026 10:51
Copy link
Collaborator

@LaurenzV LaurenzV left a comment

Choose a reason for hiding this comment

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

Nice, this looks very solid to me now! However, I'd like to wait for @grebmeg to give final approval, since it's still a somewhat delicate change.

Comment on lines +98 to +127
impl NeedsBufLayerStack {
fn clear(&mut self) {
for idx in 0..self.len {
self.stack[idx].occupied_tiles.clear();
}
self.len = 0;
}

fn push(&mut self, kind: LayerKind) {
if self.len == self.stack.len() {
self.stack.push(LayerKindAndOccupiedTiles {
kind,
occupied_tiles: vec![],
});
} else {
self.stack[self.len].occupied_tiles.clear();
self.stack[self.len].kind = kind;
}
self.len += 1;
}

fn pop(&mut self) {
debug_assert!(self.len > 0, "Called `pop` at the root");
self.len -= 1;
}

fn last(&self) -> Option<&LayerKindAndOccupiedTiles> {
self.len.checked_sub(1).map(|idx| &self.stack[idx])
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should these be inlined just to be safe?

// function call that is not inlined. That keeps the generated code size small at our call
// sites.
(layer_bufs < layers.len).then(
#[inline(never)]
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is what I get when I turn it into a normal if statement:

coarse_with_layer/Ghostscript_Tiger
                        time:   [3.1904 µs 3.2009 µs 3.2124 µs]
                        change: [-6.1956% -5.3240% -4.3456%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 10 outliers among 50 measurements (20.00%)
  7 (14.00%) high mild
  3 (6.00%) high severe

I think we should just do it? It's just a single for loop after all, so i don't think we need to be too worried about code bloat. :D

Copy link
Member Author

Choose a reason for hiding this comment

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

Interesting... I get these results (on x86) if I turn it into a normal if:

coarse_with_layer/Ghostscript_Tiger
                        time:   [5.8312 µs 5.8537 µs 5.8745 µs]
                        change: [+1.8618% +2.2918% +2.7258%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 3 outliers among 50 measurements (6.00%)
  3 (6.00%) high mild
coarse_with_layer/paris-30k
                        time:   [661.21 µs 666.10 µs 673.03 µs]
                        change: [+1.9323% +3.6281% +5.2749%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 4 outliers among 50 measurements (8.00%)
  2 (4.00%) high mild
  2 (4.00%) high severe

coarse_with_layer_4k/Ghostscript_Tiger
                        time:   [130.49 µs 133.27 µs 137.10 µs]
                        change: [+8.5701% +10.965% +13.581%] (p = 0.00 < 0.05)
                        Performance has regressed.
Found 8 outliers among 50 measurements (16.00%)
  1 (2.00%) high mild
  7 (14.00%) high severe
coarse_with_layer_4k/paris-30k
                        time:   [359.92 µs 365.88 µs 373.64 µs]
                        change: [+0.4684% +2.1311% +3.9630%] (p = 0.02 < 0.05)
                        Change within noise threshold.
Found 7 outliers among 50 measurements (14.00%)
  2 (4.00%) high mild
  5 (10.00%) high severe

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh damn... Okay, I suppose let's leave it how it is then.

Copy link
Member Author

Choose a reason for hiding this comment

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

Maybe this is something where core::hint::cold_path would be helpful, but it's nightly-only.

Copy link
Member Author

Choose a reason for hiding this comment

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

(Or automatic profile-guided optimization.)

/// The number of pushed buffers.
/// The number of pushed buffers, including buffers for clips.
///
/// Note not all layers require their own buffers.
Copy link
Collaborator

Choose a reason for hiding this comment

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

That sentence confuses me a bit. 😄 Why do not all layers require their own buffers?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh right, I see now, basically if needs_buf returns None then we don't have an explicit layer, right?

Copy link
Member Author

@tomcur tomcur Feb 24, 2026

Choose a reason for hiding this comment

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

basically if needs_buf returns None then we don't have an explicit layer, right?

Right! Added a documentation link in the comment to that method.

Copy link
Member Author

Choose a reason for hiding this comment

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

Doclink doesn't like that the WideTile field is public and the method the docs refers to is private :) maybe all WideTile fields should just become private. Only the module itself uses them.

@LaurenzV LaurenzV requested a review from grebmeg February 24, 2026 12:51
Copy link
Collaborator

@LaurenzV LaurenzV left a comment

Choose a reason for hiding this comment

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

Talked to Alex, and he said it's fine landing if I consider it good. 👍

Comment on lines +100 to +102
for idx in 0..self.len {
self.stack[idx].occupied_tiles.clear();
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm wondering, do we even need this if we clear upon every push? It's not like this will free the memory, right? And it will also only clear the elements of the current length, not previous entries that we might have popped in the meanwhile.

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

Labels

C-sparse-strips Applies to sparse strips variants of vello in general

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lazy tile push_buf for blending

3 participants