The kernel kills your inference server first, and by default
Drafted by AI · reviewed & edited by Michael
My inference server disappeared. Not crashed — disappeared. No assert, no stack trace, no final log line. The log simply stopped having new lines in it, mid-run, as though the process had been paused.
I noticed twenty minutes later, and only because /metrics had started returning zero bytes.
The whole story is a chain of five things, none of which is about machine learning, and four of which will bite anyone running a large process on a Linux desktop. If you run models at home on one box that also has to be a computer, this is the failure mode I would most want you to have read about beforehand.
1. An OOM kill leaves nothing in the application log
The evidence was not in the server's log because it could never have been. SIGKILL from the
kernel gives a process no opportunity to say anything. The record is in the kernel ring buffer:
13:08:59 NVRM: Check failed: Out of memory [NV_ERR_NO_MEMORY] ... _memdescAllocInternal
13:09:05 NVRM: (repeats)
13:12:12 dashboard-servi invoked oom-killer
13:12:12 Out of memory: Killed process 1966928 (llama-server)
total-vm:164222812kB anon-rss:31651260kB oom_score_adj:200
Two things there are worth internalizing.
The GPU allocation failures preceded the kill by three minutes. That is the earliest warning
available, and it also exists only in the kernel log. Nothing in the server, nothing in
/metrics, nothing in any application-level health check would have shown it.
And the process that invoked the OOM killer is not the process that got killed. dashboard-servi
did not cause the memory pressure; it merely made the allocation that tipped the box over,
whereupon the kernel went looking for the worst-scoring process on the system and found mine.
If a long-running server vanishes with no error, check journalctl -k | grep -i oom-kill
before you go looking for a crash. I have wasted hours reading stack traces that did not exist.
2. The kernel picked mine on purpose, and not because it was big
The killed process carried oom_score_adj: 200. My first assumption was that it had inherited
this from the coding-agent session that launched it. That was wrong — relaunching from a plain
login shell produced the same 200. The source is the systemd user manager:
$ systemctl --user show -p DefaultOOMScoreAdjust
DefaultOOMScoreAdjust=200
Which produces this hierarchy on a stock desktop install:
| process class | oom_score_adj | killed... |
|---|---|---|
| system services (including the vendor dashboard) | 0 | last |
gnome-shell, user systemd | 100 | |
| anything launched from a terminal | 200 | first |
So on a machine whose entire purpose is running an inference server, the inference server is the kernel's first choice, and a 27.7 MB vendor telemetry service is among the last. That is precisely inverted from what anyone would want. My server's effective badness score — the 0–1000 figure the kernel actually ranks on — sat at 946.
The trap is that you cannot fix this after the fact:
$ echo 0 > /proc/<pid>/oom_score_adj
DENIED — still 200
An unprivileged process may raise its own oom_score_adj, but never lower it. By the time
you notice, the only remedy is a restart.
3. The obvious fix is not obviously right
Everyone's first answer is "run it as a systemd system service." A system service defaults to
OOMScoreAdjust=0 and gets you Restart=on-failure for free. It is a good answer for a
dedicated inference host.
It is a worse answer for a workstation. A system service brings semantics you may not want: start on boot, and automatic resurrection. For a model that is holding 87 GiB you sometimes need back for something else, "it comes back whenever it dies" is a regression in control, not an improvement. I want a switch, and a service unit takes the switch away.
The alternative keeps shell scripts as the only on/off control:
sudo choom -n -500 -p "$(cat /tmp/llama-cpp-<model>.pid)"
It needs sudo for the structural reason above, which means it cannot be folded silently into a
start script — it would block on a password prompt and break every non-interactive start. And the
score resets on every restart, so it is a step you repeat rather than set once.
Neither option is free. Pick deliberately; the diagnostic is the same either way
(cat /proc/<pid>/oom_score_adj). I wrote the details up separately as a field note:
DefaultOOMScoreAdjust=200 kills terminal-launched servers first.
4. The headroom I measured was not the headroom that mattered
The configuration that died had been measured, at idle, with what looked like a comfortable margin. Here is why that measurement was worthless — and why RSS is the wrong instrument on unified memory in the first place:
| metric | value |
|---|---|
MemTotal | 121 GiB |
MemAvailable before start | 107–111 GiB |
MemAvailable with server up | 7–11 GiB |
| llama-server RSS | ~29 GiB |
| weights on disk | 93.6 GB (87 GiB) |
RSS reads 29 GiB while 87 GiB of weights sit in CUDA buffers that never enter the process's
resident set. nvidia-smi is no help either — on GB10 it reports [N/A] for memory, because
there is no separate GPU pool to report on. MemAvailable, read before and after start, is the
only honest instrument. (More on that in
RSS is the wrong instrument for memory on a DGX Spark.)
But the deeper error was measuring at idle at all. Peak allocation is the prefill compute
buffer, and it scales with ubatch. For a 512-expert MoE that reservation is the single
largest one in the run — my a-priori arithmetic underestimated total usage by about 9 GiB, and
that gap was almost entirely the compute graph. Synthetic testing never touched it. A real
pipeline issuing 25.6K-token prompts across four slots did, immediately.
| before | after | |
|---|---|---|
CTX_SIZE | 262144 | 131072 |
UBATCH_SIZE | 2048 | 1024 |
| idle headroom | 11 GiB | 17 GiB |
One reassurance from the same investigation: watching RSS climb from 28 to 34.6 GiB over the
first 39 minutes looks exactly like a leak, and I wrote it down as one. A longer window says
otherwise — at 1 h 40 min it sat at 34.5 GiB and MemAvailable had recovered slightly. With
512 experts, a long run progressively touches more of them and then stops. It is a working set
warming up. Restarting prophylactically on a memory-growth theory is not justified; the growth
stops. Restarting on death still is.
5. Restarting an 87 GiB process is a race, and I lost it on someone else's behalf
This is the part I would most like back. Restarting the server killed an unrelated application:
18:05:34 Out of memory: Killed process 32913 (code-insiders) oom_score_adj:300
The chain, every link of it avoidable:
- The server was mid-generation (4,685 tokens into a task) when stopped, so it did not exit
within the
SIGTERMgrace period and took aSIGKILL. - The stop script waited 2 seconds after
SIGKILL, saw a process still unmapping ~87 GiB, and reported a GB10 driver hang. A false diagnosis.SIGKILLis not instant on a process that large. - The start script ran anyway — invoked on the next line rather than chained with
&&— launching a second 87 GiB process alongside the first one's corpse. - The kernel resolved the shortfall by killing the worst-scoring process on the box, which was neither server. My editor sat at 300, outranking even the server's 200.
The generalization is the thing worth carrying:
For a process whose footprint is a large fraction of system RAM, stop and start are not independent operations. The gap between "the process is gone" and "its memory is back" is real, lasts tens of seconds, and is invisible to
kill -0.
Three fixes, in increasing order of importance:
-
Poll after
SIGKILL; do not sleep a fixed interval. Tearing down CUDA buffers and unmapping 87 GiB exceeds two seconds every single time. My driver-hang warning now fires at 60 s, where it means something. -
A dead process is not reclaimed memory. The stop script now blocks until
MemAvailablerecovers past a floor before returning, which is the only thing that makesstop.sh && start.shsafe to chain at all.[INFO] Still unmapping ~87 GiB, waiting (up to 30s)... [INFO] Stopped cleanly. [INFO] Memory reclaimed: 113 GiB available. -
A preflight that warns and proceeds is barely a preflight. Mine computed the shortfall correctly, printed it, and started anyway. It now refuses when
MemAvailableis below the weight size. When the failure mode is "an unrelated process dies," advisory is the wrong setting.
The through line
Four of the five links in this chain are silent by construction. The OOM kill writes to a log the application cannot see. The scoring default is inherited from a manager nobody configured. The memory instrument everyone reaches for reports a third of the truth. And a process that has stopped existing is still holding its memory.
That is the shape these systems fail in: not an error, but an absence — the run that stops, the check that passes, the number that is honest about the wrong quantity. The fix is almost never cleverness. It is knowing which log to read, and being suspicious of any measurement taken while the system was idle.
Measured on a DGX Spark (GB10 / SM 12.1, 128 GB unified LPDDR5x, Ubuntu, aarch64) running
llama-server with unsloth/Qwen3.8-Flash-Next-GGUF at UD-IQ4_XS. More on the box in
home lab AI infrastructure; the workload that found all of
this is the Agentic Daily.