Skip to content

Add T8 ternary weights: MatMulTernary with NEON, AVX2 and AVX-512 kernels - #33823

Draft
mirounga wants to merge 2 commits into
microsoft:mainfrom
mirounga:rs_ternary
Draft

mirounga wants to merge 2 commits into
microsoft:mainfrom
mirounga:rs_ternary

Conversation

@mirounga

@mirounga mirounga commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Ternary models (weights in {-1, 0, +1} times a group scale) are now shipped as real checkpoints, for example PrismML's Bonsai 2 (2B and 27B). Today ONNX Runtime can only run them as 2-bit MatMulNBits. One of the four 2-bit code values is never used, so about 21% of the weight bits carry no information. Every weight also costs a full multiply-add, even though a ternary weight only ever means add, subtract or skip.

This PR adds T8, a ternary weight format, and the com.microsoft.MatMulTernary CPU operator that runs it:

  • Smaller: 1.725 bits per weight including scales, against 2.266 for Q2 MatMulNBits. Linear-layer storage drops about 24%.
  • Faster: the kernels never multiply by a weight. They decode masks and add or subtract activations. On Snapdragon X, T8 runs Bonsai 2B 3.44x faster than Q2, and 27B 1.54x faster.
  • Lossless: existing ternary checkpoints and Q2 graphs convert bit-exactly, and every matrix is round-trip checked during export.
  • Portable: NEON on ARM64, AVX2 and AVX-512F on x86, and a scalar fallback everywhere else.

The T8 format

T8 v1 (layout = "unit5x128-planes-v1") packs five ternary weights into one byte, because 3^5 = 243 ≤ 256:

  • Five output rows share each base-3 byte: trit 0 is zero, 1 is +scale, 2 is -scale.
  • Bytes are stored as eight little-endian uint32 bit planes of shape [ceil(N/5), 8, K/32]. One plane word covers 32 consecutive inputs.
  • Scales are FP16, one per row per 128 inputs, with shape [ceil(N/5), K/128, 5]. Padded rows have scale zero.
  • There is no zero point and no bias. Byte values 243–255 are invalid and rejected at load time.

Storage: 8 bits / 5 weights = 1.6 bits, plus a 16-bit scale per 128 weights = 0.125 bits, for 1.725 bits per weight. Q2 MatMulNBits needs 2.266 bits per weight (2-bit codes, FP32 scales, packed zero points). The format is close to the information-theoretic minimum of log2(3) ≈ 1.585 bits per weight while staying byte-addressable.

Why bit planes: a bitsliced gate network turns the eight plane words of a group straight into per-row "zero" and "positive" masks, for 32 inputs (or 128–512 inputs in a SIMD register) at once. Nothing is expanded to a dense weight matrix and nothing is divided by 3. The same layout is used by an existing AVX-512 reference and by the MLX implementation, so one file serves all of them.

What's in the PR

Operator and graph support:

  • com.microsoft.MatMulTernary schema with shape inference, plus the CPU kernel. It takes float32 activations [..., K], uint32 planes and FP16 scales, and validates the planes once for constant weights.
  • Opt-in runtime layouts, packed at session initialization through PrePack. The on-disk format does not change:
    • session.ternary.prepack = current | tiled | masks (default current).
    • session.ternary.row_tile = 1 | 2: two activation rows share one decode (prefill).

MLAS kernels:

Target Decode Accumulate
ARM64 NEON gate network, 4 words per vector masked add/subtract, float32
x86 AVX-512F gate network, 16 words per vector, one vpternlogd per gate decoded words used directly as opmasks: masked xor applies the sign, masked add accumulates
x86 AVX2+FMA gate network, 8 words per vector per-lane bit moved to the sign position with vpsllvd
Everything else same gate network on 32-bit words branchless masked add
  • Dispatch: the x86 kernels are selected at startup through a new MLAS_PLATFORM::T8GemmKernel pointer.
  • Same results across layouts: for a given kernel, every layout and row tile gives bit-identical output.
  • No ISA leaks: FP16 scales are converted inside each ISA-specific translation unit, so no inline function compiled with AVX-512 can be picked up by AVX2-only code.

Tooling (onnxruntime/python/tools/transformers/models/bonsai/):

  • convert_to_onnx.py --weight-format t8 exports Bonsai 2 straight from the MLX checkpoint to T8, with no intermediate Q2 graph and no dequantize or requantize step.
  • convert_to_t8.py re-encodes an existing Q2 graph losslessly.
  • benchmark_t8.py and benchmark_t8_layouts.py reproduce the numbers below.

Results

Whole model, Windows ARM64

