Skip to content

Attention: fold the sigmoid gate into the causal reduce epilogue, one graph node instead of two - #268

Open
MichaelDementii wants to merge 1 commit into
Neroued:masterfrom
MichaelDementii:prep/attention-fused-sigmoid-gate
Open

MichaelDementii wants to merge 1 commit into
Neroued:masterfrom
MichaelDementii:prep/attention-fused-sigmoid-gate

Conversation

@MichaelDementii

Copy link
Copy Markdown
Contributor

Problem and scope

Related Issue: #267

Every full-attention call site in TextContext issues causal_softmax_attention and then
ops::sigmoid_mul over the whole output — a second pass, and a second node in the decode graph, for
work the reduce epilogue is already holding in registers. The benchmark reports the node count
itself, so this is measured rather than asserted: on the append entry with BF16 or INT8 storage the
Op's graph is three nodes in 92 of the 128 cells swept (five in 24, seven in 12), and handing the
gate to the Op removes exactly one of them — never two.

On qwen3.6-35b-a3b that is ten of the forty text layers, plus the MTP tail once per draft step —
thirteen calls in an mtp3 decode round.

Scope is causal_softmax_attention and the two call sites that feed it.
causal_softmax_attention_cached is deliberately untouched, so the third sigmoid_mul in
text.cpp stays where it is.

Implementation

causal_softmax_attention takes an optional gate. Where the route reaches the shared BF16/INT8
reducer the multiply is folded into its store; every other route — FP8, NVFP4, K8V4, the prompt
kernel, chunked small-T — applies the standalone elementwise kernel inside the Op. One contract
either way, so no caller has to know which route it landed on, and the bench's --gate switch
selects between the two forms for measurement.

The bytes are the bytes the caller's own sigmoid_mul produced, and that is a rounding question
rather than a formality. The standalone kernel reads what attention stored, so it sees the reduce
result already rounded to BF16. The fused epilogue therefore rounds first, widens back, multiplies in
FP32 and rounds to nearest. Keeping the FP32 accumulator would be more accurate and would move
tokens.

One case is not a multiplication by one: a masked column stores an exact zero, and 0 × sigmoid(g)
is zero for every finite gate but not for a NaN one, which the standalone multiply would propagate.
The fused path does the multiply there too.

Verification

RTX 5090, driver 616.64, CUDA 13.1.115, CMAKE_CUDA_ARCHITECTURES=120a, Release, base dc58675f.
Both arms' CMake caches are identical once the tree paths are normalised.

What moves

All three forms live in one binary, so the whole comparison is one process per pass:
--gate standalone is what the model issues today, --gate fused hands the gate to the Op, and
--gate standalone again is the identical-baseline null. Six passes, the arm order rotated through
six permutations, 480 cells over both entries, both geometries, all five storages, B in {1,4} and
W in {1,4,8,16}, --execution graph --cache cold --warmup 20 --repeat 300.

A pass is dropped for a cell when the two identical binaries disagree by more than 1 %; 396 of 2880
were
. Over the passes that survive that rule the null's |p95| is 0.50 % and its worst is
1.00 %; over all 2880 before the rule it is 4.05 %, which is what the drop rate is buying.

Cells are classified by the bench's own graph_nodes column, not by an assumption about which routes
fold.

cells median best worst faster outside ±0.50 %
the graph is one node shorter 76 −6.19 % −15.13 % +0.00 % 71 of 76 60 of 76
the node count is unchanged, control 404 +0.00 % −4.42 % +5.65 % 51 of 404 19 of 404
storage geometry cells median median saving
bf16 d256-h16-kv2 16 −4.43 % 1.94 µs
bf16 d256-h24-kv4 20 −4.60 % 1.99 µs
int8 d256-h16-kv2 16 −7.50 % 2.00 µs
int8 d256-h24-kv4 24 −7.93 % 2.02 µs

Which cells fold is not a rule I can state. 96 cells are on the append entry with BF16 or INT8
storage at W ≤ 8, and 76 of them fold. The twenty that do not are every d256-h16-kv2 cell at
W=8 — both storages, all sixteen — and four d256-h24-kv4 BF16 cells. The table reports what the
node column says rather than predicting it.

