Withdraw the 3x, and record what a move actually costs

The performance section reported a cost the fixture had invented. `Branch`
picks which subtree to draw from a measured length, so seed 1 draws 88
widgets at `5ed9e87` and 115 at `bd6de71` -- the ms table was comparing
three and a half times the work. Measured on one tree, fixed point costs
about a fifth to a quarter.

Also corrects where the cost is. Cycles say the `i64` division is 0.00% of
`apply_scalar` and the multiply 1.5%; it is `saturating_add`, five
instructions with no `i32` vector form. Cycle counts are steady here to
0.23% -- it is wall time that varies 2x, not the counters -- and IPC is
unchanged across fixed point, so this is instruction count and not stalls.

Records the four wins taken (-7.4% cycles, -12% instructions), the four
things tried that made it worse, and the one lever left that needs a
decision rather than a measurement.

Also: the `Len`/`LayoutLen` split and `has_exact_size`; that `Scroll`
reports `LEFTOVER` rather than its content's size, which this said wrongly
in one place and rightly in another; and that the clipping `debug_assert`
holds mask-setters only, which is why a `LazySpan` does not trip it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-16 14:08:00 -04:00
1 parent 388a6a060d
commit 9ed90505b3
1 file changed
+94 -67
+94 -67
View File
@@ -8,7 +8,7 @@ log.
Canonical Iris `main` is **`ca2b4b2`** (#17, the headless rig). **#18
`split/18-position-chain`** is open in `/home/bob/repos/iris-pr18`; its local
head is **`4f5e27c`**, sixty-three commits. Built-in alignment is complete there;
head is **`394d514`**, seventy commits. Built-in alignment is complete there;
see "Built-in alignment" below for the retained-layout details. No PR reviews
were present when checked on 2026-09-15.
@@ -37,6 +37,17 @@ length is reported and any relative or `leftover` child makes it report
`Holds` ranges propagate through final-box drawing, so a resize repositions
them without redrawing when their own contracts permit it.
`a8898aa` gives a length with no share in it its own type again, which is the
separation canonical `main` had and this branch had collapsed: `Len` is pixels
plus a fraction of a box -- a position being the length from the box's start,
which is why a span is two of them -- and `LayoutLen` is a `Len` plus a claim
only a container dividing its room can answer. What was `UiScalar` is `Len`,
what was `Len` is `LayoutLen`, and `From<Len> for LayoutLen` is the one-way
step. `490918b` renames `Painter::ruled` to `has_exact_size`, over
`SizeRule::exact`: "is there a rule here" and "is my report moot" are the same
question only while `Exact` is the only rule, and `Min`/`Max`/`Clamp` are
queued.
`71c9c39` replaces the public `Painter::place` distinction with an opt-in
widget property. `.region_node()` gives a widget one independently movable
retained region; `Widgets::set_region_node` can change that choice at runtime
@@ -78,7 +89,7 @@ rewrite:
`core/src/ui/holds.rs`, the retained tests, and the generated cold-layout oracle
pin those rules. Seeds 10 and 86 are now in the ordinary generated set.
## Verification at `4f5e27c`
## Verification at `394d514`
- `cargo fmt --all --check`
- `cargo build --workspace --all-features`
@@ -92,7 +103,8 @@ pin those rules. Seeds 10 and 86 are now in the ordinary generated set.
(159,024 widgets per case, largest tree 587) at `5ed9e87`, before fixed
point; re-run that before quoting it again.
- `tabs`, `view`, `minimal`, `text` and `random` render byte-identical at
1920x1200 across the whole fixed-point sequence.
1920x1200 across the whole fixed-point sequence, and across this session's
renames and arithmetic changes on top of it.
- `tests/cases/drift.rs` passed its 20,000-move exactness check in release
mode.
- The seeded `random` example, live-resized from 1920x1200 to 1280x800, is
@@ -127,69 +139,78 @@ choice and the incompatible visual effect of making `Full` the default.
### Performance
Measured 2026-09-16 on the release `layout_diagnostics` fixture, seed 1,
depth 8, 500 frames, as median milliseconds a frame. `5ed9e87` is the commit
before fixed point; `39e4ca2` is fixed point complete.
**The 3x this section used to report was a fixture artifact, withdrawn
2026-09-16.** `random.rs`'s `Branch` picks which of two subtrees to draw by
comparing a measured pixel length with a threshold, so the fixture's shape
moves with the thing being measured. Seed 1 at depth 8 draws 88 widgets and
writes 2,298 primitives a frame at `5ed9e87`, and 115 and 8,209 at `bd6de71`
-- three and a half times the work behind a number read as three and a half
times the cost. `394d514` gives the rig `Edits::fixed_branches`; the oracle
keeps measured branches, which is the whole point of them.
| phase | `5ed9e87` | `39e4ca2` | `4f5e27c` |
| --- | ---: | ---: | ---: |
| many | 0.179 ms | 0.544 | 0.274 |
| resize | 0.020 | 0.035 | 0.033 |
| scroll | 0.011 | 0.030 | 0.019 |
| repaint | 0.012 | 0.031 | 0.021 |
**Check the work counters before comparing two commits' times.** The rig
prints drawn widgets, widget draws and primitive writes. An undrawn
`leftover` child still moves them and no flag removes that, so the trees are
near rather than equal -- and `9d8415d` changed the generator itself, since
an X span's `OrthoSize::Full` became a size rule.
**Fixed point cost 3x, and two thirds of that was not the grid.** The
counters said eight more "placed by redrawing" a frame in `scroll`, all of
them "reuse: another layer": a retained drawing belongs to the layer it was
made on, and `Stack` measured the child that sizes it on its own layer before
drawing it again on the child layer. `97cc8b3` measures on the layer the
child ends up on, which puts the scroll phase's counters back exactly where
they were -- 4 widget draws, 12 draw requests.
Measured on the fixed-shape fixture, seed 1, depth 8, 500 frames of `many`:
What is left is per-operation cost, not more work: the `many` phase does 125
widget draws against 121 before, and takes 1.5x as long. Three roundings and
divisions were taken out after measuring (`11c55bc`, `4f5e27c`, and halving
the divisions in `Holds::through`); `RegionRemap::apply_span` is still a
fifth of the phase, and the rest is spread thin. Measure with `perf stat -e
instructions:u` rather than the clock, which varies 2x here.
| | drawn widgets | primitive writes | instructions | cycles |
| --- | ---: | ---: | ---: | ---: |
| `5ed9e87`, before fixed point | 100 | 4,272 | 1,761M | ~699M |
| `4cbb242` | 97 | 3,951 | 2,093M | ~819M |
**Why the grid is slower than floats, from the disassembly at `4f5e27c`.**
`RegionRemap::apply_span` -- two scalars, every branch -- is 352
instructions, of which two are 64-bit `idiv`, six `imul`, and about sixty
are jumps and `cmov`s over twenty-eight compares. The same arithmetic in
`f32` is a couple of dozen SSE instructions. The difference is in three
places, and none of them is the grid being a worse idea:
So the grid costs something like a fifth to a quarter, on a head that draws
slightly less. Normalised by primitive writes it is about 1.27x.
- **A float's rounding is free and a fixed-point one is code.** `Fixed::mul`
widens to `i64`, multiplies, adds a half step, shifts back, and saturates
into `i32` -- roughly eight instructions and a sign branch where `mulss`
is one instruction with the rounding in hardware.
- **Integer division is the slowest instruction on the core**, is not
pipelined, and has no vector form; `divss` is pipelined and vectorises.
`div_round` also needs the remainder and its sign. This is why removing
divisions is where the wins have been, and why the remaining ones are
worth structural effort rather than micro-optimisation.
- **Saturation costs what floats get from infinities**, and it breaks the
pairing. Adding four `i32` lanes with saturation is a dozen SSE ops
(`pcmpgtd`/`paddd`/`pandn`/`psrad`/`pxor`/`por`) against one `addps`; and
the widening multiply cannot stay in a vector register at all, since there
is no packed 64-bit `imul` here, so lanes that an `f32` `Vec2` did at once
serialise.
**Cycles are measurable on this machine and instructions alone are not
enough.** Three runs of one binary varied 0.23%. It is *wall time* that
varies 2x here, which is what the machine notes say; the counters are
steady. IPC is 2.58 before fixed point and 2.59 after, so the cost is
instruction count at unchanged throughput -- not stalls, not branch misses
(0.69%), not the division.
"Fixed point is faster" is a rule from machines without an FPU. On this one
the grid buys exactness, and exactness is what the warm-against-cold oracle
demands -- so the cost is the price of the property, not a defect to chase
back to 1.0x.
**What the cycle profile says the cost is**, against what the previous
session recorded from the disassembly. In `AxisRemap::apply_scalar` the
`i64` division is **0.00% of cycles** and the multiply 1.5%: the time is in
`saturating_add`, which is five instructions and has no `i32` vector form.
The division is rare because most moves are translations.
One thing tried and reverted, recorded so it is not tried again: short-
circuiting `apply_scalar` where the fraction is nought or one. Those are not
the common cases, and the comparisons cost 17% more than the divisions they
saved.
Recovered this session, each measured before and after on one commit with
byte-identical renders proving the layout unchanged -- on the old fixture,
689.6M cycles and 1,886M instructions down to 638.9M and 1,657M:
- `cb1bba4` works a move out once per subtree rather than per scalar: -3.8%
instructions, -0.8% cycles. The gap between those two is the finding.
- `1940e85` translates a whole region at once, since that is what a
translation is: -4% instructions, -3.4% cycles.
- `d75a1e2` and `4cbb242` skip multiplies that cannot change anything --
composing through a full box, and `lerp` where the ends are equal:
together -2.7% instructions, -1.8% cycles.
Tried and rejected, with numbers, so they are not tried again:
- A float reciprocal for the division: **+6% cycles**. The conversions cost
more than the `idiv` that is not on the hot path anyway.
- Branchless `shift_round`: **+6.7% cycles**. The sign of a product predicts
well, and the positive path is two instructions.
- Removing the per-child hash lookup in `remap_subtree` with `mem::take`:
**0.0%**. `FxHashMap` on a hot line is not the cost.
- Short-circuiting `apply_scalar` where the fraction is nought or one: +17%.
(From the previous session.)
**The one lever left that is worth a decision:** `Fixed`'s `saturating_add`
and `saturating_sub`, measured at **8.1% of instructions and 2.7% of
cycles**. Wrapping instead is one instruction where saturating is five. That
trades away "a clamped coordinate keeps the ordering a wrapped one inverts",
which is a posture rather than a micro-optimisation, so it needs Bryan.
Everything else is spread thin.
Earlier, against #18's own history: the retained rewrite took `many` from
25.17M instructions a frame at `691e3eb` to 6.14M at `29c7881`, and `resize`
from 16.08M to 8.39M. That fixture has since changed; do not compare across
it.
from 16.08M to 8.39M. That fixture has since changed twice; do not compare
across it.
## Retained-layout invariants
@@ -200,9 +221,16 @@ it.
-- `Painter::child_layer_at` addresses one -- or it pays two draws a frame
forever and keeps whichever the second ask left.
- A widget that clips its contents to its box reports its box: `Scroll` and
`Masked` both report `LEFTOVER`, and a `debug_assert` in `draw_at` holds
any widget that set a mask to it. Overflowing is otherwise ordinary and a
text too tall for its box says so.
`Masked` both report `LEFTOVER`, and a `debug_assert` holds any widget that
**set a mask this draw** to it. Overflowing is otherwise ordinary and a text
too tall for its box says so -- which is why the assertion is narrowed to
mask-setters rather than to every widget.
`Masked` is the only one that sets a mask; `Scroll` reports its box because
a drawing placed in a longer box is *scaled* by recomposition, not because
it masks. So a `LazySpan` is unaffected: it clips by not drawing, and may
report its whole content length, which is what the `Scroll` above it
measures. One that set its own mask would have to report its box and hand
the content length up another way.
- A span is as long across itself as its longest child, unless a rule beside
it says how long it is -- and then it does not read its children there at
all, since the answer is not wanted and reading one is what makes its size
@@ -261,9 +289,10 @@ it.
where there is none. The box a parent hands back and the sum of what the
children asked for are counts of the same step, so the boundary needs no
margin and the validity interval is split exactly at it.
- `Scroll` reports its content's first measured size. Its drawing can survive
container-length changes only over the interval in which clamping and its
current offset do not change.
- `Scroll` reports `Size::LEFTOVER`; what it takes from the first box it asked
about is its *content's* length, which it clamps and holds by rather than
reports. Its drawing can survive container-length changes only over the
interval in which clamping and its current offset do not change.
- **A move that keeps a box's length is a translation, and an offset is
exact on the grid.** A box that also changed length has to re-express each
part as a fraction of the new one, and that division and multiplication
@@ -562,11 +591,9 @@ Queued from this work, in order:
one offer per child. `min(report, cap)` is also not a `Len`: `Len` is a sum
of the three parts, and the smaller of two of them is not.
So a cap takes pixels and a fraction and no share, which is `UiScalar`'s
shape (`core/src/orientation/pos.rs`) -- either that type or a `Len`
without `leftover`. Awaiting Bryan: whether to split the type, and whether
a `Max` narrows the box the child draws in or only what the parent reports
for it.
So a cap takes pixels and a fraction and no share, which is what `Len` is
since `a8898aa`. Still awaiting Bryan: whether a `Max` narrows the box the
child draws in, or only what the parent reports for it.
Other queued work, in dependency order: