Skip to content

Make LineBoxMetrics Copy with an append-only subtree history - #79

Closed
nicoburns wants to merge 11 commits into
base/upstream-main-718dcb7from
devin/1790878831-subtree-log-simplify
Closed

nicoburns wants to merge 11 commits into
base/upstream-main-718dcb7from
devin/1790878831-subtree-log-simplify

Conversation

@nicoburns

Copy link
Copy Markdown
Member

LLM Contributions: Code and this description were generated by Devin.

Fork-internal PR for review. Upstream's policy requires the description to be in the author's own words, so this text needs rewriting before anything goes to linebender/parley.

Alternative implementation of linebender#855, written in response to the review feedback there that the subtree log was hard to follow. Same goal: remove the SmallVec<[SubtreeExtents; 2]> from LineBoxMetrics, which is cloned at every line-breaking opportunity. Based on upstream main at 718dcb7 (base/upstream-main-718dcb7), which linebender#855 currently conflicts with.

Design

  • LineBoxMetrics is Copy. It keeps root: SubtreeExtents and a cached non_root_height.
  • The extents of non-root aligned subtrees (rooted at vertical-align: top | bottom) move to a new SubtreeHistory on BreakerState.
  • SubtreeHistory is strictly append-only. grow pushes the subtree's new extents rather than overwriting, and skips the push if nothing changed. The current extents of a subtree are the last entry with its root.
  • PrevBoundaryState gains subtrees_len; reset_to truncates to it. This is the same pattern as the existing contributed / contributed_len.

Differences from linebender#855

  • No in-place update of entries newer than the last saved opportunity, so LineBoxMetrics::saved_subtrees and the three-way branch in grow_subtree are gone.
  • same_as is replaced by a derived PartialEq on SubtreeExtents / Extents.
  • add_to_subtree / grow_subtree become LineBoxMetrics::add_box and SubtreeHistory::grow.

Known wart

SubtreeHistory::iter (used twice per line in finish_line) still filters with "no newer entry has the same root", which is quadratic in the number of history entries on the line. That is the number of times a top/bottom subtree grew on that line.

Testing

  • The two regression tests and snapshot from Make LineBoxMetrics Copy linebender/parley#855 (lines_revert_restores_aligned_subtree_line_height, lines_aligned_subtrees_grow_across_breaks).
  • subtree_extents_restored_at_break_opportunities, ported from Make LineBoxMetrics Copy linebender/parley#855's unit test; it fails if the truncate in reset_to is removed.
  • Locally: cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace --all-features. The cargo hack feature matrix and non-host targets were not run.
  • Not in the repo: a differential fuzz of 30,000 random layouts (nested vertical-align, inline boxes, finite max line height) gave line metrics and item positions identical to the base commit.

Changelog: None (performance fix for an unreleased regression)

Link to Devin session: https://dioxus.staging.devinenterprise.com/sessions/ebf23f033e594293a023cb44a2c87744
Open in Devin Desktop: https://dioxus.staging.devinenterprise.com/desktop/session/ebf23f033e594293a023cb44a2c87744?variant=devin-insiders
Requested by: @nicoburns

Move the extents of the non-root aligned subtrees (those rooted at a
`vertical-align: top | bottom` box) out of `LineBoxMetrics` into a
`SubtreeHistory` on `BreakerState`, so that saving a line-breaking
opportunity no longer clones a `SmallVec`.

The history is strictly append-only: growing a subtree pushes its new
extents. A saved opportunity records the history's length and reverting
truncates to it, the same way `contributed` is already handled.
@staging-devin-ai-integration

Copy link
Copy Markdown

I'll fix CI failures and address comments from users with write access that start with 'Devin'.

  • Disable automatic comment, CI, and merge conflict monitoring

@staging-devin-ai-integration

Copy link
Copy Markdown

Reviewed — LGTM. This is a nice simplification of linebender#855: no saved_subtrees, no three-way branch, and the append-only log + truncate pattern matches contributed exactly.

Correctness checks I did by hand (all hold):

  • Restore consistency. root/non_root_height live in LineBoxMetrics → captured by the LineState clone inside PrevBoundaryState; subtrees is restored via subtrees_len + truncate. Boundary marks are chronological, so the recorded subtrees_len is always ≤ current entries.len() at reset_to, and both boundaries are cleared in start_new_line, so a stale length can't outlive a line.
  • non_root_height is exactly max(subtrees[1..].line_box.height()). Extents::add is max-only, so each subtree's height is monotonic across pushes; therefore the running max over all grow results equals the max over the current extents at any truncate point.
  • grow skipping the push when old == Some(new) is safe — the surviving entry is identical to the would-be-pushed one. Pushing an all--inf entry when old is None (e.g. the out-of-flow inline box path in append_inline_box_to_line) matches old subtree_mut, which also materialized the entry.
  • contributed/subtrees invariant. Every style contribution pushes contributed and grows subtrees in the same step, and both are truncated by lengths recorded at the same instant — so they're preserved/rolled back together.
  • iter() order (last-push position vs. the old first-seen order) doesn't matter: all consumers are order-independent (max accumulations in finish_line, find by root in aligned_subtree_offset).
  • PartialEq on f32 extents is fine for change detection; f32::max discards NaN operands so NaN can't sneak in, and -inf == -inf handles the sentinel case.