The saving is a node, and it does not grow with the cell. Its median is 2.000 µs; 48 of the 76
cells sit between 1.76 and 2.05 µs, with the largest mode exactly at 2.048. Across an eleven-fold
range of cell cost — the folded cells run 11.49 to 123.97 µs — the correlation between saving and
cost is r = −0.09. But it is not constant either: fourteen cells save less than 0.25 µs and five
save nothing measurable, and this sweep does not say why.

Eager timing, where a launch is not amortised by a graph, puts 74 of those 76 cells (two lose
every pass to the null rule) at a median of −14.73 % and 4.05 µs, with every one of them faster
and none saving zero. Eager is the noisier instrument here: 617 of 2880 passes dropped, 21.4 %.

The cells the product actually issues

The artifact is d256-h16-kv2 — its query_norm is [256], its output projection is 2048×4096 so
there are sixteen query heads, and its fused QKGV projection is 9216 rows, which leaves two KV heads.
Decode at mtp3 issues W=4 for the target block and W=1 per draft step, at B=1, BF16, append.

All eight such cells in the sweep fold. Their median is −2.95 % and their median saving
1.03 µs, on cells costing 15.58 to 40.16 µs — below the band median, because the band includes
larger and more favourable cells.

W=8, which DFlash7 would issue on this geometry, does not fold at all.

Output

NINFER_OP_REPORT_STATS=1 at %.17g over the whole test suite: 11900 cases shared between the
arms, all byte-identical
, none differing in any field. The candidate adds 335 cases, which are the
gated oracle checks the test gains.

Each case in causal_cache.cpp now runs a second time with a gate and is judged against a third,
ungated run multiplied by a host sigmoid — an independent path from the inputs rather than a second
run of the Op, so "the Op repeats itself" and "the gate is exact" stay separate questions.

Strength control. The rounding before the multiply is the one subtlety in the epilogue, so it has
to be the thing the test would catch. Keeping the FP32 accumulator instead — strictly more accurate,
and not what the standalone multiply produces — fails 65 exact-parity checks; restoring it passes.

ctest 120/120 on both arms, of which seven are skipped on both. clang-format reports no
replacements on any of the eight files, and none gains a line over the 100-column limit that master
did not already have.

End to end: not resolved on this machine

ninfer_bench -pg 8192,512 --prefill-chunk 4096 --max-ctx 9216 --spec mtp --draft-tokens 3 --lm-head-draft -r 10 --warmup 3, six passes with the arm order rotated, the third arm the master
binary again under a second label:

median range
candidate, decode +1.10 % −0.19 … +1.46
null, decode +0.55 % −0.44 … +0.89

The null is half the candidate and its band overlaps it, so this stand does not resolve the change
and I am not claiming a decode number.
At -r 2 it was worse: null +1.81 % median with a +5.41 %
worst pass.

What the Op measurement implies, as a prediction rather than a measurement: thirteen calls per round
at the 1.03 µs the production-shaped cells actually save is 13.4 µs against a round the same run
reports as 4891 µs, or 0.27 %. The run's counters are self-consistent, which is why that round
time is usable: 162 rounds per repetition for 512 tokens is 3.160 tokens per round, and the reported
acceptance rate of 0.718 predicts 1 + 3 × 0.718 = 3.154.

Limits

  • No end-to-end number, for the reason above. The claim is at the operator level.
  • The fold reaches BF16 and INT8 on the append entry, and not every such cell — the exceptions are
    listed above and were measured, not derived. FP8, NVFP4, K8V4 and the cached entry never fold.
  • causal_softmax_attention_cached is not extended.
  • Nineteen of 404 control cells sit outside the ±0.50 % null band, and five folded cells save nothing
    measurable. At 9 to 267 µs per cell with a 13.8 % drop rate this bench cannot say more about either
    group than that they are inside its own scatter.
  • No roofline. The change removes a launch rather than moving bytes: every byte and FLOP column in
    the bench is identical across all 2880 passes, and the derived GB/s column moves only because it is
    computed from the time.

