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,318 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Attention-aware context manager — compresses conversation context while
|
|
3
|
+
preserving the tokens that matter most for generation quality.
|
|
4
|
+
|
|
5
|
+
Key insight (Gap 2): The first 3-4 tokens in any sequence act as "attention
|
|
6
|
+
sinks" — they absorb disproportionate attention weight regardless of content.
|
|
7
|
+
Deleting them destabilizes generation. Every other tool just truncates from
|
|
8
|
+
the beginning. This manager preserves sink tokens, compresses middle context,
|
|
9
|
+
and keeps recent context intact.
|
|
10
|
+
|
|
11
|
+
Result: stable generation quality at ~50% the context length, letting a 16GB
|
|
12
|
+
RAM machine maintain conversations that would normally need 32GB of context.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import sqlite3
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Message:
|
|
27
|
+
role: str # "system", "user", "assistant"
|
|
28
|
+
content: str
|
|
29
|
+
timestamp: float = field(default_factory=time.time)
|
|
30
|
+
token_estimate: int = 0
|
|
31
|
+
compressed: bool = False
|
|
32
|
+
message_id: str = ""
|
|
33
|
+
|
|
34
|
+
def __post_init__(self):
|
|
35
|
+
if not self.token_estimate:
|
|
36
|
+
self.token_estimate = self._estimate_tokens(self.content)
|
|
37
|
+
if not self.message_id:
|
|
38
|
+
h = hashlib.md5(f"{self.timestamp}{self.content[:50]}".encode()).hexdigest()[:12]
|
|
39
|
+
self.message_id = h
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _estimate_tokens(text: str) -> int:
|
|
43
|
+
"""Rough token estimate: ~4 chars per token for English."""
|
|
44
|
+
return max(len(text) // 4, 1)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ContextWindow:
|
|
49
|
+
"""Represents the current context state with compression metadata."""
|
|
50
|
+
|
|
51
|
+
messages: list[Message]
|
|
52
|
+
total_tokens: int
|
|
53
|
+
max_tokens: int
|
|
54
|
+
compression_ratio: float
|
|
55
|
+
sink_tokens_preserved: int
|
|
56
|
+
messages_compressed: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ContextManager:
|
|
60
|
+
"""
|
|
61
|
+
Manages conversation context with attention-aware compression.
|
|
62
|
+
|
|
63
|
+
Compression strategy (from most to least aggressive):
|
|
64
|
+
1. PRESERVE: Always keep the first system message + first user message
|
|
65
|
+
(these occupy the "attention sink" positions 0-4 in the token stream)
|
|
66
|
+
2. PRESERVE: Always keep the most recent N messages (active working memory)
|
|
67
|
+
3. COMPRESS: Middle messages get summarized into shorter versions
|
|
68
|
+
4. DROP: If still over budget after compression, drop oldest middle messages
|
|
69
|
+
|
|
70
|
+
Also provides persistent session storage via SQLite.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
SINK_MESSAGES = 2 # first system + first user message to always preserve
|
|
74
|
+
RECENT_MESSAGES = 6 # recent messages to always keep uncompressed
|
|
75
|
+
COMPRESSION_TARGET = 0.4 # compress middle messages to 40% of original size
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
max_context_tokens: int = 2048,
|
|
80
|
+
db_path: Optional[Path] = None,
|
|
81
|
+
):
|
|
82
|
+
self.max_context_tokens = max_context_tokens
|
|
83
|
+
self.messages: list[Message] = []
|
|
84
|
+
self._db: Optional[sqlite3.Connection] = None
|
|
85
|
+
if db_path:
|
|
86
|
+
self._init_db(db_path)
|
|
87
|
+
|
|
88
|
+
def _init_db(self, db_path: Path) -> None:
|
|
89
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
self._db = sqlite3.connect(str(db_path))
|
|
91
|
+
self._db.execute("""
|
|
92
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
93
|
+
id TEXT PRIMARY KEY,
|
|
94
|
+
session_id TEXT NOT NULL,
|
|
95
|
+
role TEXT NOT NULL,
|
|
96
|
+
content TEXT NOT NULL,
|
|
97
|
+
timestamp REAL NOT NULL,
|
|
98
|
+
token_estimate INTEGER NOT NULL,
|
|
99
|
+
compressed INTEGER DEFAULT 0
|
|
100
|
+
)
|
|
101
|
+
""")
|
|
102
|
+
self._db.execute("""
|
|
103
|
+
CREATE INDEX IF NOT EXISTS idx_session
|
|
104
|
+
ON messages(session_id, timestamp)
|
|
105
|
+
""")
|
|
106
|
+
self._db.commit()
|
|
107
|
+
|
|
108
|
+
def add(self, role: str, content: str) -> Message:
|
|
109
|
+
msg = Message(role=role, content=content)
|
|
110
|
+
self.messages.append(msg)
|
|
111
|
+
return msg
|
|
112
|
+
|
|
113
|
+
def get_context(self) -> ContextWindow:
|
|
114
|
+
"""
|
|
115
|
+
Build the context window with attention-aware compression.
|
|
116
|
+
Returns messages ready to send to the model, respecting token budget.
|
|
117
|
+
"""
|
|
118
|
+
if not self.messages:
|
|
119
|
+
return ContextWindow(
|
|
120
|
+
messages=[],
|
|
121
|
+
total_tokens=0,
|
|
122
|
+
max_tokens=self.max_context_tokens,
|
|
123
|
+
compression_ratio=1.0,
|
|
124
|
+
sink_tokens_preserved=0,
|
|
125
|
+
messages_compressed=0,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
total = sum(m.token_estimate for m in self.messages)
|
|
129
|
+
|
|
130
|
+
# if everything fits, no compression needed
|
|
131
|
+
if total <= self.max_context_tokens:
|
|
132
|
+
return ContextWindow(
|
|
133
|
+
messages=list(self.messages),
|
|
134
|
+
total_tokens=total,
|
|
135
|
+
max_tokens=self.max_context_tokens,
|
|
136
|
+
compression_ratio=1.0,
|
|
137
|
+
sink_tokens_preserved=0,
|
|
138
|
+
messages_compressed=0,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# split into three zones
|
|
142
|
+
sink = self.messages[: self.SINK_MESSAGES]
|
|
143
|
+
n_recent = min(self.RECENT_MESSAGES, len(self.messages) - self.SINK_MESSAGES)
|
|
144
|
+
recent = self.messages[-n_recent:] if n_recent > 0 else []
|
|
145
|
+
middle_end = len(self.messages) - n_recent if n_recent > 0 else len(self.messages)
|
|
146
|
+
middle = self.messages[self.SINK_MESSAGES : middle_end]
|
|
147
|
+
|
|
148
|
+
sink_tokens = sum(m.token_estimate for m in sink)
|
|
149
|
+
recent_tokens = sum(m.token_estimate for m in recent)
|
|
150
|
+
|
|
151
|
+
# if sink + recent alone exceed budget, trim recent count
|
|
152
|
+
while sink_tokens + recent_tokens > self.max_context_tokens and len(recent) > 1:
|
|
153
|
+
dropped = recent.pop(0)
|
|
154
|
+
recent_tokens -= dropped.token_estimate
|
|
155
|
+
|
|
156
|
+
budget_for_middle = max(self.max_context_tokens - sink_tokens - recent_tokens, 0)
|
|
157
|
+
|
|
158
|
+
compressed_middle, middle_compressed_count = self._compress_middle(
|
|
159
|
+
middle, budget_for_middle
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
result = sink + compressed_middle + recent
|
|
163
|
+
result_tokens = sum(m.token_estimate for m in result)
|
|
164
|
+
|
|
165
|
+
# final safety: if still over, drop middle entirely
|
|
166
|
+
if result_tokens > self.max_context_tokens and compressed_middle:
|
|
167
|
+
result = sink + recent
|
|
168
|
+
result_tokens = sum(m.token_estimate for m in result)
|
|
169
|
+
middle_compressed_count = len(middle)
|
|
170
|
+
|
|
171
|
+
return ContextWindow(
|
|
172
|
+
messages=result,
|
|
173
|
+
total_tokens=result_tokens,
|
|
174
|
+
max_tokens=self.max_context_tokens,
|
|
175
|
+
compression_ratio=result_tokens / total if total > 0 else 1.0,
|
|
176
|
+
sink_tokens_preserved=sink_tokens,
|
|
177
|
+
messages_compressed=middle_compressed_count,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def _compress_middle(
|
|
181
|
+
self, messages: list[Message], token_budget: int
|
|
182
|
+
) -> tuple[list[Message], int]:
|
|
183
|
+
"""
|
|
184
|
+
Compress middle messages to fit within token budget.
|
|
185
|
+
Strategy: truncate each message proportionally, keeping the first
|
|
186
|
+
and last sentences (they tend to carry the most information).
|
|
187
|
+
"""
|
|
188
|
+
if not messages:
|
|
189
|
+
return [], 0
|
|
190
|
+
|
|
191
|
+
if token_budget <= 0:
|
|
192
|
+
# no room for middle context — create a single summary placeholder
|
|
193
|
+
combined = " | ".join(f"[{m.role}]: {m.content[:30]}..." for m in messages[:5])
|
|
194
|
+
summary = Message(
|
|
195
|
+
role="system",
|
|
196
|
+
content=f"[Earlier context compressed: {len(messages)} messages] {combined}",
|
|
197
|
+
compressed=True,
|
|
198
|
+
)
|
|
199
|
+
return [summary], len(messages)
|
|
200
|
+
|
|
201
|
+
total_middle_tokens = sum(m.token_estimate for m in messages)
|
|
202
|
+
|
|
203
|
+
if total_middle_tokens <= token_budget:
|
|
204
|
+
return list(messages), 0
|
|
205
|
+
|
|
206
|
+
# compress each message proportionally
|
|
207
|
+
ratio = token_budget / total_middle_tokens
|
|
208
|
+
compressed = []
|
|
209
|
+
count = 0
|
|
210
|
+
|
|
211
|
+
for msg in messages:
|
|
212
|
+
target_chars = int(len(msg.content) * ratio)
|
|
213
|
+
if target_chars < 50:
|
|
214
|
+
# too small to be useful — skip but note it
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
truncated = self._smart_truncate(msg.content, target_chars)
|
|
218
|
+
compressed.append(
|
|
219
|
+
Message(
|
|
220
|
+
role=msg.role,
|
|
221
|
+
content=truncated,
|
|
222
|
+
timestamp=msg.timestamp,
|
|
223
|
+
compressed=True,
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
count += 1
|
|
227
|
+
|
|
228
|
+
# if we still exceed budget after proportional compression, drop oldest
|
|
229
|
+
while sum(m.token_estimate for m in compressed) > token_budget and len(compressed) > 1:
|
|
230
|
+
compressed.pop(0)
|
|
231
|
+
count += 1
|
|
232
|
+
|
|
233
|
+
return compressed, count
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _smart_truncate(text: str, target_chars: int) -> str:
|
|
237
|
+
"""
|
|
238
|
+
Truncate text keeping the first and last sentences.
|
|
239
|
+
These carry the most semantic weight (topic introduction + conclusion).
|
|
240
|
+
"""
|
|
241
|
+
if len(text) <= target_chars:
|
|
242
|
+
return text
|
|
243
|
+
|
|
244
|
+
sentences = text.replace("! ", ".\n").replace("? ", ".\n").split(". ")
|
|
245
|
+
if len(sentences) <= 2:
|
|
246
|
+
return text[:target_chars] + "..."
|
|
247
|
+
|
|
248
|
+
half = target_chars // 2
|
|
249
|
+
first_part = text[:half]
|
|
250
|
+
last_part = text[-half:]
|
|
251
|
+
|
|
252
|
+
# find clean sentence boundary in first part
|
|
253
|
+
last_period = first_part.rfind(". ")
|
|
254
|
+
if last_period > half // 2:
|
|
255
|
+
first_part = first_part[: last_period + 1]
|
|
256
|
+
|
|
257
|
+
# find clean sentence boundary in last part
|
|
258
|
+
first_period = last_part.find(". ")
|
|
259
|
+
if first_period > 0 and first_period < len(last_part) // 2:
|
|
260
|
+
last_part = last_part[first_period + 2 :]
|
|
261
|
+
|
|
262
|
+
return f"{first_part} [...] {last_part}"
|
|
263
|
+
|
|
264
|
+
def save_session(self, session_id: str) -> None:
|
|
265
|
+
"""Persist current messages to SQLite."""
|
|
266
|
+
if not self._db:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
for msg in self.messages:
|
|
270
|
+
self._db.execute(
|
|
271
|
+
"INSERT OR REPLACE INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
272
|
+
(
|
|
273
|
+
msg.message_id,
|
|
274
|
+
session_id,
|
|
275
|
+
msg.role,
|
|
276
|
+
msg.content,
|
|
277
|
+
msg.timestamp,
|
|
278
|
+
msg.token_estimate,
|
|
279
|
+
int(msg.compressed),
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
self._db.commit()
|
|
283
|
+
|
|
284
|
+
def load_session(self, session_id: str) -> int:
|
|
285
|
+
"""Load messages from a previous session. Returns count loaded."""
|
|
286
|
+
if not self._db:
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
cursor = self._db.execute(
|
|
290
|
+
"SELECT role, content, timestamp, token_estimate, compressed, id "
|
|
291
|
+
"FROM messages WHERE session_id = ? ORDER BY timestamp",
|
|
292
|
+
(session_id,),
|
|
293
|
+
)
|
|
294
|
+
self.messages.clear()
|
|
295
|
+
count = 0
|
|
296
|
+
for row in cursor:
|
|
297
|
+
msg = Message(
|
|
298
|
+
role=row[0],
|
|
299
|
+
content=row[1],
|
|
300
|
+
timestamp=row[2],
|
|
301
|
+
token_estimate=row[3],
|
|
302
|
+
compressed=bool(row[4]),
|
|
303
|
+
message_id=row[5],
|
|
304
|
+
)
|
|
305
|
+
self.messages.append(msg)
|
|
306
|
+
count += 1
|
|
307
|
+
return count
|
|
308
|
+
|
|
309
|
+
def clear(self) -> None:
|
|
310
|
+
self.messages.clear()
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def token_count(self) -> int:
|
|
314
|
+
return sum(m.token_estimate for m in self.messages)
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def message_count(self) -> int:
|
|
318
|
+
return len(self.messages)
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Inference engine — wraps llama.cpp with hardware-aware configuration.
|
|
3
|
+
Handles model loading, generation, and resource management.
|
|
4
|
+
|
|
5
|
+
Phase 2 integration: layer skipping, sparsity analysis, and speculative
|
|
6
|
+
decoding hooks are initialized on model load and report stats via
|
|
7
|
+
the `optimization_stats` property.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from eigencore.engine.layer_skip import LayerSkipScheduler, SkipStrategy
|
|
18
|
+
from eigencore.engine.prompt_lookup import longest_prompt_continuation
|
|
19
|
+
from eigencore.engine.sparse_inference import SparsityCache, SparsityPredictor
|
|
20
|
+
from eigencore.engine.speculative import SpeculativeDecoder
|
|
21
|
+
from eigencore.hal.profiler import HardwareProfile, InstructionSet
|
|
22
|
+
from eigencore.models.registry import ModelSpec
|
|
23
|
+
|
|
24
|
+
# llama-cpp-python expects an int KV type; map our string names.
|
|
25
|
+
_KV_TYPE_MAP = {"fp16": 0, "q8_0": 1}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class GenerationConfig:
|
|
30
|
+
max_tokens: int = 512
|
|
31
|
+
temperature: float = 0.7
|
|
32
|
+
top_p: float = 0.9
|
|
33
|
+
top_k: int = 40
|
|
34
|
+
repeat_penalty: float = 1.1
|
|
35
|
+
stop: list[str] = field(default_factory=list)
|
|
36
|
+
stream: bool = True
|
|
37
|
+
enable_layer_skip: bool = True
|
|
38
|
+
enable_sparse_inference: bool = True
|
|
39
|
+
complexity: float = 0.5
|
|
40
|
+
# Prompt-lookup decoding: copy prompt-verbatim spans instead of decoding
|
|
41
|
+
# them. Default OFF; enable only after owner benchmarks (FINAL_REPORT §9 item 9).
|
|
42
|
+
prompt_lookup: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class GenerationResult:
|
|
47
|
+
text: str
|
|
48
|
+
tokens_generated: int
|
|
49
|
+
time_seconds: float
|
|
50
|
+
tokens_per_second: float
|
|
51
|
+
prompt_tokens: int
|
|
52
|
+
optimization_stats: dict | None = None
|
|
53
|
+
# Optimizations actually applied to the backend call. Empty today:
|
|
54
|
+
# layer-skip/sparsity plans and the speculative decoder are computed and
|
|
55
|
+
# reported but NOT wired into create_chat_completion yet.
|
|
56
|
+
applied_optimizations: list[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InferenceEngine:
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
model_path: Path,
|
|
63
|
+
profile: HardwareProfile,
|
|
64
|
+
model_spec: ModelSpec | None = None,
|
|
65
|
+
kv_type: str = "fp16",
|
|
66
|
+
flash_attn: bool = True,
|
|
67
|
+
n_gpu_layers: int = 0,
|
|
68
|
+
):
|
|
69
|
+
self.model_path = model_path
|
|
70
|
+
self.profile = profile
|
|
71
|
+
self.model_spec = model_spec
|
|
72
|
+
# Load-time backend options: KV cache type ("fp16" default; "q8_0"
|
|
73
|
+
# halves KV memory at a small quality cost) and flash attention.
|
|
74
|
+
self.kv_type = kv_type
|
|
75
|
+
self.flash_attn = flash_attn
|
|
76
|
+
# 0 = CPU-only (default, safe). Set from the fit estimator's
|
|
77
|
+
# gpu_layers_for_vram when a VRAM budget exists.
|
|
78
|
+
self.n_gpu_layers = n_gpu_layers
|
|
79
|
+
self._llm = None
|
|
80
|
+
|
|
81
|
+
self._layer_skipper: LayerSkipScheduler | None = None
|
|
82
|
+
self._sparsity_predictor: SparsityPredictor | None = None
|
|
83
|
+
self._sparsity_cache = SparsityCache()
|
|
84
|
+
self._speculative_decoder = SpeculativeDecoder(adaptive=True)
|
|
85
|
+
self.last_pld_copied_chars = 0
|
|
86
|
+
|
|
87
|
+
def load(self) -> None:
|
|
88
|
+
"""Load the model with hardware-optimized settings."""
|
|
89
|
+
try:
|
|
90
|
+
from llama_cpp import Llama
|
|
91
|
+
except ImportError:
|
|
92
|
+
raise ImportError(
|
|
93
|
+
"llama-cpp-python is required for inference. "
|
|
94
|
+
"Install with: pip install llama-cpp-python"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
ctx_length = self.profile.recommended_context_length
|
|
98
|
+
if self.model_spec and self.model_spec.context_length < ctx_length:
|
|
99
|
+
ctx_length = self.model_spec.context_length
|
|
100
|
+
|
|
101
|
+
n_threads = self.profile.recommended_threads
|
|
102
|
+
n_batch = self._compute_batch_size()
|
|
103
|
+
|
|
104
|
+
self._llm = Llama(
|
|
105
|
+
model_path=str(self.model_path),
|
|
106
|
+
n_ctx=ctx_length,
|
|
107
|
+
n_threads=n_threads,
|
|
108
|
+
n_threads_batch=n_threads,
|
|
109
|
+
n_batch=n_batch,
|
|
110
|
+
n_gpu_layers=self.n_gpu_layers,
|
|
111
|
+
type_kv=_KV_TYPE_MAP[self.kv_type],
|
|
112
|
+
flash_attn=self.flash_attn,
|
|
113
|
+
verbose=False,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
self._init_optimizers()
|
|
117
|
+
|
|
118
|
+
def _init_optimizers(self) -> None:
|
|
119
|
+
"""Initialize Phase 2 optimization modules based on loaded model.
|
|
120
|
+
|
|
121
|
+
Metadata keys are architecture-prefixed (`{arch}.block_count`, ...),
|
|
122
|
+
not always `llama.*` — resolve via the shared GGUF helper, falling
|
|
123
|
+
back to raw metadata with an architecture prefix.
|
|
124
|
+
"""
|
|
125
|
+
from eigencore.fit.gguf_meta import read_gguf_model_info
|
|
126
|
+
|
|
127
|
+
info = read_gguf_model_info(self.model_path)
|
|
128
|
+
if info is not None:
|
|
129
|
+
num_layers = info.geometry.num_layers or 0
|
|
130
|
+
hidden_size = info.geometry.hidden_size or 0
|
|
131
|
+
else:
|
|
132
|
+
metadata = dict(self._llm.metadata) if self._llm else {}
|
|
133
|
+
arch = metadata.get("general.architecture", "llama")
|
|
134
|
+
num_layers = int(
|
|
135
|
+
metadata.get(f"{arch}.block_count", metadata.get("llama.block_count", 0))
|
|
136
|
+
)
|
|
137
|
+
hidden_size = int(
|
|
138
|
+
metadata.get(f"{arch}.embedding_length", metadata.get("llama.embedding_length", 0))
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if num_layers > 0:
|
|
142
|
+
self._layer_skipper = LayerSkipScheduler(
|
|
143
|
+
num_layers=num_layers,
|
|
144
|
+
strategy=SkipStrategy.STATIC,
|
|
145
|
+
max_skip_rate=0.3,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if hidden_size > 0 and num_layers > 0:
|
|
149
|
+
self._sparsity_predictor = SparsityPredictor(
|
|
150
|
+
num_layers=num_layers,
|
|
151
|
+
neurons_per_layer=hidden_size,
|
|
152
|
+
warmup_samples=16,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _compute_batch_size(self) -> int:
|
|
156
|
+
"""Determine optimal batch size based on available RAM and ISA."""
|
|
157
|
+
if InstructionSet.AVX512F in self.profile.instruction_sets:
|
|
158
|
+
return 1024
|
|
159
|
+
elif InstructionSet.AVX2 in self.profile.instruction_sets:
|
|
160
|
+
return 512
|
|
161
|
+
else:
|
|
162
|
+
return 256
|
|
163
|
+
|
|
164
|
+
def generate(
|
|
165
|
+
self,
|
|
166
|
+
prompt: str,
|
|
167
|
+
config: GenerationConfig | None = None,
|
|
168
|
+
) -> GenerationResult:
|
|
169
|
+
"""Generate a complete response (non-streaming)."""
|
|
170
|
+
if self._llm is None:
|
|
171
|
+
raise RuntimeError("Model not loaded. Call .load() first.")
|
|
172
|
+
|
|
173
|
+
if config is None:
|
|
174
|
+
config = GenerationConfig(stream=False)
|
|
175
|
+
|
|
176
|
+
messages = [{"role": "user", "content": prompt}]
|
|
177
|
+
return self.chat(messages, config)
|
|
178
|
+
|
|
179
|
+
def stream(
|
|
180
|
+
self,
|
|
181
|
+
prompt: str,
|
|
182
|
+
config: GenerationConfig | None = None,
|
|
183
|
+
) -> Iterator[str]:
|
|
184
|
+
"""Stream tokens as they're generated."""
|
|
185
|
+
if self._llm is None:
|
|
186
|
+
raise RuntimeError("Model not loaded. Call .load() first.")
|
|
187
|
+
|
|
188
|
+
if config is None:
|
|
189
|
+
config = GenerationConfig(stream=True)
|
|
190
|
+
|
|
191
|
+
messages = [{"role": "user", "content": prompt}]
|
|
192
|
+
|
|
193
|
+
for chunk in self._llm.create_chat_completion(
|
|
194
|
+
messages=messages,
|
|
195
|
+
max_tokens=config.max_tokens,
|
|
196
|
+
temperature=config.temperature,
|
|
197
|
+
top_p=config.top_p,
|
|
198
|
+
top_k=config.top_k,
|
|
199
|
+
repeat_penalty=config.repeat_penalty,
|
|
200
|
+
stop=config.stop or None,
|
|
201
|
+
stream=True,
|
|
202
|
+
):
|
|
203
|
+
delta = chunk["choices"][0].get("delta", {})
|
|
204
|
+
token = delta.get("content", "")
|
|
205
|
+
if token:
|
|
206
|
+
yield token
|
|
207
|
+
|
|
208
|
+
def stream_messages(
|
|
209
|
+
self,
|
|
210
|
+
messages: list[dict[str, str]],
|
|
211
|
+
config: GenerationConfig | None = None,
|
|
212
|
+
) -> Iterator[str]:
|
|
213
|
+
"""Stream a chat completion from a full message list (multi-turn).
|
|
214
|
+
|
|
215
|
+
When config.prompt_lookup is set, prompt-verbatim continuations are
|
|
216
|
+
emitted directly (byte-identical copy, see engine.prompt_lookup);
|
|
217
|
+
copied characters are reported via `last_pld_copied_chars`.
|
|
218
|
+
"""
|
|
219
|
+
if self._llm is None:
|
|
220
|
+
raise RuntimeError("Model not loaded. Call .load() first.")
|
|
221
|
+
if config is None:
|
|
222
|
+
config = GenerationConfig(stream=True)
|
|
223
|
+
prompt_text = " ".join(m.get("content", "") for m in messages)
|
|
224
|
+
generated = ""
|
|
225
|
+
self.last_pld_copied_chars = 0
|
|
226
|
+
pending_skip = 0 # backend chars to swallow (already copied from prompt)
|
|
227
|
+
for chunk in self._llm.create_chat_completion(
|
|
228
|
+
messages=messages,
|
|
229
|
+
max_tokens=config.max_tokens,
|
|
230
|
+
temperature=config.temperature,
|
|
231
|
+
top_p=config.top_p,
|
|
232
|
+
top_k=config.top_k,
|
|
233
|
+
repeat_penalty=config.repeat_penalty,
|
|
234
|
+
stop=config.stop or None,
|
|
235
|
+
stream=True,
|
|
236
|
+
):
|
|
237
|
+
delta = chunk["choices"][0].get("delta", {})
|
|
238
|
+
token = delta.get("content", "")
|
|
239
|
+
if token:
|
|
240
|
+
if pending_skip > 0:
|
|
241
|
+
# this backend token is part of a span we already copied
|
|
242
|
+
pending_skip -= len(token)
|
|
243
|
+
continue
|
|
244
|
+
if config.prompt_lookup:
|
|
245
|
+
continuation = longest_prompt_continuation(prompt_text, generated + token)
|
|
246
|
+
if continuation:
|
|
247
|
+
# The prompt continues the generation verbatim: emit the
|
|
248
|
+
# copied span after this token and swallow its backend copy.
|
|
249
|
+
pending_skip = len(continuation)
|
|
250
|
+
self.last_pld_copied_chars += len(continuation)
|
|
251
|
+
generated += token + continuation
|
|
252
|
+
yield token
|
|
253
|
+
yield continuation
|
|
254
|
+
continue
|
|
255
|
+
generated += token
|
|
256
|
+
yield token
|
|
257
|
+
|
|
258
|
+
def chat(
|
|
259
|
+
self,
|
|
260
|
+
messages: list[dict[str, str]],
|
|
261
|
+
config: GenerationConfig | None = None,
|
|
262
|
+
) -> GenerationResult:
|
|
263
|
+
"""Chat-style completion with message list."""
|
|
264
|
+
if self._llm is None:
|
|
265
|
+
raise RuntimeError("Model not loaded. Call .load() first.")
|
|
266
|
+
|
|
267
|
+
if config is None:
|
|
268
|
+
config = GenerationConfig(stream=False)
|
|
269
|
+
|
|
270
|
+
start = time.perf_counter()
|
|
271
|
+
|
|
272
|
+
response = self._llm.create_chat_completion(
|
|
273
|
+
messages=messages,
|
|
274
|
+
max_tokens=config.max_tokens,
|
|
275
|
+
temperature=config.temperature,
|
|
276
|
+
top_p=config.top_p,
|
|
277
|
+
top_k=config.top_k,
|
|
278
|
+
repeat_penalty=config.repeat_penalty,
|
|
279
|
+
stop=config.stop or None,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
elapsed = time.perf_counter() - start
|
|
283
|
+
text = response["choices"][0]["message"]["content"]
|
|
284
|
+
usage = response.get("usage", {})
|
|
285
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
286
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
287
|
+
tps = completion_tokens / elapsed if elapsed > 0 else 0.0
|
|
288
|
+
|
|
289
|
+
return GenerationResult(
|
|
290
|
+
text=text,
|
|
291
|
+
tokens_generated=completion_tokens,
|
|
292
|
+
time_seconds=elapsed,
|
|
293
|
+
tokens_per_second=tps,
|
|
294
|
+
prompt_tokens=prompt_tokens,
|
|
295
|
+
optimization_stats=self._collect_stats(config),
|
|
296
|
+
applied_optimizations=self._applied_optimizations(),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def _applied_optimizations(self) -> list[str]:
|
|
300
|
+
"""Optimizations actually wired into the backend call.
|
|
301
|
+
|
|
302
|
+
Currently none: layer-skip/sparsity only produce reported plans and
|
|
303
|
+
the speculative decoder is never invoked during generation. Kept
|
|
304
|
+
explicit so callers can distinguish measured from planned work.
|
|
305
|
+
"""
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
def _collect_stats(self, config: GenerationConfig) -> dict:
|
|
309
|
+
"""Collect optimization statistics from Phase 2 modules."""
|
|
310
|
+
stats: dict = {}
|
|
311
|
+
stats["backend_wired"] = self._applied_optimizations()
|
|
312
|
+
stats["note"] = (
|
|
313
|
+
"stats are planned/reported only; no optimization is applied to the backend call yet"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if self._layer_skipper and config.enable_layer_skip:
|
|
317
|
+
plan = self._layer_skipper.plan(complexity=config.complexity)
|
|
318
|
+
stats["layer_skip"] = {
|
|
319
|
+
"applied": False,
|
|
320
|
+
"strategy": plan.strategy.name,
|
|
321
|
+
"layers_skipped": len(plan.layers_to_skip),
|
|
322
|
+
"total_layers": plan.total_layers,
|
|
323
|
+
"skip_rate": plan.skip_rate,
|
|
324
|
+
"expected_speedup": plan.expected_speedup,
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if self._sparsity_predictor and config.enable_sparse_inference:
|
|
328
|
+
if self._sparsity_predictor.is_warmed_up:
|
|
329
|
+
exec_plan = self._sparsity_predictor.create_execution_plan()
|
|
330
|
+
stats["sparse_inference"] = {
|
|
331
|
+
"applied": False,
|
|
332
|
+
"overall_sparsity": exec_plan.overall_sparsity,
|
|
333
|
+
"estimated_speedup": exec_plan.estimated_speedup,
|
|
334
|
+
"flop_savings": exec_plan.estimated_flop_savings,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
stats["sparsity_cache"] = {
|
|
338
|
+
"hit_rate": self._sparsity_cache.hit_rate,
|
|
339
|
+
"hits": self._sparsity_cache.hits,
|
|
340
|
+
"misses": self._sparsity_cache.misses,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
stats["speculative"] = {
|
|
344
|
+
"applied": False,
|
|
345
|
+
"draft_length": self._speculative_decoder.draft_length,
|
|
346
|
+
"overall_accept_rate": self._speculative_decoder.stats.overall_accept_rate,
|
|
347
|
+
"total_rounds": self._speculative_decoder.stats.total_rounds,
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return stats
|
|
351
|
+
|
|
352
|
+
def unload(self) -> None:
|
|
353
|
+
"""Release model from memory."""
|
|
354
|
+
if self._llm is not None:
|
|
355
|
+
del self._llm
|
|
356
|
+
self._llm = None
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def is_loaded(self) -> bool:
|
|
360
|
+
return self._llm is not None
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def optimization_stats(self) -> dict:
|
|
364
|
+
"""Summary of all Phase 2 optimization module states."""
|
|
365
|
+
config = GenerationConfig()
|
|
366
|
+
return self._collect_stats(config) if self._llm else {}
|
|
367
|
+
|
|
368
|
+
@property
|
|
369
|
+
def layer_skipper(self) -> LayerSkipScheduler | None:
|
|
370
|
+
return self._layer_skipper
|
|
371
|
+
|
|
372
|
+
@property
|
|
373
|
+
def sparsity_predictor(self) -> SparsityPredictor | None:
|
|
374
|
+
return self._sparsity_predictor
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def speculative_decoder(self) -> SpeculativeDecoder:
|
|
378
|
+
return self._speculative_decoder
|