Changing any primitive re-uploaded every primitive. Measured over the bench fixture by the new arena_churn rig: 758 MB across a fling and 1.2 GB across 401 streamed deltas, p50 3.0 MB per streamed frame. Three separate things were wrong, and only the first is what it looked like from the outside. ArrBuf reallocated on every length change. A fresh Buffer's contents are undefined, so adding one glyph -- which a streamed reply does constantly -- forced a full rewrite, and no partial upload could have been correct in the first place. It has a capacity now, growing geometrically and never shrinking, and update() answers whether the Buffer identity moved so a caller can rebuild its bind group and force the whole range dirty. That alone took the glyph array from 95% re-uploaded to 3%, and stopped primitive_group being rebuilt on every frame the arena changed. A redraw freed its primitives and pushed new ones. Freed slots are not reusable until the end of the frame -- a layer's draw order still names them -- and Painter::draw_twice is how a container learns a child's size, so with containers nested the arena's high-water was the transient push count rather than the live one: 17 million pushes across 401 deltas, 127,443 slots for 11,569 live primitives, growing linearly with the transcript. A redraw now gets its old handles back as a recycle pool (Painter::take_recycled, Primitives::recycle) and writes into the slots it already holds; the pool is consumed in order and whatever the draw does not claim is freed when it ends. The arena is exactly the live count now. The CPU frame improved with it, from p50 2.20ms to 1.39ms on the stream run, because the freeing and the draw-order renumbering went away. Nothing tracked which entries changed. util::Dirty is a bitset per uploaded array, coalesced into ranges at a 1 KiB gap. Marking is O(1) and allocation-free; reading it back is one word per 64 entries. Both alternatives were measured and rejected: a min..max span is nearly the whole buffer, since a frame's changes land in 5-20 scattered runs, and a Vec of indices would mean an allocation and a sort per frame at several thousand marks. It replaces Primitives::updated -- one bool that covered the instances and the per-primitive data together, so rewriting a rect's region re-uploaded every glyph -- and TrackedArena::changed. The trap only the rig could catch: writing an entry is not changing it. Recycling rewrote every glyph of every moved row with identical bytes, marking 73% of the glyph array against 0.6% genuinely changed, because what moves is the instance's region and not the glyph. PrimitiveVec::set and Primitives::set_instance compare before marking. Every array now uploads within a hair of its floor: fling instances 3.4% against 3.3%, fling glyphs 0.9% against 0.8%, stream glyphs 0.6% against 0.6%. Stream instances are at 72.7%, which *is* the floor and is a layout question rather than an upload one -- the list is pinned to the newest end, so a growing reply moves every row, and that should be one move_offsets write rather than a redraw. Noted in RUST.md as the next thing. Also: draw_inner's four old_* parameters become one Retained struct, so the recycle pool is a field rather than an eleventh positional argument next to three others of the same shape; and free_primitive is the one place a slot and its draw-order position are retired together. The rigs move to scripts/rigs/ui-profile, a crate of their own so a rig's dependencies stay out of the app's -- arena_churn needs bytemuck, which nothing in ai-app does. arena_churn prints floor, uploaded and whole side by side per array, because any two of those alone are misleading and the 122x over-marking above was invisible until all three were on screen together.
166 lines
6.2 KiB
TOML
166 lines
6.2 KiB
TOML
# The app: everything that is about *this product* rather than about the UI
|
|
# framework it draws with. One package, because splitting it was buying
|
|
# nothing -- see `docs/RUST.md`'s "One app crate" for the account. In short:
|
|
# `client` (the REST/SSE clients, the transcript cache and fold, the
|
|
# highlighter) and `ui` (the screens, in iris widgets) only ever ship
|
|
# together, and the three entry points below are three faces of one binary
|
|
# rather than three programs.
|
|
#
|
|
# `iris/` is the framework and knows nothing about any of this; the
|
|
# dependency runs one way, and a widget or a colour appearing here that is
|
|
# not about a session, a transcript or a setup belongs there instead
|
|
# (AGENTS.md).
|
|
[package]
|
|
name = "ai-app"
|
|
version = "0.1.0"
|
|
edition = "2024"
|
|
|
|
# `cdylib` is the Android face -- both the iris app (`android-project/`,
|
|
# `System.loadLibrary("ai_app")`) and the JNI bridge the Kotlin shell in
|
|
# `app/shellApp` calls (the `shell` feature). `rlib` is what the desktop
|
|
# binary, the examples and `tests/` link against. One package produces one
|
|
# library artifact, so the two Android apps share a `.so` name and pick
|
|
# what goes in it with features rather than with a second crate.
|
|
[lib]
|
|
name = "ai_app"
|
|
crate-type = ["cdylib", "rlib"]
|
|
|
|
[[bin]]
|
|
name = "ai-app-desktop"
|
|
path = "src/bin_desktop.rs"
|
|
required-features = ["screens"]
|
|
|
|
[[example]]
|
|
name = "transcript"
|
|
required-features = ["screens"]
|
|
|
|
[[example]]
|
|
name = "phone"
|
|
required-features = ["fixture"]
|
|
|
|
[dependencies]
|
|
# The event model, shared with `server/` so the two agree by construction.
|
|
# It stays a crate of its own at the repo root for exactly that reason:
|
|
# it is the contract between this app and the backend, not app code.
|
|
event-model = { path = "../event-model" }
|
|
serde = { version = "1", features = ["derive"] }
|
|
# `float_roundtrip` for the same reason `server/` sets it -- AGENTS.md's
|
|
# "Things that have bitten". `raw_value` for the transcript cache.
|
|
serde_json = { version = "1", features = ["float_roundtrip", "raw_value"] }
|
|
ureq = { version = "3", features = ["json"] }
|
|
pulldown-cmark = "0.13.4"
|
|
base64 = "0.23"
|
|
log = { version = "0.4.34", features = ["std"] }
|
|
|
|
# The UI framework. Optional so `--no-default-features --features shell`
|
|
# builds the Kotlin shell's JNI bridge without linking wgpu, parley and
|
|
# the rest of a renderer into an APK that draws with Compose.
|
|
iris = { path = "../iris", optional = true }
|
|
# iris's own tabs demo, kept runnable on Android through this project's
|
|
# Gradle app (the `tabs-screen` feature). The dependency direction is the
|
|
# right way round: the app may reach into the framework's example widget
|
|
# tree, never the reverse.
|
|
tabs-ui = { path = "../iris/tabs-ui", optional = true }
|
|
jni = { version = "0.22", optional = true }
|
|
# `bench` only: `libc` for the process CPU-time and RSS samples, `tokio`
|
|
# for the run's own timer.
|
|
libc = { version = "0.2.189", optional = true }
|
|
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
|
|
|
|
[target.'cfg(not(target_os = "android"))'.dependencies]
|
|
winit = "0.30.13"
|
|
|
|
# Pinned to the exact commit RUST.md's E1 measured on this emulator; see
|
|
# `iris/Cargo.toml`'s copy of this pin for what advancing it costs.
|
|
[target.'cfg(target_os = "android")'.dependencies]
|
|
android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" }
|
|
android_logger = "0.15.1"
|
|
|
|
[features]
|
|
default = ["screens", "fixture"]
|
|
# The iris half: `src/ui` and everything that draws. Off for the Kotlin
|
|
# shell's bridge, which is JNI and `src/client` only.
|
|
screens = ["dep:iris"]
|
|
# `src/ui/fixture.rs` and the harness tests that drive it. Default-on so
|
|
# `cargo test` covers them; `build-apk.sh` passes `--no-default-features`
|
|
# so an APK carries the 1.9 MB fixture only when it asked for `bench`.
|
|
fixture = ["screens"]
|
|
# The two Android widget trees, on the same axis: a build picks one.
|
|
transcript-screen = ["screens"]
|
|
tabs-screen = ["screens", "dep:tabs-ui"]
|
|
# P0's iris half (docs/RUST.md): the fixture screen with a "Run benchmark"
|
|
# control, driving the same scroll loop and streaming phase the Compose
|
|
# bench build type does.
|
|
bench = ["transcript-screen", "fixture", "dep:libc", "dep:tokio"]
|
|
# The JNI bridge `app/shellApp` calls -- notifications, the share target
|
|
# and the Keystore-sealed settings.
|
|
shell = ["dep:jni"]
|
|
# See `iris/Cargo.toml`'s feature of the same name. Never for a phone.
|
|
force-gles = ["screens", "iris/force-gles"]
|
|
|
|
[dev-dependencies]
|
|
tempfile = "3"
|
|
tokio = { version = "1.53.1", features = ["rt", "time"] }
|
|
|
|
# The Android builds, kept off `release`/`dev` so a desktop build is not
|
|
# also optimised for size and unwinding is not also turned off for the
|
|
# tests. `build-apk.sh` passes `--profile android-release`.
|
|
[profile.android-release]
|
|
inherits = "release"
|
|
panic = "abort"
|
|
strip = true
|
|
lto = "fat"
|
|
codegen-units = 1
|
|
# **Speed, not size** (2026-09-09). This was `"s"`, chosen when the
|
|
# question was why the APK was double the Compose one -- but that was
|
|
# measured in bytes only, and `"s"` costs the loop vectorisation and
|
|
# inlining a renderer runs on. Measured with
|
|
# `scripts/rigs/ui-profile`'s `frame_profile.rs`, the same warm fling eight
|
|
# times over:
|
|
# iris's own per-frame work is p90 0.15ms / p99 0.42ms at `"s"` and
|
|
# p90 0.09ms / p99 0.26ms at `3`, so about a third of the CPU half of a
|
|
# scrolling frame was being paid for 1.9 MB of download. The same
|
|
# argument the table in docs/RUST.md gives for refusing `"z"`, applied one
|
|
# level further up.
|
|
opt-level = 3
|
|
|
|
[profile.android-dev]
|
|
inherits = "dev"
|
|
panic = "abort"
|
|
|
|
# Same reasoning as `iris/Cargo.toml`'s copy: full DWARF in every test
|
|
# binary is what made `cargo test` here write tens of gigabytes.
|
|
[profile.dev]
|
|
debug = "line-tables-only"
|
|
|
|
[profile.test]
|
|
debug = "line-tables-only"
|
|
|
|
# The headless harness tests (`iris::harness`, no window and no GPU) all
|
|
# open the bench fixture, so they say so rather than failing to compile
|
|
# when it is off.
|
|
[[test]]
|
|
name = "catch_a_fling"
|
|
required-features = ["fixture"]
|
|
|
|
[[test]]
|
|
name = "fence_fling"
|
|
required-features = ["fixture"]
|
|
|
|
[[test]]
|
|
name = "gesture_cancel"
|
|
required-features = ["fixture"]
|
|
|
|
[[test]]
|
|
name = "input_log_roundtrip"
|
|
required-features = ["fixture"]
|
|
|
|
[[test]]
|
|
name = "phone_screen"
|
|
required-features = ["fixture"]
|
|
|
|
[[test]]
|
|
name = "top_edge"
|
|
required-features = ["fixture"]
|
|
|