Every full-attention call site in TextContext issued causal_softmax_attention
and then ops::sigmoid_mul over the whole output - a second pass, and a second
node in the decode graph, for work the reduce epilogue is already holding in
registers. The benchmark reports the node count itself, so this is measured
rather than asserted: on the append entry with BF16 or INT8 storage the Op's
graph is three nodes in 92 of the 128 cells swept, five in 24 and seven in 12,
and handing the gate to the Op removes exactly one of them, never two. On
qwen3.6-35b-a3b that is ten of the forty text layers, plus the MTP tail once
per draft step - thirteen calls in an mtp3 decode round.

causal_softmax_attention now takes an optional gate. Where the route reaches
the shared BF16/INT8 reducer the multiply is folded into its store; every
other route - FP8, NVFP4, K8V4, the prompt kernel, chunked small-T - applies
the standalone elementwise kernel inside the Op. One contract either way, so
no caller has to know which route it landed on.

The bytes are the bytes the caller's own sigmoid_mul produced, and that is a
rounding question rather than a formality. The standalone kernel reads what
attention stored, so it sees the reduce result already rounded to BF16. The
fused epilogue therefore rounds first, widens back, multiplies in FP32 and
rounds to nearest; keeping the FP32 accumulator would be more accurate and
would move tokens. One case is not a multiplication by one: a masked column
stores an exact zero, and 0 * sigmoid(g) is zero for every finite gate but not
for a NaN one, which the standalone multiply would propagate, so the fused
path does the multiply there too.

causal_softmax_attention_cached is untouched and keeps the standalone
multiply; extending it is a separate decision.

All three forms live in one binary, so the comparison is one process per pass:
--gate standalone is what the model issued, --gate fused hands the gate to the
Op, and --gate standalone again is the identical-baseline null. Six passes,
the arm order rotated through six permutations, 480 cells over both entries,
both geometries, all five storages, B in {1,4} and W in {1,4,8,16}, graph
execution, cold cache, 20 warmups and 300 samples. A pass is dropped when the
two identical binaries disagree by more than 1 %; 396 of 2880 were, and over
the rest the null's p95 is 0.50 % with a worst of 1.00 %. Before that rule it
is 4.05 %, which is what the drop rate is buying. Cells are classified by the
bench's own graph_nodes column, not by an assumption about which routes fold.

  band                                cells  median    best   worst  outside
  the graph is one node shorter          76  -6.19%  -15.13%  +0.00%  60 / 76
  the node count is unchanged, control  404  +0.00%   -4.42%  +5.65%  19 / 404

  storage geometry         cells  median  median saving
  bf16    d256-h16-kv2        16  -4.43%         1.94us
  bf16    d256-h24-kv4        20  -4.60%         1.99us
  int8    d256-h16-kv2        16  -7.50%         2.00us
  int8    d256-h24-kv4        24  -7.93%         2.02us

Which cells fold is not a rule worth stating: 96 cells are on the append entry
with BF16 or INT8 storage at W <= 8 and 76 of them fold, the twenty exceptions
being every d256-h16-kv2 cell at W=8, both storages, and four d256-h24-kv4
BF16 cells. The table reports the node column rather than predicting it.

The saving is a node and it does not grow with the cell: its median is 2.000
us, 48 of the 76 cells lie between 1.76 and 2.05 with the largest mode exactly
at 2.048, and across an eleven-fold range of cell cost - 11.49 to 123.97 us -
the correlation between saving and cost is r = -0.09. It is not constant
either: fourteen cells save under 0.25 us and five save nothing measurable,
and this sweep does not say why.

Eager timing, where a launch is not amortised by a graph, puts 74 of those 76
cells - two lose every pass to the null rule - at a median of -14.73 % and
4.05 us, every one faster and none saving zero. Eager is the noisier
instrument here: 617 of 2880 passes dropped, 21.4 %.

The cells the product actually issues are narrower than the band. The artifact
is d256-h16-kv2: query_norm is [256], the output projection is 2048x4096 so
sixteen query heads, and the fused QKGV projection is 9216 rows, leaving two
KV heads. Decode at mtp3 issues W=4 for the target block and W=1 per draft
step, at B=1, BF16, append. All eight such cells fold, at a median of -2.95 %
and a median saving of 1.03 us on cells costing 15.58 to 40.16 us. W=8, which
DFlash7 would issue on this geometry, does not fold at all.

