41df8bb76a9df33692968eb19b9a901298901d60
17
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
41df8bb76a |
Stop re-measuring the transcript at the keyboard, and seed the window
Three things, all of them the same shape: work done because something was asked a question at the wrong moment. The keyboard. The layout modifier passed `minHeight = viewportSize` down to its child, so every frame of the IME animation changed the child's constraints -- and changed constraints are exactly what defeats the early-return in `MeasurePassDelegate.remeasure`. The whole transcript was re-measured on the way up, measured on a Pixel 9 Pro XL as 250 measurements averaging 2.8ms with a 54.5ms worst. The minimum is applied to what this node reports now, not to what it asks its child for, so the child early-returns; the content is placed against the bottom to keep the anchoring the minimum existed for. It only ever bites on a conversation shorter than the screen, which was never the case paying for it. The flicker. `retained` starts empty, so on the composition that introduces the rows every one of them is outside the window, the whole transcript collapses to a single spacer, and it draws blank for a frame. Seeded now -- at the newest end on open, and around the anchor on a restore. The restore case is the one that bites: `placed()` jumps the view during placement, and a window recomputed there only schedules a recomposition, so the destination would draw as spacer for a frame or two after drawing ungates. Seeded by pixels rather than by a row count, because a run of tool calls is eight rows and less than half a screen. The block layers. Every paragraph of every reply had a layer, which was right when whole rows were re-recording constantly and one row's display list was 36,982px tall. Re-recording is rare now -- 65 whole rows in fifty seconds of reading -- and a layer costs a layout node and a display list held for the life of the row, against the node count the per-frame cost scales with. They go to the message still arriving, which is the only one whose drawing is invalidated often enough to want the granularity. Verified on the emulator against the case none of this was written for: a two-message conversation far shorter than the viewport still hangs from the composer, with the keyboard both up (messages at y1756-1940, composer 2109) and down (936-1120 against 1289). Mechanism for the block layers and the hole in the restore seeding both from the ai-app-2-6d session; the layers are not re-positioned per frame as I had it, they are baked into the row's display list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f497d1f3ad |
Actually run the amortization, and stop paying for history twice
Two things committed in |
||
|
|
94b1509733 |
Stand rows up a few per frame instead of two screens at once
The counters now say which half is left. Re-recording is rare -- 157 row display lists in a minute of scrolling, 407ms in total -- while the frame's draw phase sits at 7.9ms. Since that phase also carries Compose's own measurement, what is left is laying text out: shaping glyphs, on the thread drawing the frame, and none of it timed by anything here. The window moved in one step, so every step boundary shaped two screens of markdown inside a single frame, a page landing did the same, and opening a session did seventeen screens of it in one. It now walks towards its target a couple of rows a frame. That does not make the work cheaper and is not meant to: it stops it arriving together, which is the difference between a frame that is late and a frame that is missed by ten. What is on screen is never amortised. The visible range goes up in the frame it is needed whatever else is pending, and only the margin being read *towards* is spread out -- so this cannot show anybody a gap, which is the failure the last two changes in this area both had. Diagnosis and the ordering from the ai-app-2-6d session, whose test this follows: records small and draw high means shaping rather than recording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9052e5f55e |
Notice a row arriving, and stop subcomposing the list at the keyboard
Two faults from making the retained window lazy, both of them mine. A screen of blank between the last message and the box it was typed in. The window was recomputed when the view had moved far enough, and a message arriving does not move the view -- so the new row fell outside the window and stood in as a spacer of its guessed height. The version number the check compares against is only bumped by the recompute it guards, so asking before refreshing meant never noticing. It refreshes first now, and the window also watches how many rows there are, because a row arriving is the case it exists to catch and the one that does not announce itself through the scroll position. And the keyboard, which was the most expensive thing on the screen. The visible height came from a `BoxWithConstraints` wrapped around the transcript -- that is a `SubcomposeLayout`, and the IME animation changes the visible height on every frame of its slide, so the entire transcript was being subcomposed again for each of them. The same number read in the layout phase, from the scroll container's own measurement, makes it a relayout instead, and the rows keep the measurements they already have. Checked on the emulator: the newest message sits against the composer with the keyboard up, and there is no gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2b24362cc4 |
Work out the retained window once a frame, not once a row
The same mistake as the draw-phase culling, moved into another phase. Each row held a `derivedStateOf` over the scroll position to decide whether it stayed built. That reads correctly and costs the same shape: every one of those derived states is invalidated by every scroll frame and has to be re-evaluated to learn whether its answer changed, so the per-frame work grew with the number of loaded rows again -- this time landing in the recomposition pass, which the platform reports as the frame's animation phase. On a Pixel 9 Pro XL at 153 rows that was 11.6ms at the median, against an 8.3ms frame, and it was the largest term left. The window is now one range that every row reads, recomputed once a frame from one observer. And recomputed lazily: every row reads it, so every change to it disturbs all of them, which is affordable once every couple of screens and is not affordable at row boundaries, where a fling would cross one every few frames. The window is eight screens either side and it moves in steps of two, so the margin absorbs the staleness. Worth naming as a pattern, since this is the third time: a per-row answer to a question about the scroll position is O(rows) per frame wherever it is evaluated -- in draw, in a derived state, anywhere. The question has one answer and it belongs in one place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
718fb5320c |
Stop the transcript being able to go blank
Scrolling up emptied the screen and only reopening the session brought it back. The guess for an unmeasured row's height was re-derived from a running average each time it was wanted, and that average moves as rows are measured -- so the height a spacer had been *built* at stopped matching the height the running totals were adding up, and the two drifted apart. Once they differed by more than the retain window every row failed the distance test at once, and a transcript of nothing but spacers has nothing left to measure and so nothing to correct itself with. Two changes, and the second is the one that matters. A guess is now made once per row and kept, so it cannot drift from what was built with it. And which rows stay built is decided as a *range* rather than by each row testing itself: the row nearest the viewport is in that range by construction, whatever the arithmetic says, so the worst a mistake here can do is build too few rows or too many. A blank transcript is no longer a state this can reach. That is the shape worth keeping from the bug. Every row answering independently meant one wrong number could stand all of them down together, and the failure was silent, self-sustaining, and looked exactly like the screen having nothing to show. Checked by scrolling sixty swipes to the oldest loaded end and back -- the content holds throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
53735f8df4 |
Let a paged-in row wait its turn instead of standing up with the page
The layers took care of the steady state -- the transcript's content re-recorded fifteen times in a thirty-second scroll, and the frame's draw phase sits at 3.5ms. What is left is entirely spikes, and they are pages landing: one of those fifteen recordings took 99.9ms on its own, with the frame after it unable to start. The cause was a rule that read as caution and was not. A row with no measured height was retained whatever its distance, on the grounds that it had just been paged in and was about to be looked at -- but "no measured height" describes the *whole page*, not the near edge of it, so eight hundred events' worth of markdown was shaped inside the frame the page arrived in. A row that has not been measured now stands in at the average of those that have, so it is placed and judged by distance like every other row and is built when the reader comes near it. The average rather than a constant because these run from a one-line note to a screenful, and the average row in a conversation is a fair guess at the next one. Being wrong is cheap here and self-correcting: an estimate is only ever used above the viewport, and this layout hangs from its far end, so a correction up there moves nothing on screen. Checked by scrolling twenty-five swipes into history and back on a real transcript -- rows stand up as they are reached, and the position does not shift as the guesses are replaced by measurements. Second half of the diagnosis from the ai-app-2-6d session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e65c961e1e |
Give every row its own layer, and stop culling during draw
The piece of a lazy list this had not rebuilt was the render node per item. Without one, a row's glyphs are recorded into its parent's display list, and that list is re-recorded every frame the parent is invalidated -- which, while the list is scrolling, is every frame. With one, the row is recorded once and afterwards moved by a transform, and the render thread culls the ones off screen itself. The draw-phase culling it replaces could not have won, and the two are mutually exclusive rather than complementary: `onScreen` and `RowWindow` read the scroll position from inside every row's and every block's drawing, and reading a scroll position during draw invalidates the drawing it is in. So the machinery for deciding which rows need not be drawn was re-recording all of them, every frame, to decide it. Keeping both would have bought the cost of the first and none of the second. Measured on the emulator over a thirty-swipe scroll: **one** row display list recorded across 481 frames, against 1,544 recordings for the same gesture before, and the frame's draw phase down from 4.2ms to 2.9ms at the median. Two things this also corrects. `LAYOUT_MEASURE_DURATION` is the *View* hierarchy's pass, and Compose is one view -- `AndroidComposeView. dispatchDraw` calls `measureAndLayout()` before it records, so all of Compose's own measurement, text shaping above all, is reported inside DRAW_DURATION. Every "layout is 0.0ms, so nothing is being re-measured" reading in this file's history was reading a bucket that never contained it. And the retain window is now load-bearing for a second reason: a retained display list costs memory, so bounding what is retained bounds that too. Found by the ai-app-2-6d session reading the render reports against this code; the diagnosis and the ordering are theirs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cc41a03746 |
Keep eight screens of rows built, and stand the rest down
Iris's readings made the shape unarguable: smooth with one page loaded, a step worse at the next, worse again at the one after, and 62.7ms at the median by 390 rows -- with layout at 0.1ms and the GPU at 1.8ms the whole time. The cost was following what was *loaded* rather than what was on screen, and keeping every row built is the only thing in here that does that. So the window is bounded. Eight screens either side stay fully built, which is about sixteen times what a lazy list keeps: everything somebody has just read is still there, and only a deliberate journey back through the conversation pays to rebuild anything. That was the point of retaining rows and it survives; what does not survive is retaining all of them. A row outside the window is replaced by a spacer of the height it was last measured at, so the transcript's total height is unchanged and nothing under the reader moves. A row that has never been measured has no height to stand in for it and is kept whatever its distance, which is exactly the row that has just been paged in. Each row decides for itself, in a composable of its own, from a derived state -- so a row hears about the scroll only when its own answer changes. Read from the list's body instead, every row would recompose whenever any row crossed the edge. On the emulator, same transcript and same gesture, the frame's draw phase goes from 2.7ms at the median to 0.4ms. Iris's own timing reading from before this says where the rest of her frame goes: the transcript's whole draw is 3.0ms mean against a 4.2ms median draw phase, so the median frame was already close to budget and what is left is the tail -- 20ms of waiting and 2.3 seconds of parsing in bursts, both of which arrive with a page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bdc62b6442 |
Time the draw phase, since counting it stopped being informative
The counters did their job and then ran out: 4,090 rows drawn against 94,815 skipped, the tallest drawn block down from 36,982px to 1,765px, and drawing still took 30.9ms at the median. Almost nothing is being recorded and recording is still the expensive phase, so the next question is not "how much" but "where" -- and there are only two answers left. Either the little that is drawn is somehow costly, or the time is not inside the transcript at all and every count above is beside the point. So the draw is timed at three levels that nest: the whole transcript, one row, one block. If the transcript's own figure is most of the frame's draw, the cost is ours and the rows and blocks say which. If it is a fraction of it, the frame is being spent somewhere this has not been looking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
981856e046 |
Draw a reply one block at a time, and skip the blocks off screen
A reply's display list holds every glyph of it and is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a hundred short ones and skipping the rows around it cannot help while it is the one on screen. That was the whole of the remaining draw cost: 97% of rows correctly skipped, and the tallest one still drawn was 36,982px -- about twenty-five screens in a single message. So a message is cut into its top-level blocks and each is drawn, or not, on its own. The cut comes from the parser's own boundaries rather than from a line scanner looking for blank lines, which is what makes it safe: a heading, a table, a fenced block and a list are each one node whatever is inside them, so a loose list does not become five one-item lists and a fence is never split down the middle. Checked against a real reply whose list items are separated by blank lines -- it still draws as one list with its bullets aligned, which is the case a blank-line split gets wrong. It bounds parsing too, which was the other symptom in the same reading: one message took 1.4 seconds to parse as a single unit, and a block is a paragraph. The row and the message each know half of where a block is, so they meet at an interface declared where it is used: the list supplies the row's position, the message supplies the block's offset inside it, and drawing a message does not have to know it is inside a transcript. What this cannot divide is a single node, and a long fenced code block is one -- so the report now also carries the tallest *drawn block*, which is the number that says whether splitting bounded anything. This emulator's tallest row is one such fence, which is why its own figures do not move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dffa365e54 |
Count what the transcript draws, not just how long drawing took
"Drawing costs too much" and "drawing was skipped and still costs too much" need opposite fixes, and a millisecond figure cannot tell them apart. So the report now carries how many rows were drawn, how many were skipped, and the height of the tallest one that was drawn. The first reading answers it. Skipping works -- 854 rows drawn against 24,078 skipped, so 97% of the transcript is correctly not being recorded -- and the tallest row that *is* drawn is 36,982px. One assistant message about twenty-five screens tall, whose display list holds every glyph of it, re-recorded whenever drawing is invalidated. A row that size is a hundred short rows as far as the draw phase is concerned, and no amount of skipping its neighbours helps while it is the one on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f9432b69cc |
Keep every row laid out, but only draw the ones near the screen
Iris's Pixel 9 Pro XL said which half was costing, and it was not the half either of us was looking at. With 138 rows loaded: layout 0.0ms at the median, GPU 1.9ms, and **draw 13.6ms** against a 120Hz budget of 8.3ms. Nothing was being re-measured and the phone's rasteriser was idle; the UI thread was recording draw commands for a transcript that was almost entirely off screen. Retaining rows was right and stays. What does not follow the same rule is drawing: the draw pass walks the whole tree, so a list that keeps every row alive records every row every frame, and that cost grows with each page of history -- which is exactly what "worse afterwards" was. Composition and measurement are what must not be thrown away, because they are what has to be rebuilt from nothing when the reader comes back. A display list is rebuilt from a layout that is still there. So each row skips its own draw when it is off screen, by more than a screen's margin either side. The check reads the scroll position from the draw phase, so moving the list invalidates drawing and nothing else, and it is a lookup rather than a sum -- the running totals are rebuilt at most once a frame and only after something has actually changed height, since adding them up per row per lookup would have made the fix quadratic in the thing it was fixing. Also reports the three frame phases that were missing, which is why the phases on that reading did not add up to the total: the time a frame spent waiting for the UI thread to be free, handling input, and running animations. About 15ms of the 30.6 was in that gap and unattributed. On the emulator, same transcript, before and after: draw p50 3.9ms -> 2.5ms, p90 7.3ms -> 4.2ms, p99 48.6ms -> 5.2ms. The saving there is small because only 60 rows were loaded; it is proportional to how much is off screen, and on the phone that was 95,000px of content against a 1,474px viewport. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3e653c4797 |
Add a button that copies what this session costs to draw
A speedometer left of the usage chart, because it is the same kind of thing about the app that the chart is about the conversation. It copies rather than opens: a screenful of timings read on the phone is a screenful nobody can act on, and what it produces is a message to whoever is looking at the code. It carries both halves of the question, because "the scroll is laggy" has two causes and one appearance. From the platform, each frame's cost split into its phases -- if measuring and drawing are small and the total is large, the time is going into rasterising and no amount of doing less work per row will move it. From the app, counts and timings of the work the transcript actually does: rows composed, replies parsed on the composing thread rather than ahead of it, runs grouped. Counts rather than frame times are the point of the second half. This emulator scrolls at the same 21ms median as the stock Settings app, so every app-level cost here is under the floor of what it can measure and no number taken in it says anything about a 120Hz phone. How many times a row was composed is the same number on any machine, and it is the one that says whether the work follows what is on screen or everything ever loaded. Pressing it empties both, so two presses measure two separate stretches of scrolling rather than one and then the same one again. The first reading from the emulator already says something: measure and layout are 0.0ms at the median, so nothing is being re-measured, and the cost is 3.9ms of recording the draw against 10.9ms of GPU. It also shows 60 loaded rows composing 177 times across three page loads -- every row recomposing whenever a page lands -- which is the next thing to look at if the phone says the work is ours. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fd250378a0 |
Attach the restore hook only while there is a restore
`onPlaced` on the transcript content is the one callback left that would run every frame, and on all but the two frames of a restore it looks at a null and returns. Attaching it only while a position is waiting says that in the modifier chain rather than in a branch inside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f22c96abe2 |
Stop paying a callback per row per frame, and drop the reply cap
Holding every row alive turned two per-row callbacks from a cost paid by
whatever was on screen into a cost paid by everything ever loaded, which
is why loading one more page of history made the whole transcript lag
and each page after made it worse. Both fire for every registered node
on every layout pass:
- an `onGloballyPositioned` per row, kept so a tap could be told which
half of the row it landed in
- an `onGloballyPositioned` per clickable inside a row, for the same
reason, so a tool group paid several
Neither is needed. One gesture detector on the whole list records where
a touch went down in the content's own coordinates, and the row's own
start is added up from heights when somebody actually taps -- so the
transcript keeps a height per row, which changes when the row does, in
place of a position per row, which is wrong the moment anything scrolls.
Positions for the saved-scroll anchor come from the same sum, and the
anchor is applied once per layout from the content rather than once per
row.
The reply cap is gone at Iris's request; she wasn't seeing it and does
not want it for now. It was doing measurable work -- p50 rises from 16ms
to 20ms on the emulator with it removed -- so it is worth knowing where
to look if long history feels heavy.
That 4ms is the only figure here I trust. This emulator's own noise
between identical repeats is larger than the difference the callback
change makes (identical builds measured 3.5% and 9.3% of frames over
budget), and its p50 of 16ms is already past a 120Hz budget, so it
cannot rank any of this the way the phone will. The callbacks are gone
because they are O(rows) per frame by construction and the list no
longer bounds how many rows there are -- not because a number here says
so.
Checked on the emulator against a real 1,200-event transcript: tapping a
collapsed row expands it with its top edge held exactly still, and a
scroll position comes back pixel-identical after leaving the session and
returning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ee1c493559 |
Hold every loaded row, and page by pixels rather than by rows
The transcript is a plain Column scrolled in reverse instead of a LazyColumn. Nothing about Compose was re-measuring text that had already been drawn -- a node that is still alive and whose constraints have not changed skips measurement outright, in MeasurePassDelegate.remeasure. What was throwing that away was disposal: a lazy list drops a row the moment it leaves the viewport, and the markdown tree, the measured lines and the cached paragraph go with it. Keeping the rows is the fix, and it is what a browser-based client does that we were not. Two scroll corrections go with it, each of which had a comment explaining a way it had been seen to fire at the wrong moment. The content now hangs from its newest end, so a page of older history extends the far end and moves nothing on screen, and an arriving message extends the end the viewport is already pinned to. Following the newest message is no longer an effect that notices and corrects; it is where the content is. The same goes for the keyboard opening, which was the case that used to get missed. Paging asks its question in pixels of scroll -- how far can the reader keep going before they run out -- which is what it was always about. Rows were the wrong unit twice: a fixed count of them is a distance only by accident, and counting screenfuls of rows fixed the size of that mistake without fixing its kind. A saved position is resolved to the row that now holds that seq before the layout is asked to put it back. The events behind a row regroup between the save and the reopen, so the seq that was a row's first is often no longer any row's first, and handing the layout the saved seq named a row that did not exist -- the position was never applied and the session opened at the newest end. Verified on the emulator against a real 1,200-event transcript: the position survives leaving and reopening, jump-to-latest arrives and stays followed, and the newest message holds its place against the composer as the keyboard opens and the draft grows. Scrolling measures the same as the lazy version did (2.6% vs 2.0% janky, p50 16ms, p90 21ms, no slow UI-thread frames either way) -- the cap and the off-thread parse had already taken that cost out, so this change is about what the list can no longer do wrong rather than about frames. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |