egtest: spin-driver frame pacing + nice -10; fr_capture.py tool; 0.12% drops at 120Hz (vs sltest 0.33-0.83%)
This commit is contained in:
parent
2cfb32c746
commit
47c5c972fe
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -1865,8 +1865,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "i-slint-backend-android-activity"
|
name = "i-slint-backend-android-activity"
|
||||||
version = "1.18.1"
|
version = "1.18.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "70d538003a566d7312572ac1435be0704934e46a63d55abdabede513f72332cb"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-activity 0.5.2",
|
"android-activity 0.5.2",
|
||||||
"android-activity 0.6.1",
|
"android-activity 0.6.1",
|
||||||
|
|
@ -1875,6 +1873,8 @@ dependencies = [
|
||||||
"i-slint-core",
|
"i-slint-core",
|
||||||
"i-slint-renderer-skia",
|
"i-slint-renderer-skia",
|
||||||
"jni 0.22.4",
|
"jni 0.22.4",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
"ndk 0.9.0",
|
"ndk 0.9.0",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
]
|
]
|
||||||
|
|
@ -2037,8 +2037,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "i-slint-core"
|
name = "i-slint-core"
|
||||||
version = "1.18.1"
|
version = "1.18.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ed2ea15059d70bc31b1a8b1eb4c34f2c5df04cb7e53f36179b43c0261231d646"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"accesskit",
|
"accesskit",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
|
|
@ -2125,8 +2123,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "i-slint-renderer-skia"
|
name = "i-slint-renderer-skia"
|
||||||
version = "1.18.1"
|
version = "1.18.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dc9c1cf02385f1d26fc1f423544e52390a8e5c2d89420a8042aa94b4fe109d1f"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
|
@ -2138,6 +2134,7 @@ dependencies = [
|
||||||
"i-slint-common",
|
"i-slint-common",
|
||||||
"i-slint-core",
|
"i-slint-core",
|
||||||
"i-slint-core-macros",
|
"i-slint-core-macros",
|
||||||
|
"log",
|
||||||
"lyon_path",
|
"lyon_path",
|
||||||
"objc2 0.6.5",
|
"objc2 0.6.5",
|
||||||
"objc2-app-kit 0.3.2",
|
"objc2-app-kit 0.3.2",
|
||||||
|
|
@ -5426,8 +5423,6 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wgpu-hal"
|
name = "wgpu-hal"
|
||||||
version = "30.0.1"
|
version = "30.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_system_properties",
|
"android_system_properties",
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ perf = []
|
||||||
# instrumentation, display locked to 60 Hz via JNI (src/baseline60.rs).
|
# instrumentation, display locked to 60 Hz via JNI (src/baseline60.rs).
|
||||||
# Build: `./scripts/build.sh baseline`.
|
# Build: `./scripts/build.sh baseline`.
|
||||||
baseline60 = [] # marker; the 60Hz lock is a device setting (peak_refresh_rate)
|
baseline60 = [] # marker; the 60Hz lock is a device setting (peak_refresh_rate)
|
||||||
|
# Public-API per-frame logger (FR line: frame gap + render cost). Works in
|
||||||
|
# stock AND vendored builds — the A/B measurement tool for the 60 Hz
|
||||||
|
# baseline-vs-vendored comparison.
|
||||||
|
framelog = []
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
slint-build = "1.18.1"
|
slint-build = "1.18.1"
|
||||||
|
|
|
||||||
2670
harnesses/egtest/Cargo.lock
generated
2670
harnesses/egtest/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -1,45 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "egtest"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
crate-type = ["cdylib", "lib"]
|
|
||||||
|
|
||||||
# egui stack — all stock crates.io, nothing vendored:
|
|
||||||
# egui/egui_glow/egui_winit are released in lockstep (same version).
|
|
||||||
[dependencies]
|
|
||||||
egui = "0.31"
|
|
||||||
egui_glow = { version = "0.31", features = ["winit"] }
|
|
||||||
egui-winit = "0.31"
|
|
||||||
glow = "0.16"
|
|
||||||
winit = { version = "0.30", features = ["android-native-activity"] }
|
|
||||||
android-activity = "0.6"
|
|
||||||
egl = "0.2"
|
|
||||||
libc = "0.2"
|
|
||||||
log = "0.4"
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
|
|
||||||
[package.metadata.android]
|
|
||||||
package = "wow.gmp.egtest"
|
|
||||||
build_targets = ["x86_64-linux-android", "aarch64-linux-android"]
|
|
||||||
|
|
||||||
[package.metadata.android.sdk]
|
|
||||||
min_sdk_version = 26
|
|
||||||
target_sdk_version = 35
|
|
||||||
|
|
||||||
[package.metadata.android.application]
|
|
||||||
label = "Egtest"
|
|
||||||
|
|
||||||
[[package.metadata.android.application.activity]]
|
|
||||||
name = "android.app.NativeActivity"
|
|
||||||
config_changes = "orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|touchscreen"
|
|
||||||
|
|
||||||
[[package.metadata.android.application.activity.meta_data]]
|
|
||||||
name = "android.app.lib_name"
|
|
||||||
value = "egtest"
|
|
||||||
|
|
||||||
[[package.metadata.android.application.activity.intent_filter]]
|
|
||||||
actions = ["android.intent.action.MAIN"]
|
|
||||||
categories = ["android.intent.category.LAUNCHER"]
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Build + install egtest (egui grid, no textures) on the emulator or phone.
|
|
||||||
# ./build.sh # x86_64 (emulator) + install
|
|
||||||
# ./build.sh aarch64 # phone + install
|
|
||||||
# ./build.sh <arch> no-install
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
|
|
||||||
export ANDROID_HOME=$HOME/android-sdk
|
|
||||||
export ANDROID_NDK_ROOT=$HOME/android-sdk/ndk/27.0.12077973
|
|
||||||
export PATH="$HOME/.cargo/bin:$ANDROID_HOME/platform-tools:$PATH"
|
|
||||||
|
|
||||||
SDK_PLATFORM=android-35
|
|
||||||
export ANDROID_JAR=$HOME/android-sdk/platforms/$SDK_PLATFORM/android.jar
|
|
||||||
|
|
||||||
NDK_BIN=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin
|
|
||||||
arg1=${1:-x86_64}
|
|
||||||
arg2=${2:-install}
|
|
||||||
case $arg1 in
|
|
||||||
x86_64) export ANDROID_ABI=x86_64; export CC_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang CXX_x86_64_linux_android=$NDK_BIN/x86_64-linux-android26-clang++ AR_x86_64_linux_android=$NDK_BIN/llvm-ar; target=x86_64-linux-android ;;
|
|
||||||
aarch64) export ANDROID_ABI=arm64-v8a; export CC_aarch64_linux_android=$NDK_BIN/aarch64-linux-android26-clang CXX_aarch64_linux_android=$NDK_BIN/aarch64-linux-android26-clang++ AR_aarch64_linux_android=$NDK_BIN/llvm-ar; target=aarch64-linux-android ;;
|
|
||||||
*) echo "unknown: $arg1"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# 16KB-page kernel (Pixel 9 Pro) rejects .so with p_align that the segment
|
|
||||||
# offsets don't satisfy — pin max-page-size to 4 KB (sltest-verified layout).
|
|
||||||
export RUSTFLAGS="-C link-arg=-Wl,-z,max-page-size=4096"
|
|
||||||
|
|
||||||
export CARGO_APK_RELEASE_KEYSTORE=$HOME/.android/debug.keystore
|
|
||||||
export CARGO_APK_RELEASE_KEYSTORE_PASSWORD=android
|
|
||||||
|
|
||||||
adb=(); [ -n "${ADB_SERIAL:-}" ] && adb=(adb -s "$ADB_SERIAL")
|
|
||||||
|
|
||||||
cargo apk2 build --release --target "$target" --lib
|
|
||||||
apk=target/release/apk/egtest.apk
|
|
||||||
echo "APK: $apk ($(du -h "$apk" | cut -f1))"
|
|
||||||
if [ "$arg2" = install ] && command -v adb >/dev/null; then
|
|
||||||
"${adb[@]}" install -r "$apk"
|
|
||||||
"${adb[@]}" shell am start -n wow.gmp.egtest/android.app.NativeActivity || true
|
|
||||||
fi
|
|
||||||
|
|
@ -1,352 +0,0 @@
|
||||||
//! egtest — the egui port of sltest: 8,580 full-width rows (148 px color
|
|
||||||
//! band + 2 px white separator), vertical flickable with momentum, on a
|
|
||||||
//! 120 Hz panel. The CONTROL experiment for the Slint jank verdict:
|
|
||||||
//! if this is smooth to the user's finger where sltest (identical scene)
|
|
||||||
//! is janky, the Slint render path is the cause and the gallery gets
|
|
||||||
//! ported to egui.
|
|
||||||
//!
|
|
||||||
//! Architecture (idiomatic egui for a long list — what the gallery port
|
|
||||||
//! will use): NO 8,580 live widgets. The scroll offset is app state; each
|
|
||||||
//! frame we paint only the visible rows (+ margin) with the painter.
|
|
||||||
//!
|
|
||||||
//! Loop: winit 0.30 ApplicationHandler. about_to_wait requests a redraw
|
|
||||||
//! every idle tick → we render at the panel rate continuously (also
|
|
||||||
//! measures idle cost; the FR log distinguishes drew=0 vs drew=1).
|
|
||||||
//!
|
|
||||||
//! Instrumentation: FR lines (t, frame gap, cost, drew) via a
|
|
||||||
//! non-blocking file logger (sltest pattern).
|
|
||||||
|
|
||||||
use std::sync::{mpsc, Arc, OnceLock};
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use egui::{Color32, Pos2, Rect, Sense, Ui, Vec2};
|
|
||||||
use winit::application::ApplicationHandler;
|
|
||||||
use winit::event::WindowEvent;
|
|
||||||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
|
||||||
use winit::platform::android::{ActiveEventLoopExtAndroid, EventLoopBuilderExtAndroid};
|
|
||||||
use winit::window::WindowId;
|
|
||||||
|
|
||||||
const ROWS: usize = 8580;
|
|
||||||
const ROW_PX: f32 = 150.0; // 148 color + 2 separator
|
|
||||||
const COLOR_PX: f32 = 148.0;
|
|
||||||
const MARGIN_ROWS: usize = 4; // visible + margin painted each frame
|
|
||||||
const FLING_DECAY: f32 = 0.94;
|
|
||||||
const FLING_MIN: f32 = 8.0; // px/frame — below this the fling ends
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Instrumentation (sltest pattern: non-blocking, batch-flush)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
static T0: OnceLock<Instant> = OnceLock::new();
|
|
||||||
static LAST_FRAME_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
||||||
|
|
||||||
fn now_us() -> u64 {
|
|
||||||
T0.get_or_init(Instant::now).elapsed().as_micros() as u64
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_ms() -> f64 {
|
|
||||||
now_us() as f64 / 1000.0
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LogTx(mpsc::Sender<String>);
|
|
||||||
static LOG: OnceLock<LogTx> = OnceLock::new();
|
|
||||||
|
|
||||||
fn init_log(path: &str) {
|
|
||||||
let path = path.to_string();
|
|
||||||
let (tx, rx) = mpsc::channel::<String>();
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("log-drain".into())
|
|
||||||
.spawn(move || {
|
|
||||||
let mut f = match std::fs::File::create(&path) {
|
|
||||||
Ok(f) => f,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("log: cannot create {path}: {e}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
use std::io::Write as _;
|
|
||||||
loop {
|
|
||||||
match rx.recv() {
|
|
||||||
Ok(line) => {
|
|
||||||
let _ = writeln!(f, "{line}");
|
|
||||||
let _ = f.flush();
|
|
||||||
}
|
|
||||||
Err(_) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
let _ = LOG.set(LogTx(tx));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_line(s: String) {
|
|
||||||
if let Some(l) = LOG.get() {
|
|
||||||
let _ = l.0.send(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Scroll state (the flickable)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
struct Flick {
|
|
||||||
offset: f32, // px scrolled up (0 = top)
|
|
||||||
velocity: Option<f32>, // px per frame while flinging
|
|
||||||
last_delta: f32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Flick {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self { offset: 0.0, velocity: None, last_delta: 0.0 }
|
|
||||||
}
|
|
||||||
fn max_offset(&self, view_h: f32) -> f32 {
|
|
||||||
(ROWS as f32 * ROW_PX - view_h).max(0.0)
|
|
||||||
}
|
|
||||||
/// One physics step, called once per rendered frame.
|
|
||||||
fn step(&mut self, view_h: f32) {
|
|
||||||
if let Some(v) = self.velocity {
|
|
||||||
if v.abs() < FLING_MIN {
|
|
||||||
self.velocity = None;
|
|
||||||
} else {
|
|
||||||
self.offset += v;
|
|
||||||
self.velocity = Some(v * FLING_DECAY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.offset = self.offset.clamp(0.0, self.max_offset(view_h));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
flick: Flick,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self { flick: Flick::new() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ui(&mut self, ui: &mut Ui) {
|
|
||||||
let (rect, resp) =
|
|
||||||
ui.allocate_exact_size(ui.available_rect_before_wrap().size(), Sense::drag());
|
|
||||||
let view_h = rect.height();
|
|
||||||
|
|
||||||
let d = resp.drag_delta();
|
|
||||||
if d != Vec2::ZERO {
|
|
||||||
self.flick.offset -= d.y;
|
|
||||||
self.flick.last_delta = d.y;
|
|
||||||
self.flick.velocity = None;
|
|
||||||
}
|
|
||||||
if resp.drag_released() {
|
|
||||||
// Fling: the last drag delta (px this frame) seeds the velocity.
|
|
||||||
if self.flick.last_delta.abs() > 1.0 {
|
|
||||||
self.flick.velocity = Some(self.flick.last_delta * 1.5);
|
|
||||||
}
|
|
||||||
self.flick.last_delta = 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.flick.step(view_h);
|
|
||||||
// While flinging we need the next frame.
|
|
||||||
if self.flick.velocity.is_some() {
|
|
||||||
ui.ctx().request_repaint();
|
|
||||||
}
|
|
||||||
|
|
||||||
let min_y = self.flick.offset;
|
|
||||||
let max_y = self.flick.offset + view_h;
|
|
||||||
let first = ((min_y / ROW_PX).floor() as usize).saturating_sub(MARGIN_ROWS);
|
|
||||||
let last = ((max_y / ROW_PX).ceil() as usize).saturating_add(MARGIN_ROWS).min(ROWS);
|
|
||||||
let painter = ui.painter_at(rect);
|
|
||||||
painter.rect_filled(rect, 0.0, Color32::WHITE);
|
|
||||||
for r in first..last {
|
|
||||||
let y = r as f32 * ROW_PX - self.flick.offset;
|
|
||||||
if y + ROW_PX < rect.min.y || y > rect.max.y {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let band = Rect::from_min_max(
|
|
||||||
Pos2::new(rect.min.x, y),
|
|
||||||
Pos2::new(rect.max.x, y + COLOR_PX),
|
|
||||||
);
|
|
||||||
let c = if r % 2 == 0 {
|
|
||||||
Color32::from_rgb(60, 90, 130)
|
|
||||||
} else {
|
|
||||||
Color32::from_rgb(130, 70, 90)
|
|
||||||
};
|
|
||||||
painter.rect_filled(band, 0.0, c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// EGL (Android)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
struct Egl {
|
|
||||||
display: *mut std::ffi::c_void,
|
|
||||||
surface: *mut std::ffi::c_void,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_gl(native_window: *mut libc::c_void) -> (Arc<glow::Context>, Egl) {
|
|
||||||
unsafe {
|
|
||||||
let display = egl::get_display(egl::EGL_DEFAULT_DISPLAY).expect("get_display");
|
|
||||||
let (mut maj, mut min) = (0i32, 0i32);
|
|
||||||
assert!(egl::initialize(display, &mut maj, &mut min), "eglInitialize");
|
|
||||||
assert!(egl::bind_api(egl::EGL_OPENGL_ES_API), "eglBindAPI");
|
|
||||||
let attrs: [egl::EGLint; 13] = [
|
|
||||||
egl::EGL_SURFACE_TYPE,
|
|
||||||
egl::EGL_WINDOW_BIT,
|
|
||||||
egl::EGL_RENDERABLE_TYPE,
|
|
||||||
0x00000004 as egl::EGLint, // EGL_OPENGL_ES3_BIT
|
|
||||||
egl::EGL_RED_SIZE, 8,
|
|
||||||
egl::EGL_GREEN_SIZE, 8,
|
|
||||||
egl::EGL_BLUE_SIZE, 8,
|
|
||||||
egl::EGL_ALPHA_SIZE, 8,
|
|
||||||
egl::EGL_NONE,
|
|
||||||
];
|
|
||||||
let config = egl::choose_config(display, &attrs, 1).expect("choose_config");
|
|
||||||
let surface =
|
|
||||||
egl::create_window_surface(display, config, native_window, &[]).expect("surface");
|
|
||||||
let ctx = egl::create_context(
|
|
||||||
display,
|
|
||||||
config,
|
|
||||||
egl::EGL_NO_CONTEXT,
|
|
||||||
&[egl::EGL_CONTEXT_CLIENT_VERSION, 3, egl::EGL_NONE],
|
|
||||||
)
|
|
||||||
.expect("context");
|
|
||||||
assert!(egl::make_current(display, surface, surface, ctx), "make_current");
|
|
||||||
let gl = Arc::new(glow::Context::from_loader_function(|name| {
|
|
||||||
egl::get_proc_address(name) as *mut libc::c_void
|
|
||||||
}));
|
|
||||||
(gl, Egl { display, surface })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// The app / event loop
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
struct Harness {
|
|
||||||
egui_glow: Option<egui_glow::winit::EguiGlow>,
|
|
||||||
window: Option<winit::window::Window>,
|
|
||||||
egl: Option<Egl>,
|
|
||||||
app: App,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApplicationHandler for Harness {
|
|
||||||
fn resumed(&mut self, ael: &ActiveEventLoop) {
|
|
||||||
if self.window.is_none() {
|
|
||||||
let w = ael
|
|
||||||
.create_window(winit::window::WindowAttributes::new().with_title("egtest"))
|
|
||||||
.expect("window");
|
|
||||||
let nw = ael.android_app().native_window().expect("surface").ptr().as_ptr()
|
|
||||||
as *mut libc::c_void;
|
|
||||||
let (gl, e) = init_gl(nw);
|
|
||||||
self.egl = Some(e);
|
|
||||||
self.egui_glow = Some(egui_glow::winit::EguiGlow::new(
|
|
||||||
ael,
|
|
||||||
gl,
|
|
||||||
Some(egui_glow::ShaderVersion::Es300),
|
|
||||||
None,
|
|
||||||
false,
|
|
||||||
));
|
|
||||||
w.request_redraw();
|
|
||||||
self.window = Some(w);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn window_event(&mut self, ael: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
|
||||||
if matches!(event, WindowEvent::RedrawRequested) {
|
|
||||||
self.render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if matches!(event, WindowEvent::CloseRequested) {
|
|
||||||
ael.exit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.window.is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let w = self.window.as_ref().unwrap();
|
|
||||||
let eg = self.egui_glow.as_mut().unwrap();
|
|
||||||
let repaint = eg.on_window_event(w, &event).repaint;
|
|
||||||
if repaint {
|
|
||||||
w.request_redraw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn about_to_wait(&mut self, ael: &ActiveEventLoop) {
|
|
||||||
// winit's RedrawRequested dispatch is dead on this device
|
|
||||||
// (MainEvent::Resume never arrives -> running=false -> the
|
|
||||||
// dispatch is suppressed), so we drive frames ourselves:
|
|
||||||
// render now, then pace the next poll at ~120 Hz.
|
|
||||||
if self.window.is_some() {
|
|
||||||
self.render();
|
|
||||||
ael.set_control_flow(winit::event_loop::ControlFlow::WaitUntil(
|
|
||||||
std::time::Instant::now() + std::time::Duration::from_micros(8333),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Harness {
|
|
||||||
fn render(&mut self) {
|
|
||||||
let Some(w) = self.window.as_ref() else { return };
|
|
||||||
let Some(eg) = self.egui_glow.as_mut() else { return };
|
|
||||||
let egl = self.egl.as_ref().unwrap();
|
|
||||||
let app = &mut self.app;
|
|
||||||
|
|
||||||
let t_enter = now_ms();
|
|
||||||
eg.run(w, |ctx| {
|
|
||||||
egui::CentralPanel::default()
|
|
||||||
.frame(egui::Frame::none().fill(Color32::WHITE))
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
app.ui(ui);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
eg.paint(w);
|
|
||||||
unsafe {
|
|
||||||
egl::swap_buffers(egl.display, egl.surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
let t_exit = now_ms();
|
|
||||||
let prev = LAST_FRAME_US.swap(
|
|
||||||
(t_exit * 1000.0) as u64,
|
|
||||||
std::sync::atomic::Ordering::Relaxed,
|
|
||||||
);
|
|
||||||
let gap = if prev == 0 { 0.0 } else { t_exit - (prev as f64 / 1000.0) };
|
|
||||||
log_line(format!(
|
|
||||||
"FR {:11.2} gap={:7.2} cost={:6.2} drew=1",
|
|
||||||
t_enter,
|
|
||||||
gap,
|
|
||||||
t_exit - t_enter
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[unsafe(no_mangle)]
|
|
||||||
pub fn android_main(app: android_activity::AndroidApp) {
|
|
||||||
init_log("/sdcard/Android/data/wow.gmp.egtest/files/fr.log");
|
|
||||||
// Deadlock canary: if no frame rendered within 15 s, abort for a
|
|
||||||
// tombstone with the main-thread backtrace.
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("canary".into())
|
|
||||||
.spawn(move || {
|
|
||||||
std::thread::sleep(std::time::Duration::from_secs(15));
|
|
||||||
if std::sync::atomic::AtomicBool::new(false).load(std::sync::atomic::Ordering::Relaxed)
|
|
||||||
|| LAST_FRAME_US.load(std::sync::atomic::Ordering::Relaxed) == 0
|
|
||||||
{
|
|
||||||
eprintln!("canary: no frame after 15s — aborting for tombstone");
|
|
||||||
std::process::abort();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok();
|
|
||||||
let mut builder = EventLoop::with_user_event();
|
|
||||||
builder.with_android_app(app);
|
|
||||||
let event_loop = builder.build().expect("event loop");
|
|
||||||
let mut h = Harness { egui_glow: None, window: None, egl: None, app: App::new() };
|
|
||||||
event_loop.run_app(&mut h).expect("run");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
fn main() {
|
|
||||||
eprintln!("egtest is Android-only");
|
|
||||||
}
|
|
||||||
|
|
@ -66,7 +66,7 @@ case $arg1 in
|
||||||
# 60 Hz (src/baseline60.rs). Answers: does the grid jank WITHOUT our
|
# 60 Hz (src/baseline60.rs). Answers: does the grid jank WITHOUT our
|
||||||
# vendored stack? Run: ./scripts/build.sh baseline
|
# vendored stack? Run: ./scripts/build.sh baseline
|
||||||
baseline) target=aarch64-linux-android; export ANDROID_ABI=arm64-v8a
|
baseline) target=aarch64-linux-android; export ANDROID_ABI=arm64-v8a
|
||||||
features=baseline60
|
features="baseline60,framelog"
|
||||||
cp Cargo.toml Cargo.toml.vendored.bak
|
cp Cargo.toml Cargo.toml.vendored.bak
|
||||||
python3 - Cargo.toml <<'PY'
|
python3 - Cargo.toml <<'PY'
|
||||||
import re, sys
|
import re, sys
|
||||||
|
|
|
||||||
24
src/lib.rs
24
src/lib.rs
|
|
@ -1358,18 +1358,18 @@ fn install_render_notifier(window: &Main, wid: u32) {
|
||||||
w.window().request_redraw();
|
w.window().request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Jank-hunt instrumentation (`perf` feature; see
|
// Jank-hunt instrumentation. FR (frame gap + app-side render
|
||||||
// docs/BUGS/scroll-jank-120hz.md). FR = app-side render cost
|
// cost) is on the PUBLIC-API `framelog` feature — it works in
|
||||||
// (BeforeRendering->AfterRendering) + frame gap; STG = event-loop
|
// BOTH the vendored and the stock baseline builds (the A/B
|
||||||
// stage costs. With `perf` off these arms are empty — zero
|
// tool). STG (event-loop stage costs) is vendor-internal and
|
||||||
// overhead.
|
// stays on `perf`.
|
||||||
slint::RenderingState::BeforeRendering => {
|
slint::RenderingState::BeforeRendering => {
|
||||||
#[cfg(feature = "perf")]
|
#[cfg(any(feature = "perf", feature = "framelog"))]
|
||||||
FR_BEFORE_US.store(now_us(), std::sync::atomic::Ordering::Relaxed);
|
FR_BEFORE_US.store(now_us(), std::sync::atomic::Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
slint::RenderingState::AfterRendering => {
|
slint::RenderingState::AfterRendering => {
|
||||||
LAST_PRESENT_US.store(now_us(), std::sync::atomic::Ordering::Relaxed);
|
LAST_PRESENT_US.store(now_us(), std::sync::atomic::Ordering::Relaxed);
|
||||||
#[cfg(feature = "perf")]
|
#[cfg(any(feature = "perf", feature = "framelog"))]
|
||||||
{
|
{
|
||||||
let now = now_us();
|
let now = now_us();
|
||||||
let before = FR_BEFORE_US.load(std::sync::atomic::Ordering::Relaxed);
|
let before = FR_BEFORE_US.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
|
@ -1401,16 +1401,16 @@ fn install_render_notifier(window: &Main, wid: u32) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jank-hunt instrumentation (`perf` feature) — per-frame clock + frame
|
// Per-frame clock + frame bookkeeping for the FR line. On `framelog`
|
||||||
// bookkeeping. See docs/BUGS/scroll-jank-120hz.md.
|
// (public-API only — works in stock AND vendored builds) or `perf`.
|
||||||
#[cfg(feature = "perf")]
|
#[cfg(any(feature = "perf", feature = "framelog"))]
|
||||||
static FR_BEFORE_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
static FR_BEFORE_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
#[cfg(feature = "perf")]
|
#[cfg(any(feature = "perf", feature = "framelog"))]
|
||||||
static FR_LAST_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
static FR_LAST_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
|
||||||
/// Monotonic µs since process start (wall clock could jump on NTP).
|
/// Monotonic µs since process start (wall clock could jump on NTP).
|
||||||
static NOW_T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
static NOW_T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||||
#[cfg(feature = "perf")]
|
#[cfg(any(feature = "perf", feature = "framelog"))]
|
||||||
#[inline]
|
#[inline]
|
||||||
fn now_ms() -> f64 {
|
fn now_ms() -> f64 {
|
||||||
let t0 = NOW_T0.get_or_init(std::time::Instant::now);
|
let t0 = NOW_T0.get_or_init(std::time::Instant::now);
|
||||||
|
|
|
||||||
4002
tools/.fr_wow_gmp_egtest.log
Normal file
4002
tools/.fr_wow_gmp_egtest.log
Normal file
File diff suppressed because it is too large
Load Diff
215
tools/fr_capture.py
Normal file
215
tools/fr_capture.py
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
fr_capture.py — end-to-end 120 Hz scroll-jank capture for FR-line apps
|
||||||
|
(egtest / sltest family).
|
||||||
|
|
||||||
|
The jank_eval/jank_capture pair needs Slint's vendored PRES/VSYNC lines;
|
||||||
|
FR-line harnesses (sltest, egtest) log app-side frame gaps instead. This
|
||||||
|
tool runs the same disciplined capture pipeline and the same referee that
|
||||||
|
produced the sltest reference numbers (drops = present gaps > 1.8x the
|
||||||
|
median, within one process's log):
|
||||||
|
|
||||||
|
fr_capture.py capture --app wow.gmp.egtest --flicks 5 [--tag egui031]
|
||||||
|
|
||||||
|
1. device + build identity (apk sha256, versionName) -> PROVENANCE
|
||||||
|
2. force-stop, DELETE fr.log (one process per file)
|
||||||
|
3. cold launch, verify ResumedActivity
|
||||||
|
4. scripted flicks: N fast swipes (350 ms, 1600 px) with 2 s settles,
|
||||||
|
one slow drag (1200 ms) in the middle
|
||||||
|
5. verify still ResumedActivity, force-stop
|
||||||
|
6. pull the complete log (exactly one process)
|
||||||
|
7. report: provenance + gap verdict + per-drop context
|
||||||
|
(t, gap, neighbours — isolated vs chain) + render-cost stats
|
||||||
|
|
||||||
|
Exit: 0 = no drops, 1 = drops found / invalid capture.
|
||||||
|
fr_capture.py report FILE.log re-analyze a pulled log
|
||||||
|
fr_capture.py --selftest offline validation
|
||||||
|
|
||||||
|
Note (honesty): the gap clock is the APP's clock (swap-completion to
|
||||||
|
swap-completion), not the display slot grid. That is the same referee
|
||||||
|
used for all sltest A/B numbers, which keeps egtest-vs-sltest
|
||||||
|
apples-to-apples; jank_eval's slot grid remains the stricter referee
|
||||||
|
for builds that emit PRES/VSYNC.
|
||||||
|
"""
|
||||||
|
import argparse, hashlib, os, re, subprocess, sys, time
|
||||||
|
|
||||||
|
ADB_SERIAL = os.environ.get("ADB_SERIAL", "")
|
||||||
|
FR_RE = re.compile(r"^FR\s+([\d.]+)\s+gap=\s*([\d.]+)\s+cost=\s*([\d.]+)\s+drew=(\d)")
|
||||||
|
|
||||||
|
def adb(*args, timeout=120):
|
||||||
|
cmd = ["adb"]
|
||||||
|
if ADB_SERIAL: cmd += ["-s", ADB_SERIAL]
|
||||||
|
cmd += list(args)
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||||
|
|
||||||
|
def adb_shell(cmd, timeout=120):
|
||||||
|
return adb("shell", cmd, timeout=timeout)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ analysis
|
||||||
|
def analyze(lines, label="log"):
|
||||||
|
frames = []
|
||||||
|
for ln in lines:
|
||||||
|
m = FR_RE.match(ln.strip())
|
||||||
|
if m:
|
||||||
|
frames.append((float(m.group(1)), float(m.group(2)),
|
||||||
|
float(m.group(3)), int(m.group(4))))
|
||||||
|
if len(frames) < 30:
|
||||||
|
print(f" {label}: only {len(frames)} FR lines — nothing to judge");
|
||||||
|
return None
|
||||||
|
# drop the first frame (gap=0) and process-suspend artifacts
|
||||||
|
gaps = [(t, g, c) for (t, g, c, d) in frames[1:] if 0 < g < 200]
|
||||||
|
if len(gaps) < 20:
|
||||||
|
print(f" {label}: only {len(gaps)} usable gaps — nothing to judge");
|
||||||
|
return None
|
||||||
|
gs = sorted(g for (_, g, _) in gaps)
|
||||||
|
med = gs[len(gs) // 2]
|
||||||
|
p90 = gs[int(len(gs) * 0.90)]
|
||||||
|
p99 = gs[int(len(gs) * 0.99)]
|
||||||
|
mx = gs[-1]
|
||||||
|
thr = 1.8 * med
|
||||||
|
drops = [(t, g) for (t, g, _) in gaps if g > thr]
|
||||||
|
cs = sorted(c for (_, _, c) in gaps)
|
||||||
|
return {
|
||||||
|
"n": len(gaps), "med": med, "p90": p90, "p99": p99, "max": mx,
|
||||||
|
"thr": thr, "drops": drops,
|
||||||
|
"fps": 1000.0 / med,
|
||||||
|
"cost_med": cs[len(cs) // 2], "cost_p99": cs[int(len(cs) * 0.99)],
|
||||||
|
"cost_max": cs[-1],
|
||||||
|
"frames": frames,
|
||||||
|
}
|
||||||
|
|
||||||
|
def print_report(res, title=""):
|
||||||
|
if res is None:
|
||||||
|
return 1
|
||||||
|
drops = res["drops"]
|
||||||
|
pct = 100.0 * len(drops) / res["n"]
|
||||||
|
print(f"\n=== JANK (gap referee, {title}) ===")
|
||||||
|
print(f"frames={res['n']} median gap={res['med']:.2f} ms (~{res['fps']:.0f} fps)")
|
||||||
|
print(f"p90={res['p90']:.2f} p99={res['p99']:.2f} max={res['max']:.2f} "
|
||||||
|
f"threshold={res['thr']:.2f} ms (1.8x med)")
|
||||||
|
print(f"render cost: med={res['cost_med']:.2f} ms p99={res['cost_p99']:.2f} "
|
||||||
|
f"max={res['cost_max']:.2f}")
|
||||||
|
print(f"DROPS: {len(drops)} of {res['n']} ({pct:.2f}%)")
|
||||||
|
if drops:
|
||||||
|
print(f"{'t(ms)':>10} {'gap(ms)':>9} context (prev, next gap)")
|
||||||
|
shown = 0
|
||||||
|
for i, (t, g, c, d) in enumerate(res["frames"][1:], start=1):
|
||||||
|
if g > res["thr"]:
|
||||||
|
p = res["frames"][i - 1][1] if i >= 1 else 0.0
|
||||||
|
n = res["frames"][i + 1][1] if i + 1 < len(res["frames"]) else 0.0
|
||||||
|
kind = "chain" if (p > res["thr"] or n > res["thr"]) else "isolated"
|
||||||
|
print(f"{t:10.1f} {g:9.2f} prev={p:7.2f} next={n:7.2f} {kind}")
|
||||||
|
shown += 1
|
||||||
|
if shown >= 25:
|
||||||
|
print(f"... {len(drops) - 25} more")
|
||||||
|
break
|
||||||
|
return 0 if not drops else 1
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- capture
|
||||||
|
def capture(app, flicks, tag=""):
|
||||||
|
ok = True
|
||||||
|
r = adb_shell("getprop ro.boot.cpu; getprop ro.product.model")
|
||||||
|
print("=== CAPTURE ===")
|
||||||
|
print(f"device: serial={ADB_SERIAL or '(default)'} model={r.stdout.strip()!r}")
|
||||||
|
|
||||||
|
# build identity
|
||||||
|
path = adb_shell(f"pm path {app}").stdout.strip().replace("package:", "").strip()
|
||||||
|
sha = adb_shell(f"sha256sum {path}").stdout.split()[0] if path else "?"
|
||||||
|
ver = adb_shell(f"dumpsys package {app} | grep versionName | head -1").stdout.strip()
|
||||||
|
print(f"app: {app} {ver} apk sha256={sha[:16]}… tag={tag or '-'}")
|
||||||
|
if not path:
|
||||||
|
print("INVALID: package not installed"); return 1
|
||||||
|
|
||||||
|
log = f"/sdcard/Android/data/{app}/files/fr.log"
|
||||||
|
|
||||||
|
adb_shell(f"am force-stop {app}"); time.sleep(1.5)
|
||||||
|
adb_shell(f"rm -f {log}"); time.sleep(0.5)
|
||||||
|
adb_shell(f"am start -n {app}/android.app.NativeActivity")
|
||||||
|
time.sleep(6)
|
||||||
|
r = adb_shell("dumpsys activity activities | grep ResumedActivity")
|
||||||
|
if app not in r.stdout:
|
||||||
|
print("INVALID: app not ResumedActivity after cold launch"); return 1
|
||||||
|
print("launched (ResumedActivity verified)")
|
||||||
|
|
||||||
|
# scripted drags
|
||||||
|
H = 2856
|
||||||
|
flick = f"input swipe 640 {int(H*0.77)} 640 {int(H*0.21)} 350"
|
||||||
|
slow = f"input swipe 640 {int(H*0.77)} 640 {int(H*0.21)} 1200"
|
||||||
|
for i in range(flicks):
|
||||||
|
if i == flicks // 2:
|
||||||
|
adb_shell(slow); time.sleep(3.0)
|
||||||
|
else:
|
||||||
|
adb_shell(flick); time.sleep(2.0)
|
||||||
|
if i % 2 == 0: # flip direction to cover up-scrolls too
|
||||||
|
adb_shell("input swipe 640 600 640 2200 350"); time.sleep(2.0)
|
||||||
|
time.sleep(2.0)
|
||||||
|
|
||||||
|
r = adb_shell("dumpsys activity activities | grep ResumedActivity")
|
||||||
|
adb_shell(f"am force-stop {app}"); time.sleep(1.0)
|
||||||
|
if app not in r.stdout:
|
||||||
|
print("WARNING: app not resumed right before stop (backgrounded?)")
|
||||||
|
|
||||||
|
dest = os.path.join(os.path.dirname(os.path.abspath(__file__)), f".fr_{app.replace('.','_')}.log")
|
||||||
|
r = adb("pull", log, dest, timeout=300)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(f"INVALID: log pull failed: {r.stderr.strip()}"); return 1
|
||||||
|
with open(dest) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
print(f"pulled {len(lines)} lines -> {dest}")
|
||||||
|
res = analyze(lines, label="capture")
|
||||||
|
rc = print_report(res, title=tag or app)
|
||||||
|
return rc
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- selftest
|
||||||
|
def selftest():
|
||||||
|
# synthetic: 1200 frames at 8.33 ms, 4 injected drops (two chains)
|
||||||
|
lines, t, prev = [], 0.0, None
|
||||||
|
import random; random.seed(7)
|
||||||
|
for i in range(1200):
|
||||||
|
gap = 8.33 + random.uniform(-0.15, 0.15)
|
||||||
|
if i in (300, 301, 700, 1100):
|
||||||
|
gap = 25.0
|
||||||
|
t += gap
|
||||||
|
lines.append(f"FR {t:11.2f} gap={gap:7.2f} cost={2.0:6.2f} drew=1")
|
||||||
|
lines.insert(0, "FR 0.00 gap= 0.00 cost=180.00 drew=1")
|
||||||
|
res = analyze(lines)
|
||||||
|
assert res is not None
|
||||||
|
assert res["n"] == 1200, res["n"]
|
||||||
|
assert len(res["drops"]) == 4, res["drops"]
|
||||||
|
assert abs(res["med"] - 8.33) < 0.05
|
||||||
|
# no-drop case
|
||||||
|
lines2, t = [], 0.0
|
||||||
|
for i in range(1200):
|
||||||
|
gap = 8.33 + random.uniform(-0.1, 0.1)
|
||||||
|
t += gap
|
||||||
|
lines2.append(f"FR {t:11.2f} gap={gap:7.2f} cost=2.00 drew=1")
|
||||||
|
res2 = analyze(lines2)
|
||||||
|
assert len(res2["drops"]) == 0
|
||||||
|
# too-small case
|
||||||
|
assert analyze(["FR 0 0 0 1"] * 5) is None
|
||||||
|
print("selftest OK (drop detection, clean case, short-log case)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
sub = ap.add_subparsers(dest="cmd")
|
||||||
|
c = sub.add_parser("capture")
|
||||||
|
c.add_argument("--app", required=True)
|
||||||
|
c.add_argument("--flicks", type=int, default=6)
|
||||||
|
c.add_argument("--tag", default="")
|
||||||
|
r = sub.add_parser("report")
|
||||||
|
r.add_argument("file")
|
||||||
|
ap.add_argument("--selftest", action="store_true")
|
||||||
|
a = ap.parse_args()
|
||||||
|
if a.selftest:
|
||||||
|
return selftest()
|
||||||
|
if a.cmd == "capture":
|
||||||
|
return capture(a.app, a.flicks, a.tag)
|
||||||
|
if a.cmd == "report":
|
||||||
|
with open(a.file) as f:
|
||||||
|
return print_report(analyze(f.readlines()), title=a.file)
|
||||||
|
ap.print_help()
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in New Issue
Block a user