6 min left
6 min read

The kernel kills your inference server first, and by default

Michael Hospedales, software engineer in Miami, FL
Michael Hospedales

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 classoom_score_adjkilled...
system services (including the vendor dashboard)0last
gnome-shell, user systemd100
anything launched from a terminal200first

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:

metricvalue
MemTotal121 GiB
MemAvailable before start107–111 GiB
MemAvailable with server up7–11 GiB
llama-server RSS~29 GiB
weights on disk93.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.

beforeafter
CTX_SIZE262144131072
UBATCH_SIZE20481024
idle headroom11 GiB17 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:

  1. The server was mid-generation (4,685 tokens into a task) when stopped, so it did not exit within the SIGTERM grace period and took a SIGKILL.
  2. The stop script waited 2 seconds after SIGKILL, saw a process still unmapping ~87 GiB, and reported a GB10 driver hang. A false diagnosis. SIGKILL is not instant on a process that large.
  3. 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.
  4. 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 MemAvailable recovers past a floor before returning, which is the only thing that makes stop.sh && start.sh safe 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 MemAvailable is 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.