UH-OH

It looks like you don’t have access to that feature yet

Contact sales to get upgraded to the full DevStudio experience.

UH-OH

It looks like you don't have access to that feature yet.

to select
to navigate
escto close
Introduction to the Chimera SDK
Chimera SDK Quick Start Guide
Chimera SDK Command Line Interface (CLI)
Tutorial: Using SDK as a Library
Tutorials & Model Demos
Custom Op Tutorials
Multicore Demo
Chimera LLVM C++ Compiler
Chimera SDK Licensing Policy Documentation
Glossary
Chimera Software User GuideTutorials & Model DemosCustom Op TutorialsTutorial: MatMulNBits Custom Op Replacement

Tutorial: MatMulNBits Custom Op Replacement


NOTE: The Jupyter Notebook below is included in the Chimera SDK and can be run interactively by running the following CLI command:

$ quadric sdk notebook

From the Jupyter Notebook window in your browser, select the notebook named /quadric/sdk-cli/examples/custom_op/MatMulNBits_custom_op_tutorial.ipynb.


MatMulNBits Custom Op Replacement

Replace ONNX MatMulNBits (4-bit quantized matmul with per-block scales) with the Quadric custom op linalg::matrixMulMatrix, which computes Y = A @ B_dequantized.

This tutorial walks through loading a MatMulNBits ONNX model, computing tensor ranges from dequantized weights and ORT output ranges, replacing each MatMulNBits node with a Quadric custom op using replace_subgraph_by_edges, and finally compiling with CGC and validating against ORT.

Model: MatMulNBits single-layer (4-bit quantized, per-block scales)


Notebook Outline


1. Setup

Configuration

MODEL_PATH = "./matmulnbits_single_layer.onnx"
OUTPUT_PATH = MODEL_PATH[:-5] + "_replaced.onnx"
TRANGES_PATH = MODEL_PATH[:-5] + "_replaced.tranges"

Imports

import onnx
from onnx import numpy_helper, version_converter
import numpy as np
from tvm.contrib.epu.chipy.param_utils import max_fraction_bits
import tvm.contrib.epu.graphutils as graphutils
from tvm.contrib.epu.graphutils import CustomOpReplacer
from functools import partial

Helper Functions

def get_tensor_data(tensor_name, model):
    """Get tensor data from model initializers."""
    for init in model.graph.initializer:
        if init.name == tensor_name:
            return numpy_helper.to_array(init)
    return None


def find_matmulnbits_nodes(model):
    """Find all MatMulNBits nodes and return their info dicts."""
    matmulnbits_nodes = []
    for node in model.graph.node:
        if node.op_type == "MatMulNBits" and node.domain == "com.microsoft":
            attrs = {}
            for attr in node.attribute:
                if attr.name in ["K", "N", "bits", "block_size"]:
                    attrs[attr.name] = attr.i
            matmulnbits_nodes.append(
                {
                    "node": node,
                    "input_a": node.input[0],
                    "input_b": node.input[1],
                    "input_scales": node.input[2],
                    "input_zero_points": node.input[3] if len(node.input) > 3 else None,
                    "output": node.output[0],
                    "attributes": attrs,
                    "name": node.name,
                }
            )
    return matmulnbits_nodes


def unpack_v8i4_weights(packed_weights):
    """Unpack V8I4 format: two 4-bit values per byte (low nibble first)."""
    flat_packed = packed_weights.flatten()
    unpacked = np.zeros(len(flat_packed) * 2, dtype=np.uint8)
    unpacked[0::2] = flat_packed & 0x0F
    unpacked[1::2] = (flat_packed >> 4) & 0x0F
    return unpacked


def preprocess_weights(weights_data, K, N, block_size, scales_data=None):
    """Preprocess MatMulNBits weights: unpack, reshape, sign-convert, apply per-block scales.

    ONNX stores unsigned [0,15]; we subtract 8 to get signed [-8,7] (equivalent to
    the signed int4 in the GPNPU sweep tests). Then dequantize: weight_float = int4 * scale[block, n].
    """
    n_blocks = K // block_size

    # Unpack V8I4 [N, n_blocks, block_size/2] and reshape to [K, N]
    int4_weights = unpack_v8i4_weights(weights_data)
    int4_weights_2d = int4_weights[: K * N].reshape(N, K).T

    # Convert unsigned [0,15] to signed [-8,7]
    int4_signed = int4_weights_2d.astype(np.int8) - 8

    if scales_data is not None:
        # ONNX scales layout: [N, n_blocks]  (sweep_runner uses [n_blocks, N] — transposed)
        scales_2d = scales_data.reshape(N, n_blocks)
        dequant = np.zeros((K, N), dtype=np.float32)
        for b in range(n_blocks):
            k_start = b * block_size
            k_end = k_start + block_size
            dequant[k_start:k_end, :] = (
                int4_signed[k_start:k_end, :].astype(np.float32) * scales_2d[:, b]
            )
        return dequant
    else:
        return int4_signed.astype(np.float32)


