mjolnir-cli 0.2.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.
- mjolnir_cli/__init__.py +8 -0
- mjolnir_cli/__main__.py +4 -0
- mjolnir_cli/accuracy.py +333 -0
- mjolnir_cli/autoresearch.py +539 -0
- mjolnir_cli/benchy.py +475 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4/config.yaml +78 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DFLASH2/config.yaml +103 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_DSPARK/config.yaml +102 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA2/config.yaml +84 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_FA4hd256/config.yaml +104 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml +86 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml +93 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DFLASH2/config.yaml +123 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_DSPARK/config.yaml +124 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/.env +2 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/.env +4 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT32K/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/.env +4 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_LMH8_VT64K/config.yaml +102 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/.env +3 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3_VT32K/config.yaml +98 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/draft_vocab.json +1 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8-27B/kv_scales.json +35197 -0
- mjolnir_cli/bundled/configs/Qwen/Qwen3.8_chat_template.jinja +456 -0
- mjolnir_cli/bundled/docker/litellm/docker-compose.yaml +113 -0
- mjolnir_cli/bundled/docker/litellm/litellm.env.example +51 -0
- mjolnir_cli/bundled/docker/litellm/litellm_config.template.example.yaml +192 -0
- mjolnir_cli/bundled/docker/perses/README.md +89 -0
- mjolnir_cli/bundled/docker/perses/dashboards/global-datasource.yaml +17 -0
- mjolnir_cli/bundled/docker/perses/dashboards/performance_statistics.yaml +771 -0
- mjolnir_cli/bundled/docker/perses/dashboards/project.yaml +13 -0
- mjolnir_cli/bundled/docker/perses/dashboards/query_statistics.yaml +399 -0
- mjolnir_cli/bundled/docker/perses/docker-compose.yaml +51 -0
- mjolnir_cli/bundled/docker/perses/perses.yml +16 -0
- mjolnir_cli/bundled/docker/perses/prometheus.template.yaml +20 -0
- mjolnir_cli/bundled/scripts/hw/setup-thor.sh +1287 -0
- mjolnir_cli/cachectl.py +146 -0
- mjolnir_cli/charts.py +1687 -0
- mjolnir_cli/cli.py +1480 -0
- mjolnir_cli/clidoc.py +170 -0
- mjolnir_cli/config.py +346 -0
- mjolnir_cli/dockerctl.py +533 -0
- mjolnir_cli/drivers/fa2_driver.py +89 -0
- mjolnir_cli/drivers/fi_driver.py +134 -0
- mjolnir_cli/e2e.py +302 -0
- mjolnir_cli/fonts/InterDisplay-Bold.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-Medium.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-Regular.ttf +0 -0
- mjolnir_cli/fonts/InterDisplay-SemiBold.ttf +0 -0
- mjolnir_cli/fonts/OFL.txt +92 -0
- mjolnir_cli/gate.py +233 -0
- mjolnir_cli/history.py +159 -0
- mjolnir_cli/interactive.py +383 -0
- mjolnir_cli/legs.py +109 -0
- mjolnir_cli/litellm.py +254 -0
- mjolnir_cli/perses.py +214 -0
- mjolnir_cli/picker.py +70 -0
- mjolnir_cli/readme.py +1020 -0
- mjolnir_cli/stack.py +340 -0
- mjolnir_cli/tasks.py +122 -0
- mjolnir_cli/theme.py +281 -0
- mjolnir_cli/thor.py +108 -0
- mjolnir_cli/verify/__init__.py +2 -0
- mjolnir_cli/verify/cli.py +816 -0
- mjolnir_cli/verify/docker.py +334 -0
- mjolnir_cli/verify/kvcache.py +180 -0
- mjolnir_cli/verify/legs.py +134 -0
- mjolnir_cli/verify/ncu.py +141 -0
- mjolnir_cli/verify/profile.py +312 -0
- mjolnir_cli/verify/report.py +177 -0
- mjolnir_cli/verify/results.py +157 -0
- mjolnir_cli/verify/spec.py +279 -0
- mjolnir_cli/verify/stages.py +817 -0
- mjolnir_cli-0.2.0.dist-info/METADATA +87 -0
- mjolnir_cli-0.2.0.dist-info/RECORD +80 -0
- mjolnir_cli-0.2.0.dist-info/WHEEL +4 -0
- mjolnir_cli-0.2.0.dist-info/entry_points.txt +2 -0
- mjolnir_cli-0.2.0.dist-info/licenses/LICENSE +202 -0
- mjolnir_cli-0.2.0.dist-info/licenses/NOTICE +64 -0
mjolnir_cli/clidoc.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""The CLI reference, generated from the typer commands themselves.
|
|
2
|
+
|
|
3
|
+
``mjolnir plot --readme`` writes ``docs/cli.md`` (every command and sub-command
|
|
4
|
+
with its description, arguments and options, defaults included), so the docs
|
|
5
|
+
can never drift from the code: change a command's docstring or an option's
|
|
6
|
+
help and the next render carries it — CI fails if the committed copy is stale.
|
|
7
|
+
The README keeps a short hand-written overview; :func:`block_commands` lets a
|
|
8
|
+
test check that every command it names exists.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
try: # typer >= 0.20 vendors its own click
|
|
18
|
+
from typer._click import Context
|
|
19
|
+
except ImportError: # pragma: no cover
|
|
20
|
+
from click import Context
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Cmd:
|
|
25
|
+
path: str # "mjolnir serve up"
|
|
26
|
+
short: str # first line of the help
|
|
27
|
+
help: str # the whole help text
|
|
28
|
+
params: list[Any]
|
|
29
|
+
group: bool
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _clean(text: str | None) -> str:
|
|
33
|
+
import inspect
|
|
34
|
+
|
|
35
|
+
return inspect.cleandoc(text or "").replace("\b\n", "").strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _rst(text: str) -> str:
|
|
39
|
+
"""``x`` -> `x`, and <placeholders> outside code spans escaped (GitHub would
|
|
40
|
+
swallow them as HTML tags)."""
|
|
41
|
+
text = re.sub(r"``([^`]+)``", r"`\1`", text)
|
|
42
|
+
parts = re.split(r"(`[^`]*`)", text)
|
|
43
|
+
return "".join(p if p.startswith("`") else p.replace("<", "<").replace(">", ">") for p in parts)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# options many commands share, documented once here (their typer help is empty)
|
|
47
|
+
COMMON_HELP = {
|
|
48
|
+
"--model": "the model (`vendor/Model`; default: the active one, `mjolnir model`)",
|
|
49
|
+
"--config": "the model config (default: the active one)",
|
|
50
|
+
"--image": "the vLLM image (default: the active one, `mjolnir image`)",
|
|
51
|
+
"--port": "the server port (default: the active one)",
|
|
52
|
+
"--dry-run": "print what would run, run nothing",
|
|
53
|
+
"--gate": "wait for a clean window (vLLM queue 0/0) before measuring",
|
|
54
|
+
"--plot": "re-render the charts / README afterwards",
|
|
55
|
+
"--timeout": "seconds before a step is given up",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _short(cmd: Any) -> str:
|
|
60
|
+
"""The first paragraph of the help, on one line."""
|
|
61
|
+
text = _clean(cmd.short_help or cmd.help)
|
|
62
|
+
return _rst(text.split("\n\n", 1)[0].replace("\n", " ").strip())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def walk(app) -> list[Cmd]:
|
|
66
|
+
"""Every command, depth first, in the order the CLI lists them."""
|
|
67
|
+
import typer.main
|
|
68
|
+
|
|
69
|
+
root = typer.main.get_command(app)
|
|
70
|
+
out: list[Cmd] = []
|
|
71
|
+
|
|
72
|
+
def visit(cmd: Any, path: str, ctx: Any) -> None:
|
|
73
|
+
if getattr(cmd, "hidden", False):
|
|
74
|
+
return
|
|
75
|
+
group = hasattr(cmd, "list_commands")
|
|
76
|
+
out.append(Cmd(path, _short(cmd), _rst(_clean(cmd.help)), list(cmd.params), group))
|
|
77
|
+
if group:
|
|
78
|
+
for name in cmd.list_commands(ctx):
|
|
79
|
+
sub = cmd.get_command(ctx, name)
|
|
80
|
+
if sub is not None:
|
|
81
|
+
visit(sub, f"{path} {name}", Context(sub, info_name=name, parent=ctx))
|
|
82
|
+
|
|
83
|
+
visit(root, "mjolnir", Context(root, info_name="mjolnir"))
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_option(p: Any) -> bool:
|
|
88
|
+
return getattr(p, "param_type_name", "") == "option"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _default(p: Any) -> str:
|
|
92
|
+
d = p.default
|
|
93
|
+
if callable(d) or d is None or d == () or d == []:
|
|
94
|
+
return ""
|
|
95
|
+
if _is_option(p) and getattr(p, "is_flag", False) and p.secondary_opts:
|
|
96
|
+
return p.opts[0] if d else p.secondary_opts[0]
|
|
97
|
+
if isinstance(d, bool):
|
|
98
|
+
return "on" if d else "off"
|
|
99
|
+
return f"`{d}`"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _param_row(p: Any) -> list[str] | None:
|
|
103
|
+
if _is_option(p):
|
|
104
|
+
if p.hidden or p.name in ("help",):
|
|
105
|
+
return None
|
|
106
|
+
names = " / ".join(f"`{o}`" for o in [*p.opts, *p.secondary_opts])
|
|
107
|
+
help_ = _rst(p.help or COMMON_HELP.get(p.opts[0], "")).replace("\n", " ").replace("|", "\\|")
|
|
108
|
+
else:
|
|
109
|
+
names = f"`{(p.human_readable_name or p.name or '').upper()}`"
|
|
110
|
+
help_ = "argument"
|
|
111
|
+
return [names, help_, _default(p)]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def reference(app) -> str:
|
|
115
|
+
"""docs/cli.md."""
|
|
116
|
+
cmds = walk(app)
|
|
117
|
+
lines = [
|
|
118
|
+
"# The `mjolnir` CLI reference",
|
|
119
|
+
"",
|
|
120
|
+
"> Generated from the commands themselves by `mjolnir plot --readme` "
|
|
121
|
+
"(`mjolnir_cli/clidoc.py`) — do not edit by hand. `mjolnir <command> --help` prints the same.",
|
|
122
|
+
"",
|
|
123
|
+
"| command | what it does |",
|
|
124
|
+
"|---|---|",
|
|
125
|
+
]
|
|
126
|
+
for c in cmds[1:]:
|
|
127
|
+
anchor = c.path.replace(" ", "-")
|
|
128
|
+
lines.append(f"| [`{c.path}`](#{anchor}) | {c.short.replace('|', chr(92) + '|')} |")
|
|
129
|
+
for c in cmds[1:]:
|
|
130
|
+
lines += ["", f'<a id="{c.path.replace(" ", "-")}"></a>', "", f"## `{c.path}`", ""]
|
|
131
|
+
if c.help:
|
|
132
|
+
lines += [c.help, ""]
|
|
133
|
+
rows = [r for r in (_param_row(p) for p in c.params) if r]
|
|
134
|
+
if rows:
|
|
135
|
+
lines += ["| option | description | default |", "|---|---|---|"]
|
|
136
|
+
lines += ["| " + " | ".join(r) + " |" for r in rows]
|
|
137
|
+
lines.append("")
|
|
138
|
+
if c.group:
|
|
139
|
+
lines.append("Sub-commands: " + ", ".join(f"[`{s.path}`](#{s.path.replace(' ', '-')})"
|
|
140
|
+
for s in cmds if s.path.rsplit(" ", 1)[0] == c.path) + ".") # fmt: skip
|
|
141
|
+
lines.append("")
|
|
142
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def block_commands(block: str) -> list[str]:
|
|
146
|
+
"""Command paths named in a README-style overview block, one per line:
|
|
147
|
+
``mjolnir serve [up|down] # …`` -> ``mjolnir serve up``, ``mjolnir serve
|
|
148
|
+
down``; ``mjolnir bench perf | ab`` -> ``mjolnir bench perf``, ``mjolnir
|
|
149
|
+
bench ab``. Options (``--x``, ``[--x]``), placeholders (``<stage>``) and
|
|
150
|
+
comments are ignored."""
|
|
151
|
+
out: list[str] = []
|
|
152
|
+
for line in block.splitlines():
|
|
153
|
+
line = line.split("#", 1)[0].strip()
|
|
154
|
+
if line != "mjolnir" and not line.startswith("mjolnir "):
|
|
155
|
+
continue
|
|
156
|
+
words = []
|
|
157
|
+
for w in line.split()[1:]:
|
|
158
|
+
if w.startswith(("-", "<", "[-")):
|
|
159
|
+
break
|
|
160
|
+
words.append(w)
|
|
161
|
+
text = " ".join(words)
|
|
162
|
+
sub = re.search(r"\[([^\]]+)\]", text)
|
|
163
|
+
if sub: # mjolnir serve [up|down]: sub-commands of the prefix
|
|
164
|
+
prefix = text[: sub.start()].split()
|
|
165
|
+
out += [" ".join(["mjolnir", *prefix, a]) for a in sub.group(1).split("|")]
|
|
166
|
+
continue
|
|
167
|
+
alts = [a.split() for a in text.split("|")] if text else [[]]
|
|
168
|
+
head = alts[0][:-1] # mjolnir verify bench | all: siblings of the last word
|
|
169
|
+
out += [" ".join(["mjolnir", *head, a[-1]] if a else ["mjolnir"]) for a in alts]
|
|
170
|
+
return list(dict.fromkeys(out))
|
mjolnir_cli/config.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Mjolnir defaults + config resolution.
|
|
2
|
+
|
|
3
|
+
Everything the CLI needs has a baked-in default (Qwen3.8-27B NVFP4 served by
|
|
4
|
+
the Mjolnir image with ``--attention-backend MJOLNIR``); flags on each
|
|
5
|
+
command override them, then the remembered state (``mjolnir model`` /
|
|
6
|
+
``mjolnir image`` → ``$MJOLNIR_STATE``, default ``~/.mjolnir-state.json``),
|
|
7
|
+
then ``$MJOLNIR_*`` env vars. No ``.env`` file required.
|
|
8
|
+
|
|
9
|
+
The CLI runs from a repo checkout (``uv run mjolnir`` — configs, raws and
|
|
10
|
+
charts are the repo's own) or from a PyPI install (``uv tool install
|
|
11
|
+
mjolnir-cli`` — configs and the side-stack files come bundled in the wheel,
|
|
12
|
+
benchmark results land in ``~/.local/share/mjolnir/benchmarks``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
# ── Model / config ───────────────────────────────────────────────────────────
|
|
24
|
+
DEFAULT_MODEL = "Qwen/Qwen3.8-27B"
|
|
25
|
+
# context depths every end-to-end bench (perf / ab / e2e) measures by default:
|
|
26
|
+
# up to 128K (the ncu kernel stages keep their 256K top: verify ALL_LS)
|
|
27
|
+
E2E_DEPTHS: tuple[int, ...] = (0, 4096, 8192, 16384, 32768, 65536, 131072)
|
|
28
|
+
# the fastest Mjolnir config so far (fp8 lm_head + 64K draft vocab, opt-in via its .env):
|
|
29
|
+
# measured next to the default everywhere (verify spec / kv-cache-size / profile, bench ab / accuracy)
|
|
30
|
+
FAST_QUANT = "NVFP4_MJOLNIR_E2M3_LMH8_VT64K"
|
|
31
|
+
DEFAULT_QUANT = "NVFP4_MJOLNIR_E2M3_LMH8_VT64K" # --attention-backend MJOLNIR (this repo's kernel)
|
|
32
|
+
BASELINE_QUANT = "NVFP4" # FlashInfer — the fastest stock backend on Thor
|
|
33
|
+
FA2_QUANT = "NVFP4_FA2" # FlashAttention 2 — vLLM 0.31.0's sm_110 default (bf16 KV)
|
|
34
|
+
SERVED_MODEL_NAME = "Qwen/Qwen3.8-27B"
|
|
35
|
+
|
|
36
|
+
# ── Image / serving ──────────────────────────────────────────────────────────
|
|
37
|
+
# The image tag follows the upstream vLLM version it overlays (see
|
|
38
|
+
# .github/workflows/docker-image.yml): narandill/mjolnir-vllm:v<vllm>.
|
|
39
|
+
IMAGE_REPO = "narandill/mjolnir-vllm"
|
|
40
|
+
VLLM_VERSION = "0.31.0"
|
|
41
|
+
DEFAULT_IMAGE = f"{IMAGE_REPO}:v{VLLM_VERSION}"
|
|
42
|
+
STOCK_IMAGE = f"vllm/vllm-openai:v{VLLM_VERSION}-ubuntu2404"
|
|
43
|
+
DEFAULT_PORT = 6001 # host port; vLLM metrics live on the same port
|
|
44
|
+
CONTAINER_NAME = "mjolnir-vllm"
|
|
45
|
+
|
|
46
|
+
# Friendly backend labels for the history log / charts, keyed by quant config.
|
|
47
|
+
BACKEND_LABELS = {
|
|
48
|
+
"NVFP4_MJOLNIR": "Mjolnir",
|
|
49
|
+
"NVFP4_MJOLNIR_E2M3": "Mjolnir fp6 KV",
|
|
50
|
+
"NVFP4_MJOLNIR_E2M3_LMH8": "Mjolnir fp6 KV + fp8 lm_head",
|
|
51
|
+
"NVFP4_MJOLNIR_E2M3_VT32K": "Mjolnir fp6 KV + 32K draft vocab",
|
|
52
|
+
"NVFP4_MJOLNIR_E2M3_LMH8_VT32K": "Mjolnir fp6 KV + fp8 lm_head + 32K draft vocab",
|
|
53
|
+
"NVFP4_MJOLNIR_E2M3_LMH8_VT64K": "Mjolnir fp6 KV + fp8 lm_head + 64K draft vocab",
|
|
54
|
+
"NVFP4_MJOLNIR_E2M3_DSPARK": "Mjolnir fp6 KV + DSpark",
|
|
55
|
+
"NVFP4_MJOLNIR_E2M3_DFLASH2": "Mjolnir fp6 KV + DFlash2",
|
|
56
|
+
"NVFP4": "FlashInfer",
|
|
57
|
+
"NVFP4_DSPARK": "FlashInfer + DSpark",
|
|
58
|
+
"NVFP4_DFLASH2": "FlashInfer + DFlash2",
|
|
59
|
+
"NVFP4_FA2": "FlashAttention 2",
|
|
60
|
+
"NVFP4_FA4hd256": "FlashAttention 4 hd256",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# The repo marker: a checkout has the image build context.
|
|
64
|
+
_REPO_MARKER = Path("docker") / "vllm-thor" / "Dockerfile"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ConfigEntry:
|
|
69
|
+
"""One model config discovered under ``configs/<vendor>/<model>/<quant>/config.yaml``
|
|
70
|
+
(the repo's / bundled configs, or the user-local dir — see ``source``)."""
|
|
71
|
+
|
|
72
|
+
model: str # "vendor/Model", e.g. "Qwen/Qwen3.8-27B"
|
|
73
|
+
quant: str # config name, e.g. "NVFP4_MJOLNIR"
|
|
74
|
+
path: Path
|
|
75
|
+
source: str = "repo" # "repo" (configs/ or bundled) | "local" (~/.local/share/mjolnir/configs)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def backend(self) -> str:
|
|
79
|
+
return BACKEND_LABELS.get(self.quant, self.quant)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def scan_configs(configs_dir: Path) -> list[ConfigEntry]:
|
|
83
|
+
"""Scan ``configs/<vendor>/<model>/<quant>/config.yaml`` into pickable
|
|
84
|
+
entries. Everything config-specific lives in the config dir: the
|
|
85
|
+
``.env`` sidecar and a drafter's ``kv_scales.json``; the model-level files
|
|
86
|
+
(shared ``kv_scales.json``, ``draft_vocab.json``, chat template) stay
|
|
87
|
+
next to it."""
|
|
88
|
+
out: list[ConfigEntry] = []
|
|
89
|
+
if not configs_dir.is_dir():
|
|
90
|
+
return out
|
|
91
|
+
for p in sorted(configs_dir.glob("*/*/*/config.yaml")):
|
|
92
|
+
out.append(ConfigEntry(model=str(p.parent.parent.relative_to(configs_dir)), quant=p.parent.name, path=p))
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def user_configs_dir() -> Path:
|
|
97
|
+
"""The user-local model-config root: ``~/.local/share/mjolnir/configs``
|
|
98
|
+
(under the project data dir ``MJOLNIR_DATA``); override with
|
|
99
|
+
``$MJOLNIR_CONFIGS_DIR``. Layout: ``<vendor>/<model>/<quant>/config.yaml``."""
|
|
100
|
+
return Path(_env("MJOLNIR_CONFIGS_DIR", str(Path.home() / ".local" / "share" / "mjolnir" / "configs"))).expanduser()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _env(name: str, default: str) -> str:
|
|
104
|
+
return os.environ.get(name, default)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _env_int(name: str, default: int) -> int:
|
|
108
|
+
try:
|
|
109
|
+
return int(os.environ.get(name, default))
|
|
110
|
+
except ValueError:
|
|
111
|
+
return default
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def config_field(path: Path, key: str) -> str | None:
|
|
115
|
+
"""One top-level scalar from a config yaml (None if absent/unreadable)."""
|
|
116
|
+
try:
|
|
117
|
+
data = yaml.safe_load(Path(path).read_text())
|
|
118
|
+
except Exception: # noqa: BLE001
|
|
119
|
+
return None
|
|
120
|
+
if not isinstance(data, dict):
|
|
121
|
+
return None
|
|
122
|
+
v = data.get(key)
|
|
123
|
+
return str(v) if v else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def config_speculative(path: Path) -> dict | None:
|
|
127
|
+
"""The config's speculative decoding as ``{"method": "mtp", "tokens": 3}``
|
|
128
|
+
(None when the config has none or is unreadable)."""
|
|
129
|
+
try:
|
|
130
|
+
data = yaml.safe_load(Path(path).read_text())
|
|
131
|
+
except Exception: # noqa: BLE001
|
|
132
|
+
return None
|
|
133
|
+
spec = data.get("speculative-config") if isinstance(data, dict) else None
|
|
134
|
+
if not isinstance(spec, dict) or not spec.get("num_speculative_tokens"):
|
|
135
|
+
return None
|
|
136
|
+
return {"method": str(spec.get("method", "")), "tokens": int(spec["num_speculative_tokens"])}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def config_drafter_on_mjolnir(path: Path) -> bool:
|
|
140
|
+
"""Does the config run an external drafter (``speculative-config.model``:
|
|
141
|
+
DSpark, DFlash, EAGLE, ...) on the Mjolnir backend? Only when it says so in
|
|
142
|
+
``speculative-config.attention_backend`` (MJOLNIR / CUSTOM): vLLM gives a
|
|
143
|
+
DSpark / DFlash drafter its own backend choice — auto unless set there,
|
|
144
|
+
never the target's. Its layers then need fp6 scales of their own,
|
|
145
|
+
calibrated with the config (``<config>.kv_scales.json``)."""
|
|
146
|
+
try:
|
|
147
|
+
data = yaml.safe_load(Path(path).read_text())
|
|
148
|
+
except Exception: # noqa: BLE001
|
|
149
|
+
return False
|
|
150
|
+
spec = data.get("speculative-config") if isinstance(data, dict) else None
|
|
151
|
+
if not isinstance(spec, dict) or not spec.get("model"):
|
|
152
|
+
return False
|
|
153
|
+
return str(spec.get("attention_backend") or "").upper() in ("MJOLNIR", "CUSTOM")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def config_attention_backend(path: Path) -> str | None:
|
|
157
|
+
"""The attention backend a config selects (``attention-backend`` or
|
|
158
|
+
``attention-config.backend``), upper-cased; None when unset."""
|
|
159
|
+
try:
|
|
160
|
+
data = yaml.safe_load(Path(path).read_text())
|
|
161
|
+
except Exception: # noqa: BLE001
|
|
162
|
+
return None
|
|
163
|
+
if not isinstance(data, dict):
|
|
164
|
+
return None
|
|
165
|
+
v = data.get("attention-backend")
|
|
166
|
+
if not v and isinstance(data.get("attention-config"), dict):
|
|
167
|
+
v = data["attention-config"].get("backend")
|
|
168
|
+
return str(v).upper() if v else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── repo / resources ─────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def bundled_root() -> Path:
|
|
175
|
+
"""Resources shipped inside the wheel (configs, side stacks, HW script)."""
|
|
176
|
+
return Path(__file__).resolve().parent / "bundled"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def find_repo_root() -> Path:
|
|
180
|
+
"""Locate the mjolnir repo: $MJOLNIR_REPO wins, else walk up from CWD
|
|
181
|
+
looking for ``docker/vllm-thor/Dockerfile``. Raises FileNotFoundError
|
|
182
|
+
outside a checkout (PyPI installs then run from bundled resources)."""
|
|
183
|
+
env = os.environ.get("MJOLNIR_REPO")
|
|
184
|
+
if env:
|
|
185
|
+
return Path(env)
|
|
186
|
+
cur = Path.cwd().resolve()
|
|
187
|
+
for cand in [cur, *cur.parents]:
|
|
188
|
+
if (cand / _REPO_MARKER).exists():
|
|
189
|
+
return cand
|
|
190
|
+
raise FileNotFoundError(
|
|
191
|
+
"mjolnir repo not found (run from inside a checkout or set $MJOLNIR_REPO); "
|
|
192
|
+
"this command needs the repo — clone https://github.com/thor-arena/mjolnir"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def repo_root_or_none() -> Path | None:
|
|
197
|
+
try:
|
|
198
|
+
return find_repo_root()
|
|
199
|
+
except FileNotFoundError:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def resource_path(rel: str | Path) -> Path:
|
|
204
|
+
"""A repo resource: the checkout's copy when in a checkout (so edits are
|
|
205
|
+
live), else the copy bundled in the wheel."""
|
|
206
|
+
root = repo_root_or_none()
|
|
207
|
+
if root is not None and (root / rel).exists():
|
|
208
|
+
return root / rel
|
|
209
|
+
return bundled_root() / rel
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def configs_root() -> Path:
|
|
213
|
+
"""The canonical model-config tree (repo ``configs/`` or the bundled copy)."""
|
|
214
|
+
return resource_path("configs")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def scan_all_configs(layout: RepoLayout | None = None) -> list[ConfigEntry]:
|
|
218
|
+
"""Every config the CLI can serve: the repo's (or bundled) ``configs/`` +
|
|
219
|
+
the user-local dir. Local configs never shadow a canonical config with
|
|
220
|
+
the same ``(model, quant)`` — the reviewed copy wins."""
|
|
221
|
+
entries = scan_configs(configs_root())
|
|
222
|
+
seen = {(e.model, e.quant) for e in entries}
|
|
223
|
+
for e in scan_configs(user_configs_dir()):
|
|
224
|
+
if (e.model, e.quant) not in seen:
|
|
225
|
+
entries.append(ConfigEntry(model=e.model, quant=e.quant, path=e.path, source="local"))
|
|
226
|
+
seen.add((e.model, e.quant))
|
|
227
|
+
entries.sort(key=lambda e: (e.model, e.quant))
|
|
228
|
+
return entries
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass
|
|
232
|
+
class RepoLayout:
|
|
233
|
+
"""Where the CLI reads and writes: the checkout (if any), the user data
|
|
234
|
+
dir, and the benchmark tree (raws + history + charts)."""
|
|
235
|
+
|
|
236
|
+
repo_root: Path | None
|
|
237
|
+
data_dir: Path
|
|
238
|
+
state_file: Path
|
|
239
|
+
benchmarks_dir: Path
|
|
240
|
+
history_file: Path
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def is_repo(self) -> bool:
|
|
244
|
+
return self.repo_root is not None
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def raw_dir(self) -> Path:
|
|
248
|
+
return self.benchmarks_dir / "raw"
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def verify_dir(self) -> Path:
|
|
252
|
+
"""Structured verify results: ``raw/verify/<stage>/<run-id>.json``."""
|
|
253
|
+
return self.raw_dir / "verify"
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def charts_dir(self) -> Path:
|
|
257
|
+
base = self.repo_root if self.repo_root is not None else self.benchmarks_dir
|
|
258
|
+
return base / "assets" / "benchmarks"
|
|
259
|
+
|
|
260
|
+
def require_repo(self, what: str) -> Path:
|
|
261
|
+
if self.repo_root is None:
|
|
262
|
+
raise FileNotFoundError(f"{what} needs a mjolnir checkout (run from inside one or set $MJOLNIR_REPO)")
|
|
263
|
+
return self.repo_root
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def load_layout() -> RepoLayout:
|
|
267
|
+
root = repo_root_or_none()
|
|
268
|
+
data = Path(_env("MJOLNIR_DATA", "~/.local/share/mjolnir")).expanduser()
|
|
269
|
+
bench_default = (root / "benchmarks") if root is not None else (data / "benchmarks")
|
|
270
|
+
bench = Path(_env("MJOLNIR_BENCHMARKS", str(bench_default))).expanduser()
|
|
271
|
+
return RepoLayout(
|
|
272
|
+
repo_root=root,
|
|
273
|
+
data_dir=data,
|
|
274
|
+
state_file=Path(_env("MJOLNIR_STATE", str(Path.home() / ".mjolnir-state.json"))),
|
|
275
|
+
benchmarks_dir=bench,
|
|
276
|
+
history_file=Path(_env("MJOLNIR_HISTORY", str(bench / "history.jsonl"))).expanduser(),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@dataclass
|
|
281
|
+
class Settings:
|
|
282
|
+
"""Resolved runtime settings for a command."""
|
|
283
|
+
|
|
284
|
+
model: str = field(default_factory=lambda: _env("MJOLNIR_MODEL", DEFAULT_MODEL))
|
|
285
|
+
quant: str = field(default_factory=lambda: _env("MJOLNIR_QUANT", DEFAULT_QUANT))
|
|
286
|
+
image: str = field(default_factory=lambda: _env("MJOLNIR_IMAGE", DEFAULT_IMAGE))
|
|
287
|
+
port: int = field(default_factory=lambda: _env_int("MJOLNIR_PORT", DEFAULT_PORT))
|
|
288
|
+
data_dir: Path = field(default_factory=lambda: Path("~/.local/share/mjolnir").expanduser())
|
|
289
|
+
label: str | None = None # explicit backend-label override (history log); else derived from quant
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def config_path(self) -> Path:
|
|
293
|
+
"""The resolved config file: the canonical
|
|
294
|
+
``configs/<model>/<quant>/config.yaml`` (repo or bundled) if present,
|
|
295
|
+
else the user-local one, else the canonical path (for the not-found
|
|
296
|
+
error)."""
|
|
297
|
+
canon = configs_root() / self.model / self.quant / "config.yaml"
|
|
298
|
+
if canon.exists():
|
|
299
|
+
return canon
|
|
300
|
+
local = user_configs_dir() / self.model / self.quant / "config.yaml"
|
|
301
|
+
if local.exists():
|
|
302
|
+
return local
|
|
303
|
+
return canon
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def attention_backend(self) -> str | None:
|
|
307
|
+
return config_attention_backend(self.config_path)
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def served_model(self) -> str:
|
|
311
|
+
"""The name the served model answers to: ``served-model-name`` from
|
|
312
|
+
the config yaml, else its ``model`` field (vLLM's default served
|
|
313
|
+
name), else the baked-in constant."""
|
|
314
|
+
for key in ("served-model-name", "model"):
|
|
315
|
+
v = config_field(self.config_path, key)
|
|
316
|
+
if v:
|
|
317
|
+
return v
|
|
318
|
+
return SERVED_MODEL_NAME
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def base_url(self) -> str:
|
|
322
|
+
return f"http://127.0.0.1:{self.port}/v1"
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def metrics_url(self) -> str:
|
|
326
|
+
return f"http://127.0.0.1:{self.port}/metrics"
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def backend_label(self) -> str:
|
|
330
|
+
"""Explicit --label override if set, else the friendly name for the
|
|
331
|
+
quant config (BACKEND_LABELS), else the quant string itself."""
|
|
332
|
+
return self.label or BACKEND_LABELS.get(self.quant, self.quant)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def resolve(cli_model: str | None, cli_quant: str | None, cli_image: str | None, cli_port: int | None) -> Settings:
|
|
336
|
+
"""CLI flags > env vars > defaults."""
|
|
337
|
+
s = Settings()
|
|
338
|
+
if cli_model:
|
|
339
|
+
s.model = cli_model
|
|
340
|
+
if cli_quant:
|
|
341
|
+
s.quant = cli_quant
|
|
342
|
+
if cli_image:
|
|
343
|
+
s.image = cli_image
|
|
344
|
+
if cli_port:
|
|
345
|
+
s.port = cli_port
|
|
346
|
+
return s
|