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.
Files changed (84) hide show
  1. llmsetup/__main__.py +8 -0
  2. llmsetup/bench/__init__.py +27 -0
  3. llmsetup/bench/sweep.py +456 -0
  4. llmsetup/catalog/__init__.py +5 -0
  5. llmsetup/catalog/autoupdate.py +285 -0
  6. llmsetup/catalog/catalog.py +389 -0
  7. llmsetup/catalog/data/models.yaml +1323 -0
  8. llmsetup/catalog/discover.py +497 -0
  9. llmsetup/catalog/refresh.py +308 -0
  10. llmsetup/cli.py +835 -0
  11. llmsetup/download.py +415 -0
  12. llmsetup/gguf.py +347 -0
  13. llmsetup/hardware/__init__.py +5 -0
  14. llmsetup/hardware/bandwidth.py +150 -0
  15. llmsetup/hardware/cpu.py +178 -0
  16. llmsetup/hardware/detect.py +172 -0
  17. llmsetup/hardware/gpu.py +279 -0
  18. llmsetup/interop/__init__.py +5 -0
  19. llmsetup/interop/export.py +142 -0
  20. llmsetup/interop/jan.py +74 -0
  21. llmsetup/interop/llama_swap.py +86 -0
  22. llmsetup/interop/lmstudio.py +107 -0
  23. llmsetup/interop/ollama.py +90 -0
  24. llmsetup/interop/openai_clients.py +161 -0
  25. llmsetup/interview/__init__.py +5 -0
  26. llmsetup/interview/questions.py +213 -0
  27. llmsetup/interview/wizard.py +359 -0
  28. llmsetup/pipeline.py +773 -0
  29. llmsetup/planner/__init__.py +6 -0
  30. llmsetup/planner/memory.py +384 -0
  31. llmsetup/planner/model_fit.py +186 -0
  32. llmsetup/planner/moe.py +254 -0
  33. llmsetup/planner/plan.py +483 -0
  34. llmsetup/planner/requested.py +209 -0
  35. llmsetup/planner/score.py +174 -0
  36. llmsetup/repair/__init__.py +6 -0
  37. llmsetup/repair/loop.py +192 -0
  38. llmsetup/repair/strategies.py +795 -0
  39. llmsetup/report/__init__.py +5 -0
  40. llmsetup/report/render.py +525 -0
  41. llmsetup/runtimes/__init__.py +14 -0
  42. llmsetup/runtimes/base.py +168 -0
  43. llmsetup/runtimes/capabilities.py +94 -0
  44. llmsetup/runtimes/data/llamacpp_flags.py +422 -0
  45. llmsetup/runtimes/dependencies.py +50 -0
  46. llmsetup/runtimes/gguf_store.py +171 -0
  47. llmsetup/runtimes/install.py +230 -0
  48. llmsetup/runtimes/llamacpp.py +381 -0
  49. llmsetup/runtimes/lmstudio.py +138 -0
  50. llmsetup/runtimes/mlx.py +119 -0
  51. llmsetup/runtimes/ollama.py +223 -0
  52. llmsetup/runtimes/registry.py +215 -0
  53. llmsetup/runtimes/tabby.py +113 -0
  54. llmsetup/runtimes/vllm.py +199 -0
  55. llmsetup/tuning/__init__.py +5 -0
  56. llmsetup/tuning/advanced.py +653 -0
  57. llmsetup/tuning/platform_tuning.py +276 -0
  58. llmsetup/tuning/profiles.py +387 -0
  59. llmsetup/tuning/publisher.py +135 -0
  60. llmsetup/tuning/sampling.py +227 -0
  61. llmsetup/tuning/tuner.py +783 -0
  62. llmsetup/types.py +680 -0
  63. llmsetup/ui/__init__.py +5 -0
  64. llmsetup/ui/server.py +345 -0
  65. llmsetup/ui/static/index.html +449 -0
  66. llmsetup/util/__init__.py +1 -0
  67. llmsetup/util/errors.py +45 -0
  68. llmsetup/util/fs.py +159 -0
  69. llmsetup/util/http.py +140 -0
  70. llmsetup/util/log.py +78 -0
  71. llmsetup/util/proc.py +147 -0
  72. llmsetup/util/units.py +61 -0
  73. llmsetup/verify/__init__.py +5 -0
  74. llmsetup/verify/checks.py +1041 -0
  75. llmsetup/verify/client.py +257 -0
  76. llmsetup/verify/harness.py +182 -0
  77. llmsetup/verify/signatures.py +125 -0
  78. llmsetup-0.1.0.dist-info/METADATA +291 -0
  79. llmsetup-0.1.0.dist-info/RECORD +84 -0
  80. llmsetup-0.1.0.dist-info/WHEEL +5 -0
  81. llmsetup-0.1.0.dist-info/entry_points.txt +2 -0
  82. llmsetup-0.1.0.dist-info/licenses/LICENSE +202 -0
  83. llmsetup-0.1.0.dist-info/licenses/NOTICE +7 -0
  84. llmsetup-0.1.0.dist-info/top_level.txt +1 -0
llmsetup/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow `python -m llmsetup` alongside the installed console command."""
2
+
3
+ import sys
4
+
5
+ from llmsetup.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -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
+ ]
@@ -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
@@ -0,0 +1,5 @@
1
+ """Model catalog: loading, merging, filtering."""
2
+
3
+ from llmsetup.catalog.catalog import Catalog, load_catalog
4
+
5
+ __all__ = ["Catalog", "load_catalog"]