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/models/yolo/yolo26/yolo26.ipynb.
YOLO26 End-to-End Detection on Chimera GPNPU
YOLO26 is Ultralytics' NMS-free, end-to-end detector family. This notebook compiles the entire network into a single int8 program on the Chimera GPNPU — backbone, PSA attention, neck, detection head, and the NMS-free decode — so an image goes in and 300 scored boxes come out with no host round-trip. CGC (Chimera Graph Compiler) handles the convolutional graph automatically; two hand-written CCL (Chimera Compute Language) kernels cover the attention core and the decode tail. The same notebook runs the N, M, and L variants — flip one variable in Section 2.
Why custom ops?
Two parts of YOLO26 are where a hand-written kernel pays off, and both map cleanly onto the PE array:
- PSA attention. YOLO26 embeds Partial Self-Attention at its lowest-resolution scale: 400 tokens (a 20×20 map) attending to each other. The convolutions around it are ordinary int8 convs, but the core is two dynamic matmuls and a softmax. Mapping one token to one PE puts both matmuls on the patch mesh and makes the softmax embarrassingly parallel.
- The NMS-free decode. YOLO26 needs no NMS at all — it selects a fixed 300 candidates directly. That selection is a top-K, which is exactly the kind of data-dependent reduction the CCL library already has a primitive for (
nn::topK).
Every YOLO26 variant shares the same attention geometry (400 tokens, key dim 32, head dim 64) and the same head layout, so both kernels serve N, M, and L unchanged — every shape is derived from the tensor shapes at compile time.
Pipeline
Model: YOLO26-N/M/L (COCO, 640×640 input, NMS-free end-to-end head)
1. Setup
Everything heavy (export, graph surgery, calibration, visualization) lives in yolo26_helpers.py; the notebook keeps the pipeline story.
import gc
import logging
import warnings
from pathlib import Path
import numpy as np
from sdk_cli.lib.quantize import QuantizedONNXModel, quantize_onnx_model
from tvm.contrib.epu.chimera_job.chimera_job import ChimeraJob
from yolo26_helpers import (
DEFAULT_IMAGE_PATHS,
MODEL_SPECS,
build_calibration_subset,
build_int8_reference,
detections_in_original_coords,
display_detections,
export_onnx,
load_images,
original_hw,
replace_attention_blocks,
replace_decode,
split_gpnpu_and_decode,
)
## The exporter and quantizer emit third-party deprecation notices and per-node debug
## records that are not actionable here; quiet them so the outputs below stay readable.
warnings.filterwarnings("ignore")
logging.getLogger().setLevel(logging.ERROR)
for noisy in ("sdk", "epu", "onnxruntime"):
logging.getLogger(noisy).setLevel(logging.WARNING)
%matplotlib inline
2. Model & ONNX Export
YOLO26 is compound-scaled. The attention geometry and head layout are identical across variants, so the kernels below work unchanged for all of them:
| Variant | Params | GFLOPs | Attention blocks | Heads |
|---|---|---|---|---|
| N | 2.6M | 6.1 | 2 | 2 |
| M | 21.9M | 75.4 | 2 | 4 |
| L | 26.3M | 93.8 | 3 | 4 |
Pick the variant here — the rest of the notebook adapts automatically.
VARIANT = "n" # "n", "m", or "l"
MODEL_NAME = f"yolo26{VARIANT}"
onnx_file = export_onnx(VARIANT)
spec = MODEL_SPECS[VARIANT]
print(f"Exported {onnx_file.name}: {spec['params']} params, {spec['gflops']} GFLOPs")
## The decode tail on its own, supplying the ONNX Runtime reference in Section 8.
_, decode_onnx = split_gpnpu_and_decode(onnx_file, VARIANT)
print(f"Reference decode tail: {decode_onnx.name}")
Downloading https://github.com/ultralytics/assets/releases/download/v8.4.0/yolo26n.pt to 'yolo26n.pt': 100% ━━━━━━━━━━━━ 5.3MB 51.9MB/s 0.1s
Ultralytics 8.4.18 🚀 Python-3.10.12 torch-2.6.0+cpu CPU (AMD Ryzen 9 9950X 16-Core Processor)
YOLO26n summary (fused): 122 layers, 2,408,932 parameters, 0 gradients, 5.4 GFLOPs
PyTorch: starting from 'yolo26n.pt' with input shape (1, 3, 640, 640) BCHW and output shape(s) (1, 300, 6) (5.3 MB)
requirements: Ultralytics requirement ['onnxslim>=0.1.71'] not found, attempting AutoUpdate...
WARNING ⚠️ Retry 1/2 failed: Command 'pip install --no-cache-dir "onnxslim>=0.1.71" ' returned non-zero exit status 127.
WARNING ⚠️ Retry 2/2 failed: Command 'pip install --no-cache-dir "onnxslim>=0.1.71" ' returned non-zero exit status 127.
WARNING ⚠️ requirements: ❌ Command 'pip install --no-cache-dir "onnxslim>=0.1.71" ' returned non-zero exit status 127.
/bin/sh: 1: pip: not found
ONNX: starting export with onnx 1.16.2 opset 16...
WARNING ⚠️ ONNX: simplifier failure: No module named 'onnxslim'
ONNX: export success ✅ 2.7s, saved as 'yolo26n.onnx' (9.4 MB)
Export complete (3.9s)
Results saved to /quadric/sdk-cli/examples/models/yolo/yolo26
Predict: yolo predict task=detect model=yolo26n.onnx imgsz=640
Validate: yolo val task=detect model=yolo26n.onnx imgsz=640 data=/home/lq/codes/ultralytics/ultralytics/cfg/datasets/coco.yaml
Visualize: https://netron.app
Exported yolo26n.onnx: 2.6M params, 6.1 GFLOPs
Reference decode tail: yolo26n-decode.onnx
3. Quantization
Post-training int8 quantization with asymmetric activations, calibrated on the COCO-like subset of the Quadric calibration set. Asymmetric ranges matter for detectors: the classification logits span a wide, one-sided range (background classes sit far below zero), and asymmetric calibration keeps the informative positive tail at full resolution.
The whole graph is quantized in one pass, including the decode region, so every edge the two kernels touch carries a calibrated scale and zero point.
quantized_model: QuantizedONNXModel = quantize_onnx_model(
str(onnx_file),
build_calibration_subset(),
asymmetric_activation=True,
)
print(f"Quantized ONNX: {quantized_model.model_path.name}")
gc.collect()
Quantized ONNX: yolo26n_OpSet16_optimized_asym_int8_q_shaped.onnx
10289
4. Attention Custom Op
Each attention block appears in the quantized graph as a Reshape/Split/Transpose/QLinearMatMul/QLinearMul/Softmax/QLinearMatMul island between the qkv convolution and the positional-encoding/projection convolutions. We locate every block by its Softmax node and replace the island with one yolo26Attention node:
- input: the qkv convolution output
[1, heads·128, 20, 20] - outputs: the attention result and the v passthrough (both
[1, heads·64, 20, 20])
The calibrated scales and zero points of the replaced edges fold into a handful of combined parameters, so the kernel drops into the quantized graph with no extra calibration.
attn_onnx = Path(f"{MODEL_NAME}-attn.onnx")
_, attn_blocks = replace_attention_blocks(quantized_model.model_path, attn_onnx)
for block in attn_blocks:
params = {
key: int(val) if key.startswith("zp") else round(float(val), 6)
for key, val in block.items()
if key != "block"
}
print(f"{block['block']}\n {params}")
/model.10/m/m.0/attn
{'zp_in': 18, 'requant_q': 1.717262, 'requant_kv': 0.869863, 'score_scale': 0.000709, 'av_scale': 0.012821, 'zp_out': -5, 'vout_requant': 1.0, 'zp_vout': 18}
/model.22/m.0/m.0.1/attn
{'zp_in': 6, 'requant_q': 1.575971, 'requant_kv': 0.947761, 'score_scale': 0.000718, 'av_scale': 0.012531, 'zp_out': 5, 'vout_requant': 1.0, 'zp_vout': 6}
5. Decode Custom Op
The decode kernel takes over from the six head convolutions — box and class for each of the three scales — and produces the final [1, 300, 6] detections. Taking the convolutions directly (rather than further down the decode) is what keeps the numerics clean: the exported graph concatenates boxes in pixel units with sigmoid scores into one tensor, which would put coordinates spanning 0–640 and probabilities spanning 0–1 on a single int8 scale. Here each level keeps its own calibrated scale, and that tensor is never formed.
The kernel also reorders the work. The graph decodes all 8400 anchor boxes and then discards all but 300; selection runs first here, so only the survivors are decoded.
customop_onnx = Path(f"{MODEL_NAME}-customops.onnx")
_, decode_levels = replace_decode(attn_onnx, customop_onnx)
for level in decode_levels:
print(f"{level['level']:14s} box {level['box']} cls {level['cls']}")
print(f"\nSaved: {customop_onnx.name}")
cv2.0/cv3.0 box {'scale': 0.030702, 'zero_point': -106} cls {'scale': 0.153382, 'zero_point': 120}
cv2.1/cv3.1 box {'scale': 0.038133, 'zero_point': -126} cls {'scale': 0.695386, 'zero_point': 124}
cv2.2/cv3.2 box {'scale': 0.05039, 'zero_point': -127} cls {'scale': 0.352859, 'zero_point': 120}
Saved: yolo26n-customops.onnx
6. The CCL Kernels
Attention maps one token to one PE: the 20×20 token grid drops onto the 32×32 PE array in the exact layout the qkv convolution already produces, so there are no transposes and no random-access gathers. Per head, QᵀK is one linalg::depthToDepth on the patch mesh, the softmax runs PE-local on each PE's own score row, and attn·V is one linalg::depthToFace. Two details keep it inside the 4 kB LRM (Local Register Memory, the per-PE scratchpad) and buy extra precision: the Q30 exponentials overwrite the dead int32 score row in place, and the 1/Σexp normalizer folds into the per-PE output scale so the weights use the full int8 range.
Decode exploits a property of the graph's selection. It selects in two stages — top-300 anchors by their best class, then top-300 of those anchors' (anchor, class) pairs — but the first stage does not change the outcome: if an anchor misses the top 300 by its own best class, then 300 other anchors already beat every score it has. So the result is simply the global top-300, which is the top-300 of the union of each level's own top-300. That lets nn::topK run directly on the three class tensors as CGC hands them over, with no reduction pass, no combined buffer and no gather of candidate scores — only the 900 survivors are merged.
attention_src = Path("yolo26_attention.hpp").read_text()
decode_src = Path("yolo26_decode.hpp").read_text()
## Attention: the per-head pipeline — patch-mesh QK, PE-local softmax, patch-mesh AV
print(
attention_src[attention_src.index(" // q_i . k_j") : attention_src.index(" // Epilogue")]
)
## Decode: per-level selection and the merge
print(decode_src[decode_src.index(" // Stage 1") : decode_src.index(" // Stage 3")])
// q_i . k_j against every key tile: each PE assembles its query's full score
// row, segment by segment, as exact int32.
container::NDArray<qVar_t<std::int32_t>, numTiles * patchSize> qRow;
for(std::int32_t kTile = 0; kTile < numTiles; kTile++) {
auto&& qK = qkvResident.template slice<keyDim>(kTile * headBlk + keyDim);
auto&& qSeg = qRow.template slice<patchSize>(kTile * patchSize);
linalg::depthToDepth<tileH, viewW>(qQ, qK, qSeg);
}
// Pass 1 — exact int32 row max over the valid entries.
qVar_t<std::int32_t> qMax = std::numeric_limits<std::int32_t>::min();
for(std::int32_t kTile = 0; kTile < numTiles; kTile++) {
// the last tile of the view holds the remainder (e.g. 144 of 400 on a 16x16 array)
const std::int32_t valid = (kTile == numTiles - 1) ? tokens - kTile * patchSize : patchSize;
for(std::int32_t j = 0; j < valid; j++) {
qMax = math::max(qMax, qRow[kTile * patchSize + j]);
}
}
// Pass 2 — exp((score - max) * scoreScale); the exps reuse the dead score row.
auto& qExp = qRow.template reinterpretCast<qVar_t<Fx30>>();
qVar_t<FixedPoint32<sumFracBits>> qSum = 0;
for(std::int32_t kTile = 0; kTile < numTiles; kTile++) {
const std::int32_t valid = (kTile == numTiles - 1) ? tokens - kTile * patchSize : patchSize;
for(std::int32_t j = 0; j < valid; j++) {
const std::int32_t idx = kTile * patchSize + j;
// |score - max| < 2^21 always fits Q10; the product is clamped to exp range
qVar_t<FixedPoint32<10>> qX = FixedPoint32<10>(qRow[idx] - qMax) * scoreScale;
qVar_t<FixedPoint32<10>> qClamped =
math::max(qVar_t<FixedPoint32<10>>(FixedPoint32<10>(-31.0)), qX);
qExp[idx] = math::exp<26, math::ExpMethod::FAST, 30>(qVar_t<FixedPoint32<26>>(qClamped));
qSum += qExp[idx];
}
}
// Idle PEs outside the token grid hold garbage sums; the guard keeps their
// (never written) rows numerically harmless.
qVar_t<FixedPoint32<sumFracBits>> qDenom =
math::max(qSum, qVar_t<FixedPoint32<sumFracBits>>(FixedPoint32<sumFracBits>(1)));
qVar_t<Fx30> qRecip = qVar_t<Fx30>(Fx30(1)) / qDenom;
// Pass 3 + attn.v — per key tile: quantize that segment's weights (max exp is
// exactly 1.0, so every row uses the full int8 range), reduce against v on the
// patch mesh, and accumulate. Dead tail entries carry weight 0.
container::NDArray<qVar_t<std::int32_t>, headDim> qAcc;
qAcc.fill(0);
container::NDArray<qVar_t<std::int8_t>, patchSize> qWeights;
container::NDArray<qVar_t<std::int32_t>, headDim> qPartial;
for(std::int32_t kTile = 0; kTile < numTiles; kTile++) {
const std::int32_t valid = (kTile == numTiles - 1) ? tokens - kTile * patchSize : patchSize;
for(std::int32_t j = 0; j < patchSize; j++) {
qWeights[j] = (j < valid)
? nn::quantizeLinear(qExp[kTile * patchSize + j], weightQuant)
: qVar_t<std::int8_t>(0);
}
auto&& qV = qkvResident.template slice<headDim>(kTile * headBlk + 2 * keyDim);
linalg::depthToFace<tileH, viewW>(qWeights, qV, qPartial);
for(std::int32_t d = 0; d < headDim; d++) {
qAcc[d] += qPartial[d];
}
}
// Stage 1 — each level's own top-300, taken straight off the input tensor. topK's
// square-granularity index is channel-major within the level: cls*S*S + row*S + col.
LevelVals valsP3, valsP4, valsP5;
LevelIdx idxP3, idxP4, idxP5;
ocmMem.allocate(valsP3);
ocmMem.allocate(valsP4);
ocmMem.allocate(valsP5);
ocmMem.allocate(idxP3);
ocmMem.allocate(idxP4);
ocmMem.allocate(idxP5);
nn::topK<maxDets>(clsP3, valsP3, idxP3, ocmMem);
nn::topK<maxDets>(clsP4, valsP4, idxP4, ocmMem);
nn::topK<maxDets>(clsP5, valsP5, idxP5, ocmMem);
// Stage 2 — merge the 900 candidates: detTiles rows per level, on a common scale.
using MergeVals = OcmTensor<std::int8_t, 1, 1, 3 * detTiles, mergeStride>;
using MergeIdx = OcmTensor<std::int32_t, 1, 1, 3 * detTiles, mergeStride>;
MergeVals mergeVals;
MergeIdx mergeIdx;
ocmMem.allocate(mergeVals);
ocmMem.allocate(mergeIdx);
fillOcmTensor(mergeVals, std::numeric_limits<std::int8_t>::min());
stageLevel<maxDets>(valsP3, idxP3, mergeVals, mergeIdx, 0, clsScaleP3, clsZpP3, ocmMem);
stageLevel<maxDets>(valsP4, idxP4, mergeVals, mergeIdx, 1, clsScaleP4, clsZpP4, ocmMem);
stageLevel<maxDets>(valsP5, idxP5, mergeVals, mergeIdx, 2, clsScaleP5, clsZpP5, ocmMem);
LevelVals finalVals;
LevelIdx finalIdx;
ocmMem.allocate(finalVals);
ocmMem.allocate(finalIdx);
nn::topK<maxDets>(mergeVals, finalVals, finalIdx, ocmMem);
7. Compile & Run
ChimeraJob compiles the graph with CGC and builds both CCL kernels alongside the generated code — yolo26_kernels.hpp pulls them in, since a job takes one custom-op header. The default hardware configuration is the QC-U target: 32×32 PE array @ 1.7 GHz, 1 core, 16 MB OCM (On-Chip Memory), 4 kB LRM, 16 MACs/PE, 128 GB/s DDR.
There is no head session and no postprocessing step to wire up: the compiled program's output is the detection list.
cgc_job = ChimeraJob(model_p=str(customop_onnx), custom_ops="yolo26_kernels.hpp")
cgc_job.compile(quiet=True)
all_image_paths, all_images = load_images(DEFAULT_IMAGE_PATHS)
gpnpu_outputs = [
cgc_job.run_inference_harness(inputs={"images": image}, compare_ort=False)
for image in all_images
]
print(f"GPNPU produced {len(gpnpu_outputs)} detection sets")
2026-09-22 02:58 - INFO - epu - iss_testing - No tranges found for input, use default float range: <tvm.contrib.epu.interval.Interval object at 0x72886017f6a0>
FILM 28/28: 100%|███████████████████████████████████████████████████| 28/28 [00:23<00:00, 1.20it/s]
2026-09-22 02:59 - INFO - epu - iss_testing - No tranges found for input, use default float range: <tvm.contrib.epu.interval.Interval object at 0x7287633f36a0>
FILM 28/28: 100%|███████████████████████████████████████████████████| 28/28 [00:30<00:00, 1.08s/it]
2026-09-22 02:59 - INFO - epu - iss_testing - No tranges found for input, use default float range: <tvm.contrib.epu.interval.Interval object at 0x72886017f6a0>
FILM 28/28: 100%|███████████████████████████████████████████████████| 28/28 [00:23<00:00, 1.17it/s]
GPNPU produced 3 detection sets
8. Detections
Both panels below are the same quantized network. The reference evaluates it in ONNX Runtime; the GPNPU panel is the compiled program — backbone, attention, head and decode, all on-chip. Matching boxes, scores and classes confirm both kernels reproduce the model faithfully. No NMS runs on either side: the 300 candidates come straight from YOLO26's own top-K selection, thresholded on score.
int8_reference = build_int8_reference(quantized_model.model_path, decode_onnx)
detections_per_engine = {
"ONNX Runtime int8": [
detections_in_original_coords(int8_reference(image), original_hw(path))
for image, path in zip(all_images, all_image_paths)
],
"Chimera GPNPU": [
detections_in_original_coords(np.asarray(list(outputs.values())[0]), original_hw(path))
for outputs, path in zip(gpnpu_outputs, all_image_paths)
],
}
for engine, detections in detections_per_engine.items():
print(f"{engine}: {[len(d) for d in detections]} detections above threshold")
display_detections(all_image_paths, detections_per_engine, MODEL_NAME)
ONNX Runtime int8: [7, 5, 2] detections above threshold
Chimera GPNPU: [8, 5, 2] detections above threshold



