Perf profiling and some low hanging perf improvements in text rendering

This commit is contained in:
Will Temple
2026-05-16 19:07:44 -04:00
parent 4347232b98
commit e5ae067bc2
13 changed files with 1010 additions and 117 deletions

View File

@@ -36,6 +36,35 @@ pub struct LayoutSnapshot {
pub root_min_size: UiSize,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct LayoutFrameStats {
pub total_ms: f64,
pub nodes: usize,
pub text_nodes: usize,
pub container_nodes: usize,
pub background_quads: usize,
pub intrinsic_calls: usize,
pub intrinsic_size_calls: usize,
pub intrinsic_text_calls: usize,
pub intrinsic_container_calls: usize,
pub intrinsic_ms: f64,
pub text_prepare_calls: usize,
pub text_prepare_ms: f64,
pub viewport_culled: usize,
pub layout_cache_hits: usize,
pub layout_cache_misses: usize,
pub intrinsic_cache_hits: usize,
pub text_requests: u32,
pub text_cache_hits: u32,
pub text_cache_misses: u32,
pub text_output_glyphs: u32,
pub text_family_resolve_ms: f64,
pub text_buffer_build_ms: f64,
pub text_glyph_collect_ms: f64,
pub text_miss_ms: f64,
pub scene_items: usize,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct LayoutPath(Vec<u32>);
@@ -194,6 +223,16 @@ pub fn layout_snapshot_with_cache(
text_system: &mut TextSystem,
layout_cache: &mut LayoutCache,
) -> LayoutSnapshot {
layout_snapshot_with_cache_and_stats(version, logical_size, root, text_system, layout_cache).0
}
pub fn layout_snapshot_with_cache_and_stats(
version: u64,
logical_size: UiSize,
root: &Element,
text_system: &mut TextSystem,
layout_cache: &mut LayoutCache,
) -> (LayoutSnapshot, LayoutFrameStats) {
let layout_started = Instant::now();
let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG);
let mut perf_stats = LayoutPerfStats::new(perf_enabled);
@@ -215,48 +254,78 @@ pub fn layout_snapshot_with_cache(
None,
);
let text_stats = text_system.take_frame_stats();
let stats = LayoutFrameStats {
total_ms: layout_started.elapsed().as_secs_f64() * 1_000.0,
nodes: perf_stats.nodes,
text_nodes: perf_stats.text_nodes,
container_nodes: perf_stats.container_nodes,
background_quads: perf_stats.background_quads,
intrinsic_calls: perf_stats.intrinsic_calls,
intrinsic_size_calls: perf_stats.intrinsic_size_calls,
intrinsic_text_calls: perf_stats.intrinsic_text_calls,
intrinsic_container_calls: perf_stats.intrinsic_container_calls,
intrinsic_ms: perf_stats.intrinsic_ms,
text_prepare_calls: perf_stats.text_prepare_calls,
text_prepare_ms: perf_stats.text_prepare_ms,
viewport_culled: perf_stats.viewport_culled,
layout_cache_hits: perf_stats.layout_cache_hits,
layout_cache_misses: perf_stats.layout_cache_misses,
intrinsic_cache_hits: perf_stats.intrinsic_cache_hits,
text_requests: text_stats.requests,
text_cache_hits: text_stats.cache_hits,
text_cache_misses: text_stats.cache_misses,
text_output_glyphs: text_stats.output_glyphs,
text_family_resolve_ms: text_stats.family_resolve_ms,
text_buffer_build_ms: text_stats.buffer_build_ms,
text_glyph_collect_ms: text_stats.glyph_collect_ms,
text_miss_ms: text_stats.miss_ms,
scene_items: scene.items.len(),
};
if perf_stats.enabled {
tracing::debug!(
target: "ruin_ui::layout_perf",
scene_version = version,
width = logical_size.width,
height = logical_size.height,
total_ms = layout_started.elapsed().as_secs_f64() * 1_000.0,
nodes = perf_stats.nodes,
text_nodes = perf_stats.text_nodes,
container_nodes = perf_stats.container_nodes,
background_quads = perf_stats.background_quads,
intrinsic_calls = perf_stats.intrinsic_calls,
intrinsic_size_calls = perf_stats.intrinsic_size_calls,
intrinsic_text_calls = perf_stats.intrinsic_text_calls,
intrinsic_container_calls = perf_stats.intrinsic_container_calls,
intrinsic_ms = perf_stats.intrinsic_ms,
text_prepare_calls = perf_stats.text_prepare_calls,
text_prepare_ms = perf_stats.text_prepare_ms,
viewport_culled = perf_stats.viewport_culled,
layout_cache_hits = perf_stats.layout_cache_hits,
layout_cache_misses = perf_stats.layout_cache_misses,
intrinsic_cache_hits = perf_stats.intrinsic_cache_hits,
text_requests = text_stats.requests,
text_cache_hits = text_stats.cache_hits,
text_cache_misses = text_stats.cache_misses,
text_output_glyphs = text_stats.output_glyphs,
text_family_resolve_ms = text_stats.family_resolve_ms,
text_buffer_build_ms = text_stats.buffer_build_ms,
text_glyph_collect_ms = text_stats.glyph_collect_ms,
text_miss_ms = text_stats.miss_ms,
scene_items = scene.items.len(),
total_ms = stats.total_ms,
nodes = stats.nodes,
text_nodes = stats.text_nodes,
container_nodes = stats.container_nodes,
background_quads = stats.background_quads,
intrinsic_calls = stats.intrinsic_calls,
intrinsic_size_calls = stats.intrinsic_size_calls,
intrinsic_text_calls = stats.intrinsic_text_calls,
intrinsic_container_calls = stats.intrinsic_container_calls,
intrinsic_ms = stats.intrinsic_ms,
text_prepare_calls = stats.text_prepare_calls,
text_prepare_ms = stats.text_prepare_ms,
viewport_culled = stats.viewport_culled,
layout_cache_hits = stats.layout_cache_hits,
layout_cache_misses = stats.layout_cache_misses,
intrinsic_cache_hits = stats.intrinsic_cache_hits,
text_requests = stats.text_requests,
text_cache_hits = stats.text_cache_hits,
text_cache_misses = stats.text_cache_misses,
text_output_glyphs = stats.text_output_glyphs,
text_family_resolve_ms = stats.text_family_resolve_ms,
text_buffer_build_ms = stats.text_buffer_build_ms,
text_glyph_collect_ms = stats.text_glyph_collect_ms,
text_miss_ms = stats.text_miss_ms,
scene_items = stats.scene_items,
"layout snapshot perf"
);
}
let root_min_size = element_min_size(root);
LayoutSnapshot {
scene,
interaction_tree: InteractionTree {
root: interaction_root,
(
LayoutSnapshot {
scene,
interaction_tree: InteractionTree {
root: interaction_root,
},
root_min_size,
},
root_min_size,
}
stats,
)
}
#[allow(clippy::too_many_arguments)]
@@ -2903,7 +2972,7 @@ mod tests {
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Text(text) => Some(text.text.as_str()),
DisplayItem::Text(text) => Some(text.text.as_ref()),
_ => None,
})
.collect()

View File

@@ -31,8 +31,9 @@ pub use interaction::{
};
pub use keyboard::{KeyboardEvent, KeyboardEventKind, KeyboardKey, KeyboardModifiers};
pub use layout::{
HitTarget, InteractionTree, LayoutCache, LayoutNode, LayoutPath, LayoutSnapshot, ScrollMetrics,
TextHitTarget, element_min_size, layout_snapshot, layout_snapshot_with_cache,
HitTarget, InteractionTree, LayoutCache, LayoutFrameStats, LayoutNode, LayoutPath,
LayoutSnapshot, ScrollMetrics, TextHitTarget, element_min_size, layout_snapshot,
layout_snapshot_with_cache, layout_snapshot_with_cache_and_stats,
layout_snapshot_with_text_system,
};
pub use layout::{layout_scene, layout_scene_with_text_system};

View File

@@ -189,7 +189,7 @@ pub struct PreparedTextLine {
#[derive(Clone, Debug, PartialEq)]
pub struct PreparedText {
pub element_id: Option<ElementId>,
pub text: String,
pub text: Arc<str>,
pub origin: Point,
pub bounds: Option<UiSize>,
pub font_size: f32,
@@ -206,6 +206,8 @@ pub struct PreparedText {
/// Add `origin` to convert to absolute window coords.
#[derive(Clone, Debug, PartialEq)]
pub struct TextLayoutData {
pub text: Arc<str>,
pub cache_key: u64,
pub lines: Vec<PreparedTextLine>,
pub glyphs: Vec<GlyphInstance>,
/// Measured (unclamped) size of the laid-out text.
@@ -225,7 +227,6 @@ impl PreparedText {
#[allow(clippy::too_many_arguments)]
pub(crate) fn from_layout(
element_id: Option<ElementId>,
text: String,
origin: Point,
bounds: Option<UiSize>,
font_size: f32,
@@ -237,7 +238,7 @@ impl PreparedText {
) -> Self {
Self {
element_id,
text,
text: layout.text.clone(),
origin,
bounds,
font_size,
@@ -256,6 +257,10 @@ impl PreparedText {
Arc::as_ptr(&self.layout)
}
pub fn layout_cache_key(&self) -> u64 {
self.layout.cache_key
}
/// Create a monospace `PreparedText` without going through the text system.
/// Used for testing and low-level terminal-style rendering.
pub fn monospace(
@@ -265,7 +270,7 @@ impl PreparedText {
advance: f32,
color: Color,
) -> Self {
let text = text.into();
let text: Arc<str> = Arc::from(text.into());
// Glyphs are stored in LOCAL (origin-relative) coordinates.
let mut local_x = 0.0f32;
let mut glyphs = Vec::with_capacity(text.chars().count());
@@ -293,7 +298,7 @@ impl PreparedText {
let size = UiSize::new(local_x, font_size);
Self {
element_id: None,
text,
text: text.clone(),
origin,
bounds: None,
font_size,
@@ -302,6 +307,8 @@ impl PreparedText {
selectable: true,
selection_style: TextSelectionStyle::DEFAULT,
layout: Arc::new(TextLayoutData {
text: text.clone(),
cache_key: monospace_text_cache_key(&text, font_size, advance, color),
lines: vec![line],
glyphs,
size,
@@ -466,7 +473,10 @@ impl PreparedText {
if range.is_empty() {
return;
}
for glyph in Arc::make_mut(&mut self.layout).glyphs.iter_mut() {
let layout = Arc::make_mut(&mut self.layout);
layout.cache_key =
selected_text_cache_key(layout.cache_key, range.start, range.end, selected_color);
for glyph in layout.glyphs.iter_mut() {
if glyph.text_end > range.start && glyph.text_start < range.end {
glyph.color = selected_color;
}
@@ -583,6 +593,28 @@ impl PreparedText {
}
}
fn monospace_text_cache_key(text: &str, font_size: f32, advance: f32, color: Color) -> u64 {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut hasher = DefaultHasher::new();
text.hash(&mut hasher);
font_size.to_bits().hash(&mut hasher);
advance.to_bits().hash(&mut hasher);
color.hash(&mut hasher);
hasher.finish()
}
fn selected_text_cache_key(base: u64, start: usize, end: usize, color: Color) -> u64 {
use std::hash::{DefaultHasher, Hash, Hasher};
let mut hasher = DefaultHasher::new();
base.hash(&mut hasher);
start.hash(&mut hasher);
end.hash(&mut hasher);
color.hash(&mut hasher);
hasher.finish()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum WordClass {
Word,
@@ -886,6 +918,8 @@ mod tests {
}
let orig_size = text.layout.size;
text.layout = Arc::new(TextLayoutData {
text: text.text.clone(),
cache_key: text.layout.cache_key,
lines,
glyphs,
size: orig_size,

View File

@@ -269,7 +269,7 @@ pub struct TextSystem {
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct TextFrameStats {
pub struct TextFrameStats {
pub requests: u32,
pub cache_hits: u32,
pub cache_misses: u32,
@@ -341,7 +341,6 @@ impl TextSystem {
bounds: Option<UiSize>,
) -> PreparedText {
let bounds = bounds.or(style.bounds);
let text = combined_text(spans);
// Use the cached Arc directly — glyphs are in local (origin-0) coords.
let layout_data = self.layout(
spans,
@@ -352,7 +351,6 @@ impl TextSystem {
PreparedText::from_layout(
None,
text,
origin,
bounds,
style.font_size,
@@ -476,7 +474,8 @@ impl TextSystem {
}
self.frame_stats.buffer_build_ms += buffer_build_started.elapsed().as_secs_f64() * 1_000.0;
let line_starts = line_start_offsets(&combined_text(spans));
let text = combined_text(spans);
let line_starts = line_start_offsets(&text);
let mut measured_width: f32 = 0.0;
let mut measured_height: f32 = 0.0;
let mut lines = Vec::new();
@@ -527,6 +526,8 @@ impl TextSystem {
glyph_collect_started.elapsed().as_secs_f64() * 1_000.0;
let layout = Arc::new(TextLayoutData {
text: Arc::from(text),
cache_key,
lines,
glyphs,
size: UiSize::new(measured_width.max(0.0), measured_height.max(0.0)),

View File

@@ -244,6 +244,7 @@ enum ElementContent {
pub(crate) struct TextNode {
pub spans: Vec<TextSpan>,
pub style: TextStyle,
content_hash: u64,
}
#[derive(Clone, Debug, PartialEq)]
@@ -281,12 +282,14 @@ impl Element {
}
pub fn spans(spans: impl IntoIterator<Item = TextSpan>, style: TextStyle) -> Self {
let spans = spans.into_iter().collect::<Vec<_>>();
Self {
id: None,
style: Style::default(),
children: Vec::new(),
content: ElementContent::Text(TextNode {
spans: spans.into_iter().collect(),
content_hash: text_node_content_hash(&spans, &style),
spans,
style,
}),
}
@@ -599,8 +602,7 @@ impl Hash for Style {
impl Hash for TextNode {
fn hash<H: Hasher>(&self, state: &mut H) {
self.spans.hash(state);
self.style.hash(state);
self.content_hash.hash(state);
}
}
@@ -611,6 +613,15 @@ impl Hash for ImageNode {
}
}
fn text_node_content_hash(spans: &[TextSpan], style: &TextStyle) -> u64 {
use std::hash::DefaultHasher;
let mut hasher = DefaultHasher::new();
spans.hash(&mut hasher);
style.hash(&mut hasher);
hasher.finish()
}
impl Hash for ScrollBoxNode {
fn hash<H: Hasher>(&self, state: &mut H) {
self.offset_y.to_bits().hash(state);