innards 0.0.1__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.
- innards/__init__.py +79 -0
- innards/__main__.py +7 -0
- innards/_env.py +78 -0
- innards/_text.py +76 -0
- innards/cli.py +308 -0
- innards/collectors/__init__.py +6 -0
- innards/collectors/memory.py +85 -0
- innards/collectors/timing.py +32 -0
- innards/experts/__init__.py +43 -0
- innards/experts/analysis.py +513 -0
- innards/experts/routing.py +446 -0
- innards/experts/trace.py +679 -0
- innards/features/__init__.py +72 -0
- innards/features/base.py +359 -0
- innards/features/deltanet.py +321 -0
- innards/features/gates.py +157 -0
- innards/features/kv.py +238 -0
- innards/features/layout.py +371 -0
- innards/features/mhc.py +240 -0
- innards/features/moe.py +231 -0
- innards/features/registry.py +199 -0
- innards/kv/__init__.py +26 -0
- innards/kv/calculator.py +287 -0
- innards/kv/config.py +649 -0
- innards/kv/dtypes.py +56 -0
- innards/lineage/__init__.py +200 -0
- innards/lineage/configs/Qwen__Qwen3-0.6B.json +30 -0
- innards/lineage/configs/Qwen__Qwen3-1.7B.json +30 -0
- innards/lineage/configs/Qwen__Qwen3.5-0.8B.json +96 -0
- innards/lineage/configs/Qwen__Qwen3.5-2B.json +96 -0
- innards/lineage/configs/ibm-granite__granite-3.1-3b-a800m-instruct.json +35 -0
- innards/lineage/configs/ibm-granite__granite-swash-3b-a600m.json +70 -0
- innards/lineage/configs/sources.json +56 -0
- innards/lineage/data/compressed.json +18 -0
- innards/lineage/data/dsa.json +18 -0
- innards/lineage/data/fp8_kv.json +16 -0
- innards/lineage/data/gqa.json +19 -0
- innards/lineage/data/hybrid.json +20 -0
- innards/lineage/data/mha.json +19 -0
- innards/lineage/data/mla.json +18 -0
- innards/lineage/data/mqa.json +19 -0
- innards/lineage/data/sliding_window.json +19 -0
- innards/lineage/data/ssm.json +18 -0
- innards/lineage/lineup.json +79 -0
- innards/lineage/lineup.py +290 -0
- innards/observer/__init__.py +30 -0
- innards/observer/cache.py +304 -0
- innards/observer/events.py +74 -0
- innards/observer/observer.py +291 -0
- innards/observer/probes.py +204 -0
- innards/observer/queue.py +62 -0
- innards/observer/watch.py +904 -0
- innards/py.typed +0 -0
- innards/schema.py +315 -0
- innards/sets.py +289 -0
- innards/sinks/__init__.py +85 -0
- innards/sinks/console.py +95 -0
- innards/store.py +241 -0
- innards/sync.py +126 -0
- innards/validate.py +562 -0
- innards/view/__init__.py +111 -0
- innards/view/analysis.py +235 -0
- innards/view/app.py +497 -0
- innards/view/experts.py +292 -0
- innards/view/runner.py +152 -0
- innards/view/static/app.js +1031 -0
- innards/view/static/index.html +138 -0
- innards/view/static/style.css +219 -0
- innards/view/static/vendor/PLOTLY-LICENSE +21 -0
- innards/view/static/vendor/plotly-4.1.1.min.js +3620 -0
- innards-0.0.1.dist-info/METADATA +401 -0
- innards-0.0.1.dist-info/RECORD +75 -0
- innards-0.0.1.dist-info/WHEEL +4 -0
- innards-0.0.1.dist-info/entry_points.txt +3 -0
- innards-0.0.1.dist-info/licenses/LICENSE +201 -0
innards/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Innards: see inside open-weight models while they run.
|
|
2
|
+
|
|
3
|
+
Your code loads the model; Innards watches it::
|
|
4
|
+
|
|
5
|
+
import innards
|
|
6
|
+
|
|
7
|
+
watch = innards.observe(model, tokenizer, model_id="Qwen/Qwen3.5-0.8B", run="deltanet-study")
|
|
8
|
+
out = model.generate(**inputs, max_new_tokens=256) # one turn
|
|
9
|
+
watch.view() # the web view on this machine
|
|
10
|
+
|
|
11
|
+
Feature analyzers run automatically for the features the model has, and can be called explicitly::
|
|
12
|
+
|
|
13
|
+
from innards import GatedDeltaNet
|
|
14
|
+
GatedDeltaNet(model="Qwen/Qwen3.5-0.8B", args=watch.last_turn())
|
|
15
|
+
|
|
16
|
+
For MoE models, :mod:`innards.experts` records which experts each token and topic activates, during
|
|
17
|
+
inference and training, and which experts a topic needs::
|
|
18
|
+
|
|
19
|
+
trace = innards.experts.trace(model, tokenizer, run="topic-experts")
|
|
20
|
+
with trace.topic("math"):
|
|
21
|
+
model.generate(**inputs, max_new_tokens=64)
|
|
22
|
+
trace.map().select("math", coverage=0.9)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
__version__ = version("innards")
|
|
30
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree
|
|
31
|
+
__version__ = "0.0.0"
|
|
32
|
+
|
|
33
|
+
from innards import experts
|
|
34
|
+
from innards.features import (
|
|
35
|
+
CSAHCA,
|
|
36
|
+
DSA,
|
|
37
|
+
MLA,
|
|
38
|
+
GatedAttention,
|
|
39
|
+
GatedDeltaNet,
|
|
40
|
+
KVContext,
|
|
41
|
+
MoE,
|
|
42
|
+
mHC,
|
|
43
|
+
)
|
|
44
|
+
from innards.observer.watch import CapturedTurn, Watch, observe
|
|
45
|
+
from innards.schema import ConversationRecord, TurnRecord
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"CSAHCA",
|
|
49
|
+
"DSA",
|
|
50
|
+
"MLA",
|
|
51
|
+
"CapturedTurn",
|
|
52
|
+
"ConversationRecord",
|
|
53
|
+
"GatedAttention",
|
|
54
|
+
"GatedDeltaNet",
|
|
55
|
+
"KVContext",
|
|
56
|
+
"MoE",
|
|
57
|
+
"TurnRecord",
|
|
58
|
+
"Watch",
|
|
59
|
+
"__version__",
|
|
60
|
+
"experts",
|
|
61
|
+
"mHC",
|
|
62
|
+
"observe",
|
|
63
|
+
"view",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def view(
|
|
68
|
+
root: str | None = None,
|
|
69
|
+
*,
|
|
70
|
+
host: str = "127.0.0.1",
|
|
71
|
+
port: int = 8000,
|
|
72
|
+
runner: Any = None,
|
|
73
|
+
open_browser: bool = False,
|
|
74
|
+
block: bool = True,
|
|
75
|
+
) -> object:
|
|
76
|
+
"""Serve the web view over a folder of runs (default ``./innards_runs``). Needs ``innards[view]``."""
|
|
77
|
+
from innards.view import serve
|
|
78
|
+
|
|
79
|
+
return serve(root=root, runner=runner, host=host, port=port, open_browser=open_browser, block=block)
|
innards/__main__.py
ADDED
innards/_env.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Versions and host facts recorded with every run, and the device helpers ``observe`` uses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import socket
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
__all__ = ["DEFAULT_BUDGET_GB", "budget_bytes", "device_name", "environment", "host_kind"]
|
|
11
|
+
|
|
12
|
+
DEFAULT_BUDGET_GB = 16.0
|
|
13
|
+
"""The memory budget Innards checks against (GPU memory, or unified memory on Apple silicon), unless
|
|
14
|
+
``budget_gb`` or ``$INNARDS_GPU_BUDGET_GB`` says otherwise. 16 GB fits a local machine running models at 16 bit."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def budget_bytes(budget_gb: float | None = None) -> int:
|
|
18
|
+
value = budget_gb if budget_gb is not None else float(os.environ.get("INNARDS_GPU_BUDGET_GB") or DEFAULT_BUDGET_GB)
|
|
19
|
+
if value <= 0:
|
|
20
|
+
raise ValueError("The memory budget must be positive")
|
|
21
|
+
return int(value * 1e9)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def host_kind() -> str:
|
|
25
|
+
if os.environ.get("INNARDS_HOST"):
|
|
26
|
+
return os.environ["INNARDS_HOST"]
|
|
27
|
+
if os.environ.get("KAGGLE_KERNEL_RUN_TYPE"):
|
|
28
|
+
return "kaggle"
|
|
29
|
+
if os.environ.get("COLAB_RELEASE_TAG"):
|
|
30
|
+
return "colab"
|
|
31
|
+
if os.environ.get("SM_CURRENT_HOST") or os.environ.get("SAGEMAKER_INTERNAL_IMAGE_URI"):
|
|
32
|
+
return "sagemaker"
|
|
33
|
+
if os.environ.get("AWS_EXECUTION_ENV") or os.environ.get("ECS_CONTAINER_METADATA_URI"):
|
|
34
|
+
return "aws"
|
|
35
|
+
return "local"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def device_name(device: Any) -> str:
|
|
39
|
+
"""``NVIDIA H200``, ``mps (arm64)`` or ``cpu (x86_64)``."""
|
|
40
|
+
kind = getattr(device, "type", str(device))
|
|
41
|
+
if kind == "cuda":
|
|
42
|
+
try:
|
|
43
|
+
import torch
|
|
44
|
+
|
|
45
|
+
index = device.index if getattr(device, "index", None) is not None else torch.cuda.current_device()
|
|
46
|
+
return str(torch.cuda.get_device_name(index))
|
|
47
|
+
except Exception:
|
|
48
|
+
return "cuda"
|
|
49
|
+
return f"{kind} ({platform.machine()})"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def environment() -> dict[str, Any]:
|
|
53
|
+
"""Versions and host facts, recorded once per run and per conversation."""
|
|
54
|
+
from innards import __version__
|
|
55
|
+
|
|
56
|
+
env: dict[str, Any] = {
|
|
57
|
+
"innards": __version__,
|
|
58
|
+
"python": platform.python_version(),
|
|
59
|
+
"platform": platform.platform(),
|
|
60
|
+
"machine": platform.machine(),
|
|
61
|
+
"host": socket.gethostname(),
|
|
62
|
+
"host_kind": host_kind(),
|
|
63
|
+
}
|
|
64
|
+
try:
|
|
65
|
+
import torch
|
|
66
|
+
|
|
67
|
+
env["torch"] = torch.__version__
|
|
68
|
+
env["cuda"] = bool(torch.cuda.is_available())
|
|
69
|
+
env["mps"] = bool(getattr(torch.backends, "mps", None) and torch.backends.mps.is_available())
|
|
70
|
+
except ImportError:
|
|
71
|
+
pass
|
|
72
|
+
try:
|
|
73
|
+
import transformers
|
|
74
|
+
|
|
75
|
+
env["transformers"] = transformers.__version__
|
|
76
|
+
except ImportError:
|
|
77
|
+
pass
|
|
78
|
+
return env
|
innards/_text.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Incremental detokenization for streaming, with stop-string hold-back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
|
|
7
|
+
__all__ = ["IncrementalDecoder", "strip_stop"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def strip_stop(text: str, stop_strings: Sequence[str]) -> tuple[str, bool]:
|
|
11
|
+
"""Cut ``text`` at the first stop string. Returns ``(text, stopped)``."""
|
|
12
|
+
cut = min((i for i in (text.find(s) for s in stop_strings if s) if i >= 0), default=-1)
|
|
13
|
+
return (text[:cut], True) if cut >= 0 else (text, False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IncrementalDecoder:
|
|
17
|
+
"""Turn a growing list of token ids into text deltas.
|
|
18
|
+
|
|
19
|
+
Decodes a short window (prefix/read offsets) instead of the whole sequence,
|
|
20
|
+
holds back incomplete UTF-8 sequences, and holds back any tail that could be
|
|
21
|
+
the start of a stop string.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, decode: Callable[[list[int]], str], stop_strings: Sequence[str] = ()) -> None:
|
|
25
|
+
self._decode = decode
|
|
26
|
+
self._stop = [s for s in stop_strings if s]
|
|
27
|
+
self._ids: list[int] = []
|
|
28
|
+
self._prefix = 0
|
|
29
|
+
self._read = 0
|
|
30
|
+
self._decoded = "" # all text decoded so far
|
|
31
|
+
self._released = 0 # characters of _decoded handed to the caller
|
|
32
|
+
self.stopped = False
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def text(self) -> str:
|
|
36
|
+
"""Full text so far, cut at the first stop string."""
|
|
37
|
+
return strip_stop(self._decoded, self._stop)[0]
|
|
38
|
+
|
|
39
|
+
def _hold(self, text: str) -> int:
|
|
40
|
+
"""Length of the longest suffix of ``text`` that is a proper prefix of a stop string."""
|
|
41
|
+
best = 0
|
|
42
|
+
for stop in self._stop:
|
|
43
|
+
for n in range(min(len(stop) - 1, len(text)), 0, -1):
|
|
44
|
+
if text.endswith(stop[:n]):
|
|
45
|
+
best = max(best, n)
|
|
46
|
+
break
|
|
47
|
+
return best
|
|
48
|
+
|
|
49
|
+
def push(self, ids: Sequence[int]) -> str:
|
|
50
|
+
if self.stopped:
|
|
51
|
+
return ""
|
|
52
|
+
self._ids.extend(ids)
|
|
53
|
+
prefix_text = self._decode(self._ids[self._prefix : self._read])
|
|
54
|
+
new_text = self._decode(self._ids[self._prefix :])
|
|
55
|
+
if len(new_text) > len(prefix_text) and not new_text.endswith("�"):
|
|
56
|
+
self._decoded += new_text[len(prefix_text) :]
|
|
57
|
+
self._prefix, self._read = self._read, len(self._ids)
|
|
58
|
+
return self._release(final=False)
|
|
59
|
+
|
|
60
|
+
def flush(self) -> str:
|
|
61
|
+
if not self.stopped and self._read < len(self._ids):
|
|
62
|
+
tail = self._decode(self._ids[self._prefix :])[len(self._decode(self._ids[self._prefix : self._read])) :]
|
|
63
|
+
self._decoded += tail
|
|
64
|
+
self._read = len(self._ids)
|
|
65
|
+
return self._release(final=True)
|
|
66
|
+
|
|
67
|
+
def _release(self, final: bool) -> str:
|
|
68
|
+
text, stopped = strip_stop(self._decoded, self._stop)
|
|
69
|
+
if stopped:
|
|
70
|
+
self.stopped = True
|
|
71
|
+
end = len(text)
|
|
72
|
+
else:
|
|
73
|
+
end = len(text) if final else len(text) - self._hold(text)
|
|
74
|
+
out = text[self._released : end] if end > self._released else ""
|
|
75
|
+
self._released = max(self._released, end)
|
|
76
|
+
return out
|
innards/cli.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Command line.
|
|
2
|
+
|
|
3
|
+
innards view [folder] the web view over a folder of runs (or one run folder)
|
|
4
|
+
innards validate <model> [--device --set] the before-download checks for this machine
|
|
5
|
+
innards predict --model <id> --context <n> KV size from the config, no weights
|
|
6
|
+
innards explain <family | model> what an architecture changed from its predecessor
|
|
7
|
+
innards lineup the pairs and what each newer model changed
|
|
8
|
+
innards sync up <run> s3://bucket/prefix copy a run folder to S3 (and ``sync down`` back)
|
|
9
|
+
innards experts [folder] [--topic T] MoE routing traces: top experts and the set a topic needs
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from innards import __version__
|
|
22
|
+
|
|
23
|
+
__all__ = ["main"]
|
|
24
|
+
|
|
25
|
+
_UNITS = {"": 1, "b": 1, "k": 1000, "kb": 1000, "m": 10**6, "mb": 10**6, "g": 10**9, "gb": 10**9, "t": 10**12}
|
|
26
|
+
_UNITS |= {"tb": 10**12, "kib": 2**10, "mib": 2**20, "gib": 2**30, "tib": 2**40}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_bytes(text: str) -> int:
|
|
30
|
+
"""``"16GiB"``, ``"8 GB"``, ``"512m"`` or a plain integer."""
|
|
31
|
+
match = re.fullmatch(r"\s*([\d.]+)\s*([a-zA-Z]*)\s*", text)
|
|
32
|
+
if not match or match.group(2).lower() not in _UNITS:
|
|
33
|
+
raise argparse.ArgumentTypeError(f"not a size: {text!r} (try 16GiB, 8GB or 1073741824)")
|
|
34
|
+
return int(float(match.group(1)) * _UNITS[match.group(2).lower()])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
prog="innards", description="See inside open-weight models: KV cache, context growth and memory per message."
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument("--version", action="version", version=f"innards {__version__}")
|
|
42
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
43
|
+
|
|
44
|
+
view = sub.add_parser("view", help="Serve the web view over a folder of runs")
|
|
45
|
+
view.add_argument(
|
|
46
|
+
"root", nargs="?", default=None, help="Folder of runs, or one run folder (default ./innards_runs)"
|
|
47
|
+
)
|
|
48
|
+
view.add_argument("--host", default="127.0.0.1", help="Address to bind (default 127.0.0.1; use an SSH tunnel)")
|
|
49
|
+
view.add_argument("--port", type=int, default=8000)
|
|
50
|
+
view.add_argument("--open", action="store_true", help="Open the page in a browser")
|
|
51
|
+
|
|
52
|
+
validate = sub.add_parser("validate", help="Check a model against this machine before downloading it")
|
|
53
|
+
validate.add_argument("model", help="Hub repo id or local folder")
|
|
54
|
+
validate.add_argument("--device", default=None, help="cuda, mps or cpu (default: the best available)")
|
|
55
|
+
validate.add_argument("--precision", default=None, help="auto (published), bf16, fp16, fp32 ...")
|
|
56
|
+
validate.add_argument("--set", default=None, help="Conversation set that sizes the KV check")
|
|
57
|
+
validate.add_argument(
|
|
58
|
+
"--budget-gb", type=float, default=None, help="Memory budget (default 16 or $INNARDS_GPU_BUDGET_GB)"
|
|
59
|
+
)
|
|
60
|
+
validate.add_argument("--json", action="store_true")
|
|
61
|
+
|
|
62
|
+
lineup = sub.add_parser("lineup", help="The lineup: pairs and what each newer model changed")
|
|
63
|
+
lineup.add_argument("--json", action="store_true")
|
|
64
|
+
|
|
65
|
+
sync = sub.add_parser("sync", help="Copy run folders to or from S3 (needs innards[aws])")
|
|
66
|
+
sync_sub = sync.add_subparsers(dest="direction", required=True)
|
|
67
|
+
up = sync_sub.add_parser("up", help="Upload a run folder: innards sync up <run> s3://bucket/prefix")
|
|
68
|
+
up.add_argument("run")
|
|
69
|
+
up.add_argument("url")
|
|
70
|
+
up.add_argument("--root", default=None, help="Folder of runs (default ./innards_runs)")
|
|
71
|
+
down = sync_sub.add_parser("down", help="Download runs: innards sync down s3://bucket/prefix[/run]")
|
|
72
|
+
down.add_argument("url")
|
|
73
|
+
down.add_argument("--root", default=None, help="Folder of runs (default ./innards_runs)")
|
|
74
|
+
|
|
75
|
+
predict = sub.add_parser("predict", help="Predict KV-cache size from a model config (no weights)")
|
|
76
|
+
predict.add_argument("--model", required=True, help="Hub repo id, local path or config.json")
|
|
77
|
+
predict.add_argument("--context", type=int, required=True, help="Context length in tokens")
|
|
78
|
+
predict.add_argument(
|
|
79
|
+
"--kv-dtype", default="float16", help="float32, float16, bfloat16 or float8 (default: float16)"
|
|
80
|
+
)
|
|
81
|
+
predict.add_argument("--memory", type=parse_bytes, default=None, help="KV memory budget for capacity, e.g. 16GiB")
|
|
82
|
+
predict.add_argument("--batch-size", type=int, default=1)
|
|
83
|
+
predict.add_argument("--revision", default=None, help="Hub revision")
|
|
84
|
+
predict.add_argument("--json", action="store_true", help="Print the prediction as JSON")
|
|
85
|
+
|
|
86
|
+
experts = sub.add_parser("experts", help="MoE routing traces: which experts a topic or token activates")
|
|
87
|
+
experts.add_argument(
|
|
88
|
+
"path", nargs="?", default=None, help="Folder of runs, a run folder or a trace folder (default ./innards_runs)"
|
|
89
|
+
)
|
|
90
|
+
experts.add_argument("--topic", default=None, help="Topic to show (default: list the traces and topics)")
|
|
91
|
+
experts.add_argument("--by", default="selection", choices=["selection", "gate", "lift"], help="Ranking measure")
|
|
92
|
+
experts.add_argument("--layer", type=int, action="append", default=None, help="Only these MoE layers")
|
|
93
|
+
experts.add_argument("--top", type=int, default=8, help="Experts to show per layer (default 8)")
|
|
94
|
+
experts.add_argument(
|
|
95
|
+
"--select", type=float, default=None, metavar="COVERAGE", help="The experts that cover this share, e.g. 0.9"
|
|
96
|
+
)
|
|
97
|
+
experts.add_argument("--out", default=None, help="Write the --select expert set to this JSON file")
|
|
98
|
+
experts.add_argument("--token", default=None, help="Where one token goes, per layer (reads the token shards)")
|
|
99
|
+
experts.add_argument("--json", action="store_true")
|
|
100
|
+
|
|
101
|
+
explain = sub.add_parser("explain", help="What an architecture changed from its predecessor")
|
|
102
|
+
explain.add_argument("target", help="Family (mha, gqa, mla, sliding_window, hybrid ...) or a model id/path")
|
|
103
|
+
explain.add_argument("--json", action="store_true")
|
|
104
|
+
return parser
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------- predict / explain
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _cmd_predict(args: argparse.Namespace) -> int:
|
|
111
|
+
from innards.kv import predict
|
|
112
|
+
from innards.lineage import explain
|
|
113
|
+
from innards.sinks.console import format_bytes
|
|
114
|
+
|
|
115
|
+
p = predict(
|
|
116
|
+
args.model,
|
|
117
|
+
args.context,
|
|
118
|
+
args.kv_dtype,
|
|
119
|
+
memory_bytes=args.memory,
|
|
120
|
+
batch_size=args.batch_size,
|
|
121
|
+
revision=args.revision,
|
|
122
|
+
)
|
|
123
|
+
if args.json:
|
|
124
|
+
print(json.dumps(p.to_dict(), indent=2))
|
|
125
|
+
return 0
|
|
126
|
+
groups = p.by_layer_type
|
|
127
|
+
attention = f" ({p.attention.upper()} attention)" if p.attention and p.attention != p.architecture else ""
|
|
128
|
+
print(f"{p.model_id or args.model} · {p.architecture}{attention} · KV dtype {p.kv_dtype}")
|
|
129
|
+
print(f" per token: {p.formula}")
|
|
130
|
+
print(
|
|
131
|
+
f" at {p.context_tokens:,} tokens: KV {format_bytes(p.kv_bytes)} ({p.kv_bytes:,} B) · "
|
|
132
|
+
f"global {format_bytes(groups['global'])} · sliding {format_bytes(groups['sliding'])} · "
|
|
133
|
+
f"recurrent {format_bytes(p.recurrent_state_bytes)}"
|
|
134
|
+
)
|
|
135
|
+
if p.max_context_tokens:
|
|
136
|
+
line = f" max context: {p.max_context_tokens:,} tokens"
|
|
137
|
+
if p.memory_bytes is not None:
|
|
138
|
+
line += f" · fits in {format_bytes(p.memory_bytes)}: {p.capacity_tokens:,} tokens"
|
|
139
|
+
print(line)
|
|
140
|
+
entry = explain(p.architecture)
|
|
141
|
+
print(f" lineage: {entry.family} ← {entry.predecessor or '-'}: {entry.change}")
|
|
142
|
+
for note in p.notes:
|
|
143
|
+
print(f" note: {note}")
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _cmd_explain(args: argparse.Namespace) -> int:
|
|
148
|
+
from innards.lineage import explain
|
|
149
|
+
|
|
150
|
+
entry = explain(args.target)
|
|
151
|
+
print(json.dumps(entry.to_dict(), indent=2) if args.json else str(entry))
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --------------------------------------------------------------------------- view / validate / lineup / sync
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _cmd_view(args: argparse.Namespace) -> int:
|
|
159
|
+
from innards.view import serve
|
|
160
|
+
|
|
161
|
+
serve(root=args.root, host=args.host, port=args.port, open_browser=args.open, block=True)
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
166
|
+
from innards.validate import validate
|
|
167
|
+
|
|
168
|
+
report = validate(
|
|
169
|
+
args.model, precision=args.precision, device=args.device, conversation_set=args.set, budget_gb=args.budget_gb
|
|
170
|
+
)
|
|
171
|
+
print(json.dumps(report.to_dict(), indent=2) if args.json else str(report))
|
|
172
|
+
return 0 if report.ok else 1
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _cmd_lineup(args: argparse.Namespace) -> int:
|
|
176
|
+
from innards.lineage.lineup import lineup, what_changed
|
|
177
|
+
|
|
178
|
+
data = lineup()
|
|
179
|
+
if args.json:
|
|
180
|
+
print(json.dumps(data.to_dict(), indent=2))
|
|
181
|
+
return 0
|
|
182
|
+
for pair in data.pairs:
|
|
183
|
+
changed = what_changed(pair.newer, pair.previous)
|
|
184
|
+
added = ", ".join(a["name"] for a in changed["added"]) or "-"
|
|
185
|
+
print(f"{pair.feature} [{pair.where}]")
|
|
186
|
+
print(f" {pair.previous} → {pair.newer}")
|
|
187
|
+
print(f" adds: {added}")
|
|
188
|
+
for newer, previous in pair.alternates:
|
|
189
|
+
print(f" or: {previous} → {newer}")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _cmd_sync(args: argparse.Namespace) -> int:
|
|
194
|
+
from innards.store import RunStore
|
|
195
|
+
from innards.sync import sync_down, sync_up
|
|
196
|
+
|
|
197
|
+
if args.direction == "up":
|
|
198
|
+
result = sync_up(RunStore(args.root), args.run, args.url)
|
|
199
|
+
else:
|
|
200
|
+
result = sync_down(args.url, RunStore(args.root).root)
|
|
201
|
+
print(result)
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _pct(value: float | None) -> str:
|
|
206
|
+
return f"{value * 100:5.1f}%" if value is not None else " n/a "
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _cmd_experts(args: argparse.Namespace) -> int:
|
|
210
|
+
from pathlib import Path
|
|
211
|
+
|
|
212
|
+
from innards.experts import TokenRouting, load, traces
|
|
213
|
+
from innards.store import RunStore
|
|
214
|
+
|
|
215
|
+
path = Path(args.path) if args.path else RunStore().root
|
|
216
|
+
if args.topic is None and args.token is None:
|
|
217
|
+
found: list[dict[str, Any]]
|
|
218
|
+
if (path / "summary.json").is_file():
|
|
219
|
+
found = [{"run": None, "name": str(path), **_brief(load(path))}]
|
|
220
|
+
elif (path / "experts").is_dir():
|
|
221
|
+
found = [t for t in traces(path.parent) if t["run"] == path.name]
|
|
222
|
+
else:
|
|
223
|
+
found = traces(path)
|
|
224
|
+
if args.json:
|
|
225
|
+
print(json.dumps(found, indent=2))
|
|
226
|
+
return 0
|
|
227
|
+
if not found:
|
|
228
|
+
print(f"No expert traces under {path}")
|
|
229
|
+
for t in found:
|
|
230
|
+
where = f"{t['run']}/experts/{t['name']}" if t.get("run") else t["name"]
|
|
231
|
+
print(f"{where} · {t['model']} · {t['layers']} MoE layers")
|
|
232
|
+
for topic, count in t["tokens"].items():
|
|
233
|
+
print(f" {topic}: {count:,} tokens")
|
|
234
|
+
return 0
|
|
235
|
+
experts = load(path)
|
|
236
|
+
layers = args.layer or experts.layers
|
|
237
|
+
if args.token is not None:
|
|
238
|
+
folder = experts.folder
|
|
239
|
+
if folder is None:
|
|
240
|
+
raise ValueError("--token reads one trace folder's token shards; pass that folder")
|
|
241
|
+
where = TokenRouting(folder).token(args.token, topic=args.topic)
|
|
242
|
+
if args.json:
|
|
243
|
+
print(json.dumps(where, indent=2))
|
|
244
|
+
return 0
|
|
245
|
+
print(f"{args.token!r}: {where['occurrences']:,} occurrences (measured; ids {where['ids']})")
|
|
246
|
+
for layer, row in zip(where["layers"], where["counts"], strict=True):
|
|
247
|
+
if layer in layers:
|
|
248
|
+
total = where["occurrences"] or 1
|
|
249
|
+
ranked = sorted(range(len(row)), key=lambda e: -row[e])[: args.top]
|
|
250
|
+
print(f" layer {layer:>3}: " + " ".join(f"e{e} {row[e] / total:.0%}" for e in ranked if row[e]))
|
|
251
|
+
return 0
|
|
252
|
+
topic = args.topic
|
|
253
|
+
if args.select is not None:
|
|
254
|
+
chosen = experts.select(topic, coverage=args.select, by="gate" if args.by == "gate" else "selection")
|
|
255
|
+
if args.out:
|
|
256
|
+
chosen.save(args.out)
|
|
257
|
+
print(json.dumps(chosen.to_dict(), indent=2) if args.json else chosen)
|
|
258
|
+
if args.out and not args.json:
|
|
259
|
+
print(f"(written to {args.out})")
|
|
260
|
+
return 0
|
|
261
|
+
rows = {layer: experts.top(topic, layer, n=args.top, by=args.by) for layer in layers}
|
|
262
|
+
if args.json:
|
|
263
|
+
print(json.dumps({str(k): v for k, v in rows.items()}, indent=2))
|
|
264
|
+
return 0
|
|
265
|
+
print(f"{experts.model} · {topic}: {experts.tokens(topic):,} tokens measured · ranked by {args.by} (derived)")
|
|
266
|
+
print(" selection = share of the topic's tokens whose top-k included the expert; lift = vs the other topics")
|
|
267
|
+
for layer, top in rows.items():
|
|
268
|
+
cells = " ".join(
|
|
269
|
+
f"e{r['expert']} {_pct(r['selection']).strip()}"
|
|
270
|
+
+ (f" ×{r['lift']:.1f}" if r["lift"] is not None and args.by == "lift" else "")
|
|
271
|
+
for r in top
|
|
272
|
+
)
|
|
273
|
+
print(f" layer {layer:>3}: {cells}")
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _brief(experts: object) -> dict[str, Any]:
|
|
278
|
+
from innards.experts import ExpertMap
|
|
279
|
+
|
|
280
|
+
assert isinstance(experts, ExpertMap)
|
|
281
|
+
return {
|
|
282
|
+
"model": experts.model,
|
|
283
|
+
"layers": len(experts.layers),
|
|
284
|
+
"tokens": {t: experts.tokens(t) for t in experts.topics},
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
289
|
+
args = _build_parser().parse_args(argv)
|
|
290
|
+
commands = {
|
|
291
|
+
"predict": _cmd_predict,
|
|
292
|
+
"explain": _cmd_explain,
|
|
293
|
+
"view": _cmd_view,
|
|
294
|
+
"validate": _cmd_validate,
|
|
295
|
+
"lineup": _cmd_lineup,
|
|
296
|
+
"sync": _cmd_sync,
|
|
297
|
+
"experts": _cmd_experts,
|
|
298
|
+
}
|
|
299
|
+
try:
|
|
300
|
+
return commands[args.command](args)
|
|
301
|
+
except (ValueError, KeyError, FileNotFoundError, ImportError) as exc:
|
|
302
|
+
message = exc.args[0] if isinstance(exc, KeyError) and exc.args else exc
|
|
303
|
+
print(f"innards: error: {message}", file=sys.stderr)
|
|
304
|
+
return 2
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__": # pragma: no cover
|
|
308
|
+
sys.exit(main())
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Collectors turn raw engine facts into TurnRecord fields. They run in observer workers."""
|
|
2
|
+
|
|
3
|
+
from innards.collectors.memory import rss_sampler, split_memory, tensor_bytes, weights_bytes
|
|
4
|
+
from innards.collectors.timing import timing_from
|
|
5
|
+
|
|
6
|
+
__all__ = ["rss_sampler", "split_memory", "tensor_bytes", "timing_from", "weights_bytes"]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Memory collectors: process RSS (psutil) and the weights / KV / activations split."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from innards.schema import MemoryInfo
|
|
10
|
+
|
|
11
|
+
__all__ = ["rss_sampler", "split_memory", "tensor_bytes", "weights_bytes"]
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("innards.collectors")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def rss_sampler() -> Callable[[], int | None] | None:
|
|
17
|
+
"""Return a callable reading this process's RSS, or ``None`` if psutil is not installed."""
|
|
18
|
+
try:
|
|
19
|
+
import psutil
|
|
20
|
+
except ImportError:
|
|
21
|
+
log.debug("psutil not installed; CPU memory will be reported as null")
|
|
22
|
+
return None
|
|
23
|
+
process = psutil.Process()
|
|
24
|
+
|
|
25
|
+
def sample() -> int | None:
|
|
26
|
+
return int(process.memory_info().rss)
|
|
27
|
+
|
|
28
|
+
return sample
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def tensor_bytes(tensor: Any) -> int:
|
|
32
|
+
"""Bytes a tensor really holds on its device (metadata only: no values are read).
|
|
33
|
+
|
|
34
|
+
Quantized weights can be tensor subclasses that report their logical dtype, so
|
|
35
|
+
``numel * element_size`` would count them at the unpacked size. Their inner tensors (packed data
|
|
36
|
+
and scales) are counted instead.
|
|
37
|
+
"""
|
|
38
|
+
if hasattr(type(tensor), "__tensor_flatten__"): # plain tensors and Parameters do not have it
|
|
39
|
+
try:
|
|
40
|
+
names, _ctx = tensor.__tensor_flatten__()
|
|
41
|
+
return sum(tensor_bytes(getattr(tensor, name)) for name in names)
|
|
42
|
+
except Exception:
|
|
43
|
+
log.debug("could not unwrap %s; counting its logical size", type(tensor).__name__, exc_info=True)
|
|
44
|
+
return int(tensor.numel() * tensor.element_size())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def weights_bytes(model: Any) -> int:
|
|
48
|
+
"""Bytes of a model's parameters and buffers, each tensor counted once."""
|
|
49
|
+
seen: set[int] = set()
|
|
50
|
+
total = 0
|
|
51
|
+
for tensor in [*model.parameters(), *model.buffers()]:
|
|
52
|
+
if id(tensor) in seen:
|
|
53
|
+
continue
|
|
54
|
+
seen.add(id(tensor))
|
|
55
|
+
total += tensor_bytes(tensor)
|
|
56
|
+
return total
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def split_memory(
|
|
60
|
+
*,
|
|
61
|
+
weights_bytes: int | None,
|
|
62
|
+
kv_bytes: int | None,
|
|
63
|
+
peak_bytes: int | None,
|
|
64
|
+
source: str | None,
|
|
65
|
+
allocator_only: bool,
|
|
66
|
+
allocated_bytes: int | None = None,
|
|
67
|
+
budget_bytes: int | None = None,
|
|
68
|
+
) -> MemoryInfo:
|
|
69
|
+
"""Build MemoryInfo. ``activations = peak - weights - kv`` only when the peak counts tensors alone.
|
|
70
|
+
|
|
71
|
+
Process RSS also counts the Python runtime, libraries and allocator caches, so on
|
|
72
|
+
CPU the activation share is left ``None`` rather than guessed.
|
|
73
|
+
"""
|
|
74
|
+
activations: int | None = None
|
|
75
|
+
if allocator_only and peak_bytes is not None and weights_bytes is not None and kv_bytes is not None:
|
|
76
|
+
activations = max(peak_bytes - weights_bytes - kv_bytes, 0)
|
|
77
|
+
return MemoryInfo(
|
|
78
|
+
weights_bytes=weights_bytes,
|
|
79
|
+
kv_bytes=kv_bytes,
|
|
80
|
+
activations_bytes=activations,
|
|
81
|
+
peak_bytes=peak_bytes,
|
|
82
|
+
allocated_bytes=allocated_bytes,
|
|
83
|
+
budget_bytes=budget_bytes,
|
|
84
|
+
source=source,
|
|
85
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Timing from streamer timestamps (``time.perf_counter_ns``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from innards.observer.events import TurnEnd
|
|
6
|
+
from innards.schema import TimingInfo
|
|
7
|
+
|
|
8
|
+
__all__ = ["timing_from"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _ms(start: int | None, end: int | None) -> float | None:
|
|
12
|
+
if start is None or end is None:
|
|
13
|
+
return None
|
|
14
|
+
return round((end - start) / 1e6, 3)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def timing_from(t_request_ns: int | None, end: TurnEnd) -> TimingInfo:
|
|
18
|
+
"""TTFT and total time are measured from the request; prefill from the start of ``generate``.
|
|
19
|
+
|
|
20
|
+
Decode speed counts tokens after the first one, over the time between the first
|
|
21
|
+
and last token, so it excludes prefill.
|
|
22
|
+
"""
|
|
23
|
+
decode: float | None = None
|
|
24
|
+
first, last = end.t_first_token_ns, end.t_last_token_ns
|
|
25
|
+
if first is not None and last is not None and last > first and end.generated_tokens > 1:
|
|
26
|
+
decode = round((end.generated_tokens - 1) / ((last - first) / 1e9), 3)
|
|
27
|
+
return TimingInfo(
|
|
28
|
+
ttft_ms=_ms(t_request_ns, first),
|
|
29
|
+
prefill_ms=_ms(end.t_gen_start_ns, first),
|
|
30
|
+
decode_tokens_per_s=decode,
|
|
31
|
+
total_ms=_ms(t_request_ns, end.t_end_ns),
|
|
32
|
+
)
|