def make_process_matmulnbits_consts(K, N, block_size, bits, scales_data=None):
    """Create process_const_callback that dequantizes weights during replacement."""

    def process_matmulnbits_consts(param_dict, const_inputs, idx):
        reversed_param_dict = {}
        for param_name, tensor_name in param_dict.items():
            reversed_param_dict.setdefault(tensor_name, []).append(param_name)

        results_list = []
        for _key, const_data in const_inputs.items():
            tensor_name = const_data[0]
            tensor_data = const_data[1]
            if tensor_name in reversed_param_dict:
                param_name = reversed_param_dict[tensor_name][0]
                if "weights" in param_name:
                    processed = preprocess_weights(
                        tensor_data, K, N, block_size, scales_data=scales_data
                    )
                    results_list.append((f"{param_name}_{idx}", processed))
                else:
                    results_list.append((f"{param_name}_{idx}", tensor_data))
        return {idx: (name, data) for idx, (name, data) in enumerate(results_list)}

    return process_matmulnbits_consts


def compute_error_metrics(reference, prediction):
    """Compute RMSE, correlation, and max absolute error between two tensors."""
    ref_flat = reference.flatten()
    pred_flat = prediction.flatten()
    diff = pred_flat - ref_flat
    return {
        "rmse": float(np.sqrt(np.mean(diff**2))),
        "correlation": float(np.corrcoef(ref_flat, pred_flat)[0, 1]),
        "max_abs_error": float(np.abs(diff).max()),
    }


def replace_matmulnbits_with_custom_op(model_path, tranges_dict):
    """Replace all MatMulNBits nodes in an ONNX model with Quadric custom ops.

    Args:
        model_path: Path to the ONNX model file.
        tranges_dict: Dict of tensor name -> [min, max] ranges. Weight ranges will be
            added automatically. Output ranges are looked up by name; if missing, frac
            bits are estimated from the dequantized weight range.

    Returns:
        Tuple of (replaced_model_path, tranges_path, original_model, custom_model).
    """
    model = onnx.load(model_path)
    nodes = find_matmulnbits_nodes(model)
    print(f"Found {len(nodes)} MatMulNBits node(s):")
    for i, info in enumerate(nodes):
        attrs = info["attributes"]
        print(
            f"  Node {i}: {info['name']} - K={attrs['K']}, N={attrs['N']}, "
            f"block_size={attrs['block_size']}, bits={attrs['bits']}"
        )

    # Add dequantized weight ranges to tranges
    output_frac_bits_list = []
    for idx, info in enumerate(nodes):
        K = info["attributes"]["K"]
        N = info["attributes"]["N"]
        block_size = info["attributes"]["block_size"]

        weights_data = get_tensor_data(info["input_b"], model)
        scales_data = get_tensor_data(info["input_scales"], model)
        dequant_weights = preprocess_weights(weights_data, K, N, block_size, scales_data)
        max_abs_w = max(abs(float(dequant_weights.min())), abs(float(dequant_weights.max())))
        tranges_dict[f"weights_{idx + 1}"] = [-max_abs_w, max_abs_w]
        print(
            f"  weights_{idx + 1} range: [{dequant_weights.min():.4f}, {dequant_weights.max():.4f}]"
        )

        # Compute output frac bits from tranges (use original model output names)
        output_name = info["output"]
        if output_name in tranges_dict:
            output_range = tranges_dict[output_name]
            max_abs_out = max(abs(output_range[0]), abs(output_range[1]))
        else:
            # Estimate from weight range * assumed input range
            max_abs_out = max_abs_w * K * 0.5
            print(
                f"    Warning: output '{output_name}' not in tranges, estimating frac_bits from weight range"
            )
        output_frac_bits_list.append(max_fraction_bits(max_abs_out))
        print(f"    output_frac_bits={output_frac_bits_list[-1]}")

    # Save tranges
    tranges_path = model_path[:-5] + "_replaced.tranges"
    with open(tranges_path, "w") as f:
        json.dump(tranges_dict, f, indent=2)

    # Convert to opset 16 and replace nodes
    model_opset16 = version_converter.convert_version(model, 16)
    nodes_opset16 = find_matmulnbits_nodes(model_opset16)

    util = CustomOpReplacer(model_opset16)
    custom_model = model_opset16

    for idx, info in enumerate(nodes_opset16):
        K = info["attributes"]["K"]
        N = info["attributes"]["N"]
        block_size = info["attributes"]["block_size"]
        bits = info["attributes"]["bits"]

        scales_data = get_tensor_data(info["input_scales"], model_opset16)
        if scales_data is None:
            scales_data = get_tensor_data(info["input_scales"], model)

        param_dict = {"weights": info["input_b"]}
        out_edges = [info["output"]]
        in_edges = [info["input_a"]]

        const_callback = make_process_matmulnbits_consts(
            K, N, block_size, bits, scales_data=scales_data
        )
        ccl_function = "linalg::matrixMulMatrix"

        _, custom_model = util.replace_subgraph_by_edges(
            out_edges,
            in_edges,
            ccl_function,
            element_wise=False,
            fixed_point_frac_bits=[output_frac_bits_list[idx]],
            keep_constants=list(param_dict.values()),
            process_const_callback=partial(const_callback, param_dict),
            allow_io_in_l2_mem=False,
            needs_iter_var=False,
        )
        print(
            f"Replaced node {idx}: {info['name']} -> {ccl_function} (frac_bits={output_frac_bits_list[idx]})"
        )

    # Save replaced model
    output_path = model_path[:-5] + "_replaced.onnx"
    onnx.save(custom_model, output_path)
    print(f"\nSaved replaced model to {output_path}")

    return output_path, tranges_path, model, custom_model

2. Model Preparation

Load the ONNX model, compute tranges, and identify all MatMulNBits nodes.

model = onnx.load(MODEL_PATH)
nodes = find_matmulnbits_nodes(model)

for i, node_info in enumerate(nodes):
    attrs = node_info["attributes"]
    print(
        f"Node {i}: K={attrs['K']}, N={attrs['N']}, block_size={attrs['block_size']}, bits={attrs['bits']}"
    )

K = nodes[0]["attributes"]["K"]
Node 0: K=384, N=1152, block_size=128, bits=4

3. Custom Op Replacement

Compute tensor ranges and replace MatMulNBits nodes with linalg::matrixMulMatrix.

import json
import onnxruntime as ort

node_info = nodes[0]
K = node_info["attributes"]["K"]
N = node_info["attributes"]["N"]
block_size = node_info["attributes"]["block_size"]

weights_data = get_tensor_data(node_info["input_b"], model)
scales_data = get_tensor_data(node_info["input_scales"], model)

## Dequantized weight range
dequant_weights = preprocess_weights(weights_data, K, N, block_size, scales_data)
max_abs_weight = max(abs(float(dequant_weights.min())), abs(float(dequant_weights.max())))

## ORT output range from random inputs
sess = ort.InferenceSession(MODEL_PATH)
max_abs_y = 0
for _ in range(10):
    dummy_a = np.random.uniform(-10, 10, (1, 1, K)).astype(np.float32)
    ort_out = sess.run(None, {"A": dummy_a})[0]
    max_abs_y = max(max_abs_y, abs(float(ort_out.min())), abs(float(ort_out.max())))

tranges = {
    "A": [-10.0, 10.0],
    "Y": [-max_abs_y * 1.5, max_abs_y * 1.5],
}

print(f"Weight range: [{dequant_weights.min():.4f}, {dequant_weights.max():.4f}]")
print(f"Output Y range estimate: [{-max_abs_y * 1.5:.4f}, {max_abs_y * 1.5:.4f}]")

OUTPUT_PATH, TRANGES_PATH, _, custom_model = replace_matmulnbits_with_custom_op(MODEL_PATH, tranges)

print(f"\nModel nodes after replacement:")
for node in custom_model.graph.node:
    print(f"  {node.op_type} ({node.name}): inputs={list(node.input)}, outputs={list(node.output)}")
Weight range: [-27.3287, 27.6624]
Output Y range estimate: [-4902.7972, 4902.7972]
Found 1 MatMulNBits node(s):
  Node 0: matmul_nbits_layer - K=384, N=1152, block_size=128, bits=4
  weights_1 range: [-27.3287, 27.6624]
    output_frac_bits=18
Replaced node 0: matmul_nbits_layer -> linalg::matrixMulMatrix (frac_bits=18)

Saved replaced model to ./matmulnbits_single_layer_replaced.onnx

Model nodes after replacement:
  QuadricCustomOp (CustomOp/linalg::matrixMulMatrix0): inputs=['A', 'weights_1'], outputs=['Y']

4. Compile with Chimera Graph Compiler

Create a CGC job for the replaced model and validate ISS output against ORT.

import matplotlib

matplotlib.use("Agg")

from tvm.contrib.epu.chimera_job.chimera_job import ChimeraJob
from tvm.contrib.epu.chimera_job.hw_config import HWConfig

## Add output 'Y' to value_info for ORT validation compatibility
original_model = onnx.load(MODEL_PATH)
output_Y = original_model.graph.output[0]
y_in_value_info = any(vi.name == "Y" for vi in original_model.graph.value_info)
if not y_in_value_info:
    original_model.graph.value_info.append(output_Y)
    ORT_MODEL_PATH = MODEL_PATH[:-5] + "_ort_fixed.onnx"
    onnx.save(original_model, ORT_MODEL_PATH)
else:
    ORT_MODEL_PATH = MODEL_PATH

hw_config = HWConfig(product="QC-N")
cgc_job = ChimeraJob(
    model_p=OUTPUT_PATH,
    hw_config=hw_config,
    bypass_quantization_checks=True,
    validate_iss=True,
    trange_file=TRANGES_PATH,
    onnx_ort_override_p=ORT_MODEL_PATH,
)

inputs = {"A": np.random.uniform(-10.0, 10.0, size=(1, 1, K)).astype(np.float32)}

cgc_job.compile()
print(cgc_job)


## Workaround: run ORT and ISS separately and compare outputs directly.
ort_outputs = cgc_job.run_onnx_inf_session(inputs=inputs)
iss_outputs = cgc_job.run_inference_harness(inputs=inputs, compare_ort=False)

ort_Y = ort_outputs["Y"]
iss_Y = iss_outputs["Y"].tensor.reshape(ort_Y.shape)

metrics = compute_error_metrics(ort_Y, iss_Y)
print(f"\n=== ORT vs ISS Comparison ===")
print(f"ORT output range: [{ort_Y.min():.4f}, {ort_Y.max():.4f}]")
print(f"ISS output range: [{iss_Y.min():.4f}, {iss_Y.max():.4f}]")
print(f"RMSE:          {metrics['rmse']:.4f}")
print(f"Correlation:   {metrics['correlation']:.6f}")
print(f"Max abs error: {metrics['max_abs_error']:.4f}")

assert (
    metrics["rmse"] < 1e-3
), "MatMul Error : Error is too high, please check malmutnbits implementation"
2026-06-19 03:53 - INFO - epu - chimera_job - Overriding onnx model ort execution with the onnx model: ./matmulnbits_single_layer_ort_fixed.onnx
2026-06-19 03:53 - INFO - epu - chimera_job - START==================================onnx_ingest
2026-06-19 03:53 - INFO - epu - chimera_job - Numerical ranges provided
2026-06-19 03:53 - INFO - epu - codegen - START===============================optimize_relay
2026-06-19 03:53 - INFO - epu - codegen - START====================quantize_to_cpu_runnable_fx
2026-06-19 03:53 - INFO - epu - fx - 

Source name                        Op                             Output 0 Range         Output 0 Frac Bits
---------------------------------  -----------------------------  -------------------  --------------------
CustomOp/linalg::matrixMulMatrix0  contrib.epu.quadric_custom_op  [-4902.8f, 4902.8f]                    18

