Skip to content

Harden the jit.profile sampler: callback recovery and timer-stop ordering - #663

Draft
damiansirbu wants to merge 5 commits into
themrdemonized:prfrom
damiansirbu:fix-jit-profile-stop-callback
Draft

Harden the jit.profile sampler: callback recovery and timer-stop ordering#663
damiansirbu wants to merge 5 commits into
themrdemonized:prfrom
damiansirbu:fix-jit-profile-stop-callback

Conversation

@damiansirbu

Copy link
Copy Markdown
Contributor

Summary

Robustness improvements to the jit.profile / jit.allocprof sampler from #641.
The profile callback recovers on its own and sampling continues uninterrupted.
The stop path joins the sampler thread before clearing its state, keeping shutdown and the timer thread cleanly separated.

Callback recovery keeps the session running

The profile callback runs under lua_pcall in jit_profile_callback (lib_jit.c).
Upstream LuaJIT hands a non-zero pcall status to the panic handler and then calls exit(EXIT_FAILURE), which suits the luajit CLI but is heavy for a game.
In this fork the panic handler already logs and returns.
CScriptEngine::lua_panic (script_engine.cpp:179) prints the stack and output, then returns 0.
The improvement leans on that: it drops the exit, clears the callback thread stack with lua_settop(L2, 0) so nothing accumulates across samples, and sampling carries on.
This matters most at the LuaJIT 2.0.4 32-bit allocation ceiling during a long capture, where an out-of-memory raise in the callback is absorbed and the run continues.

Timer thread joined before its state is cleared

On stop, luaJIT_profile_stop (lj_profile.c) cleared ps->g = NULL and then stopped the timer.
The Windows sampler runs on its own thread, which reads ps->g inside profile_trigger and dereferences it (g->hookmask, lj_profile.c:163-167).
Joining that thread before clearing its state keeps the two from overlapping on ps->g.
The stop now joins the sampler thread first, setting the abort flag and waiting on it (WaitForSingleObject(ps->thread, INFINITE), lj_profile.c:287-290).
ps->g clears once the thread has exited, so the timer path always reads a live g.

Files changed

  • src/3rd party/luajit-2/src/lib_jit.c (callback recovery, +2/-1)
  • src/3rd party/luajit-2/src/lj_profile.c (timer-stop ordering, +1/-1)

Testing

Built locally (DX11, 0 errors) and run on a live GAMMA session with both profilers compiled in.
For the callback improvement, a probe armed a callback that raised every 10ms for about 6 seconds, roughly 600 raises, then stopped it.
The game stayed alive throughout and kept logging after the probe cleared the callback.
The timer improvement follows from the source above: the deref is direct, and joining first keeps the clear ordered after the thread exits.
The sampler ran a full session with a clean stop.

jit_profile_callback ran exit(EXIT_FAILURE) after a failed lua_pcall, so
one error in the sampling callback killed the whole process. The engine
panic handler (CScriptEngine::lua_panic) already logs the stack and
returns, so the error stays visible. Drop the exit, clear the callback
thread stack, and keep profiling. The failed sample is dropped.
luaJIT_profile_stop cleared ps->g before profile_timer_stop, so the Windows
timer thread could enter profile_trigger, read the now-null g, and
dereference it. Join the timer thread first, then clear the state. No
trigger runs after the join.
@damiansirbu
damiansirbu marked this pull request as draft September 9, 2026 16:57
@damiansirbu

Copy link
Copy Markdown
Contributor Author

Draft while I fold in additional hardening surfaced by large-modpack validation.
GAMMA-scale allocation captures showed that jit.allocprof stack keys can truncate mid-frame on deep call chains, corrupting attribution of the deepest frame.
The fix truncates at frame boundaries instead, alongside a buffer and depth sizing pass.
Ready flag returns once in-game captures confirm clean stacks.

…frames whole

allocprof_record cut the stack key mid-frame at the buffer bound, so a truncated
frame read as an unattributable stack and lost bytes to "unknown". Cut at the last
';' instead, so a partial frame drops whole.

Raise the CPU dumpstack buffer 2048->8192 and the alloc stack key 512->4096 so a
64-deep capture holds the whole stack without mid-frame truncation.
The CPU sampler slept a fixed interval, so a mod running on a fixed schedule could
phase-lock to it and be systematically over- or under-sampled. Draw each sleep from an
exponential around the mean, the same way the allocation profiler already draws its
sample distance, so the sample times are never periodic.
The leaf+stack aggregation tables were 80MB of static image data
(4096+16384 slots x 4104B) after the key-size increase. Allocate them
with calloc on the first jit.allocprof.start instead: a process that
never profiles holds no table memory. The tables stay alive after stop
because consumers dump after allocprof.stop(); reset and slot reads are
NULL-guarded for calls before the first start.
@damiansirbu

Copy link
Copy Markdown
Contributor Author

Pushed 3 more commits from running this in production. I use the profiler as the backend of an in-game profiling UI on a full GAMMA install (600+ mods), and these came out of that.

The buffers were too small for real modpack stacks. The dumpstack buffer went 2048 -> 8192: the deepest stack I have captured in the field is 29 frames, and 2048 could truncate it. The allocprof keys went 512 -> 4096. A stack that still does not fit now cuts at the last ;, so truncation keeps whole frames and can never invent a function name.

The CPU sampler now jitters its interval. A fixed 5ms wakeup can phase-lock with per-frame work and with the 100/200ms throttles that half the scripts in a modpack use, and then it systematically over- or under-samples them. The timer thread now sleeps an exponential draw around the configured mean, the same trick the alloc sampler already uses for its byte distance. The rate stays the same: about 200 samples/s at i5, measured as 22549 samples over a 127s fight capture.

The allocprof tables moved from static arrays to heap allocation on first start. After the key bump they had grown to 80MB of static data. The memory now exists only after the first jit.allocprof.start. The tables survive stop on purpose, because dumps happen after stop.

I did several hours of in-game testing across many sessions, at least 2 of them pure profiling. The latest round: 2-minute combat captures on a vanilla-based install and on full GAMMA, 0% of samples at the depth-64 cap, and a VM-state split that reconciles with the per-stack weights. The stop path has been through many start/stop cycles with the timer-thread join in place.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant