From f128e28b83b3258bd8d2ac1aba7fefb7d9a2a44e Mon Sep 17 00:00:00 2001 From: vii Date: Fri, 18 Sep 2026 18:26:32 -0500 Subject: [PATCH] docs(perf): describe the main-thread pump rest modes and GameMode's governor permission --- CHANGELOG.md | 15 +++++++++++++ docs/BENCHMARKING.md | 1 + docs/PERFORMANCE.md | 50 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f24e2ad..d5ac315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,21 @@ GitHub are taken from the matching section here. `docs/PERFORMANCE.md`. - With `show_place_name: false`, Discord no longer shows the experience thumbnail or its name on hover. +- The host main thread no longer spins on the engine's message pump, which + cost about half a core and 146,000 empty calls a second. It now rests 100 µs + between polls: sleeping when the governor is `performance`, parking the + core with TPAUSE on CPUs that have it, spinning otherwise. With GameMode + setting the governor, process CPU in a 60k-part place fell from 216% to + 107% at the same 120 fps. `MOCKTAIL_ENGINE_PUMP_REST` forces a mode; see + `docs/PERFORMANCE.md`. +- ETC2 uploads with a `bufferRowLength` or `bufferImageHeight` larger than + the image read the padded rows as packed data and decoded garbage. +- ETC2 resampling created and joined worker threads on every batch. It now + runs on the persistent decode pool. +- A failed trace write left the profile writer thread waking every 250 ms + for the rest of the session. It now exits. +- The `--profile` trace gains a `pump` slice for the engine's main-thread + step. ## [0.2.0] - 2026-09-16 diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index f708b0e..f662383 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -48,6 +48,7 @@ Each slice is named after the call it times, on the thread that made it. | `pipeline` | `vkCreateGraphicsPipelines`, `vkCreateComputePipelines` | `count`, `cache` (1 when a pipeline cache was passed), `result` | | `pipeline` | `vkCreateShaderModule` | `bytes`, `result` | | `pipeline` | `vkCreatePipelineCache` | `initial_bytes`, `result` | +| `pump` | `nativeCallMessagesFromMainThread`: the engine's main-thread step, on the host main thread. Only calls of 20 µs or more are recorded; the empty polls between them are not. | | The `frame interval (ms)` counter is the time between successive adapter presents. `vkQueuePresentKHR` time minus `host present` time is the adapter's diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 9994e6b..6dcd951 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -38,7 +38,19 @@ park_cores=no GameMode reads `$XDG_CONFIG_HOME`, `/usr/share/gamemode` and `/etc`, in that order. It does not read anything under Nightcap's own config directory, so this -file has to live in one of those three places. +file has to live in one of those three places. The daemon reads it when it +starts, so restart it after editing: `systemctl --user restart gamemoded`. + +The governor switch runs through polkit. Debian and Ubuntu only allow it for +members of the `gamemode` group; without that, the daemon's journal shows +`cpugovctl set performance: Not authorized` and the governor stays put while +the session is otherwise active. Add yourself and log in again: + +```bash +sudo usermod -aG gamemode "$USER" +``` + +Fedora and Arch allow any logged-in local user. Setting `performance.gamemode: on` accepts the pinning instead: `on` is an explicit request for GameMode, and Nightcap keeps the session whatever it does @@ -50,6 +62,32 @@ To see which CPUs the process may use: taskset -pc $(pgrep -x mocktail) ``` +## The main-thread pump + +Roblox posts its per-frame main-thread step at a time of its own choosing, +and nothing signals the host when it does. The host main thread therefore +polls `nativeCallMessagesFromMainThread`, and a post that waits more than a +fraction of a millisecond costs a whole frame. Between polls the thread rests +for 100 µs, and how it rests decides the CPU clock: + +| Mode | When | What it costs | +| --- | --- | --- | +| `sleep` | The governor is `performance`, which GameMode sets | The thread idles at about 3% of a core. The governor holds the clock. | +| `tpause` | Otherwise, on a CPU with WAITPKG (Intel 12th gen and newer) | The core stays awake in a light C0 state. Frame rate holds; the OS books the time as busy, so the thread shows around 70% of a core, at lower power than a spin. | +| `spin` | Otherwise | The core stays awake at full power. | + +Under a power-saving governor the CPU clocks a bursty core down unless some +core stays awake, and Roblox's render thread then runs slower and drops +frames. That is why `sleep` is not the default there. Startup prints the +chosen mode: + +``` + [main] engine pump rest: tpause (MOCKTAIL_ENGINE_PUMP_REST=sleep|tpause|spin) +``` + +`MOCKTAIL_ENGINE_PUMP_REST` forces a mode. `sleep` without the governor's help +runs cooler for a few frames per second less; measure before keeping it. + ## Small texture upscaling `MOCKTAIL_SMALL_TEXTURE_UPSCALE` (default `4`) redraws ETC2 textures of 64px or @@ -98,10 +136,12 @@ ps -L -o psr=,pcpu=,comm= -p $(pgrep -x mocktail) # which core each thread is nvidia-smi --query-gpu=utilization.gpu,temperature.gpu,power.draw --format=csv ``` -A thread pinned near 100% while the frame rate is steady is a busy-wait. Load -spread across many `RBX Worker` threads is the game's own scheduler, and its -cost tracks the place you are in — part count, moving parts and joints all show -in the Shift+F4 World section. +A thread pinned near 100% while the frame rate is steady is a busy-wait. The +one exception is the host main thread in `tpause` mode, which the OS reports +as busy while the core is parked (see above). Load spread across many +`RBX Worker` threads is the game's own scheduler, and its cost tracks the +place you are in — part count, moving parts and joints all show in the +Shift+F4 World section. `--profile ` writes a Chrome trace of the Vulkan adapter's work for ui.perfetto.dev. See [BENCHMARKING.md](BENCHMARKING.md).