Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions tools/pnnx/src/pass_ncnn/expand_expression.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

#include "pass_ncnn.h"

#include <errno.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>

#include <set>
Expand Down Expand Up @@ -50,6 +52,24 @@ static bool token_is_literal(const std::string& t)
return iss.eof() && !iss.fail();
}

static bool parse_float_noexcept(const std::string& t, float& f)
{
errno = 0;

char* endptr = 0;
f = strtof(t.c_str(), &endptr);

if (endptr == t.c_str())
return false;

// strtof reports ERANGE for both overflow and underflow.
// Accept subnormal/underflow values and reject only true overflow.
if (errno == ERANGE && (f == HUGE_VALF || f == -HUGE_VALF))
return false;

return true;
Comment on lines +55 to +70

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the catch. You're right that strtof accepts a valid prefix unless endptr is checked against the end of the string.

One note: this is not a new behavior introduced by this PR. The previous code used std::stof without checking pos, which also accepts a valid prefix and ignores trailing characters.

This PR is intentionally scoped to avoiding std::out_of_range on denormal/underflow literals in pass_ncnn. I agree that full-token validation is worth tightening, but I'd prefer to handle that separately to avoid mixing the denormal fix with a broader parser behavior change.

}

static std::string expand_expression(Graph& graph, const Operator* op, int& pnnx_expr_index)
{
std::string expr = op->params.at("expr").s;
Expand Down Expand Up @@ -241,7 +261,14 @@ static std::string expand_expression(Graph& graph, const Operator* op, int& pnnx
op_binary_inb->consumers.push_back(op_binary);

op_binary->params["1"] = 1; // with_scalar
op_binary->params["2"] = std::stof(a);

float scalar = 0.f;
if (!parse_float_noexcept(a, scalar))
{
fprintf(stderr, "ncnn expand_expression failed to parse literal %s in expr %s\n", a.c_str(), expr.c_str());
return std::string();
}
op_binary->params["2"] = scalar;

Operand* op_binary_out = graph.new_operand(op->name + "_" + r);
op_binary_out->producer = op_binary;
Expand All @@ -257,9 +284,16 @@ static std::string expand_expression(Graph& graph, const Operator* op, int& pnnx
op_binary_ina->consumers.push_back(op_binary);

op_binary->params["1"] = 1; // with_scalar
op_binary->params["2"] = std::stof(b);

if (t == "pow" && std::stof(b) == 2)
float scalar = 0.f;
if (!parse_float_noexcept(b, scalar))
{
fprintf(stderr, "ncnn expand_expression failed to parse literal %s in expr %s\n", b.c_str(), expr.c_str());
return std::string();
}
op_binary->params["2"] = scalar;

if (t == "pow" && scalar == 2)
{
// replace pow 2 with square
op_binary->type = "UnaryOp";
Expand Down
1 change: 1 addition & 0 deletions tools/pnnx/tests/ncnn/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ pnnx_ncnn_add_test(ncnn_numpy_binaryop_broadcast)
pnnx_ncnn_add_test(ncnn_reshape_expr)
pnnx_ncnn_add_test(ncnn_slice_expr)
pnnx_ncnn_add_test(ncnn_solve_batch_index)
pnnx_ncnn_add_test(ncnn_expression_denorm)

if(TorchVision_FOUND)
pnnx_ncnn_add_test(torchvision_DeformConv2d)
Expand Down
76 changes: 76 additions & 0 deletions tools/pnnx/tests/ncnn/test_ncnn_expression_denorm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Copyright 2026 Tencent
# SPDX-License-Identifier: BSD-3-Clause

import os

import torch
import torch.nn as nn


class Model(nn.Module):
def __init__(self):
super(Model, self).__init__()

def forward(self, x, y):
out0 = x * -4.928072e-40 + y
out1 = out0 * -4.881353e-40 + y
return out0, out1


def test():
net = Model()
net.eval()

torch.manual_seed(0)
x = torch.rand(1, 4, 8, 8)
y = torch.rand(1, 4, 8, 8)

a = net(x, y)

# export torchscript
mod = torch.jit.trace(net, (x, y))
mod.save("test_ncnn_expression_denorm.pt")

# torchscript to pnnx
if (
os.system(
"../../src/pnnx test_ncnn_expression_denorm.pt inputshape=[1,4,8,8],[1,4,8,8]"
)
!= 0
):
return False

# ensure pass_ncnn produced output files
if not os.path.exists("test_ncnn_expression_denorm.ncnn.param"):
return False
if not os.path.exists("test_ncnn_expression_denorm.ncnn.bin"):
return False

# ensure the test model really contains denormalized literal expression
if not os.path.exists("test_ncnn_expression_denorm.pnnx.param"):
return False
with open("test_ncnn_expression_denorm.pnnx.param", "r", encoding="utf-8") as f:
Comment on lines +30 to +52

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, thanks. Stale generated files can make this kind of test less robust.

For this PR I followed the existing tools/pnnx/tests/ncnn test style and kept the regression focused on the denormal-expression failure path. The test already checks the pnnx command result, verifies that the expected ncnn outputs are produced, confirms that the generated pnnx param still contains the denormal expression, and compares inference results.

I agree that cleanup/temp-directory isolation would make the tests more robust, but I think that is better handled as a broader test-suite cleanup rather than in this focused regression fix.

pnnx_param = f.read()

if "pnnx.Expression" not in pnnx_param:
return False
if "e-40" not in pnnx_param:
return False

# ncnn inference
import test_ncnn_expression_denorm_ncnn

b = test_ncnn_expression_denorm_ncnn.test_inference()

for aa, bb in zip(a, b):
if not torch.allclose(aa, bb, 1e-6, 1e-6):
return False

return True


if __name__ == "__main__":
if test():
exit(0)
else:
exit(1)