Skip to content

Performance tiny_skia #3368

Description

@smarteditor-io

Is your issue REALLY a bug?

  • My issue is indeed a bug!
  • I am not crazy! I will not fill out this form just to ask a question or request a feature. Pinky promise.

Is there an existing issue for this?

  • I have searched the existing issues.

Is this issue related to iced?

  • My hardware is compatible and my graphics drivers are up-to-date.

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

  1. Run an iced app with ICED_BACKEND=tiny-skia at ~1080p.
  2. Draw a full screen of clipped text (e.g. a terminal/TUI surface, ~40+ rows of styled
    runs) that repaints on every keystroke.
  3. 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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions