Skip to content

opt=1: decline the int8 variant above its activation-encoding ceiling - #156

Closed
axiom-of-choice wants to merge 1 commit into
sbryngelson:mainfrom
axiom-of-choice:fix-153-decline-int8-above-ceiling
Closed

opt=1: decline the int8 variant above its activation-encoding ceiling#156
axiom-of-choice wants to merge 1 commit into
sbryngelson:mainfrom
axiom-of-choice:fix-153-decline-int8-above-ceiling

Conversation

@axiom-of-choice

Copy link
Copy Markdown
Contributor

Fixes #153. Implements option 1 with the fail-closed rule on an unknown bound, per your design call, plus the three asks that came with it.

The bug

opt=1 selects on predicted cost and never measures (_compile._compile_opt), so nothing validated the lossy int8 variant. A matmul whose true sums sit inside fp16 range came back with inf:

[31,15] @ [15,16], true |x|@|W| max = 30133   (fp16 cliff is ~32760)
  opt=0: 0/496 non-finite
  opt=1: 179/496 non-finite       <- int8 variant won on cost, unvalidated

The three asks

Share the constant, don't add a second 4094. _targets.FP16_SLICE_SAT becomes Q4_X16_SAT, naming the encoding rather than the slice op, with the old name kept as an alias so nothing outside breaks. Your invariance result is what justifies this: the ceiling not moving with weight scale, K, N or seed means the int8-weight matmul is routing activations through the same Q.4 x16 encoding, so one constant is correct rather than a coincidence.

Per-variant, not global. _variant_act_ceiling(cfg) is keyed by config flag and returns None for variants with no encoding limit, so int4-LUT and sparse can declare their own ceilings without inheriting int8's. A test pins that {"int8": False} and const-fold report None.

Pin 4094 in a regression test. Done, including the exactness argument: np.float16(4095) == np.float16(4096), so no fp16 activation lies between 4094 and 4096 and |act| <= 4094 is a gate with no boundary gap. Parametrized either side of it.

How the bound is computed

_node_max_abs only ever bounded a single node, so it could not answer "what is the largest activation in this graph". Added _act_max_abs, which walks to the leaves and returns None if any leaf is a runtime input. _exceeds_act_ceiling then treats None as unsafe, mirroring the precedent you pointed at (_targets.py:215):

if max_abs is None or float(max_abs) > FP16_SLICE_SAT: return "saturation"

_variants(out, drop_unsafe=True) filters on that, and only the opt=1 path passes the flag. opt=2 is untouched, since it validates by measurement and was already safe.

Verification

Apple M2 Pro (Mac14,12 Mac mini), macOS 26.5.2.

The repro is fixed, and opt=1 now matches opt=0 exactly:

  opt=0: 0/496 non-finite, max 29280.0
  opt=1: 0/496 non-finite, max 29280.0      (was 179/496)

It is a gate, not a ban. int8 still survives at opt=1 where it is safe:

