Draw a peer message a block at a time, and parse it off the drawing thread
A message from another agent was the one markdown in the app still rendered whole: one parse and one display list for the entire thing. Every settled reply has been cut into blocks since the transcript was made lazy, and `warm` has been making those parses ahead on a background thread -- but it filtered for assistant replies alone, so the longest message a transcript holds was also the only one parsed on the thread that draws. Measured on the emulator against a 43KB peer message, opening it: 177ms in `markdown parsed while composing`, against none afterwards and 156 blocks already ready. What is left is the card being a single list item, so all 156 blocks are still measured, placed and recorded at once -- 118ms of placement in that same frame. The `when` in `warm` is now the rule rather than a filter: every row that draws markdown belongs in it. Blocks are spaced by the transcript's own BLOCK_SPACING rather than the renderer's internal padding, which moves a heading about 6px (2.3dp) closer to the paragraph above it. The message's total height is unchanged, and it now matches every reply in the transcript. While here: FrameStats was remembered per session screen and DebugStats is a global emptied only by the copy button, so the two halves of a render report covered different stretches of time -- and `drawAccounting` divides one by the other. A report copied after visiting two sessions claimed 36.8 seconds of placement inside a 13.5 second window, and clamped "everything else" to 0.00ms (0%), which reads as a screen whose entire cost is this app's code. One FrameStats for the app, so both halves mean "since this was last copied". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6d12388063
commit
80aaf286c2
4 files changed
+51
-28
No files matched your search
@@ -10,7 +10,6 @@ import android.view.FrameMetrics
|
||||
import android.view.Window
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
/**
|
||||
@@ -26,8 +25,15 @@ import androidx.compose.ui.platform.LocalContext
|
||||
* The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds,
|
||||
* broken down into the parts the UI thread is responsible for -- handling input, running
|
||||
* animations, measuring and laying out, recording the draw -- and the parts after it.
|
||||
*
|
||||
* One of these for the app, like [DebugStats], because the two are read as one report and
|
||||
* [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session
|
||||
* and the counters were not, so a report copied after visiting two sessions divided every session's
|
||||
* work by the newest one's frame count -- and printed the result as a per-frame measurement. It
|
||||
* said 36.8 seconds of placement inside a 13.5 second window, and left "everything else" clamped at
|
||||
* 0.00ms (0%), which reads as a screen whose whole cost is this app's own code.
|
||||
*/
|
||||
class FrameStats {
|
||||
object FrameStats {
|
||||
private val total = ArrayList<Long>()
|
||||
private val waited = ArrayList<Long>()
|
||||
private val input = ArrayList<Long>()
|
||||
@@ -108,28 +114,28 @@ class FrameStats {
|
||||
}
|
||||
|
||||
private fun percent(part: Int, whole: Int) = "%.1f%%".format(100.0 * part / whole)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Enough for a couple of minutes of scrolling; this is a diagnostic, not a log. */
|
||||
const val CAP = 20_000
|
||||
}
|
||||
}
|
||||
private const val CAP = 20_000
|
||||
|
||||
/**
|
||||
* Frame timings for as long as this screen is on it.
|
||||
* Records into [FrameStats] for as long as this screen is on it.
|
||||
*
|
||||
* The listener is what comes and goes; what it writes into does not, so a report covers the same
|
||||
* stretch of time as the counters beside it. See [FrameStats].
|
||||
*
|
||||
* The listener is handed its own thread because the platform calls it for every frame and the
|
||||
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberFrameStats(): FrameStats {
|
||||
val stats = remember { FrameStats() }
|
||||
fun recordFrames() {
|
||||
val window = LocalContext.current.activity()?.window
|
||||
DisposableEffect(window) {
|
||||
if (window == null) return@DisposableEffect onDispose {}
|
||||
val thread = HandlerThread("frame-stats").apply { start() }
|
||||
val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ ->
|
||||
stats.add(metrics)
|
||||
FrameStats.add(metrics)
|
||||
}
|
||||
window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper))
|
||||
onDispose {
|
||||
@@ -137,7 +143,6 @@ fun rememberFrameStats(): FrameStats {
|
||||
thread.quitSafely()
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
/** The activity behind a composable's context, which is what owns the window. */
|
||||
|
||||
@@ -27,6 +27,10 @@ import androidx.compose.ui.unit.dp
|
||||
* Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a
|
||||
* transcript that puts it in their voice is making a claim about who asked for the work that
|
||||
* follows -- which is exactly the question a peer message is usually the answer to.
|
||||
*
|
||||
* Opened, it is drawn a block at a time ([BlockedMarkdown]) for the reason every reply already is:
|
||||
* these are the longest messages a transcript holds, and one of them as a single render is one
|
||||
* parse and one display list proportional to the whole of it. See [markdownBlocks].
|
||||
*/
|
||||
@Composable
|
||||
fun PeerMessageRow(
|
||||
@@ -52,7 +56,7 @@ fun PeerMessageRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (expanded) MarkdownText(item.text, replies, Modifier.padding(top = 6.dp))
|
||||
if (expanded) BlockedMarkdown(item.text, replies, Modifier.padding(top = 6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1049,7 +1049,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// One poll for this machine's limits, read by the two things that show them: the bar under
|
||||
// the header, and the colour of the button that opens the dialog.
|
||||
val usage = rememberSessionUsage(settings, summary.setup)
|
||||
val frames = rememberFrameStats()
|
||||
recordFrames()
|
||||
var usageOpen by remember { mutableStateOf(false) }
|
||||
var settingsOpen by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -1150,9 +1150,9 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
" ${expandedTools.size} tool calls and" +
|
||||
" ${expandedGroups.size} groups open",
|
||||
),
|
||||
frames = frames.lines(context.refreshHz()),
|
||||
frames = FrameStats.lines(context.refreshHz()),
|
||||
accounting =
|
||||
frames.drawPhase().let { (nanos, count) ->
|
||||
FrameStats.drawPhase().let { (nanos, count) ->
|
||||
drawAccounting(nanos, count)
|
||||
},
|
||||
crash = lastCrash(context),
|
||||
@@ -1170,7 +1170,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// Emptied by the copy, so pressing it twice measures two separate
|
||||
// stretches
|
||||
// of scrolling rather than one and then the same one again.
|
||||
frames.reset()
|
||||
FrameStats.reset()
|
||||
DebugStats.reset()
|
||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
|
||||
@@ -466,32 +466,46 @@ private fun updateTool(
|
||||
private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
|
||||
|
||||
/**
|
||||
* Parses the replies among [rows], off whatever thread is drawing.
|
||||
* Parses the markdown among [rows], off whatever thread is drawing.
|
||||
*
|
||||
* Called where a page of transcript is folded rather than where a row is composed, which is the
|
||||
* whole point: the work happens seconds before the reader reaches the rows it was done for. See
|
||||
* [ParsedReplies].
|
||||
*
|
||||
* What is warmed mirrors what the rows draw, unit by unit -- prose split into its blocks, a memory
|
||||
* note whole -- because a string warmed under a key no row ever looks up is a miss that nothing
|
||||
* reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same
|
||||
* [ParsedReplies.partsOf] and [ParsedReplies.blocksOf] caches the flatten does, so a message is
|
||||
* scanned once however many pages hand it back through here, while the whole loaded transcript
|
||||
* crosses this on every page.
|
||||
* note whole, a peer message split the same way prose is -- because a string warmed under a key no
|
||||
* row ever looks up is a miss that nothing reports; see [transcriptUnits], which is the flatten
|
||||
* this has to agree with. It reads the same [ParsedReplies.partsOf] and [ParsedReplies.blocksOf]
|
||||
* caches the flatten does, so a message is scanned once however many pages hand it back through
|
||||
* here, while the whole loaded transcript crosses this on every page.
|
||||
*
|
||||
* Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer
|
||||
* message was missing: this used to filter for assistant replies alone, so the one row type nobody
|
||||
* had thought about paid its whole parse in the frame it appeared in, with no counter saying which
|
||||
* row it was.
|
||||
*/
|
||||
suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
|
||||
withContext(parsingThreads) {
|
||||
val texts =
|
||||
rows
|
||||
.filterIsInstance<TranscriptItem.AssistantMsg>()
|
||||
.flatMap { replies.partsOf(it.text) }
|
||||
.flatMap { part ->
|
||||
val texts = rows.flatMap { row ->
|
||||
when (row) {
|
||||
is TranscriptItem.AssistantMsg ->
|
||||
replies.partsOf(row.text).flatMap { part ->
|
||||
when (part) {
|
||||
is MessagePart.Prose -> replies.blocksOf(part.text)
|
||||
// Drawn as one MarkdownText, so its whole text is the key looked up.
|
||||
// Drawn as one MarkdownText, so its whole text is the key
|
||||
// looked up.
|
||||
is MessagePart.Remembered -> listOf(part.text)
|
||||
}
|
||||
}
|
||||
// A message from another agent is markdown too, and it is the longest thing
|
||||
// in a transcript often enough that leaving it out was the whole of why one
|
||||
// cost a fifth of a second to open: it was the only markdown in the app
|
||||
// parsed on the thread that draws. Its blocks, not its text, because
|
||||
// [PeerMessageRow] draws it a block at a time.
|
||||
is TranscriptItem.PeerNote -> replies.blocksOf(row.text)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
if (texts.isNotEmpty()) replies.warm(texts)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user