Files
ncnn/python/tests/test_custom_layer_lifetime.py
LI d8227bbe4f Fix Python custom layer factory lifetime (#7049)
* 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
2026-10-12 15:27:04 +08:00

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