Uploads decode on a background dispatcher and the submit carries a timeline semaphore wait, so the GPU waits for the texture rather than the render thread. The gather of the application's compressed bytes moves there too when they are already mapped; unmapping or freeing that memory drains the decodes still reading it. Staged copies gain an explicit host-write barrier. A queue submission only makes host writes from before it visible to the device, and these writes land after the submit. Devices without timelineSemaphore, and submits whose pNext already sizes arrays by their semaphore counts, decode inline as before. Blade Ball, median of three: worst vkQueueSubmit 140ms -> 1ms, p99 4.9ms -> 0.05ms, total 2534ms -> 309ms. Frame interval unchanged; its tail is the engine's own main-thread step.
8.3 KiB
Performance and CPU load
What Nightcap does to your CPU and GPU, which knobs change it, and how to measure rather than guess.
GameMode core pinning
Nightcap asks Feral GameMode for a performance session at startup
(performance.gamemode, default auto). GameMode raises the CPU governor and
the process priority, which helps.
On a CPU with a wide spread of per-core maximum frequencies — Intel P-core and E-core parts, AMD parts with preferred cores — GameMode also pins the process to the fastest cores. On a 32-thread host that can mean 4 usable threads out of 32. Roblox spreads its work across a worker pool, so the pin concentrates that pool onto a couple of physical cores instead of spreading it. Those cores sit near their boost ceiling and run hot while the rest of the chip idles.
GameMode re-applies the pin roughly every 5 seconds, so widening the mask from inside the process does not hold. Releasing the request does release the pin.
Under auto, Nightcap therefore checks its CPU affinity immediately after the
request. If GameMode narrowed it, Nightcap ends the request and prints:
[gamemode] released: it pinned this process to a subset of the CPUs, ...
The trade is that GameMode's governor and priority boost go with it. To keep those and lose only the pinning, turn pinning off for every game on the host.
These are the settings to run with:
# ~/.config/gamemode.ini
[general]
desiredgov=performance
[cpu]
pin_cores=no
park_cores=no
desiredgov is the one to check first. The packaged default in
/usr/share/gamemode/gamemode.ini is already performance, but a value in
your own file replaces it, and desiredgov=powersave there means GameMode
clocks the CPU down for the whole session while still reporting
[gamemode] performance request active. The symptoms are a low CPU clock
under load, a GPU that never leaves its lower SM clocks, and the engine pump
falling back to tpause — which costs about 70% of a core instead of 3%,
because sleep is only chosen when the governor reads performance. On one
13900K host, correcting powersave to performance took the process from
177% CPU to 42% and the package from 70°C to 53-63°C, at the same frame rate.
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. The daemon reads it when it
starts, so restart it after editing: systemctl --user restart gamemoded.
Restarting the daemon drops the session of any game already running; relaunch the game for new settings to apply to it.
gamemoded is D-Bus activated through
com.feralinteractive.GameMode.service, so systemctl --user stop gamemoded
does not hold — the next client request starts it again within seconds. To
measure with GameMode genuinely off, mask it:
systemctl --user mask gamemoded # and --unmask afterwards
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:
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
to the affinity. off never contacts the daemon.
To see which CPUs the process may use:
pid=$(pgrep -x Main)
for t in /proc/$pid/task/*; do taskset -pc "${t##*/}"; done
Two traps here. The engine process reports its name as Main, not
mocktail; pgrep -x mocktail finds only the launcher, which sits near 0%
CPU and tells you nothing. And taskset -p reads one thread, not the whole
process, so a single call reports the main thread's mask and says nothing
about the workers — which is where the game's load actually is.
For the same reason, ps -L -o psr= shows which core each thread is running
on right now, not which cores it is allowed on. On a hybrid CPU the scheduler
preferring P-cores for runnable work is normal and is not a mask.
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
smaller at N times their width and height. At the default that is 16 times the
pixels per affected texture.
The cost lands in two places. Decoding and resampling is CPU work on up to 8 threads while textures load, which shows up as a burst rather than a steady load. The enlarged textures then stay in GPU memory for the session and cost extra sampling bandwidth on every frame that uses them.
That decode does not run on the thread that submits the frame. On a device
with a timeline semaphore, uploads decode on a background dispatcher and the
submit carries a semaphore wait, so the GPU waits for the texture instead of
the render thread. In the trace this is the etc2 decode slice on its own
thread, with only a negligible etc2 gather left on the submitting one. A
device without that feature decodes inline as before.
Set it to 1 to turn upscaling off:
MOCKTAIL_SMALL_TEXTURE_UPSCALE=1 mocktail
Frame rate and present mode
graphics.frame_rate_limit (default -1) leaves Roblox's own in-game
framerate cap in charge; Nightcap sets no DFIntTaskSchedulerTargetFps
override. A fixed value forwards that number to the scheduler.
graphics.vsync (default auto) selects the presentation mode. auto and
on both pick the lowest-latency synchronized mode the driver offers, in
order: FIFO_LATEST_READY, MAILBOX, FIFO_RELAXED, FIFO. The first two do
not block the render thread on the display refresh, so the frame rate is
governed by the in-game cap rather than by presentation. off selects
IMMEDIATE and does not synchronize at all.
If the frame rate is higher than your refresh rate, the extra frames are work you cannot see. Lower the in-game cap to your refresh rate first.
Measuring
Shift+F4 in game shows Roblox's own scheduler jobs, with a millisecond cost and a percentage for each. It does not show Nightcap's threads — the Vulkan adapter, the ETC2 decode workers, the libc shim — so it is a reading on the game, not on the process.
For the whole process:
top -H -p $(pgrep -x mocktail) # per-thread CPU, sorted
ps -L -o psr=,pcpu=,comm= -p $(pgrep -x mocktail) # which core each thread is on
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. 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 <file> writes a Chrome trace of the Vulkan adapter's work for
ui.perfetto.dev. See BENCHMARKING.md.