Skip to content

[Performance] CPU MultiHeadAttention with attention_bias is ~4x slower than the unfused ops #33873

Closed

Description

On the CPU provider, com.microsoft.MultiHeadAttention with an attention_bias runs about 4x slower than the same attention written as plain MatMul/Softmax ops, with identical outputs. The case is a single-token decode step at Qwen3-0.6B's sizes, where exporters fuse attention into MultiHeadAttention (as onnxscript's optimize_for_ort does). In transformers' ONNX exporter, we currently expand those nodes back into plain ops on CPU.

Repro (onnxruntime-gpu 1.29.0, CUDA 12 build, A100):

"""ONNX Runtime CPU: `MultiHeadAttention` with an `attention_bias` runs slower than the MatMul/Softmax ops it replaces.

One decode step at Qwen3-0.6B's sizes: batch 8, one new token over 129 keys, 16 heads, head size 128, float32.
"""

import statistics
import time

import numpy as np
import onnxruntime as ort
from onnx import TensorProto, helper, numpy_helper

BATCH, NEW, TOTAL, HEADS, HEAD_SIZE = 8, 1, 129, 16, 128
F32 = TensorProto.FLOAT


def inputs():
    return [
        helper.make_tensor_value_info("query", F32, [BATCH, NEW, HEADS * HEAD_SIZE]),
        helper.make_tensor_value_info("key", F32, [BATCH, TOTAL, HEADS * HEAD_SIZE]),
        helper.make_tensor_value_info("value", F32, [BATCH, TOTAL, HEADS * HEAD_SIZE]),
        helper.make_tensor_value_info("bias", F32, [BATCH, 1, NEW, TOTAL]),
    ]


def mha() -> bytes:
    node = helper.make_node("MultiHeadAttention", ["query", "key", "value", "", "", "bias"], ["output"], domain="com.microsoft", num_heads=HEADS)
    return _model([node], [])


def unfused() -> bytes:
    # The same attention as plain ops: split heads, scaled QK^T + bias, Softmax, times V, merge heads.
    heads = numpy_helper.from_array(np.array([BATCH, -1, HEADS, HEAD_SIZE], np.int64), "heads")
    merge = numpy_helper.from_array(np.array([BATCH, -1, HEADS * HEAD_SIZE], np.int64), "merge")
    scale = numpy_helper.from_array(np.array(HEAD_SIZE**-0.5, np.float32), "scale")
    nodes = [
        helper.make_node("Reshape", ["query", "heads"], ["q4"]),
        helper.make_node("Transpose", ["q4"], ["q"], perm=[0, 2, 1, 3]),
        helper.make_node("Reshape", ["key", "heads"], ["k4"]),
        helper.make_node("Transpose", ["k4"], ["kt"], perm=[0, 2, 3, 1]),
        helper.make_node("Reshape", ["value", "heads"], ["v4"]),
        helper.make_node("Transpose", ["v4"], ["v"], perm=[0, 2, 1, 3]),
        helper.make_node("MatMul", ["q", "kt"], ["scores"]),
        helper.make_node("Mul", ["scores", "scale"], ["scaled"]),
        helper.make_node("Add", ["scaled", "bias"], ["biased"]),
        helper.make_node("Softmax", ["biased"], ["weights"], axis=-1),
        helper.make_node("MatMul", ["weights", "v"], ["o4"]),
        helper.make_node("Transpose", ["o4"], ["o"], perm=[0, 2, 1, 3]),
        helper.make_node("Reshape", ["o", "merge"], ["output"]),
    ]
    return _model(nodes, [heads, merge, scale])


def _model(nodes, inits) -> bytes:
    graph = helper.make_graph(nodes, "attention", inputs(), [helper.make_tensor_value_info("output", F32, None)], inits)
    opsets = [helper.make_opsetid("", 20), helper.make_opsetid("com.microsoft", 1)]
    return helper.make_model(graph, opset_imports=opsets, ir_version=10).SerializeToString()


def run(model: bytes, feeds: dict):
    options = ort.SessionOptions()
    options.intra_op_num_threads = 16
    session = ort.InferenceSession(model, options, providers=["CPUExecutionProvider"])
    for _ in range(20):
        session.run(None, feeds)
    times = []
    for _ in range(200):
        start = time.perf_counter()
        output = session.run(None, feeds)[0]
        times.append(time.perf_counter() - start)
    return statistics.median(times) * 1e6, output


rng = np.random.default_rng(0)
feeds = {
    "query": rng.standard_normal((BATCH, NEW, HEADS * HEAD_SIZE)).astype(np.float32),
    "key": rng.standard_normal((BATCH, TOTAL, HEADS * HEAD_SIZE)).astype(np.float32),
    "value": rng.standard_normal((BATCH, TOTAL, HEADS * HEAD_SIZE)).astype(np.float32),
    "bias": np.zeros((BATCH, 1, NEW, TOTAL), np.float32),
}
print("onnxruntime", ort.__version__)
fused_us, fused_out = run(mha(), feeds)
plain_us, plain_out = run(unfused(), feeds)
print(f"  MultiHeadAttention with attention_bias: {fused_us:7.1f} us")
print(f"  the same as MatMul/Softmax ops        : {plain_us:7.1f} us   (max diff {np.abs(fused_out - plain_out).max():.1e})")

Output:

onnxruntime 1.29.0
  MultiHeadAttention with attention_bias:  4912.6 us
  the same as MatMul/Softmax ops        :  1285.0 us   (max diff 0.0e+00)

Activity

  1. added
    model:transformerissues related to a transformer model: BERT, GPT2, Hugging Face, Longformer, T5, etc.
    on Nov 4, 2026
  2. added a commit that references this issue on Nov 9, 2026
    e440187
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

model:transformerissues related to a transformer model: BERT, GPT2, Hugging Face, Longformer, T5, etc.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions