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,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Activation sparsity analyzer — measures the emergent sparsity in dense LLM layers.
|
|
3
|
+
|
|
4
|
+
This is the first "gap exploitation" measurement tool. It hooks into the model's
|
|
5
|
+
forward pass and counts near-zero activations per layer, producing a sparsity
|
|
6
|
+
profile that quantifies how many FLOPs could be skipped on CPU inference.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class LayerSparsity:
|
|
20
|
+
layer_index: int
|
|
21
|
+
layer_name: str
|
|
22
|
+
total_activations: int
|
|
23
|
+
near_zero_count: int
|
|
24
|
+
sparsity_ratio: float
|
|
25
|
+
mean_magnitude: float
|
|
26
|
+
median_magnitude: float
|
|
27
|
+
threshold_used: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SparsityReport:
|
|
32
|
+
model_name: str
|
|
33
|
+
prompt: str
|
|
34
|
+
threshold: float
|
|
35
|
+
layers: list[LayerSparsity] = field(default_factory=list)
|
|
36
|
+
overall_sparsity: float = 0.0
|
|
37
|
+
potential_speedup: float = 0.0
|
|
38
|
+
|
|
39
|
+
def summary(self) -> str:
|
|
40
|
+
lines = [
|
|
41
|
+
f"Sparsity Report: {self.model_name}",
|
|
42
|
+
f"Prompt: {self.prompt[:80]}{'...' if len(self.prompt) > 80 else ''}",
|
|
43
|
+
f"Threshold: {self.threshold}",
|
|
44
|
+
f"Overall sparsity: {self.overall_sparsity:.1%}",
|
|
45
|
+
f"Potential CPU speedup: {self.potential_speedup:.1f}x",
|
|
46
|
+
"",
|
|
47
|
+
f"{'Layer':<8} {'Name':<30} {'Sparsity':>10} {'Mean |act|':>12} {'Skip':>8}",
|
|
48
|
+
"-" * 72,
|
|
49
|
+
]
|
|
50
|
+
for layer in self.layers:
|
|
51
|
+
lines.append(
|
|
52
|
+
f"{layer.layer_index:<8} "
|
|
53
|
+
f"{layer.layer_name:<30} "
|
|
54
|
+
f"{layer.sparsity_ratio:>9.1%} "
|
|
55
|
+
f"{layer.mean_magnitude:>11.6f} "
|
|
56
|
+
f"{layer.near_zero_count:>8}"
|
|
57
|
+
)
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict:
|
|
61
|
+
return {
|
|
62
|
+
"model_name": self.model_name,
|
|
63
|
+
"prompt": self.prompt,
|
|
64
|
+
"threshold": self.threshold,
|
|
65
|
+
"overall_sparsity": self.overall_sparsity,
|
|
66
|
+
"potential_speedup": self.potential_speedup,
|
|
67
|
+
"layers": [
|
|
68
|
+
{
|
|
69
|
+
"layer_index": layer.layer_index,
|
|
70
|
+
"layer_name": layer.layer_name,
|
|
71
|
+
"total_activations": layer.total_activations,
|
|
72
|
+
"near_zero_count": layer.near_zero_count,
|
|
73
|
+
"sparsity_ratio": layer.sparsity_ratio,
|
|
74
|
+
"mean_magnitude": layer.mean_magnitude,
|
|
75
|
+
"median_magnitude": layer.median_magnitude,
|
|
76
|
+
}
|
|
77
|
+
for layer in self.layers
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def save(self, path: Path) -> None:
|
|
82
|
+
path.write_text(json.dumps(self.to_dict(), indent=2))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SparsityAnalyzer:
|
|
86
|
+
"""
|
|
87
|
+
Analyzes activation sparsity by intercepting llama.cpp's internal state.
|
|
88
|
+
|
|
89
|
+
Since llama-cpp-python doesn't expose per-layer activations directly,
|
|
90
|
+
this uses the logits output and token embeddings as proxy measurements.
|
|
91
|
+
For full per-layer analysis, a custom ggml hook would be needed (Phase 2).
|
|
92
|
+
|
|
93
|
+
Current implementation: analyzes the output logit distribution to estimate
|
|
94
|
+
how sparse the model's internal representations are for a given input.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, threshold: float = 0.01):
|
|
98
|
+
self.threshold = threshold
|
|
99
|
+
|
|
100
|
+
def analyze_logits(
|
|
101
|
+
self,
|
|
102
|
+
logits: list[float] | np.ndarray,
|
|
103
|
+
model_name: str = "unknown",
|
|
104
|
+
prompt: str = "",
|
|
105
|
+
) -> SparsityReport:
|
|
106
|
+
"""Analyze sparsity of the output logit vector."""
|
|
107
|
+
arr = np.array(logits, dtype=np.float32)
|
|
108
|
+
abs_arr = np.abs(arr)
|
|
109
|
+
|
|
110
|
+
near_zero = int(np.sum(abs_arr < self.threshold))
|
|
111
|
+
total = len(arr)
|
|
112
|
+
sparsity = near_zero / total if total > 0 else 0.0
|
|
113
|
+
|
|
114
|
+
layer = LayerSparsity(
|
|
115
|
+
layer_index=0,
|
|
116
|
+
layer_name="output_logits",
|
|
117
|
+
total_activations=total,
|
|
118
|
+
near_zero_count=near_zero,
|
|
119
|
+
sparsity_ratio=sparsity,
|
|
120
|
+
mean_magnitude=float(np.mean(abs_arr)),
|
|
121
|
+
median_magnitude=float(np.median(abs_arr)),
|
|
122
|
+
threshold_used=self.threshold,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# potential speedup: 1 / (1 - sparsity), capped at 5x
|
|
126
|
+
speedup = min(1.0 / (1.0 - sparsity) if sparsity < 1.0 else 5.0, 5.0)
|
|
127
|
+
|
|
128
|
+
return SparsityReport(
|
|
129
|
+
model_name=model_name,
|
|
130
|
+
prompt=prompt,
|
|
131
|
+
threshold=self.threshold,
|
|
132
|
+
layers=[layer],
|
|
133
|
+
overall_sparsity=sparsity,
|
|
134
|
+
potential_speedup=speedup,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def analyze_token_probabilities(
|
|
138
|
+
self,
|
|
139
|
+
logits: list[float] | np.ndarray,
|
|
140
|
+
model_name: str = "unknown",
|
|
141
|
+
prompt: str = "",
|
|
142
|
+
) -> SparsityReport:
|
|
143
|
+
"""
|
|
144
|
+
Analyze how concentrated the probability mass is across the vocabulary.
|
|
145
|
+
|
|
146
|
+
A highly concentrated distribution (most probability on few tokens) indicates
|
|
147
|
+
the model's internal pathways are sparse for this input — only a small number
|
|
148
|
+
of semantic directions were strongly activated.
|
|
149
|
+
"""
|
|
150
|
+
arr = np.array(logits, dtype=np.float32)
|
|
151
|
+
|
|
152
|
+
# softmax
|
|
153
|
+
exp_arr = np.exp(arr - np.max(arr))
|
|
154
|
+
probs = exp_arr / np.sum(exp_arr)
|
|
155
|
+
|
|
156
|
+
# concentration: what fraction of tokens carry 95% of the probability mass
|
|
157
|
+
sorted_probs = np.sort(probs)[::-1]
|
|
158
|
+
cumsum = np.cumsum(sorted_probs)
|
|
159
|
+
tokens_for_95 = int(np.searchsorted(cumsum, 0.95)) + 1
|
|
160
|
+
concentration = 1.0 - (tokens_for_95 / len(probs))
|
|
161
|
+
|
|
162
|
+
near_zero_probs = int(np.sum(probs < 1e-6))
|
|
163
|
+
|
|
164
|
+
layer = LayerSparsity(
|
|
165
|
+
layer_index=0,
|
|
166
|
+
layer_name="probability_distribution",
|
|
167
|
+
total_activations=len(probs),
|
|
168
|
+
near_zero_count=near_zero_probs,
|
|
169
|
+
sparsity_ratio=near_zero_probs / len(probs) if len(probs) > 0 else 0.0,
|
|
170
|
+
mean_magnitude=float(np.mean(probs)),
|
|
171
|
+
median_magnitude=float(np.median(probs)),
|
|
172
|
+
threshold_used=1e-6,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
report = SparsityReport(
|
|
176
|
+
model_name=model_name,
|
|
177
|
+
prompt=prompt,
|
|
178
|
+
threshold=1e-6,
|
|
179
|
+
layers=[layer],
|
|
180
|
+
overall_sparsity=concentration,
|
|
181
|
+
potential_speedup=min(1.0 / (1.0 - concentration) if concentration < 1.0 else 5.0, 5.0),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return report
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Phase 1 benchmark/evaluation harness."""
|
|
2
|
+
|
|
3
|
+
from eigencore.benchmark.harness import (
|
|
4
|
+
DEFAULT_CASES,
|
|
5
|
+
SCHEMA_VERSION,
|
|
6
|
+
BenchmarkReport,
|
|
7
|
+
CaseResult,
|
|
8
|
+
QualityCase,
|
|
9
|
+
build_report,
|
|
10
|
+
check_quality,
|
|
11
|
+
hardware_snapshot,
|
|
12
|
+
measure_case,
|
|
13
|
+
model_snapshot,
|
|
14
|
+
run_cases,
|
|
15
|
+
sha256_file,
|
|
16
|
+
summarize,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DEFAULT_CASES",
|
|
21
|
+
"SCHEMA_VERSION",
|
|
22
|
+
"BenchmarkReport",
|
|
23
|
+
"CaseResult",
|
|
24
|
+
"QualityCase",
|
|
25
|
+
"build_report",
|
|
26
|
+
"check_quality",
|
|
27
|
+
"hardware_snapshot",
|
|
28
|
+
"measure_case",
|
|
29
|
+
"model_snapshot",
|
|
30
|
+
"run_cases",
|
|
31
|
+
"sha256_file",
|
|
32
|
+
"summarize",
|
|
33
|
+
]
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Benchmark and evaluation harness — Phase 1 (trustworthy baseline).
|
|
3
|
+
|
|
4
|
+
Produces reproducible JSON evidence artifacts: hardware snapshot, model file
|
|
5
|
+
identity (SHA-256), engine configuration, per-case prefill/decode timing,
|
|
6
|
+
decode throughput, and simple quality checks. No result from this harness is
|
|
7
|
+
a performance claim until the artifact (with its settings) is shared alongside it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import platform
|
|
15
|
+
import statistics
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import asdict, dataclass, field
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from eigencore.engine.inference import GenerationConfig, InferenceEngine
|
|
24
|
+
from eigencore.hal.profiler import HardwareProfile
|
|
25
|
+
from eigencore.models.registry import ModelSpec
|
|
26
|
+
|
|
27
|
+
SCHEMA_VERSION = 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class QualityCase:
|
|
32
|
+
"""One evaluation case: a prompt plus cheap, checkable expectations."""
|
|
33
|
+
|
|
34
|
+
id: str
|
|
35
|
+
prompt: str
|
|
36
|
+
expected_contains: list[str] = field(default_factory=list)
|
|
37
|
+
min_tokens: int = 1
|
|
38
|
+
max_tokens: int = 256
|
|
39
|
+
note: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
DEFAULT_CASES: list[QualityCase] = [
|
|
43
|
+
QualityCase(
|
|
44
|
+
id="explain-cpu",
|
|
45
|
+
prompt="Explain how a CPU executes instructions, step by step, in one short paragraph.",
|
|
46
|
+
expected_contains=["fetch"],
|
|
47
|
+
max_tokens=128,
|
|
48
|
+
note="basic technical explanation",
|
|
49
|
+
),
|
|
50
|
+
QualityCase(
|
|
51
|
+
id="quicksort",
|
|
52
|
+
prompt="Write a Python function that implements quicksort. Reply with code only.",
|
|
53
|
+
expected_contains=["def ", "quicksort", "return"],
|
|
54
|
+
max_tokens=192,
|
|
55
|
+
note="code generation sanity check",
|
|
56
|
+
),
|
|
57
|
+
QualityCase(
|
|
58
|
+
id="tcp-udp",
|
|
59
|
+
prompt="List two key differences between TCP and UDP in one sentence each.",
|
|
60
|
+
expected_contains=["TCP", "UDP"],
|
|
61
|
+
max_tokens=96,
|
|
62
|
+
note="factual recall",
|
|
63
|
+
),
|
|
64
|
+
QualityCase(
|
|
65
|
+
id="json-format",
|
|
66
|
+
prompt='Reply with exactly this JSON and nothing else: {"ok": true}',
|
|
67
|
+
expected_contains=['{"ok": true}'],
|
|
68
|
+
max_tokens=32,
|
|
69
|
+
note="instruction following / format adherence",
|
|
70
|
+
),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class CaseResult:
|
|
76
|
+
case_id: str
|
|
77
|
+
prompt: str
|
|
78
|
+
output: str
|
|
79
|
+
prompt_tokens_estimated: int
|
|
80
|
+
completion_tokens: int
|
|
81
|
+
time_to_first_token_s: float
|
|
82
|
+
decode_time_s: float
|
|
83
|
+
total_time_s: float
|
|
84
|
+
decode_tokens_per_second: float
|
|
85
|
+
quality_pass: bool
|
|
86
|
+
quality_detail: str
|
|
87
|
+
run_index: int = 0
|
|
88
|
+
warm: bool = False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class BenchmarkReport:
|
|
93
|
+
schema_version: int
|
|
94
|
+
created_at: str
|
|
95
|
+
git_commit: str | None
|
|
96
|
+
eigencore_version: str
|
|
97
|
+
hardware: dict[str, Any]
|
|
98
|
+
model: dict[str, Any]
|
|
99
|
+
engine: dict[str, Any]
|
|
100
|
+
results: list[dict[str, Any]]
|
|
101
|
+
summary: dict[str, Any]
|
|
102
|
+
|
|
103
|
+
def to_dict(self) -> dict[str, Any]:
|
|
104
|
+
return asdict(self)
|
|
105
|
+
|
|
106
|
+
def save(self, path: str | Path) -> Path:
|
|
107
|
+
path = Path(path)
|
|
108
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
path.write_text(json.dumps(self.to_dict(), indent=2) + "\n")
|
|
110
|
+
return path
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def sha256_file(path: str | Path, chunk_size: int = 1 << 20) -> str:
|
|
114
|
+
"""SHA-256 of a model file — ties results to exact model content."""
|
|
115
|
+
h = hashlib.sha256()
|
|
116
|
+
with open(path, "rb") as f:
|
|
117
|
+
for chunk in iter(lambda: f.read(chunk_size), b""):
|
|
118
|
+
h.update(chunk)
|
|
119
|
+
return h.hexdigest()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _git_commit() -> str | None:
|
|
123
|
+
try:
|
|
124
|
+
out = subprocess.run(
|
|
125
|
+
["git", "rev-parse", "HEAD"],
|
|
126
|
+
capture_output=True,
|
|
127
|
+
text=True,
|
|
128
|
+
timeout=5,
|
|
129
|
+
check=False,
|
|
130
|
+
)
|
|
131
|
+
return out.stdout.strip() or None
|
|
132
|
+
except (OSError, subprocess.SubprocessError):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def hardware_snapshot(profile: HardwareProfile) -> dict[str, Any]:
|
|
137
|
+
return {
|
|
138
|
+
"cpu_name": profile.cpu_name,
|
|
139
|
+
"arch": profile.arch,
|
|
140
|
+
"platform": platform.platform(),
|
|
141
|
+
"python_version": platform.python_version(),
|
|
142
|
+
"physical_cores": profile.physical_cores,
|
|
143
|
+
"logical_cores": profile.logical_cores,
|
|
144
|
+
"instruction_sets": [
|
|
145
|
+
f.name for f in type(profile.instruction_sets) if f in profile.instruction_sets
|
|
146
|
+
],
|
|
147
|
+
"total_ram_gb": profile.total_ram_gb,
|
|
148
|
+
"available_ram_gb": profile.available_ram_gb,
|
|
149
|
+
"estimated_bandwidth_gbps": profile.estimated_bandwidth_gbps,
|
|
150
|
+
"notes": list(profile.notes),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def model_snapshot(spec: ModelSpec, model_path: Path | None) -> dict[str, Any]:
|
|
155
|
+
snap: dict[str, Any] = {
|
|
156
|
+
"name": spec.name,
|
|
157
|
+
"repo_id": spec.repo_id,
|
|
158
|
+
"filename": spec.filename,
|
|
159
|
+
"params_b": spec.params_b,
|
|
160
|
+
"quant": spec.quant,
|
|
161
|
+
"size_gb": spec.size_gb,
|
|
162
|
+
"task": spec.task,
|
|
163
|
+
"context_length": spec.context_length,
|
|
164
|
+
}
|
|
165
|
+
if model_path is not None and Path(model_path).exists():
|
|
166
|
+
snap["local_path"] = str(model_path)
|
|
167
|
+
snap["sha256"] = sha256_file(model_path)
|
|
168
|
+
snap["file_size_bytes"] = Path(model_path).stat().st_size
|
|
169
|
+
else:
|
|
170
|
+
snap["local_path"] = None
|
|
171
|
+
snap["sha256"] = None
|
|
172
|
+
snap["warning"] = "model file not found; sha256 not recorded"
|
|
173
|
+
return snap
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def engine_snapshot(engine: InferenceEngine, config: GenerationConfig) -> dict[str, Any]:
|
|
177
|
+
p = engine.profile
|
|
178
|
+
return {
|
|
179
|
+
"n_ctx": min(p.recommended_context_length, engine.model_spec.context_length)
|
|
180
|
+
if engine.model_spec
|
|
181
|
+
else p.recommended_context_length,
|
|
182
|
+
"n_threads": p.recommended_threads,
|
|
183
|
+
"n_gpu_layers": 0,
|
|
184
|
+
"backend": "llama-cpp-python",
|
|
185
|
+
"generation": {
|
|
186
|
+
"max_tokens": config.max_tokens,
|
|
187
|
+
"temperature": config.temperature,
|
|
188
|
+
"top_p": config.top_p,
|
|
189
|
+
"top_k": config.top_k,
|
|
190
|
+
"repeat_penalty": config.repeat_penalty,
|
|
191
|
+
"enable_layer_skip": config.enable_layer_skip,
|
|
192
|
+
"enable_sparse_inference": config.enable_sparse_inference,
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def check_quality(case: QualityCase, output: str, completion_tokens: int) -> tuple[bool, str]:
|
|
198
|
+
"""Cheap deterministic quality gate; not a substitute for human eval."""
|
|
199
|
+
problems: list[str] = []
|
|
200
|
+
lowered = output.lower()
|
|
201
|
+
for needle in case.expected_contains:
|
|
202
|
+
if needle.lower() not in lowered:
|
|
203
|
+
problems.append(f"missing expected text: {needle!r}")
|
|
204
|
+
if completion_tokens < case.min_tokens:
|
|
205
|
+
problems.append(f"too few tokens: {completion_tokens} < {case.min_tokens}")
|
|
206
|
+
return (not problems, "; ".join(problems) if problems else "ok")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def measure_case(
|
|
210
|
+
engine: InferenceEngine,
|
|
211
|
+
case: QualityCase,
|
|
212
|
+
config: GenerationConfig,
|
|
213
|
+
run_index: int = 0,
|
|
214
|
+
) -> CaseResult:
|
|
215
|
+
"""Run one case via streaming so prefill (TTFT) and decode are separated.
|
|
216
|
+
|
|
217
|
+
Completion tokens are counted with the backend tokenizer (or response
|
|
218
|
+
usage), never by counting stream chunks. `run_index` 0 is tagged cold.
|
|
219
|
+
"""
|
|
220
|
+
if config.stream:
|
|
221
|
+
raise ValueError("measure_case requires a non-streaming config; it streams internally")
|
|
222
|
+
|
|
223
|
+
stream_config = GenerationConfig(
|
|
224
|
+
max_tokens=case.max_tokens,
|
|
225
|
+
temperature=config.temperature,
|
|
226
|
+
top_p=config.top_p,
|
|
227
|
+
top_k=config.top_k,
|
|
228
|
+
repeat_penalty=config.repeat_penalty,
|
|
229
|
+
stream=True,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
start = time.perf_counter()
|
|
233
|
+
first_token_at: float | None = None
|
|
234
|
+
chunks: list[str] = []
|
|
235
|
+
for chunk in engine.stream(case.prompt, stream_config):
|
|
236
|
+
if first_token_at is None:
|
|
237
|
+
first_token_at = time.perf_counter()
|
|
238
|
+
chunks.append(chunk)
|
|
239
|
+
total = time.perf_counter() - start
|
|
240
|
+
|
|
241
|
+
ttft = (first_token_at - start) if first_token_at is not None else total
|
|
242
|
+
output = "".join(chunks)
|
|
243
|
+
completion_tokens = _count_tokens(engine, output)
|
|
244
|
+
decode_time = max(total - ttft, 0.0)
|
|
245
|
+
decode_tps = completion_tokens / decode_time if decode_time > 0 else 0.0
|
|
246
|
+
quality_pass, quality_detail = check_quality(case, output, completion_tokens)
|
|
247
|
+
|
|
248
|
+
return CaseResult(
|
|
249
|
+
case_id=case.id,
|
|
250
|
+
prompt=case.prompt,
|
|
251
|
+
output=output,
|
|
252
|
+
prompt_tokens_estimated=max(len(case.prompt) // 4, 1),
|
|
253
|
+
completion_tokens=completion_tokens,
|
|
254
|
+
time_to_first_token_s=ttft,
|
|
255
|
+
decode_time_s=decode_time,
|
|
256
|
+
total_time_s=total,
|
|
257
|
+
decode_tokens_per_second=decode_tps,
|
|
258
|
+
quality_pass=quality_pass,
|
|
259
|
+
quality_detail=quality_detail,
|
|
260
|
+
run_index=run_index,
|
|
261
|
+
warm=run_index > 0,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _count_tokens(engine: InferenceEngine, text: str) -> int:
|
|
266
|
+
"""Token count from the backend tokenizer; falls back to a chars/4 estimate."""
|
|
267
|
+
llm = getattr(engine, "_llm", None)
|
|
268
|
+
if llm is not None and hasattr(llm, "tokenize"):
|
|
269
|
+
try:
|
|
270
|
+
return len(llm.tokenize(text.encode("utf-8")))
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
return max(len(text) // 4, 1)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def summarize(results: list[CaseResult]) -> dict[str, Any]:
|
|
277
|
+
def _stats(rows: list[CaseResult]) -> dict[str, float]:
|
|
278
|
+
tps = [r.decode_tokens_per_second for r in rows]
|
|
279
|
+
ttft = [r.time_to_first_token_s for r in rows]
|
|
280
|
+
return {
|
|
281
|
+
"decode_tok_s_median": statistics.median(tps) if tps else 0.0,
|
|
282
|
+
"decode_tok_s_mean": statistics.mean(tps) if tps else 0.0,
|
|
283
|
+
"decode_tok_s_min": min(tps) if tps else 0.0,
|
|
284
|
+
"decode_tok_s_max": max(tps) if tps else 0.0,
|
|
285
|
+
"ttft_s_median": statistics.median(ttft) if ttft else 0.0,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
cold = [r for r in results if not r.warm]
|
|
289
|
+
warm = [r for r in results if r.warm]
|
|
290
|
+
passed = sum(1 for r in results if r.quality_pass)
|
|
291
|
+
return {
|
|
292
|
+
"cases": len(results),
|
|
293
|
+
"quality_pass_rate": passed / len(results) if results else 0.0,
|
|
294
|
+
**{f"all_{k}": v for k, v in _stats(results).items()},
|
|
295
|
+
"cold": _stats(cold),
|
|
296
|
+
"warm": _stats(warm),
|
|
297
|
+
"note": "tokens counted via backend tokenizer; cold = first run per case, warm = later runs",
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def build_report(
|
|
302
|
+
engine: InferenceEngine,
|
|
303
|
+
spec: ModelSpec,
|
|
304
|
+
model_path: Path | None,
|
|
305
|
+
config: GenerationConfig,
|
|
306
|
+
results: list[CaseResult],
|
|
307
|
+
) -> BenchmarkReport:
|
|
308
|
+
from eigencore import __version__
|
|
309
|
+
|
|
310
|
+
return BenchmarkReport(
|
|
311
|
+
schema_version=SCHEMA_VERSION,
|
|
312
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
313
|
+
git_commit=_git_commit(),
|
|
314
|
+
eigencore_version=__version__,
|
|
315
|
+
hardware=hardware_snapshot(engine.profile),
|
|
316
|
+
model=model_snapshot(spec, model_path),
|
|
317
|
+
engine=engine_snapshot(engine, config),
|
|
318
|
+
results=[asdict(r) for r in results],
|
|
319
|
+
summary=summarize(results),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def run_cases(
|
|
324
|
+
engine: InferenceEngine,
|
|
325
|
+
cases: list[QualityCase],
|
|
326
|
+
config: GenerationConfig | None = None,
|
|
327
|
+
runs: int = 1,
|
|
328
|
+
) -> list[CaseResult]:
|
|
329
|
+
"""Run each case `runs` times (first run is cold/warm-up, all are recorded)."""
|
|
330
|
+
if config is None:
|
|
331
|
+
config = GenerationConfig(
|
|
332
|
+
max_tokens=256,
|
|
333
|
+
temperature=0.0,
|
|
334
|
+
stream=False,
|
|
335
|
+
enable_layer_skip=False,
|
|
336
|
+
enable_sparse_inference=False,
|
|
337
|
+
)
|
|
338
|
+
results: list[CaseResult] = []
|
|
339
|
+
for case in cases:
|
|
340
|
+
for run_idx in range(max(runs, 1)):
|
|
341
|
+
results.append(measure_case(engine, case, config, run_index=run_idx))
|
|
342
|
+
return results
|
|
File without changes
|