Technical notes ยท Luanti

Luanti: the stalls at viewing range 120 (2026-10-29)

Date2026-10-29
GamesLuanti
batches2026-10-29-luanti-view120, 2026-10-29-luanti-renice, 2026-10-29-luanti-nice, 2026-10-29-luanti-srvunload, 2026-10-29-luanti-shipped
game patchesluanti/0010, luanti/0011

Symptom. At the package’s viewing range of 120 (0009; the user’s floor, no lower) the bench flight ran at 20 fps steady with 36 s of frames over 100 ms in its 135 s window, a 1% low of 4 fps and frames up to 3.8 s (batch 2026-10-29-luanti-view120). The stall recorder put two thirds of it on cpu (the main thread in state R) and a quarter on swapping from the VRAM swap.

What the main thread did. One run with STALLS=1 PERF=1 MEM=1 and one with Luanti’s own profiler in a log (recipe variant prof). bench/analysis/stallperf.py places the perf samples (cpu-clock, on the monotonic clock) in the recorder’s stall frames. CPU seconds per thread:

Thread in the 55.9 s of stall frames in the other 79.1 s
main (render) 12.6 s (22%) 36.2 s (46%)
MeshUpdate 15.8 s (28%) 29.0 s (37%)
kswapd0 15.3 s (27%) 5.0 s (6%)
Emerge-0 (map generation) 13.9 s (25%) 24.5 s (31%)
Server 7.9 s (14%) 12.3 s (16%)

The main thread was runnable but not running: the mesh, map generation and server threads and kswapd had the two PPE threads. The recorder’s runq rule only fires when the whole process is starved, so it said cpu (docs/STALLS.md, “What the recorder found in a game: Luanti”). When the main thread did run in a stall, it drew (renderMap 53%, the driver below it), rebuilt the draw list (updateDrawList 14%, half of it occlusion raycasts) and took page faults (11%, swap-ins). The merged mesh buffers, which looked suspicious in the source (re-merged whenever the visible set changes), hit their cache 77-99% of the time and cost 0.1-1.5 ms a frame.

Memory. The process ended the window with 115-128 MB resident and 77-105 MB swapped, malloc 152-177 MB in use. The built-in server shares the process: its log said “4128 blocks in memory”, about 4000 map blocks of 16 KiB (the generated chunks around the flight, kept 29 s after their last use), against the client’s 1000-1300. kswapd’s share went from 6% of the time to 27% in the stall frames.

The black screenshot of one 120 run at 100 s (the sky black too, the HUD drawn) is the flight path, not the renderer. The path runs through a mountain: shots of both builds at 60 and 150 s after the load (15 s into loops 2 and 3, the same place) are black, the 100 s shot shows the mountain just ahead, and the warm-up shots at 20 and 30 s are grey with a black hand. Luanti blacks out the screen when the camera is in a solid node; grey is the camera where no block has arrived yet (the first loop outruns the map generation), so no sunlight is seen and nothing is drawn. The frames inside the rock are still drawn behind the black (the profiler log’s drawn blocks stay at 100-216 through the window), a little faster: 21 ms median before the 60 s capture against 28 ms before the 100 s one.

The fixes

  • luanti 0010: the background threads at nice 10 (LU_NICE, default 10; 0 as upstream). Thread names MeshUpdate, MinimapUpdate, Server, Emerge-N and AsyncWorker-N set their own nice value in Thread::threadProc (Linux keeps it per thread; raising one’s own needs no privilege). The work is the same, done in the render thread’s gaps. First tried from outside (recipe lu_renice, variant v120nice, batch luanti-renice): 30 fps against 20, 10.2 s of stalls against 35.1, 1% low 8.4 against 4.1. In the engine (batch luanti-nice, v120nonice the off-switch): 30 against 20 fps, 10.7 against 30.1 s, 8.5 against 4.2. A second mesh thread (v120mesh2) is worse: 28 fps, 12.8 s.
  • luanti 0011: the server unloads unused map blocks after 5 s, not 29. Nothing visible changes: the client keeps its own copy of every block, the blocks around the player stay active, and a block the server needs again is read back from the world database. With 0010 in (batch luanti-srvunload, 3 rounds): 29 s 31 fps, 9.1-9.6 s of stalls, 1% low 9.0, 80-101 MB swapped at the end; 10 s 34 fps, 3.8 s, 13.2, 52-56 MB; 5 s 38 fps, 1.2 s, 16.9, 50-56 MB.

Both together, as built (0011 at 5 s), against both off (v120old, the game as ps3.1123 ran it), 3 rounds, the same boot (batch 2026-10-29-luanti-shipped):

steady fps 1% low stalls in 135 s longest frame swap stalls swapped at the end
before (v120old) 21 (19-21) 4.3 30.9 s (30.8-35.1) 1409 ms 8.2 s 97-105 MB
after (view120) 38 (38-39) 16.7 1.8 s (1.3-2.4) 210 ms 0.1 s 49-53 MB

What is left

The main thread’s own frame at 120 (renderMap’s 180 draw calls a frame, updateDrawList’s 15-40 ms four times a second) holds the steady rate near 38 fps; the stalls left are short bursts with kswapd. Screenshots taken inside the window cost 1-3 s of stalls at this load (xwd makes Xorg copy the frame out of VRAM while the game swaps): stall numbers want the capture in the warm-up. The flight path wants fixing (todo.toml): of its shots inside the window most are rock. Not measured: a played session (pop-in while walking, digging and placing with the server at nice 10), and memory over a long session, where the client keeps up to 7500 blocks for 10 minutes (client_mapblock_limit, client_unload_unused_data_timeout).