Retain opt-in layout performance diagnostics
This commit is contained in:
1 parent
84f589e364
commit
480f0bc99f
8 files changed
+613
-22
No files matched your search
@@ -3,6 +3,9 @@ name = "iris"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
layout-diagnostics = ["iris-core/layout-diagnostics"]
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ name = "iris-core"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
layout-diagnostics = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
wgpu = { workspace = true }
|
wgpu = { workspace = true }
|
||||||
bytemuck ={ workspace = true }
|
bytemuck ={ workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
//! Opt-in counters and coarse timers for explaining CPU layout cost.
|
||||||
|
//!
|
||||||
|
//! Enable the `layout-diagnostics` feature. With it disabled, none of the
|
||||||
|
//! instrumentation is compiled into Iris. The retained rig in
|
||||||
|
//! `tests/layout_diagnostics.rs` is the ordinary entry point.
|
||||||
|
//!
|
||||||
|
//! Timers are inclusive: `update total` contains `full layout` or
|
||||||
|
//! `incremental layout`, and `text render` contains shaping and glyph
|
||||||
|
//! placement. They locate cost within one instrumented run and must not be
|
||||||
|
//! added together. Use an uninstrumented build under `perf` for final CPU
|
||||||
|
//! totals; counting every primitive and distinct widget deliberately perturbs
|
||||||
|
//! the instrumented run.
|
||||||
|
|
||||||
|
use crate::WidgetId;
|
||||||
|
use std::{
|
||||||
|
cell::RefCell,
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
fmt::Write,
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) enum Counter {
|
||||||
|
Updates,
|
||||||
|
ResizeDependents,
|
||||||
|
DrawRequests,
|
||||||
|
WidgetDraws,
|
||||||
|
PlaceCalls,
|
||||||
|
SizeReads,
|
||||||
|
HintHits,
|
||||||
|
HintMisses,
|
||||||
|
ReuseAttempts,
|
||||||
|
ReuseExact,
|
||||||
|
ReuseMoved,
|
||||||
|
ReuseDirty,
|
||||||
|
ReuseWrongParent,
|
||||||
|
ReuseUnslotted,
|
||||||
|
ReuseOwnResize,
|
||||||
|
ReuseDescendantResize,
|
||||||
|
ResizeChecks,
|
||||||
|
ResizeCheckChildren,
|
||||||
|
QueuePops,
|
||||||
|
DepthSteps,
|
||||||
|
EagerReaderRedraws,
|
||||||
|
LocalRedraws,
|
||||||
|
SizeChanges,
|
||||||
|
ReaderEdges,
|
||||||
|
PrimitiveWrites,
|
||||||
|
TextRenders,
|
||||||
|
TextShapeHits,
|
||||||
|
TextShapes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Counter {
|
||||||
|
const COUNT: usize = Self::TextShapes as usize + 1;
|
||||||
|
|
||||||
|
const NAMES: [&'static str; Self::COUNT] = [
|
||||||
|
"updates",
|
||||||
|
"resize dependents",
|
||||||
|
"draw requests",
|
||||||
|
"widget draws",
|
||||||
|
"place calls",
|
||||||
|
"draw-result size reads",
|
||||||
|
"hint hits",
|
||||||
|
"hint misses",
|
||||||
|
"reuse attempts",
|
||||||
|
"reuse exact",
|
||||||
|
"reuse moved",
|
||||||
|
"reuse: dirty",
|
||||||
|
"reuse: wrong parent",
|
||||||
|
"reuse: unslotted",
|
||||||
|
"reuse: own resize",
|
||||||
|
"reuse: descendant resize",
|
||||||
|
"resize checks",
|
||||||
|
"resize children checked",
|
||||||
|
"redraw queue pops",
|
||||||
|
"depth steps",
|
||||||
|
"eager reader redraws",
|
||||||
|
"local redraws",
|
||||||
|
"size changes",
|
||||||
|
"reader edges",
|
||||||
|
"primitive writes",
|
||||||
|
"text renders",
|
||||||
|
"text shape hits",
|
||||||
|
"text shapes",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) enum TimerKind {
|
||||||
|
Update,
|
||||||
|
FullLayout,
|
||||||
|
ResizeMarking,
|
||||||
|
IncrementalLayout,
|
||||||
|
TextRender,
|
||||||
|
TextShape,
|
||||||
|
GlyphPlacement,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimerKind {
|
||||||
|
const COUNT: usize = Self::GlyphPlacement as usize + 1;
|
||||||
|
|
||||||
|
const NAMES: [&'static str; Self::COUNT] = [
|
||||||
|
"update total",
|
||||||
|
"full layout",
|
||||||
|
"resize marking",
|
||||||
|
"incremental layout",
|
||||||
|
"text render",
|
||||||
|
"text shape",
|
||||||
|
"glyph placement",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Report {
|
||||||
|
counters: [u64; Counter::COUNT],
|
||||||
|
nanos: [u64; TimerKind::COUNT],
|
||||||
|
distinct_widgets: usize,
|
||||||
|
distinct_text_widgets: usize,
|
||||||
|
hot_widgets: Vec<Callsite>,
|
||||||
|
hot_text: Vec<Callsite>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Report {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
counters: [0; Counter::COUNT],
|
||||||
|
nanos: [0; TimerKind::COUNT],
|
||||||
|
distinct_widgets: 0,
|
||||||
|
distinct_text_widgets: 0,
|
||||||
|
hot_widgets: Vec::new(),
|
||||||
|
hot_text: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Report {
|
||||||
|
pub fn counters(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
|
||||||
|
Counter::NAMES.into_iter().zip(self.counters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inclusive elapsed time accumulated for each targeted operation.
|
||||||
|
pub fn timings_ns(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
|
||||||
|
TimerKind::NAMES.into_iter().zip(self.nanos)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn distinct_widgets(&self) -> usize {
|
||||||
|
self.distinct_widgets
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn distinct_text_widgets(&self) -> usize {
|
||||||
|
self.distinct_text_widgets
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hot_widgets(&self) -> &[Callsite] {
|
||||||
|
&self.hot_widgets
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hot_text(&self) -> &[Callsite] {
|
||||||
|
&self.hot_text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats nonzero totals divided by `frames`.
|
||||||
|
pub fn per_frame(&self, frames: usize) -> String {
|
||||||
|
let divisor = frames.max(1) as f64;
|
||||||
|
let mut out = String::new();
|
||||||
|
for (name, value) in self.counters() {
|
||||||
|
if value != 0 {
|
||||||
|
let _ = writeln!(out, " {name:<27} {:>12.2}", value as f64 / divisor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.distinct_widgets != 0 {
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" {:<27} {:>12}",
|
||||||
|
"distinct widgets", self.distinct_widgets
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if self.distinct_text_widgets != 0 {
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" {:<27} {:>12}",
|
||||||
|
"distinct text widgets", self.distinct_text_widgets
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (name, nanos) in self.timings_ns() {
|
||||||
|
if nanos != 0 {
|
||||||
|
let ms = nanos as f64 / divisor / 1_000_000.0;
|
||||||
|
let _ = writeln!(out, " {name:<27} {ms:>12.3} ms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !self.hot_widgets.is_empty() {
|
||||||
|
let _ = writeln!(out, " hottest widget draws:");
|
||||||
|
for callsite in &self.hot_widgets {
|
||||||
|
let calls = callsite.calls as f64 / divisor;
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" {calls:>9.2} {:?} {}",
|
||||||
|
callsite.id, callsite.label
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !self.hot_text.is_empty() {
|
||||||
|
let _ = writeln!(out, " hottest text renders:");
|
||||||
|
for callsite in &self.hot_text {
|
||||||
|
let calls = callsite.calls as f64 / divisor;
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" {calls:>9.2} {:>3} widths {:?} {}",
|
||||||
|
callsite.distinct_widths, callsite.id, callsite.label
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Callsite {
|
||||||
|
pub id: WidgetId,
|
||||||
|
pub label: String,
|
||||||
|
pub calls: u64,
|
||||||
|
pub distinct_widths: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Calls {
|
||||||
|
label: String,
|
||||||
|
count: u64,
|
||||||
|
widths: HashSet<Option<u32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Current {
|
||||||
|
report: Report,
|
||||||
|
widgets: HashMap<WidgetId, Calls>,
|
||||||
|
text_widgets: HashMap<WidgetId, Calls>,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static CURRENT: RefCell<Current> = RefCell::new(Current::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn bump(counter: Counter) {
|
||||||
|
CURRENT.with_borrow_mut(|current| current.report.counters[counter as usize] += 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn draw_widget(id: WidgetId, label: &str) {
|
||||||
|
CURRENT.with_borrow_mut(|current| {
|
||||||
|
let calls = current.widgets.entry(id).or_default();
|
||||||
|
if calls.label.is_empty() {
|
||||||
|
calls.label = label.to_owned();
|
||||||
|
}
|
||||||
|
calls.count += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_text(id: WidgetId, label: &str, width: Option<f32>) {
|
||||||
|
CURRENT.with_borrow_mut(|current| {
|
||||||
|
let calls = current.text_widgets.entry(id).or_default();
|
||||||
|
if calls.label.is_empty() {
|
||||||
|
calls.label = label.to_owned();
|
||||||
|
}
|
||||||
|
calls.count += 1;
|
||||||
|
calls.widths.insert(width.map(f32::to_bits));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Timer {
|
||||||
|
kind: TimerKind,
|
||||||
|
start: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn timer(kind: TimerKind) -> Timer {
|
||||||
|
Timer {
|
||||||
|
kind,
|
||||||
|
start: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Timer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let nanos = self.start.elapsed().as_nanos().min(u64::MAX as u128) as u64;
|
||||||
|
CURRENT.with_borrow_mut(|current| current.report.nanos[self.kind as usize] += nanos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes all diagnostics accumulated on this thread and resets them.
|
||||||
|
pub fn take() -> Report {
|
||||||
|
CURRENT.with_borrow_mut(|current| {
|
||||||
|
current.report.distinct_widgets = current.widgets.len();
|
||||||
|
current.report.distinct_text_widgets = current.text_widgets.len();
|
||||||
|
current.report.hot_widgets = hottest(¤t.widgets);
|
||||||
|
current.report.hot_text = hottest(¤t.text_widgets);
|
||||||
|
let report = std::mem::take(&mut current.report);
|
||||||
|
current.widgets.clear();
|
||||||
|
current.text_widgets.clear();
|
||||||
|
report
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hottest(calls: &HashMap<WidgetId, Calls>) -> Vec<Callsite> {
|
||||||
|
let mut calls: Vec<_> = calls
|
||||||
|
.iter()
|
||||||
|
.map(|(&id, calls)| Callsite {
|
||||||
|
id,
|
||||||
|
label: calls.label.clone(),
|
||||||
|
calls: calls.count,
|
||||||
|
distinct_widths: calls.widths.len(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
calls.sort_by(|a, b| b.calls.cmp(&a.calls).then_with(|| a.label.cmp(&b.label)));
|
||||||
|
calls.truncate(8);
|
||||||
|
calls
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn taking_a_report_resets_its_counters() {
|
||||||
|
let _ = take();
|
||||||
|
bump(Counter::Updates);
|
||||||
|
bump(Counter::Updates);
|
||||||
|
|
||||||
|
let report = take();
|
||||||
|
assert_eq!(report.counters().next(), Some(("updates", 2)));
|
||||||
|
assert!(take().counters().all(|(_, count)| count == 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
#![feature(coerce_unsized)]
|
#![feature(coerce_unsized)]
|
||||||
#![feature(option_into_flat_iter)]
|
#![feature(option_into_flat_iter)]
|
||||||
|
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
pub mod layout_diagnostics;
|
||||||
|
|
||||||
mod attr;
|
mod attr;
|
||||||
mod event;
|
mod event;
|
||||||
mod num;
|
mod num;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
use crate::layout_diagnostics::{self as diag, Counter, TimerKind};
|
||||||
use crate::{
|
use crate::{
|
||||||
Align, GlyphAtlas, GlyphEntry, GlyphKey, PlacedGlyph, RegionAlign, UiColor, util::Vec2,
|
Align, GlyphAtlas, GlyphEntry, GlyphKey, PlacedGlyph, RegionAlign, UiColor, util::Vec2,
|
||||||
};
|
};
|
||||||
@@ -138,8 +140,14 @@ impl TextBuffer {
|
|||||||
max_width: width,
|
max_width: width,
|
||||||
};
|
};
|
||||||
if self.layout_key.as_ref() == Some(&layout_key) {
|
if self.layout_key.as_ref() == Some(&layout_key) {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::TextShapeHits);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::TextShapes);
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
let _shape = diag::timer(TimerKind::TextShape);
|
||||||
let mut builder = data
|
let mut builder = data
|
||||||
.layout_ctx
|
.layout_ctx
|
||||||
.ranged_builder(&mut data.font_ctx, &self.text, 1.0, true);
|
.ranged_builder(&mut data.font_ctx, &self.text, 1.0, true);
|
||||||
@@ -271,8 +279,16 @@ impl TextData {
|
|||||||
attrs: &TextAttrs,
|
attrs: &TextAttrs,
|
||||||
width: Option<f32>,
|
width: Option<f32>,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::TextRenders);
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
let _render = diag::timer(TimerKind::TextRender);
|
||||||
buffer.shape(self, attrs, width);
|
buffer.shape(self, attrs, width);
|
||||||
let glyphs = self.place(buffer);
|
let glyphs = {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
let _place = diag::timer(TimerKind::GlyphPlacement);
|
||||||
|
self.place(buffer)
|
||||||
|
};
|
||||||
RenderedText {
|
RenderedText {
|
||||||
glyphs,
|
glyphs,
|
||||||
size: buffer.size(),
|
size: buffer.size(),
|
||||||
|
|||||||
+28
-3
@@ -1,3 +1,5 @@
|
|||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
use crate::layout_diagnostics::{self as diag, Counter};
|
||||||
use crate::{
|
use crate::{
|
||||||
Axis, Len, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle,
|
Axis, Len, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle,
|
||||||
UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
|
UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
|
||||||
@@ -37,6 +39,8 @@ impl<'a> Painter<'a> {
|
|||||||
|
|
||||||
/// Takes the kind, for a caller writing many of one primitive.
|
/// Takes the kind, for a caller writing many of one primitive.
|
||||||
fn write<P: Primitive>(&mut self, kind: PrimitiveKind<P>, primitive: P, region: UiRegion) {
|
fn write<P: Primitive>(&mut self, kind: PrimitiveKind<P>, primitive: P, region: UiRegion) {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::PrimitiveWrites);
|
||||||
let h = self.state.layers.write(
|
let h = self.state.layers.write(
|
||||||
self.layer,
|
self.layer,
|
||||||
PrimitiveInst {
|
PrimitiveInst {
|
||||||
@@ -104,6 +108,8 @@ impl<'a> Painter<'a> {
|
|||||||
id: &'s StrongWidget<W>,
|
id: &'s StrongWidget<W>,
|
||||||
region: UiRegion,
|
region: UiRegion,
|
||||||
) -> DrawResult<'s, 'a, W> {
|
) -> DrawResult<'s, 'a, W> {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::PlaceCalls);
|
||||||
let region = region.within(&self.region);
|
let region = region.within(&self.region);
|
||||||
self.widget_at(id, region, true)
|
self.widget_at(id, region, true)
|
||||||
}
|
}
|
||||||
@@ -139,9 +145,24 @@ impl<'a> Painter<'a> {
|
|||||||
/// What a child says its length is without being drawn, if it can say.
|
/// What a child says its length is without being drawn, if it can say.
|
||||||
/// Asking counts as reading its size.
|
/// Asking counts as reading its size.
|
||||||
pub fn size_hint<W: ?Sized>(&mut self, id: &StrongWidget<W>, axis: Axis) -> Option<Len> {
|
pub fn size_hint<W: ?Sized>(&mut self, id: &StrongWidget<W>, axis: Axis) -> Option<Len> {
|
||||||
let hint = self.rsc.widgets().get_dyn(id.id())?.size_hint(axis)?;
|
let hint = self
|
||||||
self.depend_on_size(id);
|
.rsc
|
||||||
Some(hint)
|
.widgets()
|
||||||
|
.get_dyn(id.id())
|
||||||
|
.and_then(|widget| widget.size_hint(axis));
|
||||||
|
match hint {
|
||||||
|
Some(hint) => {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::HintHits);
|
||||||
|
self.depend_on_size(id);
|
||||||
|
Some(hint)
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::HintMisses);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn depend_on_size<W: ?Sized>(&mut self, child: &StrongWidget<W>) {
|
fn depend_on_size<W: ?Sized>(&mut self, child: &StrongWidget<W>) {
|
||||||
@@ -156,6 +177,8 @@ impl<'a> Painter<'a> {
|
|||||||
attrs: &TextAttrs,
|
attrs: &TextAttrs,
|
||||||
width: Option<f32>,
|
width: Option<f32>,
|
||||||
) -> RenderedText {
|
) -> RenderedText {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::render_text(self.id, self.rsc.widgets().label(self.id), width);
|
||||||
let ui = self.rsc.ui_mut();
|
let ui = self.rsc.ui_mut();
|
||||||
ui.text.render(buffer, attrs, width)
|
ui.text.render(buffer, attrs, width)
|
||||||
}
|
}
|
||||||
@@ -238,6 +261,8 @@ pub struct DrawResult<'p, 'a, W: ?Sized> {
|
|||||||
|
|
||||||
impl<W: ?Sized> DrawResult<'_, '_, W> {
|
impl<W: ?Sized> DrawResult<'_, '_, W> {
|
||||||
pub fn size(self) -> Size {
|
pub fn size(self) -> Size {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::SizeReads);
|
||||||
self.painter.depend_on_size(self.child);
|
self.painter.depend_on_size(self.child);
|
||||||
self.size
|
self.size
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-18
@@ -1,3 +1,5 @@
|
|||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
use crate::layout_diagnostics::{self as diag, Counter, TimerKind};
|
||||||
use crate::{
|
use crate::{
|
||||||
ActiveData, Axis, DrawLayers, IdLike, MaskIdx, MoveIdx, Moves, OnResize, Painter, PixelRegion,
|
ActiveData, Axis, DrawLayers, IdLike, MaskIdx, MoveIdx, Moves, OnResize, Painter, PixelRegion,
|
||||||
Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets,
|
Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets,
|
||||||
@@ -44,6 +46,10 @@ impl UiRenderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
|
pub fn update<'a>(&mut self, root: impl Into<Option<&'a StrongWidget>>, rsc: &mut dyn UiRsc) {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::Updates);
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
let _update = diag::timer(TimerKind::Update);
|
||||||
// safety mechanism for memory leaks; might wanna return a result instead so user can
|
// safety mechanism for memory leaks; might wanna return a result instead so user can
|
||||||
// decide whether to panic or not
|
// decide whether to panic or not
|
||||||
if !rsc.widgets().waiting.is_empty() {
|
if !rsc.widgets().waiting.is_empty() {
|
||||||
@@ -68,9 +74,15 @@ impl UiRenderState {
|
|||||||
// A region is a fraction of the output plus an offset, resolved
|
// A region is a fraction of the output plus an offset, resolved
|
||||||
// against the window in the shader, so a resize moves the whole
|
// against the window in the shader, so a resize moves the whole
|
||||||
// drawing on its own. Only a widget that read pixels can be wrong.
|
// drawing on its own. Only a widget that read pixels can be wrong.
|
||||||
for (&id, active) in &self.active {
|
{
|
||||||
if active.reads_output {
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
rsc.widgets_mut().needs_redraw.insert(id);
|
let _marking = diag::timer(TimerKind::ResizeMarking);
|
||||||
|
for (&id, active) in &self.active {
|
||||||
|
if active.reads_output {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::ResizeDependents);
|
||||||
|
rsc.widgets_mut().needs_redraw.insert(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +93,8 @@ impl UiRenderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
let _layout = diag::timer(TimerKind::FullLayout);
|
||||||
self.clear(rsc);
|
self.clear(rsc);
|
||||||
// free all resources & cache
|
// free all resources & cache
|
||||||
if let Some(id) = root {
|
if let Some(id) = root {
|
||||||
@@ -112,6 +126,8 @@ impl UiRenderState {
|
|||||||
old_children: Option<Vec<WidgetId>>,
|
old_children: Option<Vec<WidgetId>>,
|
||||||
rsc: &mut dyn UiRsc,
|
rsc: &mut dyn UiRsc,
|
||||||
) -> Size {
|
) -> Size {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::DrawRequests);
|
||||||
let mut old_children = old_children.unwrap_or_default();
|
let mut old_children = old_children.unwrap_or_default();
|
||||||
if self.active.contains_key(&id) {
|
if self.active.contains_key(&id) {
|
||||||
if let Some(size) = self.try_reuse(id, region, parent_move, rsc) {
|
if let Some(size) = self.try_reuse(id, region, parent_move, rsc) {
|
||||||
@@ -151,6 +167,11 @@ impl UiRenderState {
|
|||||||
rsc,
|
rsc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
{
|
||||||
|
diag::bump(Counter::WidgetDraws);
|
||||||
|
diag::draw_widget(id, painter.rsc.widgets().label(id));
|
||||||
|
}
|
||||||
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
|
let mut widget = painter.rsc.widgets().get_dyn_dynamic(id);
|
||||||
let size = widget.draw(&mut painter);
|
let size = widget.draw(&mut painter);
|
||||||
drop(widget);
|
drop(widget);
|
||||||
@@ -246,31 +267,42 @@ impl UiRenderState {
|
|||||||
parent_move: MoveIdx,
|
parent_move: MoveIdx,
|
||||||
rsc: &mut dyn UiRsc,
|
rsc: &mut dyn UiRsc,
|
||||||
) -> Option<Size> {
|
) -> Option<Size> {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::ReuseAttempts);
|
||||||
if rsc.widgets().needs_redraw.contains(&id) {
|
if rsc.widgets().needs_redraw.contains(&id) {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::ReuseDirty);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let active = self.active.get(&id)?;
|
let active = self.active.get(&id)?;
|
||||||
// Drawn somewhere else in the tree: its box is in coordinates it no
|
// Drawn somewhere else in the tree: its box is in coordinates it no
|
||||||
// longer sits in, and its slot names the wrong parent.
|
// longer sits in, and its slot names the wrong parent.
|
||||||
if active.parent_move != parent_move {
|
if active.parent_move != parent_move {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::ReuseWrongParent);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let (size, old, slot, was) = (active.size, active.region, active.move_idx, active.px);
|
let (size, old_region, slot, old_px) =
|
||||||
|
(active.size, active.region, active.move_idx, active.px);
|
||||||
// In pixels, because `region` is a fraction of a slot's box and that
|
// In pixels, because `region` is a fraction of a slot's box and that
|
||||||
// box may be what changed -- an unchanged fraction of a box half the
|
// box may be what changed -- an unchanged fraction of a box half the
|
||||||
// size is half the widget.
|
// size is half the widget.
|
||||||
let px = self.px_of(parent_move, region);
|
let px = self.px_of(parent_move, region);
|
||||||
let mut changed = [false; 2];
|
let mut changed = [false; 2];
|
||||||
for (axis, c) in AXES.into_iter().zip(changed.iter_mut()) {
|
for (axis, c) in AXES.into_iter().zip(changed.iter_mut()) {
|
||||||
*c = px.axis(axis) != was.axis(axis);
|
*c = px.axis(axis) != old_px.axis(axis);
|
||||||
}
|
}
|
||||||
if !changed.iter().any(|&c| c) && old == region {
|
if !changed.iter().any(|&c| c) && old_region == region {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::ReuseExact);
|
||||||
return Some(size);
|
return Some(size);
|
||||||
}
|
}
|
||||||
// Only a placed widget can be given a different box without drawing
|
// Only a placed widget can be given a different box without drawing
|
||||||
// again: everything it drew is a fraction of its slot's box, so one
|
// again: everything it drew is a fraction of its slot's box, so one
|
||||||
// entry says where all of it went.
|
// entry says where all of it went.
|
||||||
if slot == parent_move {
|
if slot == parent_move {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::ReuseUnslotted);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if changed.iter().any(|&c| c) {
|
if changed.iter().any(|&c| c) {
|
||||||
@@ -282,7 +314,14 @@ impl UiRenderState {
|
|||||||
// Anything under it that has to be drawn again is drawn by drawing
|
// Anything under it that has to be drawn again is drawn by drawing
|
||||||
// this, because whatever reads that widget's size sits in between
|
// this, because whatever reads that widget's size sits in between
|
||||||
// and has to lay out around what it comes to.
|
// and has to lay out around what it comes to.
|
||||||
if redraws || self.redraws_under(id, changed, rsc) {
|
if redraws {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::ReuseOwnResize);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.redraws_under(id, changed, rsc) {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::ReuseDescendantResize);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +329,8 @@ impl UiRenderState {
|
|||||||
let active = self.active.get_mut(&id).unwrap();
|
let active = self.active.get_mut(&id).unwrap();
|
||||||
active.region = region;
|
active.region = region;
|
||||||
active.px = px;
|
active.px = px;
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::ReuseMoved);
|
||||||
Some(size)
|
Some(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,11 +344,15 @@ impl UiRenderState {
|
|||||||
/// change length has no descendant whose box did, and the walk stops
|
/// change length has no descendant whose box did, and the walk stops
|
||||||
/// there -- an 80-wide child of a widened row is not asked at all.
|
/// there -- an 80-wide child of a widened row is not asked at all.
|
||||||
fn redraws_under(&self, id: WidgetId, changed: [bool; 2], rsc: &dyn UiRsc) -> bool {
|
fn redraws_under(&self, id: WidgetId, changed: [bool; 2], rsc: &dyn UiRsc) -> bool {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::ResizeChecks);
|
||||||
let Some(active) = self.active.get(&id) else {
|
let Some(active) = self.active.get(&id) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let size_deps = &active.size_deps;
|
let size_deps = &active.size_deps;
|
||||||
active.children.iter().any(|&child| {
|
active.children.iter().any(|&child| {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::ResizeCheckChildren);
|
||||||
let Some(data) = self.active.get(&child) else {
|
let Some(data) = self.active.get(&child) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -397,6 +442,8 @@ impl UiRenderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) {
|
pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
let _layout = diag::timer(TimerKind::IncrementalLayout);
|
||||||
// A reader's answer is only valid after every dirty size it reads has
|
// A reader's answer is only valid after every dirty size it reads has
|
||||||
// settled. Equal-depth widgets are independent, so their order does
|
// settled. Equal-depth widgets are independent, so their order does
|
||||||
// not matter.
|
// not matter.
|
||||||
@@ -407,6 +454,8 @@ impl UiRenderState {
|
|||||||
.copied()
|
.copied()
|
||||||
.max_by_key(|&id| self.depth(id))
|
.max_by_key(|&id| self.depth(id))
|
||||||
{
|
{
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::QueuePops);
|
||||||
self.redraw(id, rsc);
|
self.redraw(id, rsc);
|
||||||
}
|
}
|
||||||
rsc.free();
|
rsc.free();
|
||||||
@@ -416,6 +465,8 @@ impl UiRenderState {
|
|||||||
let mut depth = 0;
|
let mut depth = 0;
|
||||||
let mut at = Some(id);
|
let mut at = Some(id);
|
||||||
while let Some(id) = at {
|
while let Some(id) = at {
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::DepthSteps);
|
||||||
at = self.active.get(&id).and_then(|active| active.parent);
|
at = self.active.get(&id).and_then(|active| active.parent);
|
||||||
depth += 1;
|
depth += 1;
|
||||||
}
|
}
|
||||||
@@ -480,6 +531,8 @@ impl UiRenderState {
|
|||||||
if (self.resized || box_changed)
|
if (self.resized || box_changed)
|
||||||
&& let Some(top) = self.mark_readers(id, rsc)
|
&& let Some(top) = self.mark_readers(id, rsc)
|
||||||
{
|
{
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::EagerReaderRedraws);
|
||||||
self.redraw(top, rsc);
|
self.redraw(top, rsc);
|
||||||
rsc.widgets_mut().needs_redraw.remove(&id);
|
rsc.widgets_mut().needs_redraw.remove(&id);
|
||||||
return;
|
return;
|
||||||
@@ -493,8 +546,10 @@ impl UiRenderState {
|
|||||||
let Some(active) = self.remove(id, false, rsc) else {
|
let Some(active) = self.remove(id, false, rsc) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::LocalRedraws);
|
||||||
|
|
||||||
let was = active.size;
|
let old_size = active.size;
|
||||||
let size = self.draw_inner(
|
let size = self.draw_inner(
|
||||||
active.layer,
|
active.layer,
|
||||||
id,
|
id,
|
||||||
@@ -507,16 +562,21 @@ impl UiRenderState {
|
|||||||
rsc,
|
rsc,
|
||||||
);
|
);
|
||||||
|
|
||||||
if size != was
|
if size != old_size {
|
||||||
&& let Some(parent) = self.active.get(&id).and_then(|active| active.parent)
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
&& self
|
diag::bump(Counter::SizeChanges);
|
||||||
.active
|
if let Some(parent) = self.active.get(&id).and_then(|active| active.parent)
|
||||||
.get(&parent)
|
&& self
|
||||||
.is_some_and(|active| active.size_deps.contains(&id))
|
.active
|
||||||
{
|
.get(&parent)
|
||||||
// Propagate one dependency edge at a time. If drawing the reader
|
.is_some_and(|active| active.size_deps.contains(&id))
|
||||||
// does not change its own size, nothing above it can observe this.
|
{
|
||||||
rsc.widgets_mut().needs_redraw.insert(parent);
|
// Propagate one dependency edge at a time. If drawing the reader
|
||||||
|
// does not change its own size, nothing above it can observe this.
|
||||||
|
rsc.widgets_mut().needs_redraw.insert(parent);
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
diag::bump(Counter::ReaderEdges);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
//! Retained CPU-layout diagnostics on one reproducible random tree.
|
||||||
|
//!
|
||||||
|
//! Counters and phase timers:
|
||||||
|
//!
|
||||||
|
//! cargo test --release --features layout-diagnostics \
|
||||||
|
//! --test layout_diagnostics -- --ignored --nocapture
|
||||||
|
//!
|
||||||
|
//! Uninstrumented hardware totals for one phase:
|
||||||
|
//!
|
||||||
|
//! IRIS_PHASE=resize IRIS_FRAMES=1000 perf stat \
|
||||||
|
//! -e cycles:u,instructions:u cargo test --release \
|
||||||
|
//! --test layout_diagnostics -- --ignored --nocapture
|
||||||
|
//!
|
||||||
|
//! `IRIS_PHASE` is `cold`, `repaint`, `size`, `scroll`, `resize`, or `all`.
|
||||||
|
//! `IRIS_SEED`, `IRIS_DEPTH`, and `IRIS_FRAMES` select the load.
|
||||||
|
|
||||||
|
use iris::harness::Harness;
|
||||||
|
use iris::prelude::*;
|
||||||
|
use iris::random::{Edits, Tree, grow};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
const OUTPUT: (f32, f32) = (1920.0, 1200.0);
|
||||||
|
|
||||||
|
fn env<T: std::str::FromStr>(name: &str, fallback: T) -> T {
|
||||||
|
std::env::var(name)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.parse().ok())
|
||||||
|
.unwrap_or(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warm(seed: u64, depth: usize) -> (Harness, Tree) {
|
||||||
|
let mut harness = Harness::new(OUTPUT);
|
||||||
|
let (root, tree) = grow(&mut harness.rsc, seed, depth, &Edits::default());
|
||||||
|
harness.state.root = Some(root);
|
||||||
|
harness.frame();
|
||||||
|
println!(
|
||||||
|
"fixture: seed {seed}, depth {depth}, {} widgets, {} active",
|
||||||
|
tree.ids.len(),
|
||||||
|
harness.render.active_widgets()
|
||||||
|
);
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
let _ = iris::core::layout_diagnostics::take();
|
||||||
|
(harness, tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report(label: &str, mut elapsed: Vec<f64>, _harness: &Harness) {
|
||||||
|
elapsed.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
let frames = elapsed.len();
|
||||||
|
println!(
|
||||||
|
"{label}: {frames} frame(s), min {:.3} ms, median {:.3} ms, total {:.1} ms",
|
||||||
|
elapsed[0],
|
||||||
|
elapsed[frames / 2],
|
||||||
|
elapsed.iter().sum::<f64>(),
|
||||||
|
);
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
{
|
||||||
|
let diagnostics = iris::core::layout_diagnostics::take();
|
||||||
|
print!("{}", diagnostics.per_frame(frames));
|
||||||
|
for callsite in diagnostics.hot_text().iter().take(3) {
|
||||||
|
let mut ancestry = Vec::new();
|
||||||
|
let mut id = Some(callsite.id);
|
||||||
|
while let Some(widget) = id {
|
||||||
|
ancestry.push(_harness.rsc.widgets().label(widget).as_str());
|
||||||
|
id = _harness
|
||||||
|
.render
|
||||||
|
.active
|
||||||
|
.get(&widget)
|
||||||
|
.and_then(|active| active.parent);
|
||||||
|
}
|
||||||
|
println!(" text ancestry: {}", ancestry.join(" < "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(
|
||||||
|
label: &str,
|
||||||
|
frames: usize,
|
||||||
|
harness: &mut Harness,
|
||||||
|
mut change: impl FnMut(&mut Harness, usize),
|
||||||
|
) {
|
||||||
|
let mut elapsed = Vec::with_capacity(frames);
|
||||||
|
for frame in 0..frames {
|
||||||
|
change(harness, frame);
|
||||||
|
let start = Instant::now();
|
||||||
|
harness.frame();
|
||||||
|
elapsed.push(start.elapsed().as_secs_f64() * 1_000.0);
|
||||||
|
}
|
||||||
|
report(label, elapsed, harness);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "measurement, not a check"]
|
||||||
|
fn layout_cost() {
|
||||||
|
let seed = env("IRIS_SEED", 1_u64);
|
||||||
|
let depth = env("IRIS_DEPTH", 7_usize);
|
||||||
|
let frames = env("IRIS_FRAMES", 100_usize);
|
||||||
|
assert!(frames > 0, "IRIS_FRAMES must be greater than zero");
|
||||||
|
let phase = env("IRIS_PHASE", String::from("all"));
|
||||||
|
assert!(
|
||||||
|
["all", "cold", "repaint", "size", "scroll", "resize"].contains(&phase.as_str()),
|
||||||
|
"unknown IRIS_PHASE {phase:?}"
|
||||||
|
);
|
||||||
|
let selected = |name| phase == "all" || phase == name;
|
||||||
|
|
||||||
|
if selected("cold") {
|
||||||
|
let mut harness = Harness::new(OUTPUT);
|
||||||
|
let (root, tree) = grow(&mut harness.rsc, seed, depth, &Edits::default());
|
||||||
|
harness.state.root = Some(root);
|
||||||
|
println!(
|
||||||
|
"fixture: seed {seed}, depth {depth}, {} widgets",
|
||||||
|
tree.ids.len()
|
||||||
|
);
|
||||||
|
#[cfg(feature = "layout-diagnostics")]
|
||||||
|
let _ = iris::core::layout_diagnostics::take();
|
||||||
|
run("cold", 1, &mut harness, |_, _| {});
|
||||||
|
drop(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected("repaint") {
|
||||||
|
let (mut harness, tree) = warm(seed, depth);
|
||||||
|
let leaf = tree.ids[0];
|
||||||
|
run("repaint", frames, &mut harness, move |harness, _| {
|
||||||
|
let _ = harness.rsc.widgets_mut().get_dyn_mut(leaf);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected("size") {
|
||||||
|
let (mut harness, tree) = warm(seed, depth);
|
||||||
|
let sized = tree.sized[0];
|
||||||
|
run("size", frames, &mut harness, move |harness, frame| {
|
||||||
|
harness.rsc[sized].x = Some(Len::abs(100.0 + (frame % 2) as f32 * 40.0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected("scroll") {
|
||||||
|
let (mut harness, tree) = warm(seed, depth);
|
||||||
|
let scroll = tree.scrolls[0];
|
||||||
|
run("scroll", frames, &mut harness, move |harness, frame| {
|
||||||
|
harness.rsc[scroll].scroll(if frame % 2 == 0 { 12.0 } else { -12.0 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected("resize") {
|
||||||
|
let (mut harness, tree) = warm(seed, depth);
|
||||||
|
run("resize", frames, &mut harness, |harness, frame| {
|
||||||
|
harness.resize((OUTPUT.0 - (frame % 2) as f32 * 8.0, OUTPUT.1));
|
||||||
|
});
|
||||||
|
drop(tree);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user