You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[CPU EP] MatMulNBits (accuracy_level=4) produces wrong results on x86-64 AVX2 when loaded from session.save_external_prepacked_constant_initializers output (1.31.1) #33677
We save an optimized model with prepacked constant initializers and load that saved model later
(the documented "save prepacked weights" flow) to memory-map the packed weights. On x86-64 with AVX2
(no AVX-512) and onnxruntime 1.31.1, graphs containing MatMulNBits (int4, block-wise, accuracy_level=4)
compute wrong values after reload. In our autoregressive TTS model the stop token is never emitted, so
generation runs to the 250-frame cap on every input.
Observations (same model, same inputs, Linux x86-64 GitHub runner, CPU flags avx avx2 f16c fma):
Variant
Frames generated (seed 1 / 2)
Expected
fp32 reference
27 / 32
stops
int4 MatMulNBits acc4, loaded from prepacked save
250 / 250
does not stop
same int4 graphs, not prepacked (ORT packs in memory)
23 / 27
stops
same int4 graphs with accuracy_level=0, prepacked
26 / 26
stops
int8 MatMulInteger graphs only, prepacked
30 / 31
stops
onnxruntime 1.23.2 (does not write int4 prepacked blobs)
26 / 28
stops
Same on windows-latest x86-64 (AMD Family 25, onnxruntime 1.31.1).
arm64 (macOS, Linux aarch64) with the same flow is correct; audio is bit-identical to the unpacked model.
Under Rosetta (no AVX exposed) onnxruntime 1.31.1 does not write int4 prepacked blobs and the issue does not appear.
A single-node probe (one MatMulNBits node, M=1) compares equal between prepacked and unpacked loads, so the
mismatch seems to need M>1 or appear only after graph fusions.
To reproduce
Save step (Python):
so=ort.SessionOptions()
so.graph_optimization_level=ort.GraphOptimizationLevel.ORT_ENABLE_ALLso.intra_op_num_threads=1so.add_session_config_entry("session.save_external_prepacked_constant_initializers", "1")
so.add_session_config_entry("session.optimized_model_external_initializers_file_name", "model.packed.data")
so.add_session_config_entry("session.optimized_model_external_initializers_min_size_in_bytes", "1024")
so.optimized_model_filepath="model.packed.onnx"ort.InferenceSession("model.onnx", so, providers=["CPUExecutionProvider"])
Then load model.packed.onnx in a new session and compare its outputs with model.onnx. A self-contained
model is not attached yet (see note above); the failing graphs are a text encoder and an autoregressive
prefix step quantized with MatMulNBits (bits=4, block_size=32, symmetric, accuracy_level=4).
Urgency
Workaround in place: we skip prepacking for graphs with MatMulNBits on x86-64, at the cost of about
100 MB more private memory per process.
Platform / versions
Linux (ubuntu-latest) and Windows (windows-latest), x86-64 AVX2; onnxruntime 1.31.1 from PyPI, Python 3.12, CPUExecutionProvider.
Follow-up from the reporter: I found a minimal, self-contained repro for this, along with the root cause.
Summary. One MatMulNBits node (int4, block 32, accuracy_level=4, M=1, K=64, N=33) is enough to reproduce it; no fusion, M>1 or real model is needed. Prepacked blobs smaller than 1 MiB are written to the .data file without alignment. On x86-64, the accuracy_level=4 (CompInt8) packed layout locates its parts (packed B, block sums, scales) by rounding the buffer address up to 64 bytes. When memory-mapped on the tested x86-64 systems, the blob is used in place at an address with the same residue mod 64 as its file offset. For the affected accuracy_level=4 layout, a nonzero residue shifts the interpreted packed-data, block-sum and scale offsets. Moving each prepacked blob to a 64-byte aligned offset in the saved file removes the mismatch (see Realignment check).
Results
python repro_33677.py --sweep compares the reloaded prepacked model with the same model packed in memory. Both run the same kernel on the same packed bytes. Mismatch cells show max abs diff and cosine similarity. onnxruntime 1.31.1:
¹ On arm64 the packed layout is smaller, so this blob is < 1 MiB, is written unaligned (offset % 64 = 48), and still matches.
Platforms: x86-64 mismatches on a Windows VM (Intel/QEMU, AVX2), a Linux VM (AVX-512), and GitHub Actions ubuntu-latest (AMD EPYC 9V45/9V74, AVX-512) and windows-latest (EPYC 7763 AVX2 / 9V74 AVX-512), with both 1.31.1 and 1.32.0.dev20261031001. arm64 (macos-latest M2 Pro with both versions, and a local M1 with 1.31.1) matches in every case.
The single-node probe in the original report may have passed because its blob happened to land on an aligned offset. In a real model, a blob whose length is not a multiple of 64 can leave subsequent small blobs misaligned; later data or padding can restore alignment.
Repro
pip install onnxruntime==1.31.1 onnx==1.23.2 numpy==2.5.4
python repro_33677.py # one node: K=64, N=33, M=1; exits 1 on mismatch
python repro_33677.py --sweep # matrix above, including the controls
The script also checks that the blob on disk is actually used: ORT packs the weight again at load time and only swaps in the disk blob when the keys match, so a silent repack would also "match". The loader selects a disk blob by the key string stored in the proto and does not hash or checksum the blob bytes (the checksum field is written as 0 and not validated: tensor_external_data_info.cc L163, tensorprotoutils.cc L1741-L1762), so a corrupted blob is still selected. After the prepacked run the script therefore XORs every prepacked blob range in a copy of the .data file with 0xFF, reloads, and requires the output to change (consumed=yes). If a future version validated the bytes, the corrupted blob would be rejected and the case would be reported inconclusive, not as a pass. Every row above, including the controls, reports consumed=yes on the Linux AVX-512 VM and on the M1. Exit codes: 0 = all match and consumed, 1 = mismatch, 2 = no mismatch but a blob was not consumed (inconclusive).
#!/usr/bin/env python3"""Self-contained repro probe for onnxruntime issue #33677.Builds synthetic ONNX graphs with int4 MatMulNBits weights (random, seeded),saves them with ``session.save_external_prepacked_constant_initializers`` andcompares the reloaded (prepacked) model against the same model loaded thenormal way. Needs only numpy, onnx and onnxruntime.Finding: on x86-64 a MatMulNBits (bits=4, accuracy_level=4) weight computeswrong values after reload whenever its prepacked blob sits at a file offsetthat is not a multiple of 64. Blobs smaller than 1 MiB are written unaligned,right after the previous initializer. The MLAS x86 packed layout aligns itssub-buffers (packed B, block sums, scales) to 64 bytes relative to thebuffer's own address. When memory-mapped on the tested x86-64 systems, theblob is used in place at an address with the same residue mod 64 as its fileoffset; for the affected accuracy_level=4 layout a nonzero residue shifts theinterpreted packed-data, block-sum and scale offsets.One node and M=1 are enough; graph fusions play no part.Consumption check: ORT re-packs every weight at load time and only swaps inthe blob from disk when its key (a hash of the freshly packed bytes) matches,so a silent in-memory repack would also "match". The loader selects a diskblob by the key string stored in the proto and does not hash or checksum theblob bytes (the checksum field is written as 0 and not validated), so acorrupted blob is still selected. After the prepacked run the script thereforeXORs every prepacked blob range in a copy of the .data file with 0xFF, reloads,and requires the output to change (consumed=yes). If a future version validatedthe bytes, the corrupted blob would be rejected and the case would be reportedinconclusive, not as a pass.Exit codes: 0 = every case matches and its blob was consumed, 1 = mismatch(bug reproduced), 2 = no mismatch but at least one case is inconclusive. python repro_33677.py # one MatMulNBits node, exit 1 on mismatch python repro_33677.py --sweep # matrix incl. aligned / accuracy_level 0 controls python repro_33677.py --model transformer --m 16 --k 256 --n 1024"""from __future__ importannotationsimportargparseimportjsonimportosimportplatformimportshutilimportsysimporttempfileimportnumpyasnpimportonnximportonnxruntimeasortfromonnximportTensorProto, helper, numpy_helperOPSET= [helper.make_opsetid("", 17), helper.make_opsetid("com.microsoft", 1)]
# --------------------------------------------------------------------------- weightsdefquantize_int4(w: np.ndarray, block: int):
"""Symmetric block-wise int4 of w [K, N] in the MatMulNBits layout. Returns (packed uint8 [N, K/block, block/2], scales float32 [N, K/block], dequantized float32 [K, N]). No zero_points input: the kernel's default zero point for 4 bits is 8. """k, n=w.shapeassertk%block==0nb=k//blockwt=w.T.reshape(n, nb, block) # [N, nb, block]scale=np.abs(wt).max(axis=2, keepdims=True) /7.0scale[scale==0] =1.0q=np.clip(np.round(wt/scale), -8, 7).astype(np.int32) +8# 0..15packed= (q[..., 0::2] | (q[..., 1::2] <<4)).astype(np.uint8) # low nibble firstdeq= ((q-8) *scale).reshape(n, k).T.astype(np.float32)
returnpacked, scale.reshape(n, nb).astype(np.float32), deqclassBuilder:
def__init__(self, seed: int, block: int, acc: int, fp32: bool):
self.rng=np.random.default_rng(seed)
self.block, self.acc, self.fp32=block, acc, fp32self.nodes, self.inits, self.i= [], [], 0defname(self, p: str) ->str:
self.i+=1returnf"{p}_{self.i}"defconst(self, arr: np.ndarray, p: str="c") ->str:
n=self.name(p)
self.inits.append(numpy_helper.from_array(arr, n))
returnndefnode(self, op: str, inputs, domain: str="", **attrs) ->str:
out=self.name(op.lower())
self.nodes.append(helper.make_node(op, list(inputs), [out], name=out+"_n",
domain=domain, **attrs))
returnoutdeflinear(self, x: str, k: int, n: int, bias: bool=True) ->str:
w= (self.rng.standard_normal((k, n)) /np.sqrt(k)).astype(np.float32)
packed, scales, deq=quantize_int4(w, self.block)
ifself.fp32: # reference twin: same dequantized weights, plain MatMuly=self.node("MatMul", [x, self.const(deq, "w")])
else:
y=self.node("MatMulNBits", [x, self.const(packed, "qw"), self.const(scales, "s")],
domain="com.microsoft", K=k, N=n, bits=4, block_size=self.block,
accuracy_level=self.acc)
ifbias:
y=self.node("Add", [y, self.const((0.02*self.rng.standard_normal(n)).astype(np.float32), "b")])
returnydeflayernorm(self, x: str, d: int) ->str:
returnself.node("LayerNormalization",
[x, self.const(np.ones(d, np.float32) +0.05*self.rng.standard_normal(d).astype(np.float32), "g"),
self.const((0.05*self.rng.standard_normal(d)).astype(np.float32), "beta")],
axis=-1, epsilon=1e-5)
defgelu(self, x: str) ->str: # erf form, the pattern GeluFusion/BiasGelu matchh=self.node("Div", [x, self.const(np.array(np.sqrt(2.0), np.float32))])
h=self.node("Erf", [h])
h=self.node("Add", [h, self.const(np.array(1.0, np.float32))])
h=self.node("Mul", [x, h])
returnself.node("Mul", [h, self.const(np.array(0.5, np.float32))])
defattention(self, x: str, d: int, heads: int) ->str:
hd=d//headsshape=self.const(np.array([0, 0, heads, hd], np.int64), "shp")
defsplit(t):
returnself.node("Transpose", [self.node("Reshape", [t, shape])], perm=[0, 2, 1, 3])
q, k, v= (split(self.linear(x, d, d)) for_inrange(3))
kt=self.node("Transpose", [k], perm=[0, 1, 3, 2])
s=self.node("Mul", [self.node("MatMul", [q, kt]), self.const(np.array(1/np.sqrt(hd), np.float32))])
p=self.node("Softmax", [s], axis=-1)
o=self.node("Transpose", [self.node("MatMul", [p, v])], perm=[0, 2, 1, 3])
o=self.node("Reshape", [o, self.const(np.array([0, 0, d], np.int64), "shp")])
returnself.linear(o, d, d)
defbuild(kind: str, args, fp32: bool) ->onnx.ModelProto:
b=Builder(args.seed, args.block, args.acc, fp32)
d, ff=args.k, args.nifkind=="single": # one MatMulNBits node, 2-D input [M, K]x=helper.make_tensor_value_info("x", TensorProto.FLOAT, ["M", args.k])
y=b.linear("x", args.k, args.n, bias=False)
elifkind=="chain": # MatMulNBits K->N then N->K, no other opsx=helper.make_tensor_value_info("x", TensorProto.FLOAT, ["M", args.k])
y=b.linear(b.linear("x", d, ff, bias=False), ff, d, bias=False)
elifkindin ("mlp", "transformer"):
x=helper.make_tensor_value_info("x", TensorProto.FLOAT, [1, "M", d])
h="x"for_inrange(args.layers):
ifkind=="transformer":
h=b.node("Add", [h, b.attention(b.layernorm(h, d), d, args.heads)])
f=b.linear(b.gelu(b.linear(b.layernorm(h, d), d, ff)), ff, d)
h=b.node("Add", [h, f])
y=b.layernorm(h, d)
else:
raiseSystemExit(f"unknown model {kind}")
out_shape= [1, "M", d] ifkindin ("mlp", "transformer") else ["M", args.nifkind=="single"elsed]
g=helper.make_graph(b.nodes, kind, [x], [helper.make_tensor_value_info(y, TensorProto.FLOAT, out_shape)],
b.inits)
m=helper.make_model(g, opset_imports=OPSET, producer_name="ort-33677-repro")
m.ir_version=8returnm# --------------------------------------------------------------------------- sessionsdefoptions(args, level=None) ->ort.SessionOptions:
so=ort.SessionOptions()
so.graph_optimization_level=leveliflevelisnotNoneelseLEVELS[args.opt]
so.intra_op_num_threads=args.threadsso.log_severity_level=3returnsoLEVELS= {"all": ort.GraphOptimizationLevel.ORT_ENABLE_ALL,
"extended": ort.GraphOptimizationLevel.ORT_ENABLE_EXTENDED,
"basic": ort.GraphOptimizationLevel.ORT_ENABLE_BASIC,
"disable": ort.GraphOptimizationLevel.ORT_DISABLE_ALL}
defsave_prepacked(src: str, dst: str, args) ->None:
"""The flow from the issue: optimize + write packed weights to dst(.data)."""so=options(args)
so.intra_op_num_threads=1so.add_session_config_entry("session.save_external_prepacked_constant_initializers", "1")
so.add_session_config_entry("session.optimized_model_external_initializers_file_name",
os.path.basename(dst).replace(".onnx", ".data"))
so.add_session_config_entry("session.optimized_model_external_initializers_min_size_in_bytes", "1024")
so.optimized_model_filepath=dstort.InferenceSession(src, so, providers=["CPUExecutionProvider"])
defrun(path: str, x: np.ndarray, args) ->np.ndarray:
s=ort.InferenceSession(path, options(args), providers=["CPUExecutionProvider"])
y=s.run(None, {s.get_inputs()[0].name: x})[0]
dels# release the session (and its mapping of the .data file) before temp files are deletedreturnydefcompare(a: np.ndarray, b: np.ndarray) ->dict:
a, b=a.ravel().astype(np.float64), b.ravel().astype(np.float64)
ifnp.isnan(a).any() ornp.isnan(b).any():
return {"max_abs": float("nan"), "cos": float("nan")}
cos=float(a @ b/ (np.linalg.norm(a) *np.linalg.norm(b) +1e-30))
return {"max_abs": float(np.abs(a-b).max()), "cos": cos}
defprepacked_blobs(path: str) ->list[tuple[str, int, int]]:
"""(data file, offset, length) of every prepacked blob in the saved model."""blobs= []
fortinonnx.load(path, load_external_data=False).graph.initializer:
ext= {e.key: e.valueforeint.external_data}
forkey, valueinext.items():
# "<key>|<offset>;<length>;<checksum>[|<offset>;<length>;<checksum>...]":# one key can hold several blobs. MatMulNBits writes one, but all are listed.ifkey.startswith("prepacked"):
forblobinvalue.split("|")[1:]:
off, length=blob.split(";")[:2]
blobs.append((ext["location"], int(off), int(length)))
returnblobsdefrun_corrupted(packed: str, blobs, x: np.ndarray, args) ->np.ndarray:
"""Run a copy of the prepacked model whose blob bytes are all XORed with 0xFF."""withtempfile.TemporaryDirectory() astmp:
dst=os.path.join(tmp, os.path.basename(packed))
shutil.copyfile(packed, dst)
src_dir=os.path.dirname(packed)
forlocin {b[0] forbinblobs}:
withopen(os.path.join(src_dir, loc), "rb") asf:
data=bytearray(f.read())
for_, off, lengthin (bforbinblobsifb[0] ==loc):
data[off:off+length] = (np.frombuffer(data, np.uint8, length, off) ^0xFF).tobytes()
withopen(os.path.join(tmp, loc), "wb") asf:
f.write(data)
returnrun(dst, x, args)
defcase(args) ->dict:
withtempfile.TemporaryDirectory() astmp:
ifargs.keep:
os.makedirs(args.keep, exist_ok=True)
tmp=args.keepq, ref, packed= (os.path.join(tmp, f) forfin ("model.onnx", "ref.onnx", "model.packed.onnx"))
onnx.save(build(args.model, args, fp32=False), q)
onnx.save(build(args.model, args, fp32=True), ref)
save_prepacked(q, packed, args)
rng=np.random.default_rng(args.seed+1)
shape= (1, args.m, args.k) ifargs.modelin ("mlp", "transformer") else (args.m, args.k)
x=rng.standard_normal(shape).astype(np.float32)
y_ref, y_q, y_p=run(ref, x, args), run(q, x, args), run(packed, x, args)
blobs=prepacked_blobs(packed)
# The disk blob is consumed only if corrupting it changes the output.consumed=bool(blobs) andnotnp.array_equal(run_corrupted(packed, blobs, x, args), y_p, equal_nan=True)
r= {"model": args.model, "M": args.m, "K": args.k, "N": args.n, "block": args.block,
"acc": args.acc, "layers": args.layers, "opt": args.opt, "threads": args.threads,
"prepacked_offsets_mod64": [b[1] %64forbinblobs],
"prepacked_lengths": [b[2] forbinblobs], "consumed": consumed,
"unpacked_vs_fp32": compare(y_q, y_ref), "prepacked_vs_unpacked": compare(y_p, y_q)}
# The prepacked weights run the same kernel on the same packed bytes, so# the two loads must agree (bit-exact on every platform where it works).d=r["prepacked_vs_unpacked"]["max_abs"]
r["mismatch"] =not (d<=1e-5) # also true for NaNr["status"] ="MISMATCH"ifr["mismatch"] else"ok"ifconsumedelse"INCONCLUSIVE"returnrdefcpu_flags() ->str:
try:
withopen("/proc/cpuinfo") asf:
forlineinf:
ifline.startswith(("flags", "Features")): # x86 / aarch64fl=set(line.split(":")[1].split())
return" ".join(sorted(xforxinflifx.startswith(("avx", "amx", "sve", "sme")) orxin ("f16c", "fma", "asimd", "asimddp", "asimdhp", "i8mm", "bf16")))
exceptOSError:
passifsys.platform=="win32":
try:
importctypesk=ctypes.windll.kernel32# PF_AVX2_INSTRUCTIONS_AVAILABLE=40, PF_AVX512F=41return" ".join(nforn, fin (("avx", 39), ("avx2", 40), ("avx512f", 41))
ifk.IsProcessorFeaturePresent(f))
exceptException:
return"?"ifsys.platform=="darwin":
importsubprocessreturnsubprocess.run(["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True).stdout.strip()
return"?"# model:M:K:N:accuracy_level. The prepacked blob is written right after the# N*K/2-byte B initializer, so N*K/2 % 64 decides where it lands in the file.DEFAULT_SWEEP=" ".join([
"single:1:64:32:4", # B = 1024 bytes -> blob offset % 64 == 0 (control)"single:1:64:33:4", # B = 1056 bytes -> blob offset % 64 == 32"single:4:32:65:4", # B = 1040 bytes -> % 64 == 16"single:4:32:67:4", # B = 1072 bytes -> % 64 == 48"single:4:64:33:0", # same misaligned blob, accuracy_level 0 (control)"single:16:800:767:4", # realistic size, % 64 == 48"single:16:768:768:4", # realistic size, aligned (control)"single:4:1056:1535:4", # B % 64 == 48 but blob >= 1 MiB, which the writer aligns to 4096 (control)"transformer:16:256:1024:4", # 2-layer transformer, all blobs aligned (control)
])
defmain() ->int:
ap=argparse.ArgumentParser()
ap.add_argument("--model", default="single", choices=["single", "chain", "mlp", "transformer"])
ap.add_argument("--m", type=int, default=1, help="rows of A (sequence length)")
ap.add_argument("--k", type=int, default=64, help="K (model dim for mlp/transformer)")
ap.add_argument("--n", type=int, default=33, help="N (FF dim for mlp/transformer)")
ap.add_argument("--block", type=int, default=32)
ap.add_argument("--acc", type=int, default=4)
ap.add_argument("--layers", type=int, default=2)
ap.add_argument("--heads", type=int, default=4)
ap.add_argument("--opt", default="all", choices=list(LEVELS))
ap.add_argument("--threads", type=int, default=1)
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--sweep", nargs="?", const="default",
help="run the default matrix, or a space-separated list of model:M:K:N:acc[:threads]")
ap.add_argument("--json", action="store_true")
ap.add_argument("--keep", help="write the models to this directory instead of a temp dir ""(one subdirectory per case with --sweep)")
args=ap.parse_args()
print(f"onnxruntime {ort.__version__} onnx {onnx.__version__} numpy {np.__version__} "f"python {platform.python_version()}{platform.system()}{platform.machine()}")
print(f"cpu: {platform.processor() or'?'} | flags: {cpu_flags()}")
ifargs.sweep:
spec=DEFAULT_SWEEPifargs.sweep=="default"elseargs.sweepgrid= []
foriteminspec.split():
f=item.split(":")
g=dict(model=f[0], m=int(f[1]), k=int(f[2]), n=int(f[3]), acc=int(f[4]))
iflen(f) >5:
g["threads"] =int(f[5])
ifargs.keep:
g["keep"] =os.path.join(args.keep, item.replace(":", "_"))
grid.append(g)
results= [case(argparse.Namespace(**{**vars(args), **g})) forgingrid]
else:
results= [case(args)]
defjoin(v):
return",".join(map(str, sorted(set(v)))) or"-"forrinresults:
ifargs.json:
print(json.dumps(r))
continuepv=r["prepacked_vs_unpacked"]
print(f"{r['model']:<11} M={r['M']:<3} K={r['K']:<4} N={r['N']:<4} block={r['block']} acc={r['acc']} "f"blob_off%64={join(r['prepacked_offsets_mod64']):<9} blob_len={join(r['prepacked_lengths']):<8} | "f"prepacked vs unpacked: max_abs={pv['max_abs']:<9.3g} cos={pv['cos']:<9.5f} | "f"consumed={'yes'ifr['consumed'] else'no':<3} | {r['status']}")
ifany(r["mismatch"] forrinresults):
print("RESULT: MISMATCH (bug reproduced)")
return1ifany(r["status"] =="INCONCLUSIVE"forrinresults):
print("RESULT: INCONCLUSIVE (a prepacked blob on disk was not consumed; the match proves nothing)")
return2print("RESULT: all match (prepacked blobs consumed)")
return0if__name__=="__main__":
sys.exit(main())
Root cause
Permalinks are to v1.31.1. The same >= align_threshold condition is present on main at 2927f3b.
Writer.ExternalDataInfo::WritePrepackedToFileAndAddToProto pads a prepacked blob only when align is set and the blob is at least align_threshold bytes; smaller blobs follow the previous data directly:
Loader.LoadPrepackedWeightsFromFile calls GetFileContent, which memory-maps the range (or falls back to new char[] plus a read) and returns the pointer as is, without an alignment check. A mapping starts at a page (or allocation-granularity) boundary, so the mapped address keeps the file offset's residue mod 64:
MLAS layout. On AMD64, PackedQuantBDataStruct aligns PackedQuantBData to 64 by address, then aligns QuantBBlkSum to 64 by address, and the scales follow the block sums:
At pack time the buffer comes from the CPU allocator, which aligns to at least MlasGetPreferredBufferAlignment() (64 here), so the initial base-address rounding is a no-op; internal section padding can still be needed:
For K=64, N=33, block 32, the packed data is 1056 bytes and the block sums sit at 1088 relative to an aligned base. After reload at residue 32, the kernel reads packed data from offset 32 and block sums from offset 1120. This is wrong data, not just a slow unaligned load.
This also explains the controls: accuracy_level=0 does not use the block-sum layout, blobs of 1 MiB or more are padded to 4096, and on arm64 the 4-bit accuracy_level=4 path never re-derives section pointers from the base address at compute time: the NEON dispatch has no SQ4BitGemmKernel_BlkSum_CompInt8 (qnbitgemm_kernel_neon.cppL508 vs L512 for 8-bit), so MlasQNBitGemmBatch uses the packed pointer and the original scales directly:
PerGemmQuantAWorkspace per_gemm_quant_a_workspace(PerGemmWorkspace, M, BlockCountK, BlkLen);
ComputeOperation(BlkLen, K, Data, &per_gemm_quant_a_workspace, 0, M, 0, N, BackendKernelSelectorConfig);
} else {
ComputeOperation(BlkLen, K, Data, PerGemmWorkspace, 0, M, 0, N, BackendKernelSelectorConfig);
arm64 is not necessarily safe in general, though: 8-bit CompInt8 on arm64 does use the BlkSum path, and the constructor aligns 8-bit packed data to 32 and the block sums to 64 by address, so bits=8 on arm64 is plausibly exposed to the same writer gap. I have not tested it here. This is another reason to fix it at the framework level rather than only for x86.
Realignment check
I rewrote the model.packed.data saved by --keep DIR so each prepacked blob starts at a 64-byte aligned offset (blobs ≥ 1 MiB kept 4096), with unchanged bytes; only the external-data file name and offsets in model.packed.onnx were updated (regular initializers were realigned too, which does not change their bytes). All three failing cases tested in this realignment experiment then had zero maximum absolute difference from the in-memory-packed model (Linux x86-64, AVX-512, onnxruntime 1.31.1):
Case
Saved by ORT
Realigned (offset % 64 = 0)
M=1, K=64, N=33
2.62
0
M=16, K=800, N=767
6.09
0
M=4, K=32, N=67
4.77
0
In the sweep, every case with offset % 64 = 0 matches, accuracy_level=0 matches at residue 32, and arm64 matches at every offset represented in this sweep.
Suggested fix
Normalize at load time in or next to LoadPrepackedWeightsFromFile. If the pointer returned by GetFileContent (mmap or copy fallback) is not 64-byte aligned, copy the whole blob, same bytes and length, into a buffer from the CPU allocator. This fixes files already saved, and also the new char[] fallback, which does not guarantee 64-byte alignment whatever the file offset. Aligned mappings stay zero-copy. Check the returned pointer, not the file offset.
A simpler alternative: ORT already runs PrePack in memory before it swaps in the disk blob (session_state.cc L702-L731), so a misaligned pointer could simply be rejected and the fresh in-memory pack kept. That has the same memory cost as the copy and needs less code.
Either way, GetFileContent treats length == 0 as "map the whole file" (tensorprotoutils.cc L1686-L1690), so the loader fix needs an explicit guard for empty blobs.
Writer: align every prepacked blob's starting offset to a multiple of 64, unconditionally, so new files load zero-copy. Don't gate this on align_offset, which ModelSavingOptions defaults to false. Keep the configured large-blob alignment. If it is validated as a positive power of two, the larger of it and 64 satisfies both requirements; otherwise validate the configuration or compute a checked common multiple. When the large-blob branch is inactive the configured alignment is unused, so callers that pass 0 there (as tensorutils_test.cc L234 does) keep working. The sizing and writing passes must keep producing identical layouts (see the comment at L142), so the padding belongs in this shared helper. Sketch assuming the active large-blob alignment has been validated as a positive power of two:
// This x86 packed layout requires a 64-byte-aligned base.constexprint64_tkMinPrepackedBlobAlignment = 64;
for (size_t i = 0, size = prepacked_weights->buffers_.size(); i < size; ++i) {
constauto size_in_bytes = prepacked_weights->buffer_sizes_[i];
int64_t blob_alignment = kMinPrepackedBlobAlignment;
if (align && SafeInt<int64_t>(size_in_bytes) >= align_threshold) {
blob_alignment = std::max(blob_alignment, on_disk_alignment); // requires validated positive power-of-two alignment
}
if (!AlignAndPad(os, blob_alignment, external_offset)) {
return os;
}
// ... unchanged: record "offset;size;0", write the blob, advance external_offset
}
Tests:
Update PrepackedExternalDataHasDeterministicAlignedLayout in onnxruntime/test/framework/tensorutils_test.cc (L260), which expects the 3-byte blob to stay unaligned at offset 3. Also cover align = false and sizes below, at and above the threshold.
Add a save-and-reload numeric test: one MatMulNBits with K=64, N=33, block 32, accuracy_level=4, saved with session.save_external_prepacked_constant_initializers, reloaded in a new session and compared. Also check that the prepacked blob is actually consumed, so a silent repack cannot hide a regression: assert every prepacked offset recorded in the saved proto is a multiple of 64, and that number_of_prepacks / used_shared_buffers shows the disk blob was consumed.
Add a loader test with unchanged blob bytes at misaligned offsets (e.g. residues 1, 16, 32, 63) and a forced Map failure, asserting 64-byte aligned pointers and preserved bytes.
Making the MLAS layout base-relative would be a larger change (SIMD alignment audit, compatibility with existing blobs), so I'd keep it separate.
Workaround until a fix lands: regenerate the model and its prepacked artifact with accuracy_level=0 for MatMulNBits, or on x86 skip session.save_external_prepacked_constant_initializers and let ORT pack at load time.
I'm happy to open a PR with (1), (2) and the tests if this direction looks right.
Activity
thiennq commentedon Nov 2, 2026
Follow-up from the reporter: I found a minimal, self-contained repro for this, along with the root cause.
Summary. One
MatMulNBitsnode (int4, block 32,accuracy_level=4, M=1, K=64, N=33) is enough to reproduce it; no fusion, M>1 or real model is needed. Prepacked blobs smaller than 1 MiB are written to the.datafile without alignment. On x86-64, theaccuracy_level=4(CompInt8) packed layout locates its parts (packed B, block sums, scales) by rounding the buffer address up to 64 bytes. When memory-mapped on the tested x86-64 systems, the blob is used in place at an address with the same residue mod 64 as its file offset. For the affectedaccuracy_level=4layout, a nonzero residue shifts the interpreted packed-data, block-sum and scale offsets. Moving each prepacked blob to a 64-byte aligned offset in the saved file removes the mismatch (see Realignment check).Results
python repro_33677.py --sweepcompares the reloaded prepacked model with the same model packed in memory. Both run the same kernel on the same packed bytes. Mismatch cells show max abs diff and cosine similarity. onnxruntime 1.31.1:¹ On arm64 the packed layout is smaller, so this blob is < 1 MiB, is written unaligned (offset % 64 = 48), and still matches.
Platforms: x86-64 mismatches on a Windows VM (Intel/QEMU, AVX2), a Linux VM (AVX-512), and GitHub Actions
ubuntu-latest(AMD EPYC 9V45/9V74, AVX-512) andwindows-latest(EPYC 7763 AVX2 / 9V74 AVX-512), with both 1.31.1 and 1.32.0.dev20261031001. arm64 (macos-latestM2 Pro with both versions, and a local M1 with 1.31.1) matches in every case.The single-node probe in the original report may have passed because its blob happened to land on an aligned offset. In a real model, a blob whose length is not a multiple of 64 can leave subsequent small blobs misaligned; later data or padding can restore alignment.
Repro
The script also checks that the blob on disk is actually used: ORT packs the weight again at load time and only swaps in the disk blob when the keys match, so a silent repack would also "match". The loader selects a disk blob by the key string stored in the proto and does not hash or checksum the blob bytes (the checksum field is written as 0 and not validated: tensor_external_data_info.cc L163, tensorprotoutils.cc L1741-L1762), so a corrupted blob is still selected. After the prepacked run the script therefore XORs every prepacked blob range in a copy of the
.datafile with 0xFF, reloads, and requires the output to change (consumed=yes). If a future version validated the bytes, the corrupted blob would be rejected and the case would be reported inconclusive, not as a pass. Every row above, including the controls, reportsconsumed=yeson the Linux AVX-512 VM and on the M1. Exit codes: 0 = all match and consumed, 1 = mismatch, 2 = no mismatch but a blob was not consumed (inconclusive).Repo with a CI workflow (ubuntu/windows/macos × 1.31.1/nightly): https://github.com/thiennq/ort-matmulnbits-prepack-repro
repro_33677.py
Root cause
Permalinks are to v1.31.1. The same
>= align_thresholdcondition is present onmainat 2927f3b.ExternalDataInfo::WritePrepackedToFileAndAddToProtopads a prepacked blob only whenalignis set and the blob is at leastalign_thresholdbytes; smaller blobs follow the previous data directly:onnxruntime/onnxruntime/core/framework/tensor_external_data_info.cc
Lines 154 to 159 in 6af1a93
Defaults are
align_offset = false,align_threshold = 1048576,on_disk_alignment = 4096:onnxruntime/include/onnxruntime/core/graph/model_saving_options.h
Lines 28 to 35 in 6af1a93
The session save path sets
align_offset = trueand leaves the threshold at its default:onnxruntime/onnxruntime/core/session/inference_session.cc
Lines 3258 to 3259 in 6af1a93
Regular initializers get their natural element alignment, but nothing similar is applied before the prepacked blobs that follow them:
onnxruntime/onnxruntime/core/graph/graph.cc
Lines 5421 to 5427 in 6af1a93
onnxruntime/onnxruntime/core/graph/graph.cc
Lines 5461 to 5465 in 6af1a93
LoadPrepackedWeightsFromFilecallsGetFileContent, which memory-maps the range (or falls back tonew char[]plus a read) and returns the pointer as is, without an alignment check. A mapping starts at a page (or allocation-granularity) boundary, so the mapped address keeps the file offset's residue mod 64:onnxruntime/onnxruntime/core/framework/tensorprotoutils.cc
Lines 1683 to 1715 in 6af1a93
onnxruntime/onnxruntime/core/framework/tensorprotoutils.cc
Lines 1737 to 1764 in 6af1a93
MatMulNBitsthen uses the loaded buffer in place:onnxruntime/onnxruntime/contrib_ops/cpu/quantization/matmul_nbits.cc
Line 894 in 6af1a93
PackedQuantBDataStructalignsPackedQuantBDatato 64 by address, then alignsQuantBBlkSumto 64 by address, and the scales follow the block sums:onnxruntime/onnxruntime/core/mlas/lib/qnbitgemm.h
Lines 75 to 77 in 6af1a93
onnxruntime/onnxruntime/core/mlas/lib/qnbitgemm.h
Lines 95 to 104 in 6af1a93
At pack time the buffer comes from the CPU allocator, which aligns to at least
MlasGetPreferredBufferAlignment()(64 here), so the initial base-address rounding is a no-op; internal section padding can still be needed:onnxruntime/onnxruntime/core/framework/allocator.cc
Lines 170 to 172 in 6af1a93
For K=64, N=33, block 32, the packed data is 1056 bytes and the block sums sit at 1088 relative to an aligned base. After reload at residue 32, the kernel reads packed data from offset 32 and block sums from offset 1120. This is wrong data, not just a slow unaligned load.
This also explains the controls:
accuracy_level=0does not use the block-sum layout, blobs of 1 MiB or more are padded to 4096, and on arm64 the 4-bitaccuracy_level=4path never re-derives section pointers from the base address at compute time: the NEON dispatch has noSQ4BitGemmKernel_BlkSum_CompInt8(qnbitgemm_kernel_neon.cppL508 vs L512 for 8-bit), soMlasQNBitGemmBatchuses the packed pointer and the original scales directly:onnxruntime/onnxruntime/core/mlas/lib/qnbitgemm.cpp
Lines 1604 to 1628 in 6af1a93
arm64 is not necessarily safe in general, though: 8-bit CompInt8 on arm64 does use the BlkSum path, and the constructor aligns 8-bit packed data to 32 and the block sums to 64 by address, so
bits=8on arm64 is plausibly exposed to the same writer gap. I have not tested it here. This is another reason to fix it at the framework level rather than only for x86.Realignment check
I rewrote the
model.packed.datasaved by--keep DIRso each prepacked blob starts at a 64-byte aligned offset (blobs ≥ 1 MiB kept 4096), with unchanged bytes; only the external-data file name and offsets in model.packed.onnx were updated (regular initializers were realigned too, which does not change their bytes). All three failing cases tested in this realignment experiment then had zero maximum absolute difference from the in-memory-packed model (Linux x86-64, AVX-512, onnxruntime 1.31.1):In the sweep, every case with offset % 64 = 0 matches,
accuracy_level=0matches at residue 32, and arm64 matches at every offset represented in this sweep.Suggested fix
Normalize at load time in or next to
LoadPrepackedWeightsFromFile. If the pointer returned byGetFileContent(mmap or copy fallback) is not 64-byte aligned, copy the whole blob, same bytes and length, into a buffer from the CPU allocator. This fixes files already saved, and also thenew char[]fallback, which does not guarantee 64-byte alignment whatever the file offset. Aligned mappings stay zero-copy. Check the returned pointer, not the file offset.A simpler alternative: ORT already runs
PrePackin memory before it swaps in the disk blob (session_state.cc L702-L731), so a misaligned pointer could simply be rejected and the fresh in-memory pack kept. That has the same memory cost as the copy and needs less code.Either way,
GetFileContenttreatslength == 0as "map the whole file" (tensorprotoutils.cc L1686-L1690), so the loader fix needs an explicit guard for empty blobs.Writer: align every prepacked blob's starting offset to a multiple of 64, unconditionally, so new files load zero-copy. Don't gate this on
align_offset, whichModelSavingOptionsdefaults to false. Keep the configured large-blob alignment. If it is validated as a positive power of two, the larger of it and 64 satisfies both requirements; otherwise validate the configuration or compute a checked common multiple. When the large-blob branch is inactive the configured alignment is unused, so callers that pass 0 there (as tensorutils_test.cc L234 does) keep working. The sizing and writing passes must keep producing identical layouts (see the comment at L142), so the padding belongs in this shared helper. Sketch assuming the active large-blob alignment has been validated as a positive power of two:Tests:
PrepackedExternalDataHasDeterministicAlignedLayoutinonnxruntime/test/framework/tensorutils_test.cc(L260), which expects the 3-byte blob to stay unaligned at offset 3. Also coveralign = falseand sizes below, at and above the threshold.MatMulNBitswith K=64, N=33, block 32,accuracy_level=4, saved withsession.save_external_prepacked_constant_initializers, reloaded in a new session and compared. Also check that the prepacked blob is actually consumed, so a silent repack cannot hide a regression: assert every prepacked offset recorded in the saved proto is a multiple of 64, and that number_of_prepacks / used_shared_buffers shows the disk blob was consumed.Mapfailure, asserting 64-byte aligned pointers and preserved bytes.Making the MLAS layout base-relative would be a larger change (SIMD alignment audit, compatibility with existing blobs), so I'd keep it separate.
Workaround until a fix lands: regenerate the model and its prepacked artifact with
accuracy_level=0forMatMulNBits, or on x86 skipsession.save_external_prepacked_constant_initializersand let ORT pack at load time.I'm happy to open a PR with (1), (2) and the tests if this direction looks right.
Preserve prepacked-weight alignment across save and reload (#33700)