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.
Fast by default
Section titled “Fast by default”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 pyzedloads 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.kbdand 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_keyoron_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 newview(). Pointer callbacks (on_move,on_enter,on_exit,mouse_move…) run as soon as the pointer moves, and when one returnspz.UNCHANGEDnothing 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 perview(). - Markdown parsed once. A long chat or document in
pz.markdownblocks 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_listand Pyzed Design’s List box (from 50 items) only build the rows you can see. - Components are drawn when they change. A
pz.Componentwhose props, state and context are the same as last time gives back its last drawing instead of runningrender()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 |
Virtual lists and tables
Section titled “Virtual lists and tables”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 pzfrom 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", )ui.virtual_list(items, render)is a themed, scrolling list:render(item)returns the widget for one item.ui.tableis virtualized too: pass it all your rows.pz.virtual_column(count, item, item_height=...)is the building block:item(index)returns rowindex. Put it inside anypz.scrollable.
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 1000Measured on a 200 000-row table, building the view went from 3.7 s to 0.2 ms.
Callbacks that change nothing on screen
Section titled “Callbacks that change nothing on screen”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.
Components drawn only when they change
Section titled “Components drawn only when they change”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, soself.items.append(x)is seen; - the values it reads with
pz.use_context(orself.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. Callself.refresh()when it changes, or putcache = Falseon the class to draw it on everyview()(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.
Threads
Section titled “Threads”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.Threadthat sends its result withpz.post(...).
Memoized subtrees
Section titled “Memoized subtrees”@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.memodef 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 therenderof 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.
Other tips
Section titled “Other tips”- 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.everysubscriptions callview()on every tick: stop them when nothing moves.