These figures come from a Snapdragon X X1E80100 running ONNX Runtime built from this branch, with the CPU EP, 8 threads and float32 activations. Each step runs a fixed 24-token window with a 21-token prompt. Values are medians of 7 alternating Q2/T8 iterations, excluding session load.

Model Q2 size T8 size Q2 step T8 step Speedup
Bonsai 2 2B 0.669 GB 0.562 GB 2.807 s 0.815 s 3.44x
Bonsai 2 27B 7.682 GB 6.002 GB 93.207 s 60.367 s 1.54x
  • Whole-model files shrink 16.0% (2B) and 21.9% (27B). Embeddings stay Q2, and the 2B tied output head needs an extra T8 copy.
  • The maximum Q2/T8 logit difference is 1.12e-5 (2B) and 4.33e-4 (27B), and greedy tokens match.

The opt-in masks layout with two-row reuse adds another 1.47x (2B) and 1.49x (27B) over the default T8 kernel in the same setup. Its output is bit-identical on 24 actual-weight matrix shapes and both full models.

Kernel, x86

These are single-thread figures on a Ryzen AI MAX+ 395 (Zen 5), using random valid T8 weights:

M×N×K, layout scalar (before) scalar (after) AVX2 AVX-512F
1×2048×2048, planes 21.95 ms 2.49 ms 0.269 ms 0.135 ms
1×2048×2048, masks 16.58 ms 2.40 ms 0.244 ms 0.118 ms
1×2048×6144, planes 65.76 ms 7.47 ms 0.812 ms 0.412 ms
16×2048×2048, masks/2 244.0 ms – 2.61 ms 1.32 ms
  • AVX-512F: a 2048×2048 ternary GEMV takes 0.135 ms, about 160x faster than the original scalar path.
  • Scalar fallback: now about 8.8x faster than before, and its results are unchanged.

Testing

  • Native operator tests (12): every valid byte and lane, padded rows, several K groups, dynamic weights, batched and empty activations, both packed layouts with both row tiles, and rejection of invalid bytes, shapes, layouts, versions and options.
  • MLAS T8Gemm test:
    • All public entry points against a double-precision reference for M 1–5, padded N, and K of 1–9 groups.
    • On x86 it also covers the scalar fallback, and calls the AVX2 and AVX-512F kernels directly, so AVX2 is tested on AVX-512 machines.
    • It checks that output is bit-identical across layouts and row tiles.
  • Python tests (12): codec, Q2 to T8 conversion, the exporter (including both recurrent-attention head counts against an independent PyTorch reference), and the benchmark checks.
  • Full-model checks: both formats against eager PyTorch Qwen3.5 with float32-dequantized original weights.
  • Suites run: full onnxruntime_mlas_test (31,853 tests) on Linux x86_64. Native and Python tests on Windows ARM64 and Linux x86_64.

Scope and limitations

  • Activations: float32 only, on the CPU EP. There is no FP16 or int8 activation path yet.
  • Benchmark method: the model numbers recompute a fixed window. They are not cached decode tokens/s, and they are not a model-quality evaluation. The 27B checkpoint answers the sample prompt wrongly in every format, PyTorch included; agreement between formats is what the checks validate.
  • Windows x64: the cmake entries follow the existing AVX2 and AVX-512 pattern but have not been built locally. CI will cover them.
  • Compatibility: unmodified ONNX Runtime releases don't recognize MatMulTernary, so models exported with --weight-format t8 need a build that includes this PR. Leaving out --weight-format keeps the existing Q2 export.

- Register MatMulTernary with T8 v1 validation and shape inference
- Implement NEON masked-add GEMM and a scalar reference path
- Add opt-in tiled and decoded-mask prepacking with two-row reuse
- Support direct Bonsai checkpoint-to-T8 export and Q2 graph conversion
- Add codec, exporter, kernel, and prepacking regression tests
- Add Q2/T8 and runtime-layout benchmarks with documented results

Validation:
- 14 native tests and 12 Python tests passed
- Experimental layouts produced bit-identical outputs on 24 matrix
  cases and both complete Bonsai models
- Masks with two-row reuse achieved 1.47x/1.49x speedups over current
  T8 on 2B/27B fixed-window inference
- ARM64 wheel rebuilt and verified in an isolated smoke test

Keep the existing kernel as default and preserve the on-disk T8 format.
MatMulTernary had a NEON kernel on ARM64. Every other target used a scalar
fallback that rebuilt each T8 byte bit by bit and decoded it with divide and
modulo by 3. On x86 that made a 1x2048x2048 GEMV take 22 ms.

x86 kernels:
- Add an MLAS_PLATFORM::T8GemmKernel pointer. platform.cpp selects the AVX2
  kernel on AVX2+FMA hosts and the AVX-512F kernel on AVX-512F hosts.
  MlasT8Gemm and MlasT8GemmPacked keep the threading (16-unit tiles, row tiles
  of 1 or 2) and call the kernel when one is set.
