この記事の要点
止めたのに VRAM が空かないときは、親のプロセス (cmd.exe・Python・ollama serve) だけが終わり、GPU を使っている子の llama-server が居残っていることがある。居残った子は VRAM を握ったまま応答も返し続けたが、下の手順で PID を探してtaskkill /PID <PID> /F で止めれば、再起動しなくても VRAM は空いた。
居残らせない止め方は、taskkill に /T を付けて子までたどるか、Python から llama-server を直接起動したなら、同じ Python から terminate() で止める形 (shell=True は使わない)。起動する側 (ランチャー) ごと強制終了されても子を残したくないなら、子を起動する前にランチャーを Job Object (プロセスをまとめて終了させる Windows の仕組み) に入れる。
居残りはエラーにならない。同じポートで起動し直しても古いプロセスが答えたり、VRAM に余裕が無いと次のモデルが大きく遅くなったりした。
VRAMが空かない原因のひとつは、止めた親の子が居残っていること
llama-server を cmd.exe・.bat・Python・ollama serve のような別のプロセス (親。以下、ランチャーとも呼ぶ) から起動している場合、親だけを強制終了しても、GPU を使っている子の llama-server は居残る。Microsoft Learn の Win32 の説明 (Terminating a Process) にあるとおり、Windows はプロセスを終了させるとき、そのプロセスが作った子プロセスまでは終了させない。この測定では、親のプロセスが終わった後も動き続けている子のプロセス (以下、居残ったプロセス) が VRAM を握ったまま残り、HTTP の問い合わせにも応答し続けた。
この記事で試したのは taskkill (/T あり・なし)・Python の terminate()・ollama serve の強制終了で、ターミナルでの Ctrl+C・コンソールウィンドウを閉じる操作・タスクマネージャーでの終了・Ollama のタスクトレイからの終了は試していない。どの止め方でも、親が終了して子だけが残った場合は、次の手順で見つけて止められる。親がまだ動いている子は、この手順の探し方では対象にならない。
扱うのは Windows ネイティブのプロセスで、WSL の中で動く Linux のプロセスは対象外。また、Ollama の keep_alive のようにモデルを載せたままにしておく設定で VRAM が埋まっているのは別の話になる。そちらの設定の考え方はOllamaを常駐・並列運用する実運用設定で扱っている。
いま VRAM が空かないときの手順
- 「居残ったプロセスを見つける」の節のスクリプト (
gpu-mem-by-process.ps1) で、GPU メモリを握っていて、親が動いていると確認できないプロセス (ParentAliveが False) を候補として探す。タスクマネージャーの詳細タブにある「専用 GPU メモリ」列で量の大きいプロセスの目星を付けてもよいが、親が動いているかどうかはこの列では分からない。 - 見つかったプロセスの名前とパスを確かめる。名前が
llama-server.exeで、パスが自分で置いた llama.cpp のフォルダか Ollama のインストール先 (Ollama に同梱のもの) なら、居残ったllama-serverの候補と考えられる。それ以外のプロセスは止めない。 taskkill /PID <PID> /Fで止める。
どの止め方で子が居残るかは「どの止め方だと子が居残るか」、今後居残らせないための起動の仕方と止め方は「居残らせない起動の仕方と止め方」の節で扱う。
居残ったプロセスを見つける
居残ったプロセスは、次の PowerShell スクリプト gpu-mem-by-process.ps1 で探せる。プロセスごとの専用 GPU メモリと、親がまだ動いていると確認できるかを一度に表示する。gpu-mem-by-process.ps1 という名前で保存し、PowerShell から powershell -ExecutionPolicy Bypass -File .\gpu-mem-by-process.ps1 で実行する。英数字だけで書いてあるので、メモ帳などで保存した文字コードに関係なく Windows PowerShell 5.1 で読み込める。表示するだけで、プロセスは止めない。
<#
gpu-mem-by-process.ps1
Shows dedicated GPU memory per process, read from the performance counter
'\GPU Process Memory(*)\Dedicated Usage'.
Use this when nvidia-smi --query-compute-apps shows [N/A] for memory (WDDM mode).
It also shows whether the original parent can be confirmed as still alive.
Usage:
powershell -ExecutionPolicy Bypass -File .\gpu-mem-by-process.ps1
powershell -ExecutionPolicy Bypass -File .\gpu-mem-by-process.ps1 -MinMiB 500
- Tested on Windows PowerShell 5.1 without administrator rights (Japanese-language Windows 11).
PowerShell 7 has not been tested. Without administrator rights, Path is empty for
processes owned by other users.
- This file is ASCII only, so it parses the same in Windows PowerShell 5.1 regardless of file encoding.
- With multiple GPUs, usage is summed per process; GPUs are not listed separately.
- The counter name is given in English. If it cannot be read, the script says so and exits.
- This script never stops any process. It only prints example commands.
#>
[CmdletBinding()]
param(
# Show only processes using at least this much dedicated GPU memory (MiB)
[double]$MinMiB = 100
)
$counterPath = '\GPU Process Memory(*)\Dedicated Usage'
# Read the counter once.
# A process that exits while the counter is being read leaves an invalid sample, and
# -ErrorAction Stop would then fail the whole read. Read with SilentlyContinue and keep
# only the valid samples (Status 0). Retry a few times if nothing valid came back.
$samples = $null
for ($try = 1; $try -le 3 -and -not $samples; $try++) {
$result = Get-Counter -Counter $counterPath -MaxSamples 1 -ErrorAction SilentlyContinue
if ($result) {
$samples = @($result.CounterSamples | Where-Object { $_.Status -eq 0 })
}
}
if (-not $samples) {
Write-Host "Could not read the performance counter (0 instances): $counterPath" -ForegroundColor Red
exit 1
}
# Instance names look like pid_<PID>_luid_..._phys_<n>.
# One PID can have several instances (per GPU / adapter), so sum them per PID.
$bytesByPid = @{}
foreach ($s in $samples) {
if ($s.InstanceName -match '^pid_(\d+)_') {
$procId = [int]$Matches[1]
if ($procId -eq 0) { continue }
if (-not $bytesByPid.ContainsKey($procId)) { $bytesByPid[$procId] = 0.0 }
$bytesByPid[$procId] += $s.CookedValue
}
}
# All processes (to look up parent PID and creation time)
$procs = @{}
foreach ($p in Get-CimInstance -ClassName Win32_Process) {
$procs[[int]$p.ProcessId] = $p
}
$rows = foreach ($procId in $bytesByPid.Keys) {
$mib = $bytesByPid[$procId] / 1MB
if ($mib -lt $MinMiB) { continue }
$p = $procs[$procId]
$exists = [bool]$p
$name = '(exited)'
$path = $null
$parentId = $null
$parentAlive = $false
if ($exists) {
$name = $p.Name
$path = $p.ExecutablePath
$parentId = [int]$p.ParentProcessId
$parent = $procs[$parentId]
# Windows reuses PIDs, so a process with the parent's PID is not necessarily the parent.
# Treat the parent as alive only if that process was created before the child.
# If either creation time is missing, it cannot be judged and is treated as False.
if ($parent -and $parent.CreationDate -and $p.CreationDate -and
($parent.CreationDate -lt $p.CreationDate)) {
$parentAlive = $true
}
}
[pscustomobject]@{
PID = $procId
Name = $name
DedicatedMiB = [math]::Round($mib, 1)
ParentPID = $parentId
ParentAlive = $parentAlive
Path = $path
Exists = $exists
}
}
if (-not $rows) {
Write-Host "No process is using $MinMiB MiB or more of dedicated GPU memory."
exit 0
}
$rows = @($rows | Sort-Object -Property DedicatedMiB -Descending)
$rows | Format-Table -Property PID, Name, DedicatedMiB, ParentPID, ParentAlive, Path -AutoSize -Wrap
# For processes whose original parent could not be confirmed as alive, print example
# stop commands (not executed).
# Some Windows processes, such as explorer.exe, also show ParentAlive False in normal use, so programs
# under the Windows folder (or without a readable path) are left out of the examples.
# Still check the name and path before stopping anything.
$winDir = $env:SystemRoot.TrimEnd('\') + '\'
$noParent = @($rows | Where-Object {
$_.Exists -and -not $_.ParentAlive -and $_.Path -and
-not $_.Path.StartsWith($winDir, [System.StringComparison]::OrdinalIgnoreCase)
})
if ($noParent.Count -gt 0) {
Write-Host "Processes whose original parent could not be confirmed as alive (example commands to stop them; not executed by this script):"
foreach ($o in $noParent) {
Write-Host (" taskkill /PID {0} /F # {1}, {2} MiB" -f $o.PID, $o.Name, $o.DedicatedMiB)
}
}
表示されるのは、専用 GPU メモリを一定量 (既定は 100 MiB、-MinMiB で変えられる) 以上使っているプロセスの PID・名前・専用 GPU メモリ・親の PID・親が動いていると確認できたか (ParentAlive)・実行ファイルのパス。ParentAlive が False は、元の親が今も動いているとスクリプトで確認できなかったことを示す。親のプロセスが見つからない場合のほか、親の PID が後から起動した別のプロセスに使い回されている場合や、作成時刻が取れず判定できない場合も False になる。Microsoft Learn の Win32_Process の説明でも、親の PID は親の終了後に動いていないプロセスや別のプロセスを指すことがあり、作成時刻で見分けられるとされている。False のプロセスのうち、Windows フォルダの外にある実行ファイルにだけ、止めるコマンドの例 (taskkill /PID <PID> /F) を出す。
このスクリプトは、管理者権限なしの Windows PowerShell 5.1 (表示言語が日本語の Windows 11) で動作を確かめた。居残らせた llama-server を、専用 GPU メモリ約 5,160 MiB・ParentAlive False として表示し、止めるコマンドの例も出た。英語名のカウンターは日本語表示の Windows でもそのまま読めた。PowerShell 7 では試していない。
ParentAlive が False のプロセスが、すべて止めてよいプロセスとは限らない。この測定機でも explorer.exe が False と出た。止めるコマンドの例を Windows フォルダの外の実行ファイルに限っているのはそのためで、例が出たプロセスでも、止める前に名前とパスを確かめる。
スクリプトで llama-server や Ollama の子が見つからないのに VRAM が埋まっているなら、居残りではなく別の原因を疑う。たとえば keep_alive でモデルが載ったままになっている、ほかのアプリが GPU を使っている、WSL2 の中で GPU を使う処理が動いている、といったケースになる。同じ測定機で WSL2 の中の処理が GPU を使っていたときは、Windows 側のカウンターではその GPU メモリが vmwp.exe の値としてまとめて見えた。
nvidia-smi では量が分からない理由
nvidia-smi でも居残ったプロセスは一覧に出るが、量が分からない。居残った llama-server について、止めてから3秒後に nvidia-smi --query-compute-apps=pid,process_name,used_memory と、パフォーマンスカウンター \GPU Process Memory(*)\Dedicated Usage (PID ごとに合計) を読み比べた。
| 読み方 | 居残った llama-server について出た値 |
|---|---|
| nvidia-smi –query-compute-apps | PID とプロセス名は出た。used_memory は [N/A] |
| パフォーマンスカウンター GPU Process Memory の Dedicated Usage | 5,410,508,800 バイト (5,160 MiB) |
| nvidia-smi の GPU 全体の memory.used | 5,160 MiB (llama-server を起動する前は 0 MiB) |
nvidia-smi のプロセス一覧に居残った llama-server の PID とプロセス名は出たので、一覧には GPU を使うプロセスとして載っている。出なかったのはメモリ量だけ。NVIDIA の nvidia-smi のドキュメントでは、プロセスごとの GPU メモリ使用量は Windows の WDDM モードでは取得できない (Windows のカーネルモードドライバがメモリを管理しているため) とされている。通常のデスクトップ向け Windows で使われるドライバ方式が、この WDDM になる。
代わりに Windows のパフォーマンスカウンター GPU Process Memory の Dedicated Usage を PID ごとに読むと、居残った llama-server の値は nvidia-smi で見た GPU 全体の増分と一致した。一致を確かめたのはこの測定の llama-server についてで、デスクトップの描画などほかのプロセスの値の意味は確かめていない。
手元でこのカウンターを読むときは、nvidia-smi の GPU 全体の値と突き合わせて、実際の量と合っているかを確かめる。Microsoft Learn のトラブルシューティング記事によると、Windows 10 (1709 以降) には、パフォーマンスモニターの GPU Process Memory とタスクマネージャーの詳細タブの「専用 GPU メモリ」列で、値が実際より大きく出る既知の不具合がある。この測定は Windows 11 で、居残った llama-server の値は nvidia-smi と一致した。タスクマネージャーの列は量の大きいプロセスの目星を付けるのに使えるが、この測定では列の値は読んでいない。また、親が動いているかどうかはこの列では分からない。
どの止め方だと子が居残るか
起動の仕方と止め方の組み合わせを11通り用意し、それぞれ3回ずつ止めた。止める操作の後、最大10秒間 nvidia-smi -i 1 で RTX 5060 Ti の VRAM を読み、あわせて止めてから3秒後に子のプロセスが生きているかと、そのプロセスの /health が HTTP 200 を返すかを見た。llama.cpp の server の README によると、/health はモデルを読み込み終えて受け付けられる状態なら HTTP 200 と {"status": "ok"} を返す。つまり 200 が返れば、その llama-server はモデルを載せたまま問い合わせを受け付けている。
表の「止めた操作」は、誰が誰を止めたかで2種類に分かれる。taskkill の行は外から親 (cmd.exe・Python・ollama serve) を止めた操作で、terminate() の行は Python が自分の起動した子を止めた操作。「空いた」は、VRAM が llama-server を起動する前の値 (0 MiB) + 300 MiB 以内に戻ったことを指す。
| 起動の仕方 | 止めた操作 | 子の llama-server | 止めた後の VRAM (最後の読み取り) | 空いたか |
|---|---|---|---|---|
| cmd.exe /c 経由で llama-server を起動 | cmd.exe を taskkill /F (/T なし) | 3回とも残った (/health も 200) | 5,160 MiB | 空かなかった |
| cmd.exe /c 経由で llama-server を起動 | cmd.exe を taskkill /T /F | 3回とも終了 | 0 MiB | すぐ空いた |
| Python の Popen に文字列で渡し shell=True | terminate() | 3回とも残った (/health も 200) | 5,160 MiB | 空かなかった |
| Python の Popen にリストで渡す (shell なし) | terminate() | 3回とも終了 | 0 MiB | すぐ空いた |
| 親の Python が Popen (リスト) で起動 | 親の Python を taskkill /F (/T なし) | 3回とも残った (/health も 200) | 5,160 MiB | 空かなかった |
| 親の Python が自分を Job に入れてから起動 | 親の Python を taskkill /F (/T なし) | 3回とも終了 | 0 MiB | すぐ空いた |
| 親の Python が起動した後で自分を Job に入れた | 親の Python を taskkill /F (/T なし) | 3回とも残った (/health も 200) | 5,160 MiB | 空かなかった |
| ollama serve (0.32.11) で llama3.1:8b を読み込み | ollama serve を taskkill /F (/T なし) | 同梱の llama-server.exe が3回とも残った (/health も 200) | 5,160 MiB | 空かなかった |
| ollama serve (0.32.11) で llama3.1:8b を読み込み | ollama serve を taskkill /T /F | 3回とも終了 | 0 MiB | すぐ空いた |
| ollama serve (0.35.0) で llama3.1:8b を読み込み | ollama serve を taskkill /F (/T なし) | 同梱の llama-server.exe が3回とも残った (/health も 200) | 5,160 MiB | 空かなかった |
| ollama serve (0.35.0) で llama3.1:8b を読み込み | ollama serve を taskkill /T /F | 3回とも終了 | 0 MiB | すぐ空いた |
1台の測定機・1つのモデル (llama3.1:8b の Q4_K_M) での結果で、VRAM はプロセスごとではなく GPU 全体の値。「すぐ空いた」は、止めた後の最初の読み取り (止めてから 0.05〜0.38 秒後) で既に空いていたことを指す。それより細かい時間は測っていない。
表から読み取れることは次の4つ。
- 親だけを強制終了した行 (
cmd.exeを/Tなしで止めた行、shell=Trueで起動してterminate()した行、Job に入れていない親の Python をtaskkillで止めた行、ollama serveを/Tなしで止めた行) は、どれも子のllama-serverが3回とも残り、VRAM も空かなかった。例外は、子を起動する前に親の Python が自分をJob Objectに入れておいた行で、その親を同じ形で止めても子は3回とも終わった。 /Tを付けた行は空いた。Microsoft Learn のtaskkillの説明では、/tは指定したプロセスとそのプロセスが起動した子プロセスを終了させ、/fは強制終了を指定する。親から子までたどって止めるので、子も残らない。- Python がリストで起動した子を同じ Python から
terminate()した行も空いた。ただしこれは/Tのように子をたどったからではない。Python のドキュメントによると、Windows のterminate()は Win32 API のTerminateProcess()で、Popenが起動した子を止める。リスト形式ではPopenが直接起動したのがllama-serverそのものなので、止めた相手が GPU を使っているプロセスだった。同じリスト形式でも、Python 自体を外から強制終了した行では子が残っている。 shell=Trueの行は、Windows では間にシェルが挟まる。Python のドキュメントには、Windows でshell=Trueを指定すると既定のシェルは環境変数COMSPECで決まる (通常はcmd.exe) とある。そのためterminate()が止めたのはcmd.exeだけで、その下のllama-serverは残った。これはドキュメントに直接書かれた文ではなく、「Windows は子プロセスを終了させない」「terminate()はPopenが起動した相手を止める」という記述と、測った結果を合わせた説明になる。
表の Job Object を使った2つの組み合わせがなぜ分かれたかと、Ollama の組み合わせの条件は、「居残らせない起動の仕方と止め方」の節で扱う。
居残らせない起動の仕方と止め方
いま居残っているプロセスは、上の「居残ったプロセスを見つける」の節の手順で PID を指定して止める。表の組み合わせで居残った子を taskkill /PID <PID> /F で止めた測定では、どれも VRAM が 0 MiB に戻り、止める操作を始めてから1秒以内に空いた。今後居残らせないためには、使い方に合わせて次のどれかを選ぶ。
- 親を外から止めるとき:
taskkillに/Tを付ける。ただし親が既に終了していると/Tはたどる先が無いので、居残った子は PID を指定して止める。 - Python から
llama-serverを直接起動するとき:shell=Trueを使わずにリスト形式でPopenに渡し、止めるときは同じ Python からterminate()する。これで空いたのは、Popenがllama-serverを直接起動した場合。ollama serveのように自分でさらに子を作るプログラムを Python から起動した場合に、terminate()で子まで止まるかは試していない (ollama serveを外から/Tなしで強制終了した組み合わせでは子が残った)。その場合は/Tか、次のJob Objectを使う。 - ランチャーごと強制終了されうるとき: Python 自体が強制終了された場合は、リスト形式でも子が残った。これも防ぎたいなら、子を起動する前にランチャー自身を
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE付きのJob Objectに入れる。そのためのスクリプトを下に載せた (launch_in_job.py)。
Ollama の場合
ollama serve を /T なしで止めると、その子として動いていた同梱の llama-server.exe が残り、/T を付けると残らなかった。0.32.11 と 0.35.0 のどちらでも同じ結果だった (「どの止め方だと子が居残るか」の表の Ollama の4つの組み合わせ)。
Ollama の Windows 向けドキュメントによると、Windows 版の Ollama はインストール後にバックグラウンドで動き、動いている Ollama を終了するときはタスクトレイのアプリから終了する。普段アプリとして使っている場合の止め方はこちらで、タスクトレイからの終了で子が残るかは測っていない。表の結果が当てはまるのは、ollama serve を自分で起動して止める使い方のときになる。
Job Object に入れるのは、子を起動する前
Microsoft Learn の説明では、Job Object に JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE を付けておくと、その Job の最後のハンドルが閉じたときに、Job に属するプロセスがすべて終了する。また、プロセスを Job に割り当てた後にそのプロセスが CreateProcess で作る子プロセスは、既定で同じ Job に入る。ランチャーが強制終了されると OS がハンドルを閉じるので、Job に入っている子も一緒に終わる、という形を作れる。
「どの止め方だと子が居残るか」の表で Job Object を使った2つの組み合わせが、これを試したもの。親の Python が子を起動する前に自分を Job に入れた場合は、親を /T なしで強制終了しても子が3回とも一緒に終わった。子を起動した後で自分を Job に入れた場合は、子が3回とも残った。IsProcessInJob で確かめると、起動前に割り当てた3回は子が Job に入っていて、起動後に割り当てた3回は入っていなかった。割り当てより前から動いていた子の扱いは、Microsoft Learn の Job Objects と AssignProcessToJobObject のページには明記が見当たらなかった。ここで書いたのは実測の結果で、起動した後で Job に入れた場合、すでに動いていた子は Job に入らなかった。だから子より先に割り当てる。
この順番で起動するランチャーが次の launch_in_job.py。launch_in_job.py という名前で保存し、python launch_in_job.py <実行ファイル> [引数...] で子のプログラムを起動する。標準ライブラリだけで動く Windows 専用のスクリプトで、子を起動する前に自分を JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE 付きの Job Object に入れるので、このスクリプトが強制終了されても子が一緒に終わる。日本語のコメントを含むので、UTF-8 で保存する。
"""launch_in_job.py
子プロセス (llama-server など) を Windows の Job Object の中で起動する。
このランチャーが taskkill /F などで強制終了されても、
Job のハンドルが閉じた時点で子プロセスもまとめて終了し、GPU メモリを握ったまま残らない。
使い方:
python launch_in_job.py <実行ファイル> [引数...]
例:
python launch_in_job.py llama-server.exe -m model.gguf -ngl 99 --port 8080
仕組み:
1. JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE を付けた Job を作る
2. 子を起動する前に、ランチャー自身をその Job に割り当てる
3. 子を起動する (割り当て後に作られた子は既定で同じ Job に入る)
注意: ランチャーを Job に割り当てても、それより前に起動済みの子が
自動的に同じ Job に入るわけではない。だから子より先に割り当てる。
Windows 専用。標準ライブラリのみ (Python 3.11.9 で動作を確認)。
"""
import ctypes
import subprocess
import sys
# Win32 の型 (64bit で HANDLE やポインタ幅の値が切れないように明示する)
HANDLE = ctypes.c_void_p
BOOL = ctypes.c_int
DWORD = ctypes.c_uint32
LPVOID = ctypes.c_void_p
LPCWSTR = ctypes.c_wchar_p
JobObjectExtendedLimitInformation = 9
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
_fields_ = [
("PerProcessUserTimeLimit", ctypes.c_int64), # LARGE_INTEGER
("PerJobUserTimeLimit", ctypes.c_int64), # LARGE_INTEGER
("LimitFlags", DWORD),
("MinimumWorkingSetSize", ctypes.c_size_t), # SIZE_T
("MaximumWorkingSetSize", ctypes.c_size_t), # SIZE_T
("ActiveProcessLimit", DWORD),
("Affinity", ctypes.c_size_t), # ULONG_PTR
("PriorityClass", DWORD),
("SchedulingClass", DWORD),
]
class IO_COUNTERS(ctypes.Structure):
_fields_ = [
("ReadOperationCount", ctypes.c_uint64),
("WriteOperationCount", ctypes.c_uint64),
("OtherOperationCount", ctypes.c_uint64),
("ReadTransferCount", ctypes.c_uint64),
("WriteTransferCount", ctypes.c_uint64),
("OtherTransferCount", ctypes.c_uint64),
]
class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
_fields_ = [
("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION),
("IoInfo", IO_COUNTERS),
("ProcessMemoryLimit", ctypes.c_size_t), # SIZE_T
("JobMemoryLimit", ctypes.c_size_t), # SIZE_T
("PeakProcessMemoryUsed", ctypes.c_size_t), # SIZE_T
("PeakJobMemoryUsed", ctypes.c_size_t), # SIZE_T
]
def create_kill_on_close_job():
"""KILL_ON_JOB_CLOSE 付きの Job を作り、このプロセス自身を割り当てる。
失敗したら例外にする (Job が無いまま子を起動しない)。
返したハンドルは閉じずに持ち続けること。閉じると Job 内のプロセス (子) が即座に終わる。
"""
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateJobObjectW.argtypes = [LPVOID, LPCWSTR]
kernel32.CreateJobObjectW.restype = HANDLE
kernel32.SetInformationJobObject.argtypes = [HANDLE, ctypes.c_int, LPVOID, DWORD]
kernel32.SetInformationJobObject.restype = BOOL
kernel32.GetCurrentProcess.argtypes = []
kernel32.GetCurrentProcess.restype = HANDLE
kernel32.AssignProcessToJobObject.argtypes = [HANDLE, HANDLE]
kernel32.AssignProcessToJobObject.restype = BOOL
kernel32.CloseHandle.argtypes = [HANDLE]
kernel32.CloseHandle.restype = BOOL
# セキュリティ属性に NULL を渡すと、ハンドルは子へ継承されない
job = kernel32.CreateJobObjectW(None, None)
if not job:
raise ctypes.WinError(ctypes.get_last_error())
try:
info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
ok = kernel32.SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
ctypes.byref(info),
ctypes.sizeof(info),
)
if not ok:
raise ctypes.WinError(ctypes.get_last_error())
# 子を起動する前に、ランチャー自身を Job に入れる
ok = kernel32.AssignProcessToJobObject(job, kernel32.GetCurrentProcess())
if not ok:
raise ctypes.WinError(ctypes.get_last_error())
except Exception:
# ランチャーを割り当てる前の失敗なので、ここで閉じても子への影響は無い
kernel32.CloseHandle(job)
raise
return job
def main():
if sys.platform != "win32":
print("このスクリプトは Windows の Job Object を使うため、Windows 専用です。", file=sys.stderr)
return 1
if len(sys.argv) < 2:
print("使い方: python launch_in_job.py <実行ファイル> [引数...]", file=sys.stderr)
return 2
# 戻り値のハンドルはプロセス終了まで保持する (閉じない)。
# ランチャーが強制終了されると OS がハンドルを閉じ、Job 内の子がまとめて終了する。
job = create_kill_on_close_job() # noqa: F841
try:
proc = subprocess.Popen(sys.argv[1:]) # shell=False
except FileNotFoundError:
print(f"実行ファイルが見つかりません: {sys.argv[1]}", file=sys.stderr)
return 1
try:
return proc.wait()
except KeyboardInterrupt:
# Ctrl+C を受けたら子を止めてから、終了を待つ
proc.terminate()
return proc.wait()
if __name__ == "__main__":
sys.exit(main())
このスクリプトは本記事の測定機の Python 3.11.9 で動作を確かめた。それより前の版では試していない。llama-server (build 11218) を llama3.1:8b・-ngl 99 で起動し、このスクリプトのプロセスだけを taskkill /F (/T なし) で止めると、3回とも1秒後には llama-server が終了し、VRAM は 5,160 MiB から 0 MiB に戻った。対照として、Job を使わない同じ形のスクリプト (子は Windows の ping コマンド) を同じように止めると、子が3回とも残った。llama-server を子にして Job を使わなかった場合は、「どの止め方だと子が居残るか」の表のとおり子が残っている。
居残った子は、エラーを出さずに次の作業を邪魔する
この測定では、居残ったプロセスがあってもエラーは一度も出なかった。そのかわり、次の2つのことが起きた。
同じポートで起動し直しても、答えるのは古いプロセスだった
cmd.exe を /T なしで止めて llama-server を居残らせた状態で、それと同じポート番号を指定し、同じモデル・同じオプションで2つ目の llama-server を起動した (3回)。
| 項目 | 結果 |
|---|---|
| 2つ目の起動 | 3回ともエラーなく起動し、listening のログを出した |
| ポートの使用中エラー | 3回とも出なかった |
| そのポートで待ち受けていたプロセス | 居残ったプロセスと2つ目の両方 (3回とも) |
| GPU 全体の VRAM | 5,160 MiB → 10,319 MiB |
2つのプロセスが同じポートで待ち受け、VRAM は2つ分に増えた。どちらが問い合わせに答えているかを見分けるため、2つ目には別のモデル (Qwen3.8-27B UD-Q3_K_XL を GPU を使わない設定で) を指定して同じ形で起動し直した。それぞれの状況で /props に20回 (0.2秒間隔・毎回新しい接続) 問い合わせ、応答に含まれるモデルのファイル名でどちらが答えたかを判定した (各3回)。
| 状況 | 応答したプロセス |
|---|---|
| 居残ったプロセスだけが動いている | 居残ったプロセス (20回中20回、3回とも) |
| 2つ目も同じポートで待ち受けている | 居残ったプロセス (20回中20回、3回とも) |
| 2つ目を止めた後 | 居残ったプロセス (20回中20回、3回とも) |
| 参考: netstat -ano の LISTENING 行 | 2つ目を起動した後は、同じポートに PID の違う行が2つ出た (3回とも) |
答えたのは毎回古いほうのプロセスで、2つ目を止めた後も古いほうが答え続けた。新しいモデルや設定で起動し直したつもりでも、実際には古いプロセスが答えている形になる。
同じポートで2つが待ち受けられたこと自体は、仕組みどおりの動きと考えられる。llama.cpp の server のソース (この測定と同じ build 11218 の tools/server/server-http.cpp) は、待ち受けのソケットに SO_REUSEADDR を付けている。Microsoft Learn の Winsock の説明によると、Windows では SO_REUSEADDR を付けたソケットは、ほかのソケットが使っているポートにも bind でき、その後どちらのソケットが接続を受けるかは決まっていない (non-deterministic) とされる。この測定で20回とも古いほうに届いた理由までは確かめておらず、この測定機・この版 (build 11218) の llama-server での結果で、ほかのソフトや Windows の版で同じになるとは限らない。手元で同じ状態になっていないかは、netstat -ano で同じポートの LISTENING 行が PID の違う2行になっていないかで確かめられる。
VRAM に余裕の無い組み合わせでは、次のモデルが大きく遅くなった
llama3.1:8b を載せた llama-server (5,160 MiB) が居残っている状態と居ない状態で、Qwen3.8-27B の UD-Q3_K_XL (GGUF) を同じ GPU に -ngl 99 -c 4096 で起動し、/completion で128トークンを生成した (temperature 0・seed 1・プロンプト86トークン・キャッシュなし)。この生成を3回行い、それを各条件3回繰り返している (計9回の生成)。速さは llama-server の応答の timings (predicted_per_second / prompt_per_second) から取った。
| 条件 | 起動 | GPU 全体の VRAM | 生成の速さ | プロンプト処理の速さ |
|---|---|---|---|---|
| 居残りあり | エラーなく起動 | 16,011 MiB | 毎秒7.33トークン | 毎秒29.9〜30.7トークン |
| 居残りなし | エラーなく起動 | 12,792 MiB | 毎秒28.8トークン | 毎秒298〜439トークン |
生成の速さは9回の中央値 (居残りありは 7.328〜7.337、居残りなしは 28.76〜28.86)。プロンプト処理は範囲で示した。居残りなしは各3回の1回目が約 298〜300、2〜3回目が約 412〜439 だった。どちらの条件もエラーなく起動したが、居残りがあると、生成は居残りが無いときの約4分の1になり、プロンプト処理はさらに大きく落ちた。
この結果には条件がある。27B は単独なら GPU 全体で 12,792 MiB を使い、居残ったプロセスの 5,160 MiB と合わせると約 17,950 MiB と、RTX 5060 Ti の 16GB を超える組み合わせだった。合わせても VRAM に収まるモデルでの影響は測っていない。「居残ったプロセスが握っている量 + 次に載せるモデルの量」が VRAM に収まるかは、影響が出るかどうかの目安になる。超えた分がどれだけの遅さになるかは、この GPU (OCuLink 接続の PCIe 4.0 x4) とこのモデルの1つの組み合わせでしか測っておらず、ほかの構成で同じ比率になるとは限らない。27B を 16GB に載せたときの詳しい数字はQwen3.8-27BをVRAM 16GBで実測、ほかの AI 作業と VRAM を分け合うときの実測はローカルLLM常駐時のVRAM配分にある。
遅くなったときのメモリの置き場所を見ると、居残りがあるときは 27B の一部 (約 0.9GB) が、専用 GPU メモリ (VRAM) から共有 GPU メモリ (メインメモリ) の側へ出ていた。確かめるため、同じ条件を各3回やり直した。27B の llama-server に -lv 4 を付けて起動時のログを詳しく出し、生成中にパフォーマンスカウンター \GPU Process Memory(*)\Dedicated Usage と \GPU Process Memory(*)\Shared Usage を 27B と居残ったプロセスのそれぞれについて同じ時点で読んだ (PID ごとに合計)。GPU ごとの合計 (\GPU Adapter Memory(*)) も記録している。
| 項目 | 居残りあり | 居残りなし |
|---|---|---|
| llama-server が起動時に見積もった VRAM の空き (ログ) | 14,572 MiB | 14,572 MiB |
| GPU に置いた層 (起動ログ) | 66/66 | 66/66 |
| 27B の専用 GPU メモリ (カウンター) | 11,884 MiB | 12,798 MiB |
| 27B の共有 GPU メモリ (カウンター) | 1,022 MiB | 106 MiB |
| 居残ったプロセスの専用 / 共有 GPU メモリ (カウンター) | 4,136 MiB (載せた直後は 5,160 MiB) / 100 MiB | – |
| GPU 全体の専用 / 共有 GPU メモリ (カウンター) | 16,024 MiB / 1,122 MiB | 12,802 MiB / 106 MiB |
カウンターの値はどれも生成中に読んだもので、3回とも同じ値だった。
llama-server が起動時に見積もった空きは、居残りの有無にかかわらず同じ 14,572 MiB で、居残ったプロセスの 5,160 MiB を差し引いた値になっていない (理由は確かめていない)。どちらの場合も、ログには調整不要 (no changes needed) と出て、全層を GPU に置いて起動した。一方カウンターでは、居残ったプロセスがあるとき、27B の専用 GPU メモリが 12,798 から 11,884 MiB に減り、そのぶん共有 GPU メモリが 106 から 1,022 MiB に増えていた。約 0.9GB が専用から共有の側へ移った形になる。居残ったプロセス自身も、専用 GPU メモリとして数えられる量が 5,160 から 4,136 MiB に減っていた。こちらは自身の共有 GPU メモリが 100 MiB だったので、減った分がどこへ行ったかは分からない。27B の専用 11,884 MiB と居残ったプロセスの専用 4,136 MiB の合計 (16,020 MiB) は、GPU 全体の専用 16,024 MiB とほぼ一致していた。
Microsoft の DirectX 開発者ブログ (2017年の記事) の説明では、タスクマネージャーでいう共有 GPU メモリは、GPU と CPU のどちらからも使える普通のシステムメモリ (メインメモリ) のこと。共有 GPU メモリに置かれた分は遅さの原因の一部として考えられる。ただし、遅さのどれだけを占めるか、どの仕組みで共有 GPU メモリに置かれたかは確かめていない。どちらの表の VRAM も GPU 全体の値で、プロセスごとの値はカウンターの行だけになる。
試した環境と条件
- GPU: RTX 5060 Ti 16GB (OCuLink = PCIe 4.0 x4 の外付け接続・画面表示なし) 1枚だけを、環境変数
CUDA_DEVICE_ORDER=PCI_BUS_IDとCUDA_VISIBLE_DEVICES=1で使った。測定機にはもう1枚 RTX 5080 16GB (画面表示を兼ねる) が挿さっているが、この測定では使っていない - OS・ドライバ: Windows 11、NVIDIA ドライバ 610.47
- llama-server: llama.cpp の build 11218 (commit 33c923db1)
- Ollama: 0.32.11 と 0.35.0 (0.35.0 は公式の Windows 用 zip 版を別のフォルダに展開して使った)。どちらも VRAM 16GB に合わせて文脈長の既定を 4096 にし、同梱の
llama-serverを-c 4096で起動していた (ログで確認) ので、VRAM はllama-serverを直接起動した行と同じ量になった。ollama serveは別のポートで起動し、Ollama が Vulkan 経由で RTX 5080 を選ばないようGGML_VK_VISIBLE_DEVICES=-1も設定した (測定機に GPU が2枚あるための設定で、1枚の構成なら要らない)。/api/generateで llama3.1:8b を読み込ませてから serve を止めている - モデル: llama3.1:8b の Q4_K_M (Ollama が保存している GGUF ファイルを
llama-serverにそのまま渡した)。速度の測定では Qwen3.8-27B の UD-Q3_K_XL も使った - 起動オプション:
-ngl 99 -c 4096。この状態で RTX 5060 Ti の VRAM は 5,160 MiB - VRAM の読み方:
nvidia-smi -i 1で読んだmemory.used。RTX 5060 Ti 1枚全体の値で、プロセスごとの値ではない。止める操作の後、最大10秒間、約 0.09 秒ごとに読んだ。空いた行は、空いた最初の読み取りから約 0.5 秒読み続けて再び増えないことを確かめ、そこで打ち切った。空かなかった行は10秒間読み続けた - 空いたとみなす基準:
llama-serverを起動する前の値 + 300 MiB 以内に戻った時点 - 子が残ったかの判定: 止めてから3秒後にプロセスが生きているかと、そのプロセスの
/healthの応答 - Job Object の行: 親の Python が
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE付きの Job を作り、子を起動する前 / 起動した後に自分を割り当てた。子が Job に入っているかはIsProcessInJobで確認 - 居残った子を PID 指定で止める測定: 子が残った組み合わせで、残った子を
taskkill /PID <PID> /Fで止め、その子が終了したのを確かめてから、VRAM が基準以内に戻るまで (最大30秒) 約 0.1 秒おきに読んだ (各3回)。止める操作を始めてから基準以内に戻るまでは 0.4〜0.9 秒だった - 回数: 各組み合わせ3回
- 測定日: 2026年10月1日
本記事の実測値は2026年10月1日に、上記の測定機・設定で計測したもの。ターミナルでの Ctrl+C、コンソールウィンドウを閉じる操作、タスクマネージャーでの終了、Ollama のタスクトレイからの終了、ほかの GPU やドライバでの結果は測っていない。測定機の構成の詳細は検証環境のページに載せている。
まとめ
止めたのに VRAM が空かず、keep_alive の常駐でもなさそうなときは、スクリプトで親が動いていると確認できないプロセスを候補として探し、名前とパスを確かめてから PID を指定して止める。この測定では、それで再起動せずに空いた。見つからなければ、別の原因を疑う。
今後残さないためには、親を止めるときは taskkill に /T を付け、Python から llama-server を直接起動したなら同じ Python から terminate() で止める (shell=True は避ける)。ランチャーごと強制終了されうるなら、子を起動する前に Job Object に入れておく。VRAM に余裕の少ない構成で新しいモデルが妙に遅いときや、同じポートで設定を変えて起動し直したのに応答が変わらないときも、居残ったプロセスを疑う材料になる。
よくある質問
再起動しないと VRAM は空かないのか
この測定では、再起動しなくても空いた。居残った子を taskkill /PID <PID> /F で止めると、どの組み合わせでも VRAM は止める操作を始めてから1秒以内に空いた。
nvidia-smi にプロセスごとのメモリ量が出ないのはなぜか
NVIDIA のドキュメントでは、WDDM モードの Windows ではプロセスごとの GPU メモリ使用量は取得できないとされている。PID とプロセス名は出るので、一覧に載っていること自体は確かめられる。量はパフォーマンスカウンター GPU Process Memory の Dedicated Usage で読める。この記事で nvidia-smi と値を照合したのはこちらのカウンターで、タスクマネージャーの「専用 GPU メモリ」列も目安になるが、同じ値になるかは測っていない。
Ollama でも起きるのか
ollama serve を自分で起動し、/T なしで強制終了した場合に、0.32.11 と 0.35.0 の両方で同梱の llama-server.exe が残った。/T を付ければ残らなかった。アプリとして使うときの普段の止め方 (タスクトレイからの終了) で残るかは測っていない。
参考資料
- Terminating a Process (Microsoft Learn)
- taskkill (Microsoft Learn)
- Job Objects (Microsoft Learn)
- JOBOBJECT_BASIC_LIMIT_INFORMATION (Microsoft Learn)
- subprocess (Python ドキュメント)
- nvidia-smi (NVIDIA ドキュメント)
- GPU Process Memory counters report wrong value (Microsoft Learn)
- llama.cpp server README
- Ollama Windows ドキュメント
- Win32_Process class (Microsoft Learn)
- Using SO_REUSEADDR and SO_EXCLUSIVEADDRUSE (Microsoft Learn)
- llama.cpp server-http.cpp (build 11218)
- GPUs in the Task Manager (Microsoft DirectX Developer Blog)

