Performance audit¶
Where the TV client (nativetv-tv) and the server (nativetv-daemon) spend time and wakeups, as of 30 October 2026. The numbers behind it are in the baseline, what was changed in the optimizations, and how to repeat the measurements in the benchmarks.
Each finding carries how it is known: measured (a number from a run), read (checked in the source by the author) or audit (reported by a read-only pass over the code and not run). Audit findings are leads, not results.
How the parts interact¶
- The client is one thread that handles input, builds the scene and submits GL (
src/main.cpp). It waits inSDL_WaitEventTimeoutwhen nothing is animating, for at most 50 ms (kIdleWaitMs), and draws ambient animation at 30 fps. The swap interval is fixed at 1, because changing it cost the first frame after the change two display periods. - Replies from the server arrive on three libcurl threads (one for API calls, two for pictures,
src/adapters/http_services.cpp) and wait in a queue. The loop drains the queue once per iteration; the worker does not wake it, so a reply that lands while nothing animates waits up to 50 ms for the next iteration (read). - Pictures are decoded on worker threads and uploaded on the render thread, at most
Renderer::uploadBudget()(about 1 MB) per frame, into an LRU cache of 40 MB (src/graphics/image_loader.cpp). - The server is one event-loop thread, a pool of civetweb workers for HTTP, and a runner thread for every resident
luna-send -isubscription child. Luna calls areluna-sendchildren started withposix_spawn, at least 50 ms apart (150 ms during the first 30 s after start,src/daemon/child_process.hpp). - The client reads the server over HTTP on loopback. Home re-reads its data every 10 s and posts a beat every 5 s.
Already in place¶
These were checked and are not repeated as findings: idle waiting on events, 30 fps ambient pacing, an upload budget per frame, off-thread decode, an orphaned streaming vertex buffer, glUseProgram guarded against repeats, no glGet*, glFinish or glReadPixels in the normal frame, culling of off-screen rows and cards, a deterministic fixed clock (--time), and per-frame statistics with percentiles (--perf). Frame intervals on the B3 are in the TV app page.
Findings¶
Ranked by expected benefit for the effort and risk.
| # | Finding | Where | Basis | State |
|---|---|---|---|---|
| 1 | Every resident luna-send -i child polls its pipes every 50 ms: about 20 wakeups a second each, six to eight children |
src/daemon/child_process.cpp |
measured on the B3 | fixed |
| 2 | The remote-key watcher loads two JSON files every 200 ms; the NTP client reads and parses config.json every second; neither checks whether the file changed |
remote_button_log_watcher.cpp, ntp_client.cpp |
read | fixed |
| 3 | The picture cache's LRU clock ticks once per loop iteration, idle wakeups included, so pictures on screen become evictable after about 100 ms of idling | src/graphics/image_loader.cpp |
read | fixed |
| 4 | The vertex shader was compiled once per program, eight times, at start-up: 41 to 46 ms on the B3 | src/graphics/renderer.cpp |
measured on the B3 | fixed (first swap 359 to 306 ms) |
| 5 | A Home ambient frame costs 3.7 ms of CPU on the B3, about 10.6 % of a core at 29 fps; gradients are 36 % of the build time, text 22 % | batch.cpp, home_view.cpp |
measured on the B3 | open |
| 6 | The pause between ambient frames ended at any event, so a stream of mouse-motion or window events (the window manager on the host; a Magic Remote pointer is expected to do the same) made 30 fps animation draw at the event rate: in 19 of 40 host runs the frames came faster than 30 fps | src/main.cpp |
measured on the host | fixed |
| 7 | There was no input-latency figure: --perf logged frame intervals only |
src/main.cpp, frame_stats.cpp |
read | added |
| 8 | The decode and HTTP threads inherit the main thread's nice -10, so they compete with the render thread at its priority |
src/main.cpp (setpriority) |
audit; measured on the B3: no effect on frame cadence | dropped |
| 9 | A server reply waits up to 50 ms for the loop to notice it when nothing animates | src/main.cpp, http_services.cpp |
read | open; small (see below) |
| 10 | Opening Recents draws the whole Home under a 0.97 veil | application.cpp, recents_view.cpp |
audit | open |
| 11 | Icons and glyphs not covered by the startup prewarm are rasterised on the render thread on first use; the prewarm covers the home and apps galleries only | main.cpp, icon_atlas.cpp |
audit | open |
| 12 | Home re-parses and rebuilds its models every 10 s even when the reply is unchanged | data_controller.cpp |
audit | open |
| 13 | Per-frame string, JSON and vector copies in the Home view; Scene builders copy nodes instead of moving them |
home_view.cpp, scene.cpp |
audit, unmeasured | open |
| 14 | Batcher::place is quadratic in batches per primitive |
batch.cpp |
audit, suspected | open |
| 15 | Source pictures up to 64 Mpx are accepted; decoded pixels in flight are not counted against the 40 MB budget | image_loader.cpp |
audit | open |
| 16 | An EGL context loss is not handled; the renderer holds raw GL names | main.cpp, renderer.cpp |
audit | open |
| 17 | With MQTT on, one telemetry collection starts roughly 15 to 20 luna-send children, about 30 to 45 a minute with the other timers |
stats_collector.cpp, mqtt_telemetry_publisher.cpp |
audit, estimated from cache lifetimes | open |
| 18 | The MQTT client loop wakes about 13 times a second; signalPoll wakes every 250 ms |
mqtt_client.cpp, daemon/main.cpp |
audit | open |
| 19 | The TV pages are rebuilt on every request (file reads, std::regex, a hash of the body); only the phone dashboard is cached |
http_static_assets.cpp |
audit | open |
| 20 | collectLgSettingsData and oledStats hold their mutexes across Luna calls of up to 5 s, so setting writes wait behind them |
http_frontdoor.cpp |
audit | open |
| 21 | No phase timestamps existed for client or server start, so start-up cost could not be attributed | main.cpp, daemon/main.cpp |
audit | added; the client's are read on the B3 |
| 22 | The first Home catalogue request starts serial luna-send children 150 ms apart; 8 shader programs and about 1,235 single-glyph uploads run before the first frame; on the B3 the glyph prewarm takes 86 ms and the 8 programs 142 to 155 ms (10 ms and not timed on the host) |
http_frontdoor.cpp, renderer.cpp, main.cpp |
audit | open |
| 23 | ARM builds use -O2, a static libstdc++ and --as-needed; there is no section GC, LTO or strip step |
CMakeLists.txt, SDK toolchain file |
read | open; size only |
Finding 9 is small. The client costs about 1.6 % of a core and 3 wakeups a second on a screen that never animates (Apple M1 Pro; the TV was not measured), and optimistic updates hide most reply waits. Waking the loop from the worker is a few lines, but it only pays once the 50 ms idle wait can also be raised, and that needs a next-deadline answer from every screen's timers.
Checked and ruled out¶
- Nagle on the server's HTTP responses. civetweb is built without
tcp_nodelayand writes headers and body separately, which can stall a keep-alive response by about 40 ms. On the B3, 12 requests for/api/capsover one connection took 0 to 1 ms each after the first, so no stall shows and the option is left alone. - GL state churn. The audit found no
glGet*,glFinishorglReadPixelsin the normal frame, and a guarded program switch.
Not measurable with the access available¶
button-to-photon latency (needs an external camera), power-on to launcher time, a 24-hour soak, the client's CPU on the TV, and behaviour under memory pressure.