2026-06-19 03:53 - INFO - epu - codegen - START====================build_cpu_runnable_fx_relay
2026-06-19 03:53 - INFO - epu - codegen - START=======================quantize_to_chimera_fx
2026-06-19 03:53 - INFO - epu - codegen - START=================================relay_to_tir
2026-06-19 03:53 - INFO - epu - codegen - START===========================relay_to_epu_relay
2026-06-19 03:53 - INFO - epu - codegen - START==============================adapt_and_order
2026-06-19 03:53 - INFO - epu - codegen - START==============================amend_ctrl_flow
2026-06-19 03:53 - INFO - epu - codegen - START=============================plan_lrm_virtual
2026-06-19 03:53 - INFO - epu - codegen - START==============================amend_ctrl_flow
2026-06-19 03:53 - INFO - epu - codegen - START===============================lrm_alloc_loop
2026-06-19 03:53 - INFO - epu - codegen - START==============================amend_ctrl_flow
2026-06-19 03:53 - INFO - epu - codegen - START================================lrm_splitting
2026-06-19 03:53 - INFO - epu - codegen - START==============================ext_split_relay
2026-06-19 03:53 - INFO - epu - codegen - START====================================build_tir
2026-06-19 03:53 - INFO - epu - chimera_job - Compilation of matmulnbits_single_layer_replaced_QC_N_1d7_16MB_4kB_128GBps_128GBps_16_OFF_x1_x1 successful



╒═════════════════════╤══════════════════════════════════════════════════════════════════════════════════╕
 Module Name          matmulnbits_single_layer_replaced_QC_N_1d7_16MB_4kB_128GBps_128GBps_16_OFF_x1_x1 
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 ONNX File            ./matmulnbits_single_layer_replaced.onnx                                         
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 Product Target       QC-N                                                                             
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 Number of Cores      1                                                                                
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 ISS Clock Frequency  1.700                                                                            
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 L2M Size             16MB                                                                             
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 LRM Size             4kB                                                                              
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 External Read BW     128GBps                                                                          
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 External Write BW    128GBps                                                                          
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 MACS per PE          16                                                                               
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 Max L2M              0.000MB                                                                          
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 Max LRM              0.000kB                                                                          
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 Max Temp Ext Bytes   0.004MB                                                                          
├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────┤
 Network GMACs                                                                                         
╘═════════════════════╧══════════════════════════════════════════════════════════════════════════════════╛

╒════╤════════╤════════╤══════════════╤══════════════════════════╤═══════╕
     Type    Name    shape         type                      mse   
╞════╪════════╪════════╪══════════════╪══════════════════════════╪═══════╡
  0  Input   A       [1, 1, 384]   tensor[FixedPoint32<27>]  n/a   
├────┼────────┼────────┼──────────────┼──────────────────────────┼───────┤
  1  Output  Y       [1, 1, 1152]  tensor[FixedPoint32<18>]  n/a   
╘════╧════════╧════════╧══════════════╧══════════════════════════╧═══════╛



2026-06-19 03:53 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x7651f47cb430>
2026-06-19 03:53 - INFO - epu - iss_testing - Started Executing Onnxruntime...
2026-06-19 03:53 - INFO - epu - chimera_job - running ort reference with overriden model: ./matmulnbits_single_layer_ort_fixed.onnx
2026-06-19 03:53 - INFO - epu - iss_testing - Done 0:00:00.075431
2026-06-19 03:53 - INFO - epu - iss_testing - Found tranges for input: <tvm.contrib.epu.interval.Interval object at 0x7651f4717010>
FILM 1/1: 100%|███████████████████████████████████████████████████████| 1/1 [00:00<00:00, 12.91it/s]



=== ORT vs ISS Comparison ===
ORT output range: [-2128.5337, 2209.0381]
ISS output range: [-2128.5337, 2209.0378]
RMSE:          0.0001
Correlation:   1.000000
Max abs error: 0.0002
Table of Contents
Introduction to the Chimera SDK
Chimera SDK Quick Start Guide
Chimera SDK Command Line Interface (CLI)
Tutorial: Using SDK as a Library
Tutorials & Model Demos
Custom Op Tutorials
Multicore Demo
Chimera LLVM C++ Compiler
Chimera SDK Licensing Policy Documentation
Glossary


© Copyright 2026 Quadric All Rights Reserved • Privacy Policy
This documentation is preliminary and confidential. It is subject to change. Quadric does not give any warranty express or implied that the contents will be complete or accurate or up to date. The company shall not be liable for any loss, actions, claims, proceedings, demands or costs or damages whatsoever or howsoever caused arising directly or indirectly in connection with or arising out of the use of this material.

Sign in to your account

Don't have an account? Create an Account
By signing in, you are agreeing to our Terms of Use and Privacy Policy.

Develop.

Simulate.

Profile.

Collaborate.

We use cookies to enhance your browsing experience, and analyze our traffic.
By clicking "Accept All", you consent to our use of cookies.