From a039f7fd5faee2828e3bc878f12e464e6c9db1e5 Mon Sep 17 00:00:00 2001 From: zhangheng Date: Wed, 24 Jun 2026 17:46:54 +0800 Subject: [PATCH] test(terminal): profile webgl render stages --- Cargo.toml | 1 + TERMINAL_ROADMAP.md | 4 ++ app/src/pages/terminal.rs | 56 ++++++++++++++++++++ app/src/terminal/component.rs | 20 ++++--- app/src/terminal/mod.rs | 1 + app/src/terminal/renderer.rs | 99 ++++++++++++++++++++++++++++++----- scripts/terminal-bench.mjs | 43 ++++++++++++++- 7 files changed, 204 insertions(+), 20 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b44574f..9c31b7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ web-sys = { version = "0.3", default-features = false, features = [ "MessageEvent", "MouseEvent", "Navigator", + "Performance", "ResizeObserver", "ResizeObserverEntry", "WebGl2RenderingContext", diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md index 8c9a7aa..ae2be2a 100644 --- a/TERMINAL_ROADMAP.md +++ b/TERMINAL_ROADMAP.md @@ -251,6 +251,10 @@ - `240x100`:WebGL `92.48 rows/sec`,frame p95 `399.9ms`;Canvas `157.65 rows/sec`,frame p95 `116.8ms`。 - 两种 renderer 的 pixel smoke 均通过;Canvas 可直接读取 canvas 像素,WebGL 走 screenshot smoke。 - 当前策略:默认 Canvas;WebGL 继续通过页面切换、`?renderer=webgl` 和 `--renderer webgl` 保留优化回归入口。 +5. **WebGL renderer profiling / 慢路径拆解** ✅ + - WebGL render path 记录阶段耗时:DOM/canvas 同步、backdrop 绘制、glyph collect、backdrop texture pass、glyph atlas、atlas upload、vertex build/upload、draw call。 + - 记录计数:cells、atlas entries、atlas insertions/resets、full-canvas fallback 次数、glyph draw failed 次数。 + - `window.__terminalBench.webglFrames` 暴露每帧采样,`scripts/terminal-bench.mjs` 汇总为 `rendererStats`,用于判断慢点是整屏 backdrop texture、atlas、vertex buffer,还是 fallback。 --- diff --git a/app/src/pages/terminal.rs b/app/src/pages/terminal.rs index a1cba15..e9c4657 100644 --- a/app/src/pages/terminal.rs +++ b/app/src/pages/terminal.rs @@ -115,6 +115,7 @@ fn update_terminal_bench_probe(event: TerminalRenderEvent) { "history" }; let renderer = format!("{:?}", event.renderer); + let webgl_stats = event.webgl_stats; let _ = js_sys::Reflect::set( &probe, @@ -152,6 +153,61 @@ fn update_terminal_bench_probe(event: TerminalRenderEvent) { &JsValue::from_str("screenRows"), &JsValue::from_f64(event.screen_rows as f64), ); + if let Some(stats) = webgl_stats { + append_webgl_stats(&probe, stats); + } +} + +#[cfg(target_arch = "wasm32")] +fn append_webgl_stats(probe: &wasm_bindgen::JsValue, stats: crate::terminal::WebGlRenderStats) { + use wasm_bindgen::JsValue; + + let frames = match js_sys::Reflect::get(probe, &JsValue::from_str("webglFrames")) { + Ok(value) if js_sys::Array::is_array(&value) => js_sys::Array::from(&value), + _ => { + let array = js_sys::Array::new(); + let _ = js_sys::Reflect::set(probe, &JsValue::from_str("webglFrames"), &array); + array + } + }; + + let frame = js_sys::Object::new(); + set_f64(&frame, "totalMs", stats.total_ms); + set_f64(&frame, "syncDomMs", stats.sync_dom_ms); + set_f64(&frame, "backdropMs", stats.backdrop_ms); + set_f64(&frame, "collectMs", stats.collect_ms); + set_f64(&frame, "backdropTextureMs", stats.backdrop_texture_ms); + set_f64(&frame, "fallbackCanvasMs", stats.fallback_canvas_ms); + set_f64(&frame, "atlasMs", stats.atlas_ms); + set_f64(&frame, "atlasUploadMs", stats.atlas_upload_ms); + set_f64(&frame, "vertexBuildMs", stats.vertex_build_ms); + set_f64(&frame, "vertexUploadMs", stats.vertex_upload_ms); + set_f64(&frame, "drawMs", stats.draw_ms); + set_f64(&frame, "cells", stats.cells as f64); + set_f64(&frame, "atlasEntries", stats.atlas_entries as f64); + set_f64(&frame, "atlasInsertions", stats.atlas_insertions as f64); + set_f64(&frame, "atlasResets", stats.atlas_resets as f64); + set_bool(&frame, "fallbackFullCanvas", stats.fallback_full_canvas); + set_bool(&frame, "glyphDrawFailed", stats.glyph_draw_failed); + + frames.push(&frame); + while frames.length() > 720 { + frames.shift(); + } +} + +#[cfg(target_arch = "wasm32")] +fn set_f64(target: &js_sys::Object, key: &str, value: f64) { + use wasm_bindgen::JsValue; + + let _ = js_sys::Reflect::set(target, &JsValue::from_str(key), &JsValue::from_f64(value)); +} + +#[cfg(target_arch = "wasm32")] +fn set_bool(target: &js_sys::Object, key: &str, value: bool) { + use wasm_bindgen::JsValue; + + let _ = js_sys::Reflect::set(target, &JsValue::from_str(key), &JsValue::from_bool(value)); } #[cfg(not(target_arch = "wasm32"))] diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs index ccf7c4b..7805f7f 100644 --- a/app/src/terminal/component.rs +++ b/app/src/terminal/component.rs @@ -28,7 +28,7 @@ use super::mouse::{ use super::protocol::{ClientTerminalMessage, ServerTerminalMessage}; use super::renderer::{ CanvasDomState, CanvasPlanInput, CanvasSurfaceFrame, CanvasSurfaceMode, - RenderCanvasSurfaceRequest, WebGlContextLifecycle, WebGlRendererState, + RenderCanvasSurfaceRequest, WebGlContextLifecycle, WebGlRenderStats, WebGlRendererState, attach_webgl_context_lifecycle, canvas_render_plan, render_canvas_surface, render_webgl, set_canvas_surface_mode, webgl_surface_mode, }; @@ -235,7 +235,7 @@ pub struct TerminalSelectionEvent { pub end: Option<(usize, usize)>, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq)] pub struct TerminalRenderEvent { pub cols: u16, pub rows: u16, @@ -243,6 +243,7 @@ pub struct TerminalRenderEvent { pub screen_rows: usize, pub mode: ViewMode, pub renderer: Renderer, + pub webgl_stats: Option, } type SerializeFn = Arc String + Send + Sync>; @@ -514,6 +515,7 @@ pub fn TerminalPanel( let selecting = RwSignal::new(false); let search_match = RwSignal::new(None::); let search_query = RwSignal::new(String::new()); + let last_webgl_stats = RwSignal::new(None::); // IME composition state: when true, regular `input` events are ignored and // the final composed string is sent on `compositionend`. @@ -624,6 +626,7 @@ pub fn TerminalPanel( screen_rows: core.screen_rows().len(), mode, renderer, + webgl_stats: last_webgl_stats.get_untracked(), }) { on_render.run(event); } @@ -921,15 +924,14 @@ pub fn TerminalPanel( }; match renderer { Renderer::WebGl => { - if render_webgl( + if let Ok(stats) = render_webgl( canvas_el, frame, &canvas_state, &webgl_state, &webgl_context_lost, - ) - .is_ok() - { + ) { + last_webgl_stats.set(Some(stats)); set_canvas_surface_mode( canvas_el, fallback_canvas_el, @@ -941,6 +943,7 @@ pub fn TerminalPanel( fallback_canvas_el, webgl_surface_mode(false), ); + last_webgl_stats.set(None); render_canvas_surface(RenderCanvasSurfaceRequest { canvas_el: fallback_canvas_el, frame, @@ -949,6 +952,7 @@ pub fn TerminalPanel( } } Renderer::Canvas => { + last_webgl_stats.set(None); set_canvas_surface_mode(canvas_el, fallback_canvas_el, CanvasSurfaceMode::WebGl); render_canvas_surface(RenderCanvasSurfaceRequest { canvas_el, @@ -956,7 +960,9 @@ pub fn TerminalPanel( canvas_state: &canvas_state, }); } - Renderer::Dom => {} + Renderer::Dom => { + last_webgl_stats.set(None); + } } }); diff --git a/app/src/terminal/mod.rs b/app/src/terminal/mod.rs index 4238d2e..a5bfc05 100644 --- a/app/src/terminal/mod.rs +++ b/app/src/terminal/mod.rs @@ -14,3 +14,4 @@ pub use component::{ TerminalSelectionEvent, TerminalTheme, }; pub use core::{SearchDirection, TerminalLink, TerminalSearchMatch}; +pub use renderer::WebGlRenderStats; diff --git a/app/src/terminal/renderer.rs b/app/src/terminal/renderer.rs index f4e1031..05c1d6d 100644 --- a/app/src/terminal/renderer.rs +++ b/app/src/terminal/renderer.rs @@ -1,11 +1,11 @@ use std::collections::HashMap; use std::rc::Rc; +use js_sys::Float32Array; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; -use web_sys::js_sys::Float32Array; use web_sys::{ CanvasRenderingContext2d, Event, HtmlCanvasElement, WebGl2RenderingContext, WebGlBuffer, WebGlProgram, WebGlShader, WebGlTexture, WebGlUniformLocation, @@ -364,6 +364,40 @@ pub(super) fn attach_webgl_context_lifecycle( }) } +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct WebGlRenderStats { + pub total_ms: f64, + pub sync_dom_ms: f64, + pub backdrop_ms: f64, + pub collect_ms: f64, + pub backdrop_texture_ms: f64, + pub fallback_canvas_ms: f64, + pub atlas_ms: f64, + pub atlas_upload_ms: f64, + pub vertex_build_ms: f64, + pub vertex_upload_ms: f64, + pub draw_ms: f64, + pub cells: usize, + pub atlas_entries: usize, + pub atlas_insertions: usize, + pub atlas_resets: usize, + pub fallback_full_canvas: bool, + pub glyph_draw_failed: bool, +} + +#[cfg(target_arch = "wasm32")] +fn perf_now() -> f64 { + web_sys::window() + .and_then(|window| window.performance()) + .map(|performance| performance.now()) + .unwrap_or(0.0) +} + +#[cfg(not(target_arch = "wasm32"))] +fn perf_now() -> f64 { + 0.0 +} + const WEBGL_VERTEX_SHADER: &str = r#" attribute vec2 a_position; attribute vec2 a_tex_coord; @@ -660,11 +694,15 @@ pub(super) fn render_webgl( canvas_state: &Rc>>, webgl_state: &Rc>>, context_lost: &Rc>, -) -> Result<(), String> { +) -> Result { + let total_start = perf_now(); + let mut stats = WebGlRenderStats::default(); if !should_attempt_webgl_render(context_lost.get()) { return Err("WebGL context is lost".to_owned()); } + let sync_start = perf_now(); sync_canvas_dom(canvas, frame.dom_state, canvas_state); + stats.sync_dom_ms += perf_now() - sync_start; let mut state_ref = webgl_state.borrow_mut(); if state_ref.is_none() { *state_ref = Some(create_webgl_renderer(canvas)?); @@ -678,23 +716,45 @@ pub(super) fn render_webgl( return Err("WebGL context is lost".to_owned()); } + let backdrop_start = perf_now(); sync_canvas_bitmap(&state.source_canvas, frame.dom_state); let _ = state .source_ctx .set_transform(frame.dpr, 0.0, 0.0, frame.dpr, 0.0, 0.0); render_canvas_backdrop(&state.source_ctx, frame); - let Some(cells) = collect_glyph_cells(frame.visible, frame.options) else { - render_canvas(&state.source_ctx, frame); - draw_webgl_texture_frame(state, frame.dom_state)?; - return Ok(()); - }; + stats.backdrop_ms = perf_now() - backdrop_start; - draw_webgl_texture_frame(state, frame.dom_state)?; - if draw_webgl_glyphs(state, frame, &cells).is_err() { + let collect_start = perf_now(); + let Some(cells) = collect_glyph_cells(frame.visible, frame.options) else { + stats.collect_ms = perf_now() - collect_start; + stats.fallback_full_canvas = true; + let fallback_canvas_start = perf_now(); render_canvas(&state.source_ctx, frame); + stats.fallback_canvas_ms += perf_now() - fallback_canvas_start; + let texture_start = perf_now(); draw_webgl_texture_frame(state, frame.dom_state)?; + stats.backdrop_texture_ms += perf_now() - texture_start; + stats.total_ms = perf_now() - total_start; + return Ok(stats); + }; + stats.collect_ms = perf_now() - collect_start; + stats.cells = cells.len(); + + let texture_start = perf_now(); + draw_webgl_texture_frame(state, frame.dom_state)?; + stats.backdrop_texture_ms += perf_now() - texture_start; + if draw_webgl_glyphs(state, frame, &cells, &mut stats).is_err() { + stats.glyph_draw_failed = true; + let fallback_canvas_start = perf_now(); + render_canvas(&state.source_ctx, frame); + stats.fallback_canvas_ms += perf_now() - fallback_canvas_start; + let texture_start = perf_now(); + draw_webgl_texture_frame(state, frame.dom_state)?; + stats.backdrop_texture_ms += perf_now() - texture_start; } - Ok(()) + stats.atlas_entries = state.atlas.entries.len(); + stats.total_ms = perf_now() - total_start; + Ok(stats) } fn create_webgl_renderer(canvas: &HtmlCanvasElement) -> Result { @@ -957,9 +1017,13 @@ fn draw_webgl_glyphs( state: &mut WebGlRendererState, frame: CanvasSurfaceFrame<'_>, cells: &[GlyphCell], + stats: &mut WebGlRenderStats, ) -> Result<(), String> { - ensure_glyphs_in_atlas(state, frame, cells)?; + let atlas_start = perf_now(); + ensure_glyphs_in_atlas(state, frame, cells, stats)?; + stats.atlas_ms += perf_now() - atlas_start; if state.atlas.dirty { + let upload_start = perf_now(); state.gl.bind_texture( WebGl2RenderingContext::TEXTURE_2D, Some(&state.atlas_texture), @@ -976,8 +1040,10 @@ fn draw_webgl_glyphs( ) .map_err(|_| "failed to upload glyph atlas texture".to_owned())?; state.atlas.dirty = false; + stats.atlas_upload_ms += perf_now() - upload_start; } + let vertex_start = perf_now(); let mut vertices = Vec::with_capacity(cells.len() * WEBGL_VERTEX_FLOATS * 6); for cell in cells { let Some(entry) = state.atlas.get(&cell.key) else { @@ -998,6 +1064,7 @@ fn draw_webgl_glyphs( frame.dom_state, ); } + stats.vertex_build_ms += perf_now() - vertex_start; if vertices.is_empty() { return Ok(()); } @@ -1005,18 +1072,22 @@ fn draw_webgl_glyphs( let gl = &state.gl; gl.use_program(Some(&state.program)); bind_webgl_vertex_layout(gl, state); + let upload_start = perf_now(); upload_webgl_vertices(gl, &vertices); + stats.vertex_upload_ms += perf_now() - upload_start; gl.active_texture(WebGl2RenderingContext::TEXTURE0); gl.bind_texture( WebGl2RenderingContext::TEXTURE_2D, Some(&state.atlas_texture), ); gl.uniform1i(state.sampler_location.as_ref(), 0); + let draw_start = perf_now(); gl.draw_arrays( WebGl2RenderingContext::TRIANGLES, 0, (vertices.len() / WEBGL_VERTEX_FLOATS) as i32, ); + stats.draw_ms += perf_now() - draw_start; Ok(()) } @@ -1024,10 +1095,12 @@ fn ensure_glyphs_in_atlas( state: &mut WebGlRendererState, frame: CanvasSurfaceFrame<'_>, cells: &[GlyphCell], + stats: &mut WebGlRenderStats, ) -> Result<(), String> { for attempt in 0..2 { if attempt == 1 { state.atlas.reset(); + stats.atlas_resets += 1; state.atlas_ctx.clear_rect( 0.0, 0.0, @@ -1038,7 +1111,7 @@ fn ensure_glyphs_in_atlas( let mut ok = true; for cell in cells { if state.atlas.get(&cell.key).is_none() - && draw_glyph_into_atlas(state, frame, &cell.key)?.is_none() + && draw_glyph_into_atlas(state, frame, &cell.key, stats)?.is_none() { ok = false; break; @@ -1055,6 +1128,7 @@ fn draw_glyph_into_atlas( state: &mut WebGlRendererState, frame: CanvasSurfaceFrame<'_>, key: &GlyphKey, + stats: &mut WebGlRenderStats, ) -> Result, String> { let dpr = frame.dpr.max(1.0); let width = (key.cols as f64 * frame.cell_size.0 * dpr).ceil() as u32; @@ -1096,6 +1170,7 @@ fn draw_glyph_into_atlas( ctx.fill_text(&key.text, f64::from(entry.x), f64::from(entry.y)) .map_err(|_| "failed to draw glyph into atlas".to_owned())?; ctx.restore(); + stats.atlas_insertions += 1; Ok(Some(entry)) } diff --git a/scripts/terminal-bench.mjs b/scripts/terminal-bench.mjs index 4c8ef99..ac0a3f7 100644 --- a/scripts/terminal-bench.mjs +++ b/scripts/terminal-bench.mjs @@ -222,6 +222,9 @@ async function runScenario(browser, options, viewport) { frameSamples.push( ...(await page.evaluate(() => window.__terminalBenchFrames || [])), ); + const rendererStats = await page.evaluate(() => window.__terminalBench?.webglFrames || []); + const cleanProbe = { ...probe }; + delete cleanProbe.webglFrames; const canvasPixelSmoke = await page.evaluate(() => { const canvas = document.querySelector("canvas.terminal-canvas:not([hidden])"); if (!canvas) { @@ -279,7 +282,8 @@ async function runScenario(browser, options, viewport) { elapsedMs, rowsPerSecond: round(options.lines / (elapsedMs / 1000)), frameMs: summarizeSamples(frameSamples), - probe, + rendererStats: summarizeRendererStats(rendererStats), + probe: cleanProbe, pixelSmoke: pixelResult, screenshotPath, }; @@ -288,6 +292,43 @@ async function runScenario(browser, options, viewport) { } } +export function summarizeRendererStats(frames) { + if (!Array.isArray(frames) || frames.length === 0) { + return { count: 0 }; + } + const numericFields = [ + "totalMs", + "syncDomMs", + "backdropMs", + "collectMs", + "backdropTextureMs", + "fallbackCanvasMs", + "atlasMs", + "atlasUploadMs", + "vertexBuildMs", + "vertexUploadMs", + "drawMs", + "cells", + "atlasEntries", + "atlasInsertions", + "atlasResets", + ]; + const summary = { count: frames.length }; + for (const field of numericFields) { + const samples = frames + .map((frame) => Number(frame[field] || 0)) + .filter((value) => Number.isFinite(value)); + summary[field] = summarizeSamples(samples); + } + summary.fallbackFullCanvasCount = frames.filter( + (frame) => Boolean(frame.fallbackFullCanvas), + ).length; + summary.glyphDrawFailedCount = frames.filter( + (frame) => Boolean(frame.glyphDrawFailed), + ).length; + return summary; +} + function benchUrl(options) { const url = new URL(options.url); url.searchParams.set("renderer", options.renderer);