mjolnir-kernel 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.
mjolnir/banner.py ADDED
@@ -0,0 +1,127 @@
1
+ """The Mjolnir boot banner.
2
+
3
+ Owned by the ``mjolnir`` package (not a vLLM patch): when the Mjolnir
4
+ backend serves a model, ``emit()`` logs the hammer exactly once, with the
5
+ info block (``Mjolnir v<version>``, author, repo, and the serving config
6
+ name when the CLI set ``MJOLNIR_CONFIG``) placed to the art's right,
7
+ vertically centred. It goes through vLLM's own logger, so it follows the
8
+ server's formatter the same way vLLM's own boot logo does (ANSI colour
9
+ only under a color formatter), and it can never break a server's
10
+ start-up. ``python -m mjolnir.banner`` previews it without vLLM.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+
18
+ __all__ = ["ART", "HEAD_START", "banner", "emit"]
19
+
20
+ # The hammer. Committed art — do not re-flow.
21
+ ART = [
22
+ " ▓██",
23
+ " █▒██",
24
+ " █ ░█▒",
25
+ " █ ▓█",
26
+ " ██ █",
27
+ " █ █░",
28
+ " ██ ▒█",
29
+ " █████▓ ██████",
30
+ " █ ▓▒ ██",
31
+ " ▒████ ████▓",
32
+ " ░███",
33
+ ]
34
+ # Rows at/below this index make up the head (brand orange); above it, the handle.
35
+ HEAD_START = 7
36
+
37
+ _HEAD = "\033[1;93m" # bold brand orange (the hammer head)
38
+ _WHITE = "\033[1m" # bold default foreground (handle, author)
39
+ _RESET = "\033[0m"
40
+ _GAP = " "
41
+ _AUTHOR = "by Mieszko Syty (ms1design)"
42
+ _URL = "https://github.com/thor-arena/mjolnir"
43
+
44
+
45
+ def banner(version: str, color: bool = False, model: str | None = None) -> str:
46
+ """Render the banner: the art, with the info block (version, author,
47
+ repo, and ``Model: <config>`` when given) to its right, vertically
48
+ centred against the art. The message is one block of lines (no leading
49
+ or trailing empty line — ``emit()`` renders those as their own empty
50
+ log records, the way the vLLM boot logo's template does). ``color``
51
+ switches on the ANSI codes (head in the brand orange, the rest bold
52
+ white)."""
53
+ info = [f"Mjolnir v{version}", _AUTHOR, _URL]
54
+ if model:
55
+ info.append(f"Model: {model}")
56
+ n, k = len(ART), len(info)
57
+ start = (n - k) // 2
58
+ width = max(len(line) for line in ART)
59
+ rows = []
60
+ for i, line in enumerate(ART):
61
+ idx = i - start
62
+ if not color:
63
+ if 0 <= idx < k:
64
+ rows.append(line.ljust(width) + _GAP + info[idx])
65
+ else:
66
+ rows.append(line)
67
+ continue
68
+ art = line.ljust(width) + _GAP if 0 <= idx < k else line
69
+ segs = [(_HEAD if i >= HEAD_START else _WHITE, art)]
70
+ if 0 <= idx < k:
71
+ if idx == 0:
72
+ segs += [(_HEAD, "Mjolnir"), (_WHITE, f" v{version}")]
73
+ else:
74
+ segs += [(_WHITE, info[idx])]
75
+ rows.append("".join(f"{code}{text}{_RESET}" for code, text in segs))
76
+ return "\n".join(rows)
77
+
78
+
79
+ _shown = False
80
+
81
+
82
+ def emit() -> None:
83
+ """Log the banner once, through vLLM's logger when importable (its
84
+ formatter decides the ANSI colour, exactly like vLLM's own boot logo)
85
+ and a stdlib logger otherwise. The art is one multi-line record; the
86
+ empty lines on top and bottom are rendered as empty-string records
87
+ (vLLM's formatter turns them into bare prefixed lines, like the boot
88
+ logo's tail). The serving config name rides in ``MJOLNIR_CONFIG`` (set
89
+ by the ``mjolnir`` CLI's serve path). Never raises: a banner must not
90
+ be able to break a server's start."""
91
+ global _shown
92
+ if _shown:
93
+ return
94
+ _shown = True
95
+ try:
96
+ from mjolnir import __version__
97
+
98
+ model = os.environ.get("MJOLNIR_CONFIG", "").strip() or None
99
+ try:
100
+ from vllm.logger import current_formatter_type, init_logger
101
+
102
+ lgr = init_logger("vllm.mjolnir")
103
+ color = current_formatter_type(lgr) == "color"
104
+ except ImportError:
105
+ import logging
106
+
107
+ lgr = logging.getLogger("vllm.mjolnir")
108
+ color = sys.stderr.isatty()
109
+ lgr.info("")
110
+ lgr.info("%s", banner(__version__, color=color, model=model))
111
+ lgr.info("")
112
+ except Exception: # noqa: BLE001 - the banner is decoration, not a dependency
113
+ pass
114
+
115
+
116
+ if __name__ == "__main__": # `python -m mjolnir.banner [--plain]`: preview without vLLM
117
+ import mjolnir
118
+
119
+ plain = "--plain" in sys.argv[1:]
120
+ msg = banner(
121
+ mjolnir.__version__,
122
+ color=sys.stdout.isatty() and not plain,
123
+ model=os.environ.get("MJOLNIR_CONFIG", "").strip() or None,
124
+ )
125
+ print("")
126
+ print(msg)
127
+ print("")
mjolnir/calibrate.py ADDED
@@ -0,0 +1,182 @@
1
+ """Calibrate per-(layer, KV head, channel) K/V scales for the fp6 (e2m3) KV cache.
2
+
3
+ Runs the model in-process on vLLM with the Mjolnir backend on **fp8** KV and
4
+ ``MJOLNIR_ATTN_KV_CALIBRATE=1``: every K/V row the backend writes to the
5
+ cache is folded into a running per-channel amax (``mjolnir.kvscales``). After
6
+ the engine's own warm-up and profiling runs the accumulators are zeroed, a
7
+ varied prompt set is generated (prose, code, math, multilingual, long
8
+ contexts), and the result is written as a ``mjolnir.kv_scales/2`` file —
9
+ what ``MJOLNIR_ATTN_KV_SCALES`` points ``--kv-cache-dtype fp6_e2m3`` at.
10
+
11
+ python -m mjolnir.calibrate --config /configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR/config.yaml \\
12
+ --out /out/kv_scales.json --download-dir /data/models/huggingface
13
+ python -m mjolnir.calibrate --model Qwen/Qwen3-0.6B --out /tmp/scales.json # small models / tests
14
+
15
+ The scales cover every attention layer that runs on the Mjolnir backend,
16
+ drafters included (drafting writes its KV too): the built-in MTP head, and an
17
+ external drafter whose ``speculative-config.attention_backend`` is MJOLNIR
18
+ (DSpark: its 5 layers come out as layers 64-68). ``mjolnir calibrate`` runs this in the image and writes a config
19
+ with its own drafter to ``<config>.kv_scales.json``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import dataclasses
26
+ import os
27
+ import sys
28
+ import time
29
+
30
+ TOPICS = [
31
+ "Explain how a transformer's attention mechanism works, step by step, with a small numeric example.",
32
+ "Write a Python function that parses an ISO-8601 timestamp without external libraries, then test it.",
33
+ "def quicksort(xs):\n # complete this implementation and explain its complexity\n",
34
+ "Summarize the causes and consequences of the 1929 stock market crash in five paragraphs.",
35
+ "Solve: a train leaves at 9:40 at 72 km/h, another at 10:05 at 90 km/h on the same track. When do they meet?",
36
+ "Translate into German, French and Polish: 'The quick brown fox jumps over the lazy dog near the river bank.'",
37
+ "Write a SQL query that returns the top three customers by revenue per region, with window functions.",
38
+ "Compose a short story about a lighthouse keeper who finds a message in a bottle.",
39
+ "List the differences between TCP and UDP and when you would use each, as a table.",
40
+ "Prove that the square root of two is irrational.",
41
+ "Describe the architecture of the NVIDIA Blackwell GPU memory hierarchy for a systems engineer.",
42
+ "Rust: implement a thread-safe bounded queue with condition variables; explain the invariants.",
43
+ "What are the main ideas of Kant's Critique of Pure Reason? Answer for a first-year student.",
44
+ "日本の四季について、それぞれの季節の特徴を説明してください。",
45
+ "Напиши краткое руководство по выращиванию томатов на балконе.",
46
+ 'Given the JSON {"a": [1, 2, {"b": null}], "c": "x"}, write a jq expression that extracts b and explain it.',
47
+ ]
48
+
49
+ LONG_SOURCE = (
50
+ "In the long history of computing, memory bandwidth has repeatedly set the pace of progress. "
51
+ "Each generation of accelerators added arithmetic faster than it added bytes per second, and "
52
+ "every workload that streams data once — decoding a language model token by token is one — "
53
+ "became a question of how few bytes it could read. Caches help only when data is reused; a "
54
+ "key-value cache of a long conversation is read in full for every new token, so its format "
55
+ "decides the speed. "
56
+ )
57
+
58
+
59
+ def long_prompt(tokens: int, i: int) -> str:
60
+ """A long context (~`tokens` words) ending in a question about it."""
61
+ words_per_rep = len(LONG_SOURCE.split())
62
+ reps = max(1, tokens // words_per_rep)
63
+ body = "".join(f"[section {k + i}] {LONG_SOURCE}" for k in range(reps))
64
+ return body + "\nQuestion: summarize the argument above in three sentences and name its weakest step."
65
+
66
+
67
+ def _engine_parser():
68
+ from vllm.engine.arg_utils import EngineArgs
69
+
70
+ try:
71
+ from vllm.utils.argparse_utils import FlexibleArgumentParser
72
+ except ImportError: # older vLLM
73
+ from vllm.utils import FlexibleArgumentParser
74
+ return EngineArgs.add_cli_args(FlexibleArgumentParser()), EngineArgs
75
+
76
+
77
+ def engine_kwargs_from_config(path: str) -> dict:
78
+ """A `vllm serve --config` YAML as LLM(...) kwargs, parsed by vLLM's own
79
+ argument parser — so every value gets the type `vllm serve` gives it
80
+ (``16K`` -> 16384, ``kv-cache-dtype-skip-layers: sliding_window`` -> a
81
+ list, nested dicts -> configs). Serve-only keys (port, served-model-name,
82
+ tool parsers, ...) are dropped; only values that differ from the engine
83
+ defaults are returned."""
84
+ import tempfile
85
+
86
+ import yaml
87
+
88
+ parser, engine_args = _engine_parser()
89
+ data = yaml.safe_load(open(path)) or {}
90
+ known = {k: v for k, v in data.items() if "--" + str(k).replace("_", "-") in parser._option_string_actions}
91
+ with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f:
92
+ yaml.safe_dump(known, f)
93
+ try:
94
+ ns = vars(parser.parse_args(["--config", f.name]))
95
+ finally:
96
+ os.unlink(f.name)
97
+ base = vars(parser.parse_args([]))
98
+ fields = {fld.name for fld in dataclasses.fields(engine_args)}
99
+ kw = {}
100
+ for k, v in ns.items():
101
+ if k not in fields:
102
+ continue
103
+ try:
104
+ same = v == base.get(k)
105
+ except Exception: # noqa: BLE001 - uncomparable config objects: keep the value
106
+ same = False
107
+ if not same:
108
+ kw[k] = v
109
+ return kw
110
+
111
+
112
+ def main(argv=None) -> int:
113
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
114
+ src = ap.add_mutually_exclusive_group(required=True)
115
+ src.add_argument("--config", help="vllm serve YAML (the serving config to calibrate)")
116
+ src.add_argument("--model", help="a model id (no config: defaults for everything else)")
117
+ ap.add_argument("--out", required=True, help="where to write the mjolnir.kv_scales/2 JSON")
118
+ ap.add_argument("--download-dir", default=None)
119
+ ap.add_argument("--max-model-len", type=int, default=32768)
120
+ ap.add_argument("--gpu-mem", type=float, default=None, help="gpu_memory_utilization (default: the config's)")
121
+ ap.add_argument("--prompts", type=int, default=48, help="short prompts (cycled through the topic list)")
122
+ ap.add_argument("--long", type=int, default=6, help="long-context prompts")
123
+ ap.add_argument("--long-tokens", type=int, default=6000, help="approx. words per long prompt")
124
+ ap.add_argument("--max-tokens", type=int, default=96, help="tokens generated per prompt")
125
+ args = ap.parse_args(argv)
126
+
127
+ os.environ["MJOLNIR_ATTN_KV_CALIBRATE"] = "1"
128
+ os.environ.setdefault("VLLM_ENABLE_V1_MULTIPROCESSING", "0") # the accumulators live in this process
129
+ import mjolnir
130
+ from mjolnir import kvscales
131
+
132
+ mjolnir.register()
133
+ from vllm import LLM, SamplingParams
134
+
135
+ kw = engine_kwargs_from_config(args.config) if args.config else {"model": args.model, "dtype": "bfloat16"}
136
+ for k in ("served_model_name",):
137
+ kw.pop(k, None)
138
+ kw.update(
139
+ kv_cache_dtype="fp8", # calibrate on the fp8 path; the K/V are observed before quantization
140
+ enforce_eager=True,
141
+ max_model_len=min(int(kw.get("max_model_len") or args.max_model_len), args.max_model_len),
142
+ seed=0,
143
+ )
144
+ kw["attention_backend"] = "MJOLNIR" if mjolnir.has_native_member() else "CUSTOM"
145
+ kw.pop("attention_config", None)
146
+ if args.gpu_mem is not None:
147
+ kw["gpu_memory_utilization"] = args.gpu_mem
148
+ if args.download_dir:
149
+ kw["download_dir"] = args.download_dir
150
+ print(f"calibrate: LLM({', '.join(f'{k}={v!r}' for k, v in sorted(kw.items()))})", flush=True)
151
+ try:
152
+ llm = LLM(**kw)
153
+ except TypeError:
154
+ backend = kw.pop("attention_backend")
155
+ llm = LLM(attention_config={"backend": backend}, **kw)
156
+
157
+ kvscales.reset() # drop what the warm-up / profiling runs wrote
158
+ prompts = [TOPICS[i % len(TOPICS)] for i in range(args.prompts)]
159
+ budget = max(256, kw["max_model_len"] - args.max_tokens - 64)
160
+ prompts += [long_prompt(min(args.long_tokens, budget * 3 // 4), i) for i in range(args.long)]
161
+ sp = SamplingParams(temperature=0.8, top_p=0.95, max_tokens=args.max_tokens, seed=0)
162
+ t0 = time.time()
163
+ outs = llm.generate(prompts, sp)
164
+ toks = sum(len(o.prompt_token_ids) + len(o.outputs[0].token_ids) for o in outs)
165
+ doc = kvscales.dump(args.out, model=str(kw.get("model", "")))
166
+ if not doc["amax"]:
167
+ print("calibrate: no K/V was observed — did the Mjolnir backend run?", file=sys.stderr)
168
+ return 1
169
+ print(f"calibrate: {len(prompts)} prompts, {toks} tokens in {time.time() - t0:.0f}s", flush=True)
170
+ for name, e in doc["amax"].items():
171
+ k_max, v_max = max(max(r) for r in e["k"]), max(max(r) for r in e["v"])
172
+ k_med = sorted(x for r in e["k"] for x in r)[len(e["k"]) * len(e["k"][0]) // 2]
173
+ print(
174
+ f" {name}: K amax {k_max:8.3f} (median channel {k_med:7.3f}) V amax {v_max:8.3f}"
175
+ f" ({len(e['k'])} heads x {len(e['k'][0])} channels)"
176
+ )
177
+ print(f"wrote {args.out} ({len(doc['amax'])} layers)")
178
+ return 0
179
+
180
+
181
+ if __name__ == "__main__":
182
+ raise SystemExit(main())
mjolnir/draftvocab.py ADDED
@@ -0,0 +1,150 @@
1
+ """Count which tokens the target model generates — the drafter's vocabulary
2
+ for VocabTrim (``MJOLNIR_DRAFT_VOCAB``, see ``mjolnir.lmhead``).
3
+
4
+ Runs the serving config in-process (vLLM ``LLM``), samples a varied prompt
5
+ set (several completions per prompt, temperature 0.7), counts the generated
6
+ token ids and writes them most frequent first, with the coverage — the share
7
+ of generated tokens inside the top-K — for a few K. Coverage is measured
8
+ held out (ranking from half the prompts, scored on the other half), since the
9
+ in-sample number is 100% as soon as K passes the distinct-token count. Ids the
10
+ run never generated follow in id order (BPE merge rank), so a top-K always has
11
+ K tokens. VocabTrim found the target's own generations the best source
12
+ (arXiv 2506.22694).
13
+
14
+ python -m mjolnir.draftvocab --config /configs/Qwen/Qwen3.8-27B/NVFP4_MJOLNIR_E2M3/config.yaml \\
15
+ --out /out/draft_vocab.json --download-dir /data/models/huggingface
16
+
17
+ File: ``{"format": "mjolnir.draft_vocab/1", "model": ..., "generated_tokens": N,
18
+ "coverage": {"32768": 0.997, ...}, "tokens": [ids, most frequent first]}``.
19
+ Special tokens (EOS, the chat / think markers) are always included.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+ import time
29
+ from collections import Counter
30
+
31
+ FORMAT = "mjolnir.draft_vocab/1"
32
+ COVERAGE_K = (4096, 8192, 16384, 32768, 49152, 65536)
33
+
34
+ EXTRA_TOPICS = [
35
+ "Write a detailed README for a command-line tool that benchmarks GPUs.",
36
+ "Explain gradient descent to a high-school student, then give the math.",
37
+ "Write TypeScript code for a debounced search box in React, with tests.",
38
+ "Summarize the plot of a classic novel of your choice and analyze its themes.",
39
+ "Give a step-by-step recipe for sourdough bread with timings.",
40
+ "Compare PostgreSQL and SQLite for an embedded device; answer as a table and a recommendation.",
41
+ "Write a formal email declining a meeting and proposing two alternative times.",
42
+ "Debug this Python: `for i in range(10): print(i/0)` — explain the error and fix it.",
43
+ "Describe the water cycle in Spanish, then in English.",
44
+ "Write a C function that reverses a linked list in place; explain each line.",
45
+ "List ten interview questions for a senior backend engineer with model answers.",
46
+ "Explain how TLS 1.3 handshakes work, with a sequence of messages.",
47
+ ]
48
+
49
+
50
+ def rank(counts: Counter, special: set, vocab: int) -> list[int]:
51
+ """Special tokens, then generated ids by count, then the rest in id order."""
52
+ out = [t for t in sorted(special) if t not in counts] + [t for t, _ in counts.most_common()]
53
+ seen = set(out)
54
+ return out + [t for t in range(vocab) if t not in seen]
55
+
56
+
57
+ def held_out_coverage(train: Counter, test: Counter, special: set, vocab: int) -> dict[str, float]:
58
+ """Share of ``test``'s tokens inside the top-K of a ranking built from ``train``."""
59
+ total = sum(test.values())
60
+ if not total:
61
+ return {str(k): 0.0 for k in COVERAGE_K}
62
+ order = rank(train, special, vocab)
63
+ pos = {t: i for i, t in enumerate(order)}
64
+ out = {}
65
+ for k in COVERAGE_K:
66
+ out[str(k)] = round(sum(c for t, c in test.items() if pos.get(t, vocab) < k) / total, 5)
67
+ return out
68
+
69
+
70
+ def main(argv=None) -> int:
71
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
72
+ src = ap.add_mutually_exclusive_group(required=True)
73
+ src.add_argument("--config", help="vllm serve YAML")
74
+ src.add_argument("--model", help="a model id")
75
+ ap.add_argument("--out", required=True)
76
+ ap.add_argument("--download-dir", default=None)
77
+ ap.add_argument("--max-model-len", type=int, default=16384)
78
+ ap.add_argument("--prompts", type=int, default=192)
79
+ ap.add_argument("--samples", type=int, default=4, help="completions per prompt")
80
+ ap.add_argument("--max-tokens", type=int, default=768)
81
+ ap.add_argument("--long", type=int, default=4, help="long-context prompts")
82
+ args = ap.parse_args(argv)
83
+
84
+ os.environ.setdefault("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
85
+ for k in ("MJOLNIR_DRAFT_VOCAB", "MJOLNIR_LM_HEAD"): # count with the plain heads
86
+ os.environ.pop(k, None)
87
+ import mjolnir
88
+ from mjolnir.calibrate import TOPICS, engine_kwargs_from_config, long_prompt
89
+
90
+ mjolnir.register()
91
+ from vllm import LLM, SamplingParams
92
+
93
+ kw = engine_kwargs_from_config(args.config) if args.config else {"model": args.model, "dtype": "bfloat16"}
94
+ kw.pop("served_model_name", None)
95
+ kw["max_model_len"] = min(int(kw.get("max_model_len") or args.max_model_len), args.max_model_len)
96
+ if args.download_dir:
97
+ kw["download_dir"] = args.download_dir
98
+ print(f"draftvocab: LLM({', '.join(f'{k}={v!r}' for k, v in sorted(kw.items()))})", flush=True)
99
+ llm = LLM(**kw)
100
+ topics = TOPICS + EXTRA_TOPICS
101
+ prompts = [topics[i % len(topics)] for i in range(args.prompts)]
102
+ prompts += [long_prompt(min(4000, kw["max_model_len"] // 2), i) for i in range(args.long)]
103
+ msgs = [[{"role": "user", "content": p}] for p in prompts]
104
+ sp = SamplingParams(temperature=0.7, top_p=0.95, max_tokens=args.max_tokens, n=args.samples, seed=0)
105
+ t0 = time.time()
106
+ outs = llm.chat(msgs, sp)
107
+ counts: Counter = Counter()
108
+ halves: tuple[Counter, Counter] = (Counter(), Counter())
109
+ for i, o in enumerate(outs):
110
+ for c in o.outputs:
111
+ counts.update(c.token_ids)
112
+ halves[i % 2].update(c.token_ids)
113
+ total = sum(counts.values())
114
+ if not total:
115
+ print("draftvocab: nothing generated", file=sys.stderr)
116
+ return 1
117
+ tok = llm.get_tokenizer()
118
+ special = set(getattr(tok, "all_special_ids", []) or [])
119
+ try:
120
+ special |= set(tok.get_added_vocab().values())
121
+ except Exception: # noqa: BLE001
122
+ pass
123
+ vocab = len(tok)
124
+ ranked = rank(counts, special, vocab)
125
+ coverage = held_out_coverage(halves[0], halves[1], special, vocab)
126
+ doc = {
127
+ "format": FORMAT,
128
+ "model": str(kw.get("model", "")),
129
+ "generated_tokens": total,
130
+ "distinct_tokens": len(counts),
131
+ "coverage": coverage,
132
+ "coverage_kind": "held-out",
133
+ "measured_tokens": len(counts),
134
+ "tokens": ranked,
135
+ }
136
+ os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
137
+ with open(args.out, "w") as f:
138
+ json.dump(doc, f)
139
+ print(
140
+ f"draftvocab: {len(prompts)} prompts x {args.samples}, {total} tokens generated in {time.time() - t0:.0f}s, "
141
+ f"{len(counts)} distinct"
142
+ )
143
+ for k in COVERAGE_K:
144
+ print(f" top {k:>6}: {coverage[str(k)] * 100:6.2f}% of held-out generated tokens")
145
+ print(f"wrote {args.out}")
146
+ return 0
147
+
148
+
149
+ if __name__ == "__main__":
150
+ raise SystemExit(main())