Skip to content

Make LineBoxMetrics Copy - #855

Open
nicoburns wants to merge 3 commits into
linebender:mainfrom
DioxusLabs:devin/1790175038-lineboxmetrics-copy
Open

nicoburns wants to merge 3 commits into
linebender:mainfrom
DioxusLabs:devin/1790175038-lineboxmetrics-copy

Conversation

@nicoburns

@nicoburns nicoburns commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

LLM Contributions: Generated with Fable 5.1 Low

Depends on:

Context

This is a follow-up to #766 (vertical-align) that gets us back to performance parity with before that PR was merged. It was purposefully left as a follow-up to make things easier to review, but we probably need to land this or some alternative performance fix before we can release.

Summary

Eliminate SmallVec<[SubtreeExtents; 2]> on LineBoxMetrics, allowing it to impl Copy which is performance-critical because it is copied at every line breaking opportunity.

New design

  • LineBoxMetrics loses the general SmallVec<[SubtreeExtents; 2]> and gains root: SubtreeExtents. So it still stores metrics for the root subtree on the line but extra subtrees generated by vertical-align: top | bottom move out.
  • BreakerState gains Vec<SubtreeExtents> for the extra subtrees generated by vertical-align: top | bottom. This is stored is per-line state that is processed in finish_line and then reset for the next line. The length of the vector will be roughly the number of items on the line that belong to non-root subtrees.
  • LineBoxMetrics also gains saved_subtrees: usize which records the length of the Vec<SubtreeExtents> at the time that each line-breaking opportunity is encountered. This is used to revert to that state when taking the line-breaking opportunity.
  • In cases when there have been no new line-breaking opportunities recorded since a SubtreeExtents entry was pushed, we can update the entry in place. However, we cannot update the extents of subtree across line-breaking opportunity (as we would have no way to revert in case the opportunity is taken). So saved_subtrees is also used to limit deduplication to only "uncommited" subtree records.
  • (the deduplication doesn't happen very often because line-breaking opportunities are common, but the check for it is cheap)

Changelog: None (performance improvement for never-published regression)

@nicoburns
nicoburns marked this pull request as ready for review September 28, 2026 20:33
@nicoburns
nicoburns requested review from DJMcNab and tomcur September 28, 2026 20:34
@staging-devin-ai-integration
staging-devin-ai-integration Bot force-pushed the devin/1790175038-lineboxmetrics-copy branch 2 times, most recently from 110f5d3 to 47877e6 Compare September 29, 2026 12:58
LineState is cloned at every soft-break opportunity, and the SmallVec<[SubtreeExtents; 2]> inside LineBoxMetrics made that a non-trivial clone/drop plus a 128-byte move in reset_to/take().

The root subtree's extents now live inline as a plain Copy field. Non-root (vertical-align: top/bottom) subtrees are recorded in an append-only Vec<SubtreeExtents> owned by BreakerState next to the `contributed` buffer, and cleared with it in reset_line. Entries at indices below the most recent saved break opportunity (LineBoxMetrics::saved_subtrees) are never mutated: growing such a subtree pushes a new copy instead, so truncating the log in reset_to restores the exact extents at that opportunity. Later entries are updated in place, and nothing is pushed when the extents don't change. The current extents of a subtree are the last log entry for its root; earlier entries are stale and ignored when finishing a line. Plain prose never touches the log.

Span boxes, text run boxes and inline boxes all grow a subtree through add_to_subtree, which takes their BoxMetrics.

`append_atom_to_line` and `LineBoxMetrics::add_text` are #[inline(always)] so they fold back into break_remaining.

Adds tests for rolling back a tall top-aligned span at a break and for two aligned subtrees whose extents grow across several words/lines.

Break phase (250KB prose, 800/400px alternating, min of 5 interleaved rounds): plain 4.52 -> 3.46 ms, styled 5.69 -> 4.57 ms, styled+vertical-align 6.25 -> 4.90 ms; layout output unchanged.
Keep height-limit checks constant-time while preserving Copy and restoring cached heights with regular and emergency break checkpoints.

Validated library and integration tests, 1,000 differential layouts, formatting, and std/no-std Clippy. The 8,192-box finite-height probe drops from 16.22 ms to 0.38 ms.

LLM Contributions: Generated with Devin.
@staging-devin-ai-integration
staging-devin-ai-integration Bot force-pushed the devin/1790175038-lineboxmetrics-copy branch from 47877e6 to 2cd1543 Compare September 29, 2026 13:14

@DJMcNab DJMcNab left a comment

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 PR is extremely hard to review. I don't know if there's a simpler model which exists, but the in-code explanations of this seem to obfuscate/spread about the details.

Comment on lines +111 to +113
/// This is plain `Copy` data so that saving a line-breaking opportunity (which clones
/// [`LineState`]) is a memcpy; the growable per-line buffers live in [`BreakerState`].
///

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.

Is this comment really needed? I guess it's only internal docs, so maybe it's valuable...

Comment on lines +131 to +132
/// Length of [`BreakerState::subtrees`] at the most recently saved line-breaking opportunity.
/// Entries below it may be part of that opportunity's state and are never modified.

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.

Below here is ambiguous - I don't know if it means before or after. I presume it has to be after?

.add(baseline_offset, metrics.ascent, metrics.descent);
}

fn same_as(&self, other: &Self) -> bool {

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.

Hmm...

Seems like this should be a PartialEq

Comment on lines +282 to +291
/// Grow the extents of the non-root subtree rooted at `root` in the log `subtrees` (see
/// [`BreakerState::subtrees`]).
#[inline]
fn grow_subtree(
&mut self,
subtrees: &mut Vec<SubtreeExtents>,
root: u16,
baseline_offset: f32,
metrics: BoxMetrics,
) {

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.

Why is this function split out? It has a very confusing doc/semantics. I presumed that it would be a cold path for add_to_subtree, but it being marked as inline seems to undermine that.

Comment on lines +234 to 242
/// The current extents of each non-root subtree in the log `subtrees` (see
/// [`BreakerState::subtrees`]), in order of first appearance.
fn current_subtrees(subtrees: &[SubtreeExtents]) -> impl Iterator<Item = SubtreeExtents> + '_ {
subtrees
.iter()
.enumerate()
.filter(|(i, s)| subtrees.iter().rposition(|t| t.root == s.root) == Some(*i))
.map(|(_, s)| *s)
}

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 turns my brain into soup.

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