Makepad-skills makepad-2.0-performance
git clone https://github.com/ZhangHanDong/makepad-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/ZhangHanDong/makepad-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/makepad-2.0-performance" ~/.claude/skills/zhanghandong-makepad-skills-makepad-2-0-performance && rm -rf "$T"
skills/makepad-2.0-performance/SKILL.mdMakepad 2.0 Performance & Debugging Skill
1. Overview
Makepad 2.0 uses a unique rendering pipeline combined with the Splash script VM. Performance depends on understanding three critical subsystems:
- Draw Batching - How Makepad groups GPU draw calls and why
mattersnew_batch: true - Garbage Collection - The Splash VM's mark-sweep GC with per-type-bucket thresholds
- Render Triggers - The
/on_render
system that controls when sub-trees rebuild.render()
Unlike traditional retained-mode UI frameworks, Makepad uses an immediate-mode-inspired draw pipeline where widgets emit draw commands into a sorted batch list. Understanding this pipeline is essential for diagnosing invisible text, flickering, and performance regressions.
2. Draw Batching System
How It Works
Makepad automatically batches consecutive draw calls that use the same shader into a single GPU draw call. This is a major performance optimization, but it has a critical side effect: draw order can be surprising.
Draw pipeline (simplified): Widget tree: GPU batches (default): View (bg shader) Batch 1: all bg shaders Label (text) --> Batch 2: all text shaders View (bg shader) Label (text) Result: ALL backgrounds draw first, then ALL text draws second
When a View has
show_bg: true AND contains text children, the text can end up behind the background because both text draws get batched together into a single draw call that executes before (or after) the background draw calls.
new_batch: true
new_batch: trueSetting
new_batch: true on a View forces Makepad to start a new draw batch at that point. This creates a ViewOptimize::DrawList internally, which ensures proper draw ordering within that View's subtree.
// PROBLEM: Label text is invisible - batched behind the background RoundedView{ width: Fill height: Fit draw_bg.color: #1e1e2e Label{text: "This text is INVISIBLE"} } // FIX: new_batch ensures background draws before text RoundedView{ width: Fill height: Fit new_batch: true draw_bg.color: #1e1e2e Label{text: "This text is VISIBLE"} }
When new_batch: true
Is Required
new_batch: true| Scenario | Required? | Why |
|---|---|---|
View with containing Labels | YES | Text batches behind background |
| View with hover animator + text children | YES | Hover bg covers text on activation |
| Container of repeated items with backgrounds | YES | Each item and the container need it |
Transparent View (no ) with Labels | NO | No background to overlap |
| View with only non-text children (e.g., icons) | NO | Same shader type - no overlap issue |
| Deeply nested Views each with backgrounds | YES on each | Each background layer needs its own batch |
Hover Effects and new_batch
new_batchThis is the number one mistake with hoverable list items. When a View has
show_bg: true with a hover animator that transitions from transparent (#0000) to opaque on hover, the text disappears on hover because the newly-opaque background covers the batched text.
// CORRECT: Hoverable item with new_batch let HoverItem = View{ width: Fill height: Fit new_batch: true show_bg: true draw_bg +: { color: uniform(#0000) color_hover: uniform(#fff2) hover: instance(0.0) } animator: Animator{ hover: { default: { from: {all: Forward{duration: 0.1}} apply: {draw_bg: {hover: 0.0}} } on: { from: {all: Forward{duration: 0.1}} apply: {draw_bg: {hover: 1.0}} } } } label := Label{text: "item" draw_text.color: #fff} } // Parent container of hover items also needs new_batch RoundedView{ flow: Down height: Fit new_batch: true draw_bg.color: #2a2a3d draw_bg.border_radius: 8.0 HoverItem{label.text: "First item"} HoverItem{label.text: "Second item"} }
ViewOptimize Internals
The
new_batch and texture_caching properties map to a ViewOptimize enum:
ViewOptimize::None - Default. No special draw ordering. ViewOptimize::DrawList - Created by new_batch: true. Starts a new DrawList2d. ViewOptimize::Texture - Created by texture_caching: true. Renders to offscreen texture.
Priority:
texture_caching takes precedence over new_batch if both are set.
3. Texture Caching
How It Works
Setting
texture_caching: true on a View renders its entire child sub-tree to an offscreen GPU texture. On subsequent frames, if nothing in the sub-tree has changed, Makepad can skip re-rendering the children and just blit the cached texture.
// Cache a complex but rarely-changing sidebar sidebar := View{ width: 280 height: Fill texture_caching: true flow: Down spacing: 4 // ... many child widgets ... }
Pre-Built Cached Views
Makepad provides pre-styled cached views:
| Widget | Description |
|---|---|
| Texture-cached rectangle container |
| Texture-cached rounded rectangle |
When to Use Texture Caching
Good candidates:
- Complex static sidebars or toolbars that rarely change
- Large widget sub-trees with many nested backgrounds and text
- Decorative panels with shader effects
Bad candidates:
- Frequently updating views (e.g., animation targets, live data)
- Small simple views (overhead exceeds benefit)
- Views that contain scrolling content (CachedView wraps the whole content, not the viewport)
Trade-offs
| Benefit | Cost |
|---|---|
| Reduces per-frame draw call count | Uses GPU memory for cached texture |
| Avoids re-traversing large sub-trees | Texture must be invalidated on change |
| Can eliminate batching issues (the texture resolves draw order) | DPI factor affects texture resolution |
4. Garbage Collection (mod.gc)
Architecture
The Splash VM uses a mark-and-sweep garbage collector with isolated heaps for different value types:
Heap Layout: +-- Objects (ScriptObject) -- Primary allocation type +-- Arrays (ScriptArray) -- Typed arrays and value arrays +-- Strings (ScriptString) -- Interned strings +-- Pods (ScriptPod) -- Pod values (vec2, vec3, vec4, etc.) +-- Handles (ScriptHandle) -- Native Rust handles +-- Regexes (ScriptRegex) -- Interned regex patterns
Automatic GC Triggering
The GC uses a growth-based heuristic similar to Lua and V8:
- Growth Factor: 2x - GC triggers when any heap category doubles since last GC
- Minimum Thresholds (to avoid thrashing on small heaps):
| Category | Minimum Before GC Can Trigger |
|---|---|
| Objects | 1,024 |
| Strings | 256 |
| Arrays | 128 |
| Pods | 128 |
| Handles | 64 |
GC triggers when:
current_count >= MIN_THRESHOLD AND current_count >= last_gc_count * 2
Script API
- Force a GC cycle immediately. Silent (no log output).mod.gc.run()
- Force a GC cycle and print detailed statistics:mod.gc.run_status()
GC 142us: obj[S:1200 A:340 R:89] arr[S:45 A:12 R:3] str[S:890 A:120 R:15] ...
Where S=static (permanent), A=alive (survived), R=removed (freed).
- Mark a value and its entire reachable object graph as static. Static objects:mod.gc.set_static(value)
- Are never collected by GC (permanent)
- Are skipped during GC mark phase (faster GC traversal)
- Cannot be un-marked (irreversible within the VM lifetime)
- Debug tool. Prints internal tag information for an object: type index, static flag, proto chain.mod.gc.dump_tag(value)
Best Practices
Pattern: Static UI Trees
For large, stable UI tree definitions (like a Dock with many tabs), mark them as static immediately after definition. This is the standard pattern used in the Studio and UIZoo examples:
// Define a large widget tree let AppDock = Dock{ // ... tabs, splitters, content templates ... TabEditor := TabEditor{} TabFileTree := TabFileTree{} TabSettings := TabSettings{} } // Mark the entire tree as static - it will never be GC'd mod.gc.set_static(AppDock) // Run GC immediately to clean up any temporaries from tree construction mod.gc.run() // Now start the app startup() do #(App::script_component(vm)){ ui: Root{ main_window := Window{ body +: { // ... use AppDock here ... } } } }
Pattern: Dynamic Content
For dynamic content (lists, user-generated items, chat messages), let the automatic GC handle cleanup:
// Dynamic data - no need to call mod.gc manually var todos = [] fn add_todo(text) { todos.push({text: text done: false}) ui.main_view.render() // Automatic GC will clean up old unreachable objects } fn delete_todo(index) { todos.splice(index, 1) ui.main_view.render() // Old todo object becomes unreachable, will be collected automatically }
Pattern: Periodic Manual GC for Long-Running Apps
For apps that create and destroy many objects (e.g., chat applications with streaming responses):
var message_count = 0 fn on_new_message(msg) { messages.push(msg) message_count += 1 // Every 100 messages, run GC to reclaim temporary parsing objects if message_count % 100 == 0 { mod.gc.run() } ui.message_list.render() }
GC Mark Phase Details
The mark phase traverses from roots:
- Type check prototypes
- Type defaults objects
- Pod type defaults and objects
- Root objects (held by Rust via
)ScriptObjectRef - Root arrays (held by Rust via
)ScriptArrayRef - Root handles (held by Rust via
)ScriptHandleRef - Thread stacks (all live values on VM execution stacks)
- Thread scopes
- Method call contexts
- Loop source values
- Trap error/return/bail values
- Script body scopes and tokenizer string literals
- Native type table objects
Static objects are skipped during traversal since they only reference other static values.
5. Render Optimization
The on_render
/ .render()
System
on_render.render()Makepad 2.0 uses a pull-based rendering model for dynamic content. The
on_render callback on a View only executes when .render() is called on that View.
// Define a reactive view counter_view := View{ on_render: || { Label{ text: "Count: " + state.counter draw_text.color: #fff } } } // In event handler - only re-render what changed fn increment() { state.counter += 1 ui.counter_view.render() // Only this view re-renders }
Rules for Efficient Rendering
-
NEVER call
unnecessarily - Each call completely rebuilds that sub-tree's widget output..render() -
Render only affected sub-trees - If only a list changed, render only the list view, not the entire UI.
-
Avoid rendering in tight loops - Batch state changes, then render once:
// BAD: renders 100 times for i in 0..100 { items[i].value = compute(i) ui.item_list.render() // WASTEFUL - rebuilds list 100 times } // GOOD: render once after all changes for i in 0..100 { items[i].value = compute(i) } ui.item_list.render() // Render once with all changes applied
- Use
for initial render - Trigger the first render when the app starts:on_startup
ui: Root{ on_startup: || { ui.main_view.render() } main_window := Window{ body +: { main_view := View{ on_render: || { // ... dynamic content ... } } } } }
Render Scope
When
.render() is called on a View, only that View's on_render callback executes. Child Views with their own on_render callbacks will NOT automatically re-render unless their .render() is also called (or they are reconstructed by the parent's on_render).
6. Debug Logging
Rust-Side Logging
Use the
log! macro from Makepad's error log system:
use makepad_widgets::*; // In Rust code log!("Button clicked, counter = {}", self.counter); log!("Widget action: {:?}", action);
Script-Side Logging
In Splash scripts, you can use
log() or string interpolation for debugging:
fn handle_click() { let value = compute_something() // Log values during development log("computed value: " + value) }
GC Status Logging
Use
mod.gc.run_status() to get a detailed breakdown of GC activity:
// Output example: // GC 142us: obj[S:1200 A:340 R:89] arr[S:45 A:12 R:3] str[S:890 A:120 R:15] // hdl[S:8 A:2 R:0] pod[S:200 A:45 R:10] rex[S:3 A:0 R:0]
Fields:
- Time (142us) - GC cycle duration in microseconds
- S (Static) - Objects permanently marked, never collected
- A (Alive) - Objects that survived this GC cycle
- R (Removed) - Objects freed in this cycle
Tag Debugging
For deep debugging of specific objects, use
mod.gc.dump_tag(value):
let my_widget = View{...} mod.gc.dump_tag(my_widget) // Output: obj 4523 type_index=Some(12) is_static=false proto=Some(89) ...
7. Common Performance Issues & Fixes
| Issue | Cause | Fix |
|---|---|---|
| Text invisible | Missing | Add to parent View with |
| Text disappears on hover | Batch overlap during hover animation | Add to the hoverable View |
| UI freezes / stutters | Excessive calls | Batch state changes, render only changed sub-trees |
| Memory growing unbounded | GC not running or large static leaks | Use for stable trees, let auto GC handle dynamic content |
| Slow initial load | Large script evaluation at startup | Split into modules, use lazy loading patterns |
| Scroll stuttering | Too many items rendering | Use for virtualized rendering |
| Hover not responding | View missing | Views need to receive mouse events for hover |
| Widget not found at runtime | Wrong naming operator | Use (not ) for named/addressable children |
| Style overrides not applying | Missing merge operator | Use to merge properties, not which replaces entirely |
| Layout collapsed to zero | Missing | All containers need explicit or a fixed height |
8. PortalList for Large Lists
Why PortalList
PortalList virtualizes rendering -- only items visible in the viewport are drawn. This is mandatory for lists with 100+ items. Without it, all items are drawn every frame regardless of visibility.
Script-Side PortalList (with on_render)
For Splash-driven lists, define the PortalList with templates and use
on_render:
list := PortalList{ width: Fill height: Fill flow: Down spacing: 4 scroll_bar: ScrollBar{} Item := View{ width: Fill height: Fit padding: 8 new_batch: true draw_bg.color: #2a2a3d label := Label{text: "" draw_text.color: #ddd} } }
Rust-Side PortalList (with Widget trait)
For Rust-driven rendering, implement the Widget trait:
impl Widget for MyList { fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { while let Some(item) = self.view.draw_walk(cx, scope, walk).step() { if let Some(mut list) = item.borrow_mut::<PortalList>() { list.set_item_range(cx, 0, self.items.len()); while let Some(item_id) = list.next_visible_item(cx) { let template = id!(Item); let item = list.item(cx, item_id, template); item.label(ids!(label)).set_text(cx, &self.items[item_id].text); item.draw_all(cx, &mut Scope::empty()); } } } DrawStep::done() } }
FlatList vs PortalList
| Feature | FlatList | PortalList |
|---|---|---|
| Virtualization | No | Yes |
| Suitable for | < 100 items | Any number of items |
| Memory usage | All items in memory | Only visible items |
| Scroll performance | Degrades with count | Constant |
9. ViewOptimize Options Summary
| Property | ViewOptimize Value | Effect |
|---|---|---|
| (default) | | Standard batched drawing |
| | New draw batch, proper draw ordering |
| | Render children to offscreen texture |
| N/A | Skip rendering entirely |
Priority: If both
texture_caching and new_batch are set, texture_caching wins (becomes ViewOptimize::Texture).
10. Debugging Checklist
UI Not Showing
- Check
on all containers (defaultheight: Fit
inside aheight: Fill
parent = 0 height)Fit - Check
on root container (never use fixed pixel width on outermost element)width: Fill - Verify
is at the top of the scriptuse mod.prelude.widgets.*
Text Invisible
- Add
to any View withnew_batch: true
that contains textshow_bg: true - Check
is not transparent or same as backgrounddraw_text.color - Verify the Label is a direct or properly-batched child
Text Disappears on Hover
- Add
to the hoverable Viewnew_batch: true - Ensure the container of hoverable items also has
new_batch: true
Clicks Not Working
- Check
vs:=
-- use:
for named/dynamic children you reference:= - Verify
is set for Views that need mouse eventsshow_bg: true - Check
if keyboard events are neededgrab_key_focus
Widget Not Found
- Verify
registration in script_mod#(WidgetName::register_widget(vm)) - Check that
is called before custom registrationscrate::makepad_widgets::script_mod(vm) - Verify widget crate is a dependency in Cargo.toml
Script Errors
- Use
to debug values during executionlog() - Use
to check heap statisticsmod.gc.run_status() - Use
to inspect object internalsmod.gc.dump_tag(value)
Style Not Applying
- Use
merge operator for extending existing styles:+:draw_bg +: { color: #fff } - Use
only when you want to fully replace a property: - Check dot-path syntax:
is shorthand fordraw_bg.color: #fffdraw_bg +: { color: #fff }
11. Profiling with Studio
Makepad Studio includes a built-in profiler for monitoring application performance.
Studio Remote Protocol
Studio can connect to running applications and provide:
- Screenshots - Capture current frame state
- Widget Tree Dumps - Inspect the live widget hierarchy
- Widget Queries - Find specific widgets by ID
- Performance Monitoring - Frame times, draw call counts
Using Studio for Performance Debugging
- Start Studio remote:
cargo run -p cargo-makepad --release -- studio --studio=127.0.0.1:8001
- Run your app through Studio:
{"Run":{"mount":"makepad","process":"makepad-example-myapp","args":[]}}
- Capture widget tree to identify render structure:
{"WidgetTreeDump":{"build_id":BUILD_ID}}
- Take screenshots to verify visual state:
{"Screenshot":{"build_id":BUILD_ID}}
Identifying Hot Render Paths
- Use
to see how many widgets are in the treeWidgetTreeDump - Look for deeply nested View hierarchies that could benefit from
texture_caching - Identify repeated items that should use
instead of manual loopsPortalList - Check for Views with
that might not need it (each new batch = new draw list)new_batch: true
12. Quick Reference Card
Performance Properties on View
// Force new GPU draw batch (fixes text-behind-background) new_batch: true // Cache children to GPU texture (reduces draw calls for stable subtrees) texture_caching: true // Hide without removing from tree (skip rendering entirely) visible: false
GC API
mod.gc.set_static(value) // Mark value tree as permanent mod.gc.run() // Force GC cycle (silent) mod.gc.run_status() // Force GC cycle with log output mod.gc.dump_tag(value) // Debug: print object tag info
Render API
ui.widget_name.render() // Trigger on_render for specific widget
GC Thresholds (Automatic Trigger)
Objects: >= 1024 AND >= 2x since last GC Strings: >= 256 AND >= 2x since last GC Arrays: >= 128 AND >= 2x since last GC Pods: >= 128 AND >= 2x since last GC Handles: >= 64 AND >= 2x since last GC