Skip to content

Commit 9f694ab

Browse files
anmolxlightAnmol Mishrapre-commit-ci[bot]danielhanchenwasimysaid
authored
fix(studio): Windows GGUF cancel hang + CPU spinlock overhead (#5692) (#5749)
* fix(studio): Windows GGUF cancel hang + CPU spinlock overhead (#5692) Two fixes for Windows-native GGUF inference via llama-server: **Issue 1 — GPU/CUDA Hang on Stream Cancellation:** - Add `Connection: close` header to all httpx requests proxying to llama-server, preventing Keep-Alive from masking downstream socket closure. - Introduce `_await_disconnect_then_close` background watcher that polls `request.is_disconnected()` every 100ms and calls `resp.aclose()` immediately when the client disconnects. This runs alongside the existing cancel-POST watcher and covers client aborts that never reach the /cancel endpoint (tab close, proxy aborts, Colab, mobile navigation, etc.). - Change all StreamingResponse `Connection: keep-alive` headers to `Connection: close`. **Issue 2 — High CPU Spinlock & KV Cache Backup Overhead:** - Set OMP_WAIT_POLICY=PASSIVE and OMP_NUM_THREADS=2 in the llama-server subprocess environment on Windows to prevent OpenMP from spin-waiting on all logical cores while the GPU decodes. - Limit `--threads` to 2 on Windows when the model is fully GPU-offloaded (`-ngl -1`). Auto-detect otherwise. - Pass `--cache-ram 0 --ctx-checkpoints 0 --no-cache-prompt --checkpoint-every-n-tokens -1` on Windows to disable prompt-cache snapshots that copy KV cache to system RAM over the WDDM/PCI-E bus. Closes #5692. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: use local import to avoid ruff F823 (sys used before assignment) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * review: address gemini review feedback - Simplify _fully_gpu_offloaded init: default to False, only set True in the gpu_indices branch, drop redundant else. - Log exceptions in _await_disconnect_then_close at debug level instead of silent pass, per review suggestion. * Adjust review feedback for PR #5749 - _await_disconnect_then_close: set cancel_event before resp.aclose() so the streamer's RemoteProtocolError handler treats the watcher-driven close as cancellation, not an upstream error. Both call sites pass cancel_event through. - Windows --cache-ram / --no-cache-prompt / --ctx-checkpoints block: gate on _fully_gpu_offloaded so CPU and partial-offload Windows runs keep prompt-cache reuse across turns. - Windows OMP_WAIT_POLICY / OMP_NUM_THREADS env: same gate so CPU and partial-offload Windows runs keep default OpenMP parallelism. * Shorten code comments touched by PR #5749 * Clean up local imports and rename underscore locals in PR #5749 - Drop the function-local `import sys as _sys` introduced as an F823 workaround; remove the redundant in-function `import os`/`import sys` block so module-level imports resolve sys/os instead. F823 no longer triggers because no shadowing import remains inside load_model. - Rename `_fully_gpu_offloaded` and `_t` to `fully_gpu_offloaded` and `threads_arg`. Underscore-prefixed names usually mean private/module- level; plain locals match Python style for in-function temporaries. No behavior change. ruff clean, py_compile clean, 35 studio cancel- infra tests + 13 launch-gating AST locks + 6 disconnect-watcher locks + 4 spoof live-import tests all pass. * Fix Windows GGUF follow-ups for PR #5749 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix cache flag gating for PR #5749 * Fix Python 3.9 annotations for PR #5749 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Anmol Mishra <anmolx.work@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com> Co-authored-by: wasimysaid <wasimysdev@gmail.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
1 parent 4176448 commit 9f694ab

4 files changed

Lines changed: 303 additions & 43 deletions

File tree

studio/backend/core/inference/llama_cpp.py

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import threading
2424
import time
2525
from pathlib import Path
26-
from typing import Callable, Generator, Iterable, List, Optional
26+
from typing import Callable, Collection, Generator, Iterable, List, Optional, Union
2727

2828
import httpx
2929

@@ -567,14 +567,27 @@ def _auto_mode_drops_mtp(
567567
def _extra_args_set_spec_type(extra_args: Optional[Iterable[str]]) -> bool:
568568
"""User passed --spec-type / --spec-default? llama-server takes one
569569
--spec-type (comma-separated to chain), so suppress auto-emit."""
570+
return _extra_args_set_any_flag(extra_args, {"--spec-type", "--spec-default"})
571+
572+
573+
_GPU_OFFLOAD_OVERRIDE_FLAGS = frozenset({"-ngl", "--gpu-layers", "--n-gpu-layers", "-fit", "--fit"})
574+
_THREAD_OVERRIDE_FLAGS = frozenset({"-t", "--threads"})
575+
576+
577+
def _extra_arg_flag_name(token: str) -> Optional[str]:
578+
if not token.startswith("-") or token in {"-", "--"}:
579+
return None
580+
if len(token) >= 2 and (token[1].isdigit() or token[1] == "."):
581+
return None
582+
return token.split("=", 1)[0]
583+
584+
585+
def _extra_args_set_any_flag(extra_args: Optional[Iterable[str]], flags: Collection[str]) -> bool:
570586
if not extra_args:
571587
return False
572588
for raw in extra_args:
573-
tok = str(raw)
574-
if not tok.startswith("--"):
575-
continue
576-
flag = tok.split("=", 1)[0]
577-
if flag in ("--spec-type", "--spec-default"):
589+
flag = _extra_arg_flag_name(str(raw))
590+
if flag in flags:
578591
return True
579592
return False
580593

@@ -1217,7 +1230,7 @@ def _scan_pinned(paths: list):
12171230
def probe_server_capabilities(cls, binary: Optional[str] = None) -> dict[str, object]:
12181231
"""Parse `llama-server --help` for feature flags. Returns
12191232
{found, mtp_token, supports_mtp, ngram_mod_flavor,
1220-
supports_ngram_mod, spec_draft_n_max_flag}.
1233+
supports_ngram_mod, spec_draft_n_max_flag, cache flag support}.
12211234
12221235
``ngram_mod_flavor``: ``"new"`` when the post-rename
12231236
``--spec-ngram-mod-n-match / -n-min / -n-max`` are real args;
@@ -1242,6 +1255,9 @@ def probe_server_capabilities(cls, binary: Optional[str] = None) -> dict[str, ob
12421255
"spec_draft_n_max_flag": None,
12431256
"supports_kv_unified": False,
12441257
"supports_fit_ctx": False,
1258+
"supports_cache_ram": False,
1259+
"supports_ctx_checkpoints": False,
1260+
"supports_no_cache_prompt": False,
12451261
}
12461262
try:
12471263
mtime = int(Path(bin_path).stat().st_mtime)
@@ -1257,6 +1273,9 @@ def probe_server_capabilities(cls, binary: Optional[str] = None) -> dict[str, ob
12571273
spec_draft_n_max_flag: Optional[str] = None
12581274
supports_kv_unified = False
12591275
supports_fit_ctx = False
1276+
supports_cache_ram = False
1277+
supports_ctx_checkpoints = False
1278+
supports_no_cache_prompt = False
12601279
try:
12611280
result = subprocess.run(
12621281
[bin_path, "--help"],
@@ -1347,6 +1366,9 @@ def _is_real(flag: str) -> bool:
13471366

13481367
supports_kv_unified = _is_real("--kv-unified")
13491368
supports_fit_ctx = _is_real("--fit-ctx")
1369+
supports_cache_ram = _is_real("--cache-ram")
1370+
supports_ctx_checkpoints = _is_real("--ctx-checkpoints")
1371+
supports_no_cache_prompt = _is_real("--no-cache-prompt")
13501372
except (OSError, subprocess.SubprocessError) as exc:
13511373
logger.debug(f"llama-server --help probe failed: {exc}")
13521374

@@ -1359,6 +1381,9 @@ def _is_real(flag: str) -> bool:
13591381
"spec_draft_n_max_flag": spec_draft_n_max_flag,
13601382
"supports_kv_unified": supports_kv_unified,
13611383
"supports_fit_ctx": supports_fit_ctx,
1384+
"supports_cache_ram": supports_cache_ram,
1385+
"supports_ctx_checkpoints": supports_ctx_checkpoints,
1386+
"supports_no_cache_prompt": supports_no_cache_prompt,
13621387
}
13631388
cls._capability_cache[cache_key] = info
13641389
return info
@@ -3924,27 +3949,43 @@ def load_model(
39243949
"--no-context-shift",
39253950
]
39263951

3952+
fully_gpu_offloaded = False
39273953
if use_fit:
39283954
cmd.extend(["--fit", "on"])
39293955
elif gpu_indices is not None:
39303956
# Fits on selected GPU(s) -- offload all layers
39313957
cmd.extend(["-ngl", "-1"])
3958+
fully_gpu_offloaded = True
39323959

3960+
server_caps = self.probe_server_capabilities(binary)
39333961
cmd.extend(
39343962
self._ctx_integrity_flags(
39353963
n_parallel,
39363964
use_fit,
39373965
requested_ctx,
39383966
effective_ctx,
3939-
self.probe_server_capabilities(binary),
3967+
server_caps,
39403968
)
39413969
)
3970+
offload_overridden = _extra_args_set_any_flag(
3971+
extra_args, _GPU_OFFLOAD_OVERRIDE_FLAGS
3972+
)
3973+
threads_overridden = _extra_args_set_any_flag(extra_args, _THREAD_OVERRIDE_FLAGS)
3974+
full_offload_tuning_active = fully_gpu_offloaded and not offload_overridden
39423975

3943-
# -1 = llama.cpp auto-detect (physical cores). Pass explicitly
3944-
# so we don't inherit llama-server's internal default, which
3945-
# has varied (hardware concurrency incl. hyperthreads on some
3946-
# builds).
3947-
cmd.extend(["--threads", str(n_threads if n_threads is not None else -1)])
3976+
# Pass --threads explicitly so we do not inherit llama-server
3977+
# defaults. Windows + full offload caps at 2 to stop OpenMP
3978+
# spin-wait burning CPU during GPU decode. User pass-through
3979+
# offload/thread flags keep last-wins semantics. #5692.
3980+
if (
3981+
sys.platform == "win32"
3982+
and full_offload_tuning_active
3983+
and not threads_overridden
3984+
):
3985+
threads_arg = 2
3986+
else:
3987+
threads_arg = n_threads if n_threads is not None else -1
3988+
cmd.extend(["--threads", str(threads_arg)])
39483989

39493990
# Enable Jinja chat template rendering
39503991
cmd.extend(["--jinja"])
@@ -4086,6 +4127,28 @@ def load_model(
40864127
else:
40874128
self._api_key = None
40884129

4130+
# Windows + full offload: disable KV checkpoints (WDDM/PCI-E
4131+
# overhead). CPU/partial offload keeps prompt caching. #5692.
4132+
if sys.platform == "win32" and full_offload_tuning_active:
4133+
unsupported_cache_flags: list[str] = []
4134+
if server_caps.get("supports_cache_ram"):
4135+
cmd.extend(["--cache-ram", "0"])
4136+
else:
4137+
unsupported_cache_flags.append("--cache-ram")
4138+
if server_caps.get("supports_ctx_checkpoints"):
4139+
cmd.extend(["--ctx-checkpoints", "0"])
4140+
else:
4141+
unsupported_cache_flags.append("--ctx-checkpoints")
4142+
if server_caps.get("supports_no_cache_prompt"):
4143+
cmd.append("--no-cache-prompt")
4144+
else:
4145+
unsupported_cache_flags.append("--no-cache-prompt")
4146+
if unsupported_cache_flags:
4147+
logger.info(
4148+
"Skipping unsupported Windows cache flags for llama-server: %s",
4149+
", ".join(unsupported_cache_flags),
4150+
)
4151+
40894152
# User pass-through args go last so llama.cpp's last-wins parsing
40904153
# lets the user override Studio's auto-set flags. Already
40914154
# validated by the route via validate_extra_args().
@@ -4101,9 +4164,6 @@ def load_model(
41014164
logger.info(f"Starting llama-server: {' '.join(_log_cmd)}")
41024165

41034166
# Library paths so llama-server finds its shared libs and CUDA DLLs.
4104-
import os
4105-
import sys
4106-
41074167
env = child_env_without_native_path_secret()
41084168
binary_dir = str(Path(binary).parent)
41094169

@@ -4131,6 +4191,14 @@ def load_model(
41314191
existing_path = env.get("PATH", "")
41324192
env["PATH"] = ";".join(path_dirs) + ";" + existing_path
41334193

4194+
# Windows + full offload: PASSIVE OMP + 2 threads stop
4195+
# spin-wait burning CPU. CPU/partial offload keeps
4196+
# default OMP parallelism. #5692.
4197+
if full_offload_tuning_active:
4198+
env.setdefault("OMP_WAIT_POLICY", "PASSIVE")
4199+
if not threads_overridden:
4200+
env.setdefault("OMP_NUM_THREADS", "2")
4201+
41344202
# ROCm: the prebuilt bundles rocblas.dll but NOT the Tensile
41354203
# kernel files (rocblas/library/*.dat + *.hsaco); the DLL
41364204
# searches <binary_dir>/rocblas/library/ which doesn't exist
@@ -5512,7 +5580,7 @@ def generate_chat_completion(
55125580
reasoning_effort: Optional[str] = None,
55135581
preserve_thinking: Optional[bool] = None,
55145582
seed: Optional[int] = None,
5515-
) -> Generator[str | dict, None, None]:
5583+
) -> Generator[Union[str, dict], None, None]:
55165584
"""
55175585
Send a chat completion to llama-server and stream tokens back.
55185586

0 commit comments

Comments
 (0)