9. Run Statistics
Cycle and utilization breakdown of the compiled program on the QC-U target. Note the external write traffic: with the decode on-chip, the six head tensors never travel to DDR — only the 300 finished detections do.
print(cgc_job)
_ = cgc_job.plot_run_statistics() # returns the plot path; the figure is what we want
╒═════════════════════╤══════════════════════════════════════════════════════════════════╕
│ Module Name │ yolo26n_customops_QC_U_1d7_16MB_4kB_128GBps_128GBps_16_OFF_x1_x1 │
├─────────────────────┼──────────────────────────────────────────────────────────────────┤
│ ONNX File │ yolo26n-customops.onnx │
├─────────────────────┼──────────────────────────────────────────────────────────────────┤
│ Custom Ops │ /quadric/sdk-cli/examples/models/yolo/yolo26/yolo26_kernels.hpp │
├─────────────────────┼──────────────────────────────────────────────────────────────────┤
│ Product Target │ QC-U │
├─────────────────────┼──────────────────────────────────────────────────────────────────┤
│ 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 │ 7.862MB │
├─────────────────────┼──────────────────────────────────────────────────────────────────┤
│ Max LRM │ 1.625kB │
├─────────────────────┼──────────────────────────────────────────────────────────────────┤
│ Max Temp Ext Bytes │ 0.000MB │
├─────────────────────┼──────────────────────────────────────────────────────────────────┤
│ Network GMACs │ 2.740 │
╘═════════════════════╧══════════════════════════════════════════════════════════════════╛
╒════╤════════╤═════════╤══════════════════╤══════════════════════════╤═══════╕
│ │ Type │ Name │ shape │ type │ mse │
╞════╪════════╪═════════╪══════════════════╪══════════════════════════╪═══════╡
│ 0 │ Input │ images │ [1, 3, 640, 640] │ tensor[FixedPoint32<27>] │ n/a │
├────┼────────┼─────────┼──────────────────┼──────────────────────────┼───────┤
│ 1 │ Output │ output0 │ [1, 300, 6] │ tensor[FixedPoint32<16>] │ n/a │
╘════╧════════╧═════════╧══════════════════╧══════════════════════════╧═══════╛
Post-ISS Report 1.7 GHz ***
Fully placed-and-routed gate simulation:
╒══════════════════════════════════╤═════════╕
│ Latency (ms) │ 1.21 │
├──────────────────────────────────┼─────────┤
│ FPS │ 823.33 │
├──────────────────────────────────┼─────────┤
│ Average Power @ 3nm SSGNP (mW) │ 3302.16 │
├──────────────────────────────────┼─────────┤
│ FPS per Watt @ 3nm SSGNP (FPS/W) │ 249.33 │
├──────────────────────────────────┼─────────┤
│ Ext Rd Bytes (MB) │ 7.15 │
├──────────────────────────────────┼─────────┤
│ Ext Wr Bytes (MB) │ 0.01 │
├──────────────────────────────────┼─────────┤
│ Avg Ext Rd BW (GBps) │ 5.75 │
├──────────────────────────────────┼─────────┤
│ Avg Ext Wr BW (GBps) │ 0.01 │
├──────────────────────────────────┼─────────┤
│ MAC Utilization │ 8.10% │
╘══════════════════════════════════╧═════════╛
*** Data generated using 7nm SSGNP gatesim and scaled to 3nm
[SDK-CLI] : TotalCycles: 2,064,775
[SDK-CLI] : Executions/second: 823.33
compute : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 837.475K
data_array : ▇▇▇▇▇▇▇▇▇▇▇▇ 210.233K
mac : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 450.031K
data_external: ▇▇▇ 63.516K
data_ocm : ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 501.209K
for more information check run directory: /quadric/sdk-cli/examples/models/yolo/yolo26/ccl_build/yolo26n_customops_QC_U_1d7_16MB_4kB_128GBps_128GBps_16_OFF_x1_x1/run/20260922_025938_69918f
2026-09-22 03:00 - INFO - epu - chimera_job - Combined plots generated and saved to:
/quadric/sdk-cli/examples/models/yolo/yolo26/ccl_build/yolo26n_customops_QC_U_1d7_16MB_4kB_128GBps_128GBps_16_OFF_x1_x1/run/20260922_025938_69918f/data/yolo26n_customops_QC_U_1d7_16MB_4kB_128GBps_128GBps_16_OFF_x1_x1.combined.png

