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.
Files changed (48) hide show
  1. polyserve/__init__.py +6 -0
  2. polyserve/backends/__init__.py +36 -0
  3. polyserve/backends/base.py +314 -0
  4. polyserve/backends/llamacpp.py +298 -0
  5. polyserve/backends/sglang.py +151 -0
  6. polyserve/backends/vllm.py +285 -0
  7. polyserve/backends/vllm_cpu.py +84 -0
  8. polyserve/bench/__init__.py +19 -0
  9. polyserve/bench/ablation.py +326 -0
  10. polyserve/bench/baselines.py +82 -0
  11. polyserve/bench/compare.py +358 -0
  12. polyserve/bench/references.py +159 -0
  13. polyserve/bench/report.py +305 -0
  14. polyserve/cache.py +108 -0
  15. polyserve/calibrate/__init__.py +7 -0
  16. polyserve/calibrate/datasets.py +129 -0
  17. polyserve/calibrate/measure.py +593 -0
  18. polyserve/calibrate/objectives.py +287 -0
  19. polyserve/calibrate/search.py +914 -0
  20. polyserve/calibrate/tail.py +30 -0
  21. polyserve/calibrate/tokens.py +82 -0
  22. polyserve/calibrate/workload.py +305 -0
  23. polyserve/cli.py +873 -0
  24. polyserve/disagg.py +714 -0
  25. polyserve/gguf.py +239 -0
  26. polyserve/hardware.py +483 -0
  27. polyserve/hfconfig.py +148 -0
  28. polyserve/layout.py +387 -0
  29. polyserve/memcal.py +322 -0
  30. polyserve/memlog.py +213 -0
  31. polyserve/memory.py +110 -0
  32. polyserve/models.py +336 -0
  33. polyserve/pipeline.py +548 -0
  34. polyserve/power.py +366 -0
  35. polyserve/predict.py +483 -0
  36. polyserve/quantized.py +169 -0
  37. polyserve/router.py +41 -0
  38. polyserve/selector.py +75 -0
  39. polyserve/serve/__init__.py +4 -0
  40. polyserve/serve/proxy.py +115 -0
  41. polyserve/serve/supervisor.py +155 -0
  42. polyserve/speculative.py +127 -0
  43. polyserve-0.1.0.dist-info/METADATA +87 -0
  44. polyserve-0.1.0.dist-info/RECORD +48 -0
  45. polyserve-0.1.0.dist-info/WHEEL +5 -0
  46. polyserve-0.1.0.dist-info/entry_points.txt +2 -0
  47. polyserve-0.1.0.dist-info/licenses/LICENSE +21 -0
  48. polyserve-0.1.0.dist-info/top_level.txt +1 -0
polyserve/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """PolyServe: an autotuner for LLM serving.
2
+
3
+ probe -> select backends -> prepare model -> plan memory -> calibrate -> cache -> serve
4
+ """
5
+
6
+ __version__ = "0.1.0"
@@ -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