llmsetup 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.
- llmsetup/__main__.py +8 -0
- llmsetup/bench/__init__.py +27 -0
- llmsetup/bench/sweep.py +456 -0
- llmsetup/catalog/__init__.py +5 -0
- llmsetup/catalog/autoupdate.py +285 -0
- llmsetup/catalog/catalog.py +389 -0
- llmsetup/catalog/data/models.yaml +1323 -0
- llmsetup/catalog/discover.py +497 -0
- llmsetup/catalog/refresh.py +308 -0
- llmsetup/cli.py +835 -0
- llmsetup/download.py +415 -0
- llmsetup/gguf.py +347 -0
- llmsetup/hardware/__init__.py +5 -0
- llmsetup/hardware/bandwidth.py +150 -0
- llmsetup/hardware/cpu.py +178 -0
- llmsetup/hardware/detect.py +172 -0
- llmsetup/hardware/gpu.py +279 -0
- llmsetup/interop/__init__.py +5 -0
- llmsetup/interop/export.py +142 -0
- llmsetup/interop/jan.py +74 -0
- llmsetup/interop/llama_swap.py +86 -0
- llmsetup/interop/lmstudio.py +107 -0
- llmsetup/interop/ollama.py +90 -0
- llmsetup/interop/openai_clients.py +161 -0
- llmsetup/interview/__init__.py +5 -0
- llmsetup/interview/questions.py +213 -0
- llmsetup/interview/wizard.py +359 -0
- llmsetup/pipeline.py +773 -0
- llmsetup/planner/__init__.py +6 -0
- llmsetup/planner/memory.py +384 -0
- llmsetup/planner/model_fit.py +186 -0
- llmsetup/planner/moe.py +254 -0
- llmsetup/planner/plan.py +483 -0
- llmsetup/planner/requested.py +209 -0
- llmsetup/planner/score.py +174 -0
- llmsetup/repair/__init__.py +6 -0
- llmsetup/repair/loop.py +192 -0
- llmsetup/repair/strategies.py +795 -0
- llmsetup/report/__init__.py +5 -0
- llmsetup/report/render.py +525 -0
- llmsetup/runtimes/__init__.py +14 -0
- llmsetup/runtimes/base.py +168 -0
- llmsetup/runtimes/capabilities.py +94 -0
- llmsetup/runtimes/data/llamacpp_flags.py +422 -0
- llmsetup/runtimes/dependencies.py +50 -0
- llmsetup/runtimes/gguf_store.py +171 -0
- llmsetup/runtimes/install.py +230 -0
- llmsetup/runtimes/llamacpp.py +381 -0
- llmsetup/runtimes/lmstudio.py +138 -0
- llmsetup/runtimes/mlx.py +119 -0
- llmsetup/runtimes/ollama.py +223 -0
- llmsetup/runtimes/registry.py +215 -0
- llmsetup/runtimes/tabby.py +113 -0
- llmsetup/runtimes/vllm.py +199 -0
- llmsetup/tuning/__init__.py +5 -0
- llmsetup/tuning/advanced.py +653 -0
- llmsetup/tuning/platform_tuning.py +276 -0
- llmsetup/tuning/profiles.py +387 -0
- llmsetup/tuning/publisher.py +135 -0
- llmsetup/tuning/sampling.py +227 -0
- llmsetup/tuning/tuner.py +783 -0
- llmsetup/types.py +680 -0
- llmsetup/ui/__init__.py +5 -0
- llmsetup/ui/server.py +345 -0
- llmsetup/ui/static/index.html +449 -0
- llmsetup/util/__init__.py +1 -0
- llmsetup/util/errors.py +45 -0
- llmsetup/util/fs.py +159 -0
- llmsetup/util/http.py +140 -0
- llmsetup/util/log.py +78 -0
- llmsetup/util/proc.py +147 -0
- llmsetup/util/units.py +61 -0
- llmsetup/verify/__init__.py +5 -0
- llmsetup/verify/checks.py +1041 -0
- llmsetup/verify/client.py +257 -0
- llmsetup/verify/harness.py +182 -0
- llmsetup/verify/signatures.py +125 -0
- llmsetup-0.1.0.dist-info/METADATA +291 -0
- llmsetup-0.1.0.dist-info/RECORD +84 -0
- llmsetup-0.1.0.dist-info/WHEEL +5 -0
- llmsetup-0.1.0.dist-info/entry_points.txt +2 -0
- llmsetup-0.1.0.dist-info/licenses/LICENSE +202 -0
- llmsetup-0.1.0.dist-info/licenses/NOTICE +7 -0
- llmsetup-0.1.0.dist-info/top_level.txt +1 -0
llmsetup/__main__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Measuring the machine rather than predicting it."""
|
|
2
|
+
|
|
3
|
+
from llmsetup.bench.sweep import (
|
|
4
|
+
BenchRow,
|
|
5
|
+
SweepCandidate,
|
|
6
|
+
SweepResult,
|
|
7
|
+
apply_sweep,
|
|
8
|
+
candidate_grid,
|
|
9
|
+
parse_bench_json,
|
|
10
|
+
pick_best,
|
|
11
|
+
prefill_weight,
|
|
12
|
+
run_sweep,
|
|
13
|
+
score_rows,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BenchRow",
|
|
18
|
+
"SweepCandidate",
|
|
19
|
+
"SweepResult",
|
|
20
|
+
"apply_sweep",
|
|
21
|
+
"candidate_grid",
|
|
22
|
+
"parse_bench_json",
|
|
23
|
+
"pick_best",
|
|
24
|
+
"prefill_weight",
|
|
25
|
+
"run_sweep",
|
|
26
|
+
"score_rows",
|
|
27
|
+
]
|
llmsetup/bench/sweep.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
"""Trying settings on the real machine and keeping what wins.
|
|
2
|
+
|
|
3
|
+
Everything else in llmsetup predicts. The memory model is exact arithmetic and the
|
|
4
|
+
throughput estimate is grounded in bandwidth, but neither can know that this
|
|
5
|
+
card, on this driver, with this build, processes prompts fastest at a micro-batch
|
|
6
|
+
of 2048 rather than the default 512. Measurements on real hardware put that gap
|
|
7
|
+
at 25-30% of prompt throughput, and it is not the kind of thing a formula
|
|
8
|
+
recovers: it comes from kernel shapes and cache sizes.
|
|
9
|
+
|
|
10
|
+
An experienced person handles this by running llama-bench a few times and
|
|
11
|
+
eyeballing the numbers. That works and it is also where llmsetup can do better,
|
|
12
|
+
not because the method is clever but because a machine will sit through twelve
|
|
13
|
+
configurations in a fixed order and a person will try three and stop.
|
|
14
|
+
|
|
15
|
+
Three rules keep it honest:
|
|
16
|
+
|
|
17
|
+
* **Nothing is benchmarked that would not fit.** Candidates go through the same
|
|
18
|
+
memory model as the plan, at the context the user actually asked for, so the
|
|
19
|
+
sweep cannot recommend a setting that runs out of memory later.
|
|
20
|
+
* **The objective follows the use case.** Prefill and decode trade against each
|
|
21
|
+
other, and which one matters is not a property of the hardware. Someone
|
|
22
|
+
completing code as they type and someone holding a conversation want opposite
|
|
23
|
+
ends of the same curve.
|
|
24
|
+
* **A win inside the noise is not a win.** llama-bench reports a standard
|
|
25
|
+
deviation with every number. A candidate that beats the default by less than
|
|
26
|
+
the run-to-run spread is reported as a tie, because saying otherwise would be
|
|
27
|
+
inventing a result out of measurement error.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
35
|
+
|
|
36
|
+
from llmsetup.types import ModelSpec, RuntimeConfig, Task, UseCaseProfile
|
|
37
|
+
|
|
38
|
+
#: Micro-batch sizes worth trying. The default is 512, and the measured optimum
|
|
39
|
+
#: on some hardware is four times that, so the grid spans a range wide enough to
|
|
40
|
+
#: contain both without becoming a long job.
|
|
41
|
+
_UBATCH_CANDIDATES = (256, 512, 1024, 2048)
|
|
42
|
+
|
|
43
|
+
#: Logical batch size is a ceiling on the micro-batch, so only values at or
|
|
44
|
+
#: above it are meaningful pairings.
|
|
45
|
+
_BATCH_CANDIDATES = (1024, 2048, 4096)
|
|
46
|
+
|
|
47
|
+
#: How long one llama-bench invocation is allowed to take. A sweep that hangs is
|
|
48
|
+
#: worse than no sweep: the user is sitting in front of it.
|
|
49
|
+
BENCH_TIMEOUT_S = 900.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class SweepCandidate:
|
|
54
|
+
"""One configuration to measure."""
|
|
55
|
+
|
|
56
|
+
batch_size: int
|
|
57
|
+
ubatch_size: int
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def label(self) -> str:
|
|
61
|
+
return f"b={self.batch_size} ub={self.ubatch_size}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class BenchRow:
|
|
66
|
+
"""One measurement out of llama-bench.
|
|
67
|
+
|
|
68
|
+
Field names mirror the tool's own JSON so there is no translation layer to
|
|
69
|
+
get subtly wrong.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
n_batch: int
|
|
73
|
+
n_ubatch: int
|
|
74
|
+
n_prompt: int
|
|
75
|
+
n_gen: int
|
|
76
|
+
avg_ts: float
|
|
77
|
+
stddev_ts: float = 0.0
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def is_prefill(self) -> bool:
|
|
81
|
+
"""Prompt-processing rows have a prompt and no generation."""
|
|
82
|
+
return self.n_prompt > 0 and self.n_gen == 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class SweepResult:
|
|
87
|
+
"""What the sweep found, and whether it is worth acting on."""
|
|
88
|
+
|
|
89
|
+
best: Optional[SweepCandidate] = None
|
|
90
|
+
baseline: Optional[SweepCandidate] = None
|
|
91
|
+
best_score: float = 0.0
|
|
92
|
+
baseline_score: float = 0.0
|
|
93
|
+
improvement: float = 0.0
|
|
94
|
+
within_noise: bool = False
|
|
95
|
+
rows: List[BenchRow] = field(default_factory=list)
|
|
96
|
+
rationale: str = ""
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def worth_applying(self) -> bool:
|
|
100
|
+
"""A real, measured improvement over what the plan already chose."""
|
|
101
|
+
return (self.best is not None
|
|
102
|
+
and self.baseline is not None
|
|
103
|
+
and self.best != self.baseline
|
|
104
|
+
and not self.within_noise)
|
|
105
|
+
|
|
106
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
107
|
+
return {
|
|
108
|
+
"best": vars(self.best) if self.best else None,
|
|
109
|
+
"baseline": vars(self.baseline) if self.baseline else None,
|
|
110
|
+
"best_score": round(self.best_score, 2),
|
|
111
|
+
"baseline_score": round(self.baseline_score, 2),
|
|
112
|
+
"improvement": round(self.improvement, 4),
|
|
113
|
+
"within_noise": self.within_noise,
|
|
114
|
+
"worth_applying": self.worth_applying,
|
|
115
|
+
"rationale": self.rationale,
|
|
116
|
+
"measurements": len(self.rows),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --------------------------------------------------------------------------- #
|
|
121
|
+
# what to measure
|
|
122
|
+
# --------------------------------------------------------------------------- #
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def candidate_grid(
|
|
126
|
+
spec: ModelSpec,
|
|
127
|
+
config: RuntimeConfig,
|
|
128
|
+
budget_bytes: int,
|
|
129
|
+
bpw: float,
|
|
130
|
+
max_candidates: int = 8,
|
|
131
|
+
) -> List[SweepCandidate]:
|
|
132
|
+
"""Configurations worth measuring on this machine, cheapest risk first.
|
|
133
|
+
|
|
134
|
+
Only combinations that fit are included. A larger micro-batch buys prompt
|
|
135
|
+
throughput with compute-buffer memory, and finding that out by running out
|
|
136
|
+
of memory halfway through a sweep is a poor way to learn it, so the memory
|
|
137
|
+
model rules them out first.
|
|
138
|
+
|
|
139
|
+
The configuration the plan already chose is always present, because a sweep
|
|
140
|
+
with nothing to compare against cannot tell whether it found anything.
|
|
141
|
+
"""
|
|
142
|
+
from llmsetup.planner.memory import estimate_memory
|
|
143
|
+
|
|
144
|
+
def fits(ubatch: int) -> bool:
|
|
145
|
+
estimate = estimate_memory(
|
|
146
|
+
spec, bpw, config.context_tokens, quant=config.quant,
|
|
147
|
+
kv_quant=config.kv_cache_type, runtime=config.runtime,
|
|
148
|
+
ubatch=ubatch, parallel=config.parallel, swa_full=config.swa_full,
|
|
149
|
+
)
|
|
150
|
+
return estimate.total <= budget_bytes
|
|
151
|
+
|
|
152
|
+
planned = SweepCandidate(config.batch_size, config.ubatch_size)
|
|
153
|
+
batches = sorted({*_BATCH_CANDIDATES, config.batch_size})
|
|
154
|
+
|
|
155
|
+
# One candidate per micro-batch size, because that is the axis the gains sit
|
|
156
|
+
# on and pairing every batch with every micro-batch spends the whole budget
|
|
157
|
+
# covering a dimension that barely moves. The logical batch is the smallest
|
|
158
|
+
# one that can actually hold the micro-batch, since a larger one changes
|
|
159
|
+
# nothing except how many tokens are queued ahead of it.
|
|
160
|
+
candidates: List[SweepCandidate] = [planned]
|
|
161
|
+
for ubatch in sorted({*_UBATCH_CANDIDATES, config.ubatch_size}):
|
|
162
|
+
if ubatch == planned.ubatch_size or not fits(ubatch):
|
|
163
|
+
continue
|
|
164
|
+
batch = next((b for b in batches if b >= ubatch), ubatch)
|
|
165
|
+
candidate = SweepCandidate(batch_size=batch, ubatch_size=ubatch)
|
|
166
|
+
if candidate not in candidates:
|
|
167
|
+
candidates.append(candidate)
|
|
168
|
+
|
|
169
|
+
# Trim from the middle rather than the end: the extremes are what say
|
|
170
|
+
# whether the curve rises or falls, and dropping the largest micro-batch
|
|
171
|
+
# would discard the one most likely to win.
|
|
172
|
+
while len(candidates) > max_candidates:
|
|
173
|
+
candidates.pop(len(candidates) // 2)
|
|
174
|
+
return candidates
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --------------------------------------------------------------------------- #
|
|
178
|
+
# what counts as better
|
|
179
|
+
# --------------------------------------------------------------------------- #
|
|
180
|
+
|
|
181
|
+
#: How much prefill matters, against decode, per use case. Both are real speeds
|
|
182
|
+
#: and neither is "the" speed: someone completing code as they type waits on
|
|
183
|
+
#: prompt processing every keystroke, while a conversation is dominated by how
|
|
184
|
+
#: fast words appear once the model starts.
|
|
185
|
+
_PREFILL_WEIGHT = {
|
|
186
|
+
Task.AUTOCOMPLETE: 0.8,
|
|
187
|
+
Task.RAG: 0.7,
|
|
188
|
+
Task.SUMMARIZE: 0.7,
|
|
189
|
+
Task.AGENT: 0.6,
|
|
190
|
+
Task.CODING: 0.5,
|
|
191
|
+
Task.EXTRACT: 0.5,
|
|
192
|
+
Task.VISION: 0.4,
|
|
193
|
+
Task.TRANSLATE: 0.3,
|
|
194
|
+
Task.CHAT: 0.25,
|
|
195
|
+
Task.CREATIVE: 0.2,
|
|
196
|
+
}
|
|
197
|
+
_DEFAULT_PREFILL_WEIGHT = 0.4
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def prefill_weight(profile: UseCaseProfile) -> float:
|
|
201
|
+
"""How much this use case waits on reading rather than writing."""
|
|
202
|
+
return _PREFILL_WEIGHT.get(profile.primary_task, _DEFAULT_PREFILL_WEIGHT)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def score_rows(rows: Sequence[BenchRow], weight: float) -> Dict[Tuple[int, int], float]:
|
|
206
|
+
"""Collapse each configuration's measurements into one comparable number.
|
|
207
|
+
|
|
208
|
+
Prefill and decode are quoted in the same unit and differ by an order of
|
|
209
|
+
magnitude, so a raw sum would let prompt throughput decide everything. Each
|
|
210
|
+
is normalised against the best result for that phase, which turns both into
|
|
211
|
+
a fraction of the best seen and makes the weight mean what it says.
|
|
212
|
+
"""
|
|
213
|
+
prefill: Dict[Tuple[int, int], float] = {}
|
|
214
|
+
decode: Dict[Tuple[int, int], float] = {}
|
|
215
|
+
for row in rows:
|
|
216
|
+
key = (row.n_batch, row.n_ubatch)
|
|
217
|
+
target = prefill if row.is_prefill else decode
|
|
218
|
+
# Several rows can share a configuration; keep the best of each phase.
|
|
219
|
+
target[key] = max(target.get(key, 0.0), row.avg_ts)
|
|
220
|
+
|
|
221
|
+
best_prefill = max(prefill.values(), default=0.0)
|
|
222
|
+
best_decode = max(decode.values(), default=0.0)
|
|
223
|
+
|
|
224
|
+
scores: Dict[Tuple[int, int], float] = {}
|
|
225
|
+
for key in set(prefill) | set(decode):
|
|
226
|
+
p = (prefill.get(key, 0.0) / best_prefill) if best_prefill else 0.0
|
|
227
|
+
d = (decode.get(key, 0.0) / best_decode) if best_decode else 0.0
|
|
228
|
+
if not prefill:
|
|
229
|
+
scores[key] = d
|
|
230
|
+
elif not decode:
|
|
231
|
+
scores[key] = p
|
|
232
|
+
else:
|
|
233
|
+
scores[key] = weight * p + (1.0 - weight) * d
|
|
234
|
+
return scores
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def noise_floor(rows: Sequence[BenchRow], key: Tuple[int, int]) -> float:
|
|
238
|
+
"""Relative run-to-run spread for one configuration.
|
|
239
|
+
|
|
240
|
+
llama-bench reports a standard deviation alongside every average. Expressed
|
|
241
|
+
as a fraction of the average it is the smallest difference that means
|
|
242
|
+
anything, and anything under it is measurement error wearing a result's
|
|
243
|
+
clothes.
|
|
244
|
+
"""
|
|
245
|
+
spreads = [row.stddev_ts / row.avg_ts
|
|
246
|
+
for row in rows
|
|
247
|
+
if (row.n_batch, row.n_ubatch) == key and row.avg_ts > 0]
|
|
248
|
+
return max(spreads) if spreads else 0.0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def pick_best(rows: Sequence[BenchRow], baseline: SweepCandidate,
|
|
252
|
+
weight: float) -> SweepResult:
|
|
253
|
+
"""Choose a configuration, or decline to.
|
|
254
|
+
|
|
255
|
+
Declining is a real outcome and the common one. Most of the time the planned
|
|
256
|
+
settings are already at or near the top of the curve, and reporting a
|
|
257
|
+
quarter-percent improvement as a finding would be noise dressed up as
|
|
258
|
+
tuning.
|
|
259
|
+
"""
|
|
260
|
+
result = SweepResult(baseline=baseline, rows=list(rows))
|
|
261
|
+
if not rows:
|
|
262
|
+
result.rationale = "no measurements were taken"
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
scores = score_rows(rows, weight)
|
|
266
|
+
if not scores:
|
|
267
|
+
result.rationale = "the benchmark produced no usable rows"
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
baseline_key = (baseline.batch_size, baseline.ubatch_size)
|
|
271
|
+
result.baseline_score = scores.get(baseline_key, 0.0)
|
|
272
|
+
|
|
273
|
+
best_key = max(scores, key=lambda k: scores[k])
|
|
274
|
+
result.best = SweepCandidate(batch_size=best_key[0], ubatch_size=best_key[1])
|
|
275
|
+
result.best_score = scores[best_key]
|
|
276
|
+
|
|
277
|
+
if result.baseline_score <= 0:
|
|
278
|
+
# The planned configuration was never measured, so there is nothing to
|
|
279
|
+
# compare against and no basis for claiming an improvement.
|
|
280
|
+
result.best = baseline
|
|
281
|
+
result.best_score = 0.0
|
|
282
|
+
result.rationale = (
|
|
283
|
+
"the planned settings were not among the measurements, so no "
|
|
284
|
+
"comparison is possible"
|
|
285
|
+
)
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
result.improvement = (result.best_score / result.baseline_score) - 1.0
|
|
289
|
+
floor = max(noise_floor(rows, best_key), noise_floor(rows, baseline_key))
|
|
290
|
+
result.within_noise = result.improvement <= floor
|
|
291
|
+
|
|
292
|
+
if best_key == baseline_key:
|
|
293
|
+
result.rationale = (
|
|
294
|
+
f"the planned settings ({baseline.label}) measured fastest of "
|
|
295
|
+
f"{len(scores)} tried"
|
|
296
|
+
)
|
|
297
|
+
elif result.within_noise:
|
|
298
|
+
result.rationale = (
|
|
299
|
+
f"{result.best.label} came out {result.improvement:.1%} ahead of "
|
|
300
|
+
f"{baseline.label}, which is inside the {floor:.1%} run-to-run "
|
|
301
|
+
f"spread, so the planned settings stand"
|
|
302
|
+
)
|
|
303
|
+
result.best = baseline
|
|
304
|
+
else:
|
|
305
|
+
result.rationale = (
|
|
306
|
+
f"{result.best.label} measured {result.improvement:.1%} faster than "
|
|
307
|
+
f"the planned {baseline.label} on this machine, weighting prompt "
|
|
308
|
+
f"processing at {weight:.0%} for this use case"
|
|
309
|
+
)
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# --------------------------------------------------------------------------- #
|
|
314
|
+
# running it
|
|
315
|
+
# --------------------------------------------------------------------------- #
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def parse_bench_json(text: str) -> List[BenchRow]:
|
|
319
|
+
"""Read llama-bench's JSON output.
|
|
320
|
+
|
|
321
|
+
Tolerant on purpose: a row missing a field it should have is skipped rather
|
|
322
|
+
than taken down the whole sweep, because a partial measurement is still
|
|
323
|
+
worth something and the tool's output has changed shape before.
|
|
324
|
+
"""
|
|
325
|
+
try:
|
|
326
|
+
payload = json.loads(text)
|
|
327
|
+
except ValueError:
|
|
328
|
+
return []
|
|
329
|
+
if isinstance(payload, dict):
|
|
330
|
+
payload = payload.get("results") or []
|
|
331
|
+
rows: List[BenchRow] = []
|
|
332
|
+
for entry in payload if isinstance(payload, list) else []:
|
|
333
|
+
if not isinstance(entry, dict):
|
|
334
|
+
continue
|
|
335
|
+
try:
|
|
336
|
+
rows.append(BenchRow(
|
|
337
|
+
n_batch=int(entry["n_batch"]),
|
|
338
|
+
n_ubatch=int(entry["n_ubatch"]),
|
|
339
|
+
n_prompt=int(entry.get("n_prompt", 0)),
|
|
340
|
+
n_gen=int(entry.get("n_gen", 0)),
|
|
341
|
+
avg_ts=float(entry["avg_ts"]),
|
|
342
|
+
stddev_ts=float(entry.get("stddev_ts", 0.0)),
|
|
343
|
+
))
|
|
344
|
+
except (KeyError, TypeError, ValueError):
|
|
345
|
+
continue
|
|
346
|
+
return rows
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def bench_command(binary: str, model_path: str, candidates: Sequence[SweepCandidate],
|
|
350
|
+
config: RuntimeConfig, prompt_tokens: int = 2048,
|
|
351
|
+
gen_tokens: int = 128, repetitions: int = 3) -> List[str]:
|
|
352
|
+
"""The llama-bench invocation that measures every candidate at once.
|
|
353
|
+
|
|
354
|
+
One process rather than one per candidate: llama-bench takes lists and loads
|
|
355
|
+
the model a single time, which is most of the wall clock on a large model.
|
|
356
|
+
"""
|
|
357
|
+
batches = sorted({c.batch_size for c in candidates})
|
|
358
|
+
ubatches = sorted({c.ubatch_size for c in candidates})
|
|
359
|
+
cmd = [
|
|
360
|
+
binary,
|
|
361
|
+
"--model", model_path,
|
|
362
|
+
"-p", str(prompt_tokens),
|
|
363
|
+
"-n", str(gen_tokens),
|
|
364
|
+
"-b", ",".join(str(b) for b in batches),
|
|
365
|
+
"-ub", ",".join(str(u) for u in ubatches),
|
|
366
|
+
"-ngl", str(config.gpu_layers),
|
|
367
|
+
"-r", str(repetitions),
|
|
368
|
+
"-o", "json",
|
|
369
|
+
]
|
|
370
|
+
if config.kv_cache_type and config.kv_cache_type != "f16":
|
|
371
|
+
cmd += ["-ctk", config.kv_cache_type, "-ctv", config.kv_cache_type]
|
|
372
|
+
if config.threads:
|
|
373
|
+
cmd += ["-t", str(config.threads)]
|
|
374
|
+
cmd += ["-fa", "1" if config.flash_attention else "0"]
|
|
375
|
+
return cmd
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def run_sweep(
|
|
379
|
+
spec: ModelSpec,
|
|
380
|
+
config: RuntimeConfig,
|
|
381
|
+
profile: UseCaseProfile,
|
|
382
|
+
budget_bytes: int,
|
|
383
|
+
bpw: float,
|
|
384
|
+
console,
|
|
385
|
+
binary: str = "llama-bench",
|
|
386
|
+
repetitions: int = 3,
|
|
387
|
+
max_candidates: int = 6,
|
|
388
|
+
) -> SweepResult:
|
|
389
|
+
"""Measure candidate settings on this machine and report what won.
|
|
390
|
+
|
|
391
|
+
Returns a result rather than applying it. Whether to take a measured
|
|
392
|
+
improvement is a decision with a cost attached, and the caller is the one
|
|
393
|
+
holding the user's answer about that.
|
|
394
|
+
"""
|
|
395
|
+
from llmsetup.util.proc import run, which
|
|
396
|
+
|
|
397
|
+
if which(binary) is None:
|
|
398
|
+
return SweepResult(rationale=(
|
|
399
|
+
f"{binary} is not installed, so nothing was measured. It ships with "
|
|
400
|
+
f"llama.cpp and is what turns these settings from predicted into "
|
|
401
|
+
f"known"
|
|
402
|
+
))
|
|
403
|
+
if not config.model_path:
|
|
404
|
+
return SweepResult(rationale=(
|
|
405
|
+
"the weights are not on disk yet, and a sweep measures a real model"
|
|
406
|
+
))
|
|
407
|
+
|
|
408
|
+
candidates = candidate_grid(spec, config, budget_bytes, bpw,
|
|
409
|
+
max_candidates=max_candidates)
|
|
410
|
+
baseline = SweepCandidate(config.batch_size, config.ubatch_size)
|
|
411
|
+
if len(candidates) < 2:
|
|
412
|
+
return SweepResult(baseline=baseline, best=baseline, rationale=(
|
|
413
|
+
"only the planned settings fit in the memory available at this "
|
|
414
|
+
"context, so there was nothing to compare against"
|
|
415
|
+
))
|
|
416
|
+
|
|
417
|
+
command = bench_command(binary, config.model_path, candidates, config,
|
|
418
|
+
repetitions=repetitions)
|
|
419
|
+
console.info(
|
|
420
|
+
f"measuring {len(candidates)} configurations "
|
|
421
|
+
f"(about {estimate_duration_s(candidates, repetitions) / 60:.0f} minutes)"
|
|
422
|
+
)
|
|
423
|
+
console.debug(" ".join(command))
|
|
424
|
+
|
|
425
|
+
result = run(command, timeout=BENCH_TIMEOUT_S)
|
|
426
|
+
if not result.ok:
|
|
427
|
+
return SweepResult(baseline=baseline, best=baseline, rationale=(
|
|
428
|
+
f"the benchmark did not complete, so the planned settings stand: "
|
|
429
|
+
f"{result.output.strip().splitlines()[-1][:200] if result.output else 'no output'}"
|
|
430
|
+
))
|
|
431
|
+
|
|
432
|
+
rows = parse_bench_json(result.output)
|
|
433
|
+
return pick_best(rows, baseline, prefill_weight(profile))
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def apply_sweep(config: RuntimeConfig, sweep: SweepResult) -> bool:
|
|
437
|
+
"""Take a measured improvement. Returns whether anything changed."""
|
|
438
|
+
if not sweep.worth_applying or sweep.best is None:
|
|
439
|
+
return False
|
|
440
|
+
config.batch_size = sweep.best.batch_size
|
|
441
|
+
config.ubatch_size = sweep.best.ubatch_size
|
|
442
|
+
return True
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def estimate_duration_s(candidates: Sequence[SweepCandidate],
|
|
446
|
+
repetitions: int = 3) -> float:
|
|
447
|
+
"""Roughly how long the sweep will take, for asking before spending it.
|
|
448
|
+
|
|
449
|
+
Deliberately generous. Someone told sixty seconds and made to wait three
|
|
450
|
+
minutes trusts the next estimate less than someone told five.
|
|
451
|
+
"""
|
|
452
|
+
# Each (batch, ubatch) pairing runs a prompt pass and a generation pass, and
|
|
453
|
+
# llama-bench repeats each. Model load is paid once.
|
|
454
|
+
per_measurement_s = 4.0
|
|
455
|
+
pairs = len({(c.batch_size, c.ubatch_size) for c in candidates})
|
|
456
|
+
return 30.0 + pairs * 2 * repetitions * per_measurement_s
|