xpu-kernels — fused kernels for Intel Arc (Xe2), and the instruments that prove them
Kernel work for Intel Arc XPU tensors, in two halves:
xpu_kernels.int8— a SYCL library (libxpuint8.so) plus a torch-only binding. Fused Kronecker-Hadamard rotation + INT8 quantization on the activation side, a fused dequant(+activation) epilogue on the output side, and accumulator paths that let a consumer walk an int32 GEMM result without a bf16 round trip. Inference, plus the backward'sdL/dx(weight-only dequant, since the weight scale sits on the contraction index there).xpu_kernels.triton— single-pass Triton kernels for the non-quantized path:rms_norm + ropeandrms_norm + adaLN modulation, each bit-identical to the chain it replaces, each verified against a reference once per process before it is allowed to engage.
Everything in debug/ is the evidence: bandwidth and epilogue ceilings, the
tanh slow path, attention backend equivalence, host-vs-device attribution,
and the A/B harnesses that produce the numbers quoted here. The findings are
written up in docs/ so they do not have to be rediscovered.
Documentation
| doc | contents |
|---|---|
docs/architecture.md |
how the rotation maps onto the hardware, the register-residency constraint, DPAS layout |
docs/performance.md |
all measurements, device ceilings, stage tables, the fair BF16 comparison |
docs/experiments.md |
what was tried, including the dead ends, so they are not repeated |
python/xpu_kernels |
the Python package: xpu_kernels.int8 (ConvRot INT8) and xpu_kernels.triton (rope, adaLN) |
docs/adding-a-kernel.md |
the structure every addition follows: gate, escape hatch, reference, evidence |
docs/todo.md |
what is next, ranked, with the evidence that would close each item |
docs/for-b580-diffusion-training.md |
what a training project on the same card can reuse (and what it must not) |
debug/README.md |
the measurement instruments and how to re-run them |
Headline result
Both INT8 and BF16 have to rotate the activation (ConvRot stores weights pre-rotated), so that is what the comparison below includes:
| M | ratio INT8/BF16 (4 runs) | verdict |
|---|---|---|
| 512 | 1.00 - 1.10 | INT8 0-10% slower |
| 1024 | 0.83 - 0.85 | INT8 15-17% faster |
| 2048 | 0.98 - 1.01 | parity |
| 4096 | 0.94 - 0.99 | parity to 6% faster |
| 8192 | 0.90 - 0.98 | parity to 10% faster |
(Ratios are stable across runs to ~1-2%; absolute milliseconds drift 10-15%
with GPU thermals. Full single-run table in docs/performance.md.)
Native INT8 is never meaningfully slower, and is 15-17% faster at M=1024,
with 4x less weight memory. Reproduce with debug/bench_bf16_fair.py.
Earlier revisions compared against a BF16
F.linearwith no activation rotation and reported INT8 as 1.9-2.3x slower. That comparison was not fair; it omitted work the INT8 path has to do.
Build
./build.sh # -> build/libxpuint8.so
build.sh needs a DPC++ (icpx) toolchain and pins the version on purpose:
the .so links against the SYCL runtime that torch ships (libsycl.so.8, oneAPI
2025.3 here). A 2026.x icpx produces a .so that cannot load — the 2025
runtime exports no submit_kernel* symbol, so it is not shimmable with -L or
an rpath. Set SYCL_ROOT if yours is elsewhere:
SYCL_ROOT=/opt/intel/oneapi/compiler/2025.3 ./build.sh
The library is optional at run time: if it is missing, the Python package says so once and every call raises; ComfyUI's equivalent path falls back to a pure torch implementation (4-6x slower, same numbers within INT8 noise).
Distribute
./wheel.sh # -> dist/xpu_kernels-0.2.0-py3-none-linux_x86_64.whl
./wheel.sh --sdist # source only: no .so, no toolchain needed
wheel.sh builds the .so first (with the pin above), stages it inside the
package, and tags the result linux_x86_64 rather than any -- the payload is a
real ELF. It is not manylinux: the .so links libsycl.so.8 and does not bundle
it, so a wheel only works in a process using the oneAPI runtime it was built
against. PyPI forbids local version identifiers (PEP 440), so the version stays
plain and the toolchain is recorded in the tag and the release notes instead
(v0.2.0-oneapi2025.3) -- a wheel that says which runtime it wants is better
than a "latest" that quietly stops loading when oneAPI moves. The sdist installs
without a toolchain and reports the library as unavailable until you run
./build.sh from an unpacked copy of it.
Use from Python
Install it as a package -- that is what a consumer should do, and what ComfyUI
does (pip install -e, no vendored copies):
pip install xpu-kernels # wheel: needs a matching oneAPI runtime, else see Build
pip install -e . # from a checkout: depends on torch only, picks up edits
python -m xpu_kernels # self-test on this machine: library + every gate
If the installed wheel reports the library unavailable (its .so was built
against a different oneAPI), rebuild from the sdist:
pip download --no-binary :all: --no-deps xpu-kernels
tar xf xpu_kernels-*.tar.gz && cd xpu_kernels-*/ && ./build.sh
export XPU_KERNELS_LIB=$PWD/build/libxpuint8.so # the loader's first choice
Or run it from the tree, which is what PYTHONPATH=python python -m xpu_kernels
does for the self-test.
import torch, xpu_kernels
w8, w_scale = xpu_kernels.prepare_weight(w) # bf16 [N,K] -> int8 + fp32 [N], once
y = xpu_kernels.linear(x, w8, w_scale, bias) # bf16 [..,K] -> bf16 [..,N]
# or drop-in for inference:
model.some_linear = xpu_kernels.Int8Linear.from_linear(model.some_linear)
The library is resolved from $XPU_KERNELS_LIB (or the legacy $XPU_INT8_LIB),
then lib/libxpuint8.so or build/libxpuint8.so next to this repo, then
sys.prefix.
When the consumer can walk the accumulator itself, skip the bf16 round trip:
acc, x_scale = xpu_kernels.linear_acc(x, w8, w_scale) # int32 [M,N]
y = xpu_kernels.dequant(acc, x_scale, w_scale, bias) # bf16 [M,N]
slice = xpu_kernels.dequant_slice(big_acc[:, 0:3072], xs, ws[:3072]) # strided, in place
q, s = xpu_kernels.dequant_act_quantize(up_acc, xs, up_ws) # dequant+situ+rotate+quantize
q, s = xpu_kernels.quantize_act(x2, "situ") # activation fused into the quantizer
dequant(linear_acc(...)) is bit-identical to linear(...); the fused
activation paths are bit-identical to the unfused chain they replace
(python -m xpu_kernels checks the second one; the exhaustive suites are in
debug/). prepare_weight is the only supported way to make a weight: the
activation rotation is baked into the weight layout, so a plain
w/amax*127 quantization is silently wrong.
The backward: dL/dx, weight-only
For y = x @ W^T, the vjp is dx = dy @ W — no transpose, no bias. With an
INT8 weight, recover W once and keep it:
w = xpu_kernels.dequant_weight(w8, w_scale) # bf16 [N,K], once per weight
dx = xpu_kernels.linear_dx(dy, w) # bf16 [M,K]
or on a module, which caches it: mod.dense_weight(), mod.dx(dy).
Why dequantize instead of an INT8 GEMM: W[n,k] = W8[n,k] * s[n], so s[n] sits
on the contraction index of dx = dy @ W and cannot fold into an epilogue
(the forward gets away with it because its weight scale is per output column).
Dequantizing reads 1.5 bytes per parameter and writes bf16 — 2.6 ms per 25 M
parameters here, ~0.4 s once for a 3.7 B-parameter model.
Against the same GEMM in autograd the result is exact (it is the same
oneDNN bf16 GEMM, so linear_dx(dy, W) == the autograd gradient bit for bit).
Against the unquantized weight the gradient is 0.9 % off, which is the INT8
weight itself (0.90 % quantization + 0.17 % for storing the dequantized weight
in bf16); python -m xpu_kernels and debug/verify_linear_dx.py both print it.
There is also linear_dx_int8(dy, w8, w_scale), which keeps the weight in int8
and quantizes dy instead (exact in the weight, since s[n] becomes a column
scale on dy). Measured at M=4096: it is 3.6x slower than the bf16 path
(6.9 vs 1.9 ms -- the quantizer, epilogue and final rotation cost more than the
faster GEMM saves) and 40 % noisier, so treat it as a memory option
(2x less weight memory), not a speed one.
Not here yet: dW = dy^T @ x (only needed for full fine-tuning) -- see
docs/todo.md, which also has the graph-capture checklist.
For a worked example of wiring this into a real model — weight loading, the
per-layer fallback when the device is offloaded or the library is missing, and
the fused FFN path — read comfy/xpu_int8.py in the ComfyUI tree; it is the
same library and the same C API, just with ComfyUI's dispatch around it.
Use the Triton kernels
The other half, for the bf16 path. Both kernels fuse a norm with the op that reads it, and both refuse to run until they have proved themselves against the implementation you would otherwise fall back to:
import xpu_kernels.triton as k
k.set_reference(k.rope, my_rms_rope) # once, before first use
q, kk = k.rope.rms_rope_split_half(q, kk, freqs_cis, q_scale, k_scale)
x = k.adaln.rms_adaln(x, scale, shift)
print(k.status()) # rms_rope : active: verified against the reference (1572864 values), num_warps=4
# rms_rope_dequant : active: verified against the reference (1572864 values), num_warps=4
# rms_rope_backward : active: verified within 0.0001 (worst relative error 7.1e-08, 786432 values), num_warps=4
# rms_adaln : active: verified against the reference (786432 values), num_warps=4
If comfy_kitchen is importable, its kernels are the default reference and no
set_reference is needed. Without a reference the kernels stay inactive and
status() says why — a kernel whose numerics nobody has pinned down is not
worth the speed. XPU_KERNELS_NO_ROPE=1 / XPU_KERNELS_NO_ADALN=1 disable them
outright (the legacy MOSS_NO_* names still work).
The gate itself is shared and tested on CPU (tests/test_gate.py): it coalesces
"verified once per process", "any mismatch disables the kernel for the rest of
the process", and "never pass a probe that could not run". docs/adding-a-kernel.md
is the checklist for a new kernel, including the backward-pass rules.
rms_adaln is differentiable too: one kernel for dx plus a two-stage
reduction for dscale/dshift (sums over the rows the modulation was broadcast
across, both in the same pass, no atomics), 6.3× faster than the eager chain
forward+backward and verified against torch.autograd at 1.5e-7.
rms_rope_split_half is differentiable: with a grad-requiring input it routes
through an autograd.Function whose backward is a single fused kernel (the
per-head dot product never leaves registers, so there is no scratch buffer and
nothing extra to launch). Measured on this card at the production shape, one
tensor: ~0.62 ms forward+backward vs ~8.9 ms for the eager chain, 14.3×,
and the gradient is closer to the float64 truth than torch.autograd's
(1.7e-3 vs 2.3e-3) because the fused path never rounds the gradient to bf16 at
the forward's cast. freqs_cis and scale are treated as constants; a trainer
that learns them needs a differentiable chain instead
(xpu_kernels.triton.reference.rms_rope_split_half).
Measured on this card, 42-block DiT at M≈3974: rope 2.34× (35.7 → 15.3 ms per
forward), adaLN 1.56× (12.7 → 8 ms), both bit-exact. A different num_warps
changes the reduction order and the output; do not tune it without re-running
the gate.
Use from C
xpuint8_convrot_quantize(x, q, scale, M, K); // bf16[M,K] -> int8[M,K] + fp32[M]
acc = torch._int_mm(q, wt); // int8 GEMM, oneDNN
xpuint8_dequant_epilogue(acc, out, scale, ws, bias, M, N); // -> bf16[M,N]
The BF16 path needs only the rotation:
xpuint8_convrot_rotate_bf16(x, xr, M, K); // bf16[M,K] -> bf16[M,K]
y = torch.nn.functional.linear(xr, w_bf16, bias);
Every entry point has a _stream variant taking the queue pointer from
torch.xpu.current_stream(dev).sycl_queue, which is what the Python binding
uses; the non-stream form waits. See include/xpu_kernels/convrot.h for the full
C API and docs/architecture.md for the algorithm.
Status
| component | state |
|---|---|
ESIMD dpas layout/probe |
resolved, documented below |
| Correct ESIMD INT8 GEMM | working, CPU-verified, 43 TOPS (was 0.7); torch._int_mm (oneDNN) is still faster and is what ships |
| Fused ConvRot rotate+quantize kernel | working, fp64-verified, 1.4x faster than eager |
DPAS RepeatCount + accumulating form (kernels/int8_gemm_v4.cpp) |
working, CPU-verified, 2.5x fewer instructions but slower (28 vs 43 TOPS) |
| Fused INT8 dequant epilogue | working, fp64-verified, 9x faster than eager, at bandwidth limit |
| End-to-end ConvRot INT8 linear | working, 1.7-2.1x faster than eager |
| Native INT8 vs dequantized BF16 including the rotation | parity, 15-17% faster at M=1024 — see the headline table. INT8's real win is 4x less weight memory |
| Fused dequant+situ+rotate+quantize | working, bit-identical to the unfused chain, 7 launches -> 1 |
| Row-strided dequant epilogue | working, bit-identical; lets a q |
Triton rms_norm + rope |
working, 2.34×, bit-identical to the shipped kernel (num_warps=4 pinned) |
| Triton rope backward | working, 14.3× on fwd+bwd vs the eager chain, differentiable wrapper |
Triton rms_norm + adaLN |
working, 1.56×, bit-identical, differentiable (fwd+bwd 6.3× vs eager) |
INT8 dL/dx (weight-only) |
working, exact vs the same GEMM in autograd, 0.9 % from the unquantized weight (that is the INT8 weight); dequant is one-time |
INT8 dW, quantized-dy gradient |
quantized-dy measured (a memory option, not a speed one); dW not implemented — see docs/todo.md |
| Device-graph capture | rope and adaLN fwd+bwd captured in one XPUGraph each, replay bit-identical, host time 648–1282 → 40–82 µs per stage |
Layout
include/xpu_kernels/convrot.h public C API (callable on torch XPU data_ptr())
src/xpuint8.cpp both SYCL kernels + C API
build.sh builds build/libxpuint8.so with icpx (version-pinned)
python/xpu_kernels/int8/ torch-only binding: _lib.py loader, ops.py, selftest.py
python/xpu_kernels/triton/ kernels + _gate.py; reference.py: eager chain and analytic backward
pyproject.toml `pip install .` for another project
benchmarks/bench_pipeline.py end-to-end: eager vs fused vs BF16
benchmarks/bench_int8.py torch _int_mm vs BF16 microbenchmark
reference/convrot_linear.py portable PyTorch ConvRot reference
kernels/ standalone experiments (DPAS probes, GEMM, per-kernel tests)
debug/ measurement instruments and the verification harnesses
docs/ architecture, performance, experiments, findings, how to add a kernel, todo
tests/ CPU-only unit tests (the gate's contract)
Build and run
./build.sh # -> build/libxpuint8.so
PYTHONPATH=python python -m xpu_kernels # self-test: int8 + every gate
PYTHONPATH=python python -m pytest tests/ -q # CPU-only: the gate's contract
python benchmarks/bench_pipeline.py
python debug/bench_bf16_fair.py # the honest INT8-vs-BF16 number
Triton's XPU backend compiles the kernels against the Level Zero headers on
first use. If they are not on the default include path, point
LEVEL_ZERO_V1_SDK_PATH at the SDK that came with the driver install — that is
a first-run cost only, the compiled kernels are cached.
Per-kernel tests:
icpx -O3 -fsycl kernels/convrot_quant.cpp -o /tmp/cq && /tmp/cq 4096 3072
icpx -O3 -fsycl kernels/dequant_epilogue.cpp -o /tmp/epi && /tmp/epi 4096 3072
icpx -O3 -fsycl kernels/gemm_tiled.cpp -o /tmp/gt && /tmp/gt 1024 1024 1024 16
API
int xpuint8_init(void);
const char *xpuint8_device_name(void);
int xpuint8_convrot_groupsize(void); // 256
// bf16 [M,K] -> int8 [M,K] + fp32 [M] row scales
int xpuint8_convrot_quantize(const void *x, int8_t *q, float *scale, int64_t M, int64_t K);
// int32 [M,N] -> bf16 [M,N]; out = acc * x_scale[m] * w_scale[n] + bias[n]
int xpuint8_dequant_epilogue(const int32_t *acc, void *out, const float *x_scale,
const float *w_scale, const float *bias, int64_t M, int64_t N);
const char *xpuint8_last_error(void);
Algorithm: fused ConvRot rotation
ConvRot's transform matrix is H = kron(H4,...,H4) / sqrt(gs) with
H4 = [[ 1, 1, 1, -1],
[ 1, 1, -1, 1],
[ 1, -1, 1, 1],
[-1, 1, 1, 1]]
A Kronecker product of identical factors applies H4 along each axis of the 4D
tensor view, and — because every factor is the same matrix — the axis-to-stride
mapping is arbitrary. Indexing a gs=256 group as i = 16*w + q gives
i0 = q & 3 i1 = (q >> 2) & 3 i2 = w & 3 i3 = w >> 2
so every axis collapses to a plain 4-point butterfly:
| axis | where | how |
|---|---|---|
| 0 | inside a register | 4 broadcast-swizzles + H4 sign vectors |
| 1 | inside a register | 4 broadcast-swizzles + H4 sign vectors |
| 2 | across registers {4g+0..4g+3} |
full-width elementwise butterfly |
| 3 | across registers {c, c+4, c+8, c+12} |
full-width elementwise butterfly |
Each butterfly is H4/2; four of them give (H4/2)^4 = 1/16 = 1/sqrt(256).
This costs ~2 vector ops per element instead of the 256 MACs per element that a
bmm(M, ng, 256, 256) rotation needs.
A group lives in 16 vec<float,16> registers (16 zmm, 1 KB) with fully unrolled
compile-time indices, so it never spills. The grid is one sub-group (16 lanes)
per row; lane l owns ConvRot groups l, l+16, l+32, ..., so the rotation needs
no cross-lane communication at all. The only sub-group collective is the row
abs-max, done with a 4-step XOR butterfly over select_from_group.
H4 does not factor into signed H2 butterflies (verified exhaustively over
all 192 signed-H2 level matrices, depths 3 and 4), so the 4-point form is the
cheap one.
Verification
kernels/convrot_quant.cpp checks against a CPU Kronecker reference;
benchmarks/bench_pipeline.py checks end-to-end output against a float64
rotation on CPU:
correctness vs float64 ground truth @ M=8192:
fused xpu-int8 relative L2 error: 0.008787
comfy eager relative L2 error: 0.009353
The fused path is slightly more accurate than comfy-kitchen's eager backend, because it accumulates the rotation in fp32.
comfy-kitchen's eager path is not a valid reference. It rotates activations
with a bfloat16 bmm, and the resulting row abs-max — hence the row scale — can
be wrong by up to ~40%:
K= 256 groups= 1 our scale vs fp64: 0.0e+00 eager scale vs fp64: 4.08e-01
K= 3072 groups= 12 our scale vs fp64: 6.4e-08 eager scale vs fp64: 2.39e-01
Consequently ~8% of quantized activations differ from eager by +/-1. That is eager's error, not ours.
Performance
Precise stage breakdown at M=4096, K=N=3072 (measured with preallocated buffers):
| stage | time | share | floor | headroom |
|---|---|---|---|---|
| rotate + row-quantize | 0.55 ms | 43% | 0.40 ms | 0.15 ms |
torch._int_mm |
0.53 ms | 41% | 0.41 ms (190 TOPS peak) | 0.11 ms |
| dequant epilogue | 0.15 ms | 12% | 0.15 ms (bandwidth limit) | none |
| python alloc/dispatch | 0.28 ms | 22% | ~0 | benchmark artifact |
| total | 1.53 ms | ~0.26 ms |
* benchmark artifact: the loop re-allocates the 12.6 MB int8 and 25 MB bf16 output buffers every iteration. Reused buffers do not pay this.
End-to-end sweep (K=N=3072). The bf16 column here omits the activation
rotation, so it is not the fair comparison — use the headline table at the
top or debug/bench_bf16_fair.py; this table is kept only as history of the
eager-vs-fused INT8 gap:
M eager int8 fused int8 bf16 eager vs bf16 fused vs bf16
512 0.782m 0.275m 0.140m 5.58x 1.96x
1024 0.854m 0.536m 0.252m 3.39x 2.13x
2048 1.664m 1.021m 0.405m 4.11x 2.52x
4096 3.288m 1.877m 0.832m 3.95x 2.26x
8192 6.344m 3.322m 1.631m 3.89x 2.04x
Optimization log
All numbers measured on the B580 at M=4096, K=N=3072.
Device ceilings (measured, not estimated)
These were wrong in an earlier revision of this file, so they are re-measured here. The old "788 GB/s streaming" figure came from a buffer that fit in the 18 MB L2 and was re-read in a loop; it is not a DRAM number.
| buffer | read | write | copy (r+w) |
|---|---|---|---|
| 12.6 MB | 291 GB/s | 577 GB/s | 776 GB/s |
| 25.2 MB | 310 GB/s | 329 GB/s | 606 GB/s |
| 50.3 MB | 322 GB/s | 346 GB/s | 640 GB/s |
| 400 MB | 468 GB/s | 397 GB/s | 885 GB/s |
So the pipeline's DRAM floor is roughly: rotate 37.8 MB -> 0.12 ms, epilogue 75 MB -> 0.12 ms.
Where the rotate+quantize time actually goes
The first ablation of this kernel was wrong -- it wrote the output twice, which inflated the "store" attribution. With a single store pass:
| variant | time | delta |
|---|---|---|
| full kernel | 0.407 ms | |
| minus transform | 0.273 ms | transform = 0.134 ms (33%) |
| minus int8 store | 0.276 ms | store = 0.131 ms (32%) |
| minus abs-max + sub-group reduce | 0.381 ms | 0.025 ms (6%) |
| minus sub-group reduce only | 0.407 ms | 0.000 ms |
The load+store skeleton alone is 0.273 ms for 37.8 MB = 138 GB/s, i.e. 4x below the streaming floor. That gap is register residency: 256 floats (16 zmm) must stay live across load -> row abs-max -> quantize, so all 16 loads complete before the first store and there is nothing to overlap with. An earlier micro-benchmark isolated it exactly -- same copy loop, 0.402 ms when all 16 registers are held vs 0.128 ms when load and store are interleaved.
The row-scale reduce itself is free (0.000 ms). It is the serialization it forces that costs.
Tried and rejected, each with data
Everything below was implemented and measured on this kernel:
| idea | result vs 0.407 ms baseline |
|---|---|
| fp16 butterfly (8 zmm instead of 16) | 1.14 ms -- no native 512-bit fp16 ops here |
| shared-memory staging of the strided axis | 1.33 ms -- barriers cost more than the pressure |
| 3-pass (rotate+stage bf16, reduce, quantize) | 0.75 ms -- pass 1 alone is 0.73 ms; the 16 registers are still live |
| forced full unrolling of the group loop | 1.27 ms -- spills |
| work-group size 16 / 32 / 64 / 128 / 256 | 0.60 / 0.60 / 0.57 / 0.57 / 0.59 ms -- not a lever |
| 64-byte stores instead of 16-byte | no change (0.114 vs 0.111 ms) |
vec<int32_t> pack instead of scalar float packs |
no change (0.411 vs 0.407 ms) |
| sub-group-scalar bf16 conversion via rounding-bit trick | 0.24-0.26 ms vs 0.135 -- the scalar loop is already vectorised |
| 2 or 4 rows per work-group | 0.76 / 0.42 ms -- 1.6-3x loss |
AOT -fsycl-targets=spir64_gen12 |
unavailable: this compiler only accepts generic spir64 |
Idle lanes are free. At K=3072 only 12 of 16 lanes have work. Measured per element, K=1024 (4/16 lanes busy) is the fastest case at 33.4 Melem/s vs 26.5 at K=3072 and 19-20 at K=4096 -- that ordering is cache size, not lane occupancy, so there is nothing to reclaim there.
GEMM: our implementation is L2-bandwidth bound, not DPAS bound
v3 reaches 43 TOPS = 12.5% of the 344 TOPS DPAS peak. A shape sweep shows why:
| M x N x K | ms | TOPS | B+A traffic | effective |
|---|---|---|---|---|
| 4096x3072x3072 | 1.78 | 43.4 | 7.25 GB | 4.07 TB/s |
| 4096x256x3072 | 0.156 | 41.2 | 0.60 GB | 3.86 TB/s |
| 512x3072x3072 | 0.237 | 40.9 | 0.91 GB | 3.83 TB/s |
| 8192x3072x3072 | 3.50 | 44.2 | 14.50 GB | 4.14 TB/s |
| 4096x3072x12288 | 13.32 | 23.2 | 28.99 GB | 2.18 TB/s |
TOPS is flat at 41-44 for every cacheable shape and collapses only when the working set leaves L2. Effective traffic sits at ~4 TB/s in all of them. The kernel is saturating L2, not DPAS. (B is re-read once per row tile, A once per column tile: 4.8 GB + 2.4 GB.)
kernels/int8_gemm_v4.cpp implements the two DPAS forms v3 was not using --
dpas<8,R,T,CT,BT,AT,...,N,BN,AN>(C,B,A) == C + A x B, which folds the
accumulate into the instruction, and RepeatCount=R, which amortises the B
load over R rows (2.5x fewer instructions per unit of work). CPU-verified
correct at R=1,2,4,8. It is nonetheless slower: 28 TOPS at R=8 vs 43 for
v3, because the 8-zmm accumulator operand costs more than the instructions
saved. Registers, again.
The honest conclusion is that reaching _int_mm's 151 TOPS needs a fully
blocked, double-buffered GEMM with ROWS/NT tiling and shared-memory staging --
worth maybe 2x on B traffic, i.e. ~0.9 ms, still 1.8x behind _int_mm. So
the fused epilogue (which would only save 0.15 ms) cannot pay for it.
Keep torch._int_mm.
Things checked that turned out to be non-issues
_int_mmlayout sensitivity:[K,N]contiguous vs an[N,K]transposed view differ by less than run-to-run noise._weight_int8pack_mmexists but requires a float A, so it is not an int8xint8 path.- N-blocking the GEMM so the int32 accumulator stays in the 18 MB L2: the
blocked
_int_mmcosts 0.99 ms vs 0.50 monolithic, versus a 0.13 ms saving on the epilogue. Net loss. - Temporary allocation:
torch.emptycosts 0.004 ms regardless of size. The 0.146 vs 0.275 ms difference seen for the epilogue between benchmarks is an artefact of whether the output buffer happens to be L2-warm, not allocator cost. Real models rotate buffers, so 0.27 ms is the honest figure.
Current stage table
| stage | time | floor | at floor? |
|---|---|---|---|
| rotate + quantize | 0.41 ms (0.56 via the library) | 0.12 ms DRAM / 0.27 ms register-resident | 40% of the residency floor |
torch._int_mm |
0.50 ms | oneDNN is the best available | yes, use it |
| dequant epilogue | 0.135-0.275 ms | 0.12 ms | 88% (0.135, warm) |
| total kernel time | 1.05-1.19 ms |
Why the first comparison said INT8 loses, and what the fair one says
The early measurement compared the INT8 pipeline against linear(x, wbf, bias)
with a pre-rotated weight and a pre-rotated activation, i.e. it handed
BF16 the activation rotation for free. ConvRot stores the weights rotated, so
both paths have to rotate the activation: BF16 with the rotate-only kernel,
INT8 with rotate+quantize. The old comparison therefore understated the BF16
side by exactly that kernel, and it is the reason the first revision of this
file reported INT8 as 1.9-2.3× slower.
With that kernel counted on both sides (debug/bench_bf16_fair.py), the ratio
is 0.90-1.10 across M=512..8192 — parity, with INT8 15-17% ahead at M=1024
(see the headline table).
The rotation is what makes them close: it is 82% of the eager INT8 layer, and even at its memory floor it costs about what the GEMM difference is worth. Driving it below that floor would need the 256 in-register floats (load → row abs-max → quantize) to stop being live at once; every attempt (fp16 butterflies, shared-memory staging, 3-pass, unrolling, wider stores, 2-4 rows per work-group) measured slower — see the optimization log above.
The honest summary: INT8 is a memory lever on this card, not a speed lever.
4× less weight memory and 2× for cached activations, at parity speed, which is
why ComfyUI keeps it behind the supports_int8_compute() opt-in instead of
making it the default.
ESIMD dpas findings (resolved)
The earlier blocker — dpas returning 16 instead of 32 for all-ones — was a
template-size mistake, not a hardware quirk. kernels/probe512b.cpp establishes:
dpas<8, 1, int, signed char, signed char,
dpas_argument_type::s8, dpas_argument_type::s8, 512, 32>(B, A)
computes 16 output columns, each a 32-element int8 dot product. All 32 lanes of
A are consumed in order: A[i] pairs with B[ACTIVE_B[i]] where
ACTIVE_B = 0..3, 64..67, 128..131, 192..195, 256..259, 320..323, 384..387, 448..451
i.e. only the even VNNI groups (group stride 128 bytes, 4 bytes per group).
The BN=256, AN=32 variant only exposes 8 columns and therefore only 16 active
MAC lanes — that was the source of the "16 instead of 32" result.
kernels/int8_gemm_v2.cpp uses the 512-byte layout with weights pre-packed
offline and passes a CPU reference check.
Metadata
Release files for xpu-kernels 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| xpu_kernels-0.2.0.tar.gz | 85.7 kB | With provenance | Details for xpu_kernels-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| xpu_kernels-0.2.0-py3-none-manylinux_2_35_x86_64.whl | Python 3 | none | Linux glibc 2.35+ x86-64 | Details for xpu_kernels-0.2.0-py3-none-manylinux_2_35_x86_64.whl |
Total release size: 382.2 kB
Release files / xpu_kernels-0.2.0.tar.gz
| Download URL | xpu_kernels-0.2.0.tar.gz |
|---|---|
| Size | 85.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
875e02f8679116d11a7614c920ff952047db8f48d449ada298af51af281e6759
|
|
BLAKE2b-256 checksum How to use checksums |
bd189cca795acaaaf9a48e4db4b446b6a2fbd51c1b33a9c4e02c50a093e11cb7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logRelease files / xpu_kernels-0.2.0-py3-none-manylinux_2_35_x86_64.whl
| Download URL | xpu_kernels-0.2.0-py3-none-manylinux_2_35_x86_64.whl |
|---|---|
| Size | 296.5 kB |
| Tags | Linux glibc 2.35+ x86-64 Python 3 |
|
SHA-256 checksum How to use checksums |
60d66f77c5055e8cb96c6815d679eb8681becce7c83247ef679f6da2bb93ae55
|
|
BLAKE2b-256 checksum How to use checksums |
1f7a4d164ebeaebd81c0560c3de3b33a724ab33029244e0f3a366131ed30dbb9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logSecurity for xpu-kernels 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for xpu_kernels-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.