- Both kernels use the NEON kernel's bitsliced gate network, shared as a
  template in t8gemm_kernel.h. AVX-512F decodes four groups per vector with
  one vpternlogd per gate; AVX2 decodes two groups per vector.
- AVX-512F uses each 16-bit half of a decoded word as an opmask: a masked xor
  applies the sign and a masked add accumulates. AVX2 broadcasts each word and
  moves every lane's bit into the sign position with vpsllvd. A is read in
  natural order, so the workspace is not used on x86.
- The masks layout skips the decode. For a given kernel, the planes, tiled
  and masks layouts with row tile 1 or 2 give bit-identical results, as on
  NEON.
- FP16 scales are converted with vcvtph2ps inside each kernel, not with the
  inline MLFloat16::ToFloat, so no ISA-specific copy of a shared inline
  function can reach other translation units.

Scalar fallback:
- It uses the same gate network on 32-bit words and a branchless masked add.
  Inputs are still summed one at a time in input order, so results match
  the old code (except that an exactly-zero sum is +0 instead of -0).
- The packed path decodes a tiled group once per row tile, and the scalar
  masks packing uses the same decode.

Single-thread time on a Ryzen AI MAX+ 395 (Zen 5):

  shape (MxNxK), layout   old scalar  new scalar   AVX2      AVX-512F
  1x2048x2048, planes     21.95 ms    2.49 ms      0.269 ms  0.135 ms
  1x2048x2048, masks      16.58 ms    2.40 ms      0.244 ms  0.118 ms
  1x2048x6144, planes     65.76 ms    7.47 ms      0.812 ms  0.412 ms
  16x2048x2048, masks/2   244.0 ms    -            2.61 ms   1.32 ms
  masks packing, 2048^2   21.6 ms     0.057 ms     -         -

Tests:
- New MLAS test T8Gemm.ShortExecute checks MlasT8Gemm and MlasT8GemmPacked
  (tiled and masks, row tiles 1 and 2) against a double-precision reference
  for M 1-5, N with padded units and K of 1-9 groups.
- On x86 it also runs the entry points with the platform kernel cleared, to
  cover the scalar fallback. It calls the AVX2 and AVX-512F kernels directly,
  so AVX2 is tested on AVX-512 hosts, and checks that results are
  bit-identical across layouts and row tiles.
- Passed: onnxruntime_mlas_test (31853), MatMulTernary provider tests (12),
  and the bonsai Python tests (12).
- The Windows x64 cmake entries are not built or tested.
Copilot AI balanced review requested due to automatic review settings November 2, 2026 18:37
@mirounga
mirounga marked this pull request as draft November 2, 2026 18:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved converter bugs bypass zero-point validation for cached matrices and misreference retained external tensors, risking silently incorrect results or model-load failures.

7 open issues
What changed in this PR

Adds T8 ternary-weight support to ONNX Runtime through the com.microsoft.MatMulTernary CPU operator, with optimized kernels and Bonsai model conversion tools.

Changes:

  • Introduces the operator schema, CPU registration, validation, and configurable weight prepacking.
  • Adds NEON, AVX2, AVX-512F, and scalar kernels with runtime dispatch.
  • Adds Bonsai export, conversion, generation, benchmarks, documentation, and regression tests.
File Description
onnxruntime/​test/​mlas/​unittest/​test_t8gemm.cpp Tests kernel accuracy and layout equivalence.
onnxruntime/​test/​contrib_ops/​matmul_ternary_test.cc Tests operator execution, packing, and invalid inputs.
onnxruntime/​python/​tools/​transformers/​models/​bonsai/​test_t8.py Tests codec, conversion, and benchmark checks.
onnxruntime/​python/​tools/​transformers/​models/​bonsai/​test_bonsai_export.py Tests direct export and attention behavior.
onnxruntime/​python/​tools/​transformers/​models/​bonsai/​t8_codec.py Encodes and decodes T8 weights.
onnxruntime/​python/​tools/​transformers/​models/​bonsai/​run_onnx.py Runs fixed-window greedy generation.
onnxruntime/​python/​tools/​transformers/​models/​bonsai/​README.md Documents conversion, layouts, and benchmark results.
onnxruntime/​python/​tools/​transformers/​models/​bonsai/​convert_to_t8.py Converts existing Q2 graphs to T8.
onnxruntime/​python/​tools/​transformers/​models/​bonsai/​convert_to_onnx.py Exports Bonsai checkpoints as Q2 or T8.
onnxruntime/​python/​tools/​transformers/​models/​bonsai/​benchmark_t8.py Compares Q2 and T8 correctness and latency.
onnxruntime/​python/​tools/​transformers/​models/​bonsai/​benchmark_t8_layouts.py Compares runtime packing and row-reuse variants.
onnxruntime/​core/​mlas/​lib/​t8gemm.cpp Implements validation, packing, NEON, and scalar execution.
onnxruntime/​core/​mlas/​lib/​t8gemm_kernel.h Shares decoding logic and layout helpers.
onnxruntime/​core/​mlas/​lib/​t8gemm_kernel_avx512f.cpp Implements the AVX-512F kernel.
onnxruntime/​core/​mlas/​lib/​t8gemm_kernel_avx2.cpp Implements the AVX2 kernel.
onnxruntime/​core/​mlas/​lib/​platform.cpp Selects x86 kernels at startup.
onnxruntime/​core/​mlas/​lib/​mlasi.h Declares internal kernel interfaces and dispatch state.
onnxruntime/​core/​mlas/​inc/​mlas_t8.h Declares T8 MLAS entry points.
onnxruntime/​core/​graph/​contrib_ops/​contrib_defs.cc Defines the operator schema and shape inference.
onnxruntime/​contrib_ops/​cpu/​quantization/​matmul_ternary.cc Implements CPU execution and prepacking.
onnxruntime/​contrib_ops/​cpu/​cpu_contrib_kernels.cc Registers the CPU operator.
include/​onnxruntime/​core/​session/​onnxruntime_session_options_config_keys.h Adds packing and row-tile configuration keys.
cmake/​onnxruntime_mlas.cmake Includes T8 sources with architecture-specific compiler flags.

Review effort: Balanced

Comment on lines +61 to +78
weight, scales, zeros = (resolve(name) for name in node.input[1:4])
key = (weight.name, scales.name, int(attrs["N"]), int(attrs["K"]))
if key not in matrices:
packed, s, zp = array(weight), array(scales), array(zeros)
if s.dtype != np.float32:
raise ValueError(f"{node.name}: MatMulTernary currently supports float32 activations only")
n, k = key[2:]
if packed.shape != (n, k // 128, 32) or k <= 0 or k % 128:
raise ValueError(f"{node.name}: Q2 shape disagrees with K/N")
if zp.dtype != np.uint8:
raise ValueError(f"{node.name}: expected packed uint8 zero points")
expected_zp_bytes = (k // 128 + 3) // 4
if zp.shape not in ((n, expected_zp_bytes), (n * expected_zp_bytes,)):
raise ValueError(f"{node.name}: invalid zero-point shape")
zp_codes = ((zp.reshape(n, -1, 1) >> np.array([0, 2, 4, 6], dtype=np.uint8)) & 3).reshape(n, -1)
if not np.all(zp_codes[:, : k // 128] == 1):
raise ValueError(f"{node.name}: every zero point must equal 1")
planes, t8_scales = encode_q2(packed, s, verify=True)
Comment on lines +116 to +118
for tensor in tensors:
if not tensor.external_data or external_data_helper.ExternalDataInfo(tensor).location != data_path.name:
store(tensor)
Comment on lines +39 to +49
Status PrePack(const Tensor& tensor, int input_idx, AllocatorPtr allocator,
bool& is_packed, PrePackedWeights*) override {
is_packed = false;
if (input_idx != 1 || !use_packed_) return Status::OK();
ORT_RETURN_IF_ERROR(ValidatePlanes(tensor));
packed_ = IAllocator::MakeUniquePtr<uint32_t>(allocator, PackedWords(), true);
MlasT8Pack(n_, k_, tensor.Data<uint32_t>(), packed_.get(), layout_);
constant_planes_ = true;
is_packed = true;
return Status::OK();
}
if (m == 0) return Status::OK();
AllocatorPtr allocator;
ORT_RETURN_IF_ERROR(context->GetTempSpaceAllocator(&allocator));
auto workspace = IAllocator::MakeUniquePtr<float>(allocator, SafeInt<size_t>(m) * k_, true);
Comment on lines +79 to +81
names = (weight.name + ".t8", scales.name + ".t8")
if any(name in initializers for name in names):
raise ValueError(f"{node.name}: T8 initializer name collision")
f"{sequence_length - len(token_ids)} generated tokens"
)

eos_token_ids = set(tokenizer.all_special_ids)
Comment on lines +3858 to +3860
ONNX_CONTRIB_OPERATOR_SCHEMA(MatMulTernary)
.SetDomain(kMSDomain)
.SinceVersion(1)
from pathlib import Path

import numpy as np
import onnx
from pathlib import Path

import numpy as np
import onnx
from pathlib import Path

import numpy as np
import onnx
from unittest.mock import Mock

import numpy as np
import onnx

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.

3 participants