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,268 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dynamic layer skipping — decides which transformer layers to skip based on
|
|
3
|
+
per-layer sparsity profiles and input complexity.
|
|
4
|
+
|
|
5
|
+
Transformer models have high layer redundancy: many middle layers produce
|
|
6
|
+
near-identical residual stream updates for routine tokens. Skipping them
|
|
7
|
+
on CPU saves wall-clock time proportional to the skip rate with minimal
|
|
8
|
+
quality loss (typically < 2% perplexity increase at 20-30% skip rate).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import Enum, auto
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SkipStrategy(Enum):
|
|
20
|
+
NONE = auto()
|
|
21
|
+
STATIC = auto()
|
|
22
|
+
ADAPTIVE = auto()
|
|
23
|
+
CALIBRATED = auto()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class LayerProfile:
|
|
28
|
+
index: int
|
|
29
|
+
importance: float
|
|
30
|
+
avg_sparsity: float
|
|
31
|
+
skip_safe: bool
|
|
32
|
+
samples_seen: int = 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SkipPlan:
|
|
37
|
+
total_layers: int
|
|
38
|
+
layers_to_run: list[int]
|
|
39
|
+
layers_to_skip: list[int]
|
|
40
|
+
strategy: SkipStrategy
|
|
41
|
+
expected_speedup: float
|
|
42
|
+
expected_quality_loss: float
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def skip_rate(self) -> float:
|
|
46
|
+
if self.total_layers == 0:
|
|
47
|
+
return 0.0
|
|
48
|
+
return len(self.layers_to_skip) / self.total_layers
|
|
49
|
+
|
|
50
|
+
def summary(self) -> str:
|
|
51
|
+
return (
|
|
52
|
+
f"Skip {len(self.layers_to_skip)}/{self.total_layers} layers "
|
|
53
|
+
f"({self.skip_rate:.0%}) | ~{self.expected_speedup:.1f}x speedup | "
|
|
54
|
+
f"~{self.expected_quality_loss:.1%} quality loss"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LayerSkipScheduler:
|
|
59
|
+
"""
|
|
60
|
+
Determines which transformer layers to skip during inference.
|
|
61
|
+
|
|
62
|
+
Three strategies:
|
|
63
|
+
1. STATIC — skip fixed layers based on position heuristics (no calibration needed)
|
|
64
|
+
2. ADAPTIVE — adjust skip decisions per-token based on running sparsity stats
|
|
65
|
+
3. CALIBRATED — use pre-computed per-layer importance scores from a calibration run
|
|
66
|
+
|
|
67
|
+
The key insight: in most transformer models, the first ~15% and last ~15% of
|
|
68
|
+
layers are critical (embedding projection + output head). The middle layers
|
|
69
|
+
show high redundancy, especially for simple/repetitive tokens.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
CRITICAL_HEAD_RATIO = 0.15
|
|
73
|
+
CRITICAL_TAIL_RATIO = 0.15
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
num_layers: int,
|
|
78
|
+
strategy: SkipStrategy = SkipStrategy.STATIC,
|
|
79
|
+
max_skip_rate: float = 0.3,
|
|
80
|
+
quality_threshold: float = 0.02,
|
|
81
|
+
):
|
|
82
|
+
self.num_layers = num_layers
|
|
83
|
+
self.strategy = strategy
|
|
84
|
+
self.max_skip_rate = max_skip_rate
|
|
85
|
+
self.quality_threshold = quality_threshold
|
|
86
|
+
self._layer_profiles: list[LayerProfile] = []
|
|
87
|
+
self._calibrated = False
|
|
88
|
+
|
|
89
|
+
def plan(self, complexity: float = 0.5) -> SkipPlan:
|
|
90
|
+
"""
|
|
91
|
+
Generate a skip plan for the current inference pass.
|
|
92
|
+
|
|
93
|
+
complexity: 0.0 (trivial repetition) to 1.0 (novel/complex content).
|
|
94
|
+
Lower complexity → more aggressive skipping.
|
|
95
|
+
"""
|
|
96
|
+
if self.num_layers <= 4:
|
|
97
|
+
return self._no_skip_plan()
|
|
98
|
+
|
|
99
|
+
if self.strategy == SkipStrategy.NONE:
|
|
100
|
+
return self._no_skip_plan()
|
|
101
|
+
elif self.strategy == SkipStrategy.STATIC:
|
|
102
|
+
return self._static_plan(complexity)
|
|
103
|
+
elif self.strategy == SkipStrategy.ADAPTIVE:
|
|
104
|
+
return self._adaptive_plan(complexity)
|
|
105
|
+
elif self.strategy == SkipStrategy.CALIBRATED:
|
|
106
|
+
if not self._calibrated:
|
|
107
|
+
return self._static_plan(complexity)
|
|
108
|
+
return self._calibrated_plan(complexity)
|
|
109
|
+
|
|
110
|
+
return self._no_skip_plan()
|
|
111
|
+
|
|
112
|
+
def calibrate(self, layer_importances: list[float]) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Load per-layer importance scores from a calibration run.
|
|
115
|
+
Importance is typically measured as the mean L2 norm of each layer's
|
|
116
|
+
residual stream contribution over a calibration dataset.
|
|
117
|
+
"""
|
|
118
|
+
if len(layer_importances) != self.num_layers:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"Expected {self.num_layers} importance scores, got {len(layer_importances)}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
self._layer_profiles = [
|
|
124
|
+
LayerProfile(
|
|
125
|
+
index=i,
|
|
126
|
+
importance=imp,
|
|
127
|
+
avg_sparsity=0.0,
|
|
128
|
+
skip_safe=(imp < np.median(layer_importances)),
|
|
129
|
+
)
|
|
130
|
+
for i, imp in enumerate(layer_importances)
|
|
131
|
+
]
|
|
132
|
+
self._calibrated = True
|
|
133
|
+
|
|
134
|
+
def update_sparsity(self, layer_index: int, sparsity: float) -> None:
|
|
135
|
+
"""Update running sparsity stats for adaptive strategy."""
|
|
136
|
+
if layer_index >= len(self._layer_profiles):
|
|
137
|
+
while len(self._layer_profiles) <= layer_index:
|
|
138
|
+
self._layer_profiles.append(
|
|
139
|
+
LayerProfile(
|
|
140
|
+
index=len(self._layer_profiles),
|
|
141
|
+
importance=1.0,
|
|
142
|
+
avg_sparsity=0.0,
|
|
143
|
+
skip_safe=False,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
profile = self._layer_profiles[layer_index]
|
|
148
|
+
n = profile.samples_seen
|
|
149
|
+
profile.avg_sparsity = (profile.avg_sparsity * n + sparsity) / (n + 1)
|
|
150
|
+
profile.samples_seen = n + 1
|
|
151
|
+
|
|
152
|
+
if profile.avg_sparsity > 0.7 and profile.samples_seen >= 10:
|
|
153
|
+
profile.skip_safe = True
|
|
154
|
+
|
|
155
|
+
def _no_skip_plan(self) -> SkipPlan:
|
|
156
|
+
return SkipPlan(
|
|
157
|
+
total_layers=self.num_layers,
|
|
158
|
+
layers_to_run=list(range(self.num_layers)),
|
|
159
|
+
layers_to_skip=[],
|
|
160
|
+
strategy=SkipStrategy.NONE,
|
|
161
|
+
expected_speedup=1.0,
|
|
162
|
+
expected_quality_loss=0.0,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _static_plan(self, complexity: float) -> SkipPlan:
|
|
166
|
+
"""Skip middle layers at fixed intervals based on complexity."""
|
|
167
|
+
head = max(1, int(self.num_layers * self.CRITICAL_HEAD_RATIO))
|
|
168
|
+
tail = max(1, int(self.num_layers * self.CRITICAL_TAIL_RATIO))
|
|
169
|
+
tail_start = self.num_layers - tail
|
|
170
|
+
|
|
171
|
+
middle_layers = list(range(head, tail_start))
|
|
172
|
+
if not middle_layers:
|
|
173
|
+
return self._no_skip_plan()
|
|
174
|
+
|
|
175
|
+
adjusted_skip = self.max_skip_rate * (1.0 - complexity)
|
|
176
|
+
max_skippable = max(1, int(len(middle_layers) * adjusted_skip))
|
|
177
|
+
|
|
178
|
+
step = max(1, len(middle_layers) // max_skippable) if max_skippable > 0 else 1
|
|
179
|
+
skip_set = set(middle_layers[::step][:max_skippable])
|
|
180
|
+
|
|
181
|
+
layers_to_run = [i for i in range(self.num_layers) if i not in skip_set]
|
|
182
|
+
layers_to_skip = sorted(skip_set)
|
|
183
|
+
|
|
184
|
+
skip_rate = len(layers_to_skip) / self.num_layers
|
|
185
|
+
speedup = 1.0 / (1.0 - skip_rate) if skip_rate < 1.0 else 1.0
|
|
186
|
+
quality_loss = skip_rate * 0.06
|
|
187
|
+
|
|
188
|
+
return SkipPlan(
|
|
189
|
+
total_layers=self.num_layers,
|
|
190
|
+
layers_to_run=layers_to_run,
|
|
191
|
+
layers_to_skip=layers_to_skip,
|
|
192
|
+
strategy=SkipStrategy.STATIC,
|
|
193
|
+
expected_speedup=speedup,
|
|
194
|
+
expected_quality_loss=quality_loss,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def _adaptive_plan(self, complexity: float) -> SkipPlan:
|
|
198
|
+
"""Skip layers with high observed sparsity."""
|
|
199
|
+
if not self._layer_profiles:
|
|
200
|
+
return self._static_plan(complexity)
|
|
201
|
+
|
|
202
|
+
head = max(1, int(self.num_layers * self.CRITICAL_HEAD_RATIO))
|
|
203
|
+
tail = max(1, int(self.num_layers * self.CRITICAL_TAIL_RATIO))
|
|
204
|
+
tail_start = self.num_layers - tail
|
|
205
|
+
|
|
206
|
+
skip_set = set()
|
|
207
|
+
max_total_skip = int(self.num_layers * self.max_skip_rate * (1.0 - complexity))
|
|
208
|
+
|
|
209
|
+
candidates = [
|
|
210
|
+
p for p in self._layer_profiles if p.skip_safe and head <= p.index < tail_start
|
|
211
|
+
]
|
|
212
|
+
candidates.sort(key=lambda p: p.avg_sparsity, reverse=True)
|
|
213
|
+
|
|
214
|
+
for profile in candidates[:max_total_skip]:
|
|
215
|
+
skip_set.add(profile.index)
|
|
216
|
+
|
|
217
|
+
layers_to_run = [i for i in range(self.num_layers) if i not in skip_set]
|
|
218
|
+
layers_to_skip = sorted(skip_set)
|
|
219
|
+
|
|
220
|
+
skip_rate = len(layers_to_skip) / self.num_layers
|
|
221
|
+
speedup = 1.0 / (1.0 - skip_rate) if skip_rate < 1.0 else 1.0
|
|
222
|
+
quality_loss = skip_rate * 0.04
|
|
223
|
+
|
|
224
|
+
return SkipPlan(
|
|
225
|
+
total_layers=self.num_layers,
|
|
226
|
+
layers_to_run=layers_to_run,
|
|
227
|
+
layers_to_skip=layers_to_skip,
|
|
228
|
+
strategy=SkipStrategy.ADAPTIVE,
|
|
229
|
+
expected_speedup=speedup,
|
|
230
|
+
expected_quality_loss=quality_loss,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _calibrated_plan(self, complexity: float) -> SkipPlan:
|
|
234
|
+
"""Skip layers ranked by calibrated importance scores."""
|
|
235
|
+
head = max(1, int(self.num_layers * self.CRITICAL_HEAD_RATIO))
|
|
236
|
+
tail = max(1, int(self.num_layers * self.CRITICAL_TAIL_RATIO))
|
|
237
|
+
tail_start = self.num_layers - tail
|
|
238
|
+
|
|
239
|
+
middle_profiles = [p for p in self._layer_profiles if head <= p.index < tail_start]
|
|
240
|
+
middle_profiles.sort(key=lambda p: p.importance)
|
|
241
|
+
|
|
242
|
+
max_total_skip = int(self.num_layers * self.max_skip_rate * (1.0 - complexity))
|
|
243
|
+
skip_set = set()
|
|
244
|
+
cumulative_loss = 0.0
|
|
245
|
+
|
|
246
|
+
for profile in middle_profiles:
|
|
247
|
+
estimated_loss = (1.0 - profile.importance) * 0.01
|
|
248
|
+
if cumulative_loss + estimated_loss > self.quality_threshold:
|
|
249
|
+
break
|
|
250
|
+
if len(skip_set) >= max_total_skip:
|
|
251
|
+
break
|
|
252
|
+
skip_set.add(profile.index)
|
|
253
|
+
cumulative_loss += estimated_loss
|
|
254
|
+
|
|
255
|
+
layers_to_run = [i for i in range(self.num_layers) if i not in skip_set]
|
|
256
|
+
layers_to_skip = sorted(skip_set)
|
|
257
|
+
|
|
258
|
+
skip_rate = len(layers_to_skip) / self.num_layers
|
|
259
|
+
speedup = 1.0 / (1.0 - skip_rate) if skip_rate < 1.0 else 1.0
|
|
260
|
+
|
|
261
|
+
return SkipPlan(
|
|
262
|
+
total_layers=self.num_layers,
|
|
263
|
+
layers_to_run=layers_to_run,
|
|
264
|
+
layers_to_skip=layers_to_skip,
|
|
265
|
+
strategy=SkipStrategy.CALIBRATED,
|
|
266
|
+
expected_speedup=speedup,
|
|
267
|
+
expected_quality_loss=cumulative_loss,
|
|
268
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Prompt-lookup decoding (PLD) - Stage C, behind a flag, default OFF.
|
|
3
|
+
|
|
4
|
+
PLD (prompt lookup / SpecInfer-style): when the model is about to emit a span
|
|
5
|
+
that already appears verbatim in the prompt (common in code, quotes,
|
|
6
|
+
boilerplate), copy it directly from the prompt instead of running the model -
|
|
7
|
+
zero backend work for those tokens.
|
|
8
|
+
|
|
9
|
+
This module is a pure, backend-independent matcher: given the prompt text and
|
|
10
|
+
the tokens generated so far, find the longest prompt substring continuing the
|
|
11
|
+
generation. The engine uses it only when prompt_lookup=True.
|
|
12
|
+
|
|
13
|
+
Correctness rule: a copied span must be byte-identical to the prompt span -
|
|
14
|
+
no fuzzy matching, no paraphrase - so output quality is unaffected by
|
|
15
|
+
construction. Speedup is measured, never assumed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def longest_prompt_continuation(prompt: str, generated: str, max_span: int = 512) -> str:
|
|
22
|
+
"""Longest prompt text that continues the current generation.
|
|
23
|
+
|
|
24
|
+
Finds the longest suffix of `generated` that occurs in `prompt` with at
|
|
25
|
+
least one more prompt character after it, and returns the prompt text
|
|
26
|
+
from that point (up to max_span chars). Empty string when nothing matches.
|
|
27
|
+
"""
|
|
28
|
+
if not prompt or not generated:
|
|
29
|
+
return ""
|
|
30
|
+
gen = generated[-max_span:]
|
|
31
|
+
for size in range(min(len(gen), max_span), 0, -1):
|
|
32
|
+
suffix = gen[-size:]
|
|
33
|
+
idx = prompt.find(suffix)
|
|
34
|
+
while idx != -1:
|
|
35
|
+
end = idx + size
|
|
36
|
+
if end < len(prompt):
|
|
37
|
+
return prompt[end : end + max_span]
|
|
38
|
+
idx = prompt.find(suffix, idx + 1)
|
|
39
|
+
return ""
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Layer-pruning experiment — Stage E slice 2 (ShortGPT-style), REPORTED-ONLY.
|
|
3
|
+
|
|
4
|
+
ShortGPT (Men et al., 2024) scores each transformer layer by Block Influence:
|
|
5
|
+
the 1 - cosine similarity between the layer's input and output hidden states,
|
|
6
|
+
averaged over calibration samples. Low-BI layers are candidates for removal.
|
|
7
|
+
|
|
8
|
+
This module computes BI scores, builds a prune plan, and applies the KILL
|
|
9
|
+
CRITERION from the experiment plan: if the measured quality delta (e.g.
|
|
10
|
+
perplexity increase) exceeds `max_quality_delta`, the experiment is aborted
|
|
11
|
+
and NO layers are pruned. Nothing here is wired into inference; the backend
|
|
12
|
+
always runs all layers today.
|
|
13
|
+
|
|
14
|
+
No performance or quality claim is made without the owner's calibration +
|
|
15
|
+
perplexity runs (FINAL_REPORT §9 item 10).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def block_influence(layer_in: np.ndarray, layer_out: np.ndarray, eps: float = 1e-8) -> float:
|
|
26
|
+
"""BI for one layer on one sample: 1 - cos(x_in, x_out).
|
|
27
|
+
|
|
28
|
+
`layer_in`/`layer_out`: (hidden,) vectors. Returns 0 when either is ~zero.
|
|
29
|
+
"""
|
|
30
|
+
x = np.asarray(layer_in, dtype=np.float64).reshape(-1)
|
|
31
|
+
y = np.asarray(layer_out, dtype=np.float64).reshape(-1)
|
|
32
|
+
if x.shape != y.shape or x.size == 0:
|
|
33
|
+
raise ValueError("layer_in/layer_out must be same-shape non-empty vectors")
|
|
34
|
+
nx = float(np.linalg.norm(x))
|
|
35
|
+
ny = float(np.linalg.norm(y))
|
|
36
|
+
if nx < eps or ny < eps:
|
|
37
|
+
return 0.0
|
|
38
|
+
cos = float(np.dot(x, y) / (nx * ny))
|
|
39
|
+
return 1.0 - max(-1.0, min(1.0, cos))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def layer_bi_scores(
|
|
43
|
+
hidden_states: np.ndarray,
|
|
44
|
+
) -> np.ndarray:
|
|
45
|
+
"""Mean BI per layer over samples.
|
|
46
|
+
|
|
47
|
+
`hidden_states`: (num_layers + 1, num_samples, hidden) — activations at each
|
|
48
|
+
layer boundary (0 = model input, L = model output). Layer i's BI uses
|
|
49
|
+
boundary i (input) and i+1 (output). Returns (num_layers,) float64.
|
|
50
|
+
"""
|
|
51
|
+
hs = np.asarray(hidden_states, dtype=np.float64)
|
|
52
|
+
if hs.ndim != 3 or hs.shape[0] < 2:
|
|
53
|
+
raise ValueError("expected (layers+1, samples, hidden)")
|
|
54
|
+
num_layers = hs.shape[0] - 1
|
|
55
|
+
scores = np.zeros(num_layers)
|
|
56
|
+
for i in range(num_layers):
|
|
57
|
+
x = hs[i] # (samples, hidden)
|
|
58
|
+
y = hs[i + 1]
|
|
59
|
+
nx = np.linalg.norm(x, axis=1)
|
|
60
|
+
ny = np.linalg.norm(y, axis=1)
|
|
61
|
+
denom = nx * ny
|
|
62
|
+
cos = np.where(denom > 1e-16, (x * y).sum(axis=1) / np.maximum(denom, 1e-16), 0.0)
|
|
63
|
+
cos = np.clip(cos, -1.0, 1.0)
|
|
64
|
+
scores[i] = float(np.mean(1.0 - cos))
|
|
65
|
+
return scores
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class PrunePlan:
|
|
70
|
+
layers_to_prune: list[int]
|
|
71
|
+
bi_scores: np.ndarray
|
|
72
|
+
max_quality_delta_allowed: float
|
|
73
|
+
killed: bool = False
|
|
74
|
+
kill_reason: str = ""
|
|
75
|
+
notes: list[str] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_prune_plan(
|
|
79
|
+
bi_scores: np.ndarray,
|
|
80
|
+
num_layers: int,
|
|
81
|
+
max_prune_fraction: float = 0.3,
|
|
82
|
+
protect_head: float = 0.15,
|
|
83
|
+
protect_tail: float = 0.15,
|
|
84
|
+
max_quality_delta_allowed: float = 0.02,
|
|
85
|
+
measured_quality_delta: float | None = None,
|
|
86
|
+
) -> PrunePlan:
|
|
87
|
+
"""Rank layers by BI ascending and pick the lowest-BI middle layers.
|
|
88
|
+
|
|
89
|
+
First/last `protect_*` fraction of layers are never pruned (embedding/head
|
|
90
|
+
criticality). Kill criterion: if `measured_quality_delta` is provided and
|
|
91
|
+
exceeds `max_quality_delta_allowed`, the plan is killed (no pruning).
|
|
92
|
+
"""
|
|
93
|
+
scores = np.asarray(bi_scores, dtype=np.float64)
|
|
94
|
+
if scores.shape[0] != num_layers:
|
|
95
|
+
raise ValueError("bi_scores length must equal num_layers")
|
|
96
|
+
if measured_quality_delta is not None and measured_quality_delta > max_quality_delta_allowed:
|
|
97
|
+
return PrunePlan(
|
|
98
|
+
layers_to_prune=[],
|
|
99
|
+
bi_scores=scores,
|
|
100
|
+
max_quality_delta_allowed=max_quality_delta_allowed,
|
|
101
|
+
killed=True,
|
|
102
|
+
kill_reason=(
|
|
103
|
+
f"quality delta {measured_quality_delta:.4f} > allowed "
|
|
104
|
+
f"{max_quality_delta_allowed:.4f} — kill criterion hit, no pruning"
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
n_protect_head = int(num_layers * protect_head)
|
|
108
|
+
n_protect_tail = int(num_layers * protect_tail)
|
|
109
|
+
candidates = list(range(n_protect_head, num_layers - n_protect_tail))
|
|
110
|
+
max_prune = int(num_layers * max_prune_fraction)
|
|
111
|
+
candidates.sort(key=lambda i: scores[i])
|
|
112
|
+
return PrunePlan(
|
|
113
|
+
layers_to_prune=sorted(candidates[:max_prune]),
|
|
114
|
+
bi_scores=scores,
|
|
115
|
+
max_quality_delta_allowed=max_quality_delta_allowed,
|
|
116
|
+
)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sparsity-aware inference — exploits the 70-90% activation sparsity in dense
|
|
3
|
+
transformer models to skip near-zero neuron computations on CPU.
|
|
4
|
+
|
|
5
|
+
GPU warp divergence makes sparse execution inefficient on GPUs (all 32 threads
|
|
6
|
+
in a warp must execute the same instruction). CPUs have fine-grained branch
|
|
7
|
+
prediction — skipping a zero multiply is a correctly-predicted branch that
|
|
8
|
+
costs nearly nothing. This is the core CPU advantage EigenCore exploits.
|
|
9
|
+
|
|
10
|
+
This module provides:
|
|
11
|
+
1. SparsityPredictor: learns per-layer sparsity patterns from observation
|
|
12
|
+
2. SparseExecutionPlan: decides which neurons to skip for a given input
|
|
13
|
+
3. SparsityCache: caches sparsity masks across similar inputs for reuse
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
from collections import OrderedDict
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class NeuronMask:
|
|
27
|
+
layer_index: int
|
|
28
|
+
total_neurons: int
|
|
29
|
+
active_indices: np.ndarray
|
|
30
|
+
sparsity: float
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def active_count(self) -> int:
|
|
34
|
+
return len(self.active_indices)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def skip_count(self) -> int:
|
|
38
|
+
return self.total_neurons - self.active_count
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class SparseExecutionPlan:
|
|
43
|
+
masks: list[NeuronMask]
|
|
44
|
+
total_neurons: int
|
|
45
|
+
total_active: int
|
|
46
|
+
overall_sparsity: float
|
|
47
|
+
estimated_flop_savings: float
|
|
48
|
+
estimated_speedup: float
|
|
49
|
+
|
|
50
|
+
def summary(self) -> str:
|
|
51
|
+
return (
|
|
52
|
+
f"Sparse plan: {self.overall_sparsity:.0%} sparsity across "
|
|
53
|
+
f"{len(self.masks)} layers | {self.estimated_speedup:.1f}x speedup | "
|
|
54
|
+
f"{self.estimated_flop_savings:.0%} FLOPs saved"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SparsityPredictor:
|
|
59
|
+
"""
|
|
60
|
+
Learns and predicts per-layer activation sparsity patterns.
|
|
61
|
+
|
|
62
|
+
Tracks running statistics of which neurons activate (|value| > threshold)
|
|
63
|
+
across multiple inputs. After enough observations, it can predict which
|
|
64
|
+
neurons are likely inactive for a new input, enabling preemptive skipping.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
num_layers: int,
|
|
70
|
+
neurons_per_layer: int,
|
|
71
|
+
threshold: float = 0.01,
|
|
72
|
+
warmup_samples: int = 16,
|
|
73
|
+
):
|
|
74
|
+
self.num_layers = num_layers
|
|
75
|
+
self.neurons_per_layer = neurons_per_layer
|
|
76
|
+
self.threshold = threshold
|
|
77
|
+
self.warmup_samples = warmup_samples
|
|
78
|
+
self._activation_counts = np.zeros((num_layers, neurons_per_layer), dtype=np.int32)
|
|
79
|
+
self._total_samples = 0
|
|
80
|
+
self._layer_sparsity_history: list[list[float]] = [[] for _ in range(num_layers)]
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_warmed_up(self) -> bool:
|
|
84
|
+
return self._total_samples >= self.warmup_samples
|
|
85
|
+
|
|
86
|
+
def observe(self, layer_index: int, activations: np.ndarray) -> float:
|
|
87
|
+
"""
|
|
88
|
+
Record which neurons activated for this input.
|
|
89
|
+
Returns the observed sparsity for this layer.
|
|
90
|
+
"""
|
|
91
|
+
if layer_index >= self.num_layers:
|
|
92
|
+
raise ValueError(f"Layer {layer_index} >= num_layers {self.num_layers}")
|
|
93
|
+
|
|
94
|
+
abs_acts = np.abs(activations)
|
|
95
|
+
active_mask = abs_acts >= self.threshold
|
|
96
|
+
active_count = int(np.sum(active_mask))
|
|
97
|
+
sparsity = 1.0 - (active_count / len(activations)) if len(activations) > 0 else 0.0
|
|
98
|
+
|
|
99
|
+
n = min(len(activations), self.neurons_per_layer)
|
|
100
|
+
self._activation_counts[layer_index, :n] += active_mask[:n].astype(np.int32)
|
|
101
|
+
self._total_samples += 1
|
|
102
|
+
self._layer_sparsity_history[layer_index].append(sparsity)
|
|
103
|
+
|
|
104
|
+
return sparsity
|
|
105
|
+
|
|
106
|
+
def predict_mask(self, layer_index: int, aggressiveness: float = 0.5) -> NeuronMask:
|
|
107
|
+
"""
|
|
108
|
+
Predict which neurons will be active for the next input.
|
|
109
|
+
|
|
110
|
+
aggressiveness: 0.0 (conservative, skip only always-zero neurons)
|
|
111
|
+
to 1.0 (aggressive, skip anything below median activation rate)
|
|
112
|
+
"""
|
|
113
|
+
if not self.is_warmed_up:
|
|
114
|
+
return NeuronMask(
|
|
115
|
+
layer_index=layer_index,
|
|
116
|
+
total_neurons=self.neurons_per_layer,
|
|
117
|
+
active_indices=np.arange(self.neurons_per_layer),
|
|
118
|
+
sparsity=0.0,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
activation_rates = self._activation_counts[layer_index] / self._total_samples
|
|
122
|
+
cutoff = aggressiveness * np.median(activation_rates)
|
|
123
|
+
active_indices = np.where(activation_rates > cutoff)[0]
|
|
124
|
+
|
|
125
|
+
sparsity = 1.0 - (len(active_indices) / self.neurons_per_layer)
|
|
126
|
+
|
|
127
|
+
return NeuronMask(
|
|
128
|
+
layer_index=layer_index,
|
|
129
|
+
total_neurons=self.neurons_per_layer,
|
|
130
|
+
active_indices=active_indices,
|
|
131
|
+
sparsity=sparsity,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def create_execution_plan(self, aggressiveness: float = 0.5) -> SparseExecutionPlan:
|
|
135
|
+
"""Generate a full execution plan across all layers."""
|
|
136
|
+
masks = []
|
|
137
|
+
total_neurons = 0
|
|
138
|
+
total_active = 0
|
|
139
|
+
|
|
140
|
+
for layer_idx in range(self.num_layers):
|
|
141
|
+
mask = self.predict_mask(layer_idx, aggressiveness)
|
|
142
|
+
masks.append(mask)
|
|
143
|
+
total_neurons += mask.total_neurons
|
|
144
|
+
total_active += mask.active_count
|
|
145
|
+
|
|
146
|
+
overall_sparsity = 1.0 - (total_active / total_neurons) if total_neurons > 0 else 0.0
|
|
147
|
+
flop_savings = overall_sparsity
|
|
148
|
+
speedup = 1.0 / (1.0 - overall_sparsity) if overall_sparsity < 1.0 else 1.0
|
|
149
|
+
speedup = min(speedup, 5.0)
|
|
150
|
+
|
|
151
|
+
return SparseExecutionPlan(
|
|
152
|
+
masks=masks,
|
|
153
|
+
total_neurons=total_neurons,
|
|
154
|
+
total_active=total_active,
|
|
155
|
+
overall_sparsity=overall_sparsity,
|
|
156
|
+
estimated_flop_savings=flop_savings,
|
|
157
|
+
estimated_speedup=speedup,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def layer_stats(self) -> list[dict]:
|
|
161
|
+
"""Per-layer statistics summary."""
|
|
162
|
+
stats = []
|
|
163
|
+
for i in range(self.num_layers):
|
|
164
|
+
history = self._layer_sparsity_history[i]
|
|
165
|
+
stats.append(
|
|
166
|
+
{
|
|
167
|
+
"layer": i,
|
|
168
|
+
"avg_sparsity": float(np.mean(history)) if history else 0.0,
|
|
169
|
+
"std_sparsity": float(np.std(history)) if history else 0.0,
|
|
170
|
+
"samples": len(history),
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
return stats
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class SparsityCache:
|
|
177
|
+
"""
|
|
178
|
+
Caches sparsity masks for similar inputs to avoid recomputation.
|
|
179
|
+
|
|
180
|
+
Uses a hash of the input token sequence to look up previously computed
|
|
181
|
+
masks. LRU eviction keeps memory bounded.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def __init__(self, max_entries: int = 256):
|
|
185
|
+
self.max_entries = max_entries
|
|
186
|
+
self._cache: OrderedDict[str, SparseExecutionPlan] = OrderedDict()
|
|
187
|
+
self.hits = 0
|
|
188
|
+
self.misses = 0
|
|
189
|
+
|
|
190
|
+
def _hash_input(self, token_ids: list[int]) -> str:
|
|
191
|
+
key = ",".join(str(t) for t in token_ids[-32:])
|
|
192
|
+
return hashlib.md5(key.encode()).hexdigest()
|
|
193
|
+
|
|
194
|
+
def get(self, token_ids: list[int]) -> SparseExecutionPlan | None:
|
|
195
|
+
key = self._hash_input(token_ids)
|
|
196
|
+
if key in self._cache:
|
|
197
|
+
self.hits += 1
|
|
198
|
+
self._cache.move_to_end(key)
|
|
199
|
+
return self._cache[key]
|
|
200
|
+
self.misses += 1
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
def put(self, token_ids: list[int], plan: SparseExecutionPlan) -> None:
|
|
204
|
+
key = self._hash_input(token_ids)
|
|
205
|
+
self._cache[key] = plan
|
|
206
|
+
self._cache.move_to_end(key)
|
|
207
|
+
while len(self._cache) > self.max_entries:
|
|
208
|
+
self._cache.popitem(last=False)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def hit_rate(self) -> float:
|
|
212
|
+
total = self.hits + self.misses
|
|
213
|
+
return self.hits / total if total > 0 else 0.0
|
|
214
|
+
|
|
215
|
+
def clear(self) -> None:
|
|
216
|
+
self._cache.clear()
|
|
217
|
+
self.hits = 0
|
|
218
|
+
self.misses = 0
|