Summary
| Model | YOLO26-N/M/L (COCO, 640×640, NMS-free end-to-end head) |
| Pipeline | One int8 program: CGC-compiled convolutions with CCL attention and decode kernels |
| Target | QC-U @ 1.7 GHz — 1 core, 32×32 PEs, 16 MB OCM, 4 kB LRM, 16 MACs/PE, 128 GB/s |
| Quantization | Post-training int8, asymmetric activations, COCO-like calibration |
| Custom Ops | yolo26Attention — patch-mesh QᵀK + PE-local softmax + patch-mesh attn·V |
yolo26Decode — per-level top-300, merge, anchor decode on survivors |
Key takeaways
- Image in, detections out. The whole network compiles to a single program, so there is no host round-trip and nothing to reimplement on the CPU side — and the two stages worth writing by hand are exactly the two the PE array handles best.
- Attention maps naturally onto the array. One token per PE turns both attention matmuls into single patch-mesh operations and makes the softmax embarrassingly parallel, with no transposes and no random access.
- Reasoning about the graph beat optimizing it. The decode's two-stage selection collapses to one global top-300, which let
nn::topKrun straight on the head tensors and removed a reduction pass, a buffer and a per-PE gather. Only the 300 surviving boxes are decoded, not all 8400. - One kernel pair, three variants. Head count, token grid and every shape come from the ONNX edge shapes at compile time, so the same sources serve YOLO26-N, -M and -L.
Citation
@software{yolo26_ultralytics,
author = {Glenn Jocher and Jing Qiu},
title = {Ultralytics YOLO26},
version = {26.0.0},
year = {2025},
url = {https://docs.ultralytics.com/models/yolo26/},
license = {AGPL-3.0}
}
