Skip to content

Commit 6dd7b3a

Browse files
committed
Profiled classifier with same dspcount handling as conv layer
1 parent d2ae595 commit 6dd7b3a

9 files changed

Lines changed: 832 additions & 4 deletions

File tree

README.md renamed to docs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ GPIO_NUM_1 - GPIO 47 (PMOD1A)
151151
GPIO_NUM_2 - GPIO 45 (PMOD1A)
152152
GND - GND (PMOD1B)
153153
```
154-
![CNN Design Flow](cnn_design_flow.svg)
154+
![CNN Design Flow](assets/cnn_design_flow.svg)
155155

156156
## Neural Network
157157
### Setup

nn/util.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ def design_stat(args: argparse.Namespace) -> None:
219219
sys.exit("SYNTH_SOURCES not set — run via `make stat-design`")
220220
yosys = os.environ.get("YOSYS", "yosys")
221221
config = NN_CFG
222-
222+
assert config.in_dims.width is not None and config.in_dims.height is not None
223223
w = int(config.in_dims.width)
224224
h = int(config.in_dims.height)
225225
jobs: list[tuple[str, str, dict]] = []
@@ -262,7 +262,7 @@ def design_stat(args: argparse.Namespace) -> None:
262262
"ShiftBits": cc._shift, "DSPCount": cc._dsp_count,
263263
}))
264264

265-
layer_results: list[tuple[str, defaultdict]] = []
265+
layer_results: list[tuple[str, dict]] = []
266266
grand: defaultdict = defaultdict(int)
267267

268268
for label, module, params in jobs:
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
"""
2+
classifier_profile.py — LC predictor for classifier_layer on iCE40 UP5K.
3+
4+
Loads regression coefficients from profiles/classifier_coeffs.csv.
5+
Models per (TermBits, DSPCount) corner:
6+
DSP=0: LC = A*IC*CC*WB + B*IC + C*log2(CC) + D
7+
DSP>0: LC = A*IC*CC + B*IC + C*log2(CC) + D (WB fixed by hardware)
8+
9+
Usage:
10+
from classifier_profile import predict, feasible
11+
lc = predict(tb=4, ic=8, cc=4, wb=4)
12+
lc = predict(tb=4, ic=8, cc=4, wb=4, dsp=2)
13+
"""
14+
15+
import csv
16+
import math
17+
import os
18+
import subprocess
19+
import sys
20+
from pathlib import Path
21+
22+
LC_CAP = 5280
23+
DSP_CAP = 8
24+
25+
_LC: dict[tuple, tuple] = {} # (tb, dsp) -> (A, B, C, D, R2)
26+
27+
28+
def _load(path: Path = Path(__file__).parent / "profiles/classifier_coeffs.csv") -> None:
29+
with open(path, newline="") as f:
30+
reader = csv.DictReader(f)
31+
for row in reader:
32+
if row["Model"] != "LC":
33+
continue
34+
dsp = int(row["DSPCount"]) if "DSPCount" in row else 0
35+
key = (int(row["TermBits"]), dsp)
36+
_LC[key] = (
37+
float(row["A"]), float(row["B"]),
38+
float(row["C"]), float(row["D"]), float(row["R2"]),
39+
)
40+
41+
42+
def _lc(tb: int, ic: int, cc: int, wb: int, dsp: int) -> float:
43+
key = (tb, dsp)
44+
if key not in _LC:
45+
if dsp > 0:
46+
raise KeyError(
47+
f"No LC model for TermBits={tb} DSPCount={dsp} — "
48+
f"run fill_corner(tb={tb}, dsp={dsp}) to synthesize it"
49+
)
50+
raise KeyError(f"No LC model for TermBits={tb}")
51+
A, B, C, D, _ = _LC[key]
52+
if dsp == 0:
53+
return A*ic*cc*wb + B*ic + C*math.log2(max(cc, 1)) + D
54+
else:
55+
return A*ic*cc + B*ic + C*math.log2(max(cc, 1)) + D
56+
57+
58+
def predict(tb: int, ic: int, cc: int, wb: int, dsp: int = 0) -> int:
59+
"""
60+
Predict LC for a classifier_layer configuration.
61+
Raises KeyError if the (TermBits, DSPCount) corner is missing.
62+
Raises ValueError if cc % dsp != 0 or LC exceeds the iCE40 UP5K cap.
63+
"""
64+
if dsp > 0 and cc % dsp != 0:
65+
raise ValueError(f"DSPCount={dsp} does not divide ClassCount={cc}")
66+
67+
try:
68+
lc = _lc(tb, ic, cc, wb, dsp)
69+
except KeyError:
70+
if dsp == 0:
71+
raise
72+
fill_corner(tb=tb, dsp=dsp)
73+
lc = _lc(tb, ic, cc, wb, dsp)
74+
75+
if lc > LC_CAP:
76+
raise ValueError(f"TB={tb} IC={ic} CC={cc} WB={wb} DSP={dsp}: LC={lc:.0f} exceeds cap ({LC_CAP})")
77+
return round(lc)
78+
79+
80+
def feasible(tb: int, ic: int, cc: int, wb: int, dsp: int = 0) -> bool:
81+
"""Return True if the configuration fits within the LC cap."""
82+
try:
83+
predict(tb, ic, cc, wb, dsp)
84+
return True
85+
except (KeyError, ValueError):
86+
return False
87+
88+
89+
def valid_dsp_counts(cc: int) -> list[int]:
90+
"""Divisors of cc that are <= DSP_CAP."""
91+
return [d for d in range(1, min(cc, DSP_CAP) + 1) if cc % d == 0]
92+
93+
94+
def _count_csv_rows(path: Path, tb: int, dsp: int) -> int:
95+
if not path.exists():
96+
return 0
97+
with open(path, newline="") as f:
98+
return sum(1 for row in csv.DictReader(f)
99+
if int(row["TermBits"]) == tb and int(row["DSPCount"]) == dsp)
100+
101+
102+
def fill_corner(tb: int, dsp: int,
103+
base_csv: str | None = None,
104+
samples: int = 8,
105+
yosys: str = "yosys") -> None:
106+
"""
107+
Synthesize a missing (tb, dsp) LC corner, refit the regression, and reload.
108+
109+
Appends new synthesis rows to profiles/classifier_dsp_chars.csv, reruns the
110+
MATLAB regression to overwrite profiles/classifier_coeffs.csv, then reloads
111+
coefficients into this process.
112+
113+
base_csv: path to DSP=0 sweep CSV used for sampling (IC, CC) points.
114+
Defaults to profiles/sweep_classifier.csv.
115+
"""
116+
import subprocess
117+
118+
here = Path(__file__).parent
119+
repo = here.parent.parent
120+
sweep = here / "sweep/sweep_classifier_dsp_1_8.py"
121+
regr = here / "regression/classifier_dsp_1_8.m"
122+
dsp_csv = here / "profiles/classifier_dsp_chars.csv"
123+
124+
if not regr.exists():
125+
raise FileNotFoundError(
126+
f"Regression script not found: {regr}\n"
127+
f"Create classifier_dsp_1_8.m to enable automatic corner fitting."
128+
)
129+
130+
if base_csv is None:
131+
base_csv = str(here / "profiles/sweep_classifier.csv")
132+
133+
def _run(label: str, cmd: list[str], cwd: Path = repo) -> str:
134+
print(f"[fill_corner] {label} ...", flush=True)
135+
r = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
136+
if r.returncode != 0:
137+
print(r.stdout[-3000:])
138+
print(r.stderr[-3000:])
139+
raise RuntimeError(f"fill_corner: {label} failed (exit {r.returncode})")
140+
return r.stdout
141+
142+
rows_before = _count_csv_rows(dsp_csv, tb=tb, dsp=dsp)
143+
_run(f"Synthesizing TB={tb} DSP={dsp} ({samples} points)",
144+
["python3", str(sweep),
145+
"--base", base_csv,
146+
"--tb", str(tb),
147+
"--dsp", str(dsp),
148+
"--samples", str(samples),
149+
"--out", str(dsp_csv),
150+
"--append",
151+
"--yosys", yosys])
152+
rows_after = _count_csv_rows(dsp_csv, tb=tb, dsp=dsp)
153+
print(f"[fill_corner] Synthesized {rows_after - rows_before} new rows "
154+
f"(total TB={tb} DSP={dsp} rows: {rows_after})", flush=True)
155+
if rows_after < 4:
156+
print(f"[fill_corner] WARNING: only {rows_after} rows — need ≥4 for regression fit. "
157+
f"Try --samples with a larger value or check baseline has CC divisible by {dsp}.")
158+
159+
out = _run("Refitting regression",
160+
["matlab", "-batch", f"run('{regr.resolve()}')"],
161+
cwd=regr.parent)
162+
for line in out.splitlines():
163+
if any(w in line.lower() for w in ("corner", "exported", "written")):
164+
print(f"[fill_corner] MATLAB: {line.strip()}")
165+
166+
_LC.clear()
167+
_load()
168+
key = (tb, dsp)
169+
if key in _LC:
170+
print(f"[fill_corner] Done — corner {key} loaded successfully")
171+
else:
172+
print(f"[fill_corner] Corner {key} still missing after refit.")
173+
print(f"[fill_corner] Available LC corners: {sorted(_LC)}")
174+
175+
176+
_FIXED = {"TermCount": 32, "BusBits": 8, "BiasBits": 8, "ShiftBits": 0}
177+
_MODULE = "classifier_layer"
178+
179+
180+
def _get_synth_sources(repo: Path) -> list[str]:
181+
r = subprocess.run(
182+
["python3", "sim/util/get_filelist.py", "rtl/top/top.json"],
183+
capture_output=True, text=True, cwd=repo,
184+
)
185+
return r.stdout.split()
186+
187+
188+
def _synthesize(repo: Path, sources: list[str],
189+
tb: int, ic: int, cc: int, wb: int, dsp: int,
190+
yosys: str = "yosys", verbose: bool = False) -> int | None:
191+
sys.path.insert(0, str(repo))
192+
from nn.util import parse, total_cells, _fold, _base # noqa: E402
193+
params = {**_FIXED, "DSPCount": dsp, "TermBits": tb,
194+
"InChannels": ic, "ClassCount": cc, "WeightBits": wb}
195+
param_cmds = "".join(f"chparam -set {k} {v} {_MODULE}; " for k, v in params.items())
196+
dsp_flag = "-dsp" if dsp > 0 else ""
197+
script = (
198+
f"read_verilog -sv -DSYNTHESIS {' '.join(sources)}; "
199+
f"{param_cmds}"
200+
f"synth_ice40 {dsp_flag} -noflatten -top {_MODULE}; stat"
201+
)
202+
r = subprocess.run([yosys, "-p", script], capture_output=True, text=True, cwd=repo)
203+
if verbose:
204+
print(r.stdout)
205+
if r.stderr:
206+
print(r.stderr)
207+
mods = parse((r.stdout + r.stderr).splitlines())
208+
top_key = next((k for k in mods if _base(k) == _MODULE), None)
209+
if top_key is None:
210+
return None
211+
tot = _fold(dict(total_cells(top_key, mods, {})))
212+
return tot.get("LC", 0)
213+
214+
215+
_load()
216+
217+
218+
def _report() -> None:
219+
print(f"Corners loaded — LC: {len(_LC)}")
220+
for tb, dsp in sorted(_LC):
221+
A, B, C, D, r2 = _LC[(tb, dsp)]
222+
if dsp == 0:
223+
print(f" LC TB={tb} DSP=0 {A:.3f}·IC·CC·WB + {B:.3f}·IC + {C:.3f}·log2(CC) + {D:.3f} R²={r2:.3f}")
224+
else:
225+
print(f" LC TB={tb} DSP={dsp} {A:.3f}·IC·CC + {B:.3f}·IC + {C:.3f}·log2(CC) + {D:.3f} R²={r2:.3f}")
226+
227+
228+
if __name__ == "__main__":
229+
import argparse
230+
import random
231+
ap = argparse.ArgumentParser(description="Query or fill classifier_layer LC models")
232+
ap.add_argument("--tb", type=int, help="TermBits")
233+
ap.add_argument("--ic", type=int, help="InChannels")
234+
ap.add_argument("--cc", type=int, help="ClassCount")
235+
ap.add_argument("--wb", type=int, help="WeightBits")
236+
ap.add_argument("--dsp", type=int, default=0, help="DSPCount (default 0)")
237+
ap.add_argument("--predict", action="store_true", help="Predict LC for --tb --ic --cc --wb --dsp")
238+
ap.add_argument("--fill", action="store_true", help="Synthesize and fit a missing DSP corner for --tb --dsp")
239+
ap.add_argument("--samples", type=int, default=8,
240+
help="Sample points per corner when filling (default: 8)")
241+
ap.add_argument("--trials", type=int, metavar="N",
242+
help="Synthesize N random valid configs and report prediction accuracy")
243+
ap.add_argument("--seed", type=int, default=42, help="RNG seed for --trials (default 42)")
244+
ap.add_argument("--synth", action="store_true",
245+
help="Run synthesis for --tb --ic --cc --wb --dsp and dump yosys log")
246+
ap.add_argument("--yosys", default="yosys")
247+
args = ap.parse_args()
248+
249+
if args.synth:
250+
if any(v is None for v in (args.tb, args.ic, args.cc, args.wb)):
251+
ap.error("--synth requires --tb --ic --cc --wb (and optionally --dsp)")
252+
repo = Path(__file__).parent.parent.parent
253+
sources = _get_synth_sources(repo)
254+
actual = _synthesize(repo, sources, args.tb, args.ic, args.cc, args.wb, args.dsp,
255+
args.yosys, verbose=True)
256+
print(f"\nActual LC={actual}")
257+
if actual is not None:
258+
try:
259+
pred = predict(tb=args.tb, ic=args.ic, cc=args.cc, wb=args.wb, dsp=args.dsp)
260+
print(f"Predicted LC={pred} err={pred - actual:+d}")
261+
except (KeyError, ValueError) as e:
262+
print(f"Prediction failed: {e}")
263+
264+
if args.fill:
265+
if args.tb is None or args.dsp is None:
266+
ap.error("--fill requires --tb and --dsp")
267+
fill_corner(tb=args.tb, dsp=args.dsp, samples=args.samples)
268+
269+
if args.predict:
270+
if any(v is None for v in (args.tb, args.ic, args.cc, args.wb)):
271+
ap.error("--predict requires --tb --ic --cc --wb (and optionally --dsp)")
272+
tb, ic, cc, wb, dsp = args.tb, args.ic, args.cc, args.wb, args.dsp
273+
try:
274+
lc = predict(tb=tb, ic=ic, cc=cc, wb=wb, dsp=dsp)
275+
print(f"predict(tb={tb}, ic={ic}, cc={cc}, wb={wb}, dsp={dsp}) => LC={lc}")
276+
except (ValueError, KeyError) as e:
277+
print(f"predict(tb={tb}, ic={ic}, cc={cc}, wb={wb}, dsp={dsp}) => {e}")
278+
elif args.trials:
279+
repo = Path(__file__).parent.parent.parent
280+
sources = _get_synth_sources(repo)
281+
if not sources:
282+
print("ERROR: no source files found — run from repo root", file=sys.stderr)
283+
sys.exit(1)
284+
285+
rng = random.Random(args.seed)
286+
tb_pool = [tb for tb, dsp in _LC if dsp == args.dsp]
287+
if not tb_pool:
288+
print(f"ERROR: no corners loaded for DSP={args.dsp}", file=sys.stderr)
289+
sys.exit(1)
290+
291+
abs_errors, pct_errors = [], []
292+
n = 0
293+
while n < args.trials:
294+
tb = rng.choice(tb_pool)
295+
wb = rng.randint(2, 8)
296+
ic = rng.randint(1, 16)
297+
cc = rng.randint(1, 16)
298+
if args.dsp > 0 and cc % args.dsp != 0:
299+
continue
300+
try:
301+
pred = predict(tb=tb, ic=ic, cc=cc, wb=wb, dsp=args.dsp)
302+
except (KeyError, ValueError):
303+
continue
304+
actual = _synthesize(repo, sources, tb, ic, cc, wb, args.dsp, args.yosys)
305+
if actual is None:
306+
continue
307+
n += 1
308+
ae = abs(pred - actual)
309+
pe = 100 * ae / actual if actual else 0
310+
abs_errors.append(ae)
311+
pct_errors.append(pe)
312+
print(f" [{n:3d}] TB={tb} IC={ic:2d} CC={cc:2d} WB={wb} DSP={args.dsp}"
313+
f" pred={pred:5d} actual={actual:5d} err={pred-actual:+d} ({pe:.1f}%)")
314+
315+
if abs_errors:
316+
print(f"\nTrials : {n}")
317+
print(f"Mean |err| : {sum(abs_errors)/n:.1f} LC ({sum(pct_errors)/n:.1f}%)")
318+
print(f"Max |err| : {max(abs_errors)} LC ({max(pct_errors):.1f}%)")
319+
print(f"Std : {(sum((e - sum(abs_errors)/n)**2 for e in abs_errors)/n)**0.5:.1f} LC")
320+
elif not args.fill:
321+
_report()

0 commit comments

Comments
 (0)