diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index 12abca8..f9ae47b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -185,8 +185,16 @@ class ParsedReplies { parsed[text]?.also { DebugStats.count("markdown ready") } ?: DebugStats.timed("markdown parsed while composing") { parseMarkdown(text) } - /** Parses whatever is not held yet. Call off the composing thread; that is the whole point. */ - fun warm(texts: List) { + /** + * Parses whatever is not held yet. Call off the composing thread; that is the whole point. + * + * Suspending, and yielding between messages, because "off the composing thread" is not the same + * as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in + * a twelve second scroll, measured on a Pixel 9 Pro XL -- and on the default dispatcher that is + * every core busy, with the frame's own thread waiting for one. That showed up as 21ms of + * `waited` at the 90th percentile: the frame could not start, rather than taking too long. + */ + suspend fun warm(texts: List) { texts.forEach { text -> parsed.computeIfAbsent(text) { DebugStats.timed("markdown warmed") { parseMarkdown(it) } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 8f2880e..ab78a69 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -564,6 +564,18 @@ private fun updateTool( if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it } +/** + * Where markdown is parsed ahead of being drawn: two threads, never all of them. + * + * The default dispatcher sizes itself to the machine, which is right for work somebody is waiting + * on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and + * taking every core for them leaves the thread that draws the frame queueing behind one -- measured + * on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile, which is the frame failing to + * *start* rather than taking too long once it had. + */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +private val parsingThreads = Dispatchers.Default.limitedParallelism(2) + /** * Parses the replies among [rows], off whatever thread is drawing. * @@ -576,7 +588,7 @@ private suspend fun warm(replies: ParsedReplies, rows: List) { // [markdownIn] splits every assistant message looking for memory notes, and this is handed // the *whole* loaded transcript on every page, so the scan grows with the conversation while // the work it finds stays one page's worth. Off the calling thread it is nobody's frame. - withContext(Dispatchers.Default) { + withContext(parsingThreads) { val texts = rows .filterIsInstance()