Locally verified on this branch: cargo fmt --all --check, the exact CI clippy invocation (--target x86_64-unknown-none --no-default-features --features libm), cargo test -p parley --lib, and the parley_tests lines_* tests all pass.

Two minor things:

  1. The acknowledged quadratic iter() — see inline suggestion; it can be made O(n) in a few lines if you want to close out the wart now.
  2. Nit: non_root_height's doc could mention it's a cached max maintained by add_box (and that it lives in LineBoxMetrics precisely so it snapshots with LineState). Not required — the "see [SubtreeHistory]" pointer covers the mechanics.

Written by Devin

Comment thread parley/src/layout/line_break.rs Outdated
let is_current = |(i, s): &(usize, &SubtreeExtents)| {
let newer = &self.entries[i + 1..];
newer.iter().all(|n| n.root != s.root)
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If you want to drop the quadratic wart, a reverse scan with a seen-set makes this O(entries):

fn iter(&self) -> impl Iterator<Item = SubtreeExtents> + '_ {
    let mut seen: Vec<u16> = Vec::new();
    self.entries
        .iter()
        .rev()
        .filter(move |s| {
            if seen.contains(&s.root) {
                return false;
            }
            seen.push(s.root);
            true
        })
        .copied()
}

Yields entries in last-push order rather than this version's first-push order, but every consumer is order-independent (max accumulation in finish_line, find by root in aligned_subtree_offset). It also slightly favors the common case where a subtree's newest entry is near the end. A SmallVec<[u16; 4]> (or a bitset, since roots are u16) avoids the alloc; Vec is fine given this runs twice per line and the existing entries Vec already allocates.

Same goes for grow's get scan — O(history) per non-root add_box vs. the old position() over live subtrees only. Both bounded per line since clear() runs in reset_line, so either way is defensible.

Replace SubtreeHistory::iter, which was filtered twice per line, with
SubtreeHistory::current, which walks the history once in reverse and
collects the latest entry of each root into a SmallVec.
finish_line only takes maxima over the list, and the per-line offsets
it pushes are looked up by root, so the order doesn't matter.
Growing a subtree only has to push a new entry if its current one was
there at the last saved line-breaking opportunity. Otherwise overwrite
it, so that the history doesn't fill up with obsolete entries when
there are few opportunities (e.g. without wrapping).

The history now tracks which entries are frozen itself, behind
save/restore instead of len/truncate.
Comment thread parley/src/layout/line_break.rs Outdated
Comment on lines +318 to +322
if aligned_subtree == 0 {
self.root.add(baseline_offset, metrics);
} else {
let subtree = subtrees.grow(aligned_subtree, baseline_offset, metrics);
self.non_root_height = self.non_root_height.max(subtree.line_box.height());

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Metrics for the main (root) aligned subtree on a line get stored on LineBoxMetrics. Metrics for all other aligned subtrees (if any) go into the SubtreeHistory

Comment on lines +254 to +266
fn grow(&mut self, root: u16, baseline_offset: f32, metrics: BoxMetrics) -> SubtreeExtents {
let index = self.entries.iter().rposition(|s| s.root == root);
let old = index.map(|i| self.entries[i]);
let mut new = old.unwrap_or(SubtreeExtents::new(root));
new.add(baseline_offset, metrics);
if old != Some(new) {
match index {
Some(i) if i >= self.frozen_count => self.entries[i] = new,
_ => self.entries.push(new),
}
}
new
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

If there is a non-frozen entry for the aligned subtree with root root, then modify it's extents in-place. If there is no entry or the entry is frozen, push a new entry.

CSS defines an aligned subtree for every inline box, recursively, but
layout only ever deals with the ones that aren't part of a larger one:
those rooted at the root span box or at a box with
`vertical-align: top | bottom`. Name those "independent aligned
subtrees" (the specifications have no term for them), say that this is
what "aligned subtree" means elsewhere in the crate, and say explicitly
which of them `SubtreeHistory` holds.

Also fix the definition, which included every parent-relative descendant
rather than stopping at `top`/`bottom` boxes.
Follow-up to the module doc change: comments that said "non-root",
"top-level" or just "aligned subtree" where the distinction matters now
use the terms defined in `style_metrics`. Comments and one test name
only; no behaviour change.
The field, parameters and locals of this name hold the style index of
the root of an independent aligned subtree, not a subtree. No behaviour
change.
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.

1 participant