Skip to content

Add W8A8 int8 MatMul with optional rotations - #1003

Open
Mikyx-1 wants to merge 10 commits into
google:devfrom
Mikyx-1:w8a8-int8-matmul-dev
Open

Mikyx-1 wants to merge 10 commits into
google:devfrom
Mikyx-1:w8a8-int8-matmul-dev

Conversation

@Mikyx-1

@Mikyx-1 Mikyx-1 commented Aug 31, 2026

Copy link
Copy Markdown

Summary

  • add a symmetric W8A8 MatMul kernel with per-token activation scales, per-output-channel weight scales, and int32 accumulation
  • consume packed int8 weights directly through Highway integer dot products instead of repeatedly decompressing weight tiles to BF16
  • use the native signed encoding on Arm-like targets and an x86 biased-weight encoding with per-K-range correction
  • add an optional block-128 Rademacher-Hadamard rotation to reduce quantization outlier error
  • add model-level experimental routing, lazy weight caching, selection controls, kernel benchmarks, correctness tests, and generic model-comparison tooling

Evaluation

The complete 83-question repository MMLU fixture was run on Gemma 3 270M IT and 1B IT. Full methodology, hardware/software details, flips, KL, RAM, and raw-artifact descriptions are in issue #1002.

Model Configuration Accuracy Speedup Answer changes Correctness flips Mean KL Peak RSS
270M naive W8A8 19/83 1.432x 13/83 5/83 0.544739 1037.2 MiB
270M rotated W8A8 18/83 1.313x 3/83 2/83 0.048856 1039.4 MiB
1B naive W8A8 23/83 1.561x 17/83 8/83 0.327469 2728.4 MiB
1B rotated W8A8 25/83 1.494x 6/83 0/83 0.055162 2786.6 MiB

Rotation reduced mean KL versus naive W8A8 by 91.0% on 270M and 83.2% on 1B while retaining most of the speedup. The fixture is small, so one- or two-question accuracy differences are not statistically conclusive.

Validation

  • signed int8, rotation off: PASS
  • signed int8, rotation on: PASS
  • biased uint8, rotation off: PASS
  • biased uint8, rotation on: PASS
  • rotation dot-product relative error: 1.270e-07
  • compare_models Python tests: 6 passed
  • full 270M and 1B evaluations completed without OOM or disk pressure

Prototype scope

The model integration is intentionally experimental. It lazily quantizes from the loaded representation, retains a process-wide cache, and currently shares shape-only MatMul autotune keys with the original path. A production integration should load weights quantized directly from the original checkpoint and use separate tuning identities.

Closes #1002

@google-cla

google-cla Bot commented Aug 31, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Prototype of the int8*int8 path suggested in google#560. Unlike the existing I8Stream support, which dequantizes B to BF16 per tile via MMDecompress::DecompressB, this consumes B directly and multiplies with hn::SumOfMulQuadAccumulate (vpdpbusd on x86 VNNI, sdot/usdot on NEON, svdot on SVE), accumulating in int32.

Quantization is symmetric throughout: per-token scales for A, computed on the fly in place of DecompressA, and per-output-channel scales for B baked in at pack time. No zero points, so C[r,c] = a_scale[r] * b_scale[c] * dot(qa[r], qb[c]) and int32 accumulation runs over a whole kc range before a single scaling step.

MMLoops is now generic over the kernel and B type, so the int8 path reuses the existing blocking, parallelization and autotuning rather than duplicating the loop nest. The BF16 path is unchanged; matmul_test still passes on all attainable targets.

On x86 the 4-way dot needs one unsigned operand. B is biased by 128 there and the 128*sum_k(qa) term is subtracted per kc range using prefix sums of the quantized A. Biasing B rather than A is what makes that per-range correction cheap, and it keeps the values written to C close to the true partial sums: correcting once over the whole K would inflate the intermediates that MMAddC accumulates through C, which is unrecoverable when C is BF16 and the weight channels are not zero-mean.

matmul_i8_test is built twice, once per encoding, so the x86 path is covered on non-x86 hosts. bench_matmul_i8 reports throughput against the BF16 and SFP kernels plus accuracy against an F64 reference.

matmul_i8_model-inl.h routes the model's MatMuls through the kernel behind GEMMA_MM_I8=1, for end-to-end measurement only. It quantizes lazily from whatever the weights file holds, so it stacks a second quantization on top of e.g. SFP; a production path would quantize the original checkpoint and would not share MatMulEnv's shape-only autotune keys between the two kernels.

