Skip to content

pnnx: bool attributes desynchronise the ncnn .bin — MemoryData declares element count but only 1 byte/element is written #6855

Description

@giuliocorradi

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions