Skip to content

Fix decode attention for Neuron SDK 2.32 and validate on Inf2 - #133

Open
varuntej07 wants to merge 4 commits into
aws-neuron:mainfrom
varuntej07:contributed/fix-decode-attention-sdk232
Open

varuntej07 wants to merge 4 commits into
aws-neuron:mainfrom
varuntej07:contributed/fix-decode-attention-sdk232

Conversation

@varuntej07

@varuntej07 varuntej07 commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Description of changes:

contributed/decode_attention.py, merged in #129, no longer runs on a current install. Its correctness checks call nki.simulate_kernel, which Neuron SDK 2.32 / NKI 0.6.0 removed along with nki.baremetal and nki.benchmark. #131 moved the repo to 2.32 and rewrote the attention_fwd_performance tutorial, but did not touch contributed/, so the break is latent in what is already on main:

$ python contributed/decode_attention.py
AttributeError: module 'nki' has no attribute 'simulate_kernel'

Updated per review to contain only the port and the range change. The device benchmark that was originally in this PR has been split out.

The kernels themselves are unchanged. decode_attention_fwd, decode_attention_gqa_fwd and both NumPy references differ only in the loop iterators and comments described below.

1. Harness ported to the 0.6.0 API

Pre-0.6.0 Now
nki.simulate_kernel(kernel, *args) nki.simulate(kernel)(*args)
nki.baremetal()(kernel)(*args) kernel(*args)

The second row is per @nki.jit's own docstring, "compiles and executes standalone kernel, without a framework": a plain call with NumPy arrays is the device path on 0.6.0, which is why baremetal was removed rather than renamed.

Adds --backend {simulate,baremetal}, defaulting to the device when /dev/neuron0 exists, so python contributed/decode_attention.py does the right thing on a laptop or on an instance.

Worth flagging for anyone porting another contributed/ kernel: simulate_kernel / baremetal / benchmark do still import from the deprecated neuronxcc.nki namespace, which makes them look like a working fallback. They are not. They cannot drive a kernel decorated with the current top-level @nki.jit and fail with AttributeError: 'Kernel' object has no attribute 'grid', because they expect the older TraceKernel.

