Record the fourteenth sweep, over allocation and the cost per child
The sweep is Iris `97fca76`. Its two largest findings are both costs per thing rather than per frame: a container's draw searched a list once per child and so cost the square of its children, and a mask's rectangle was resolved once per fragment rather than once per instance. Both now have a rig that can see them -- `children_cost.rs` is the only one here that varies width, and `chain_cost.rs` has a masked fixture beside its two-pixel quads. The sweep also settles the `Fixed::div`-versus-`ratio` item this file listed, by deleting the operation nothing performed, and adds to the pre-gate review's waiting list the `Arc<AtomicU32>` every `StrongWidget` allocates for a count it can never raise.
This commit is contained in:
1 parent
ea66fc3f71
commit
d24ff83d6b
2 files changed
+214
-2
No files matched your search
@@ -6,6 +6,161 @@ nothing here is rediscovered. Each entry says who found it and when.
|
||||
it (settled design, the measurement method) belongs in `docs/LAYOUT.md`, and
|
||||
the current plan is in `docs/HANDOFF.md`.
|
||||
|
||||
## Fourteenth sweep: allocation, and the cost per child (2026-09-20)
|
||||
|
||||
Over the whole branch again, aimed first at what allocates -- `Vec`s, `Arc`s,
|
||||
capacity dropped and re-grown -- and then at whatever else the reading turned
|
||||
up. Eight findings.
|
||||
|
||||
- **A container's draw was quadratic in its children.** Every per-child step
|
||||
in one draw asked "have I done this one already?" by searching a list:
|
||||
`widget_at` searched `children` and then `under`, `place_at` searched
|
||||
`children`, `draw_at` searched it once per old child and once per size read,
|
||||
and `depend_on` searched `size_deps`. At a hundred children none of that
|
||||
shows; at a thousand it is most of the frame. Profiled at 1,600 children,
|
||||
70% of the redraw was in those searches -- `place_at` alone 15.6%, and it
|
||||
does nothing else linear.
|
||||
|
||||
The question each of them asks is answered in one read now: a draw takes a
|
||||
`DrawId` and leaves it on every widget it asks about, and a widget carries
|
||||
the draw that last asked. One note per widget is enough because one widget
|
||||
is asked about by one container -- the handle a container holds a child by
|
||||
cannot be cloned. `under` is still searched, but only on the rare re-ask,
|
||||
since it is added to in step with the child list. `depend_on` no longer
|
||||
dedupes at all: a child that answered with a hint may have no record to note
|
||||
it on until the draw ends, and the loop `size_deps` drives asks the same of a
|
||||
widget twice as of it once. A debug assertion in `widget_at` checks the note
|
||||
against the list it claims to be in, which is the cheap guard for the pair.
|
||||
|
||||
`tests/children_cost.rs` is new and is the only rig here that varies width;
|
||||
every other one varies depth, the window, or what changed. 40 full redraws of
|
||||
one span, per child in the last column:
|
||||
|
||||
| children | before | after | per child before | after |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 100 | 0.068 ms | 0.053 ms | 0.0007 ms | 0.0005 ms |
|
||||
| 200 | 0.143 ms | 0.101 ms | 0.0007 ms | 0.0005 ms |
|
||||
| 400 | 0.386 ms | 0.213 ms | 0.0010 ms | 0.0005 ms |
|
||||
| 800 | 1.158 ms | 0.410 ms | 0.0014 ms | 0.0005 ms |
|
||||
| 1600 | 3.680 ms | 0.811 ms | 0.0023 ms | 0.0005 ms |
|
||||
|
||||
Flat per child after, 4.5x at 1,600. A transcript is exactly this shape.
|
||||
|
||||
- **A mask's rectangle was resolved once per fragment.** `masked()` in the
|
||||
prelude called `resolve_move` -- a walk up to `CHAIN_LIMIT` (64) links
|
||||
long -- for every fragment of every masked primitive, although the rectangle
|
||||
is the same for all of them. It is resolved in `vs_main` now and handed on as
|
||||
two flat `vec2`s, which also takes `masks` and `move_offsets` out of the
|
||||
fragment stage entirely: both bindings are `ShaderStages::VERTEX` now, and
|
||||
the wrong visibility is what the GPU rig caught first.
|
||||
|
||||
`chain_cost.rs` could not see this: its instances are two pixels wide on
|
||||
purpose, so vertex work dominates. It has a second fixture now -- one
|
||||
screenful of rows, each clipped by a mask whose own chain is that deep --
|
||||
and the two tables are what tell the stages apart. GPU timestamps, best of 8
|
||||
batches, 1024x1024:
|
||||
|
||||
| mask chain | before | after |
|
||||
| --- | --- | --- |
|
||||
| 1 | 66.5 us | 66.2 us |
|
||||
| 4 | 91.4 us | 67.6 us |
|
||||
| 8 | 141.2 us | 69.4 us |
|
||||
| 16 | 240.8 us | 73.1 us |
|
||||
| 64 | 838.4 us | 95.7 us |
|
||||
|
||||
8.8x at depth 64 and 2.0x at depth 8; unchanged at depth 1, which is the
|
||||
check that nothing else moved. What is left at depth 64 is the instances' own
|
||||
vertex walk, which this does not touch.
|
||||
|
||||
- **`TextBuffer::shape` copied the attrs before the check that would not need
|
||||
them.** It built a whole `LayoutKey` first and compared that, so every text
|
||||
draw copied its `TextAttrs` -- a heap allocation for any text naming its font
|
||||
family, which is what the app does for every icon and every monospace run --
|
||||
and then compared the attrs up to three times over. The comparisons are
|
||||
asked of the borrowed attrs now and the key is built where it is kept.
|
||||
Measured over 100 redraws of 8 named-family texts at one width: 800
|
||||
allocations before, 0 after, which is one per text per frame.
|
||||
|
||||
`tests/allocation_cost.rs` had no text case at all, so none of this was
|
||||
visible to the rig whose whole job is to say the steady state allocates
|
||||
nothing. It has one now, asserting zero for a text redrawn at the width it
|
||||
already has.
|
||||
|
||||
- **`cargo test --release` failed on this branch**, and every rig here is run
|
||||
in release. `a_clipping_widget_reporting_more_than_its_box_is_caught` is
|
||||
`#[should_panic]` on a `debug_assert`, which a release build does not
|
||||
compile, so the test cannot pass there -- and a `should_panic` that does not
|
||||
panic fails rather than passing vacuously. It is `#[cfg(debug_assertions)]`
|
||||
now.
|
||||
|
||||
- **`Fixed::div` and `Div for Fixed` were reached only by their own test.**
|
||||
Nothing in the framework divides one length by another; `div_int` and `ratio`
|
||||
are what the layout uses. Deleted with the test that was its only caller,
|
||||
which also settles the open item about `div` answering `MIN`/`MAX` for a zero
|
||||
divisor where `ratio` answers `ZERO`: there is one of them now.
|
||||
|
||||
- **`Moves::remove` marked the entries changed.** Freeing a slot changes no
|
||||
byte the GPU holds -- the slot keeps what it had, nothing names it until it
|
||||
is handed out again, and whoever is handed it writes it then -- so every
|
||||
frame that retired a region node re-uploaded the whole array for nothing.
|
||||
|
||||
- **A comment saying `widget_trait!` takes no attributes**, which this branch's
|
||||
own `76aaf06` made false when it taught the macro to forward them to both the
|
||||
trait and the impl. The comment was written on 2026-09-16 and was true then;
|
||||
it is the doc comment it says it cannot be now.
|
||||
|
||||
- **A comment saying `Scroll` clips.** `Masked` explained reporting its own box
|
||||
"for the reason `Scroll` reports the same: it clips what is inside to that
|
||||
box". Nothing calls `set_mask` for a `Scroll`, and `scrollable()` only makes
|
||||
its inner a region node: a scroll area positions its content by a move and
|
||||
does not clip it, because masking is a capability a caller opts into by
|
||||
putting a `Masked` around it. The code is right; the reason was not.
|
||||
|
||||
Five things the sweep **looked at and left**:
|
||||
|
||||
- **Text allocates about six times per re-broken paragraph per frame**, and it
|
||||
is not worth removing. One is the whole text copied into the placement store
|
||||
(`keep_placed`), and the other five are `place` growing its glyph list from
|
||||
empty, 4 through 64 entries for a 58-glyph paragraph. Both would go with a
|
||||
free list fed by the store's own evictions, which happen exactly when a
|
||||
placement needs one. Measured what that would buy, with the copy removed and
|
||||
the list given its capacity outright: 3,710,680,668 instructions before and
|
||||
3,708,100,546 after over 500 sweeping resize frames of 40 paragraphs, which
|
||||
is **0.07%**. The machinery is two pools and a changed `place` signature for
|
||||
that, so the count is recorded here instead. Allocation count is not the
|
||||
same quantity as cost, and this is where the two part.
|
||||
- **`StrongWidget` carries a `RefCounter(Arc<AtomicU32>)` it can never use.**
|
||||
`StrongWidget` deliberately has no `Clone`, so the count never rises above
|
||||
zero and `RefCounter::drop` always answers true -- but every widget still
|
||||
allocates an `Arc`, and creating and dropping one is two atomic
|
||||
read-modify-writes. `StrongWidget::refs` has no caller. `handle.rs` is
|
||||
untouched by #19, so this belongs to the review of the code written before
|
||||
the gate; recorded there.
|
||||
- **More for that same pass, all dead and all older than #19**:
|
||||
`Size::to_uivec2`, `Size::rel`, `Size::leftover`, `Len::to_uivec2`,
|
||||
`Vec2::with_x`, `Vec2::with_y`, `Vec2::ceil`, `Layers::iter_orderless_mut`.
|
||||
Left out of this sweep's diff because they are not this branch's, not
|
||||
because they should stay.
|
||||
- **`request_readers` keeps an empty `HashSet` for a widget whose readers have
|
||||
all gone.** `remove` takes the reader out of the set and leaves the set; only
|
||||
freeing the widget itself takes the entry. Bounded by the number of live
|
||||
widgets and one allocation each, so it is a cost that does not grow with
|
||||
time; left as it stands.
|
||||
- **`Nodes::graft` copies a shared sub-expression once per reference.** A node
|
||||
can be named twice only where a caller reused a `SizeRequest` it cloned, so
|
||||
the duplication is bounded by what the caller wrote rather than by anything
|
||||
the arena does on its own.
|
||||
|
||||
Verified at the sweep's tip: format, workspace clippy under `-D warnings` with
|
||||
and without `layout-diagnostics`, **190** ordinary, **193** diagnostic and
|
||||
**189** release tests (188 and 191 before, with release failing), and the cold
|
||||
dump byte-identical to `cbccfb6` across all **34,986** boxes. All three seed
|
||||
scans pass -- 400 depth 5 (64.42s), 1,000 depth 6 (160.99s), 2,000 depth 4
|
||||
(301.45s) -- as do 400 depth-5 trees in each of the three deferred corpora
|
||||
(206.31s); the depth-5 scan was run again after the text change (91.40s). The
|
||||
new `mask_clip.rs` reads the clipped pixels back off the GPU and gives the same
|
||||
6,000 pixels in the same bounds before and after the shader change.
|
||||
|
||||
## Thirteenth sweep: a full pass over #19 (2026-09-20)
|
||||
|
||||
Over the whole branch rather than one commit, and the first review of
|
||||
|
||||
Reference in new issue
Block a user