Fixes #1
@Mikyx-1
Mikyx-1 force-pushed the w8a8-int8-matmul-dev branch from 7353b06 to f4034f4 Compare August 31, 2026 15:51
Keep microscale accumulators in registers, specialize 64/128-value groups, and reduce AVX2 dot products without a stack transpose. Fuse Hadamard sign and normalization passes and activation prefix accumulation.

Allow quantization groups independent of rotation width with per-tensor divisibility fallback. Add an optional preference for fewer K splits during fixed tuning while preserving existing quantization defaults and reference paths.

Extend prefix, long-K BF16, group fallback, and fixed-output regression coverage. Update the benchmark to use valid quantization groups.

Validated with the Release build, 11 kernel configurations, exact-output comparisons, multi-target x86 syntax checking, and focused AddressSanitizer with leak detection.
Add shared-weight dual-int8 activation kernels, calibrated weight interchange, packed head/body routing, focused correctness coverage, and reusable offline GPTQ calibration tools.

@jan-wassenberg jan-wassenberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for updating, and congrats on the results :D Some comments on the code:

Comment thread evals/model_comparison.h
std::vector<float> logits;
};

// Versioned binary store for root-model logits. It is uncompressed so target

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not sure about a binary format here, how feasible is text?

Comment thread evals/model_comparison.cc

} // namespace

uint64_t ModelComparisonFingerprint(const std::string& bytes) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

FNV-like is a pretty terrible hash. Consider hwy/contrib/hash/highwayhash-inl.h .

Comment thread ops/matmul-inl.h
// BF16 before the existing MatMul. This intentionally adds overhead: its
// purpose is to isolate activation-quantization error from a new kernel and
// weight quantization.
static bool ActivationI8RoundtripEnabled() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we can remove this again.

Comment thread ops/matmul.h
return !candidates_.empty();
}
void SetCandidates(std::vector<TConfig> candidates) {
void SetCandidates(std::vector<TConfig> candidates, bool tune = true) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It seems more elegant to only enable a single candidate at the point where those are produced (matmul.cc), rather than introducing a tune/fixed_ flag here.

Comment thread ops/matmul_i8-inl.h
return;
}
#endif
MMQuantizedDot4Accumulate<GEMMA_MM_I8_BIASED_B>(di32, a, b0, b1, b2, b3, c0,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's avoid the assembly. What prevents us from just using the MMQuantizedDot4Accumulate and its SumOfMulQuadAccumulate? Note that we can make any changes to Highway required. Is the issue the lack of avxvnni?

Comment thread ops/matmul_i8-inl.h
const auto perm = hn::IndicesFromVec(df, hn::Xor(lane, bit));
const auto other = hn::TableLookupLanes(v, perm);
const auto upper =
hn::RebindMask(df, hn::Ne(hn::And(lane, bit), hn::Zero(du)));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TestBit(lane, bit) can be a bit faster.

Comment thread ops/matmul_i8-inl.h
if (fast && (signs.size() < k || cached_hash != hash_bits)) {
signs.resize(k);
for (size_t i = 0; i < k; ++i) {
signs[i] = MMI8NegativeSign(i, hash_bits) ? 0x80000000u : 0u;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd suggest vectorizing this. Happy to advise.
i could be a u16 vector initialized to Iota(du16, 0), and incremented by Set(du16, Lanes(du16)).
To go from u16 to u32, we could perhaps just BitCast from the u16 result to u32. Or better the other way around: we have u32 indices, we temporarily drop to u16 to do the Mul, then BitCast back to u32 and shift left by 16 to put the result in the upper bit of the u32.

Comment thread ops/matmul_i8-inl.h
return value == nullptr ? fallback : atoi(value) != 0;
}

static inline bool MMI8FastRotate() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Any reason not to just hardcode this to true?

Comment thread ops/matmul_i8-inl.h
// Call only after every quantized weight and calibration update is final.
static HWY_NOINLINE void MMI8PackMicroB(MatPtrT<int8_t>& data) {
const size_t k = data.Cols();
HWY_ASSERT(data.Rows() % 8 == 0 && k % 4 == 0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

On the critical path, prefer HWY_DASSERT.
Also, we shouldn't allocate: we can have per-thread storage in MatMulEnv and pass the thread index indicating which one to use.

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