# Performance
gled renders the LED output on its own render thread, independent of any
window or client. Splitting gled into a server and clients that connect over
the network must not slow that thread down. This page records the measured
render frame rate before and after each step of that split, on the developer
laptop and on a Raspberry Pi 5.
## How it is measured
- **Render frame rate.** The render thread logs a `frame_stats` line once per
second with the average frame rate and the longest gap between two frames.
The frame limiter is raised to 5000 fps with `GLED_FPS_LIMIT`, so the number
shows the headroom, not the configured limit.
- **Before the split** the measurement was `tests/fps.rs` (removed since; it
is in the git history before the split), which started the desktop app and
parsed the log.
- **After the split** the measurement is `crates/gled-server/tests/perf.rs`. It
starts the engine headless on the GPU with a copy of the local asset
repository, serves it over WebSocket, and connects 0, 1, 5 and 10 clients.
Each client changes a scene's opacity 20 times a second and receives every
update. A separate monitoring client reads the render statistics the
engine publishes. The test also reports how long a change takes to come
back to its client, and how much each client receives:
```sh
cargo test --release -p gled-server --test perf -- --ignored --nocapture
```
It renders without a frame limit, which is harder for the render thread
than the desktop measurement with its 5000 fps limit and a window to draw.
The two kinds of numbers are therefore not comparable with each other.
## Acceptance thresholds
| No client connected | median frame rate at least 95 % of the baseline |
| 5 or 10 clients connected | median frame rate at least 85 % of the baseline |
| Any | longest frame gap at most twice the baseline |
## Results
Laptop: Framework Laptop 13 with an AMD Ryzen AI 300 series CPU (Ryzen AI 9
HX 370, Radeon 890M), Fedora 44, Wayland with
XWayland. Project "2025 / Proseccival" (14 scene instances, about 50 KB of
project JSON).
| 2.29.0 desktop, baseline, run 1 | 0 | 3914 | 4020 | 4.1 ms |
| 2.29.0 desktop, baseline, run 2 | 0 | 3876 | 3935 | 2.9 ms |
| 2.29.0 desktop, baseline, run 3 | 0 | 3925 | 3969 | 4.7 ms |
| A1 in progress: model decoupled from egui and globals, run 1 | 0 | 4153 | 4184 | 2.9 ms |
| A1 in progress: model decoupled from egui and globals, run 2 | 0 | 4098 | 4148 | 4.2 ms |
| A1 done: model in its own crate, run 1 | 0 | 3812 | 3990 | 5.0 ms |
| A1 done: model in its own crate, run 2 | 0 | 3789 | 4125 | 4.6 ms |
| 2.29.0 baseline, rerun interleaved with A1, run 1 | 0 | 3952 | 4940 | 4.6 ms |
| 2.29.0 baseline, rerun interleaved with A1, run 2 | 0 | 3800 | 4470 | 5.3 ms |
| A2 done: GPU code in its own crate, run 1 | 0 | 3844 | 4943 | 5.0 ms |
| A2 done: GPU code in its own crate, run 2 | 0 | 4888 | 4923 | 2.5 ms |
| 2.29.0 baseline, interleaved with A2 | 0 | 4925 | 4939 | 3.2 ms |
| A3: engine thread, desktop app as its client, run 1 | 0 | 4868 | 4875 | 1.8 ms |
| A3: engine thread, desktop app as its client, run 2 | 0 | 4724 | 4753 | 3.9 ms |
| 2.29.0 baseline, interleaved with A3, run 1 | 0 | 4691 | 4781 | 3.3 ms |
| 2.29.0 baseline, interleaved with A3, run 2 | 0 | 4913 | 4920 | 2.2 ms |
Runs taken while other builds compete for the CPU show worst frame gaps of
15 to 40 ms for both versions; only measurements on an otherwise idle machine
are comparable.
### Headless engine with remote clients
Same laptop and project, A5: the engine runs headless in the test process,
without a frame limit, and serves the clients over WebSocket. The round trip
includes up to 33 ms of waiting, because a client gets at most 30 updates a
second.
| 0 | 20056 | 1.5 ms | - | - | - |
| 1 | 20013 | 1.2 ms | 20 ms | 44 ms | 89 KB/s |
| 5 | 19931 | 1.8 ms | 24 ms | 48 ms | 81 KB/s |
| 10 | 19971 | 2.1 ms | 20 ms | 45 ms | 84 KB/s |
Ten editing clients cost less than 1 % of the frame rate. The first version
sent the whole project, about 40 KB, with every change and needed 809 KB/s per
client; sending only the changed scenes brought that down tenfold. Without
edits a client receives only the beat timing, about 30 small messages a
second.
Rerun with the full browser interface's protocol (assets, status, sound,
MIDI and input events), same laptop, 2026-10-14:
| 0 | 19808 | 2.7 ms | - | - | - |
| 1 | 19404 | 1.8 ms | 18 ms | 42 ms | 103 KB/s |
| 5 | 19293 | 1.4 ms | 23 ms | 49 ms | 99 KB/s |
| 10 | 18330 | 3.4 ms | 23 ms | 47 ms | 100 KB/s |
Ten editing clients now cost 7.5 % of the frame rate at about 20000 frames a
second, within the 15 % limit; each client receives about 100 KB/s, a fifth
more than before because of the status the interface shows.
All numbers above were taken in blackout: the engine starts in it and the
test did not switch it off, so no effect was rendered. The test now switches
blackout off first.
## GPU time of the LED output
The headless numbers above are limited by the laptop's CPU; they hide what the
LED output costs the GPU, which is what limits a Raspberry Pi 5. A benchmark
records nothing but output frames, ten effects each, and times the GPU on its
own:
```sh
cargo test -p gled-gpu gpu_time -- --ignored --nocapture
```
Each effect used to be three kinds of compute pass (a clear per frame, then
per effect a pass computing its lamps and one mixing them into the output),
dispatched as single-thread workgroups over all 128 universes whatever the
effect's lamps were: about 300 000 workgroups for 15 effects. Now each effect
is one pass of 64-thread workgroups over only the quads of four lamps it has
lamps in, adding into the shared output directly.
Laptop as above, Radeon 890M, debug build, two runs each:
| 170 | 0.56 ms | 0.08 ms |
| 2 720 | 0.57 to 0.59 ms | 0.08 to 0.09 ms |
| 21 760 | 0.57 to 0.63 ms | 0.08 to 0.09 ms |
What is left on the Radeon is the fixed cost of the ten passes.
Raspberry Pi 5, release image, frame rate as the interface shows it:
| Before | 5 | 56 fps |
| Before | 15 | 25 fps, at 17 % CPU |
| After | 13 | 230 fps |
## Full rig: 8192 universes with output
The numbers above measure parts of the path. This benchmark drives all of it
at gled's limit, to find how many universes a machine drives without
dropping frames. Every universe is full, 170 RGB lamps each, up to the 8192
universes gled renders at once: 1 392 640 lamps. Every universe is routed to an
Art-Net output device. Ten active scenes of five effects run 50 animations
out of blackout. The engine runs headless on the GPU on a rig the test builds
itself, so no asset repository is needed:
```sh
cargo test --release -p gled-core --test rig_bench -- --ignored --nocapture
```
Results are taken from release builds only; a debug build runs the same test
but renders slower.
It sweeps 32, 64, 128, 192, 256, 512, 1024, 2048, 4096 and 8192 universes,
replacing only the patch, so the GPU pipelines stay. Settings:
| `GLED_BENCH_UNIVERSES` | the sweep | one universe count, 1 to 8192 |
| `GLED_BENCH_FPS_LIMIT` | 5000, unlimited | the render and output rate limit |
| `GLED_BENCH_ANIMATIONS` | 50 | running animations, 1 to 240; fewer let the output, not the GPU, set the pace |
| `GLED_BENCH_ARTNET_IP` | 127.0.0.1 | where the packets go; a node's address puts the load onto the network |
| `GLED_BENCH_ASSERT` | off | `1` fails the test when 8192 universes, or the one count, do not keep up |
The test holds the Art-Net port 6454 itself, so gled sends from another port
and its packets do not come back into its own Art-Net input. A running gled
holds the port too; close it before running the benchmark.
Framework Laptop 13, AMD Ryzen AI 9 HX 370 (Ryzen AI 300 series) with Radeon
890M, Fedora 44, release build, 2026-10-23, without a frame limit:
| 32 | 5 440 | 2111 | 1.5 ms | 277 Mbit/s | 68 587 |
| 64 | 10 880 | 2146 | 1.6 ms | 550 Mbit/s | 136 071 |
| 128 | 21 760 | 1412 | 1.8 ms | 740 Mbit/s | 183 097 |
| 192 | 32 640 | 1536 | 1.8 ms | 1206 Mbit/s | 298 261 |
| 256 | 43 520 | 1214 | 2.0 ms | 1257 Mbit/s | 310 798 |
| 512 | 87 040 | 667 | 2.3 ms | 1384 Mbit/s | 342 187 |
| 1024 | 174 080 | 367 | 5.0 ms | 1523 Mbit/s | 376 718 |
| 2048 | 348 160 | 207 | 12.8 ms | 1661 Mbit/s | 410 738 |
| 4096 | 696 320 | 118 | 15.8 ms | 1953 Mbit/s | 482 873 |
| 8192 | 1 392 640 | 64 | 18.6 ms | 2133 Mbit/s | 527 588 |
Every rendered frame reaches the wire at every count; the GPU sets the
pace. The laptop drives 2048 universes at more than 200 fps, 4096 at about
120 and 8192 at about 60.
### The output alone
A second benchmark feeds the output threads made-up frames as fast as they
take them, without GPU or engine, and compares the packets the kernel counts
(`UdpOutDatagrams`) with what plain sending loops on as many threads get
through:
```sh
cargo test --release -p gled-core output_bench -- --ignored --nocapture
```
Same laptop, eight sender threads:
| 256 | 4651 | 1 190 575 | 39 % |
| 1024 | 2631 | 2 693 809 | 88 % |
| 2048 | 1374 | 2 814 589 | 92 % |
| 4096 | 692 | 2 833 640 | 93 % |
From 1024 universes on the output sends close to what the kernel takes; a
frame of 256 universes uses two of the eight senders. 2.8 million ArtDmx
packets a second are more than 10 Gbit/s Ethernet carries (about 2.1
million); 1 Gbit/s carries about 210 000, 2.5 Gbit/s about 525 000.