Summary
When a model contains a bool attribute/constant, pnnx writes it to model.ncnn.bin as one byte per element, but emits a MemoryData layer that declares the element count. MemoryData::load_model() then reads that many float32. Because ModelBin is a sequential reader, the cursor ends up 3n/4 floats out of step and every weight-bearing layer after that point loads shifted data.
Nothing reports an error: pnnx exits 0, load_param() and load_model() both return 0, and inference produces plausible-looking numbers that are simply wrong.
Environment
|
|
| pnnx |
20260526 (pip) |
| ncnn (python) |
1.0.20260526 |
| ncnn source |
a4d2ea1 (2026-07-22) |
| torch |
2.12.1 |
| OS |
Ubuntu 24.04.3, x86_64 |
| Python |
3.10.20 |
Minimal reproduction
import subprocess, numpy as np, torch, torch.nn as nn, ncnn
N = 64 # 64 bytes written, 256 bytes read -> 48 floats adrift
class M(nn.Module):
def __init__(self):
super().__init__()
self.c1 = nn.Conv2d(3, N, 3, padding=1)
self.c2 = nn.Conv2d(N, 8, 3, padding=1) # loaded AFTER the bool
self.register_buffer("keep", torch.ones(1, N, 1, 1, dtype=torch.bool))
def forward(self, x):
return self.c2(self.c1(x) * self.keep)
torch.manual_seed(0)
m, x = M().eval(), torch.randn(1, 3, 32, 32)
ref = m(x).detach().numpy()[0]
np.save("in0.npy", x.numpy())
torch.jit.trace(m, x).save("m.pt")
subprocess.run(["pnnx", "m.pt", "input=in0.npy", "fp16=0",
"ncnnparam=m.ncnn.param", "ncnnbin=m.ncnn.bin"])
net = ncnn.Net()
net.load_param("m.ncnn.param"); net.load_model("m.ncnn.bin")
ex = net.create_extractor()
ex.input("in0", ncnn.Mat(np.ascontiguousarray(x.numpy()[0])))
got = np.array(ex.extract("out0")[1])
print("corr", np.corrcoef(got.ravel(), ref.ravel())[0, 1])
Output:
pnnx exit status: 0 (no error reported)
ncnn load_param rc=0 load_model rc=0
torch out : shape=(8, 32, 32) range=[-1.6347, 1.2956]
ncnn out : shape=(8, 32, 32) range=[-0.7004, 0.5721]
correlation: -0.037241 max|diff|=1.578086
The generated .param contains:
MemoryData keep 0 1 <blob> 0=64
64 elements declared → ncnn reads 256 bytes; pnnx wrote 64. The reader is 48 floats out of step from there on, so c2's weights are garbage.
Root cause
tools/pnnx/src/pass_ncnn/convert_attribute.cpp sets the MemoryData params from the tensor shape, without regard to dtype — correct for the layer, but it commits ncnn to reading w*h*c float32 (MemoryData::load_model() → mb.load(..., load_type), default load_type=1, untagged fp32).
tools/pnnx/src/save_ncnn.cpp then writes the attribute. It special-cases fp32→fp16 and int64→int32:
if (attr.type == 5) // i64 --> i32
{
...
fwrite(data_int32.data(), data_int32.size() * sizeof(int32_t), 1, binfp);
continue;
}
fwrite(attr.data.data(), attr.data.size(), 1, binfp); // <-- bool lands here
There is no case for attr.type == 9 (bool), so it takes the raw fwrite and emits 1 byte per element.
The mismatch is not unique to bool in principle — the raw path writes the tensor's native element size, while MemoryData always reads 4 bytes per element, so any narrower attribute dtype reaching this path has the same problem. bool is the one I hit and confirmed.
Suggested fix
Convert bool (and other narrow dtypes) to float32 on write, mirroring the existing int64 case:
if (attr.type == 9) // bool --> fp32
{
const unsigned char* p = (const unsigned char*)attr.data.data();
int len = attr.data.size();
std::vector<float> data_fp32(len);
for (int i = 0; i < len; i++)
data_fp32[i] = p[i] ? 1.f : 0.f;
fwrite(data_fp32.data(), data_fp32.size() * sizeof(float), 1, binfp);
continue;
}
An alternative would be to keep the packed bytes and set MemoryData's load_type, but there is no byte-wise load type, so widening on write looks like the smaller change.
How this surfaced
Found while converting yoloe-11s-seg-pf (ultralytics YOLOE). A 400-element folded bool constant put the reader 300 floats out of step, which corrupted the anchor-point grid — it arrived as [-0.032, -0.04, ...] instead of [0.5, 1.5, 2.5, ...] — along with the mask and prototype convolutions. Output correlation against torch was 0.012. The class-score branch happened to sit earlier in the .bin and was unaffected, which made the corruption look selective and cost a lot of time to localise.
The silence is the expensive part: with a zero exit status and successful loads, there is no signal that the model is wrong until you compare against the reference implementation.
Happy to open a PR with the fix and a test case if that is useful.
Summary
When a model contains a bool attribute/constant,
pnnxwrites it tomodel.ncnn.binas one byte per element, but emits aMemoryDatalayer that declares the element count.MemoryData::load_model()then reads that many float32. BecauseModelBinis a sequential reader, the cursor ends up3n/4floats out of step and every weight-bearing layer after that point loads shifted data.Nothing reports an error:
pnnxexits 0,load_param()andload_model()both return 0, and inference produces plausible-looking numbers that are simply wrong.Environment
20260526(pip)1.0.20260526a4d2ea1(2026-07-22)Minimal reproduction
Output:
The generated
.paramcontains:64elements declared → ncnn reads 256 bytes; pnnx wrote 64. The reader is 48 floats out of step from there on, soc2's weights are garbage.Root cause
tools/pnnx/src/pass_ncnn/convert_attribute.cppsets theMemoryDataparams from the tensor shape, without regard to dtype — correct for the layer, but it commits ncnn to readingw*h*cfloat32 (MemoryData::load_model()→mb.load(..., load_type), defaultload_type=1, untagged fp32).tools/pnnx/src/save_ncnn.cppthen writes the attribute. It special-cases fp32→fp16 and int64→int32:There is no case for
attr.type == 9(bool), so it takes the rawfwriteand emits 1 byte per element.The mismatch is not unique to bool in principle — the raw path writes the tensor's native element size, while
MemoryDataalways reads 4 bytes per element, so any narrower attribute dtype reaching this path has the same problem. bool is the one I hit and confirmed.Suggested fix
Convert bool (and other narrow dtypes) to float32 on write, mirroring the existing int64 case:
An alternative would be to keep the packed bytes and set
MemoryData'sload_type, but there is no byte-wise load type, so widening on write looks like the smaller change.How this surfaced
Found while converting
yoloe-11s-seg-pf(ultralytics YOLOE). A 400-element folded bool constant put the reader 300 floats out of step, which corrupted the anchor-point grid — it arrived as[-0.032, -0.04, ...]instead of[0.5, 1.5, 2.5, ...]— along with the mask and prototype convolutions. Output correlation against torch was 0.012. The class-score branch happened to sit earlier in the.binand was unaffected, which made the corruption look selective and cost a lot of time to localise.The silence is the expensive part: with a zero exit status and successful loads, there is no signal that the model is wrong until you compare against the reference implementation.
Happy to open a PR with the fix and a test case if that is useful.