eigencore 0.4.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.
- eigencore/__init__.py +82 -0
- eigencore/agent/__init__.py +45 -0
- eigencore/agent/executor.py +297 -0
- eigencore/agent/grammar.py +121 -0
- eigencore/agent/policy.py +262 -0
- eigencore/agent/react.py +261 -0
- eigencore/agent/tool.py +149 -0
- eigencore/analysis/__init__.py +3 -0
- eigencore/analysis/sparsity.py +184 -0
- eigencore/benchmark/__init__.py +33 -0
- eigencore/benchmark/harness.py +342 -0
- eigencore/cli/__init__.py +0 -0
- eigencore/cli/main.py +856 -0
- eigencore/context/__init__.py +3 -0
- eigencore/context/manager.py +318 -0
- eigencore/engine/__init__.py +3 -0
- eigencore/engine/inference.py +378 -0
- eigencore/engine/layer_skip.py +268 -0
- eigencore/engine/prompt_lookup.py +39 -0
- eigencore/engine/pruning.py +116 -0
- eigencore/engine/sparse_inference.py +218 -0
- eigencore/engine/sparsity_experiment.py +109 -0
- eigencore/engine/speculative.py +238 -0
- eigencore/fit/__init__.py +34 -0
- eigencore/fit/estimator.py +310 -0
- eigencore/fit/gguf_meta.py +130 -0
- eigencore/forge.py +216 -0
- eigencore/hal/__init__.py +3 -0
- eigencore/hal/_cpuid_win.py +119 -0
- eigencore/hal/profiler.py +349 -0
- eigencore/kernels/__init__.py +28 -0
- eigencore/kernels/q4k.py +76 -0
- eigencore/kernels/reference.py +168 -0
- eigencore/models/__init__.py +3 -0
- eigencore/models/registry.py +193 -0
- eigencore/router/__init__.py +3 -0
- eigencore/router/classifier.py +176 -0
- eigencore/training/__init__.py +3 -0
- eigencore/training/convergence.py +112 -0
- eigencore/training/trainer.py +401 -0
- eigencore/tui/__init__.py +5 -0
- eigencore/tui/chat.py +265 -0
- eigencore/tui/onboarding.py +134 -0
- eigencore/tui/theme.py +61 -0
- eigencore-0.4.0.dist-info/METADATA +144 -0
- eigencore-0.4.0.dist-info/RECORD +49 -0
- eigencore-0.4.0.dist-info/WHEEL +4 -0
- eigencore-0.4.0.dist-info/entry_points.txt +2 -0
- eigencore-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Activation-sparsity experiment — Stage E slice 3 (PowerInfer/TurboSparse/TEAL-
|
|
3
|
+
style), REPORTED-ONLY.
|
|
4
|
+
|
|
5
|
+
Idea: if a fraction of FFN neurons are near-zero for a given token, skip their
|
|
6
|
+
contribution (sparse GEMM) to save compute. On bandwidth-bound CPU decode the
|
|
7
|
+
win is small and quant/dequant dominates — hence the kill criterion.
|
|
8
|
+
|
|
9
|
+
This module measures per-layer activation sparsity on real activations,
|
|
10
|
+
builds a sparse mask plan, and enforces the kill criterion: if the measured
|
|
11
|
+
quality delta exceeds the allowed threshold, the experiment is aborted and no
|
|
12
|
+
sparsity is applied. Nothing here is wired into inference; the backend always
|
|
13
|
+
runs dense today. No quality/performance claim without the owner's runs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def activation_sparsity(activations: np.ndarray, threshold: float = 0.01) -> float:
|
|
24
|
+
"""Fraction of |x| <= threshold. `activations`: (tokens, hidden)."""
|
|
25
|
+
a = np.abs(np.asarray(activations, dtype=np.float64))
|
|
26
|
+
if a.size == 0:
|
|
27
|
+
return 0.0
|
|
28
|
+
return float((a <= threshold).mean())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def layer_sparsity_profile(
|
|
32
|
+
hidden_states: np.ndarray, # (layers, tokens, hidden) FFN inputs per layer
|
|
33
|
+
threshold: float = 0.01,
|
|
34
|
+
) -> np.ndarray:
|
|
35
|
+
"""Per-layer sparsity, (layers,)."""
|
|
36
|
+
hs = np.asarray(hidden_states, dtype=np.float64)
|
|
37
|
+
if hs.ndim != 3:
|
|
38
|
+
raise ValueError("expected (layers, tokens, hidden)")
|
|
39
|
+
return np.array([activation_sparsity(hs[i], threshold) for i in range(hs.shape[0])])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class SparseMask:
|
|
44
|
+
layer: int
|
|
45
|
+
keep: np.ndarray # bool mask over hidden dim; True = compute
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def sparsity(self) -> float:
|
|
49
|
+
return 1.0 - float(self.keep.mean())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_sparse_mask(
|
|
53
|
+
activations: np.ndarray,
|
|
54
|
+
threshold: float = 0.01,
|
|
55
|
+
) -> SparseMask:
|
|
56
|
+
"""Keep neurons whose |activation| exceeds threshold, averaged over tokens."""
|
|
57
|
+
a = np.abs(np.asarray(activations, dtype=np.float64))
|
|
58
|
+
mean_abs = a.mean(axis=0) if a.ndim == 2 else np.abs(a)
|
|
59
|
+
return SparseMask(layer=-1, keep=mean_abs > threshold)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class SparsityPlan:
|
|
64
|
+
masks: list[SparseMask]
|
|
65
|
+
overall_sparsity: float
|
|
66
|
+
max_quality_delta_allowed: float
|
|
67
|
+
killed: bool = False
|
|
68
|
+
kill_reason: str = ""
|
|
69
|
+
notes: list[str] = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_sparsity_plan(
|
|
73
|
+
hidden_states: np.ndarray, # (layers, tokens, hidden)
|
|
74
|
+
threshold: float = 0.01,
|
|
75
|
+
min_sparsity_to_apply: float = 0.5,
|
|
76
|
+
max_quality_delta_allowed: float = 0.02,
|
|
77
|
+
measured_quality_delta: float | None = None,
|
|
78
|
+
) -> SparsityPlan:
|
|
79
|
+
"""Build per-layer sparse masks; kill if quality delta exceeds allowed.
|
|
80
|
+
|
|
81
|
+
Layers whose measured sparsity is below `min_sparsity_to_apply` get a full
|
|
82
|
+
keep mask (applying sparsity there saves nothing meaningful).
|
|
83
|
+
"""
|
|
84
|
+
profile = layer_sparsity_profile(hidden_states, threshold)
|
|
85
|
+
if measured_quality_delta is not None and measured_quality_delta > max_quality_delta_allowed:
|
|
86
|
+
return SparsityPlan(
|
|
87
|
+
masks=[],
|
|
88
|
+
overall_sparsity=float(profile.mean()),
|
|
89
|
+
max_quality_delta_allowed=max_quality_delta_allowed,
|
|
90
|
+
killed=True,
|
|
91
|
+
kill_reason=(
|
|
92
|
+
f"quality delta {measured_quality_delta:.4f} > allowed "
|
|
93
|
+
f"{max_quality_delta_allowed:.4f} — kill criterion hit, no sparsity applied"
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
masks: list[SparseMask] = []
|
|
97
|
+
for i in range(hidden_states.shape[0]):
|
|
98
|
+
if profile[i] >= min_sparsity_to_apply:
|
|
99
|
+
masks.append(build_sparse_mask(hidden_states[i], threshold))
|
|
100
|
+
masks[-1].layer = i
|
|
101
|
+
else:
|
|
102
|
+
masks.append(SparseMask(layer=i, keep=np.ones(hidden_states.shape[2], dtype=bool)))
|
|
103
|
+
overall = float(sum(m.sparsity for m in masks) / len(masks)) if masks else 0.0
|
|
104
|
+
return SparsityPlan(
|
|
105
|
+
masks=masks,
|
|
106
|
+
overall_sparsity=overall,
|
|
107
|
+
max_quality_delta_allowed=max_quality_delta_allowed,
|
|
108
|
+
notes=[f"mean measured sparsity {profile.mean():.1%} at threshold {threshold}"],
|
|
109
|
+
)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Speculative decoding rethought for CPU.
|
|
3
|
+
|
|
4
|
+
Standard speculative decoding uses a small "draft" model to predict N tokens,
|
|
5
|
+
then the large "verifier" model checks them in a single batch. On GPU this is
|
|
6
|
+
fast because the batch verify step parallelizes well.
|
|
7
|
+
|
|
8
|
+
On CPU the advantage is different: we exploit the fact that CPU branch prediction
|
|
9
|
+
and sequential processing makes it cheaper to run a tiny draft model for N tokens
|
|
10
|
+
than to run the large model N times. The verify step is still sequential on CPU,
|
|
11
|
+
but we only pay the large-model cost once per accepted draft sequence.
|
|
12
|
+
|
|
13
|
+
Key CPU-specific adaptations:
|
|
14
|
+
1. Draft model shares the same memory-mapped GGUF file as verifier (different quant)
|
|
15
|
+
2. Acceptance threshold adapts based on observed accept rate
|
|
16
|
+
3. Draft length scales with CPU cache size (longer drafts if L3 cache can hold draft model)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DraftToken:
|
|
29
|
+
token_id: int
|
|
30
|
+
logprob: float
|
|
31
|
+
position: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class SpeculativeResult:
|
|
36
|
+
accepted_tokens: list[int]
|
|
37
|
+
drafted_tokens: int
|
|
38
|
+
accepted_count: int
|
|
39
|
+
rejection_position: int | None
|
|
40
|
+
correction_token: int | None
|
|
41
|
+
draft_time_ms: float
|
|
42
|
+
verify_time_ms: float
|
|
43
|
+
total_time_ms: float
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def accept_rate(self) -> float:
|
|
47
|
+
return self.accepted_count / self.drafted_tokens if self.drafted_tokens > 0 else 0.0
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def speedup(self) -> float:
|
|
51
|
+
if self.total_time_ms <= 0:
|
|
52
|
+
return 1.0
|
|
53
|
+
naive_time = self.total_time_ms * (self.drafted_tokens + 1) / max(self.drafted_tokens, 1)
|
|
54
|
+
return naive_time / self.total_time_ms if self.total_time_ms > 0 else 1.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class SpeculativeStats:
|
|
59
|
+
total_drafted: int = 0
|
|
60
|
+
total_accepted: int = 0
|
|
61
|
+
total_rounds: int = 0
|
|
62
|
+
draft_times_ms: list[float] = field(default_factory=list)
|
|
63
|
+
verify_times_ms: list[float] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def overall_accept_rate(self) -> float:
|
|
67
|
+
return self.total_accepted / self.total_drafted if self.total_drafted > 0 else 0.0
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def avg_draft_time_ms(self) -> float:
|
|
71
|
+
return np.mean(self.draft_times_ms) if self.draft_times_ms else 0.0
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def avg_verify_time_ms(self) -> float:
|
|
75
|
+
return np.mean(self.verify_times_ms) if self.verify_times_ms else 0.0
|
|
76
|
+
|
|
77
|
+
def summary(self) -> str:
|
|
78
|
+
return (
|
|
79
|
+
f"Speculative decoding: {self.total_rounds} rounds | "
|
|
80
|
+
f"Accept rate: {self.overall_accept_rate:.0%} | "
|
|
81
|
+
f"Avg draft: {self.avg_draft_time_ms:.1f}ms | "
|
|
82
|
+
f"Avg verify: {self.avg_verify_time_ms:.1f}ms"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SpeculativeDecoder:
|
|
87
|
+
"""
|
|
88
|
+
CPU-optimized speculative decoding engine.
|
|
89
|
+
|
|
90
|
+
Takes a draft function (fast, small model) and a verify function (large model)
|
|
91
|
+
as callables, so it can wrap any underlying inference backend.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
DEFAULT_DRAFT_LENGTH = 5
|
|
95
|
+
MIN_DRAFT_LENGTH = 2
|
|
96
|
+
MAX_DRAFT_LENGTH = 12
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
draft_fn=None,
|
|
101
|
+
verify_fn=None,
|
|
102
|
+
initial_draft_length: int = 5,
|
|
103
|
+
acceptance_threshold: float = 0.3,
|
|
104
|
+
adaptive: bool = True,
|
|
105
|
+
):
|
|
106
|
+
self.draft_fn = draft_fn
|
|
107
|
+
self.verify_fn = verify_fn
|
|
108
|
+
self.draft_length = initial_draft_length
|
|
109
|
+
self.acceptance_threshold = acceptance_threshold
|
|
110
|
+
self.adaptive = adaptive
|
|
111
|
+
self.stats = SpeculativeStats()
|
|
112
|
+
|
|
113
|
+
def optimal_draft_length(self, l3_cache_mb: float, draft_model_mb: float) -> int:
|
|
114
|
+
"""
|
|
115
|
+
Scale draft length based on whether the draft model fits in L3 cache.
|
|
116
|
+
If it fits, longer drafts are nearly free (no main memory round-trips).
|
|
117
|
+
"""
|
|
118
|
+
if draft_model_mb <= 0:
|
|
119
|
+
return self.DEFAULT_DRAFT_LENGTH
|
|
120
|
+
|
|
121
|
+
cache_ratio = l3_cache_mb / draft_model_mb
|
|
122
|
+
|
|
123
|
+
if cache_ratio >= 1.0:
|
|
124
|
+
return min(self.MAX_DRAFT_LENGTH, int(self.DEFAULT_DRAFT_LENGTH * 1.5))
|
|
125
|
+
elif cache_ratio >= 0.5:
|
|
126
|
+
return self.DEFAULT_DRAFT_LENGTH
|
|
127
|
+
else:
|
|
128
|
+
return max(self.MIN_DRAFT_LENGTH, int(self.DEFAULT_DRAFT_LENGTH * 0.6))
|
|
129
|
+
|
|
130
|
+
def verify_draft(
|
|
131
|
+
self,
|
|
132
|
+
draft_logprobs: np.ndarray,
|
|
133
|
+
verifier_logprobs: np.ndarray,
|
|
134
|
+
draft_token_ids: list[int],
|
|
135
|
+
rng: np.random.Generator | None = None,
|
|
136
|
+
) -> SpeculativeResult:
|
|
137
|
+
"""
|
|
138
|
+
Leviathan/Chen-style speculative rejection sampling.
|
|
139
|
+
|
|
140
|
+
`verifier_logprobs` is (n_positions, vocab) log-probs from the target
|
|
141
|
+
model; `draft_logprobs[i]` is the draft model's log-prob of the drafted
|
|
142
|
+
token at position i. Token t is accepted with probability
|
|
143
|
+
min(1, q(t)/p(t)); on rejection the correction token is sampled from
|
|
144
|
+
the residual distribution norm(max(q - p, 0)), which preserves the
|
|
145
|
+
target distribution exactly. (The draft distribution is approximated
|
|
146
|
+
as a point mass at the drafted token, since only its log-prob is
|
|
147
|
+
passed in — documented limitation.)
|
|
148
|
+
"""
|
|
149
|
+
start = time.perf_counter()
|
|
150
|
+
n_draft = len(draft_token_ids)
|
|
151
|
+
rng = rng or np.random.default_rng()
|
|
152
|
+
|
|
153
|
+
if n_draft == 0 or len(draft_logprobs) == 0 or len(verifier_logprobs) == 0:
|
|
154
|
+
return SpeculativeResult(
|
|
155
|
+
accepted_tokens=[],
|
|
156
|
+
drafted_tokens=0,
|
|
157
|
+
accepted_count=0,
|
|
158
|
+
rejection_position=None,
|
|
159
|
+
correction_token=None,
|
|
160
|
+
draft_time_ms=0,
|
|
161
|
+
verify_time_ms=0,
|
|
162
|
+
total_time_ms=0,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
accepted = []
|
|
166
|
+
rejection_pos = None
|
|
167
|
+
correction = None
|
|
168
|
+
|
|
169
|
+
for i in range(min(n_draft, len(verifier_logprobs))):
|
|
170
|
+
token_id = draft_token_ids[i]
|
|
171
|
+
if i < len(draft_logprobs):
|
|
172
|
+
draft_lp = draft_logprobs[i]
|
|
173
|
+
else:
|
|
174
|
+
break
|
|
175
|
+
q = np.exp(verifier_logprobs[i] - verifier_logprobs[i].max())
|
|
176
|
+
q /= q.sum()
|
|
177
|
+
p_t = float(np.exp(draft_lp))
|
|
178
|
+
q_t = float(q[token_id]) if token_id < len(q) else 0.0
|
|
179
|
+
if rng.random() <= min(1.0, q_t / p_t) if p_t > 0 else True:
|
|
180
|
+
accepted.append(token_id)
|
|
181
|
+
else:
|
|
182
|
+
rejection_pos = i
|
|
183
|
+
# residual distribution: norm(max(q - p, 0)), p = point mass at token
|
|
184
|
+
residual = np.maximum(q - p_t * np.eye(len(q))[token_id], 0.0)
|
|
185
|
+
total = residual.sum()
|
|
186
|
+
if total > 0:
|
|
187
|
+
correction = int(rng.choice(len(q), p=residual / total))
|
|
188
|
+
else:
|
|
189
|
+
correction = int(np.argmax(q))
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
elapsed = (time.perf_counter() - start) * 1000
|
|
193
|
+
|
|
194
|
+
result = SpeculativeResult(
|
|
195
|
+
accepted_tokens=accepted,
|
|
196
|
+
drafted_tokens=n_draft,
|
|
197
|
+
accepted_count=len(accepted),
|
|
198
|
+
rejection_position=rejection_pos,
|
|
199
|
+
correction_token=correction,
|
|
200
|
+
draft_time_ms=0,
|
|
201
|
+
verify_time_ms=elapsed,
|
|
202
|
+
total_time_ms=elapsed,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
self._update_stats(result)
|
|
206
|
+
|
|
207
|
+
if self.adaptive:
|
|
208
|
+
self._adapt_draft_length(result.accept_rate)
|
|
209
|
+
|
|
210
|
+
return result
|
|
211
|
+
|
|
212
|
+
def _adapt_draft_length(self, recent_accept_rate: float) -> None:
|
|
213
|
+
"""
|
|
214
|
+
Adjust draft length based on acceptance rate.
|
|
215
|
+
High accept rate → try longer drafts (more tokens per round).
|
|
216
|
+
Low accept rate → shorter drafts (less wasted compute).
|
|
217
|
+
"""
|
|
218
|
+
if recent_accept_rate > 0.8:
|
|
219
|
+
self.draft_length = min(self.draft_length + 1, self.MAX_DRAFT_LENGTH)
|
|
220
|
+
elif recent_accept_rate < 0.3:
|
|
221
|
+
self.draft_length = max(self.draft_length - 1, self.MIN_DRAFT_LENGTH)
|
|
222
|
+
|
|
223
|
+
def _update_stats(self, result: SpeculativeResult) -> None:
|
|
224
|
+
self.stats.total_drafted += result.drafted_tokens
|
|
225
|
+
self.stats.total_accepted += result.accepted_count
|
|
226
|
+
self.stats.total_rounds += 1
|
|
227
|
+
self.stats.draft_times_ms.append(result.draft_time_ms)
|
|
228
|
+
self.stats.verify_times_ms.append(result.verify_time_ms)
|
|
229
|
+
|
|
230
|
+
def measured_speedup(self, draft_time_ms: float, verify_time_ms: float) -> float:
|
|
231
|
+
"""Speedup computed only from measured times; no theoretical claims."""
|
|
232
|
+
if verify_time_ms <= 0:
|
|
233
|
+
return 1.0
|
|
234
|
+
return (
|
|
235
|
+
(draft_time_ms + verify_time_ms) / max(verify_time_ms, 1e-9)
|
|
236
|
+
if draft_time_ms > 0
|
|
237
|
+
else 1.0
|
|
238
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Phase 2 model-fit estimation."""
|
|
2
|
+
|
|
3
|
+
from eigencore.fit.estimator import (
|
|
4
|
+
BYTES_PER_PARAM,
|
|
5
|
+
DEFAULT_RUNTIME_RESERVE_GB,
|
|
6
|
+
DEFAULT_SAFETY_MARGIN_GB,
|
|
7
|
+
FitEstimate,
|
|
8
|
+
FitVerdict,
|
|
9
|
+
ModelGeometry,
|
|
10
|
+
decode_ceiling_tok_s,
|
|
11
|
+
estimate_fit,
|
|
12
|
+
estimate_kv_cache_gib,
|
|
13
|
+
estimate_weights_gib,
|
|
14
|
+
gpu_layers_for_vram,
|
|
15
|
+
max_context_for_budget,
|
|
16
|
+
)
|
|
17
|
+
from eigencore.fit.gguf_meta import GGUFModelInfo, read_gguf_model_info
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BYTES_PER_PARAM",
|
|
21
|
+
"DEFAULT_RUNTIME_RESERVE_GB",
|
|
22
|
+
"DEFAULT_SAFETY_MARGIN_GB",
|
|
23
|
+
"GGUFModelInfo",
|
|
24
|
+
"FitEstimate",
|
|
25
|
+
"FitVerdict",
|
|
26
|
+
"ModelGeometry",
|
|
27
|
+
"estimate_fit",
|
|
28
|
+
"estimate_kv_cache_gib",
|
|
29
|
+
"estimate_weights_gib",
|
|
30
|
+
"gpu_layers_for_vram",
|
|
31
|
+
"decode_ceiling_tok_s",
|
|
32
|
+
"max_context_for_budget",
|
|
33
|
+
"read_gguf_model_info",
|
|
34
|
+
]
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model-fit estimation — Phase 2 (honest hardware fit).
|
|
3
|
+
|
|
4
|
+
Estimates the memory needed to *load and run* a model, separating:
|
|
5
|
+
- weights (actual GGUF file size when known, else params × bytes-per-param)
|
|
6
|
+
- runtime reserve (allocator/backend/OS overhead, configurable)
|
|
7
|
+
- KV cache (context-length dependent; needs per-model geometry)
|
|
8
|
+
|
|
9
|
+
All sizes are **GiB** (1 GiB = 1024**3 bytes) end to end. Without model
|
|
10
|
+
geometry the KV cache is unknowable, so the verdict is `UNKNOWN` rather than
|
|
11
|
+
a made-up heuristic — a Core i7 label alone cannot predict whether a 35B Q4
|
|
12
|
+
model is usable.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
|
|
20
|
+
# Approximate bytes per parameter for common GGUF quant types.
|
|
21
|
+
# These are planning estimates, not measurements.
|
|
22
|
+
BYTES_PER_PARAM: dict[str, float] = {
|
|
23
|
+
"Q4_K_M": 0.55,
|
|
24
|
+
"Q4_0": 0.50,
|
|
25
|
+
"Q5_K_M": 0.68,
|
|
26
|
+
"Q6_K": 0.84,
|
|
27
|
+
"Q8_0": 1.05,
|
|
28
|
+
"F16": 2.0,
|
|
29
|
+
"F32": 4.0,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# KV cache per token, per layer, per KV head, in bytes at fp16.
|
|
33
|
+
# kv_bytes_per_token = layers × kv_heads × head_dim × 2 (K+V) × 2 bytes
|
|
34
|
+
# head_dim = hidden_size / n_heads; for grouped-query attention kv_heads < n_heads.
|
|
35
|
+
FP16_BYTES_PER_ELEMENT = 2
|
|
36
|
+
KV_BYTES_PER_ELEMENT_Q8 = 1 # q8_0 KV cache
|
|
37
|
+
|
|
38
|
+
DEFAULT_RUNTIME_RESERVE_GB = 2.0
|
|
39
|
+
DEFAULT_SAFETY_MARGIN_GB = 1.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FitVerdict(str, Enum):
|
|
43
|
+
FITS_WITH_MARGIN = "fits_with_margin"
|
|
44
|
+
TIGHT = "tight"
|
|
45
|
+
DOES_NOT_FIT = "does_not_fit"
|
|
46
|
+
UNKNOWN = "unknown"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class ModelGeometry:
|
|
51
|
+
"""Architecture metadata needed for a KV-cache estimate.
|
|
52
|
+
|
|
53
|
+
All fields optional: when absent the estimator falls back to a
|
|
54
|
+
conservative generic estimate and marks the result as UNKNOWN-precision.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
num_layers: int | None = None
|
|
58
|
+
hidden_size: int | None = None
|
|
59
|
+
num_attention_heads: int | None = None
|
|
60
|
+
num_kv_heads: int | None = None # GQA/MQA: defaults to num_attention_heads
|
|
61
|
+
expert_count: int | None = None # MoE: total experts per MoE layer
|
|
62
|
+
expert_used_count: int | None = None # MoE: experts activated per token
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def kv_heads(self) -> int | None:
|
|
66
|
+
return self.num_kv_heads or self.num_attention_heads
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def head_dim(self) -> int | None:
|
|
70
|
+
if self.hidden_size and self.num_attention_heads:
|
|
71
|
+
return self.hidden_size // self.num_attention_heads
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_complete(self) -> bool:
|
|
76
|
+
return all(
|
|
77
|
+
v is not None for v in (self.num_layers, self.hidden_size, self.num_attention_heads)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def is_moe(self) -> bool:
|
|
82
|
+
return bool(self.expert_count and self.expert_used_count)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class FitEstimate:
|
|
87
|
+
weights_gb: float
|
|
88
|
+
active_weights_gb: float | None # MoE only: weights actually read per decode token
|
|
89
|
+
runtime_reserve_gb: float
|
|
90
|
+
kv_cache_gb: float
|
|
91
|
+
total_gb: float
|
|
92
|
+
available_ram_gb: float
|
|
93
|
+
headroom_gb: float
|
|
94
|
+
verdict: FitVerdict
|
|
95
|
+
context_length: int
|
|
96
|
+
quant: str
|
|
97
|
+
resident: bool # file fully in RAM (False → mmap/SSD paging)
|
|
98
|
+
decode_ceiling_tok_s: float | None # bandwidth / active bytes-per-token ceiling
|
|
99
|
+
gpu_layers: int | None = None # layers offloaded to GPU, if a VRAM budget was given
|
|
100
|
+
gpu_weights_gb: float = 0.0 # share of weights resident in VRAM
|
|
101
|
+
notes: list[str] = field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def fits(self) -> bool:
|
|
105
|
+
return self.verdict in (FitVerdict.FITS_WITH_MARGIN, FitVerdict.TIGHT)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def gpu_layers_for_vram(
|
|
109
|
+
num_layers: int,
|
|
110
|
+
layer_bytes: int,
|
|
111
|
+
available_vram_gb: float,
|
|
112
|
+
kv_cache_gb: float = 0.0,
|
|
113
|
+
vram_reserve_gb: float = 1.0,
|
|
114
|
+
) -> int:
|
|
115
|
+
"""How many layers fit in VRAM: (vram - reserve - kv) / layer_bytes, capped at num_layers.
|
|
116
|
+
|
|
117
|
+
layer_bytes is the per-layer weight size (uniform approximation; real
|
|
118
|
+
per-layer sizes vary slightly). Returns 0 when nothing fits.
|
|
119
|
+
"""
|
|
120
|
+
if num_layers <= 0 or layer_bytes <= 0 or available_vram_gb <= 0:
|
|
121
|
+
return 0
|
|
122
|
+
budget_bytes = max((available_vram_gb - vram_reserve_gb - kv_cache_gb) * 1024**3, 0.0)
|
|
123
|
+
return min(num_layers, int(budget_bytes // layer_bytes))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def estimate_weights_gib(
|
|
127
|
+
params_b: float,
|
|
128
|
+
quant: str,
|
|
129
|
+
file_size_bytes: int | None = None,
|
|
130
|
+
) -> tuple[float, str | None]:
|
|
131
|
+
"""Weight size in GiB.
|
|
132
|
+
|
|
133
|
+
Uses the actual GGUF file size when available (ground truth, includes
|
|
134
|
+
tokenizer/mmap overhead); otherwise params × bytes-per-param.
|
|
135
|
+
"""
|
|
136
|
+
if file_size_bytes is not None:
|
|
137
|
+
return file_size_bytes / 1024**3, None
|
|
138
|
+
bpp = BYTES_PER_PARAM.get(quant.upper())
|
|
139
|
+
if bpp is None:
|
|
140
|
+
return params_b * 0.61, f"unknown quant {quant!r}; assumed ~0.61 bytes/param (Q4_K_M class)"
|
|
141
|
+
return params_b * bpp, None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def estimate_kv_cache_gib(
|
|
145
|
+
context_length: int,
|
|
146
|
+
geometry: ModelGeometry | None,
|
|
147
|
+
params_b: float,
|
|
148
|
+
kv_quant: str = "fp16",
|
|
149
|
+
) -> tuple[float | None, str | None]:
|
|
150
|
+
"""KV cache size in GiB for a context length.
|
|
151
|
+
|
|
152
|
+
layers × kv_heads × head_dim × 2 (K+V) × bytes-per-element × ctx.
|
|
153
|
+
Returns (None, note) when geometry is incomplete — no heuristic.
|
|
154
|
+
"""
|
|
155
|
+
if geometry is None or not geometry.is_complete:
|
|
156
|
+
return None, "no GGUF geometry available; KV cache unknown"
|
|
157
|
+
bytes_per_elem = (
|
|
158
|
+
FP16_BYTES_PER_ELEMENT if kv_quant.lower() in ("fp16", "f16") else KV_BYTES_PER_ELEMENT_Q8
|
|
159
|
+
)
|
|
160
|
+
per_token = geometry.num_layers * geometry.kv_heads * geometry.head_dim * 2 * bytes_per_elem
|
|
161
|
+
return per_token * context_length / 1024**3, None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def decode_ceiling_tok_s(bandwidth_gbps: float, active_weights_gib: float) -> float:
|
|
165
|
+
"""Optimistic decode ceiling: bandwidth / bytes streamed per token.
|
|
166
|
+
|
|
167
|
+
For dense models active == resident weights. For MoE, only the activated
|
|
168
|
+
experts' weights are read per token (plus shared/non-expert weights, which
|
|
169
|
+
we approximate as included in the active figure). This is a CEILING —
|
|
170
|
+
real decode is lower due to cache misses, dequant, sampling overhead.
|
|
171
|
+
"""
|
|
172
|
+
if active_weights_gib <= 0:
|
|
173
|
+
return 0.0
|
|
174
|
+
return bandwidth_gbps / active_weights_gib
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def estimate_fit(
|
|
178
|
+
params_b: float,
|
|
179
|
+
quant: str,
|
|
180
|
+
context_length: int,
|
|
181
|
+
available_ram_gb: float,
|
|
182
|
+
geometry: ModelGeometry | None = None,
|
|
183
|
+
runtime_reserve_gb: float = DEFAULT_RUNTIME_RESERVE_GB,
|
|
184
|
+
safety_margin_gb: float = DEFAULT_SAFETY_MARGIN_GB,
|
|
185
|
+
kv_quant: str = "fp16",
|
|
186
|
+
file_size_bytes: int | None = None,
|
|
187
|
+
bandwidth_gbps: float | None = None,
|
|
188
|
+
active_params_b: float | None = None,
|
|
189
|
+
available_vram_gb: float = 0.0,
|
|
190
|
+
) -> FitEstimate:
|
|
191
|
+
"""Full memory fit estimate (GiB) for loading + running a model at a context length.
|
|
192
|
+
|
|
193
|
+
MoE: pass `active_params_b` (params activated per token) — resident memory
|
|
194
|
+
still uses the full file size, but the decode ceiling uses active weights.
|
|
195
|
+
"""
|
|
196
|
+
notes: list[str] = []
|
|
197
|
+
weights_gb, note = estimate_weights_gib(params_b, quant, file_size_bytes)
|
|
198
|
+
if note:
|
|
199
|
+
notes.append(note)
|
|
200
|
+
if geometry is not None and geometry.is_moe and active_params_b is None:
|
|
201
|
+
# derive active params from expert ratio when geometry is present
|
|
202
|
+
assert geometry.expert_count and geometry.expert_used_count
|
|
203
|
+
active_params_b = params_b * geometry.expert_used_count / geometry.expert_count
|
|
204
|
+
notes.append(
|
|
205
|
+
f"MoE: {geometry.expert_count} experts, {geometry.expert_used_count} active/token "
|
|
206
|
+
f"→ active ≈ {active_params_b:.1f}B params"
|
|
207
|
+
)
|
|
208
|
+
active_weights_gb: float | None = None
|
|
209
|
+
if active_params_b is not None and active_params_b < params_b:
|
|
210
|
+
active_weights_gb, _ = estimate_weights_gib(active_params_b, quant)
|
|
211
|
+
kv_gb_raw, note = estimate_kv_cache_gib(context_length, geometry, params_b, kv_quant)
|
|
212
|
+
if note:
|
|
213
|
+
notes.append(note)
|
|
214
|
+
kv_gb = kv_gb_raw or 0.0
|
|
215
|
+
|
|
216
|
+
total = weights_gb + runtime_reserve_gb + kv_gb
|
|
217
|
+
headroom = available_ram_gb - total
|
|
218
|
+
resident = weights_gb <= available_ram_gb
|
|
219
|
+
|
|
220
|
+
if kv_gb_raw is None:
|
|
221
|
+
verdict = FitVerdict.UNKNOWN
|
|
222
|
+
notes.append("verdict UNKNOWN: KV cache excluded (no geometry); real usage is higher")
|
|
223
|
+
elif headroom >= safety_margin_gb:
|
|
224
|
+
verdict = FitVerdict.FITS_WITH_MARGIN
|
|
225
|
+
elif headroom >= 0:
|
|
226
|
+
verdict = FitVerdict.TIGHT
|
|
227
|
+
notes.append(f"only {headroom:.1f} GB headroom; expect swapping/slowdown under load")
|
|
228
|
+
else:
|
|
229
|
+
verdict = FitVerdict.DOES_NOT_FIT
|
|
230
|
+
notes.append(f"short by {-headroom:.1f} GB at context {context_length}")
|
|
231
|
+
|
|
232
|
+
if not resident:
|
|
233
|
+
notes.append(
|
|
234
|
+
f"model file ({weights_gb:.1f} GiB) exceeds available RAM ({available_ram_gb:.1f} GiB): "
|
|
235
|
+
"would run via mmap/SSD paging — expect severe decode slowdown (SSD read rate, not RAM bandwidth)"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
ceiling = None
|
|
239
|
+
if bandwidth_gbps is not None:
|
|
240
|
+
active_gib = active_weights_gb if active_weights_gb is not None else weights_gb
|
|
241
|
+
ceiling = decode_ceiling_tok_s(bandwidth_gbps, active_gib)
|
|
242
|
+
|
|
243
|
+
gpu_layers = None
|
|
244
|
+
gpu_weights_gb = 0.0
|
|
245
|
+
if available_vram_gb > 0 and geometry is not None and geometry.num_layers:
|
|
246
|
+
layer_bytes = int(weights_gb * 1024**3 / geometry.num_layers) if weights_gb > 0 else 0
|
|
247
|
+
gpu_layers = gpu_layers_for_vram(geometry.num_layers, layer_bytes, available_vram_gb, kv_gb)
|
|
248
|
+
gpu_weights_gb = gpu_layers * layer_bytes / 1024**3
|
|
249
|
+
if gpu_layers > 0:
|
|
250
|
+
notes.append(
|
|
251
|
+
f"GPU offload: ~{gpu_layers}/{geometry.num_layers} layers "
|
|
252
|
+
f"(≈{gpu_weights_gb:.1f} GiB) fit in {available_vram_gb:.1f} GiB VRAM; "
|
|
253
|
+
"rest stays in RAM"
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
notes.append("VRAM present but too small for even one layer after reserve/KV")
|
|
257
|
+
|
|
258
|
+
return FitEstimate(
|
|
259
|
+
weights_gb=weights_gb,
|
|
260
|
+
active_weights_gb=active_weights_gb,
|
|
261
|
+
runtime_reserve_gb=runtime_reserve_gb,
|
|
262
|
+
kv_cache_gb=kv_gb,
|
|
263
|
+
total_gb=total,
|
|
264
|
+
available_ram_gb=available_ram_gb,
|
|
265
|
+
headroom_gb=headroom,
|
|
266
|
+
verdict=verdict,
|
|
267
|
+
context_length=context_length,
|
|
268
|
+
quant=quant,
|
|
269
|
+
resident=resident,
|
|
270
|
+
decode_ceiling_tok_s=ceiling,
|
|
271
|
+
gpu_layers=gpu_layers,
|
|
272
|
+
gpu_weights_gb=gpu_weights_gb,
|
|
273
|
+
notes=notes,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def max_context_for_budget(
|
|
278
|
+
params_b: float,
|
|
279
|
+
quant: str,
|
|
280
|
+
available_ram_gb: float,
|
|
281
|
+
geometry: ModelGeometry | None = None,
|
|
282
|
+
runtime_reserve_gb: float = DEFAULT_RUNTIME_RESERVE_GB,
|
|
283
|
+
safety_margin_gb: float = DEFAULT_SAFETY_MARGIN_GB,
|
|
284
|
+
model_context_limit: int = 128 * 1024,
|
|
285
|
+
kv_quant: str = "fp16",
|
|
286
|
+
file_size_bytes: int | None = None,
|
|
287
|
+
) -> int:
|
|
288
|
+
"""Largest context length that fits the RAM budget (power-of-two capped).
|
|
289
|
+
|
|
290
|
+
Returns 0 when geometry is unknown — without per-token KV bytes a context
|
|
291
|
+
recommendation would be a guess.
|
|
292
|
+
"""
|
|
293
|
+
if geometry is None or not geometry.is_complete:
|
|
294
|
+
return 0
|
|
295
|
+
weights_gb, _ = estimate_weights_gib(params_b, quant, file_size_bytes)
|
|
296
|
+
budget_gb = available_ram_gb - runtime_reserve_gb - safety_margin_gb - weights_gb
|
|
297
|
+
if budget_gb <= 0:
|
|
298
|
+
return 0
|
|
299
|
+
bytes_per_elem = (
|
|
300
|
+
FP16_BYTES_PER_ELEMENT if kv_quant.lower() in ("fp16", "f16") else KV_BYTES_PER_ELEMENT_Q8
|
|
301
|
+
)
|
|
302
|
+
per_token = geometry.num_layers * geometry.kv_heads * geometry.head_dim * 2 * bytes_per_elem
|
|
303
|
+
if per_token <= 0:
|
|
304
|
+
return 0
|
|
305
|
+
max_ctx = int(budget_gb * 1024**3 / per_token)
|
|
306
|
+
# snap down to a power of two, clamp to model limit and a sane floor
|
|
307
|
+
ctx = 1
|
|
308
|
+
while ctx * 2 <= max_ctx and ctx * 2 <= model_context_limit:
|
|
309
|
+
ctx *= 2
|
|
310
|
+
return max(ctx, 0)
|