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>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 02:20:03 -04:00
1 parent 3e653c4797
commit f9432b69cc
2 files changed
+76 -3

No files matched your search

@@ -29,6 +29,9 @@ import androidx.compose.ui.platform.LocalContext
*/ */
class FrameStats { class FrameStats {
private val total = ArrayList<Long>() private val total = ArrayList<Long>()
private val waited = ArrayList<Long>()
private val input = ArrayList<Long>()
private val animation = ArrayList<Long>()
private val layout = ArrayList<Long>() private val layout = ArrayList<Long>()
private val draw = ArrayList<Long>() private val draw = ArrayList<Long>()
private val sync = ArrayList<Long>() private val sync = ArrayList<Long>()
@@ -43,6 +46,12 @@ class FrameStats {
if (metrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME) == 1L) return if (metrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME) == 1L) return
if (total.size >= CAP) return if (total.size >= CAP) return
total += metrics.getMetric(FrameMetrics.TOTAL_DURATION) total += metrics.getMetric(FrameMetrics.TOTAL_DURATION)
// How long the frame waited for the UI thread to be free before it could start. Reported
// because the phases otherwise do not add up to the total, and the gap is the interesting
// part: it is the frame being held up by work that is not the frame's.
waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)
input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)
animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION)
layout += metrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) layout += metrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION)
draw += metrics.getMetric(FrameMetrics.DRAW_DURATION) draw += metrics.getMetric(FrameMetrics.DRAW_DURATION)
sync += metrics.getMetric(FrameMetrics.SYNC_DURATION) sync += metrics.getMetric(FrameMetrics.SYNC_DURATION)
@@ -55,7 +64,9 @@ class FrameStats {
@Synchronized @Synchronized
fun reset() { fun reset() {
listOf(total, layout, draw, sync, issue, swap, gpu).forEach { it.clear() } listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
it.clear()
}
since = System.currentTimeMillis() since = System.currentTimeMillis()
} }
@@ -71,6 +82,9 @@ class FrameStats {
" late: $late (${percent(late, total.size)})" + " late: $late (${percent(late, total.size)})" +
if (total.size >= CAP) " [capped]" else "", if (total.size >= CAP) " [capped]" else "",
phase("total ", total), phase("total ", total),
phase("waited", waited),
phase("input ", input),
phase("anim ", animation),
phase("layout", layout), phase("layout", layout),
phase("draw ", draw), phase("draw ", draw),
phase("sync ", sync), phase("sync ", sync),
@@ -20,6 +20,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.layout.onPlaced
@@ -78,14 +79,67 @@ class TranscriptScroll(internal val scroll: ScrollState) {
private var spacing = 0 private var spacing = 0
private var padTop = 0 private var padTop = 0
/** Each row's top edge, added up from the heights before it; see [refreshTops]. */
private var tops = IntArray(0)
private var rowIndex = HashMap<Long, Int>()
private var topsStale = true
internal fun laidOut(order: List<Long>, spacing: Int, padTop: Int) { internal fun laidOut(order: List<Long>, spacing: Int, padTop: Int) {
this.order = order this.order = order
this.spacing = spacing this.spacing = spacing
this.padTop = padTop this.padTop = padTop
rowIndex = HashMap(order.size)
order.forEachIndexed { index, seq -> rowIndex[seq] = index }
topsStale = true
} }
internal fun height(seq: Long, height: Int) { internal fun height(seq: Long, height: Int) {
heights[seq] = height if (heights.put(seq, height) != height) topsStale = true
}
/**
* The running total of every row's top edge, recomputed at most once per frame and only after
* something has actually changed height.
*
* Adding it up per row per lookup would be quadratic, and the lookup happens once per row per
* frame -- so on a long transcript the thing meant to save the frame would have been the thing
* costing it.
*/
private fun refreshTops() {
if (!topsStale) return
val out = IntArray(order.size)
var y = padTop
order.forEachIndexed { index, seq ->
out[index] = y
y += (heights[seq] ?: 0) + spacing
}
tops = out
topsStale = false
}
/**
* Whether the row named by [seq] is close enough to the viewport to be worth drawing.
*
* Every row stays composed and measured -- that is what stops a message being rebuilt when
* somebody scrolls back to it, and it is why layout costs nothing here. Drawing is the other
* half and does not work the same way: the draw pass walks the whole tree, so with the rows
* retained it was recording a display list for all of them on every frame. Measured on a Pixel
* 9 Pro XL with 138 rows loaded: 13.6ms of draw at the median against an 8.3ms budget, with the
* GPU itself at 1.9ms. The work was ours and it was all in issuing draw commands for a
* transcript that was almost entirely off screen.
*
* A screen of margin either side, so a row is already drawn by the time it is reached and
* nothing appears mid-fling.
*/
fun onScreen(seq: Long): Boolean {
val index = rowIndex[seq] ?: return true
val height = heights[seq] ?: return true
refreshTops()
val top = tops.getOrNull(index) ?: return true
val viewportTop = scroll.maxValue - scroll.value
val margin = scroll.viewportSize
return top + height >= viewportTop - margin &&
top <= viewportTop + scroll.viewportSize + margin
} }
/** Where the last touch went down, in the content's own coordinates. */ /** Where the last touch went down, in the content's own coordinates. */
@@ -289,7 +343,12 @@ fun TranscriptColumn(
// tool call, stays with the row rather than with the position. // tool call, stays with the row rather than with the position.
key(item.key) { key(item.key) {
Column( Column(
Modifier.fillMaxWidth().onSizeChanged { state.height(item.startSeq, it.height) } Modifier.fillMaxWidth()
.onSizeChanged { state.height(item.startSeq, it.height) }
// Composed and measured whether or not it is drawn; see
// [TranscriptScroll.onScreen]. The check reads the scroll position from
// the draw phase, so moving the list invalidates drawing and nothing else.
.drawWithContent { if (state.onScreen(item.startSeq)) drawContent() }
) { ) {
row(item) row(item)
} }