Skip to content

Large data and performance

Pyzed calls view() after every message and turns the widget tree it returns into Iced widgets. For most apps that takes well under a millisecond. Two things make it slow: building many rows that are not even on screen, and rebuilding big subtrees that did not change. Pyzed has a tool for each.

You don’t have to turn any of this on. Pyzed does it in every app, following what fast native editors do to keep their UI fast: start drawing as soon as possible, and don’t redo work nobody asked for.

  • Fast start. import pyzed loads only what an app needs to open a window. The designer, the API reference, asyncio and the package metadata load the first time you use them.
  • Fonts in parallel. Scanning the system fonts starts the moment Pyzed is imported, on its own thread, while Python runs the rest of your imports.
  • Native GPU APIs first. The GPU renderer starts Vulkan, Metal or DirectX 12. The OpenGL backend, slower to start and heavier on memory, is started only when none of those works.
  • Cheap widget trees. Building containers, buttons and theme colors costs less, and the native core allocates with mimalloc, which is much faster than the system allocator with the many small objects every view() creates.
  • Shared pieces of components. ui.button, ui.icon_button, ui.kbd and tooltips reuse the widgets that only depend on the theme and on their text, icon or keys. Widgets never change once built, so one copy serves every button that says “Open”. A view of 1,000 buttons and labels builds in about a third less time.
  • The CPU renderer when there is no real GPU. On a virtual machine, a remote desktop or a computer without a graphics driver, the GPU that Vulkan, DirectX or OpenGL offer is an emulation running on the CPU (Mesa’s llvmpipe and lavapipe, Windows’ WARP). There Pyzed draws with its CPU renderer (tiny-skia) instead: it is faster and takes about 100 MB less memory.
  • No rebuilds for keys nobody listens to. Key presses and releases only reach your app (and rebuild the view) when a window defines on_key or on_key_up. Typing in a field rebuilds the view once per key instead of three times.
  • Hovering costs nothing. Buttons, list rows and anything styled with hover= light up under the pointer on their own, without a message or a new view(). Pointer callbacks (on_move, on_enter, on_exit, mouse_move…) run as soon as the pointer moves, and when one returns pz.UNCHANGED nothing is rebuilt nor redrawn. The library’s own components and Pyzed Design do that whenever a move changes nothing.
  • Styles resolved once. widget.style(...) caches what it resolves by its properties and the theme, so the same hover color on every row of a list is worked out once, not once per row per view().
  • Markdown parsed once. A long chat or document in pz.markdown blocks is parsed the first time it is drawn and kept while it is on screen, however many blocks there are.
  • Long lists build what is in view. ui.table, ui.virtual_list and Pyzed Design’s List box (from 50 items) only build the rows you can see.
  • Components are drawn when they change. A pz.Component whose props, state and context are the same as last time gives back its last drawing instead of running render() again. A view of 500 rows of components rebuilds in 4 ms instead of 26 ms when one of them changes.
  • So are form controls. A form made in Pyzed Design, running or on the designer’s canvas, only builds again the controls whose properties changed. The designer does the same with its console, outline and toolbox: its view went from 21 ms to 4.5 ms with an 85-control form and 400 log lines.
  • Files are listed off the UI thread. The Open dialog, the agent’s @ list and the shader file picker read the project folder once and then refresh it on a worker thread, so typing in them never waits for the disk.

Measured with the benchmark app (640×480 window, a label and a button per row, Xvfb without a GPU, medians of 5 launches) on the same machine:

Before Now
import pyzed 178 ms 78 ms
First frame, 10 rows 454 ms 323 ms
Update of 1,000 rows 40.7 ms 34.9 ms
Memory, 10 rows (RSS) 174 MB 146 MB

A virtual list builds only the rows in view. Rows are built when they scroll into view and dropped when they leave it, so a list of a million items costs the same as one of fifty.

import pyzed as pz
from pyzed import ui
class Logs(pz.App):
def __init__(self):
self.lines = [f"[{i:06}] request handled" for i in range(500_000)]
def view(self):
return ui.virtual_list(
self.lines,
lambda line: pz.text(line, font="mono", size=12),
item_height=20,
id="logs",
)

Every row has the same height (item_height), which is what lets Pyzed know where each row goes without building it. To scroll to row i, scroll the list’s id to i * (item_height + spacing):