Output is unchanged: NINFER_OP_REPORT_STATS=1 at %.17g over the whole test
suite gives 11900 cases shared between the arms, all byte-identical, none
differing in any field. The 335 cases the candidate adds are the gated oracle
checks the test gains.

Each case in causal_cache.cpp now runs a second time with a gate and is judged
against a third, ungated run multiplied by a host sigmoid - an independent path
from the inputs rather than a second run of the Op. Strength control: keeping
the FP32 accumulator instead of rounding before the multiply, which is
strictly more accurate and not what the standalone multiply produces, fails 65
exact-parity checks; restoring it passes.

No end-to-end number. On this machine the identical-baseline arm reads +0.55 %
median on decode over a -0.44 to +0.89 % band, against a candidate reading
+1.10 %, so the stand does not resolve the change. What the Op measurement
implies, as a prediction rather than a measurement: thirteen calls per round at
the 1.03 us the production-shaped cells save is 13.4 us against a round the
same run reports as 4891 us, or 0.27 %.

ctest 120/120 on both arms.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-16T18:54:43.164708Z 2961dac Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@MichaelDementii

Copy link
Copy Markdown
Contributor Author

Supersedes #225. Scope is in #267.

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2961dac61c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +136 to +140
* An optional gate asks the Op to finish with out *= sigmoid(gate). When present it is a contiguous
* BF16 tensor shaped exactly like out and disjoint from every other operand. Where the route allows
* it the multiply is folded into the reduce epilogue; every other route applies the standalone
* elementwise kernel inside the Op. Either way the result is bit-identical to calling sigmoid_mul
* on the ungated output, so no caller has to know which route it landed on.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Define masked-tail behavior for NaN gates

When valid_columns masks a tail and the corresponding BF16 gate is NaN, both the fused reducer and the fallback sigmoid_mul produce NaN from 0 * sigmoid(NaN), but the preceding contract still promises that every tail column is exact BF16 zero. Because the new gate contract does not require finite values, these guarantees conflict for a valid input; either restrict gates to finite values or qualify the zero guarantee and cover the chosen behavior with an oracle case.

AGENTS.md reference: AGENTS.md:L89-L94

Useful? React with 👍 / 👎.

MichaelDementii pushed a commit to MichaelDementii/ninfer that referenced this pull request Sep 16, 2026
A Q8 threshold sweep contained, by accident, a band above T=57 where the
threshold selects nothing and both columns run identical code. They did not
read the same: <= 0.25 % apart on one shape and about 1 % on another,
systematically favouring whichever column ran first. That is the methods own
bias, priced inside the measurement without a third arm. Found by llm-5090-3b.

Named the pattern because two of our packages already use it - Neroued#264 sweeps
whole-tile widths that take the same route on both arms (+0.05 %, and chunk
1536 at -0.05 % beside a +2.37 % candidate), Neroued#268 carries 404 cells whose node
count is unchanged (+0.00 %). Neroued#222 cannot have one, since its arms differ at
every width, so its burden falls entirely on the null arm - which is why its
worst kept pass is quoted as carefully as it is.
Wallawalla47 pushed a commit to Wallawalla47/ninfer-custom that referenced this pull request Sep 17, 2026
Wallawalla47 pushed a commit to Wallawalla47/ninfer-custom that referenced this pull request Sep 19, 2026
@Neroued

Neroued commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

The fusion direction makes sense, but this PR needs the following changes before merging:

  1. Remove the intermediate BF16 rounding kept for bit-exactness. Apply the sigmoid gate directly to the FP32 attention result, then convert the final output to BF16. Check correctness against an independent oracle for the fused formula. There is no need to reproduce the old implementation's intermediate rounding.

  2. Cover the other KV types and related kernels. The small-T paths should cover BF16, INT8, FP8, NVFP4, and K8V4, including ChunkedSmallT and cached attention calls that use a gate. For NVFP4/K8V4, apply the gate after the inverse Hadamard transform. Large-T prefill can keep the standalone call. For any other path left unfused, please explain the specific reason.

  3. Shorten the PR description. Focus on what changed, which paths are covered, how correctness was checked, and the main performance results. Move detailed experiment history, repeated explanations, and large amounts of intermediate data to a separate report.

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.

3 participants