Skip to content

Commit 6fd552b

Browse files
committed
docs: visualize RTL saturation evidence
1 parent 5e3589b commit 6fd552b

3 files changed

Lines changed: 226 additions & 9 deletions

File tree

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,16 @@ exact_sum = sum(a[i] * b[i] for i in 0 .. VEC_LEN-1)
6868
result = clamp(exact_sum, -2^(OUT_W-1), 2^(OUT_W-1)-1)
6969
```
7070

71+
<p align="center">
72+
<img src="assets/rtl-saturation-evidence.svg" width="100%" alt="Signed 16-bit saturation curve with six named deterministic RTL vectors">
73+
</p>
74+
75+
I generate this boundary plot from the same named vectors and bit-exact Python
76+
model used by the self-checking RTL regression. It shows the wide accumulator
77+
value before clamping and the emitted 16-bit result, including exact limits,
78+
one-step overflow cases, and both signed extremes. It is arithmetic evidence,
79+
not a timing, area, or power result.
80+
7181
Lane 0 occupies bits `[7:0]` of each packed input bus. Accumulation does not
7282
wrap with the default parameters; saturation happens once, after the final
7383
lane. See [Architecture](docs/architecture.md) for timing and width details.
@@ -80,7 +90,7 @@ lane. See [Architecture](docs/architecture.md) for timing and width details.
8090
| `model/dot_product_model.py` | Bit-exact reference arithmetic and packing |
8191
| `tb/tb_int8_dot_product.sv` | Self-checking ready/valid testbench |
8292
| `tools/run_regression.py` | Vector generation, compile, and simulation harness |
83-
| `tools/render_readme_assets.py` | Rebuilds and checks the README evidence figure |
93+
| `tools/render_readme_assets.py` | Rebuilds and checks both README evidence figures |
8494
| `tests/` | Python unit tests for the arithmetic contract |
8595
| `docs/` | Architecture and verification notes |
8696

assets/rtl-saturation-evidence.svg

Lines changed: 70 additions & 0 deletions
Loading

tools/render_readme_assets.py

Lines changed: 145 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
import argparse
77
from pathlib import Path
88

9-
from run_regression import corner_cases
9+
from run_regression import corner_cases, dot_product_int8
1010

1111

1212
ROOT = Path(__file__).resolve().parents[1]
1313
OUTPUT = ROOT / "assets" / "rtl-regression.svg"
14+
SATURATION_OUTPUT = ROOT / "assets" / "rtl-saturation-evidence.svg"
1415
SCENARIOS = ((1, 40), (8, 200), (17, 100))
1516

1617

@@ -94,21 +95,157 @@ def render() -> str:
9495
'''
9596

9697

98+
def render_saturation() -> str:
99+
cases = {case.name: case for case in corner_cases(vec_len=8)}
100+
names = (
101+
"max_negative",
102+
"one_below_negative_limit",
103+
"exact_negative_limit",
104+
"exact_positive_limit",
105+
"one_above_positive_limit",
106+
"max_positive",
107+
)
108+
expected = {
109+
"max_negative": (-130_048, -32_768),
110+
"one_below_negative_limit": (-32_769, -32_768),
111+
"exact_negative_limit": (-32_768, -32_768),
112+
"exact_positive_limit": (32_767, 32_767),
113+
"one_above_positive_limit": (32_768, 32_767),
114+
"max_positive": (129_032, 32_767),
115+
}
116+
labels = {
117+
"max_negative": "max negative",
118+
"one_below_negative_limit": "one below limit",
119+
"exact_negative_limit": "exact negative limit",
120+
"exact_positive_limit": "exact positive limit",
121+
"one_above_positive_limit": "one above limit",
122+
"max_positive": "max positive",
123+
}
124+
125+
rows: list[tuple[str, int, int]] = []
126+
for name in names:
127+
case = cases[name]
128+
exact_sum = sum(a * b for a, b in zip(case.vector_a, case.vector_b, strict=True))
129+
emitted = dot_product_int8(case.vector_a, case.vector_b)
130+
if (exact_sum, emitted) != expected[name]:
131+
raise ValueError(f"named saturation vector drifted: {name}")
132+
rows.append((name, exact_sum, emitted))
133+
134+
plot_left = 104
135+
plot_top = 154
136+
plot_width = 676
137+
plot_height = 350
138+
exact_min = -140_000
139+
exact_max = 140_000
140+
output_min = -32_768
141+
output_max = 32_767
142+
143+
def map_x(value: int) -> float:
144+
return plot_left + (value - exact_min) * plot_width / (exact_max - exact_min)
145+
146+
def map_y(value: int) -> float:
147+
return plot_top + (output_max - value) * plot_height / (output_max - output_min)
148+
149+
points = []
150+
table_rows = []
151+
for index, (name, exact_sum, emitted) in enumerate(rows):
152+
x = map_x(exact_sum)
153+
y = map_y(emitted)
154+
clamped = exact_sum != emitted
155+
points.append(
156+
f'''<circle cx="{x:.2f}" cy="{y:.2f}" r="8" fill="{"#e39a32" if clamped else "#2f827f"}" stroke="#fbf6ec" stroke-width="3">
157+
<title>{labels[name]}: exact accumulator {exact_sum:,}, emitted {emitted:,}</title>
158+
</circle>'''
159+
)
160+
row_y = 180 + index * 55
161+
table_rows.append(
162+
f'''<text x="856" y="{row_y}" class="case">{labels[name]}</text>
163+
<text x="1160" y="{row_y}" text-anchor="end" class="numbers">{exact_sum:,} -&gt; {emitted:,}</text>
164+
<text x="1190" y="{row_y}" class="{"clamp" if clamped else "exact"}">{"CLAMP" if clamped else "EXACT"}</text>'''
165+
)
166+
167+
lower_y = map_y(output_min)
168+
upper_y = map_y(output_max)
169+
lower_x = map_x(output_min)
170+
upper_x = map_x(output_max)
171+
zero_x = map_x(0)
172+
zero_y = map_y(0)
173+
174+
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="650" viewBox="0 0 1280 650" role="img" aria-labelledby="sat-title sat-desc">
175+
<title id="sat-title">Edge AI RTL Lab signed 16-bit saturation evidence</title>
176+
<desc id="sat-desc">The clamp transfer curve and six named deterministic vectors show exact accumulator sums mapped to signed 16-bit outputs. Four vectors clamp and two remain exactly on the boundaries.</desc>
177+
<style>
178+
.title {{ font: 700 31px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #13253b; }}
179+
.subtitle,.axis,.footer {{ font: 15px system-ui, sans-serif; fill: #5f6974; }}
180+
.axis {{ font-size: 13px; }}
181+
.case {{ font: 600 14px system-ui, sans-serif; fill: #253c55; }}
182+
.numbers {{ font: 600 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #13253b; }}
183+
.clamp {{ font: 700 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #b66317; }}
184+
.exact {{ font: 700 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fill: #2f827f; }}
185+
.grid {{ stroke: #e5dac5; stroke-width: 1; }}
186+
.curve {{ fill: none; stroke: #203c5b; stroke-width: 5; stroke-linejoin: round; }}
187+
</style>
188+
<rect width="1280" height="650" rx="28" fill="#fbf6ec"/>
189+
<path d="M0 92H1280" stroke="#e6dac3"/>
190+
<text x="62" y="54" class="title">SIGNED SATURATION / NAMED RTL VECTORS</text>
191+
<text x="62" y="80" class="subtitle">Exact wide accumulator -&gt; signed 16-bit output / VEC_LEN=8</text>
192+
193+
<g>
194+
<path d="M{plot_left} {upper_y:.2f}H{plot_left + plot_width}" class="grid"/>
195+
<path d="M{plot_left} {zero_y:.2f}H{plot_left + plot_width}" class="grid"/>
196+
<path d="M{plot_left} {lower_y:.2f}H{plot_left + plot_width}" class="grid"/>
197+
<path d="M{zero_x:.2f} {plot_top}V{plot_top + plot_height}" class="grid"/>
198+
<polyline points="{plot_left},{lower_y:.2f} {lower_x:.2f},{lower_y:.2f} {upper_x:.2f},{upper_y:.2f} {plot_left + plot_width},{upper_y:.2f}" class="curve"/>
199+
{"".join(points)}
200+
<text x="{plot_left - 16}" y="{upper_y + 5:.2f}" text-anchor="end" class="axis">32,767</text>
201+
<text x="{plot_left - 16}" y="{zero_y + 5:.2f}" text-anchor="end" class="axis">0</text>
202+
<text x="{plot_left - 16}" y="{lower_y + 5:.2f}" text-anchor="end" class="axis">-32,768</text>
203+
<text x="{plot_left}" y="536" text-anchor="middle" class="axis">-140k</text>
204+
<text x="{lower_x:.2f}" y="536" text-anchor="middle" class="axis">-32,768</text>
205+
<text x="{zero_x:.2f}" y="536" text-anchor="middle" class="axis">0</text>
206+
<text x="{upper_x:.2f}" y="536" text-anchor="middle" class="axis">32,767</text>
207+
<text x="{plot_left + plot_width}" y="536" text-anchor="middle" class="axis">140k</text>
208+
<text x="{plot_left + plot_width / 2}" y="570" text-anchor="middle" class="subtitle">exact accumulator sum</text>
209+
<text transform="translate(30 {plot_top + plot_height / 2}) rotate(-90)" text-anchor="middle" class="subtitle">emitted output</text>
210+
</g>
211+
212+
<path d="M824 132V516" stroke="#e6dac3"/>
213+
<text x="856" y="140" class="subtitle">named deterministic vectors</text>
214+
{"".join(table_rows)}
215+
<circle cx="868" cy="536" r="7" fill="#2f827f"/><text x="886" y="541" class="footer">exact boundary</text>
216+
<circle cx="1026" cy="536" r="7" fill="#e39a32"/><text x="1044" y="541" class="footer">saturated</text>
217+
<path d="M62 592H1218" stroke="#e6dac3"/>
218+
<text x="62" y="622" class="footer">Derived from corner_cases(8) and the bit-exact Python model; the same named vectors feed the self-checking RTL regression. No timing, area, or power claim.</text>
219+
</svg>
220+
'''
221+
222+
97223
def main() -> None:
98224
parser = argparse.ArgumentParser(description=__doc__)
99225
parser.add_argument("--check", action="store_true", help="fail if the committed SVG is stale")
100226
args = parser.parse_args()
101-
expected = render()
227+
expected_outputs = {
228+
OUTPUT: render(),
229+
SATURATION_OUTPUT: render_saturation(),
230+
}
102231

103232
if args.check:
104-
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != expected:
105-
raise SystemExit(f"stale generated asset: {OUTPUT.relative_to(ROOT)}")
106-
print(f"up to date: {OUTPUT.relative_to(ROOT)}")
233+
stale = [
234+
path
235+
for path, expected in expected_outputs.items()
236+
if not path.exists() or path.read_text(encoding="utf-8") != expected
237+
]
238+
if stale:
239+
paths = ", ".join(str(path.relative_to(ROOT)) for path in stale)
240+
raise SystemExit(f"stale generated assets: {paths}")
241+
for path in expected_outputs:
242+
print(f"up to date: {path.relative_to(ROOT)}")
107243
return
108244

109-
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
110-
OUTPUT.write_text(expected, encoding="utf-8")
111-
print(f"wrote {OUTPUT.relative_to(ROOT)}")
245+
for path, expected in expected_outputs.items():
246+
path.parent.mkdir(parents=True, exist_ok=True)
247+
path.write_text(expected, encoding="utf-8")
248+
print(f"wrote {path.relative_to(ROOT)}")
112249

113250

114251
if __name__ == "__main__":

0 commit comments

Comments
 (0)