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]]
|
||||
name = "i-slint-backend-android-activity"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70d538003a566d7312572ac1435be0704934e46a63d55abdabede513f72332cb"
|
||||
dependencies = [
|
||||
"android-activity 0.5.2",
|
||||
"android-activity 0.6.1",
|
||||
|
|
@ -1875,6 +1873,8 @@ dependencies = [
|
|||
"i-slint-core",
|
||||
"i-slint-renderer-skia",
|
||||
"jni 0.22.4",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk 0.9.0",
|
||||
"raw-window-handle",
|
||||
]
|
||||
|
|
@ -2037,8 +2037,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "i-slint-core"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2ea15059d70bc31b1a8b1eb4c34f2c5df04cb7e53f36179b43c0261231d646"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"async-channel",
|
||||
|
|
@ -2125,8 +2123,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "i-slint-renderer-skia"
|
||||
version = "1.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc9c1cf02385f1d26fc1f423544e52390a8e5c2d89420a8042aa94b4fe109d1f"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
|
|
@ -2138,6 +2134,7 @@ dependencies = [
|
|||
"i-slint-common",
|
||||
"i-slint-core",
|
||||
"i-slint-core-macros",
|
||||
"log",
|
||||
"lyon_path",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
|
|
@ -5426,8 +5423,6 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "wgpu-hal"
|
||||
version = "30.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ perf = []
|
|||
# instrumentation, display locked to 60 Hz via JNI (src/baseline60.rs).
|
||||
# Build: `./scripts/build.sh baseline`.
|
||||
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]
|
||||
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
|
||||
# vendored stack? Run: ./scripts/build.sh baseline
|
||||
baseline) target=aarch64-linux-android; export ANDROID_ABI=arm64-v8a
|
||||
features=baseline60
|
||||
features="baseline60,framelog"
|
||||
cp Cargo.toml Cargo.toml.vendored.bak
|
||||
python3 - Cargo.toml <<'PY'
|
||||
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();
|
||||
}
|
||||
}
|
||||
// Jank-hunt instrumentation (`perf` feature; see
|
||||
// docs/BUGS/scroll-jank-120hz.md). FR = app-side render cost
|
||||
// (BeforeRendering->AfterRendering) + frame gap; STG = event-loop
|
||||
// stage costs. With `perf` off these arms are empty — zero
|
||||
// overhead.
|
||||
// Jank-hunt instrumentation. FR (frame gap + app-side render
|
||||
// cost) is on the PUBLIC-API `framelog` feature — it works in
|
||||
// BOTH the vendored and the stock baseline builds (the A/B
|
||||
// tool). STG (event-loop stage costs) is vendor-internal and
|
||||
// stays on `perf`.
|
||||
slint::RenderingState::BeforeRendering => {
|
||||
#[cfg(feature = "perf")]
|
||||
#[cfg(any(feature = "perf", feature = "framelog"))]
|
||||
FR_BEFORE_US.store(now_us(), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
slint::RenderingState::AfterRendering => {
|
||||
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 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
|
||||
// bookkeeping. See docs/BUGS/scroll-jank-120hz.md.
|
||||
#[cfg(feature = "perf")]
|
||||
// Per-frame clock + frame bookkeeping for the FR line. On `framelog`
|
||||
// (public-API only — works in stock AND vendored builds) or `perf`.
|
||||
#[cfg(any(feature = "perf", feature = "framelog"))]
|
||||
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);
|
||||
|
||||
/// Monotonic µs since process start (wall clock could jump on NTP).
|
||||
static NOW_T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
||||
#[cfg(feature = "perf")]
|
||||
#[cfg(any(feature = "perf", feature = "framelog"))]
|
||||
#[inline]
|
||||
fn now_ms() -> f64 {
|
||||
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