iris-android-app: a bench feature, P0's iris half
A third AndroidAppState (BenchClient) on top of transcript-screen: embeds app/bench-fixture/assets/transcript.jsonl with include_str! (no server, no enrollment), folds the first 3,200 lines through client_core's real fold_page as the opening backlog, and holds the rest back as a streaming tail. "Run benchmark" resets FrameReport, animates the same 24-swipe/ 6-cycle scroll BenchRun.kt drives (List::scroll in ~60Hz steps, since iris has no built-in tween), then replays the tail at 20/s through fold_event -- the same fold path a live SSE reply takes -- and shows a report in a selectable TextEdit. "Copy report" puts it on the clipboard. The report adds process CPU time (libc::getrusage), peak RSS (/proc/self/ status's VmHWM) and battery current (BatteryManager.getIntProperty via direct JNI, bench_jni.rs's PlatformHandle) to FrameStats's existing frames/janky%/percentiles/CPU-GPU-split line -- "unavailable" rather than a fabricated number wherever the platform can't answer. build.rs now exits early under the bench feature before requiring a live server's host/port/token/CA: BenchClient never calls build_transport(). app/build.gradle gains a signed `release` build type (previously only debug) so the cdylib cargo ndk builds can be packaged for a phone, the same key app/build-apk.sh generates. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
8d23a20792
commit
683db4908a
7 files changed
+637
-2
No files matched your search
Generated
+2
@@ -1768,9 +1768,11 @@ dependencies = [
|
|||||||
"client-core",
|
"client-core",
|
||||||
"event-model",
|
"event-model",
|
||||||
"iris",
|
"iris",
|
||||||
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tabs-ui",
|
"tabs-ui",
|
||||||
|
"tokio",
|
||||||
"transcript-ui",
|
"transcript-ui",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ transcript-ui = { path = "../transcript-ui", optional = true }
|
|||||||
client-core = { path = "../../client-core", optional = true }
|
client-core = { path = "../../client-core", optional = true }
|
||||||
event-model = { path = "../../event-model", optional = true }
|
event-model = { path = "../../event-model", optional = true }
|
||||||
serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
|
serde_json = { version = "1", features = ["float_roundtrip"], optional = true }
|
||||||
|
# P0's bench build only (docs/RUST.md): `getrusage(RUSAGE_SELF)` for
|
||||||
|
# process CPU time, matching `libc::getrusage`'s mention in that box over
|
||||||
|
# parsing `/proc/self/stat` by hand and assuming `USER_HZ`. Already in the
|
||||||
|
# workspace's own dependency tree transitively (`iris/Cargo.lock`, pinned
|
||||||
|
# at 0.2.179) -- this makes it a direct dependency at the same version
|
||||||
|
# rather than a second, possibly-drifting resolution.
|
||||||
|
libc = { version = "0.2.179", optional = true }
|
||||||
|
# P0's bench build only: the scroll animation and the streaming phase are
|
||||||
|
# both a sequence of `sleep`s inside the async task `rsc.spawn_task` already
|
||||||
|
# runs on iris's own tokio runtime (`iris/src/task.rs`'s `Tasks::init`), and
|
||||||
|
# the battery sampler is a second, concurrent task on that same runtime
|
||||||
|
# (`tokio::spawn`) -- so this crate needs `tokio` directly rather than only
|
||||||
|
# through `iris`. `rt`+`time` only: no I/O, no macros, nothing this crate
|
||||||
|
# doesn't call. Version matches the one `iris`'s own dependency tree already
|
||||||
|
# resolves to (`iris/Cargo.lock`), so there is one copy of the runtime, not
|
||||||
|
# two.
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["tabs-screen"]
|
default = ["tabs-screen"]
|
||||||
@@ -41,6 +58,14 @@ transcript-screen = ["dep:transcript-ui", "dep:client-core", "dep:event-model",
|
|||||||
# instead of SwiftShader's software Vulkan. See `iris/Cargo.toml`'s own doc
|
# instead of SwiftShader's software Vulkan. See `iris/Cargo.toml`'s own doc
|
||||||
# on the feature this forwards to.
|
# on the feature this forwards to.
|
||||||
force-gles = ["iris/force-gles"]
|
force-gles = ["iris/force-gles"]
|
||||||
|
# P0's iris half (docs/RUST.md, docs/AGENTS.md's "The rigs"): the same
|
||||||
|
# checked-in fixture, scroll loop and streaming phase the Compose `bench`
|
||||||
|
# build type drives, run here against `transcript-ui`'s real screen with no
|
||||||
|
# server. Depends on `transcript-screen` for `transcript-ui`/`client-core`/
|
||||||
|
# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature
|
||||||
|
# priority over `transcript-screen`'s own `TranscriptClient` when both are
|
||||||
|
# listed, which is how this crate's build command names both explicitly.
|
||||||
|
bench = ["transcript-screen", "dep:libc", "dep:tokio"]
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
|
|||||||
@@ -19,9 +19,39 @@ android {
|
|||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A release build must be signed, and the key is per machine rather than per repo -- same
|
||||||
|
// reasoning and the same key as `app/build-apk.sh` (the Compose app): it is what a phone
|
||||||
|
// recognises the app by, and a secret never lives in a checkout (the mount is shared with an
|
||||||
|
// untrusted VM). `build-apk.sh` generates this key once and points at it through the
|
||||||
|
// environment; without it a release build here is unsigned, which is fine for everything
|
||||||
|
// except installing.
|
||||||
|
def keystore = System.getenv("AI_APP_KEYSTORE")
|
||||||
|
signingConfigs {
|
||||||
|
if (keystore != null) {
|
||||||
|
release {
|
||||||
|
storeFile = file(keystore)
|
||||||
|
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
|
||||||
|
keyAlias = "ai-app"
|
||||||
|
keyPassword = storePassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
}
|
}
|
||||||
|
// P0's iris half (docs/RUST.md's P0 box): the build a phone actually runs. The `.so`
|
||||||
|
// itself is built separately with `cargo ndk --release --features "transcript-screen
|
||||||
|
// force-gles bench"` straight into src/main/jniLibs/ (this crate's own Cargo.toml) --
|
||||||
|
// Gradle here only packages and signs whatever is already there, the same division as the
|
||||||
|
// debug/tabs-screen build this project started with. `applicationIdSuffix` keeps it
|
||||||
|
// installable beside a debug build of the tabs demo rather than replacing it.
|
||||||
|
release {
|
||||||
|
applicationIdSuffix ".bench"
|
||||||
|
if (keystore != null) {
|
||||||
|
signingConfig = signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ fn main() {
|
|||||||
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
|
if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// P0's bench build (docs/RUST.md) opens the checked-in fixture with no
|
||||||
|
// server at all -- `bench_client.rs` never references the `pinned`
|
||||||
|
// module this generates, so requiring a live server's host/port/token/
|
||||||
|
// CA to build it (as plain `transcript-screen` does, below) would be a
|
||||||
|
// pointless requirement for a build that talks to nothing.
|
||||||
|
if std::env::var_os("CARGO_FEATURE_BENCH").is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST");
|
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST");
|
||||||
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT");
|
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT");
|
||||||
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN");
|
println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN");
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
//! P0's iris half (docs/RUST.md's P0 box, docs/AGENTS.md's "The rigs"):
|
||||||
|
//! the same fixture, scroll loop and streaming phase the Compose `bench`
|
||||||
|
//! build type's `BenchRun.kt`/`BenchFixture.kt` drive, run here against
|
||||||
|
//! `transcript-ui`'s real screen with no server -- a frame-time comparison
|
||||||
|
//! that measures the renderer rather than the data or the network.
|
||||||
|
//!
|
||||||
|
//! **Reuses `transcript_client.rs`'s shape** (folded items, a full
|
||||||
|
//! `transcript_ui::build_tree` rebuild per event) with the network half
|
||||||
|
//! replaced by the checked-in fixture, embedded with `include_str!` --
|
||||||
|
//! `app/bench-fixture/assets/transcript.jsonl`, 1,915,760 bytes, generated
|
||||||
|
//! by `app/bench-fixture/generate.py` and never a real transcript (that
|
||||||
|
//! file's own README). The first 3,200 lines are the opening backlog,
|
||||||
|
//! folded once through `client_core::transcript_fold::fold_page` exactly
|
||||||
|
//! as a real `/transcript` page would be; the remaining ~400 are the
|
||||||
|
//! streaming tail, replayed one at a time through `fold_event` -- the same
|
||||||
|
//! fold path a live SSE reply arrives on -- by the "Run benchmark"
|
||||||
|
//! control below.
|
||||||
|
|
||||||
|
use crate::bench_jni::PlatformHandle;
|
||||||
|
use android_view::jni::{JavaVM, objects::GlobalRef};
|
||||||
|
use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs};
|
||||||
|
use event_model::SeqEvent;
|
||||||
|
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
|
||||||
|
use iris::prelude::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are
|
||||||
|
/// the opening window; the rest are the streaming tail. Kept in sync with
|
||||||
|
/// `BenchFixture.kt`'s identical constant by hand -- both read the same
|
||||||
|
/// checked-in file, so a mismatch would only mean the two apps' bench
|
||||||
|
/// builds open a different split of it, not a wrong-vs-right answer.
|
||||||
|
const BACKLOG_COUNT: usize = 3200;
|
||||||
|
|
||||||
|
/// `BenchRun.kt`'s own constants -- kept identical so the two apps' bench
|
||||||
|
/// runs are the same gesture and the same load, which is the entire point
|
||||||
|
/// of a shared fixture and a shared scripted loop (P0's pass condition).
|
||||||
|
const CYCLES: usize = 6;
|
||||||
|
const SWIPE_PX: f32 = 900.0;
|
||||||
|
const SWIPE_MS: u64 = 200;
|
||||||
|
const SWIPE_PAUSE_MS: u64 = 500;
|
||||||
|
const STREAM_EVENTS_PER_SEC: u64 = 20;
|
||||||
|
const STREAM_SECONDS: u64 = 20;
|
||||||
|
/// One animation step's target cadence -- close enough to 60Hz that a
|
||||||
|
/// `List::scroll` swipe is many small moves rather than one jump, so
|
||||||
|
/// frames are actually rendered along the way (the point of animating it
|
||||||
|
/// at all rather than calling `scroll` once per swipe).
|
||||||
|
const ANIM_STEP_MS: u64 = 16;
|
||||||
|
|
||||||
|
const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl");
|
||||||
|
|
||||||
|
pub struct BenchClient {
|
||||||
|
ui_state: AndroidUiState,
|
||||||
|
content: WeakWidget<WidgetPtr>,
|
||||||
|
report_display: WeakWidget<TextEdit>,
|
||||||
|
screen: Option<transcript_ui::TranscriptScreen>,
|
||||||
|
items: Vec<TranscriptItem>,
|
||||||
|
/// The events not yet streamed -- consumed by `start_benchmark`'s own
|
||||||
|
/// clone, kept here only as the source a second run would need (the
|
||||||
|
/// button can be pressed more than once; `running` just stops overlap,
|
||||||
|
/// not repeat).
|
||||||
|
stream_tail: Vec<SeqEvent>,
|
||||||
|
platform: Option<Arc<PlatformHandle>>,
|
||||||
|
last_report: Option<String>,
|
||||||
|
running: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HasAndroidUiState for BenchClient {
|
||||||
|
fn android_state(&self) -> &AndroidUiState {
|
||||||
|
&self.ui_state
|
||||||
|
}
|
||||||
|
fn android_state_mut(&mut self) -> &mut AndroidUiState {
|
||||||
|
&mut self.ui_state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the fixture once: `serde_json::Value`s for the backlog
|
||||||
|
/// (`fold_page` takes a page of raw wire JSON, same as a real
|
||||||
|
/// `/transcript` response) and folded `SeqEvent`s for the tail (`fold_event`
|
||||||
|
/// takes one live wire event at a time, same as a real SSE frame).
|
||||||
|
fn parse_fixture() -> (Vec<serde_json::Value>, Vec<SeqEvent>) {
|
||||||
|
let lines: Vec<&str> = FIXTURE_JSONL
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.collect();
|
||||||
|
let mut backlog = Vec::with_capacity(BACKLOG_COUNT.min(lines.len()));
|
||||||
|
let mut stream_tail = Vec::new();
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
let value: serde_json::Value =
|
||||||
|
serde_json::from_str(line).expect("bench fixture is generated JSON, always valid");
|
||||||
|
if i < BACKLOG_COUNT {
|
||||||
|
backlog.push(value);
|
||||||
|
} else {
|
||||||
|
let event: SeqEvent = serde_json::from_value(value)
|
||||||
|
.expect("bench fixture event matches event-model's SeqEvent");
|
||||||
|
stream_tail.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(backlog, stream_tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
|
||||||
|
wtext(message.to_string())
|
||||||
|
.color(Color::WHITE)
|
||||||
|
.wrap(true)
|
||||||
|
.pad(16)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `getrusage(RUSAGE_SELF)`'s user+system time, in ms -- `None` only if
|
||||||
|
/// the syscall itself fails, which UI_RULES.md's "never present an
|
||||||
|
/// inferred value as a measured one" says to keep apart from a real (and
|
||||||
|
/// here, impossible) zero.
|
||||||
|
fn process_cpu_ms() -> Option<u64> {
|
||||||
|
// SAFETY: `rusage` is a plain-old-data struct `getrusage` fully
|
||||||
|
// initialises on success; on failure it is never read.
|
||||||
|
unsafe {
|
||||||
|
let mut usage: libc::rusage = std::mem::zeroed();
|
||||||
|
if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let user_ms = usage.ru_utime.tv_sec as u64 * 1000 + usage.ru_utime.tv_usec as u64 / 1000;
|
||||||
|
let sys_ms = usage.ru_stime.tv_sec as u64 * 1000 + usage.ru_stime.tv_usec as u64 / 1000;
|
||||||
|
Some(user_ms + sys_ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `VmHWM` from `/proc/self/status` -- the process's peak RSS since it
|
||||||
|
/// started, in kB. Same source `BenchRun.kt`'s `peakRssLine` reads, so the
|
||||||
|
/// two reports' numbers mean the same thing.
|
||||||
|
fn peak_rss_kb() -> Option<u64> {
|
||||||
|
std::fs::read_to_string("/proc/self/status")
|
||||||
|
.ok()?
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("VmHWM:"))
|
||||||
|
.and_then(|rest| rest.trim().strip_suffix("kB"))
|
||||||
|
.and_then(|n| n.trim().parse().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn battery_line(samples: &[i32]) -> String {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return " battery current: unavailable on this device".to_string();
|
||||||
|
}
|
||||||
|
let mean = samples.iter().map(|&v| v as i64).sum::<i64>() / samples.len() as i64;
|
||||||
|
let min = samples.iter().min().unwrap();
|
||||||
|
let max = samples.iter().max().unwrap();
|
||||||
|
format!(
|
||||||
|
" battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})",
|
||||||
|
samples.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidAppState for BenchClient {
|
||||||
|
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
|
||||||
|
let content = WidgetPtr::new().add(rsc);
|
||||||
|
let loading = placeholder(rsc, "Loading fixture...");
|
||||||
|
content(rsc).set(loading);
|
||||||
|
|
||||||
|
let report_display = wtext("")
|
||||||
|
.editable(EditMode::MultiLine)
|
||||||
|
.text_align(Align::LEFT)
|
||||||
|
.wrap(true)
|
||||||
|
.size(14)
|
||||||
|
.color(Color::WHITE)
|
||||||
|
.attr::<Selectable>(())
|
||||||
|
.label("Benchmark report")
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let controls = bench_controls(rsc);
|
||||||
|
let tree = (
|
||||||
|
controls,
|
||||||
|
content.height(rest(2)),
|
||||||
|
report_display.height(rest(1)).pad(8),
|
||||||
|
)
|
||||||
|
.span(Dir::DOWN)
|
||||||
|
.add_strong(rsc)
|
||||||
|
.any();
|
||||||
|
ui_state.set_root(tree);
|
||||||
|
|
||||||
|
let mut client = Self {
|
||||||
|
ui_state,
|
||||||
|
content,
|
||||||
|
report_display,
|
||||||
|
screen: None,
|
||||||
|
items: Vec::new(),
|
||||||
|
stream_tail: Vec::new(),
|
||||||
|
platform: None,
|
||||||
|
last_report: None,
|
||||||
|
running: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (backlog, stream_tail) = parse_fixture();
|
||||||
|
client.stream_tail = stream_tail;
|
||||||
|
match fold_page(&backlog) {
|
||||||
|
Ok(items) => {
|
||||||
|
client.items = items;
|
||||||
|
client.rebuild_transcript(rsc);
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client
|
||||||
|
}
|
||||||
|
|
||||||
|
fn platform_ready(&mut self, _rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {
|
||||||
|
self.platform = Some(Arc::new(PlatformHandle::new(vm, view)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>, _render: &mut UiRenderState) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rsc = AndroidRsc<BenchClient>;
|
||||||
|
|
||||||
|
fn bench_controls(rsc: &mut Rsc) -> WeakWidget {
|
||||||
|
let run_rect = rect(Color::rgb(40, 70, 40))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
|
||||||
|
ctx.state.start_benchmark(rsc);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Run benchmark");
|
||||||
|
let run = (
|
||||||
|
run_rect,
|
||||||
|
wtext("Run benchmark").size(18).text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(8)
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
let copy_rect = rect(Color::rgb(50, 50, 60))
|
||||||
|
.on(
|
||||||
|
CursorSense::click(),
|
||||||
|
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
|
||||||
|
ctx.state.copy_report();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.label("Copy report");
|
||||||
|
let copy = (
|
||||||
|
copy_rect,
|
||||||
|
wtext("Copy report").size(18).text_align(Align::CENTER),
|
||||||
|
)
|
||||||
|
.stack()
|
||||||
|
.pad(8)
|
||||||
|
.add(rsc);
|
||||||
|
|
||||||
|
(run, copy).span(Dir::RIGHT).height(56).add(rsc)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BenchClient {
|
||||||
|
fn show_message(&mut self, rsc: &mut Rsc, message: &str) {
|
||||||
|
let widget = placeholder(rsc, message);
|
||||||
|
(self.content)(rsc).set(widget);
|
||||||
|
self.screen = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_transcript(&mut self, rsc: &mut Rsc) {
|
||||||
|
let rows = group_tool_runs(&self.items);
|
||||||
|
let (screen, tree) = transcript_ui::build_tree(rsc, rows);
|
||||||
|
(self.content)(rsc).set(tree);
|
||||||
|
self.screen = Some(screen);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_report(&mut self) {
|
||||||
|
let Some(report) = &self.last_report else {
|
||||||
|
log::info!("iris bench report: nothing to copy -- run the benchmark first");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(platform) = &self.platform else {
|
||||||
|
log::info!("iris bench report: no platform handle, can't reach the clipboard");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if platform.copy_to_clipboard("iris bench report", report) {
|
||||||
|
log::info!("iris bench report: copied to clipboard");
|
||||||
|
} else {
|
||||||
|
log::info!("iris bench report: clipboard copy failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// P0's scripted run: `BenchRun.kt`'s scroll loop, then its streaming
|
||||||
|
/// phase, then the report -- run in-process for the same reason that
|
||||||
|
/// file's own doc gives (no usable system tracing on a real phone, no
|
||||||
|
/// agent that can drive one).
|
||||||
|
fn start_benchmark(&mut self, rsc: &mut Rsc) {
|
||||||
|
if self.running {
|
||||||
|
log::info!("iris bench report: already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.running = true;
|
||||||
|
self.android_state_mut().frame_report.reset();
|
||||||
|
self.report_display.edit(rsc).set("Running benchmark...");
|
||||||
|
|
||||||
|
let redraw = rsc.tasks.redraw_handle();
|
||||||
|
let platform = self.platform.clone();
|
||||||
|
let stream_tail = self.stream_tail.clone();
|
||||||
|
let cpu_start = process_cpu_ms();
|
||||||
|
|
||||||
|
rsc.spawn_task(async move |mut ctx| {
|
||||||
|
// The swipe loop: two drags toward newer content, two back --
|
||||||
|
// a cycle returns to where it started, so the whole loop
|
||||||
|
// measures steady-state scrolling. `BenchRun.kt`'s own
|
||||||
|
// comment on this shape.
|
||||||
|
for _ in 0..CYCLES {
|
||||||
|
for delta in [SWIPE_PX, SWIPE_PX, -SWIPE_PX, -SWIPE_PX] {
|
||||||
|
animate_scroll(&mut ctx, &redraw, delta, SWIPE_MS).await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(SWIPE_PAUSE_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned to the newest end before streaming starts, matching
|
||||||
|
// `stream-bench.sh`'s "Jump to latest" tap.
|
||||||
|
ctx.update(|state: &mut BenchClient, rsc| {
|
||||||
|
if let Some(screen) = &state.screen {
|
||||||
|
(screen.list)(rsc).jump_to_end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
|
||||||
|
// The battery sampler runs concurrently with the streaming
|
||||||
|
// phase, once a second, the same cadence `BatterySampler` uses
|
||||||
|
// on the Compose side -- via its own JNI-attached thread, not
|
||||||
|
// `ctx.update`, since a sample needs no widget-tree access.
|
||||||
|
let sampler_done = Arc::new(AtomicBool::new(false));
|
||||||
|
let samples = Arc::new(std::sync::Mutex::new(Vec::<i32>::new()));
|
||||||
|
let sampler = platform.clone().map(|platform| {
|
||||||
|
let done = sampler_done.clone();
|
||||||
|
let samples = samples.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while !done.load(Ordering::Relaxed) {
|
||||||
|
if let Some(value) = platform.battery_current_ua() {
|
||||||
|
samples.lock().unwrap().push(value);
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize;
|
||||||
|
let mut sent = 0usize;
|
||||||
|
for event in stream_tail.into_iter().take(total) {
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
state.items = fold_event(&state.items, &event);
|
||||||
|
state.rebuild_transcript(rsc);
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
sent += 1;
|
||||||
|
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
|
||||||
|
}
|
||||||
|
// Lets the last few deltas land and draw before the report is
|
||||||
|
// read -- `BenchRun.kt`'s own closing delay.
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
sampler_done.store(true, Ordering::Relaxed);
|
||||||
|
if let Some(sampler) = sampler {
|
||||||
|
let _ = sampler.await;
|
||||||
|
}
|
||||||
|
let battery = battery_line(&samples.lock().unwrap());
|
||||||
|
let cpu_line = match (cpu_start, process_cpu_ms()) {
|
||||||
|
(Some(start), Some(end)) => {
|
||||||
|
format!(" process CPU time over this run: {}ms", end.saturating_sub(start))
|
||||||
|
}
|
||||||
|
_ => " process CPU time over this run: unavailable".to_string(),
|
||||||
|
};
|
||||||
|
let rss_line = match peak_rss_kb() {
|
||||||
|
Some(kb) => format!(" peak RSS: {kb}kB"),
|
||||||
|
None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
state.running = false;
|
||||||
|
let scroll_line = format!(
|
||||||
|
" scroll: {CYCLES} cycles ({} swipes), streamed {sent}/{total} fixture events",
|
||||||
|
CYCLES * 4
|
||||||
|
);
|
||||||
|
let frames_line = match state.android_state().frame_report.report() {
|
||||||
|
Some(stats) => format!("{stats}"),
|
||||||
|
None => "no frames recorded".to_string(),
|
||||||
|
};
|
||||||
|
let report = format!(
|
||||||
|
"iris bench report\n{frames_line}\n{scroll_line}\n{cpu_line}\n{rss_line}\n{battery}"
|
||||||
|
);
|
||||||
|
log::info!("iris bench report: {report}");
|
||||||
|
state.report_display.edit(rsc).set(&report);
|
||||||
|
state.last_report = Some(report);
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves `List::scroll` by `total_px` over `duration_ms`, in ~60Hz steps,
|
||||||
|
/// so the swipe is many rendered frames rather than one jump -- the same
|
||||||
|
/// shape `animateScrollBy(SWIPE_PX, tween(SWIPE_MS))` gives on the Compose
|
||||||
|
/// side, in the one place the two backends have to differ (iris's `List`
|
||||||
|
/// has no built-in tween, so this drives it by hand).
|
||||||
|
async fn animate_scroll(
|
||||||
|
ctx: &mut iris::task::TaskCtx<Rsc>,
|
||||||
|
redraw: &Arc<dyn iris::task::RequestRedraw>,
|
||||||
|
total_px: f32,
|
||||||
|
duration_ms: u64,
|
||||||
|
) {
|
||||||
|
let steps = (duration_ms / ANIM_STEP_MS).max(1);
|
||||||
|
let step_px = total_px / steps as f32;
|
||||||
|
for _ in 0..steps {
|
||||||
|
ctx.update(move |state: &mut BenchClient, rsc| {
|
||||||
|
if let Some(screen) = &state.screen {
|
||||||
|
(screen.list)(rsc).scroll(step_px);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
redraw.request_redraw();
|
||||||
|
tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//! JNI calls the `bench` feature needs that go through the shell's own
|
||||||
|
//! Java side rather than anything `iris`/`android-view` already wraps:
|
||||||
|
//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the
|
||||||
|
//! per-second battery sample, and `ClipboardManager.setPrimaryClip` for
|
||||||
|
//! the "Copy report" control (P0's iris half, docs/RUST.md). Neither is
|
||||||
|
//! part of `android_view::context`'s own `Context`/`Resources` wrappers
|
||||||
|
//! (that file's own `// TODO: more methods?`), so this calls them
|
||||||
|
//! directly rather than growing that crate's wrapper for two one-off
|
||||||
|
//! calls this crate alone needs.
|
||||||
|
//!
|
||||||
|
//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through
|
||||||
|
//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach
|
||||||
|
//! whichever thread calls it -- the battery sampler runs on a background
|
||||||
|
//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls
|
||||||
|
//! run on. `JavaVM::attach_current_thread` is safe to call from a thread
|
||||||
|
//! already attached (the `jni` crate detects it and does not double
|
||||||
|
//! attach), so no caller here needs to know or care which thread it is.
|
||||||
|
|
||||||
|
use android_view::jni::{
|
||||||
|
JNIEnv, JavaVM,
|
||||||
|
objects::{GlobalRef, JObject, JValue},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `android.os.BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` -- not exposed
|
||||||
|
/// as a constant anywhere reachable without the Android SDK jar, so named
|
||||||
|
/// here with its source rather than left as a bare `2`.
|
||||||
|
const BATTERY_PROPERTY_CURRENT_NOW: i32 = 2;
|
||||||
|
|
||||||
|
pub struct PlatformHandle {
|
||||||
|
vm: JavaVM,
|
||||||
|
view: GlobalRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlatformHandle {
|
||||||
|
pub fn new(vm: JavaVM, view: GlobalRef) -> Self {
|
||||||
|
Self { vm, view }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn context<'e>(&self, env: &mut JNIEnv<'e>) -> Option<JObject<'e>> {
|
||||||
|
env.call_method(
|
||||||
|
self.view.as_obj(),
|
||||||
|
"getContext",
|
||||||
|
"()Landroid/content/Context;",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.l()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_service<'e>(
|
||||||
|
&self,
|
||||||
|
env: &mut JNIEnv<'e>,
|
||||||
|
context: &JObject<'e>,
|
||||||
|
name: &str,
|
||||||
|
) -> Option<JObject<'e>> {
|
||||||
|
let jname = env.new_string(name).ok()?;
|
||||||
|
env.call_method(
|
||||||
|
context,
|
||||||
|
"getSystemService",
|
||||||
|
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||||
|
&[JValue::Object(jname.as_ref())],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.l()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One sample of `BATTERY_PROPERTY_CURRENT_NOW`, in microamps. `None`
|
||||||
|
/// on any JNI failure, on a device with no `BatteryManager` service,
|
||||||
|
/// or when the platform itself answers "not supported" -- `0` or
|
||||||
|
/// `Integer.MIN_VALUE` are both documented SDK answers for that, and
|
||||||
|
/// both would read as a real (and wrong) measurement if folded into an
|
||||||
|
/// average rather than named apart. UI_RULES.md: never present an
|
||||||
|
/// inferred value as a measured one.
|
||||||
|
pub fn battery_current_ua(&self) -> Option<i32> {
|
||||||
|
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
|
let env: &mut JNIEnv = &mut guard;
|
||||||
|
let context = self.context(env)?;
|
||||||
|
let battery_manager = self.system_service(env, &context, "batterymanager")?;
|
||||||
|
let value = env
|
||||||
|
.call_method(
|
||||||
|
&battery_manager,
|
||||||
|
"getIntProperty",
|
||||||
|
"(I)I",
|
||||||
|
&[JValue::Int(BATTERY_PROPERTY_CURRENT_NOW)],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.i()
|
||||||
|
.ok()?;
|
||||||
|
if value == 0 || value == i32::MIN {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts `text` on the system clipboard through `ClipboardManager` --
|
||||||
|
/// `true` only if the whole JNI chain (service lookup, `ClipData`,
|
||||||
|
/// `setPrimaryClip`) succeeded.
|
||||||
|
pub fn copy_to_clipboard(&self, label: &str, text: &str) -> bool {
|
||||||
|
self.try_copy_to_clipboard(label, text).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_copy_to_clipboard(&self, label: &str, text: &str) -> Option<()> {
|
||||||
|
let mut guard = self.vm.attach_current_thread().ok()?;
|
||||||
|
let env: &mut JNIEnv = &mut guard;
|
||||||
|
let context = self.context(env)?;
|
||||||
|
let clipboard = self.system_service(env, &context, "clipboard")?;
|
||||||
|
let jlabel = env.new_string(label).ok()?;
|
||||||
|
let jtext = env.new_string(text).ok()?;
|
||||||
|
let clip = env
|
||||||
|
.call_static_method(
|
||||||
|
"android/content/ClipData",
|
||||||
|
"newPlainText",
|
||||||
|
"(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
|
||||||
|
&[
|
||||||
|
JValue::Object(jlabel.as_ref()),
|
||||||
|
JValue::Object(jtext.as_ref()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.ok()?
|
||||||
|
.l()
|
||||||
|
.ok()?;
|
||||||
|
env.call_method(
|
||||||
|
&clipboard,
|
||||||
|
"setPrimaryClip",
|
||||||
|
"(Landroid/content/ClipData;)V",
|
||||||
|
&[JValue::Object(&clip)],
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,18 @@
|
|||||||
//! A build picks one screen or the other, never both, so `Client` and
|
//! A build picks one screen or the other, never both, so `Client` and
|
||||||
//! `TranscriptClient` are cfg-gated apart rather than switched at runtime --
|
//! `TranscriptClient` are cfg-gated apart rather than switched at runtime --
|
||||||
//! there is no in-app navigation to switch *to* on either side yet.
|
//! there is no in-app navigation to switch *to* on either side yet.
|
||||||
|
//!
|
||||||
|
//! **`bench` feature (P0's iris half, docs/RUST.md):** a third
|
||||||
|
//! `AndroidAppState`, `bench_client::BenchClient`, on the same axis --
|
||||||
|
//! `transcript_ui::build_tree` again, this time against the checked-in
|
||||||
|
//! fixture (`app/bench-fixture/assets/transcript.jsonl`) instead of a real
|
||||||
|
//! server, with a "Run benchmark" control that drives the same scroll loop
|
||||||
|
//! and streaming phase the Compose `bench` build type's `BenchRun.kt`
|
||||||
|
//! does. `bench` depends on `transcript-screen` (Cargo.toml) for
|
||||||
|
//! `transcript-ui`/`client-core`/`event-model`, so both features end up
|
||||||
|
//! enabled together -- `ActiveClient` below gives `bench` priority in that
|
||||||
|
//! case, the same way `transcript-screen` already takes priority over the
|
||||||
|
//! default `tabs-screen`.
|
||||||
|
|
||||||
use android_view::{
|
use android_view::{
|
||||||
Context, View,
|
Context, View,
|
||||||
@@ -39,7 +51,11 @@ use iris::prelude::*;
|
|||||||
use log::LevelFilter;
|
use log::LevelFilter;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
|
|
||||||
#[cfg(feature = "transcript-screen")]
|
#[cfg(feature = "bench")]
|
||||||
|
mod bench_client;
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
mod bench_jni;
|
||||||
|
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||||
mod transcript_client;
|
mod transcript_client;
|
||||||
|
|
||||||
/// The app's `View` subclass, matching the Java side's package --
|
/// The app's `View` subclass, matching the Java side's package --
|
||||||
@@ -85,8 +101,10 @@ impl AndroidAppState for Client {
|
|||||||
|
|
||||||
#[cfg(not(feature = "transcript-screen"))]
|
#[cfg(not(feature = "transcript-screen"))]
|
||||||
type ActiveClient = Client;
|
type ActiveClient = Client;
|
||||||
#[cfg(feature = "transcript-screen")]
|
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
|
||||||
type ActiveClient = transcript_client::TranscriptClient;
|
type ActiveClient = transcript_client::TranscriptClient;
|
||||||
|
#[cfg(feature = "bench")]
|
||||||
|
type ActiveClient = bench_client::BenchClient;
|
||||||
|
|
||||||
extern "system" fn new_view_peer<'local>(
|
extern "system" fn new_view_peer<'local>(
|
||||||
env: JNIEnv<'local>,
|
env: JNIEnv<'local>,
|
||||||
|
|||||||
Reference in new issue
Block a user