pz.Command.scroll_to("logs", 0, 1_000 * 20) # row 1000

Measured on a 200 000-row table, building the view went from 3.7 s to 0.2 ms.

A message makes Pyzed call view() again and lay out and draw the window. That is what you want after a click, but a pointer that moves over a big view sends a message per move. When a callback only remembers something that is not drawn, return pz.UNCHANGED: the message ends there, with no view(), no layout and no redraw.

class Canvas(pz.App):
def __init__(self):
self.pointer = (0, 0) # used when the menu opens, not drawn
self.hovered = None # drawn highlighted
def moved(self, x, y):
self.pointer = (x, y)
hovered = self.item_at(x, y)
if hovered == self.hovered:
return pz.UNCHANGED # nothing on screen depends on the move
self.hovered = hovered # this one is drawn: the view is rebuilt
def view(self):
return pz.mouse_area(self.board(), on_move=self.moved)

It works for pointer callbacks (on_move, on_enter, on_exit of pz.mouse_area and the mouse_move, mouse_enter, mouse_leave… events of .on(...)), messages posted with pz.post, and anything your update() returns. Return it only when nothing that view(), title or subscriptions() read has changed: the window keeps showing what it showed.

Every component keeps its last drawing. On the next view() it is reused, without calling render(), while all of these are the same:

  • its props and its state (any attribute you set on self). Lists, dicts, sets and plain objects are compared by their contents, so self.items.append(x) is seen;
  • the values it reads with pz.use_context (or self.context);
  • the theme, the language, the window size and the stylesheet;
  • the components drawn inside it: when a child changes its state, every component that contains it is drawn again too.

Callbacks you pass as props or create in render() don’t get in the way: they are compared by their code and the values they captured, so a new lambda: self.pick(i) with the same i counts as the same callback.

class Row(pz.Component):
name: str = ""
stars: int = 0
def render(self): # runs only when name, stars or the Rating inside change
return pz.row([ui.label(self.name), Rating(value=self.stars)])

Two cases need a hand, because the component can’t see what changed:

  • render() reads something that is neither a prop, nor state, nor context: the clock, a global, data changed by another object. Call self.refresh() when it changes, or put cache = False on the class to draw it on every view() (live meters do that).
  • A value whose changes can’t be compared (a NumPy array, a native handle, a huge object graph) turns the cache off for that component on its own: it is drawn every time, as before.

A component that gets widgets as children is drawn whenever the parent builds them again, since they are new widgets each time; it costs no more than drawing it.

Your view(), update() and callbacks run on the UI thread, one at a time, as in every native toolkit. Python runs one thread at a time anyway (the GIL), so the fastest UI is the one that does less on that thread, which is what the caches above are for. What Pyzed does on other threads:

  • Iced lays out and renders the frame in Rust, outside the GIL, and the GPU draws it.
  • System fonts are scanned, project files are listed and the designer’s agents, language servers and builds run on worker threads.
  • Your slow work belongs there too: async tasks or a threading.Thread that sends its result with pz.post(...).

@pz.memo caches what a function returns by its arguments. While the arguments are equal, the next view() reuses the same widget tree instead of building it again:

@pz.memo
def sidebar(files: tuple, selected: str):
return pz.column([ui.list_item(f, icon="file", selected=f == selected) for f in files])
class Editor(pz.App):
def view(self):
return pz.row([sidebar(tuple(self.files), self.selected), self.editor()])

How arguments are compared:

Argument Compared by
Numbers, strings, tuples, frozen dataclasses… Value (their hash).
Lists, dicts and sets Their contents.
Anything else (your objects, self) Identity: mutating it in place does not refresh the cache.

The current theme and language are always part of the key, so switching them rebuilds every memoized subtree.

  • @pz.memo(maxsize=256) keeps more results (64 by default). It is handy on a function called once per row, such as the render of a virtual list.
  • function.cache_clear() empties one function’s cache, pz.clear_memos() all of them.
  • pz.memo_stats() returns {"hits": ..., "misses": ...} to check that a subtree is reused.
  • Keep view() free of slow work: load files, query databases and sort data in handlers or async tasks, and keep the results in your state.
  • Draw thousands of shapes with one canvas instead of thousands of widgets.
  • pz.every subscriptions call view() on every tick: stop them when nothing moves.