Prune commentary and stale Rust port notes
This commit is contained in:
1 parent
5428cd75c9
commit
25370731d0
193 files changed
+693
-16219
No files matched your search
@@ -3,16 +3,11 @@ name = "gpu-probe"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# Deliberately its own crate rather than a member of iris's workspace: the
|
||||
# vendored `iris/` tree is meant to stay reconcilable with the iris/iris
|
||||
# repository, and this is a rig belonging to ai-app.
|
||||
# Standalone so this app-specific rig does not alter iris's workspace.
|
||||
|
||||
[dependencies]
|
||||
# Pinned to what iris asks for, so the answer is about iris rather than
|
||||
# about a different wgpu.
|
||||
# Match iris's wgpu version.
|
||||
wgpu = "30.0.1"
|
||||
pollster = "1.0.1"
|
||||
# Queried directly, because wgpu and `cmd gpu vkjson` disagreed about
|
||||
# descriptor indexing in the emulator and only the raw call says which is
|
||||
# right.
|
||||
# Raw Vulkan queries settle capabilities on which wgpu and `cmd gpu vkjson` disagree.
|
||||
ash = "0.38"
|
||||
@@ -1,33 +1,3 @@
|
||||
//! Why a GPU test segfaults *after* it has passed, and what stops it.
|
||||
//!
|
||||
//! Measured here 2026-09-08, on this VM's Venus adapter. Destroying the
|
||||
//! last `VkInstance` makes the Vulkan loader `dlclose` the ICD; Mesa's
|
||||
//! ICD (`/usr/lib/libvulkan_virtio.so`) registers a `pthread_key_create`
|
||||
//! destructor pointing into its own text and is not linked `-z nodelete`,
|
||||
//! so glibc calls that destructor through unmapped memory when the thread
|
||||
//! that used Vulkan exits. libtest runs every `#[test]` on a spawned
|
||||
//! thread, which is why it looked like "wgpu crashes on drop": the drop
|
||||
//! itself completes, and the crash lands as the thread unwinds.
|
||||
//!
|
||||
//! The four modes are the experiment, and each is one variable:
|
||||
//!
|
||||
//! | mode | what it does | 2026-09-08 |
|
||||
//! |---|---|---|
|
||||
//! | `main` | wgpu instance + device on the main thread, dropped | exits 0 |
|
||||
//! | `thread` | the same on a spawned thread | **SIGSEGV** |
|
||||
//! | `keep` | the same, but the instance is never dropped | exits 0 |
|
||||
//! | `raw` | raw Vulkan (`ash`), instance + device, spawned thread | **SIGSEGV** |
|
||||
//!
|
||||
//! `raw` is the one that says whose bug it is: no wgpu is involved, so
|
||||
//! there is nothing for wgpu or a caller to fix in its drop order. `keep`
|
||||
//! is the fix -- hold one `wgpu::Instance` for the process, which is what
|
||||
//! wgpu asks for anyway. `iris/tests/mask_sdf.rs` does exactly that.
|
||||
//!
|
||||
//! `VK_LOADER_DISABLE_DYNAMIC_LIBRARY_UNLOADING=1` also makes every mode
|
||||
//! exit cleanly, which is the confirmation that the unload is the
|
||||
//! mechanism -- but it is an environment variable every caller would have
|
||||
//! to remember, so it belongs in this comment rather than in a script.
|
||||
|
||||
use ash::vk;
|
||||
use pollster::block_on;
|
||||
use wgpu::*;
|
||||
@@ -42,12 +12,9 @@ fn main() {
|
||||
other => panic!("unknown mode {other:?}: main | thread | keep | raw"),
|
||||
};
|
||||
std::thread::spawn(body).join().expect("the spawned thread");
|
||||
// Not reached when the thread's exit takes the process with it.
|
||||
eprintln!("thread joined");
|
||||
}
|
||||
|
||||
/// A wgpu instance and device, opened and closed. `keep_instance` is the
|
||||
/// fix under test: everything else still drops normally.
|
||||
fn wgpu_open_and_close(keep_instance: bool) {
|
||||
let instance = Instance::default();
|
||||
let adapter =
|
||||
@@ -71,8 +38,6 @@ fn wgpu_open_and_close(keep_instance: bool) {
|
||||
eprintln!("wgpu closed");
|
||||
}
|
||||
|
||||
/// The same shape with no wgpu in it at all, which is what makes this a
|
||||
/// loader/driver bug rather than a wgpu one.
|
||||
fn raw_vulkan_open_and_close() {
|
||||
unsafe {
|
||||
let entry = ash::Entry::load().expect("vulkan loader");
|
||||
|
||||
@@ -1,55 +1,13 @@
|
||||
//! Ask a device whether it can give iris the GPU it asks for.
|
||||
//!
|
||||
//! Until 2026-09-04 iris's renderer bound every texture it had drawn as one
|
||||
//! binding array and indexed it non-uniformly from the shader, which needed
|
||||
//! descriptor indexing and a very large per-stage binding-array limit
|
||||
//! (101,000 elements: 100,000 textures and 1,000 samplers,
|
||||
//! `UiLimits::default`). That was ordinary on a desktop and, per
|
||||
//! TEXTURES.md's "iris's binding array does not survive real Android
|
||||
//! hardware", not available on a real share of Android GPUs -- and it failed
|
||||
//! outright on this emulator's software Vulkan, which is what this rig
|
||||
//! caught first. iris now asks for nothing beyond wgpu's own defaults (see
|
||||
//! `iris/src/default/render.rs`): the glyph atlas is one `texture_2d_array`
|
||||
//! and a standalone image is its own ordinary bind group, and neither needs
|
||||
//! descriptor indexing. This rig still asks `request_device` for exactly
|
||||
//! what iris asks for, so it keeps being the answer to "does iris's actual
|
||||
//! device request succeed here" rather than a guess from reading the code.
|
||||
//!
|
||||
//! It runs as a plain executable with no window and no APK, because
|
||||
//! `request_adapter` needs no surface -- so it can be pushed to a device with
|
||||
//! `adb push` and run from `/data/local/tmp`, which is far cheaper than an
|
||||
//! app. What it therefore cannot answer is anything about presenting to a
|
||||
//! surface; that is the Android backend's own problem.
|
||||
|
||||
mod vk;
|
||||
|
||||
use wgpu::*;
|
||||
|
||||
/// What `iris/src/default/render.rs` asks `request_device` for, now that the
|
||||
/// binding array is gone: nothing beyond wgpu's own default feature set.
|
||||
fn iris_features() -> Features {
|
||||
Features::empty()
|
||||
}
|
||||
|
||||
/// The one non-default limit iris asks for -- unrelated to the binding array,
|
||||
/// kept for the big storage buffers behind rects/glyphs.
|
||||
const IRIS_MAX_BUFFER_SIZE: u64 = 1 << 30;
|
||||
|
||||
/// Mirrors `iris_core::device_limits()` (`iris/core/src/render/mod.rs`) --
|
||||
/// cannot call it directly, since this rig is deliberately its own crate,
|
||||
/// not a workspace member (this file's own Cargo.toml comment). Keep the
|
||||
/// two in sync by hand when one changes; this rig's whole purpose is "does
|
||||
/// the device iris actually builds come back," so a stale copy here would
|
||||
/// silently stop answering that question. Zeroed rather than left at
|
||||
/// `Limits::default()`'s desktop-tier values because nothing in iris
|
||||
/// creates a `ComputePipeline` or a `@compute` shader stage -- found by
|
||||
/// grepping the whole `iris`/`iris-core` tree before this rig's comment was
|
||||
/// written -- and the unconditional default request is what crashed
|
||||
/// `request_device` on the Android emulator's software GL path
|
||||
/// (`EMU_GPU=software`, `force-gles`: SwiftShader's GL reports itself as
|
||||
/// OpenGL ES 3.0, which has no compute shaders at all, so the adapter's
|
||||
/// real limit is 0). The same would happen on a real GLES-3.0-only Android
|
||||
/// device.
|
||||
fn iris_limits() -> Limits {
|
||||
Limits {
|
||||
max_buffer_size: IRIS_MAX_BUFFER_SIZE,
|
||||
@@ -79,10 +37,6 @@ fn main() {
|
||||
" {:?} {} ({:?})",
|
||||
info.backend, info.name, info.device_type
|
||||
);
|
||||
// Compute is a *downlevel* capability, not a feature: Vulkan
|
||||
// grants it to any 1.0 device, and GLES only from ES 3.1. So
|
||||
// "can iris use a compute pass here" is this flag on every
|
||||
// adapter iris might fall back to, not just the preferred one.
|
||||
let down = adapter.get_downlevel_capabilities();
|
||||
let limits = adapter.limits();
|
||||
println!(
|
||||
@@ -143,11 +97,6 @@ fn main() {
|
||||
}
|
||||
);
|
||||
|
||||
// The question that actually matters: does the device iris builds come
|
||||
// back, or does wgpu refuse it? With no features and no binding-array
|
||||
// limits requested, this is expected to succeed everywhere -- this rig
|
||||
// is what turned that from an assumption into a measurement, first on
|
||||
// this emulator's software Vulkan.
|
||||
let wanted = iris_limits();
|
||||
match pollster::block_on(adapter.request_device(&DeviceDescriptor {
|
||||
required_features: iris_features(),
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
//! The raw Vulkan half of the probe.
|
||||
//!
|
||||
//! wgpu reports a feature only after a chain of its own decisions -- which
|
||||
//! physical device, which API version, which extension list -- so "wgpu says
|
||||
//! no" and "the driver says no" are different claims. This asks
|
||||
//! `vkGetPhysicalDeviceFeatures2` itself and prints the inputs to that chain,
|
||||
//! so a disagreement can be attributed rather than guessed at.
|
||||
|
||||
use ash::{Entry, vk};
|
||||
use std::ffi::CStr;
|
||||
|
||||
@@ -29,9 +21,6 @@ pub fn report() {
|
||||
println!("\nraw vulkan:");
|
||||
println!(" loader instance version: {}", ver(instance_version));
|
||||
|
||||
// Ask for the highest instance version the loader admits to: wgpu clamps
|
||||
// the device version by the instance's, so an instance created at 1.0
|
||||
// makes a 1.3 device look like 1.0.
|
||||
let app_info = vk::ApplicationInfo::default().api_version(instance_version);
|
||||
let create = vk::InstanceCreateInfo::default().application_info(&app_info);
|
||||
let instance = match unsafe { entry.create_instance(&create, None) } {
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
# The UI profiling rigs: what a frame costs on the CPU, and what each GPU
|
||||
# arena costs to upload. Layer 1 of docs/RUST.md's "Three test layers" --
|
||||
# the real transcript screen over the real bench fixture, with no window,
|
||||
# no compositor and no GPU.
|
||||
#
|
||||
# **Its own crate so a rig's dependencies stay out of the app's** (Iris,
|
||||
# 2026-09-09). `bytemuck` is here because `arena_churn` reads the arenas
|
||||
# as bytes; nothing in `ai-app` needs it, and a dev-dependency there would
|
||||
# put it in the graph of every `cargo test` the app runs.
|
||||
#
|
||||
# Deliberately not a member of any workspace, for the same reason
|
||||
# `gpu-probe` is not: `iris/` is meant to stay reconcilable with the
|
||||
# upstream iris tree, and these belong to ai-app.
|
||||
# Standalone so profiling dependencies stay out of the app and iris workspaces.
|
||||
[package]
|
||||
name = "ui-profile"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
[dev-dependencies]
|
||||
ai-app = { path = "../../../app-rust" }
|
||||
iris = { path = "../../../iris" }
|
||||
bytemuck = "1"
|
||||
@@ -1,11 +1,4 @@
|
||||
# iris needs nightly (see the #![feature] list in core/src/lib.rs and src/lib.rs).
|
||||
# The pin is dated rather than "nightly" because the const-traits feature set
|
||||
# changes shape between nightlies: on 2026-09-04 the vendored January tree would
|
||||
# not parse at all, because `impl const Trait for T` had become
|
||||
# `const impl Trait for T`. A rolling channel turns that into a build that
|
||||
# breaks unattended on whatever machine Dev Updater happens to build on.
|
||||
# Advance this deliberately, with the feature list in RUST.md's I0b.
|
||||
[toolchain]
|
||||
channel = "nightly-2026-09-03"
|
||||
channel = "nightly"
|
||||
components = ["clippy", "rustfmt"]
|
||||
targets = ["aarch64-linux-android", "x86_64-linux-android"]
|
||||
@@ -1,10 +1,5 @@
|
||||
//! The percentile and summary printing both rigs share, so two runs'
|
||||
//! output can be read side by side.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// The `p`th percentile of `sorted`, in milliseconds. Sorts in place, so
|
||||
/// a caller keeping its samples passes a clone.
|
||||
pub fn pct(sorted: &mut [Duration], p: f64) -> f64 {
|
||||
if sorted.is_empty() {
|
||||
return 0.0;
|
||||
@@ -14,9 +9,6 @@ pub fn pct(sorted: &mut [Duration], p: f64) -> f64 {
|
||||
sorted[i].as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
/// Half a 120Hz frame -- the budget these rigs judge a *CPU* sample
|
||||
/// against, since layer 1 measures only the build phase and a frame has
|
||||
/// to acquire and submit as well.
|
||||
pub const CPU_BUDGET: Duration = Duration::from_micros(8_333);
|
||||
|
||||
pub fn summarise(name: &str, samples: &[Duration]) {
|
||||
@@ -33,7 +25,6 @@ pub fn summarise(name: &str, samples: &[Duration]) {
|
||||
);
|
||||
}
|
||||
|
||||
/// The percentile of a plain count (bytes, calls) rather than a duration.
|
||||
pub fn pct_u64(v: &mut [u64], p: f64) -> u64 {
|
||||
if v.is_empty() {
|
||||
return 0;
|
||||
|
||||
@@ -1,51 +1,17 @@
|
||||
//! What each GPU arena costs to upload per frame, at layer 1 (docs/RUST.md's
|
||||
//! "Three test layers") -- the real transcript screen over the real bench
|
||||
//! fixture, with no window, no compositor and no GPU.
|
||||
//!
|
||||
//! cargo test --release --test arena_churn -- --ignored --nocapture
|
||||
//!
|
||||
//! from `scripts/rigs/ui-profile/`.
|
||||
//!
|
||||
//! It exists because the upload is the one part of a frame that layer 1
|
||||
//! *builds* and never performs, so `frame_profile.rs` cannot see it at
|
||||
//! all: the emulator's `stream: build p50` stayed at 10.5ms across a
|
||||
//! change that nearly halved layer 1's CPU frame, and nothing could say
|
||||
//! why until this could count bytes.
|
||||
//!
|
||||
//! Three numbers per array per frame, which is the point of the rig --
|
||||
//! any two of them alone are misleading:
|
||||
//!
|
||||
//! - **changed** is the floor: entries whose bytes actually differ from
|
||||
//! the previous frame, found by diffing. Nothing correct can upload
|
||||
//! less.
|
||||
//! - **uploaded** is what `iris` really writes, read from the same
|
||||
//! `Dirty` sets `UiRenderNode::update` consumes and cleared here the
|
||||
//! way an upload would clear them. Above `changed` by whatever the
|
||||
//! marking over-marks plus whatever range coalescing pulls in.
|
||||
//! - **whole** is what the old code wrote every time anything changed.
|
||||
//!
|
||||
//! `#[ignore]`d and assertion-free: it prints distributions, so
|
||||
//! `run-tests.sh` neither runs it nor can fail on it.
|
||||
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::Harness;
|
||||
use iris::prelude::{GlyphPrimitive, Primitive, RectPrimitive};
|
||||
use iris::widget::Scrollable;
|
||||
use ui_profile::stats::pct_u64;
|
||||
|
||||
/// Passes over the same content, alternating direction -- the same
|
||||
/// out-and-back `frame_profile.rs`'s fling drives, so the two rigs
|
||||
/// describe the same gesture.
|
||||
const PASSES: usize = 8;
|
||||
const VELOCITY: f32 = 12_000.0;
|
||||
const PASS_CAP_MS: u64 = 4_000;
|
||||
|
||||
/// One array's per-frame totals.
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
name: &'static str,
|
||||
stride: usize,
|
||||
/// The previous frame's bytes, for the diff that finds the floor.
|
||||
prev: Vec<u8>,
|
||||
changed: Vec<u64>,
|
||||
uploaded: Vec<u64>,
|
||||
@@ -62,7 +28,6 @@ impl Tally {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records one frame, and clears the dirty set as an upload would.
|
||||
fn frame(&mut self, bytes: &[u8], ranges: Vec<std::ops::Range<usize>>) {
|
||||
let n = self.prev.len().min(bytes.len()) / self.stride;
|
||||
let mut changed = (0..n)
|
||||
@@ -71,8 +36,6 @@ impl Tally {
|
||||
self.prev[r.clone()] != bytes[r]
|
||||
})
|
||||
.count();
|
||||
// Everything past the old end is new, and so is dirty by
|
||||
// definition.
|
||||
changed += bytes.len() / self.stride - n;
|
||||
self.changed.push((changed * self.stride) as u64);
|
||||
self.uploaded
|
||||
@@ -85,11 +48,8 @@ impl Tally {
|
||||
|
||||
fn report(&mut self) {
|
||||
let sum = |v: &[u64]| v.iter().sum::<u64>();
|
||||
let (changed, uploaded, whole) = (
|
||||
sum(&self.changed),
|
||||
sum(&self.uploaded),
|
||||
sum(&self.whole),
|
||||
);
|
||||
let (changed, uploaded, whole) =
|
||||
(sum(&self.changed), sum(&self.uploaded), sum(&self.whole));
|
||||
println!(
|
||||
" {:<10} whole {:>7.1} MB | uploaded {:>7.1} MB ({:>5.1}%) | floor {:>7.1} MB ({:>5.1}%)",
|
||||
self.name,
|
||||
@@ -114,9 +74,6 @@ impl Tally {
|
||||
}
|
||||
}
|
||||
|
||||
/// The three arenas a transcript frame writes. Masks and move offsets are
|
||||
/// left out deliberately: they are a hundred-odd entries, so their whole
|
||||
/// buffer is smaller than one range of any of these.
|
||||
struct Arenas {
|
||||
instances: Tally,
|
||||
rects: Tally,
|
||||
@@ -132,15 +89,11 @@ impl Arenas {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads this frame's dirty ranges out of the render state and clears
|
||||
/// them, exactly as `UiRenderNode::update` would on a real backend.
|
||||
fn frame(&mut self, h: &mut Harness) {
|
||||
let count = h.render.primitives.instances().len();
|
||||
let (entries, dirty) = h.render.primitives.instances_for_upload();
|
||||
// `PrimitiveInstance` is not exported, so the stride comes from
|
||||
// the slice rather than from `size_of`.
|
||||
let bytes: Vec<u8> = bytemuck::cast_slice(entries).to_vec();
|
||||
self.instances.stride = if count == 0 { 48 } else { bytes.len() / count };
|
||||
self.instances.stride = bytes.len().checked_div(count).unwrap_or(48);
|
||||
let ranges = dirty.ranges(count, 1024 / self.instances.stride);
|
||||
dirty.clear();
|
||||
self.instances.frame(&bytes, ranges);
|
||||
@@ -196,7 +149,6 @@ fn what_a_fling_uploads() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A moment at rest between passes, as a finger would leave.
|
||||
t += 200;
|
||||
}
|
||||
println!("\na fling, {PASSES} passes:");
|
||||
|
||||
@@ -1,44 +1,11 @@
|
||||
//! Profiling runs rather than tests: what a frame costs on the CPU, at
|
||||
//! layer 1 (docs/RUST.md's "Three test layers") -- the real
|
||||
//! transcript screen over the real bench fixture, with no window, no
|
||||
//! compositor and no GPU, on a clock this file owns. It exists so "the
|
||||
//! fling stutters" can be attributed rather than guessed at, and it is
|
||||
//! kept between investigations rather than rewritten each time (Iris,
|
||||
//! 2026-09-09: "please keep the profiling rig around for future use").
|
||||
//!
|
||||
//! cargo test --release --test frame_profile -- --ignored --nocapture
|
||||
//!
|
||||
//! from `scripts/rigs/ui-profile/`.
|
||||
//!
|
||||
//! Two runs today: `what_a_fling_frame_costs` (scrolling over transcript
|
||||
//! that is already folded) and `what_a_streamed_event_costs` (a reply
|
||||
//! arriving into it).
|
||||
//!
|
||||
//! `#[ignore]`d because it asserts nothing -- it prints a distribution,
|
||||
//! so `run-tests.sh` neither runs it nor can fail on it. **Release, or
|
||||
//! the numbers mean nothing**: layout is dominated by text shaping, which
|
||||
//! is an order of magnitude slower unoptimised.
|
||||
//!
|
||||
//! What it cannot answer: anything about the GPU, the present queue, or
|
||||
//! the phone's own clock. It measures the CPU half of a frame, which is
|
||||
//! where `cpu_p50` in a phone bench report comes from.
|
||||
|
||||
use ai_app::ui::fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size};
|
||||
use iris::harness::{Harness, TouchScript};
|
||||
use iris::prelude::*;
|
||||
use std::time::{Duration, Instant};
|
||||
use ui_profile::stats::summarise;
|
||||
|
||||
/// Passes over the same content, alternating direction. More than two
|
||||
/// because the question the rig was built for is whether a frame's cost
|
||||
/// is first-time work (which the first pass pays and the rest do not) or
|
||||
/// work repeated every time a row comes back on screen.
|
||||
const PASSES: usize = 8;
|
||||
/// The velocity `bench_client.rs`'s fling phase uses, so a number here
|
||||
/// and a number in a phone report describe the same gesture.
|
||||
const VELOCITY: f32 = 12_000.0;
|
||||
/// A fling settles on the spline's own schedule (~2s at this velocity);
|
||||
/// this only stops a pass that somehow never settles from running away.
|
||||
const PASS_CAP_MS: u64 = 4_000;
|
||||
|
||||
#[test]
|
||||
@@ -49,9 +16,8 @@ fn what_a_fling_frame_costs() {
|
||||
h.frame(0);
|
||||
h.frame(PHONE_FRAME_MS);
|
||||
|
||||
// The recorded flick first, so the velocity a real finger produces is
|
||||
// in the log beside the scripted passes below.
|
||||
let flick = TouchScript::parse(include_str!("../../../../app-rust/touch/flick-120hz.touch")).unwrap();
|
||||
let flick =
|
||||
TouchScript::parse(include_str!("../../../../app-rust/touch/flick-120hz.touch")).unwrap();
|
||||
h.replay(&flick);
|
||||
println!(
|
||||
"recorded flick released at {:?}px/s; scripted passes run at {VELOCITY}px/s",
|
||||
@@ -62,8 +28,6 @@ fn what_a_fling_frame_costs() {
|
||||
let mut all_frames = Vec::new();
|
||||
let mut all_layouts = Vec::new();
|
||||
for pass in 0..PASSES {
|
||||
// Away from the newest end on the even passes and back on the
|
||||
// odd ones, the same out-and-back the bench's fling phase drives.
|
||||
let velocity = if pass % 2 == 0 { VELOCITY } else { -VELOCITY };
|
||||
let list = (opened.screen.list)(&mut h.rsc);
|
||||
list.fling(velocity);
|
||||
@@ -95,7 +59,6 @@ fn what_a_fling_frame_costs() {
|
||||
summarise("frame", &frames);
|
||||
all_frames.extend(frames);
|
||||
all_layouts.extend(layouts);
|
||||
// A moment at rest between passes, as a finger would leave.
|
||||
t += 200;
|
||||
}
|
||||
|
||||
@@ -107,15 +70,6 @@ fn what_a_fling_frame_costs() {
|
||||
summarise("layout", &all_layouts);
|
||||
}
|
||||
|
||||
/// The other half of a bench run, and since 2026-09-09 the expensive one:
|
||||
/// what it costs to fold one arriving event into the transcript and show
|
||||
/// it. The bench's stream phase measured `build p50 9.5ms` on Iris's
|
||||
/// phone against a fling's 0.4ms, so this is where the frame time now is.
|
||||
///
|
||||
/// Reports the fold and the widget-tree apply separately, because they
|
||||
/// are different problems with different fixes -- and reports how the
|
||||
/// cost moves as the transcript grows, which is the shape that says
|
||||
/// whether the work is per-event or per-event-times-transcript.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn what_a_streamed_event_costs() {
|
||||
@@ -134,8 +88,6 @@ fn what_a_streamed_event_costs() {
|
||||
let mut fold = Vec::new();
|
||||
let mut apply = Vec::new();
|
||||
let mut frame = Vec::new();
|
||||
// Split by whether the delta started a new markdown block, since that
|
||||
// is the delta that builds a widget rather than re-shaping one.
|
||||
let mut frame_same_block = Vec::new();
|
||||
let mut frame_new_block = Vec::new();
|
||||
let mut t = PHONE_FRAME_MS;
|
||||
@@ -171,8 +123,6 @@ fn what_a_streamed_event_costs() {
|
||||
frame_same_block.push(took);
|
||||
}
|
||||
|
||||
// Where the cost sits as the transcript grows -- one line early,
|
||||
// one late, is enough to see a per-event cost from a quadratic.
|
||||
if n == 0 || n == opened.stream_tail.len() - 1 {
|
||||
println!(
|
||||
" event {n:>3} of {}: items={} fold {:?} apply {:?}",
|
||||
@@ -189,36 +139,18 @@ fn what_a_streamed_event_costs() {
|
||||
summarise("frame", &frame);
|
||||
summarise("frame/same-block", &frame_same_block);
|
||||
summarise("frame/new-block", &frame_new_block);
|
||||
// What the GPU side has to carry, which layer 1 builds but never
|
||||
// uploads and so cannot time: every primitive is re-uploaded whenever
|
||||
// the arena changes, and the buffer is recreated when its length does
|
||||
// (`ArrBuf::update`). Splitting the streamed reply into blocks trades
|
||||
// shaping cost for more widgets, so this is the number that says
|
||||
// whether that trade is free on a real GPU path.
|
||||
println!(
|
||||
" primitives on screen at the end: {}",
|
||||
h.render.active_primitive_count()
|
||||
);
|
||||
}
|
||||
|
||||
/// What re-shaping a *growing* message costs, isolated from everything
|
||||
/// else a frame does -- the measurement that decides whether an
|
||||
/// incremental-text design would pay for itself (Iris, 2026-09-09:
|
||||
/// "we should definitely investigate incremental text rendering").
|
||||
///
|
||||
/// Grows one text buffer a delta at a time, the way a streamed reply
|
||||
/// grows one row, and reports what `TextBuffer::shape` costs at each
|
||||
/// length. Linear per-delta cost means the total over a reply is
|
||||
/// quadratic in its length, which is the thing an incremental shaper
|
||||
/// would remove.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn what_reshaping_a_growing_message_costs() {
|
||||
use iris::prelude::*;
|
||||
|
||||
let mut h = Harness::new(phone_size(), PHONE_SCALE);
|
||||
// A reply-sized paragraph built a delta at a time. The deltas are
|
||||
// words rather than characters because that is what a model streams.
|
||||
const DELTA: &str = "the quick brown fox jumps over the lazy dog ";
|
||||
let attrs = TextAttrs::default();
|
||||
let width = Some(phone_size().x);
|
||||
@@ -250,13 +182,6 @@ fn what_reshaping_a_growing_message_costs() {
|
||||
);
|
||||
summarise("reshape", &per_delta);
|
||||
|
||||
// The same measurement at the sizes real replies actually reach.
|
||||
// Measured 2026-09-09 over 7,706 top-level blocks from 3,675 real
|
||||
// assistant messages on this machine: p50 147 chars, p90 449, p99
|
||||
// 836, largest 1,580, and *nothing* above 4,000. The bench fixture's
|
||||
// streamed message is one 14,888-character block, which is 9x the
|
||||
// largest real one -- so the sizes below are what a live reshape
|
||||
// actually costs and the run above is what the benchmark measures.
|
||||
println!(" at the sizes real replies reach:");
|
||||
for chars in [147usize, 449, 836, 1580] {
|
||||
let mut sample = String::new();
|
||||
@@ -271,19 +196,11 @@ fn what_reshaping_a_growing_message_costs() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a streamed delta's cost actually is, given that `RowBlocks::
|
||||
/// apply_delta` already re-shapes only the block the delta landed in.
|
||||
/// Three candidates, all of which scale with the *whole* message rather
|
||||
/// than the delta: re-parsing the markdown to find the blocks, comparing
|
||||
/// them against the ones already drawn, and re-shaping the last block.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn where_a_streamed_deltas_cost_is() {
|
||||
use ai_app::client::markdown_blocks::{common_prefix, split_blocks};
|
||||
|
||||
// A reply with real block structure -- paragraphs separated by blank
|
||||
// lines, the way a model writes -- so the last block is one paragraph
|
||||
// rather than the whole message.
|
||||
const SENTENCE: &str = "The quick brown fox jumps over the lazy dog. ";
|
||||
let mut src = String::new();
|
||||
let mut blocks = Vec::new();
|
||||
@@ -292,8 +209,6 @@ fn where_a_streamed_deltas_cost_is() {
|
||||
let mut compare = Vec::new();
|
||||
for n in 1..=400 {
|
||||
src.push_str(SENTENCE);
|
||||
// A paragraph break every eight deltas, so the trailing block
|
||||
// stays a normal size and only the message grows.
|
||||
if n % 8 == 0 {
|
||||
src.push_str("\n\n");
|
||||
}
|
||||
@@ -323,8 +238,6 @@ fn where_a_streamed_deltas_cost_is() {
|
||||
println!(" 400 deltas: {total:?} in block-splitting and comparison alone");
|
||||
}
|
||||
|
||||
/// What the bench fixture's streamed tail actually is, since the cost of
|
||||
/// a delta depends entirely on how big the block it lands in gets.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn what_the_fixture_streams() {
|
||||
@@ -340,8 +253,6 @@ fn what_the_fixture_streams() {
|
||||
items = ai_app::client::transcript_fold::fold_event(&items, event);
|
||||
}
|
||||
println!("{} items -> {}", before, items.len());
|
||||
// The stress message the generator plants in the backlog: one block,
|
||||
// no blank line, just under `text_cap`'s MESSAGE_BYTES.
|
||||
let biggest = backlog
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
@@ -362,8 +273,6 @@ fn what_the_fixture_streams() {
|
||||
" backlog's largest single block: {longest} chars (in a {chars}-char message of {blocks} blocks)"
|
||||
);
|
||||
}
|
||||
// The last few items are where the stream landed. Only the message
|
||||
// variants matter -- those are what a delta appends to.
|
||||
for item in items.iter().rev().take(4) {
|
||||
let (kind, text) = match item {
|
||||
TranscriptItem::AssistantMsg { text, .. } => ("AssistantMsg", text.clone()),
|
||||
|
||||
+1
-19
@@ -1,23 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Runs this repo's Rust tests. Extra arguments are forwarded to each
|
||||
# `cargo test`, e.g. `scripts/run-tests.sh transcript` to run just the transcript
|
||||
# tests in every workspace.
|
||||
#
|
||||
# Three workspaces, in dependency order:
|
||||
#
|
||||
# event-model the wire shape server/ and app-rust/ share, so the two
|
||||
# agree by construction rather than by review
|
||||
# server/ the backend's own logic (event normalization, transcript
|
||||
# cursors, config persistence, token auth)
|
||||
# app-rust/ the app itself: `client` (highlighter, ANSI parser,
|
||||
# transcript cache and fold, REST and SSE clients -- see
|
||||
# docs/CLIENT_CORE.md) and `ui` (the screens, drawn with
|
||||
# iris), plus the headless harness tests over the bench
|
||||
# fixture that need no window and no GPU.
|
||||
#
|
||||
# `iris/` is the UI framework and has its own tests; run them from there
|
||||
# (`cd iris && cargo test`). They are not in this loop because iris is not
|
||||
# about this product and its suite is the slower of the two.
|
||||
# Extra arguments are forwarded to each workspace's `cargo test`.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
for workspace in event-model server app-rust; do
|
||||
|
||||
@@ -35,7 +35,6 @@ SERVER_UDP_IP=10.99.0.1
|
||||
CLIENT_UDP_IP=10.99.0.2
|
||||
LISTEN_PORT=51820
|
||||
KEYDIR=/run/ai-app-wg-test
|
||||
# This script lives in scripts/; everything it names is under the root.
|
||||
REPO=$(cd "$(dirname "$0")/.." && pwd)
|
||||
|
||||
up() {
|
||||
@@ -62,9 +61,7 @@ up() {
|
||||
sudo ip addr add "$SERVER_WG_IP/24" dev "$WG_SERVER"
|
||||
sudo ip link set "$WG_SERVER" up
|
||||
|
||||
# Created in the main namespace, then moved: a wireguard interface keeps
|
||||
# its UDP socket in the namespace it was born in, which is exactly what
|
||||
# lets the "phone" reach the server's veth address from inside its own.
|
||||
# Moving the interface preserves its birth namespace's UDP socket.
|
||||
echo "==> Creating $WG_CLIENT (phone side, $CLIENT_WG_IP) in netns '$NS'"
|
||||
sudo ip link add "$WG_CLIENT" type wireguard
|
||||
sudo ip link set "$WG_CLIENT" netns "$NS"
|
||||
|
||||
@@ -83,7 +83,6 @@ ListenPort = $PORT
|
||||
PrivateKey = $(cat "$WG_DIR/server.key")
|
||||
|
||||
[Peer]
|
||||
# phone
|
||||
PublicKey = $(cat "$PEER_DIR/phone.pub")
|
||||
AllowedIPs = $PHONE_IP/32
|
||||
EOF
|
||||
@@ -99,8 +98,7 @@ PublicKey = $(cat "$WG_DIR/server.pub")
|
||||
Endpoint = $ENDPOINT:$PORT
|
||||
# Split tunnel: only the backend's subnet goes over WireGuard.
|
||||
AllowedIPs = $SUBNET
|
||||
# Keeps the mapping alive through home NAT so the backend can reach the
|
||||
# phone first (needed later for "your turn" push).
|
||||
# Keep the phone's NAT mapping reachable from the server.
|
||||
PersistentKeepalive = 25
|
||||
EOF
|
||||
|
||||
|
||||
@@ -3,14 +3,7 @@ name = "xtask"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# E5 (RUST.md): packages app/shellApp into a signed, installable APK without
|
||||
# Gradle driving the assembly (cargo ndk -> javac -> d8 -> aapt2 -> zipalign
|
||||
# -> apksigner). No dependencies beyond the standard library: every step
|
||||
# below is "run this SDK tool with these arguments and check its exit
|
||||
# status," which needs nothing a crate would help with, and every tool
|
||||
# invoked is one this project already requires (the NDK, the SDK
|
||||
# build-tools, the JDK, `cargo ndk`) -- see AGENTS.md's "new dependencies
|
||||
# need a reason."
|
||||
# Packages the Compose shell APK directly with existing SDK/JDK tools.
|
||||
[[bin]]
|
||||
name = "xtask"
|
||||
path = "src/main.rs"
|
||||
@@ -1,26 +1,3 @@
|
||||
//! The pipeline itself: `cargo ndk` -> `javac`/`d8` -> `aapt2` ->
|
||||
//! `zipalign` -> `apksigner`, with no Gradle driving *this* file's steps.
|
||||
//!
|
||||
//! **One disclosed exception**, recorded here rather than left to be
|
||||
//! rediscovered: step 3 below still runs `./gradlew
|
||||
//! :shellApp:printRuntimeClasspathJars` once, because `app/shellApp`
|
||||
//! depends on the `:link` submodule (Kotlin: `ServerStore`/`ServerSettings`,
|
||||
//! the Keystore-sealed enrollment, RUST.md's E3 entry explains why that
|
||||
//! code is reused rather than re-derived in Rust) and on
|
||||
//! `androidx.core:core-ktx` (used at runtime through JNI by
|
||||
//! the shell bridge's `notify.rs`, for `NotificationCompat` and friends).
|
||||
//! Both are ordinary Maven/AAR dependency graphs, and reimplementing a
|
||||
//! dependency resolver to avoid one Gradle invocation was not a good trade
|
||||
//! against "smallest honest route" (RUST.md's E5 box) -- especially since
|
||||
//! that one call also compiles `:link`'s Kotlin as a side effect, using
|
||||
//! Gradle's own embedded Kotlin compiler. This machine has no standalone
|
||||
//! `kotlinc` (checked: not on PATH, not under any SDK), so that side
|
||||
//! effect is what answers E3's open question about `kotlinc` -- see
|
||||
//! RUST.md's E5 entry for the full account. Nothing past this one call
|
||||
//! touches Gradle: `javac`, `d8`, `aapt2`, `zipalign` and `apksigner` are
|
||||
//! invoked directly, and the jars this call resolves are consumed as
|
||||
//! plain binary inputs to `d8`, exactly like any other pre-built `.jar`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
@@ -43,10 +20,6 @@ pub fn build(variant: Variant, abis: &[String]) -> Result<PathBuf, Fail> {
|
||||
sdk::require_ndk_installed(&sdk.root)?;
|
||||
require_cargo_ndk()?;
|
||||
|
||||
// xtask's own intermediate files, in its own crate's target/ rather
|
||||
// than a `target/` at the repo root -- there is no workspace there and
|
||||
// a build directory in the root is not something anybody was looking
|
||||
// for (Iris, 2026-09-09).
|
||||
let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("target")
|
||||
.join("apk");
|
||||
@@ -98,16 +71,6 @@ pub fn build(variant: Variant, abis: &[String]) -> Result<PathBuf, Fail> {
|
||||
let signed_apk = out_dir.join(format!("ai-app-shell-{variant_name}.apk"));
|
||||
align_and_sign(&sdk, &merged_apk, &signed_apk, &signer)?;
|
||||
|
||||
// Copied into a Gradle-shaped path (`build/outputs/apk/<mode>/*.apk`
|
||||
// under this xtask's own directory) as the final step, purely so Dev
|
||||
// Updater's fixed-pattern APK discovery (`discover.rs`'s
|
||||
// `APK_PATTERNS`, which has no per-component path override) finds it
|
||||
// without needing a change on that side -- `.dev-updater.ron`'s
|
||||
// `shell` component points its `cwd` here. The working files above
|
||||
// stay under `scripts/xtask/target/apk/`, an ordinary build-cache location.
|
||||
// `scripts/build/...`, not `scripts/xtask/build/...`: Dev Updater
|
||||
// discovers APKs with `*/build/outputs/apk/*/*.apk` from the checkout
|
||||
// root, which is exactly one directory deep. See `.dev-updater.ron`.
|
||||
let published_dir = repo_root
|
||||
.join("scripts/build/outputs/apk")
|
||||
.join(variant_name);
|
||||
@@ -131,8 +94,6 @@ pub fn build(variant: Variant, abis: &[String]) -> Result<PathBuf, Fail> {
|
||||
}
|
||||
|
||||
fn repo_root() -> Result<PathBuf, Fail> {
|
||||
// xtask's own Cargo.toml is at <repo_root>/scripts/xtask/Cargo.toml,
|
||||
// so the root is two levels up (2026-09-09: it used to be one).
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
manifest_dir
|
||||
.parent()
|
||||
@@ -156,9 +117,6 @@ fn require_cargo_ndk() -> Result<(), Fail> {
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// `cargo ndk`'s `-t` target name for each ABI, and `-P 26` -- the API
|
||||
/// level every other cross-compile in this repo uses (RUST.md: E0, E1, E3,
|
||||
/// I2), kept consistent here rather than picked fresh.
|
||||
fn build_native_libs(
|
||||
crate_dir: &Path,
|
||||
shell_app_dir: &Path,
|
||||
@@ -173,11 +131,6 @@ fn build_native_libs(
|
||||
cmd.args(["-t", abi]);
|
||||
}
|
||||
cmd.args(["-P", "26", "-o"]).arg(&jni_libs);
|
||||
// Always the release profile for the native library, independent of
|
||||
// the APK's signing variant -- a debug build's Vulkan object-labelling
|
||||
// segfaults this emulator's driver (RUST.md's E1 entry), and there is
|
||||
// no reason for this crate's debug build to be bigger or slower for a
|
||||
// signing choice that has nothing to do with it.
|
||||
cmd.args([
|
||||
"build",
|
||||
"--release",
|
||||
@@ -270,11 +223,6 @@ fn compile_java(
|
||||
"check permissions under scripts/xtask/target/",
|
||||
)
|
||||
})?;
|
||||
// Same shape as shellApp's Gradle `generatePinnedCa` task: the text
|
||||
// block must start immediately after the opening `"""`, or
|
||||
// CertificateFactory stops recognising the "-----BEGIN" preamble (a
|
||||
// real bug this project hit once -- see AGENTS.md's "Things that have
|
||||
// bitten").
|
||||
let pinned_ca_java = format!(
|
||||
"package com.example.aiapp.shell;\n\npublic final class PinnedCa {{\n private PinnedCa() {{}}\n public static final String PINNED_CA_PEM = \"\"\"\n{ca_pem}\"\"\";\n}}\n"
|
||||
);
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
//! The signing key. Mirrors `app/build-apk.sh`'s exact logic for the
|
||||
//! release key -- same env vars, same path, same generation recipe -- so
|
||||
//! the two tools sign with the *same* key and their outputs can
|
||||
//! `adb install -r` over each other. That is the whole point of E5's pass
|
||||
//! condition: the key has to be identical, not merely present.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -15,10 +9,6 @@ pub struct Signer {
|
||||
pub alias: String,
|
||||
}
|
||||
|
||||
/// The release key at `$AI_APP_KEYSTORE` or
|
||||
/// `$XDG_CONFIG_HOME/ai-app/release.jks` (`~/.config/ai-app/release.jks` by
|
||||
/// default) -- generated with `keytool` if it doesn't exist yet, exactly as
|
||||
/// `build-apk.sh` does, so either tool can run first on a fresh machine.
|
||||
pub fn release_signer() -> Result<Signer, Fail> {
|
||||
let keystore = std::env::var_os("AI_APP_KEYSTORE")
|
||||
.map(PathBuf::from)
|
||||
@@ -188,9 +178,6 @@ fn which_keytool() -> Result<PathBuf, Fail> {
|
||||
}
|
||||
|
||||
fn random_password() -> String {
|
||||
// No dependency on `rand`: /dev/urandom is what build-apk.sh's `head -c
|
||||
// 24 /dev/urandom | base64` reads too, so this reproduces exactly the
|
||||
// same recipe without shelling out to head/base64/tr for it.
|
||||
let mut bytes = [0u8; 24];
|
||||
std::fs::File::open("/dev/urandom")
|
||||
.and_then(|mut f| std::io::Read::read_exact(&mut f, &mut bytes))
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
//! `cargo xtask apk` -- E5 (RUST.md): packages `app/shellApp` into a
|
||||
//! signed, installable APK with no Gradle in the packaging step itself.
|
||||
//! `cargo ndk` cross-compiles the app crate's `shell` feature; `javac`/`d8` turn its two
|
||||
//! Java stub classes (plus the generated pinned-CA constant) into dex;
|
||||
//! `aapt2` compiles the manifest into `resources.arsc`; the dex and native
|
||||
//! libraries are merged into that base APK with `jar`; `zipalign` and
|
||||
//! `apksigner` finish it. See `apk.rs`'s module doc for what "no Gradle in
|
||||
//! the packaging step" does and does not cover -- one disclosed exception.
|
||||
//!
|
||||
//! Usage: `cargo xtask apk [--release|--debug] [--abi ABI]...`
|
||||
|
||||
mod apk;
|
||||
mod keystore;
|
||||
mod sdk;
|
||||
@@ -16,9 +5,6 @@ mod sdk;
|
||||
use std::fmt;
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// A failure a person acts on: what went wrong, what this process actually
|
||||
/// saw, and the next thing to try. Matches CODE_RULES's "a failure message
|
||||
/// names the thing, the cause, and the fix."
|
||||
pub struct Fail {
|
||||
what: String,
|
||||
cause: String,
|
||||
@@ -71,11 +57,6 @@ fn main() -> ExitCode {
|
||||
let mut i = 0;
|
||||
while i < rest.len() {
|
||||
match rest[i].as_str() {
|
||||
// Bare "release"/"debug" is `.dev-updater.ron`'s interface
|
||||
// (`ByMode::One` appends the chosen mode as the build
|
||||
// command's last argument -- the same convention
|
||||
// `app/build-apk.sh`'s `${1:-release}` uses); the `--`-prefixed
|
||||
// spellings are for typing this by hand.
|
||||
"release" | "--release" => variant = Variant::Release,
|
||||
"debug" | "--debug" => variant = Variant::Debug,
|
||||
"--abi" => {
|
||||
@@ -96,9 +77,6 @@ fn main() -> ExitCode {
|
||||
i += 1;
|
||||
}
|
||||
if abis.is_empty() {
|
||||
// arm64-v8a for a real phone, x86_64 for this machine's emulator --
|
||||
// the two ABIs every other experiment in RUST.md has actually run
|
||||
// on. `--abi` overrides either way.
|
||||
abis = vec!["arm64-v8a".to_string(), "x86_64".to_string()];
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user