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:
1 parent
388a6a060d
commit
9ed90505b3
1 file changed
+94
-67
+94
-67
@@ -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:
|
||||
|
||||
|
||||
Reference in new issue
Block a user