polyserve 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- polyserve/__init__.py +6 -0
- polyserve/backends/__init__.py +36 -0
- polyserve/backends/base.py +314 -0
- polyserve/backends/llamacpp.py +298 -0
- polyserve/backends/sglang.py +151 -0
- polyserve/backends/vllm.py +285 -0
- polyserve/backends/vllm_cpu.py +84 -0
- polyserve/bench/__init__.py +19 -0
- polyserve/bench/ablation.py +326 -0
- polyserve/bench/baselines.py +82 -0
- polyserve/bench/compare.py +358 -0
- polyserve/bench/references.py +159 -0
- polyserve/bench/report.py +305 -0
- polyserve/cache.py +108 -0
- polyserve/calibrate/__init__.py +7 -0
- polyserve/calibrate/datasets.py +129 -0
- polyserve/calibrate/measure.py +593 -0
- polyserve/calibrate/objectives.py +287 -0
- polyserve/calibrate/search.py +914 -0
- polyserve/calibrate/tail.py +30 -0
- polyserve/calibrate/tokens.py +82 -0
- polyserve/calibrate/workload.py +305 -0
- polyserve/cli.py +873 -0
- polyserve/disagg.py +714 -0
- polyserve/gguf.py +239 -0
- polyserve/hardware.py +483 -0
- polyserve/hfconfig.py +148 -0
- polyserve/layout.py +387 -0
- polyserve/memcal.py +322 -0
- polyserve/memlog.py +213 -0
- polyserve/memory.py +110 -0
- polyserve/models.py +336 -0
- polyserve/pipeline.py +548 -0
- polyserve/power.py +366 -0
- polyserve/predict.py +483 -0
- polyserve/quantized.py +169 -0
- polyserve/router.py +41 -0
- polyserve/selector.py +75 -0
- polyserve/serve/__init__.py +4 -0
- polyserve/serve/proxy.py +115 -0
- polyserve/serve/supervisor.py +155 -0
- polyserve/speculative.py +127 -0
- polyserve-0.1.0.dist-info/METADATA +87 -0
- polyserve-0.1.0.dist-info/RECORD +48 -0
- polyserve-0.1.0.dist-info/WHEEL +5 -0
- polyserve-0.1.0.dist-info/entry_points.txt +2 -0
- polyserve-0.1.0.dist-info/licenses/LICENSE +21 -0
- polyserve-0.1.0.dist-info/top_level.txt +1 -0
polyserve/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Backend registry. New hardware = new class here, no core changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict
|
|
6
|
+
|
|
7
|
+
from polyserve.backends.base import Backend, BaseBackend, LaunchSpec, LlmtraceHooks, Process, free_port
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def registry() -> Dict[str, BaseBackend]:
|
|
11
|
+
from polyserve.backends.llamacpp import LlamaCppCpuBackend, LlamaCppCudaBackend
|
|
12
|
+
from polyserve.backends.sglang import SglangBackend
|
|
13
|
+
from polyserve.backends.vllm import VllmBackend
|
|
14
|
+
from polyserve.backends.vllm_cpu import VllmCpuBackend
|
|
15
|
+
|
|
16
|
+
backends = [VllmBackend(), SglangBackend(), LlamaCppCudaBackend(), LlamaCppCpuBackend(), VllmCpuBackend()]
|
|
17
|
+
return {b.name: b for b in backends}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_backend(name: str) -> BaseBackend:
|
|
21
|
+
reg = registry()
|
|
22
|
+
if name not in reg:
|
|
23
|
+
raise KeyError(f"unknown backend {name!r}; known: {sorted(reg)}")
|
|
24
|
+
return reg[name]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Backend",
|
|
29
|
+
"BaseBackend",
|
|
30
|
+
"LaunchSpec",
|
|
31
|
+
"LlmtraceHooks",
|
|
32
|
+
"Process",
|
|
33
|
+
"free_port",
|
|
34
|
+
"registry",
|
|
35
|
+
"get_backend",
|
|
36
|
+
]
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Backend interface (spec section 3) plus the subprocess wrapper every backend uses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import socket
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, List, Optional, Protocol, Tuple, runtime_checkable
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from polyserve.memory import MemoryModel
|
|
19
|
+
from polyserve.models import Config, HardwareDescriptor, ModelSpec, PreparedModel
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class LlmtraceHooks:
|
|
26
|
+
"""How the calibration driver should talk to (and measure) this backend."""
|
|
27
|
+
|
|
28
|
+
completions_path: str = "/v1/completions"
|
|
29
|
+
health_path: str = "/health"
|
|
30
|
+
models_path: str = "/v1/models"
|
|
31
|
+
stream_usage: bool = True # backend reports usage in the final stream chunk when asked
|
|
32
|
+
model_name: Optional[str] = None # value to put in the "model" field of requests
|
|
33
|
+
tokenizer_id: Optional[str] = None # HF id whose tokenizer counts tokens when usage is absent
|
|
34
|
+
gpu_ids: List[int] = field(default_factory=list) # NVML indices llmtrace should sample
|
|
35
|
+
process_memory: bool = False # sample RSS of the backend process (CPU backends)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class LaunchSpec:
|
|
40
|
+
args: List[str]
|
|
41
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
42
|
+
cwd: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Process:
|
|
46
|
+
"""A launched backend server: subprocess + readiness probe + logs."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, spec: LaunchSpec, port: int, health_url: str, log_path: Optional[Path] = None):
|
|
49
|
+
self.spec = spec
|
|
50
|
+
self.port = port
|
|
51
|
+
self.health_url = health_url
|
|
52
|
+
self.log_path = log_path
|
|
53
|
+
self._proc: Optional[subprocess.Popen] = None
|
|
54
|
+
self._log_fh = None
|
|
55
|
+
self.started_at: Optional[float] = None
|
|
56
|
+
self.ready_at: Optional[float] = None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def pid(self) -> Optional[int]:
|
|
60
|
+
return self._proc.pid if self._proc else None
|
|
61
|
+
|
|
62
|
+
def start(self) -> "Process":
|
|
63
|
+
env = dict(os.environ)
|
|
64
|
+
env.update(self.spec.env)
|
|
65
|
+
if self.log_path:
|
|
66
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
self._log_fh = open(self.log_path, "ab")
|
|
68
|
+
stdout = self._log_fh
|
|
69
|
+
else:
|
|
70
|
+
stdout = subprocess.DEVNULL
|
|
71
|
+
logger.info("launch: %s", " ".join(self.spec.args))
|
|
72
|
+
kwargs = {}
|
|
73
|
+
if sys.platform != "win32":
|
|
74
|
+
kwargs["start_new_session"] = True # own process group so we can kill children
|
|
75
|
+
self._proc = subprocess.Popen(
|
|
76
|
+
self.spec.args,
|
|
77
|
+
env=env,
|
|
78
|
+
cwd=self.spec.cwd,
|
|
79
|
+
stdout=stdout,
|
|
80
|
+
stderr=subprocess.STDOUT,
|
|
81
|
+
stdin=subprocess.DEVNULL,
|
|
82
|
+
**kwargs,
|
|
83
|
+
)
|
|
84
|
+
self.started_at = time.monotonic()
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def alive(self) -> bool:
|
|
88
|
+
return self._proc is not None and self._proc.poll() is None
|
|
89
|
+
|
|
90
|
+
def returncode(self) -> Optional[int]:
|
|
91
|
+
return self._proc.poll() if self._proc else None
|
|
92
|
+
|
|
93
|
+
def wait_ready(self, timeout: float = 600.0, poll: float = 1.0) -> bool:
|
|
94
|
+
deadline = time.monotonic() + timeout
|
|
95
|
+
with httpx.Client(timeout=5.0) as client:
|
|
96
|
+
while time.monotonic() < deadline:
|
|
97
|
+
if not self.alive():
|
|
98
|
+
logger.error("backend exited during startup (rc=%s); see %s", self.returncode(), self.log_path)
|
|
99
|
+
return False
|
|
100
|
+
try:
|
|
101
|
+
r = client.get(self.health_url)
|
|
102
|
+
if r.status_code < 500:
|
|
103
|
+
self.ready_at = time.monotonic()
|
|
104
|
+
return True
|
|
105
|
+
except httpx.HTTPError:
|
|
106
|
+
pass
|
|
107
|
+
time.sleep(poll)
|
|
108
|
+
logger.error("backend not ready after %.0fs; see %s", timeout, self.log_path)
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
def stop(self, grace: float = 15.0) -> None:
|
|
112
|
+
if self._proc is None:
|
|
113
|
+
return
|
|
114
|
+
if self.alive():
|
|
115
|
+
try:
|
|
116
|
+
if sys.platform != "win32":
|
|
117
|
+
os.killpg(self._proc.pid, signal.SIGTERM) # own session: group id == server pid
|
|
118
|
+
else:
|
|
119
|
+
self._proc.terminate()
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
try:
|
|
123
|
+
self._proc.wait(timeout=grace)
|
|
124
|
+
except subprocess.TimeoutExpired:
|
|
125
|
+
try:
|
|
126
|
+
if sys.platform != "win32":
|
|
127
|
+
os.killpg(self._proc.pid, signal.SIGKILL)
|
|
128
|
+
else:
|
|
129
|
+
self._proc.kill()
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
self._proc.wait(timeout=10)
|
|
133
|
+
if sys.platform != "win32":
|
|
134
|
+
# The server can be gone while its children are not: a vLLM API server that died leaves
|
|
135
|
+
# VLLM::EngineCore holding the GPU, and every later trial then sees less memory. The
|
|
136
|
+
# children share the server's process group, so reap whatever is left of it.
|
|
137
|
+
try:
|
|
138
|
+
os.killpg(self._proc.pid, signal.SIGKILL)
|
|
139
|
+
except OSError:
|
|
140
|
+
pass
|
|
141
|
+
if self._log_fh:
|
|
142
|
+
self._log_fh.close()
|
|
143
|
+
self._log_fh = None
|
|
144
|
+
|
|
145
|
+
def tail_log(self, n: int = 40) -> str:
|
|
146
|
+
if not self.log_path or not self.log_path.exists():
|
|
147
|
+
return ""
|
|
148
|
+
try:
|
|
149
|
+
lines = self.log_path.read_text(errors="replace").splitlines()
|
|
150
|
+
return "\n".join(lines[-n:])
|
|
151
|
+
except OSError:
|
|
152
|
+
return ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
CTX_GRID = (2048, 4096, 8192, 16384, 32768, 65536, 131072)
|
|
156
|
+
CTX_GRID_WIDTH = 3 # sizes tried per calibration: the smallest that fits the workload and the next two
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def ctx_grid(max_pos: int, min_ctx: int = 0) -> List[int]:
|
|
160
|
+
"""Context lengths to try: the three smallest standard sizes that fit the model and hold the workload.
|
|
161
|
+
|
|
162
|
+
Returns [] if the model cannot hold `min_ctx` at all.
|
|
163
|
+
"""
|
|
164
|
+
if min_ctx > max_pos:
|
|
165
|
+
return []
|
|
166
|
+
grid = [c for c in CTX_GRID if min_ctx <= c <= max_pos]
|
|
167
|
+
if not grid:
|
|
168
|
+
# Workload needs more than the largest standard size that fits: use the model max.
|
|
169
|
+
grid = [max_pos]
|
|
170
|
+
return grid[:CTX_GRID_WIDTH]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def render_extra(extra: Dict[str, object], skip: Tuple[str, ...] = ()) -> List[str]:
|
|
174
|
+
"""Extra launch flags. `True` renders as a bare flag; `False` and None are dropped."""
|
|
175
|
+
out: List[str] = []
|
|
176
|
+
for k, v in extra.items():
|
|
177
|
+
if k in skip or v is None or v is False:
|
|
178
|
+
continue
|
|
179
|
+
flag = f"--{k.replace('_', '-')}"
|
|
180
|
+
out += [flag] if v is True else [flag, str(v)]
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def free_port(preferred: Optional[int] = None) -> int:
|
|
185
|
+
if preferred:
|
|
186
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
187
|
+
try:
|
|
188
|
+
s.bind(("127.0.0.1", preferred))
|
|
189
|
+
return preferred
|
|
190
|
+
except OSError:
|
|
191
|
+
pass
|
|
192
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
193
|
+
s.bind(("127.0.0.1", 0))
|
|
194
|
+
return s.getsockname()[1]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@runtime_checkable
|
|
198
|
+
class Backend(Protocol):
|
|
199
|
+
name: str
|
|
200
|
+
|
|
201
|
+
def available(self, hw: HardwareDescriptor) -> bool: ...
|
|
202
|
+
|
|
203
|
+
def supports(self, hw: HardwareDescriptor, model: ModelSpec) -> bool: ...
|
|
204
|
+
|
|
205
|
+
def prepare(self, model: ModelSpec, hw: HardwareDescriptor, quants: Optional[List[str]] = None) -> PreparedModel: ...
|
|
206
|
+
|
|
207
|
+
def memory_model(self, hw: HardwareDescriptor) -> MemoryModel: ...
|
|
208
|
+
|
|
209
|
+
def estimate_memory(self, cfg: Config, model: PreparedModel, hw: HardwareDescriptor) -> int: ...
|
|
210
|
+
|
|
211
|
+
def candidate_configs(self, hw: HardwareDescriptor, model: PreparedModel, min_ctx: int = 0) -> List[Config]: ...
|
|
212
|
+
|
|
213
|
+
def launch_spec(self, cfg: Config, model: PreparedModel, port: int) -> LaunchSpec: ...
|
|
214
|
+
|
|
215
|
+
def launch(self, cfg: Config, model: PreparedModel, port: int, log_path: Optional[Path] = None) -> Process: ...
|
|
216
|
+
|
|
217
|
+
def workload_hooks(self, hw: HardwareDescriptor, model: PreparedModel) -> LlmtraceHooks: ...
|
|
218
|
+
|
|
219
|
+
def default_config(self, hw: HardwareDescriptor, model: PreparedModel, min_ctx: int = 0) -> Config: ...
|
|
220
|
+
|
|
221
|
+
def version(self, hw: HardwareDescriptor) -> Optional[str]: ...
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class BaseBackend:
|
|
225
|
+
"""Shared plumbing. Concrete backends override the abstract-ish methods."""
|
|
226
|
+
|
|
227
|
+
name: str = "base"
|
|
228
|
+
runtime_workspace_bytes: int = 1024 * 1024 * 1024
|
|
229
|
+
|
|
230
|
+
def available(self, hw: HardwareDescriptor) -> bool:
|
|
231
|
+
return hw.backend_available(self.name)
|
|
232
|
+
|
|
233
|
+
def version(self, hw: HardwareDescriptor) -> Optional[str]:
|
|
234
|
+
return hw.backend_version(self.name)
|
|
235
|
+
|
|
236
|
+
def memory_model(self, hw: HardwareDescriptor) -> MemoryModel:
|
|
237
|
+
raise NotImplementedError
|
|
238
|
+
|
|
239
|
+
def calibrated_memory(self, hw: HardwareDescriptor, kv_tokens_fn, device: str) -> MemoryModel:
|
|
240
|
+
"""MemoryModel using this machine's fitted workspace/margin when `polyserve memory-report --apply` ran."""
|
|
241
|
+
from polyserve.hardware import hardware_hash
|
|
242
|
+
from polyserve.memcal import margin_override, workspace_override
|
|
243
|
+
from polyserve.memory import DEFAULT_MARGIN_FRACTION
|
|
244
|
+
|
|
245
|
+
hh = hardware_hash(hw)
|
|
246
|
+
ws = workspace_override(hh, self.name)
|
|
247
|
+
mf = margin_override(hh, self.name)
|
|
248
|
+
return MemoryModel(
|
|
249
|
+
runtime_workspace=ws if ws is not None else self.runtime_workspace_bytes,
|
|
250
|
+
kv_tokens_fn=kv_tokens_fn,
|
|
251
|
+
device=device,
|
|
252
|
+
margin_fraction=mf if mf is not None else DEFAULT_MARGIN_FRACTION,
|
|
253
|
+
calibrated=ws is not None or mf is not None,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
def estimate_memory(self, cfg: Config, model: PreparedModel, hw: HardwareDescriptor) -> int:
|
|
257
|
+
from polyserve.memory import estimate
|
|
258
|
+
|
|
259
|
+
return estimate(hw, model, cfg, self.memory_model(hw)).total
|
|
260
|
+
|
|
261
|
+
def materialize(self, model: PreparedModel, quants: List[str]) -> PreparedModel:
|
|
262
|
+
"""Download / convert weights for the quants the planner kept. Default: nothing to do."""
|
|
263
|
+
return model
|
|
264
|
+
|
|
265
|
+
def prefill_variants(self, cfg: Config) -> List[Config]:
|
|
266
|
+
"""Configs differing from `cfg` only in the prefill knob. Default: this backend has none."""
|
|
267
|
+
return []
|
|
268
|
+
|
|
269
|
+
def disagg_launch_spec(self, cfg: Config, model: PreparedModel, port: int, role: str,
|
|
270
|
+
kv_transfer_config: dict, gpu_index: int, side_channel_port: int) -> LaunchSpec:
|
|
271
|
+
"""Launch one engine of a disaggregated prefill/decode pair."""
|
|
272
|
+
raise NotImplementedError(f"{self.name} does not support disaggregated prefill/decode")
|
|
273
|
+
|
|
274
|
+
# ---- optional search dimensions (default: this backend offers none)
|
|
275
|
+
|
|
276
|
+
supports_tp: bool = False
|
|
277
|
+
|
|
278
|
+
def supported_quants(self, hw: HardwareDescriptor) -> List[str]:
|
|
279
|
+
"""Every weight precision this backend could run on `hw` (what --quant filters)."""
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
def kv_dtypes(self, hw: HardwareDescriptor) -> List[str]:
|
|
283
|
+
"""Quantized KV-cache types worth trying on `hw`."""
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
def batch_ladder(self) -> Tuple[int, ...]:
|
|
287
|
+
"""Batch sizes, smallest first, that a smaller KV cache may let the search step up to."""
|
|
288
|
+
return ()
|
|
289
|
+
|
|
290
|
+
def prefix_variants(self, cfg: Config) -> List[Config]:
|
|
291
|
+
"""Prefix-cache settings to try when the workload's prompts share a prefix."""
|
|
292
|
+
return []
|
|
293
|
+
|
|
294
|
+
def spec_variants(self, cfg: Config, model: PreparedModel) -> List[Config]:
|
|
295
|
+
"""Speculative-decoding settings to try."""
|
|
296
|
+
return []
|
|
297
|
+
|
|
298
|
+
def replica_launch_spec(self, cfg: Config, model: PreparedModel, port: int, gpu_index: int) -> LaunchSpec:
|
|
299
|
+
"""One full engine pinned to one GPU, for the replicas layout."""
|
|
300
|
+
spec = self.launch_spec(cfg, model, port)
|
|
301
|
+
spec.env = {**spec.env, "CUDA_VISIBLE_DEVICES": str(gpu_index)}
|
|
302
|
+
return spec
|
|
303
|
+
|
|
304
|
+
def launch_spec(self, cfg: Config, model: PreparedModel, port: int) -> LaunchSpec:
|
|
305
|
+
raise NotImplementedError
|
|
306
|
+
|
|
307
|
+
health_path: str = "/health"
|
|
308
|
+
|
|
309
|
+
def workload_hooks(self, hw: HardwareDescriptor, model: PreparedModel) -> LlmtraceHooks:
|
|
310
|
+
return LlmtraceHooks(gpu_ids=[hw.gpu.index] if hw.gpu else [])
|
|
311
|
+
|
|
312
|
+
def launch(self, cfg: Config, model: PreparedModel, port: int, log_path: Optional[Path] = None) -> Process:
|
|
313
|
+
spec = self.launch_spec(cfg, model, port)
|
|
314
|
+
return Process(spec, port, f"http://127.0.0.1:{port}{self.health_path}", log_path=log_path).start()
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""llama.cpp backend: `llama-server`, CUDA (any NVIDIA GPU, incl. Pascal/Volta) and CPU."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from polyserve import speculative
|
|
13
|
+
from polyserve.backends.base import BaseBackend, LaunchSpec, LlmtraceHooks, ctx_grid, render_extra
|
|
14
|
+
from polyserve.gguf import (
|
|
15
|
+
GGUF_QUANTS,
|
|
16
|
+
GGUFCandidate,
|
|
17
|
+
convert_and_quantize,
|
|
18
|
+
download_gguf,
|
|
19
|
+
estimate_gguf_bytes,
|
|
20
|
+
search_hub_gguf,
|
|
21
|
+
)
|
|
22
|
+
from polyserve.hardware import llama_server_binary
|
|
23
|
+
from polyserve.hfconfig import load_arch
|
|
24
|
+
from polyserve.memory import MemoryModel
|
|
25
|
+
from polyserve.models import Config, HardwareDescriptor, MiB, ModelSpec, PreparedModel
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@functools.lru_cache(maxsize=8)
|
|
29
|
+
def server_help(binary: str) -> str:
|
|
30
|
+
"""`llama-server --help`, to follow flag renames across llama.cpp versions. Empty if it cannot run."""
|
|
31
|
+
try:
|
|
32
|
+
out = subprocess.run([binary, "--help"], capture_output=True, text=True, timeout=60)
|
|
33
|
+
return out.stdout + out.stderr
|
|
34
|
+
except Exception:
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _modern_spec(binary: str) -> bool:
|
|
39
|
+
return "--spec-type" in server_help(binary)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LlamaCppBackend(BaseBackend):
|
|
45
|
+
"""Shared implementation; the CUDA and CPU variants differ in offload and workspace."""
|
|
46
|
+
|
|
47
|
+
name = "llamacpp"
|
|
48
|
+
cuda = False
|
|
49
|
+
runtime_workspace_bytes = 768 * MiB # compute buffers (scale with n_batch) + CUDA context
|
|
50
|
+
|
|
51
|
+
def supports(self, hw: HardwareDescriptor, model: ModelSpec) -> bool:
|
|
52
|
+
if self.cuda and (hw.gpu is None or hw.gpu.vendor != "nvidia"):
|
|
53
|
+
return False
|
|
54
|
+
return llama_server_binary() is not None
|
|
55
|
+
|
|
56
|
+
# ---- prepare: resolve (no download); materialize: download/convert only what the planner kept
|
|
57
|
+
|
|
58
|
+
def prepare(self, model: ModelSpec, hw: HardwareDescriptor, quants: Optional[List[str]] = None) -> PreparedModel:
|
|
59
|
+
arch = load_arch(model)
|
|
60
|
+
quants = list(quants or GGUF_QUANTS)
|
|
61
|
+
prepared = PreparedModel(spec=model, backend=self.name, arch=arch)
|
|
62
|
+
if os.path.isfile(model.hf_id) and model.hf_id.lower().endswith(".gguf"):
|
|
63
|
+
# Local GGUF file given directly.
|
|
64
|
+
q = _quant_from_filename(model.hf_id) or "Q4_K_M"
|
|
65
|
+
prepared.gguf_paths[q] = model.hf_id
|
|
66
|
+
prepared.weights_bytes[q] = os.path.getsize(model.hf_id)
|
|
67
|
+
return prepared
|
|
68
|
+
try:
|
|
69
|
+
found: Dict[str, GGUFCandidate] = search_hub_gguf(model, quants)
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
logger.warning("GGUF hub search failed (%s); will fall back to conversion", exc)
|
|
72
|
+
found = {}
|
|
73
|
+
params = arch.num_params or 0
|
|
74
|
+
for q in quants:
|
|
75
|
+
cand = found.get(q)
|
|
76
|
+
if cand is not None:
|
|
77
|
+
prepared.weights_bytes[q] = cand.size_bytes or estimate_gguf_bytes(params, q)
|
|
78
|
+
prepared.gguf_paths[q] = f"hf://{cand.repo_id}/{cand.filename}"
|
|
79
|
+
else:
|
|
80
|
+
prepared.weights_bytes[q] = estimate_gguf_bytes(params, q)
|
|
81
|
+
prepared.gguf_paths[q] = f"convert://{q}"
|
|
82
|
+
# A small same-family draft model for speculative decoding, when the Hub has a GGUF of it.
|
|
83
|
+
draft = speculative.draft_for(model.hf_id)
|
|
84
|
+
if draft:
|
|
85
|
+
try:
|
|
86
|
+
d = search_hub_gguf(ModelSpec(hf_id=draft), ["Q8_0"]).get("Q8_0")
|
|
87
|
+
if d is not None:
|
|
88
|
+
prepared.draft_paths[draft] = f"hf://{d.repo_id}/{d.filename}"
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
logger.debug("no draft GGUF for %s: %s", draft, exc)
|
|
91
|
+
return prepared
|
|
92
|
+
|
|
93
|
+
def materialize(self, model: PreparedModel, quants: List[str]) -> PreparedModel:
|
|
94
|
+
need_convert: List[str] = []
|
|
95
|
+
for q in quants:
|
|
96
|
+
ref = model.gguf_paths.get(q)
|
|
97
|
+
if ref is None:
|
|
98
|
+
continue
|
|
99
|
+
if ref.startswith("hf://"):
|
|
100
|
+
repo, _, fname = ref[len("hf://"):].rpartition("/")
|
|
101
|
+
path = download_gguf(GGUFCandidate(repo_id=repo, filename=fname, quant=q))
|
|
102
|
+
model.gguf_paths[q] = str(path)
|
|
103
|
+
model.weights_bytes[q] = path.stat().st_size
|
|
104
|
+
elif ref.startswith("convert://"):
|
|
105
|
+
need_convert.append(q)
|
|
106
|
+
if need_convert:
|
|
107
|
+
produced = convert_and_quantize(model.spec, need_convert)
|
|
108
|
+
for q, path in produced.items():
|
|
109
|
+
model.gguf_paths[q] = str(path)
|
|
110
|
+
model.weights_bytes[q] = Path(path).stat().st_size
|
|
111
|
+
for q in need_convert:
|
|
112
|
+
if q not in produced:
|
|
113
|
+
model.gguf_paths.pop(q, None)
|
|
114
|
+
model.weights_bytes.pop(q, None)
|
|
115
|
+
for draft, ref in list(model.draft_paths.items()):
|
|
116
|
+
if ref.startswith("hf://"):
|
|
117
|
+
repo, _, fname = ref[len("hf://"):].rpartition("/")
|
|
118
|
+
try:
|
|
119
|
+
model.draft_paths[draft] = str(download_gguf(GGUFCandidate(repo_id=repo, filename=fname,
|
|
120
|
+
quant="Q8_0")))
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
logger.warning("draft model %s unavailable: %s", draft, exc)
|
|
123
|
+
model.draft_paths.pop(draft, None)
|
|
124
|
+
return model
|
|
125
|
+
|
|
126
|
+
# ---- memory: llama-server allocates the full KV for -c up front (per-slot ctx x n_parallel)
|
|
127
|
+
|
|
128
|
+
def memory_model(self, hw: HardwareDescriptor) -> MemoryModel:
|
|
129
|
+
return self.calibrated_memory(hw, lambda cfg: cfg.ctx * cfg.batch, "gpu" if self.cuda else "cpu")
|
|
130
|
+
|
|
131
|
+
# ---- configs
|
|
132
|
+
|
|
133
|
+
def supported_quants(self, hw: HardwareDescriptor) -> List[str]:
|
|
134
|
+
return list(GGUF_QUANTS)
|
|
135
|
+
|
|
136
|
+
def kv_dtypes(self, hw: HardwareDescriptor) -> List[str]:
|
|
137
|
+
return ["q8_0", "q4_0"]
|
|
138
|
+
|
|
139
|
+
def batch_ladder(self) -> Tuple[int, ...]:
|
|
140
|
+
return (1, 4, 8, 16)
|
|
141
|
+
|
|
142
|
+
def prefix_variants(self, cfg: Config) -> List[Config]:
|
|
143
|
+
# Reuse cached prompt chunks by KV shifting, and share one KV buffer across slots so a common
|
|
144
|
+
# prefix computed by one slot serves every slot.
|
|
145
|
+
options = [{"cache_reuse": 256}, {"kv_unified": True}]
|
|
146
|
+
return [cfg.model_copy(update={"extra": {**cfg.extra, **o}}) for o in options
|
|
147
|
+
if any(cfg.extra.get(k) != v for k, v in o.items())]
|
|
148
|
+
|
|
149
|
+
def spec_variants(self, cfg: Config, model: PreparedModel) -> List[Config]:
|
|
150
|
+
specs: List[str] = []
|
|
151
|
+
binary = llama_server_binary()
|
|
152
|
+
if binary and _modern_spec(binary): # built-in n-gram lookup: no second model needed
|
|
153
|
+
specs.append(speculative.ngram(speculative.NGRAM_TOKENS_LLAMACPP))
|
|
154
|
+
draft = speculative.draft_for(model.spec.hf_id)
|
|
155
|
+
path = model.draft_paths.get(draft or "")
|
|
156
|
+
if draft and path and not path.startswith(("hf://", "convert://")):
|
|
157
|
+
specs.append(speculative.draft(draft, speculative.DRAFT_TOKENS_LLAMACPP))
|
|
158
|
+
return [cfg.model_copy(update={"spec_decode": s}) for s in specs if s != cfg.spec_decode]
|
|
159
|
+
|
|
160
|
+
def _quants(self, model: PreparedModel) -> List[str]:
|
|
161
|
+
return [q for q in GGUF_QUANTS if q in model.weights_bytes] or list(model.weights_bytes)
|
|
162
|
+
|
|
163
|
+
def candidate_configs(self, hw: HardwareDescriptor, model: PreparedModel, min_ctx: int = 0) -> List[Config]:
|
|
164
|
+
layers = model.arch.num_layers
|
|
165
|
+
ctxs = ctx_grid(model.arch.max_position_embeddings, min_ctx)
|
|
166
|
+
if self.cuda:
|
|
167
|
+
ngls = sorted({layers + 1, (3 * layers) // 4, layers // 2}, reverse=True) # +1 = output layer too
|
|
168
|
+
n_batches = [512]
|
|
169
|
+
else:
|
|
170
|
+
ngls = [0]
|
|
171
|
+
n_batches = [512, 2048]
|
|
172
|
+
out: List[Config] = []
|
|
173
|
+
for quant in self._quants(model):
|
|
174
|
+
for ngl in ngls:
|
|
175
|
+
for ctx in ctxs:
|
|
176
|
+
for np_ in (1, 4, 8):
|
|
177
|
+
for nb in n_batches:
|
|
178
|
+
out.append(
|
|
179
|
+
Config(
|
|
180
|
+
backend=self.name, quant=quant, ctx=ctx, batch=np_, n_gpu_layers=ngl, n_batch=nb
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
def default_config(self, hw: HardwareDescriptor, model: PreparedModel, min_ctx: int = 0) -> Config:
|
|
186
|
+
quants = self._quants(model)
|
|
187
|
+
return Config(
|
|
188
|
+
backend=self.name,
|
|
189
|
+
quant="Q4_K_M" if "Q4_K_M" in quants else quants[0],
|
|
190
|
+
ctx=min(max(4096, min_ctx), model.arch.max_position_embeddings),
|
|
191
|
+
batch=4,
|
|
192
|
+
n_gpu_layers=(model.arch.num_layers + 1) if self.cuda else 0,
|
|
193
|
+
n_batch=512,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# ---- launch
|
|
197
|
+
|
|
198
|
+
def _spec_args(self, spec: str, model: PreparedModel, binary: str) -> List[str]:
|
|
199
|
+
"""Speculative-decoding flags. llama.cpp (2026) moved to `--spec-type` and renamed --draft-max;
|
|
200
|
+
older builds only know the draft-model flags."""
|
|
201
|
+
kind, draft, k = speculative.parse(spec)
|
|
202
|
+
modern = _modern_spec(binary)
|
|
203
|
+
if kind == "ngram":
|
|
204
|
+
if not modern:
|
|
205
|
+
raise RuntimeError("this llama-server has no built-in n-gram speculative decoding")
|
|
206
|
+
return ["--spec-type", "ngram-mod", "--spec-ngram-mod-n-max", str(k)]
|
|
207
|
+
if kind != "draft":
|
|
208
|
+
raise RuntimeError(f"llama.cpp has no {kind} speculative decoding")
|
|
209
|
+
draft_path = model.draft_paths.get(draft or "")
|
|
210
|
+
if not draft_path or draft_path.startswith(("hf://", "convert://")):
|
|
211
|
+
raise RuntimeError(f"draft model for {spec} not materialized")
|
|
212
|
+
out = ["-md", draft_path]
|
|
213
|
+
out += ["--spec-type", "draft-simple", "--spec-draft-n-max", str(k)] if modern else ["--draft-max", str(k)]
|
|
214
|
+
if self.cuda:
|
|
215
|
+
out += ["-ngld", "999"]
|
|
216
|
+
return out
|
|
217
|
+
|
|
218
|
+
def launch_spec(self, cfg: Config, model: PreparedModel, port: int) -> LaunchSpec:
|
|
219
|
+
binary = llama_server_binary()
|
|
220
|
+
if binary is None:
|
|
221
|
+
raise RuntimeError("llama-server not found on PATH (set $LLAMA_SERVER)")
|
|
222
|
+
path = model.gguf_paths.get(cfg.quant)
|
|
223
|
+
if not path or path.startswith(("hf://", "convert://")):
|
|
224
|
+
raise RuntimeError(f"GGUF for {cfg.quant} not materialized: {path}")
|
|
225
|
+
n_parallel = max(1, cfg.batch)
|
|
226
|
+
args = [
|
|
227
|
+
binary,
|
|
228
|
+
"-m", path,
|
|
229
|
+
"--host", "127.0.0.1",
|
|
230
|
+
"--port", str(port),
|
|
231
|
+
"-c", str(cfg.ctx * n_parallel), # -c is total context, split across slots
|
|
232
|
+
"-np", str(n_parallel),
|
|
233
|
+
"-b", str(max(cfg.n_batch or 512, cfg.prefill_budget or 0)), # logical batch must cover -ub
|
|
234
|
+
"-ngl", str(cfg.n_gpu_layers if cfg.n_gpu_layers is not None else (999 if self.cuda else 0)),
|
|
235
|
+
"--alias", model.spec.hf_id,
|
|
236
|
+
]
|
|
237
|
+
if cfg.prefill_budget is not None:
|
|
238
|
+
args += ["-ub", str(cfg.prefill_budget)]
|
|
239
|
+
threads = cfg.extra.get("threads")
|
|
240
|
+
if threads:
|
|
241
|
+
args += ["-t", str(threads)]
|
|
242
|
+
if self.cuda or cfg.kv_dtype != "auto":
|
|
243
|
+
args += ["-fa", "on"] # a quantized V cache needs flash attention, on CPU too
|
|
244
|
+
if cfg.kv_dtype != "auto":
|
|
245
|
+
args += ["-ctk", cfg.kv_dtype, "-ctv", cfg.kv_dtype]
|
|
246
|
+
if cfg.spec_decode:
|
|
247
|
+
args += self._spec_args(cfg.spec_decode, model, binary)
|
|
248
|
+
args += render_extra(cfg.extra, skip=("threads",))
|
|
249
|
+
env = {}
|
|
250
|
+
if not self.cuda:
|
|
251
|
+
env["CUDA_VISIBLE_DEVICES"] = ""
|
|
252
|
+
return LaunchSpec(args=args, env=env)
|
|
253
|
+
|
|
254
|
+
# Physical prompt-processing batch (-ub) to try around the engine default of 512.
|
|
255
|
+
UBATCHES = (256, 1024, 2048)
|
|
256
|
+
|
|
257
|
+
def prefill_variants(self, cfg: Config) -> List[Config]:
|
|
258
|
+
return [cfg.model_copy(update={"prefill_budget": ub, "n_batch": max(cfg.n_batch or 512, ub)})
|
|
259
|
+
for ub in self.UBATCHES if ub != cfg.prefill_budget]
|
|
260
|
+
|
|
261
|
+
def workload_hooks(self, hw: HardwareDescriptor, model: PreparedModel) -> LlmtraceHooks:
|
|
262
|
+
return LlmtraceHooks(
|
|
263
|
+
health_path="/health",
|
|
264
|
+
model_name=model.spec.hf_id,
|
|
265
|
+
tokenizer_id=model.spec.hf_id,
|
|
266
|
+
gpu_ids=[hw.gpu.index] if (self.cuda and hw.gpu) else [],
|
|
267
|
+
process_memory=not self.cuda,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class LlamaCppCudaBackend(LlamaCppBackend):
|
|
272
|
+
name = "llamacpp-cuda"
|
|
273
|
+
cuda = True
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class LlamaCppCpuBackend(LlamaCppBackend):
|
|
277
|
+
name = "llamacpp-cpu"
|
|
278
|
+
cuda = False
|
|
279
|
+
runtime_workspace_bytes = 512 * MiB
|
|
280
|
+
|
|
281
|
+
def candidate_configs(self, hw: HardwareDescriptor, model: PreparedModel, min_ctx: int = 0) -> List[Config]:
|
|
282
|
+
cfgs = super().candidate_configs(hw, model, min_ctx)
|
|
283
|
+
for c in cfgs:
|
|
284
|
+
c.extra["threads"] = hw.cpu.physical_cores
|
|
285
|
+
return cfgs
|
|
286
|
+
|
|
287
|
+
def default_config(self, hw: HardwareDescriptor, model: PreparedModel, min_ctx: int = 0) -> Config:
|
|
288
|
+
c = super().default_config(hw, model, min_ctx)
|
|
289
|
+
c.extra["threads"] = hw.cpu.physical_cores
|
|
290
|
+
return c
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _quant_from_filename(path: str) -> Optional[str]:
|
|
294
|
+
base = os.path.basename(path).upper()
|
|
295
|
+
for q in GGUF_QUANTS + ("F16", "BF16", "F32"):
|
|
296
|
+
if q in base:
|
|
297
|
+
return q
|
|
298
|
+
return None
|