graph static bound int8 offered
runtime input None no (fail closed)
const-fed, 10.0 10.0 yes
const-fed, 5000.0 5000.0 no
const-fed, 4094.0 4094.0 yes
const-fed, 4096.0 4096.0 no
Check Result
tests/test_opt1_act_ceiling.py 10 passed, all build-level so they run in CI without an ANE
test_routes, test_tune_guards, test_compile_targets 36 passed
tests/run_corpus.py GATE: GREEN, 90/90
ruff / pyright aneforge / pre-commit clean (pyright's 9 findings are the pre-existing optional-import ones)

Behaviour gap confirmed on main through the public API rather than by a stashed-file run, which only produces a collection error: _variants has no drop_unsafe parameter there and offers int8 unconditionally.

On the cost, honestly

You flagged that on any graph with runtime inputs the bound is None, so this declines int8 at opt=1 nearly always, landing close to option 3 in practice. That matches what I see, and I have not measured the throughput given up on real opt=1 workloads either, so I would not claim the cost is negligible. Two things make me still prefer this shape: it keeps int8 available for const-fed subgraphs today, and it is the right structure once input ranges can be declared, which is the change that makes this meaningfully different from option 3. Happy to switch to option 2 (single probe dispatch, reject on non-finite) if you would rather trade a dispatch for the throughput.

Declaring an input range looks worth its own issue, as you said. Say the word and I will file it with this PR as context.

Not in this PR

The fail-closed not (relerr <= tol) change on the opt=2 path, which you asked for as a separate small PR since it is independently merge-worthy rather than a fix for this. Sending that next.

opt=1 picks on predicted cost and never measures, so nothing validated the lossy
int8 variant: a matmul whose true sums sit inside fp16 range came back with inf
(179/496 non-finite on M2 Pro, 0/496 at opt=0). Fixes sbryngelson#153.

Takes option 1 with the fail-closed rule on an unknown bound, per the design call
in the issue, and follows the three asks that came with it.

Shares the existing constant rather than adding a second 4094: _targets renames
FP16_SLICE_SAT to Q4_X16_SAT, naming the encoding instead of the slice op, and
keeps the old name as an alias. The int8-weight matmul routes activations through
that same Q.4 x16 encoding, which is why the ceiling does not move with weight
scale, K, N or seed.

Keeps the ceiling per variant, not global: _variant_act_ceiling is keyed by config
flag and returns None for variants with no encoding limit, so int4-LUT and sparse
can declare their own.

Bounds activations across the whole graph. _node_max_abs only ever bounded one
node; _act_max_abs walks to the leaves and returns None if any is a runtime input,
which _exceeds_act_ceiling treats as unsafe, mirroring the slice-saturation rule
at _targets.py:215.

On M2 Pro the repro goes from 179/496 non-finite to 0/496, matching opt=0. int8
still survives at opt=1 for a const-fed graph bounded under the ceiling, so this
is a gate rather than a ban, and opt=2 is untouched since it validates by
measurement. 10 new build-level tests run in CI without an ANE and pin 4094
including the exact 4094/4096 boundary. Corpus GATE GREEN 90/90.
@sbryngelson

Copy link
Copy Markdown
Owner

This is an excellent implementation -- exactly the design we converged on: per-variant Q4_X16_SAT (renamed from FP16_SLICE_SAT with the alias kept), fail-closed on an unknown bound, int8 retained for bounded graphs, opt=2 untouched, and the 4094 pin + exact-fp16(4095)==4096 boundary test are precisely what I asked for. Thank you.

I'm going to hold it, though -- and it's purely a sequencing call, nothing about the code. As test_opt1_declines_int8_for_runtime_input makes explicit, _act_max_abs returns None for any graph with a runtime af.input, so int8 is dropped at opt=1 for every real inference graph, not just the large-activation ones that actually overflow. That trades a narrow correctness bug (int8 -> inf only above 4094, a rare pathological case) for a broad opt=1 throughput regression (int8 off for all runtime-input models), and the perf-recovery path -- #155, declaring input ranges so a bounded graph keeps int8 -- doesn't exist yet.

So let's land #155 first, then this, together: provably-bounded and range-declared graphs keep the fast path, and only genuinely-unbounded activations lose int8 at opt=1. The fix is right; I just don't want to ship the regression window in between. #155 is the more interesting piece anyway, and I'll review it as soon as you pick it up.

One nit for when this comes back: the test docstring references bench/opt1_int8_saturation_repro.py, which isn't in the PR -- either add it (a committed on-device repro would be great to have) or drop the line.

@axiom-of-choice

Copy link
Copy Markdown
Contributor Author

Understood on the sequencing, and the reasoning is right: trading a narrow correctness bug for a broad throughput regression is the wrong order when the recovery path does not exist yet.

Picked up #155: #161. It carries this gate plus the bound machinery, so it supersedes this PR rather than stacking, and there is no window where int8 is off for everything.

The part that turned out to matter more than the declaration itself: the saturating activations bound an undeclared input, so softmax, sigmoid, tanh, l2_norm and relu6 recover int8 at opt=1 with no annotation. Anything downstream of one keeps the fast path for free, which covers a lot of real graphs without users touching their code.

Also fixed your nit there: bench/opt1_int8_saturation_repro.py is committed in #161, and the docstring reference is accurate now.

Happy to close this one in favour of #161, or leave it open if you would rather diff the two. Your call.

@sbryngelson

Copy link
Copy Markdown
Owner

Closing in favor of #161, which carries this same opt=1 ceiling gate plus the #155 bound machinery -- so there's no window where int8 is off for everything, and any graph downstream of a saturating op (softmax/sigmoid/tanh/l2_norm/relu6) keeps the fast path with no annotation. The design here was exactly right; #161 is the same idea with the recovery path attached. Thanks -- see the review over there (one rebase needed so it doesn't revert #157).

@sbryngelson sbryngelson closed this Aug 3, 2026
axiom-of-choice added a commit to axiom-of-choice/ANEForge that referenced this pull request Aug 3, 2026
I built this branch's _optimize.py from the sbryngelson#156 branch, which predates sbryngelson#157, so
copying it reverted the fail-closed relerr check. sbryngelson#157's source-form test caught
it in CI, which is what that test exists for.

Verified the rest of the copy carried nothing else stale: the diff against main is
now only the ceiling gate and the rename.
sbryngelson pushed a commit that referenced this pull request Aug 3, 2026
…vably safe (#161)

* compile: declare input activation ranges so opt=1 keeps int8 when provably safe

Implements #155, and supersedes #156 by carrying its gate plus the bound machinery
that makes the gate an optimization instead of a near-blanket decline.

af.input(shape, max_abs=) records a promise about |value| at runtime. Undeclared
inputs stay unbounded, so the fail-closed default from #153 is unchanged.

_propagate_max_abs walks the graph conservatively. Anything not modelled returns
None rather than a guess, since over-estimating only declines a lossy variant more
often while under-estimating would wrongly keep one. Covers non-expanding
elementwise ops, scalar mul/add, add/sub/mul/min/max over both operands, the
matmul and linear weight row-sum bound, bmm, clip by its limits, and the
saturating activations that are bounded whatever feeds them.

That last group matters more than the declaration itself: sigmoid, tanh, softmax,
l2_norm and relu6 bound an undeclared input, so anything downstream of a softmax
or a sigmoid recovers int8 at opt=1 with no annotation at all.

_act_max_abs now bounds the activation feeding each weight-consuming node rather
than the graph output, which is the quantity a weight-quantizing variant actually
has to encode.

Verified on M2 Pro: the repro goes from 179/496 non-finite to 0/496, and a graph
declaring max_abs=8.0 with real activations peaking at 7.1 keeps int8 and stays
finite at relerr 4.6e-3, against 2.5e-4 for the lossless path. 21 new build-level
tests, the 10 from #156 still pass, corpus GATE GREEN 90/90.

Also commits bench/opt1_int8_saturation_repro.py, which the review on #156 asked
for, and fixes the test docstring that referenced it while it was absent.

* Restore the merged fail-closed gate in _optimize

I built this branch's _optimize.py from the #156 branch, which predates #157, so
copying it reverted the fail-closed relerr check. #157's source-form test caught
it in CI, which is what that test exists for.

Verified the rest of the copy carried nothing else stale: the diff against main is
now only the ceiling gate and the rename.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

autotuner emits inf instead of declining the int8 variant when activations exceed its saturation ceiling

2 participants