|
| 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