Is your issue REALLY a bug?
Is there an existing issue for this?
Is this issue related to iced?
What happened?
Bug report (performance) — with a proposed fix that's confirmed to resolve it in a real app.
Summary
In the tiny_skia (software) renderer, iced_tiny_skia::engine::adjust_clip_mask calls
clip_mask.clear() — which memsets the entire window-sized mask buffer — and it is
invoked once per layer, once per primitive group, and once per text run that overflows
its clip bounds. A dense-text layer (terminal emulator, log view, large table) re-applies
the same clip bounds for every run, so a single frame performs ~100+ full-screen
memsets just to set up clipping. The cost is roughly:
per-frame mask cost ≈ (#layers + #groups + #overflowing-runs) × (window_w × window_h) bytes
On a machine without a usable GPU — where wgpu falls back to software and many users
force ICED_BACKEND=tiny-skia — this dominates frame time and starves input. In our app
(an iced-based terminal) it produced 3+ second keystroke-echo latency with the render
thread pinned at ~100%.
Steps to reproduce
- Run an iced app with
ICED_BACKEND=tiny-skia at ~1080p.
- Draw a full screen of clipped text (e.g. a terminal/TUI surface, ~40+ rows of styled
runs) that repaints on every keystroke.
- Type. Observe seconds of echo latency and the render thread saturated.
Expected vs actual
- Expected: drawing one screen of text costs ~one clip setup per distinct clip region.
- Actual: ~one full-window mask
memset per text run (~150/frame at 1080p ≈ ~300 MB
of memset per frame), almost all redundant re-clears of the identical rectangle.
Evidence
Sampling the (single) render thread while it sat at ~99% CPU during typing — 3 of 3
stacks identical:
__memset_sse2_unaligned_erms
core::slice::fill<u8>
tiny_skia::mask::Mask::clear (tiny-skia-0.11.4/src/mask.rs:370)
iced_tiny_skia::engine::adjust_clip_mask (engine.rs → clip_mask.clear())
iced_tiny_skia::engine::Engine::draw_text (engine.rs)
iced_tiny_skia::Renderer::draw (lib.rs)
iced_tiny_skia::window::compositor::present (compositor.rs)
The thread is genuinely idle (0% CPU) when nothing repaints, so this is pure render cost.
Root cause
// iced_tiny_skia/src/engine.rs
pub fn adjust_clip_mask(clip_mask: &mut tiny_skia::Mask, bounds: Rectangle) {
clip_mask.clear(); // <-- memset of the ENTIRE window-sized mask
let path = /* rect from bounds */;
clip_mask.fill_path(&path, FillRule::EvenOdd, false, Transform::default());
}
Call sites (the lib.rs layer/group loop, and engine.rs draw_text/draw_quad/image
when a primitive overflows its clip) re-invoke this with the same clip_bounds for
every run in a layer. After the first call the mask already holds exactly that clip, so
every subsequent clear+fill of the identical rect is wasted work.
Proposed fix
Memoize the bounds the shared per-frame mask currently holds; skip the clear+fill when
unchanged. Reset the memo at the top of Renderer::draw (the clip mask may be a fresh
allocation each frame, so a stale memo must not be trusted). Within a frame only
adjust_clip_mask writes the mask, so this is safe.
// engine.rs
thread_local! {
static CLIP_MASK_BOUNDS: std::cell::Cell<Option<[u32; 4]>> =
const { std::cell::Cell::new(None) };
}
pub fn reset_clip_mask_memo() {
CLIP_MASK_BOUNDS.with(|c| c.set(None));
}
pub fn adjust_clip_mask(clip_mask: &mut tiny_skia::Mask, bounds: Rectangle) {
let key = [
bounds.x.to_bits(), bounds.y.to_bits(),
bounds.width.to_bits(), bounds.height.to_bits(),
];
if CLIP_MASK_BOUNDS.with(|c| c.get()) == Some(key) {
return;
}
CLIP_MASK_BOUNDS.with(|c| c.set(Some(key)));
clip_mask.clear();
/* fill rect as before */
}
// lib.rs — Renderer::draw, right after `let scale_factor = …;`
engine::reset_clip_mask_memo();
This collapses ~150 full-screen memsets/frame down to ~1–2 (one per distinct clip
region actually used) and eliminated the multi-second input lag in our app (iced 0.14 /
iced_tiny_skia 0.14.0 / tiny-skia 0.11.4, 1080p, software rendering).
A more thorough variant could clear only the bounding box of the previous∪current clip
rect via Mask::data_mut() for workloads using many distinct small clips, but the
memoization above fixes the dominant dense-text case with a trivial, obviously-safe change.
Happy to open a PR if the approach looks good.
Environment
iced 0.14, iced_tiny_skia 0.14.0, tiny-skia 0.11.4
- Renderer:
fallback::Renderer<iced_wgpu, iced_tiny_skia> falling back to tiny-skia
(ICED_BACKEND=tiny-skia)
- 1920×1080, CPU-only rendering (older Intel iGPU, no Vulkan; the GL path busy-waits on
present, so software is the practical choice)
What is the expected behavior?
Instant response to keyboard.
Version
crates.io release
Operating System
Linux
Do you have any log output?
Is your issue REALLY a bug?
Is there an existing issue for this?
Is this issue related to iced?
What happened?
Bug report (performance) — with a proposed fix that's confirmed to resolve it in a real app.
Summary
In the
tiny_skia(software) renderer,iced_tiny_skia::engine::adjust_clip_maskcallsclip_mask.clear()— whichmemsets the entire window-sized mask buffer — and it isinvoked once per layer, once per primitive group, and once per text run that overflows
its clip bounds. A dense-text layer (terminal emulator, log view, large table) re-applies
the same clip bounds for every run, so a single frame performs ~100+ full-screen
memsets just to set up clipping. The cost is roughly:On a machine without a usable GPU — where
wgpufalls back to software and many usersforce
ICED_BACKEND=tiny-skia— this dominates frame time and starves input. In our app(an iced-based terminal) it produced 3+ second keystroke-echo latency with the render
thread pinned at ~100%.
Steps to reproduce
ICED_BACKEND=tiny-skiaat ~1080p.runs) that repaints on every keystroke.
Expected vs actual
memsetper text run (~150/frame at 1080p ≈ ~300 MBof
memsetper frame), almost all redundant re-clears of the identical rectangle.Evidence
Sampling the (single) render thread while it sat at ~99% CPU during typing — 3 of 3
stacks identical:
The thread is genuinely idle (0% CPU) when nothing repaints, so this is pure render cost.
Root cause
Call sites (the
lib.rslayer/group loop, andengine.rsdraw_text/draw_quad/imagewhen a primitive overflows its clip) re-invoke this with the same
clip_boundsforevery run in a layer. After the first call the mask already holds exactly that clip, so
every subsequent clear+fill of the identical rect is wasted work.
Proposed fix
Memoize the bounds the shared per-frame mask currently holds; skip the clear+fill when
unchanged. Reset the memo at the top of
Renderer::draw(the clip mask may be a freshallocation each frame, so a stale memo must not be trusted). Within a frame only
adjust_clip_maskwrites the mask, so this is safe.This collapses ~150 full-screen
memsets/frame down to ~1–2 (one per distinct clipregion actually used) and eliminated the multi-second input lag in our app (iced 0.14 /
iced_tiny_skia0.14.0 /tiny-skia0.11.4, 1080p, software rendering).A more thorough variant could clear only the bounding box of the previous∪current clip
rect via
Mask::data_mut()for workloads using many distinct small clips, but thememoization above fixes the dominant dense-text case with a trivial, obviously-safe change.
Happy to open a PR if the approach looks good.
Environment
iced0.14,iced_tiny_skia0.14.0,tiny-skia0.11.4fallback::Renderer<iced_wgpu, iced_tiny_skia>falling back to tiny-skia(
ICED_BACKEND=tiny-skia)present, so software is the practical choice)
What is the expected behavior?
Instant response to keyboard.
Version
crates.io release
Operating System
Linux
Do you have any log output?