Compare commits
2
Commits
3b80a88f3b
...
5b2ca039f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b2ca039f1 | ||
|
|
a8d24553d5 |
No files matched your search
@@ -3,9 +3,13 @@ package com.example.aiapp
|
||||
import android.content.Context
|
||||
import android.os.BatteryManager
|
||||
import android.os.Process
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -19,27 +23,84 @@ import kotlinx.coroutines.launch
|
||||
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
|
||||
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
|
||||
* [BenchFixture]'s doc comment gives.
|
||||
*
|
||||
* **v2 (2026-09-06)**, asked for by Iris because the v1 fling was too gentle to stress-test the
|
||||
* scroll path and said nothing about typing or the keyboard. Four phases now, each a slice of the
|
||||
* same [FrameStats] recording ([FrameStats.markPhase]/[FrameStats.phaseLines] -- one recorder, not
|
||||
* two): **fling** (real `FlingBehavior`, not `animateScrollBy`), **stream** (unchanged from v1),
|
||||
* **type** (600 fixed characters into the real composer `TextFieldValue`, then deleted), and
|
||||
* **keyboard** (five show/hide cycles). The exact constants below are also written into
|
||||
* `docs/RUST.md`'s P0 box, "Benchmark v2 (2026-09-06)", so the iris half implements the identical
|
||||
* spec -- changing a number here without updating that box makes the two apps measure different
|
||||
* things while looking like the same benchmark.
|
||||
*/
|
||||
object BenchRun {
|
||||
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, 900px over 200ms, 500ms apart. */
|
||||
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, kept as the pre-v2 comparison. */
|
||||
private const val CYCLES = 6
|
||||
private const val SWIPE_PX = 900f
|
||||
private const val SWIPE_MS = 200
|
||||
private const val SWIPE_PAUSE_MS = 500L
|
||||
|
||||
/**
|
||||
* Fling phase (v2): a real fling through the list's own [FlingBehavior], not `animateScrollBy`
|
||||
* -- Iris's ask was that it "travel way faster" than the old tween-based swipe, and a tween can
|
||||
* never exceed the distance it is told to cover in the time it is given, while a real fling
|
||||
* decays from an initial velocity the way a finger flick does. 12,000 px/s is roughly a hard,
|
||||
* fast flick on a ~420dp/in device (about 30 dp/ms-equivalent initial speed); chosen well above
|
||||
* the ~4,500 px/s a moderate `animateScrollBy` swipe implies, so this phase exercises the fast
|
||||
* end of what the platform's fling decay produces rather than the gentle one v1 measured.
|
||||
*/
|
||||
private const val FLING_VELOCITY_PX_S = 12_000f
|
||||
|
||||
private const val FLING_COUNT = 8
|
||||
private const val FLING_SETTLE_CAP_MS = 3_000L
|
||||
private const val FLING_PAUSE_MS = 300L
|
||||
|
||||
/** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */
|
||||
private const val STREAM_EVENTS_PER_SEC = 20
|
||||
private const val STREAM_SECONDS = 20
|
||||
|
||||
/**
|
||||
* Scrolls, then streams, then returns the extra report lines P0 asked for (CPU time, peak RSS,
|
||||
* battery current) -- [FrameStats] and [DebugStats] are reset first, exactly as
|
||||
* `copyRenderReport` resets them, so the two accountings cover the same stretch of work.
|
||||
* Type phase (v2): sentences built from long, multisyllabic words so the composer actually
|
||||
* wraps across lines rather than fitting one, and long enough (600 chars) that the composer's
|
||||
* own height grows over several frames, pushing the transcript above it upward the same way a
|
||||
* real long message does. Exactly this string is also in `docs/RUST.md`'s P0 box so the iris
|
||||
* half types the identical content.
|
||||
*/
|
||||
const val TYPE_TEXT =
|
||||
"Benchmarking this transcript screen requires unusually long, multisyllabic words so " +
|
||||
"wrapping and reflow are properly exercised: internationalization, " +
|
||||
"counterproductiveness, disproportionately, incomprehensibility, " +
|
||||
"deinstitutionalization, uncharacteristically, overenthusiastically, " +
|
||||
"misunderstanding, straightforwardness, telecommunications, and interdisciplinary " +
|
||||
"collaboration all push a narrow composer field to wrap across several lines while " +
|
||||
"the transcript above is pushed upward by the growing keyboard-adjacent box, which " +
|
||||
"is exactly what a real reader typing a long message sees happening now!!!"
|
||||
|
||||
private const val TYPE_CHAR_DELAY_MS = 50L
|
||||
|
||||
/**
|
||||
* Keyboard phase (v2): five show/hide cycles, a second apart, is enough to see whether the
|
||||
* transition is ever actually observed rather than being a one-off fluke either way.
|
||||
*/
|
||||
private const val KEYBOARD_CYCLES = 5
|
||||
private const val KEYBOARD_SHOW_WAIT_MS = 1_000L
|
||||
private const val KEYBOARD_HIDE_WAIT_MS = 1_000L
|
||||
|
||||
/**
|
||||
* Scrolls, flings, streams, types and toggles the keyboard, then returns the extra report lines
|
||||
* P0 asked for (per-phase travel/typing/keyboard counts, plus CPU time, peak RSS, battery
|
||||
* current) -- [FrameStats] and [DebugStats] are reset first, exactly as `copyRenderReport`
|
||||
* resets them, so the two accountings cover the same stretch of work.
|
||||
*/
|
||||
suspend fun run(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
listState: LazyListState,
|
||||
flingBehavior: FlingBehavior,
|
||||
composerFocus: FocusRequester,
|
||||
setComposerText: (String) -> Unit,
|
||||
view: View,
|
||||
): List<String> {
|
||||
FrameStats.reset()
|
||||
DebugStats.reset()
|
||||
@@ -55,34 +116,10 @@ object BenchRun {
|
||||
}
|
||||
}
|
||||
|
||||
// The swipe loop: transcript-bench.sh's four swipes per cycle are two drags toward newer
|
||||
// content and two back, so a cycle returns to where it started and the whole loop measures
|
||||
// steady-state scrolling rather than travelling somewhere new each time.
|
||||
repeat(CYCLES) {
|
||||
repeat(2) {
|
||||
listState.animateScrollBy(SWIPE_PX, tween(SWIPE_MS))
|
||||
delay(SWIPE_PAUSE_MS)
|
||||
}
|
||||
repeat(2) {
|
||||
listState.animateScrollBy(-SWIPE_PX, tween(SWIPE_MS))
|
||||
delay(SWIPE_PAUSE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned to the newest end before streaming starts, the way stream-bench.sh's "Jump to
|
||||
// latest" tap is -- a reply streamed into a list parked further back arrives off-screen and
|
||||
// the report would show nothing happened.
|
||||
listState.scrollToItem(0)
|
||||
|
||||
var sent = 0
|
||||
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
|
||||
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
|
||||
BenchFixture.pushNextLiveEvent()
|
||||
sent++
|
||||
delay(1000L / STREAM_EVENTS_PER_SEC)
|
||||
}
|
||||
// Lets the last few deltas land and draw before the report is read.
|
||||
delay(300)
|
||||
val travel = runFlingPhase(listState, flingBehavior)
|
||||
val sent = runStreamPhase()
|
||||
runTypePhase(listState, composerFocus, setComposerText, view)
|
||||
val keyboard = runKeyboardPhase(context, view)
|
||||
|
||||
samplerJob.cancel()
|
||||
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
|
||||
@@ -90,13 +127,158 @@ object BenchRun {
|
||||
val batteryLine = battery.finish()
|
||||
|
||||
return listOf(
|
||||
" scroll: $CYCLES cycles (${CYCLES * 4} swipes), streamed $sent/$total fixture events",
|
||||
" fling: $FLING_COUNT flings out + $FLING_COUNT back at" +
|
||||
" ${FLING_VELOCITY_PX_S.toInt()}px/s, travel $travel",
|
||||
" scroll: $CYCLES cycles (${CYCLES * 4} swipes, legacy tween), " +
|
||||
"streamed $sent/${STREAM_EVENTS_PER_SEC * STREAM_SECONDS} fixture events",
|
||||
" type: ${TYPE_TEXT.length} characters inserted then deleted, one per" +
|
||||
" ${TYPE_CHAR_DELAY_MS}ms",
|
||||
keyboard,
|
||||
" process CPU time over this run: ${cpuMs}ms",
|
||||
rssLine,
|
||||
batteryLine,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: starting pinned at the newest end, [FLING_COUNT] flings away from it (toward older
|
||||
* messages) through the list's real fling path, then [FLING_COUNT] back. Positive velocity here
|
||||
* matches this list's existing scroll-offset convention (`TranscriptList`'s `reverseLayout`
|
||||
* pins index 0 -- the newest item -- at the bottom; a positive scroll offset moves the viewport
|
||||
* toward higher indices, i.e. away from the newest end and toward older content), the same sign
|
||||
* the pre-v2 swipe loop below already used for its first two swipes.
|
||||
*/
|
||||
private suspend fun runFlingPhase(
|
||||
listState: LazyListState,
|
||||
flingBehavior: FlingBehavior,
|
||||
): String {
|
||||
FrameStats.markPhase("fling")
|
||||
listState.scrollToItem(0)
|
||||
val start = position(listState)
|
||||
repeat(FLING_COUNT) {
|
||||
listState.scroll { with(flingBehavior) { performFling(FLING_VELOCITY_PX_S) } }
|
||||
waitForSettle(listState)
|
||||
delay(FLING_PAUSE_MS)
|
||||
}
|
||||
val outward = position(listState)
|
||||
repeat(FLING_COUNT) {
|
||||
listState.scroll { with(flingBehavior) { performFling(-FLING_VELOCITY_PX_S) } }
|
||||
waitForSettle(listState)
|
||||
delay(FLING_PAUSE_MS)
|
||||
}
|
||||
val back = position(listState)
|
||||
return "start=$start outward=$outward end=$back"
|
||||
}
|
||||
|
||||
private fun position(listState: LazyListState) =
|
||||
"idx=${listState.firstVisibleItemIndex}/off=${listState.firstVisibleItemScrollOffset}px"
|
||||
|
||||
/** Belt-and-suspenders on top of `performFling` already suspending until its own decay ends. */
|
||||
private suspend fun waitForSettle(listState: LazyListState) {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
while (
|
||||
listState.isScrollInProgress &&
|
||||
System.currentTimeMillis() - startedAt < FLING_SETTLE_CAP_MS
|
||||
) {
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 (unchanged from v1): pinned to the newest end before streaming starts, the way
|
||||
* stream-bench.sh's "Jump to latest" tap is -- a reply streamed into a list parked further back
|
||||
* arrives off-screen and the report would show nothing happened.
|
||||
*/
|
||||
private suspend fun runStreamPhase(): Int {
|
||||
FrameStats.markPhase("stream")
|
||||
var sent = 0
|
||||
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
|
||||
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
|
||||
BenchFixture.pushNextLiveEvent()
|
||||
sent++
|
||||
delay(1000L / STREAM_EVENTS_PER_SEC)
|
||||
}
|
||||
// Lets the last few deltas land and draw before the next phase starts.
|
||||
delay(300)
|
||||
return sent
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3: focuses the real composer, shows the keyboard if the platform allows it, then types
|
||||
* [TYPE_TEXT] one character at a time through the same `TextFieldValue` state a real keystroke
|
||||
* updates, and deletes it the same way -- this is what exercises wrapping and the transcript
|
||||
* being pushed upward, not a single big write.
|
||||
*/
|
||||
private suspend fun runTypePhase(
|
||||
listState: LazyListState,
|
||||
composerFocus: FocusRequester,
|
||||
setComposerText: (String) -> Unit,
|
||||
view: View,
|
||||
) {
|
||||
FrameStats.markPhase("type")
|
||||
listState.scrollToItem(0)
|
||||
composerFocus.requestFocus()
|
||||
showIme(view.context, view)
|
||||
// Lets focus and the keyboard's opening animation land before typing starts, so the frames
|
||||
// this phase records are the wrap/reflow it is measuring, not the keyboard opening.
|
||||
delay(300)
|
||||
var typed = ""
|
||||
for (ch in TYPE_TEXT) {
|
||||
typed += ch
|
||||
setComposerText(typed)
|
||||
delay(TYPE_CHAR_DELAY_MS)
|
||||
}
|
||||
delay(200)
|
||||
while (typed.isNotEmpty()) {
|
||||
typed = typed.dropLast(1)
|
||||
setComposerText(typed)
|
||||
delay(TYPE_CHAR_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4: [KEYBOARD_CYCLES] show/hide cycles through the same [WindowInsetsControllerCompat]
|
||||
* path a real IME toggle goes through, reporting how many of each were actually confirmed by
|
||||
* [android.view.WindowInsets.isVisible] rather than assumed from having asked -- UI_RULES:
|
||||
* never present an inferred value as a measured one. If the platform never shows it even once,
|
||||
* this says so in words rather than reporting a phase with no keyboard in it.
|
||||
*/
|
||||
private suspend fun runKeyboardPhase(context: Context, view: View): String {
|
||||
FrameStats.markPhase("keyboard")
|
||||
var shown = 0
|
||||
var hidden = 0
|
||||
repeat(KEYBOARD_CYCLES) {
|
||||
showIme(context, view)
|
||||
delay(KEYBOARD_SHOW_WAIT_MS)
|
||||
if (imeVisible(view)) shown++
|
||||
hideIme(context, view)
|
||||
delay(KEYBOARD_HIDE_WAIT_MS)
|
||||
if (!imeVisible(view)) hidden++
|
||||
}
|
||||
return if (shown == 0) {
|
||||
" keyboard: could not be shown ($KEYBOARD_CYCLES attempts, 0 confirmed visible)"
|
||||
} else {
|
||||
" keyboard: shown $shown/$KEYBOARD_CYCLES, hidden $hidden/$KEYBOARD_CYCLES" +
|
||||
" (confirmed via isImeVisible)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun controller(context: Context, view: View): WindowInsetsControllerCompat? {
|
||||
val window = context.activity()?.window ?: return null
|
||||
return WindowInsetsControllerCompat(window, view)
|
||||
}
|
||||
|
||||
private fun showIme(context: Context, view: View) {
|
||||
controller(context, view)?.show(WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
|
||||
private fun hideIme(context: Context, view: View) {
|
||||
controller(context, view)?.hide(WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
|
||||
private fun imeVisible(view: View): Boolean =
|
||||
ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
|
||||
|
||||
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
|
||||
private fun peakRssLine(): String {
|
||||
val kb =
|
||||
|
||||
@@ -134,6 +134,13 @@ fun debugReport(
|
||||
* render-report button reads exactly as it did before this existed.
|
||||
*/
|
||||
extra: List<String> = emptyList(),
|
||||
/**
|
||||
* Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) --
|
||||
* fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below
|
||||
* still cover in full. Empty on every path but the scripted bench run, same reasoning as
|
||||
* [extra].
|
||||
*/
|
||||
phaseFrames: List<String> = emptyList(),
|
||||
): String = buildString {
|
||||
appendLine("ai-app render report")
|
||||
appendLine(device)
|
||||
@@ -148,6 +155,11 @@ fun debugReport(
|
||||
appendLine("transcript:")
|
||||
transcript.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
if (phaseFrames.isNotEmpty()) {
|
||||
appendLine("per phase:")
|
||||
phaseFrames.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
}
|
||||
appendLine("frames:")
|
||||
frames.forEach { appendLine(it) }
|
||||
appendLine()
|
||||
|
||||
@@ -42,6 +42,21 @@ object FrameStats {
|
||||
private val gpu = ArrayList<Long>()
|
||||
private var since = System.currentTimeMillis()
|
||||
|
||||
/**
|
||||
* Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an
|
||||
* index into [total] and a wall-clock time -- not a second recorder, just a mark on this one,
|
||||
* so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced.
|
||||
*/
|
||||
private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long)
|
||||
|
||||
private val phaseMarks = ArrayList<PhaseMark>()
|
||||
|
||||
/** Call at the start of each named phase of a scripted run; see [BenchRun]. */
|
||||
@Synchronized
|
||||
fun markPhase(name: String) {
|
||||
phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun add(metrics: FrameMetrics) {
|
||||
// The first frame after a window opens includes inflating it and is nobody's scroll.
|
||||
@@ -69,6 +84,7 @@ object FrameStats {
|
||||
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
|
||||
it.clear()
|
||||
}
|
||||
phaseMarks.clear()
|
||||
since = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
@@ -95,6 +111,38 @@ object FrameStats {
|
||||
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
|
||||
}
|
||||
|
||||
/**
|
||||
* One block per [markPhase] call: how many frames landed between that mark and the next (or the
|
||||
* end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst
|
||||
* single frame, and how long the phase actually ran. Marks with no frames between them (a phase
|
||||
* that finished before a frame was drawn) still get a line rather than being silently dropped
|
||||
* -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number.
|
||||
*/
|
||||
@Synchronized
|
||||
fun phaseLines(refreshHz: Float): List<String> {
|
||||
if (phaseMarks.isEmpty()) return emptyList()
|
||||
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
|
||||
val lines = ArrayList<String>()
|
||||
phaseMarks.forEachIndexed { i, mark ->
|
||||
val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size
|
||||
val endMs =
|
||||
if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs
|
||||
else System.currentTimeMillis()
|
||||
val samples = total.subList(mark.startIndex, endIndex)
|
||||
val seconds = (endMs - mark.startMs) / 1000.0
|
||||
lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s"
|
||||
if (samples.isEmpty()) {
|
||||
lines += " no frames recorded in this phase"
|
||||
} else {
|
||||
val late = samples.count { it / 1_000_000.0 > budget }
|
||||
lines += " late: $late (${percent(late, samples.size)})"
|
||||
lines += " " + phase("total ", samples)
|
||||
lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}"
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** How long the frames recorded here spent in their draw phase, and how many there were. */
|
||||
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
|
||||
|
||||
|
||||
@@ -187,13 +187,20 @@ class MainActivity : ComponentActivity() {
|
||||
model = null,
|
||||
keepsOwnTranscript = false,
|
||||
permissionMode = null,
|
||||
effort = null,
|
||||
takesEffort = false,
|
||||
imported = false,
|
||||
notify = false,
|
||||
autoResume = false,
|
||||
autoResumeMessage = "",
|
||||
resumeAt = null,
|
||||
cwd = null,
|
||||
contextTokens = null,
|
||||
maxImageEdge = null,
|
||||
usageProvider = null,
|
||||
status = "idle",
|
||||
lastActivity = 0.0,
|
||||
subagents = 0,
|
||||
)
|
||||
|
||||
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -64,12 +65,15 @@ import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextRange
|
||||
@@ -354,6 +358,17 @@ fun SessionScreen(
|
||||
// `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
|
||||
// fight over the first frame.
|
||||
val listState = remember(address) { LazyListState() }
|
||||
// The list's own fling path -- what a real flick decays through -- captured here so BenchRun's
|
||||
// fling phase can drive `LazyListState.scroll` through exactly the `FlingBehavior` this
|
||||
// screen's
|
||||
// `TranscriptList` already uses by not overriding it (its `LazyColumn` takes no `flingBehavior`
|
||||
// argument, so this is the same default it gets).
|
||||
val flingBehavior = ScrollableDefaults.flingBehavior()
|
||||
// Where BenchRun's type phase focuses before it types, and the view it toggles the keyboard on
|
||||
// -- both bench-only, but cheap enough (a remembered object, a CompositionLocal read) to hold
|
||||
// unconditionally rather than behind a second code path only the bench build compiles.
|
||||
val composerFocus = remember { FocusRequester() }
|
||||
val view = LocalView.current
|
||||
// Whether the newest message is on screen right now. The list is reversed, so the newest end is
|
||||
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
|
||||
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
|
||||
@@ -1256,6 +1271,10 @@ fun SessionScreen(
|
||||
FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) },
|
||||
crash = lastCrash(context),
|
||||
extra = extra,
|
||||
// Empty outside a BenchRun.run pass -- copyRenderReport's own reset below clears
|
||||
// the
|
||||
// marks along with everything else, so an ordinary copy never has any to show.
|
||||
phaseFrames = FrameStats.phaseLines(context.refreshHz()),
|
||||
)
|
||||
context.copyToClipboard("ai-app render report", report)
|
||||
// Also to the log, so a session driving the app over adb can read the same report the
|
||||
@@ -1270,15 +1289,24 @@ fun SessionScreen(
|
||||
Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
val copyRenderReport = { buildAndCopyReport() }
|
||||
// Bench build only: P0's scripted scroll-and-stream benchmark (BenchRun.kt), against the
|
||||
// fixture session opened below instead of a real server. Null everywhere else -- see
|
||||
// Bench build only: P0's scripted fling/stream/type/keyboard benchmark (BenchRun.kt), against
|
||||
// the fixture session opened below instead of a real server. Null everywhere else -- see
|
||||
// [SessionSettingsDialog]'s onRunBenchmark.
|
||||
val runBenchmark: (() -> Unit)? =
|
||||
if (BuildConfig.FIXTURE_MODE) {
|
||||
{
|
||||
settingsOpen = false
|
||||
scope.launch {
|
||||
val extra = BenchRun.run(context, scope, listState)
|
||||
val extra =
|
||||
BenchRun.run(
|
||||
context = context,
|
||||
scope = scope,
|
||||
listState = listState,
|
||||
flingBehavior = flingBehavior,
|
||||
composerFocus = composerFocus,
|
||||
setComposerText = { text -> input = atEnd(text) },
|
||||
view = view,
|
||||
)
|
||||
buildAndCopyReport(extra)
|
||||
}
|
||||
}
|
||||
@@ -1777,7 +1805,10 @@ fun SessionScreen(
|
||||
input = it
|
||||
saveDraft(context, summary.id, it.text)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
// BenchRun's type phase requests focus on this exact field
|
||||
// (`composerFocus`)
|
||||
// so it types through the real composer rather than a stand-in.
|
||||
modifier = Modifier.fillMaxWidth().focusRequester(composerFocus),
|
||||
// No longer "(+image)": the images are on screen above this, and a
|
||||
// placeholder saying so said it in words beside the thing itself.
|
||||
placeholder = { Text("Message") },
|
||||
|
||||
+180
@@ -4135,6 +4135,186 @@ device.
|
||||
back with a diagnostic that will say the real story on the next
|
||||
run either way.
|
||||
|
||||
**Benchmark v2 (2026-09-06), asked for by Iris after using the
|
||||
Compose build on her phone**: "it doesn't fling like I typically do
|
||||
when scrolling up to find old messages. It should travel way faster
|
||||
which is better for stress testing. You may also want to add typing
|
||||
in the textbox as well and seeing how performant wrapping & pushing
|
||||
the transcript up are, and also keyboard performance if possible."
|
||||
**This is the one spec** -- written once here so both apps' "Run
|
||||
benchmark" implement the identical four phases; a change to a
|
||||
constant below has to be made in both `app/`'s `BenchRun.kt` and
|
||||
`iris/`'s bench client, together, or the two reports stop measuring
|
||||
the same thing while still looking like they do.
|
||||
|
||||
1. **fling.** Starting pinned at the newest end
|
||||
(`listState.scrollToItem(0)` / iris's equivalent), 8 flings away
|
||||
from it (toward older messages) through the list's own real fling
|
||||
path -- Compose: `LazyListState.scroll { with(flingBehavior) {
|
||||
performFling(velocity) } }` using the screen's actual
|
||||
`FlingBehavior` (`ScrollableDefaults.flingBehavior()`, since
|
||||
`TranscriptList`'s `LazyColumn` never overrides it -- **not**
|
||||
`animateScrollBy`, which can only ever cover the fixed distance
|
||||
and time it is given and was Iris's complaint) -- each fling's
|
||||
`initialVelocity = 12,000 px/s`. That number is well above a
|
||||
moderate tween-swipe's implied speed (v1's `SWIPE_PX`/`SWIPE_MS`
|
||||
is roughly 4,500 px/s) and is meant to be a hard, fast flick for
|
||||
stress-testing, per Iris's ask. After each fling, wait for
|
||||
`isScrollInProgress` to clear (cap 3s; `performFling` already
|
||||
suspends until its own decay ends, this is belt-and-suspenders)
|
||||
plus 300ms between flings. Then 8 more flings back toward the
|
||||
newest end (`-12,000 px/s`). Record the list's first visible
|
||||
index/offset at the start, after the 8 outward flings, and at the
|
||||
end, so the two apps' *travel* can be compared directly rather
|
||||
than just their frame times.
|
||||
2. **stream. Unchanged from v1**: 400 tail events at 20/s (20
|
||||
seconds), pinned to the newest end before it starts (the same
|
||||
"Jump to latest" pin `stream-bench.sh` does).
|
||||
3. **type.** Pin to the newest end, focus the composer, show the IME
|
||||
if the platform allows it, then insert this **exact 600-character
|
||||
string** one character per 50ms through the composer's real
|
||||
`TextFieldValue` state (Compose: the same `input` state
|
||||
`onValueChange` writes; iris: whatever holds the composer's text
|
||||
today), then delete it the same way, one character per 50ms.
|
||||
Chosen for long, multisyllabic words specifically so the composer
|
||||
wraps across lines and the transcript above it is pushed upward
|
||||
by a growing box, which is what Iris asked to see measured:
|
||||
|
||||
Benchmarking this transcript screen requires unusually long, multisyllabic words so wrapping and reflow are properly exercised: internationalization, counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, telecommunications, and interdisciplinary collaboration all push a narrow composer field to wrap across several lines while the transcript above is pushed upward by the growing keyboard-adjacent box, which is exactly what a real reader typing a long message sees happening now!!!
|
||||
|
||||
Report whether the IME was actually open during typing (it should
|
||||
be, from this phase's own show-IME step -- see phase 4 for what
|
||||
to say if the platform refuses to show it at all).
|
||||
4. **keyboard.** Show the IME (`WindowInsetsControllerCompat.show
|
||||
(WindowInsetsCompat.Type.ime())` against the window/view; iris's
|
||||
equivalent through its own shell), wait 1s, hide it, wait 1s;
|
||||
five cycles. Confirm each show/hide with the platform's own
|
||||
answer (Compose: `ViewCompat.getRootWindowInsets(view)
|
||||
?.isVisible(WindowInsetsCompat.Type.ime())`, i.e. the same
|
||||
`WindowInsets.isImeVisible` fact `SessionScreen`'s composer-inset
|
||||
bug fix already relies on) rather than assuming the request
|
||||
worked -- if it is never confirmed shown even once, the report's
|
||||
`keyboard:` line has to say "**keyboard: could not be shown**"
|
||||
in words (UI_RULES: never present an inferred value as a
|
||||
measured one, and design the unknown/failed state before the
|
||||
answer's).
|
||||
|
||||
**Frame accounting**: one recorder, not two. Mark each phase's start
|
||||
in the existing per-frame recorder (Compose: `FrameStats.markPhase
|
||||
(name)`, a list of `(name, frameIndexAtStart, wallClockAtStart)`
|
||||
alongside the existing `total`/`waited`/... arrays) and slice the
|
||||
same `FrameMetrics` samples by phase afterward
|
||||
(`FrameStats.phaseLines`) rather than running a second listener.
|
||||
|
||||
**Report shape**: a `per phase:` block appears once any phase marks
|
||||
exist (empty/absent on an ordinary "Copy" press, which never marks a
|
||||
phase), one entry per phase: frame count, the phase's wall-clock
|
||||
duration, late count/percent (against the same refresh-rate budget
|
||||
the whole-run section uses), p50/p90/p99, and the worst single
|
||||
frame. Then **every existing whole-run section stays, unchanged in
|
||||
shape** -- `frames:`, `where the draw phase went:`, `work since this
|
||||
was last copied:` -- because that is what the emulator-baseline and
|
||||
phone-baseline numbers already on record in this file were read
|
||||
against, and a report that dropped or renamed one of those lines
|
||||
would silently stop being comparable to them. Finally `bench:` gains
|
||||
new lines beside the existing CPU/RSS/battery ones: the fling
|
||||
phase's total travel (start/outward/end index+offset), the typed
|
||||
character count, and the keyboard phase's shown/hidden-confirmed
|
||||
counts (or the "could not be shown" line).
|
||||
|
||||
**Compose half: done, 2026-09-06.** `FrameStats.markPhase`/
|
||||
`phaseLines` (`app/androidApp/src/main/kotlin/com/example/aiapp/
|
||||
FrameStats.kt`), `debugReport`'s new `phaseFrames` parameter
|
||||
(`DebugStats.kt`), and `BenchRun.kt`'s four-phase `run` (fling via
|
||||
`ScrollableDefaults.flingBehavior()` captured in `SessionScreen` and
|
||||
passed down since it needs a `@Composable` call site; type via a new
|
||||
`composerFocus: FocusRequester` attached to the composer's
|
||||
`OutlinedTextField` plus a `setComposerText` callback that writes
|
||||
`input` the same way a keystroke does; keyboard via
|
||||
`WindowInsetsControllerCompat` against `LocalView.current`) are all
|
||||
in. `BenchRun.TYPE_TEXT` is the exact 600-character constant quoted
|
||||
above (verified `.length == 600`). A pre-existing, unrelated break
|
||||
in `MainActivity.kt`'s `benchSessionSummary()` (missing several
|
||||
`SessionSummary` constructor arguments added by a change this pass
|
||||
did not otherwise touch -- confirmed pre-existing by reproducing the
|
||||
same compile failure after stashing this pass's own diff) was fixed
|
||||
alongside this, since it blocked `compileBenchKotlin` outright and
|
||||
is in this session's own `app/` scope.
|
||||
|
||||
Checks all clean: `ktfmtFormat`, `compileDebugKotlin`,
|
||||
`compileBenchKotlin`, `lintDebug`, `lintBench` (both "No issues
|
||||
found"), `testDebugUnitTest`. `grep -n "tap [0-9]" app/*.sh` still
|
||||
has its one pre-existing, unrelated hit.
|
||||
|
||||
**Compose bench v2, emulator smoke run, 2026-09-05** (this
|
||||
checkout's AVD, cold `emu up`, `ui-trace` tap-by-label throughout --
|
||||
the dialog needed a swipe to reach "Run benchmark" below the fold,
|
||||
report read back over `adb logcat`):
|
||||
|
||||
ai-app render report
|
||||
device: sdk_gphone64_x86_64 (Google), Android 16
|
||||
build: release
|
||||
|
||||
transcript:
|
||||
124 events, 26 rows, 58 units loaded
|
||||
viewport 1714px, 2 units visible
|
||||
on screen: the list's own 0px, AssistantMsg 18732px
|
||||
0 tool calls and 0 groups open
|
||||
|
||||
per phase:
|
||||
fling: 1620 frames over 32.3s
|
||||
late: 1537 (94.9%)
|
||||
total p50 20.5ms p90 29.2ms p99 45.9ms
|
||||
worst 61.8ms
|
||||
stream: 1079 frames over 20.6s
|
||||
late: 1037 (96.1%)
|
||||
total p50 21.0ms p90 33.5ms p99 39.3ms
|
||||
worst 51.2ms
|
||||
type: 3568 frames over 61.4s
|
||||
late: 3536 (99.1%)
|
||||
total p50 23.8ms p90 32.1ms p99 38.5ms
|
||||
worst 50.3ms
|
||||
keyboard: 215 frames over 10.0s
|
||||
late: 212 (98.6%)
|
||||
total p50 21.3ms p90 37.6ms p99 48.4ms
|
||||
worst 50.2ms
|
||||
|
||||
frames:
|
||||
6482 frames over 124.3s at 60Hz (16.7ms budget)
|
||||
late: 6322 (97.5%)
|
||||
total p50 21.7ms p90 33.1ms p99 45.3ms
|
||||
gpu p50 17.4ms p90 27.0ms p99 30.6ms
|
||||
|
||||
where the draw phase went:
|
||||
draw phase 1.27ms per frame, of which:
|
||||
the transcript: 0.16ms (measure 0.09, place 0.07, record 0.00)
|
||||
everything else: 1.10ms (87%)
|
||||
|
||||
bench:
|
||||
fling: 8 flings out + 8 back at 12000px/s, travel start=idx=0/off=0px outward=idx=218/off=73px end=idx=0/off=0px
|
||||
scroll: 6 cycles (24 swipes, legacy tween), streamed 400/400 fixture events
|
||||
type: 600 characters inserted then deleted, one per 50ms
|
||||
keyboard: shown 5/5, hidden 5/5 (confirmed via isImeVisible)
|
||||
process CPU time over this run: 61192ms
|
||||
peak RSS: 195716kB
|
||||
battery current: mean 900000µA over 125 samples (min 900000, max 900000)
|
||||
|
||||
Read this the same way the v1 emulator smoke run above is read: it
|
||||
proves the harness runs end to end and produces every field this
|
||||
spec asked for, not a phone number -- software rasterisation, and
|
||||
the fixed 900mA battery reading is the emulator's mocked charger
|
||||
again. Two things worth carrying forward: the **fling phase reached
|
||||
index 218** in 8 flings (against v1's `animateScrollBy` loop, which
|
||||
never moved past a handful of indices in the same 8-swipe count),
|
||||
which is the direct evidence the new fling travels "way faster" as
|
||||
asked; and **the emulator's software keyboard toggled and was
|
||||
confirmed by `isImeVisible` all 10 times**, so phase 4 is not a
|
||||
guaranteed "could not be shown" on every platform, only where the
|
||||
IME genuinely refuses. `frames:`'s 6,482-frame, 124.3s total matches
|
||||
the sum of the four phase durations (32.3+20.6+61.4+10.0 ≈ 124.3s),
|
||||
confirming the phase marks partition the whole run rather than
|
||||
overlapping or dropping frames between them.
|
||||
|
||||
- [ ] **P1 — session screen parity.** History paging backward (with the
|
||||
page-boundary healing `client-core` does not have yet, below),
|
||||
`TranscriptSource`-backed cache/server stitching, jump-to-latest,
|
||||
|
||||
Reference in new issue
Block a user