Perfetto profiling
You can record and read Roku performance traces to diagnose animation jank, dropped frames, slow key-event handlers, blocking transactions, and render-thread stalls. This document is for app developers and SDK maintainers who investigate rendering performance.
You record a trace with the rk perfetto command while you interact with your app, and then open the trace in the Perfetto UI or in VS Code with the Perfetto plugin.
This document covers the following topics:
- Recording a trace
- Identifying the important threads
- Understanding the slices that the firmware provides
- Understanding Program (TypeScript) and Render transactions
- Analyzing animation frames
- Identifying performance issues
- Following the optimization workflow
- Seeing inside transactions with auto-instrumentation
- Discovering
--perfetto-pathsfrom the compiled bundle - Optimizing grids and cells
Recording a trace
The following commands add a device, enable tracing, deploy the app, and record a trace:
# 1. Add your device (one-time)
rk device add <device-ip>
# 2. Enable tracing before launching the app
rk perfetto enable
# 3. Deploy the app
rk dev
# 4. Record while interacting with the app
rk perfetto start --open
# 5. Press Ctrl+C; the trace opens in VS Code with the Perfetto pluginYou must run
rk perfetto enablebefore the app launches. Enabling tracing after launch misses early initialization traces.
By default, the trace file is named trace.<esn>.<timestamp>.perfetto-trace. The CLI writes it to its current directory in local mode (--local), or to the server's project root in server mode. Relative --output paths use the same base directory. In both modes, output paths (relative or absolute) must stay within that base directory. You can open the trace in the Perfetto UI (or in VS Code with the Perfetto plugin) to read it. See rk perfetto for more information about the command options.
Identifying the important threads
Three threads matter for rendering work. You can find them in the Perfetto UI track list first; the rest of this document describes slices relative to these threads. The following table lists the threads:
| Thread | Role |
|---|---|
ts_event_dev | Where TypeScript code runs (your components, grid engines, navigation) |
RenderThr:Main | The main SceneGraph render thread |
AuxRenderThread | Auxiliary render thread that runs when the main render thread is busy |
The AuxRenderThread thread running in parallel with the RenderThr:Main thread is normal. What matters is what AuxRenderThread runs during an animation. See Identifying performance issues for more information.
TypeScript-level Perfetto events appear under the rokuos.typescript track.
Understanding the slices that the firmware provides
These slices appear in every trace automatically, without code changes or tracepoints. The following table lists the slices:
| Slice | Thread | Meaning |
|---|---|---|
keyEvent | RenderThr:Main | Firmware instant event that is the first trace point to fire when a key press happens. You can use it as the start of an animation sequence. The slice carries the debug.key and debug.isKeyPress arguments. |
render | RenderThr:Main | SceneGraph processing a draw instruction (drawTime) |
swapBuffers | RenderThr:Main | SceneGraph flushing draw instructions to the GPU (text, images, rectangles), reported as drawFlushTime. Its duration is synchronized to the 16.67 ms frame; if render takes 1 ms, swapBuffers waits the remaining ~15.67 ms. |
observer.callback | RenderThr:Main | A field-change observer firing (key events, signal updates) |
consumeAllTasks | RenderThr:Main / AuxRenderThread | Runs all observer events and BrightScript callbacks |
animationnode / animationstart | RenderThr:Main | SceneGraph animation lifecycle |
component.init | various | Component initialization |
ExecBrightScript | various | BrightScript task execution |
Because swapBuffers pads out the frame, a long swapBuffers on its own is not a problem; it usually means the frame had spare time. You must read render + swapBuffers together. See Identifying performance issues for more information.
Understanding Program (TypeScript) and render transactions
Work crosses between TypeScript and the render thread through transactions. Each transaction has four trace points. The toTS segment in the name distinguishes the direction.
A transaction from the render thread to Program (TypeScript) has the following trace points:
transaction.toTS.starttransaction.toTS.committransaction.toTS.updatetransaction.toTS.end
A transaction from Program (TypeScript) to the render thread has the following trace points:
transaction.starttransaction.committransaction.updatetransaction.end
For the Program (TypeScript) to Render direction, two intervals are blocking:
- The interval from
transaction.starttotransaction.commitis blocking. - The interval from
transaction.updatetotransaction.endis blocking.
The following diagram shows the blocking intervals in each direction:
Program (TS) -> Render
transaction.start ─┐
│ blocking
transaction.commit ─┘
transaction.update ─┐
│ blocking
transaction.end ─┘
Render -> Program (TS)
transaction.toTS.start (not blocking on the render thread)
transaction.toTS.commit
transaction.toTS.update
transaction.toTS.endThe toTS trace points are useful for seeing when the render thread hands work to TypeScript. The interval from transaction.toTS.start to transaction.toTS.commit does not block the render thread; therefore, you must not read a wide toTS span as a render-thread stall.
For the Program (TypeScript) to Render direction, you can measure the two blocking intervals rather than the whole transaction span. The remaining portions do not stall the initiating thread.
Analyzing animation frames
An animation that a key press drives produces the following pattern on RenderThr:Main:
[keyEvent instant] -> [observer.callback] -> [render][swapBuffers] -> [render][swapBuffers] ...The frame budget for 60 frames per second (FPS) is 16.67 ms: render + swapBuffers ≤ 16.67 ms.
To measure FPS, do the following:
- Find the first
swapBuffersafter the triggeringkeyEvent. - Find the last
swapBuffersin the animation sequence. - Count the
swapBuffersslices (N). - Calculate
FPS = (N / duration_ms) × 1000.
Common causes of low FPS include the following:
- Long
observer.callbackslices pushing the firstrenderpast the 16.67 ms budget - A single very long
renderorswapBuffers(GPU stall or heavy scene) - SceneGraph node creation during the animation
See Seeing inside transactions with auto-instrumentation and Optimizing grids and cells for more information.
To find the cause of low FPS, do the following:
- On
RenderThr:Main, look forobserver.callbackslices longer than 1 ms. - Check the
keyEventinstant above each slice to confirm which key and which press or release triggered it. - Instrument the TypeScript side to see which function or signal write ran inside the slow window. The firmware
observer.callbackslice tells you that an observer fired, but not which one. See Seeing inside transactions with auto-instrumentation for more information.
Identifying performance issues
The following three rules identify rendering jank.
-
Dropped frames during animation. If
swapBuffers + renderexceeds 16.67 ms during an animation, the frame drops. You can look for a single longrender(heavy scene or too many nodes) or a longobserver.callbackpushing the firstrenderpast the budget. -
Blocking transactions during animation. If the time between
transaction.startandtransaction.commitexceeds 300 ms while an animation is running, the animation janks during smooth scrolling. The same applies to thetransaction.updatetotransaction.endinterval. This rule applies to the Program (TypeScript) to Render direction only because thetoTStrace points do not block the render thread. See Understanding Program (TypeScript) and Render transactions for more information. -
Render time taken by
consumeAllTasks. IfconsumeAllTasksruns onAuxRenderThreadorRenderThr:Mainduring an animation, it can block and take time away fromswapBuffersandrender. Observer callbacks and BrightScript work scheduled during a scroll are the usual cause.
Deciding what to do next
After you find an offending frame or interval, do the following:
- Note the timestamp of the offending frame or interval and find every slice that overlaps it on all three threads.
- Identify the source of the work: smooth-scroll steps (
flex-grid:smooth-scroll-stepY,flex-grid:smooth-scroll-stepX), observers (consumeAllTasksandobserver.callback), signal writes (writeSignal), or node creation (createElement). See Seeing inside transactions with auto-instrumentation for more information aboutwriteSignalandcreateElement. - Reduce the work, or move it off the render-critical path by prefetching earlier, warming the pool up front, or deferring the work until the animation completes.
Interpreting intermittent long slices
If the same trace event occasionally takes a long time and the next run is fast, you must not treat the single spike as a steady CPU cost. A spike can have the following causes:
- A TypeScript tracepoint (on
ts_event_dev) that spikes may have had garbage collection run inside it. - A tracepoint on
RenderThr:MainorAuxRenderThreadthat spikes may reflect context switching (the CPU was scheduled away) rather than extra SceneGraph work.
You should trust patterns that repeat across many frames more than one-off outliers.
Following the optimization workflow
Every rendering performance investigation follows the same loop: record, read, classify, fix, and re-verify.
-
Record. Enable tracing before the app launches, then record while reproducing the jank (for a grid, usually a press-and-hold vertical scroll):
rk perfetto enable rk dev rk perfetto start # interact, then Ctrl+C -
Read. Open the recorded trace in the Perfetto UI and locate the animation sequence: the run of
renderandswapBuffersframes that follows thekeyEventthat you care about. See Analyzing animation frames for more information. -
Classify. Identify the three threads, the firmware slices, and the transaction boundaries, then apply the rules in Identifying performance issues to decide whether you are looking at a frame-budget overrun, a blocking transaction, or contention. See Identifying the important threads, Understanding the slices that the firmware provides, and Understanding Program (TypeScript) and Render transactions for more information.
-
Fix. Reduce or relocate the work. Typical fixes fall into three categories:
- You can keep row data cached ahead of the focus so that a scroll step does not trigger a second layout pass when data arrives.
- You can move repeated signal reads and map lookups out of frequently run loops.
- You can avoid creating SceneGraph nodes during animation. See Seeing inside transactions with auto-instrumentation and Optimizing grids and cells for more information.
-
Re-verify. Re-record the same interaction and confirm that the frame budget and transaction blocking intervals improved. A single trace is not evidence. See Interpreting intermittent long slices for more information.
Reviewing classes of change that produced measurable wins
The following classes of change produced measurable wins:
- Caching reads in frequently run code. You can move signal getters and
Maplookups out of per-row and per-card loops (for example, cache anactiveboolean instead of re-reading anisActive()signal in a very frequent setter, and move a cumulative-offset lookup out of the inner column loop). - Naming signals and memos. You can give every
createSignalandcreateMemoa name so that Solid development hooks and traces attribute reactive work to a source. Named reactive slices appear only in builds that resolvesolidthrough its development build. To get a development build, setsolidDevMode: trueat the top level ofroku-config.tsand build withrk dev --debug, or pass--solid-dev-mode; the default isfalse. ThesolidDevModefield applies only tork dev --debug; the--solid-dev-modeflag works on any JavaScript build. Builds that precompile to Hermes bytecode skip the development build; as a result, reactive work stays anonymous. - Running a single layout per scroll step (prefetch). A vertical smooth-scroll step normally runs one layout pass. A second pass appears when the data for the entering row arrives asynchronously and increments the store version. If you keep the prefetch window ahead of the scroll (so that rows are in the store before focus lands), those steps return to a single layout.
- Pooling and warmup. You should recycle nodes through the pool rather than creating them on every frame. The
poolBufferwarmup in FlexGrid is additive; therefore, warming the working set up front removes node creation from the animation path.
Seeing inside transactions with auto-instrumentation
Build-time Perfetto auto-instrumentation wraps eligible named functions in perfetto.beginEvent and endEvent during compilation; therefore, TypeScript execution appears as named slices without editing sources. You can use auto-instrumentation to see what happens inside the transaction windows. After you find a long interval from transaction.start to transaction.commit, instrument Solid; the blocking window then shows which Solid work ran inside it. See Understanding Program (TypeScript) and Render transactions for more information.
The following table lists the --perfetto-paths flag values for each goal:
| Goal | Flag |
|---|---|
Tracepoints for Roku Solid (@roku-sdk/solid) | --perfetto-paths "packages/solid/src" |
Tracepoints for Roku Solid and open-source solid-js | --perfetto-paths "packages/solid/src,solid-js" |
The following commands instrument the Roku Solid package, and then also instrument the open-source solid-js internals:
rk dev --perfetto-paths "packages/solid/src"
# deeper: also instrument open-source solid-js internals
rk dev --perfetto-paths "packages/solid/src,solid-js"You should start with the narrower path. Adding solid-js produces more slices, and each tracepoint costs roughly from 10 to 30 microseconds per invocation, which can distort the timings that you are measuring. See App configuration for more information about the full configuration (app.perfetto.instrumentPaths, --perfetto-exclude, and --perfetto-filter).
Device support: The RSG 2.0 SDK runtime provides the
perfettoglobal, which is absent on Liberty devices. On those devices, tracepoints compile to no-ops (instructions that do nothing) rather than failing. This applies to both auto-instrumentation and hand-writtenperfetto.beginEventcalls.
Understanding the writeSignal and createElement slices
writeSignal and createElement slicesThe writeSignal and createElement slices show signal writes and SceneGraph node creation.
The writeSignal slice appears when solid-js is in --perfetto-paths. Each time app code calls a signal setter (setSignal()), a writeSignal slice appears. You can use it to attribute work inside a long transaction to specific signal writes.
The createElement slice appears with packages/solid/src instrumentation. Every createElement slice means that your app is creating a SceneGraph node. You should not create many elements during an animation because doing so causes jank. When you see these slices during a scroll, you can revisit the pooling and buffer warmup strategy so that the app recycles nodes and warms them ahead of the scroll instead of allocating them mid-animation.
Discovering --perfetto-paths from the compiled bundle
--perfetto-paths from the compiled bundleThe build matches instrumentPaths and --perfetto-paths entries as substrings of the source file path that it sees at compile time. To find the right substring for a function that you want to trace, do the following:
-
Build a readable dev bundle from the app under test:
rk build --debugThe
--debugpreset disables minification (--no-minify) and keeps__DEV__true; therefore, function names survive in the bundle. You can use--no-minifydirectly if you want only that without the other debug-preset defaults. -
Open
dist-build/bundle/source/compiled/index.jsand search for the function name that you want to trace (for example,createElement).That file is the esbuild JavaScript bundle. You must not look in
dist-build/pkg-dir/; that directory is the assembled Roku package, and the build deliberately excludes the raw JavaScript from it. Withrk dev, the default output directory isdist-devinstead ofdist-build. -
From the match, scroll up to the nearest source-path comment. The bundler emits one comment for each module:
// ../../../packages/solid/src/solid.ts -
Use any substring of that path as the
--perfetto-pathsvalue. The following table shows example values for the comment above:Path substring Scope solid/src/solid.tsThat single file packages/solid/srcThe whole Roku Solid package solid-jsOpen-source solid-jsmodules
Once you have the source-path comment, you have a valid --perfetto-paths value. You should pick the narrowest substring that still covers the functions that you want to trace; short entries like src match any path that contains that substring and over-instrument the build.
Optimizing grids and cells
The techniques in this section come from optimizing @roku-sdk/flex-grid and apply to any pooled, virtualized list or grid.
Avoiding <Show> for showing and hiding cell content
<Show> for showing and hiding cell contentFlexGrid cell components are pooled templates. The <Show when={...}> component mounts and unmounts its children, which creates new SceneGraph nodes (createElement) every time the condition changes; this is expensive during animation. You can use one of two alternatives:
-
You can toggle
visibleso that the app creates the node once and only its visibility changes. The following example togglesvisible:<rectangle visible={props.focused()} width={props.width} height={props.height} color="0x0066FFFF" /> -
You can create a new template type with a different number of SceneGraph nodes when the structure differs, and map it through
getCellTemplateIdandgetCellComponentso that each variant gets its own pool stack with a fixed node count. The following example maps a template ID to a cell component:getCellTemplateId: (cellData) => (cellData?.hero ? "hero" : "portrait"), getCellComponent: (templateId) => TEMPLATES[templateId],
See Rendering cells for more information about the full cell rendering contract. See FlexGrid for more information about the rest of the grid documentation.
Related documentation
- rk perfetto: Describes the command options of the
rk perfettoCLI. - App configuration options (roku-config.ts): Describes the
app.perfettooptions, includingapp.perfetto.instrumentPaths,--perfetto-exclude, and--perfetto-filter. - App profiling: Describes how to collect CPU and heap profiling data in VS Code.
- FlexGrid: Includes the cell rendering contract and the rest of the grid documentation.
Updated about 3 hours ago