2. nl.affine_range -> builtin range (closes the thread on #129)

@aws-mattmcm asked on #129 whether new content should use range instead of affine_range, and @mrkcath-aws merged as-is and left it to me. On 0.6.0 the answer is yes.

nl.affine_range, nl.sequential_range and nl.static_range are all deprecated, all three docstrings say to prefer range() directly, and their implementations are identical in both the tracer and the simulator backends:

def affine_range(start, stop, step):     return range(start, stop, step)
def sequential_range(start, stop, step): return range(start, stop, step)
def static_range(start, stop, step):     return range(start, stop, step)

nl.range is not the migration target either: it is the same function object as nl.affine_range and carries the same deprecation. So both loops become plain range, and the dependency reasoning moves into comments, since the iterator name no longer records it. The KV-head loop's iterations are genuinely independent (each gets its own softmax state and touches disjoint slices of q and out), and the KV-tile loop genuinely carries (m, l, acc) and relies on in-order unrolling.

Validation

Run on inf2.xlarge (NeuronCore-v2, gen2), NKI 0.6.0, fp32, before and after the range change, with identical results:

$ python contributed/decode_attention.py --backend baremetal
[check_correct]     baremetal float32  d=128 seqlen_kv=128            max|diff|=7.302e-07  PASS
[check_correct_gqa] baremetal float32  d=128 seqlen_kv=512 group=4    max|diff|=1.460e-06  PASS

$ python contributed/decode_attention.py --backend simulate
[check_correct]     simulate  float32  d=128 seqlen_kv=128            max|diff|=5.960e-08  PASS
[check_correct_gqa] simulate  float32  d=128 seqlen_kv=512 group=4    max|diff|=1.192e-07  PASS

Also clean under python -W error::DeprecationWarning.

This is the first validation of these kernels on Inferentia2; #129 was reviewed on Trainium2. That distinction caught one real thing, now gated rather than silently broken: bf16 inputs do not compile on gen2, because nl.matmul has no dtype parameter and infers its PSUM destination dtype from the operands, and the gen2 tensor engine rejects a non-fp32 matmul destination:

error: assertion failed: nc_matmul dst dtype must be float32 on gen2, got bfloat16

trn2 is gen3 and appears to permit it, which is why a Trn2 review would not surface it. The file now skips bf16 with an explanatory note on gen2 instead of failing to compile. Fixing it properly means explicit fp32 PSUM tiles plus nisa.nc_matmul at all four matmul sites, which changes the kernels, so it belongs in its own PR.

Per the README, contributed/ is experimental, and the baremetal / p99 / E2E requirements in CONTRIBUTING.md sit under "Requirements for Kernels Targeting src/reference/", so I have followed the in-file check_correct() / main() house style of the other contributed/ kernels rather than adding a test_*.py.

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

contributed/decode_attention.py no longer runs. Its checks call
nki.simulate_kernel, which Neuron SDK 2.32 / NKI 0.6.0 removed along with
nki.baremetal and nki.benchmark, so `python contributed/decode_attention.py`
fails with AttributeError on a current install. The file was merged in aws-neuron#129
against the older API, and aws-neuron#131's move to 2.32 updated the tutorials but not
contributed/.

Port the harness:

- nki.simulate_kernel -> nki.simulate for the CPU path.
- nki.baremetal -> a plain kernel(*args) call, which on 0.6.0 is the
  on-device path: @nki.jit "compiles and executes standalone, without a
  framework" when handed numpy arrays.
- Add --backend, defaulting to the device when /dev/neuron0 exists and to
  simulation otherwise, so the file does the right thing on either host.
- Factor input construction into _make_mha_inputs / _make_gqa_inputs so both
  kernels run on either backend from one code path.

Record why neuronxcc.nki is not a fallback: nki.baremetal, nki.benchmark and
nki.simulate_kernel still exist there, but they cannot drive a kernel
decorated with the current top-level @nki.jit and raise AttributeError:
'Kernel' object has no attribute 'grid', because they expect the older
TraceKernel.

Gate bf16 behind BF16_SUPPORTED. bf16 inputs do not compile on NeuronCore-v2:
nl.matmul infers its PSUM destination dtype from its operands, and the gen2
tensor engine rejects a non-fp32 matmul destination with "nc_matmul dst dtype
must be float32 on gen2, got bfloat16". Fixing it means explicit fp32 PSUM
tiles via nisa.nc_matmul at the matmul sites and dtype=nl.float32 at the
transpose sites, which is a kernel change rather than a harness change, so it
is left for a follow-up. gen3 accepts a bf16 destination, which is why review
on Trn2 did not surface this.

Add _quantize so a low-precision run compares against the reference computed
from the same narrowed values widened back to fp32, keeping NumPy's own
low-precision arithmetic out of the measured error.

No kernel logic changes: decode_attention_fwd, decode_attention_gqa_fwd and
both NumPy references are unchanged apart from comments.

Tested on inf2.xlarge (NeuronCore-v2), NKI 0.6.0, fp32, both backends:
MHA max|diff| 7.30e-07, GQA (seqlen_kv=512, group=4) max|diff| 1.46e-06.
This is the first hardware validation of these kernels on Inferentia2; aws-neuron#129
was reviewed on Trainium2.
Timing a plain kernel(*args) call on NKI 0.6.0 measures the compiler, not the
kernel: nki/framework/compiled.py passes enable_cache=False to
compile_kernel_to_nir, so the standalone path re-runs the frontend on every
invocation (~1.5 s per call on Inf2, against ~67 us of kernel time at
seqlen_kv=512). nki.benchmark, which older samples used, was removed in 2.32
along with simulate_kernel and baremetal.

contributed/decode_attention_benchmark.py compiles once through the parser
frontend, the same frontend @nki.jit itself uses, and replays the NEFF:

    with nki_ir_context() as context:
        result   = ParserFrontend().compile(context, kernel, inputs=...)
        compiled = CompiledKernel.from_frontend(result, compile_opts)
        res      = compiled.benchmark(warmup=..., iterations=..., **tensors)

Two sweeps, both against the hypothesis that decode attention is memory-bound:
KV heads halved with query heads fixed, and seqlen_kv swept with head counts
fixed. benchmark() returns the real outputs, so every timed row is also checked
against the NumPy reference; a fast wrong kernel is not worth reporting.

Measured on inf2.xlarge, fp32, d=128, 5 warmup + 50 iterations. The kernel
reaches 4.7% of the 410 GB/s per-core HBM peak at best, and estimated_utilization
puts Vector at 41.5% against DMA at 5.5%. It is Vector-bound, not
bandwidth-bound, which makes softmax partition occupancy the target for
split-KV rather than the loads.

This lives in its own file because it needs a device. The numeric checks in
decode_attention.py still run anywhere via nki.simulate.
Closes the one thread left open on aws-neuron#129, where @aws-mattmcm asked whether new
content should use range instead of affine_range and the decision was left to
the kernel author.

On NKI 0.6.0 the answer is yes, and more strongly than the question implies.
nl.affine_range, nl.sequential_range and nl.static_range are all deprecated
("will be removed in future releases") and their docstrings all say to prefer
range() directly. nl.range is not a separate API either: it is the same
function object as nl.affine_range. In both backends the implementations are
identical:

    # nki/_backends/mlir_tracer/__init__.py, and the simulator backend
    def affine_range(start, stop, step):     return range(start, stop, step)
    def sequential_range(start, stop, step): return range(start, stop, step)
    def static_range(start, stop, step):     return range(start, stop, step)

So the iterator name conveys nothing to the compiler on 0.6.0; the earlier
affine-vs-sequential distinction is gone. Both loops become plain range, with
the dependency reasoning kept in comments where it still belongs: the KV-head
loop's iterations are genuinely independent, and the KV-tile loop genuinely
carries (m, l, acc) and relies on being emitted in program order.

Re-validated on inf2.xlarge after the change, fp32, both backends:
MHA max|diff| 7.30e-07, GQA (seqlen_kv=512, group=4) max|diff| 1.46e-06,
unchanged from before it.
@mrkcath-aws

mrkcath-aws commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Hi @varuntej07, can we split this into two PRs? This one with the decode_attention.py port and range change, as it is self-contained, correct, and uses public APIs. The solution for decode_attention_benchmark.py relies on APIs that are not part of the public contract and this will likely break again with the next release.

Per review: decode_attention_benchmark.py reaches for compiler internals that
are not part of the public contract, so it does not belong alongside a change
that is otherwise public-API only.

Specifically it depended on nki_ir_context pulled out of compile_to_bir's
__globals__, the private CompiledKernel._PEAK_HBM_BW, and the
nki.compiler.frontend / nki.compiler.ncc_driver submodules, none of which are
in nki's top-level exports. That is exactly the surface that moved under this
file between 2.29 and 2.32 in the first place.

Drops the benchmark, the two .gitignore entries for its NEFF artifacts, and
the docstring line pointing at it. What remains is the SDK 2.32 harness port
and the range change, unchanged and still validated on Inf2.
@varuntej07 varuntej07 changed the title Fix decode attention for Neuron SDK 2.32, validate on Inf2, add a device benchmark Fix decode attention for Neuron SDK 2.32 and validate on Inf2 Sep 22, 2026
@varuntej07

Copy link
Copy Markdown
Contributor Author

Thanks, that's fair and I've split it. This PR is now just the decode_attention.py port and the range change. I've dropped decode_attention_benchmark.py, the two .gitignore entries for its NEFF artifacts.

Before I open a second PR, I'd rather ask what you'd actually accept, because I couldn't find a supported route. There's no public timing API in 0.6.0, and src/ doesn't benchmark either: the attention_fwd_performance tutorial that #131 rewrote is correctness-only, and the one remaining reference to neuron-profile is stale now that it's been replaced by neuron-explorer. Timing a plain kernel(*args) call isn't an option because the standalone path re-runs the frontend on every invocation, so it measures the compiler (~1.5 s per call on Inf2 against tens of microseconds of actual kernel time).

Is there a supported way to get latency for a contributed/ kernel that I should be using instead? or would you rather this stay out of the repo entirely? Happy with either answer.

Separately, the measurement did turn up something worth recording regardless of whether the tool itself lands. Decode attention is usually assumed memory-bound, but on Inf2 this kernel is Vector-bound: it reaches 4.7% of the 410 GB/s per-core HBM peak at best, estimated_utilization puts Vector at 41.5% against DMA at 5.5%, and cost tracks KV tiles rather than bytes (~7.3 us per 128-wide tile, against the ~0.32 us that tile's 128 KiB would take at peak bandwidth). The likely cause is the online softmax running on [group, TILE_KV] tiles, so at Hkv=2 that's 4 rows on a 128-wide partition axis. That makes softmax partition occupancy, not the loads, the thing to target for the planned split-KV work. I can open an issue with the numbers if that's useful to have written down somewhere. Looking forward!

This branch has not been deployed

No deployments
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.

2 participants