mirror of
https://github.com/Tencent/ncnn.git
synced 2026-10-16 20:32:33 +08:00
d8227bbe4f
* fix(python): scope custom layer factories to Net lifetime Replace the process-global ten-slot callback table with stable per-Net userdata. Preserve registrations across clear() and destroy layers before releasing their factories. Add real-binding regression coverage for repeated and simultaneous Nets, numeric and named registrations, reloads, callback lifetime, subclassing and interpreter shutdown. Fixes #4560 * fix(python): release superseded custom layer callbacks * fix(python): guard unencodable named custom layer slots
477 lines
14 KiB
Python
477 lines
14 KiB
Python
# Copyright 2026 Tencent
|
|
# SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
import gc
|
|
import struct
|
|
import weakref
|
|
|
|
import pytest
|
|
|
|
import ncnn
|
|
|
|
|
|
class LayerFactory:
|
|
def __init__(self, increment):
|
|
self.increment = increment
|
|
self.layers = []
|
|
self.destroyed = 0
|
|
|
|
def create(self):
|
|
increment = self.increment
|
|
|
|
class CustomLayer(ncnn.Layer):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.one_blob_only = True
|
|
|
|
def forward(self, bottom, top, opt):
|
|
top.clone_from(bottom, opt.blob_allocator)
|
|
top.fill(bottom[0] + increment)
|
|
return 0
|
|
|
|
layer = CustomLayer()
|
|
self.layers.append(layer)
|
|
return layer
|
|
|
|
def destroy(self, layer):
|
|
self.layers.remove(layer)
|
|
self.destroyed += 1
|
|
|
|
|
|
def register(net, factory, kind, index=0):
|
|
key = 'CustomLayer' + str(index) if kind == 'name' else 256 + index
|
|
assert net.register_custom_layer(key, factory.create, factory.destroy) == 0
|
|
|
|
|
|
def load(net, kind, tmp_path, index=0):
|
|
if kind == 'name':
|
|
assert net.load_param_mem(
|
|
'7767517\n2 2\nInput data 0 1 data\n'
|
|
'CustomLayer{} custom 1 1 data output\n'.format(index)
|
|
) == 0
|
|
else:
|
|
# Binary Input (16), followed by the registered custom layer.
|
|
values = [7767517, 2, 2, 16, 0, 1, 0, -233,
|
|
256 + index, 1, 1, 0, 1, -233]
|
|
path = tmp_path / 'custom.param.bin'
|
|
path.write_bytes(struct.pack('<{}i'.format(len(values)), *values))
|
|
assert net.load_param_bin(str(path)) == 0
|
|
assert net.load_model(ncnn.DataReaderFromEmpty()) == 0
|
|
|
|
|
|
def infer(net, expected):
|
|
with net.create_extractor() as ex:
|
|
value = ncnn.Mat(1)
|
|
value.fill(1.0)
|
|
assert ex.input(0, value) == 0
|
|
ret, output = ex.extract(1)
|
|
assert ret == 0
|
|
assert output[0] == expected
|
|
|
|
|
|
@pytest.mark.parametrize('kind', ['name', 'index'])
|
|
def test_repeated_net_registration(kind, tmp_path):
|
|
for i in range(20):
|
|
factory = LayerFactory(i)
|
|
net = ncnn.Net()
|
|
register(net, factory, kind)
|
|
load(net, kind, tmp_path)
|
|
infer(net, i + 1)
|
|
del net
|
|
gc.collect()
|
|
assert factory.destroyed == 1
|
|
assert not factory.layers
|
|
|
|
|
|
@pytest.mark.parametrize('kind', ['name', 'index'])
|
|
def test_independent_live_nets(kind, tmp_path):
|
|
nets = []
|
|
factories = []
|
|
for i in range(20):
|
|
net = ncnn.Net()
|
|
factory = LayerFactory(i)
|
|
register(net, factory, kind)
|
|
load(net, kind, tmp_path)
|
|
nets.append(net)
|
|
factories.append(factory)
|
|
# Exercise old callbacks after all subsequent registrations.
|
|
for i, net in enumerate(nets):
|
|
infer(net, i + 1)
|
|
net.clear()
|
|
assert factories[i].destroyed == 1
|
|
for i, net in enumerate(nets):
|
|
load(net, kind, tmp_path)
|
|
infer(net, i + 1)
|
|
net.clear()
|
|
assert factories[i].destroyed == 2
|
|
|
|
|
|
@pytest.mark.parametrize('kind', ['name', 'index'])
|
|
def test_many_layers_in_one_net(kind, tmp_path):
|
|
net = ncnn.Net()
|
|
factories = [LayerFactory(i) for i in range(20)]
|
|
for i, factory in enumerate(factories):
|
|
register(net, factory, kind, i)
|
|
for i in range(20):
|
|
load(net, kind, tmp_path, i)
|
|
infer(net, i + 1)
|
|
net.clear()
|
|
assert all(factory.destroyed == 1 for factory in factories)
|
|
|
|
|
|
@pytest.mark.parametrize('kind', ['name', 'index'])
|
|
def test_callbacks_live_until_net_destruction(kind, tmp_path):
|
|
net = ncnn.Net()
|
|
factory = LayerFactory(3)
|
|
ref = weakref.ref(factory)
|
|
register(net, factory, kind)
|
|
del factory
|
|
gc.collect()
|
|
assert ref() is not None
|
|
load(net, kind, tmp_path)
|
|
infer(net, 4)
|
|
net.clear()
|
|
# clear() keeps registrations available for loading another model.
|
|
assert ref() is not None
|
|
load(net, kind, tmp_path)
|
|
infer(net, 4)
|
|
del net
|
|
gc.collect()
|
|
assert ref() is None
|
|
|
|
|
|
@pytest.mark.parametrize('kind', ['name', 'index'])
|
|
def test_extractor_keeps_callbacks_alive(kind, tmp_path):
|
|
net = ncnn.Net()
|
|
factory = LayerFactory(5)
|
|
ref = weakref.ref(factory)
|
|
register(net, factory, kind)
|
|
load(net, kind, tmp_path)
|
|
ex = net.create_extractor()
|
|
del net, factory
|
|
gc.collect()
|
|
assert ref() is not None
|
|
value = ncnn.Mat(1)
|
|
value.fill(1.0)
|
|
assert ex.input(0, value) == 0
|
|
ret, output = ex.extract(1)
|
|
assert ret == 0 and output[0] == 6
|
|
del ex
|
|
gc.collect()
|
|
assert ref() is None
|
|
|
|
|
|
@pytest.mark.parametrize('kind', ['name', 'index'])
|
|
def test_replace_registration_after_clear(kind, tmp_path):
|
|
net = ncnn.Net()
|
|
factories = []
|
|
for i in range(20):
|
|
factory = LayerFactory(i)
|
|
factories.append(factory)
|
|
register(net, factory, kind)
|
|
load(net, kind, tmp_path)
|
|
infer(net, i + 1)
|
|
net.clear()
|
|
assert factory.destroyed == 1
|
|
assert all(not factory.layers for factory in factories)
|
|
|
|
|
|
@pytest.mark.parametrize('index', [-1, 1000000 + 256])
|
|
def test_rejected_registration_releases_callbacks(index):
|
|
net = ncnn.Net()
|
|
active = LayerFactory(2)
|
|
active_ref = weakref.ref(active)
|
|
register(net, active, 'name')
|
|
del active
|
|
factory = LayerFactory(1)
|
|
ref = weakref.ref(factory)
|
|
assert net.register_custom_layer(index, factory.create, factory.destroy) == -1
|
|
del factory
|
|
gc.collect()
|
|
assert ref() is None
|
|
assert active_ref() is not None
|
|
del net
|
|
gc.collect()
|
|
assert active_ref() is None
|
|
|
|
|
|
@pytest.mark.parametrize('first,second,type_name', [
|
|
('CustomLayer0', 'CustomLayer0', 'CustomLayer0'),
|
|
(256, 256, None),
|
|
('CustomLayer0', 256, 'CustomLayer0'),
|
|
('Input', 16, 'Input'),
|
|
(16, 'Input', 'Input'),
|
|
])
|
|
def test_re_registration_releases_superseded_callbacks(first, second, type_name, tmp_path):
|
|
net = ncnn.Net()
|
|
previous = LayerFactory(1)
|
|
ref = weakref.ref(previous)
|
|
assert net.register_custom_layer(first, previous.create, previous.destroy) == 0
|
|
del previous
|
|
current = LayerFactory(2)
|
|
assert net.register_custom_layer(second, current.create, current.destroy) == 0
|
|
gc.collect()
|
|
assert ref() is None
|
|
|
|
if type_name == 'Input':
|
|
assert net.load_param_mem('7767517\n1 1\nInput data 0 1 data\n') == 0
|
|
assert net.load_model(ncnn.DataReaderFromEmpty()) == 0
|
|
else:
|
|
# Name->index replacement must retain the original native name storage.
|
|
load(net, 'name' if type_name else 'index', tmp_path)
|
|
infer(net, 3)
|
|
net.clear()
|
|
assert current.destroyed == 1
|
|
|
|
|
|
def test_new_named_slot_with_custom_tag_is_rejected(tmp_path):
|
|
net = ncnn.Net()
|
|
refs = []
|
|
for i in range(256):
|
|
factory = LayerFactory(i)
|
|
refs.append(weakref.ref(factory))
|
|
register(net, factory, 'name', i)
|
|
del factory
|
|
gc.collect()
|
|
assert all(ref() is not None for ref in refs)
|
|
|
|
rejected = LayerFactory(300)
|
|
rejected_ref = weakref.ref(rejected)
|
|
assert net.register_custom_layer('CustomLayer256', rejected.create, rejected.destroy) == -1
|
|
del rejected
|
|
gc.collect()
|
|
assert rejected_ref() is None
|
|
assert all(ref() is not None for ref in refs)
|
|
# Rejecting the unencodable next slot must preserve the original routing.
|
|
load(net, 'name', tmp_path)
|
|
infer(net, 1)
|
|
net.clear()
|
|
assert refs[0]().destroyed == 1
|
|
|
|
|
|
@pytest.mark.parametrize('index', [253, 254, 255])
|
|
@pytest.mark.parametrize('replacement', ['name', 'index'])
|
|
def test_named_boundary_slots_load_after_replacement(index, replacement, tmp_path):
|
|
net = ncnn.Net()
|
|
refs = []
|
|
for i in range(index + 1):
|
|
factory = LayerFactory(i)
|
|
refs.append(weakref.ref(factory))
|
|
register(net, factory, 'name', i)
|
|
del factory
|
|
current = LayerFactory(300)
|
|
register(net, current, replacement, index)
|
|
gc.collect()
|
|
assert refs[index]() is None
|
|
assert all(ref() is not None for ref in refs[:index])
|
|
load(net, 'name', tmp_path, index)
|
|
infer(net, 301)
|
|
net.clear()
|
|
assert current.destroyed == 1 and not current.layers
|
|
load(net, 'name', tmp_path, index)
|
|
infer(net, 301)
|
|
net.clear()
|
|
assert current.destroyed == 2
|
|
|
|
|
|
@pytest.mark.parametrize('index', [0, 254, 255, 512])
|
|
def test_index_registration_tracks_native_extent_for_new_names(index, tmp_path):
|
|
net = ncnn.Net()
|
|
indexed = LayerFactory(1)
|
|
register(net, indexed, 'index', index)
|
|
named = LayerFactory(2)
|
|
ref = weakref.ref(named)
|
|
next_index = index + 1
|
|
result = net.register_custom_layer('FollowingNumeric', named.create, named.destroy)
|
|
if next_index & 256:
|
|
assert result == -1
|
|
del named
|
|
gc.collect()
|
|
assert ref() is None
|
|
else:
|
|
assert result == 0
|
|
assert net.load_param_mem(
|
|
'7767517\n2 2\nInput data 0 1 data\n'
|
|
'FollowingNumeric custom 1 1 data output\n'
|
|
) == 0
|
|
assert net.load_model(ncnn.DataReaderFromEmpty()) == 0
|
|
infer(net, 3)
|
|
net.clear()
|
|
assert named.destroyed == 1 and not named.layers
|
|
load(net, 'index', tmp_path, index)
|
|
infer(net, 2)
|
|
net.clear()
|
|
assert indexed.destroyed == 1 and not indexed.layers
|
|
|
|
|
|
def test_replacement_failed_and_builtin_registration_preserve_named_extent(tmp_path):
|
|
net = ncnn.Net()
|
|
for i in range(260):
|
|
factory = LayerFactory(i)
|
|
register(net, factory, 'name')
|
|
del factory
|
|
builtin = LayerFactory(0)
|
|
assert net.register_custom_layer('Input', builtin.create, builtin.destroy) == 0
|
|
assert net.register_custom_layer(16, builtin.create, builtin.destroy) == 0
|
|
rejected = LayerFactory(1)
|
|
rejected_ref = weakref.ref(rejected)
|
|
for index in [-1, 1000000 + 256]:
|
|
assert net.register_custom_layer(index, rejected.create, rejected.destroy) == -1
|
|
del rejected
|
|
gc.collect()
|
|
assert rejected_ref() is None
|
|
for i in range(1, 256):
|
|
factory = LayerFactory(i)
|
|
register(net, factory, 'name', i)
|
|
del factory
|
|
load(net, 'name', tmp_path, 255)
|
|
infer(net, 256)
|
|
net.clear()
|
|
assert builtin.destroyed == 1 and not builtin.layers
|
|
|
|
|
|
@pytest.mark.parametrize('kind', ['name', 'index'])
|
|
def test_re_registration_retains_only_loaded_callback_owners(kind, tmp_path):
|
|
class CompatibleFactory(LayerFactory):
|
|
def destroy(self, layer):
|
|
# Native registration uses the current destroyer, including for a
|
|
# layer created before the replacement.
|
|
if layer in self.layers:
|
|
self.layers.remove(layer)
|
|
self.destroyed += 1
|
|
|
|
net = ncnn.Net()
|
|
previous = CompatibleFactory(1)
|
|
previous_ref = weakref.ref(previous)
|
|
register(net, previous, kind)
|
|
load(net, kind, tmp_path)
|
|
del previous
|
|
for increment in range(2, 9):
|
|
current = CompatibleFactory(increment)
|
|
current_ref = weakref.ref(current)
|
|
register(net, current, kind)
|
|
del current
|
|
gc.collect()
|
|
assert previous_ref() is not None
|
|
if increment > 2:
|
|
assert intermediate_ref() is None
|
|
intermediate_ref = current_ref
|
|
|
|
infer(net, 2)
|
|
net.clear()
|
|
gc.collect()
|
|
assert previous_ref() is None
|
|
assert current_ref().destroyed == 1
|
|
load(net, kind, tmp_path)
|
|
infer(net, 9)
|
|
net.clear()
|
|
assert not current_ref().layers
|
|
assert current_ref().destroyed == 2
|
|
del net
|
|
gc.collect()
|
|
assert current_ref() is None
|
|
|
|
|
|
def test_null_creator_does_not_retain_replaced_callbacks():
|
|
class NullFactory:
|
|
def create(self):
|
|
return None
|
|
|
|
def destroy(self, layer):
|
|
raise AssertionError('no layer was created')
|
|
|
|
net = ncnn.Net()
|
|
previous = NullFactory()
|
|
ref = weakref.ref(previous)
|
|
assert net.register_custom_layer('CustomLayer0', previous.create, previous.destroy) == 0
|
|
assert net.load_param_mem('7767517\n1 1\nCustomLayer0 custom 0 1 output\n') != 0
|
|
del previous
|
|
current = LayerFactory(1)
|
|
register(net, current, 'name')
|
|
gc.collect()
|
|
assert ref() is None
|
|
|
|
|
|
def test_global_net_interpreter_shutdown(tmp_path):
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
|
|
# Smoke-test process exit with a loaded global Net. Python callback/global
|
|
# reference cycles are outside this test's collection guarantees.
|
|
script = textwrap.dedent('''
|
|
import ncnn
|
|
|
|
_state = []
|
|
|
|
class CustomLayer(ncnn.Layer):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.one_blob_only = True
|
|
|
|
def create():
|
|
layer = CustomLayer()
|
|
held.append(layer)
|
|
return layer
|
|
|
|
def destroy(layer):
|
|
_state.append('destroyed')
|
|
held.remove(layer)
|
|
|
|
net = ncnn.Net()
|
|
held = []
|
|
assert net.register_custom_layer('CustomLayer', create, destroy) == 0
|
|
assert net.load_param_mem(
|
|
'7767517\\n2 2\\nInput data 0 1 data\\n'
|
|
'CustomLayer custom 1 1 data output\\n'
|
|
) == 0
|
|
print('loaded')
|
|
''')
|
|
# Use the binding under test, including when pytest was launched with a
|
|
# temporary sys.path rather than an installed ncnn package.
|
|
env = os.environ.copy()
|
|
env['PYTHONPATH'] = os.pathsep.join(os.path.abspath(path) for path in sys.path)
|
|
result = subprocess.run(
|
|
[sys.executable, '-c', script],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
universal_newlines=True,
|
|
timeout=30,
|
|
env=env,
|
|
cwd=tmp_path,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
assert result.stdout.strip() == 'loaded'
|
|
assert not result.stderr
|
|
|
|
|
|
def test_net_subclass_keeps_long_type_name_stable():
|
|
class DerivedNet(ncnn.Net):
|
|
pass
|
|
|
|
net = DerivedNet()
|
|
factory = LayerFactory(7)
|
|
ref = weakref.ref(factory)
|
|
name = 'CustomLayerWithLongTypeNameBeyondSmallStringStorage_' * 2
|
|
param = ('7767517\n2 2\nInput data 0 1 data\n'
|
|
'{} custom 1 1 data output\n'.format(name))
|
|
assert net.register_custom_layer(name, factory.create, factory.destroy) == 0
|
|
del name
|
|
# Grow the owner container after registration, then use the original name
|
|
# and userdata. Both addresses must remain valid through reallocations.
|
|
for i in range(35):
|
|
assert net.register_custom_layer(
|
|
'OtherLongCustomLayerTypeName_{}'.format(i),
|
|
factory.create,
|
|
factory.destroy,
|
|
) == 0
|
|
assert net.load_param_mem(param) == 0
|
|
assert net.load_model(ncnn.DataReaderFromEmpty()) == 0
|
|
infer(net, 8)
|
|
net.clear()
|
|
assert factory.destroyed == 1
|
|
del factory, net
|
|
gc.collect()
|
|
assert ref() is None
|