renpy-0001: ShownImageInfo.choose_image() list membership O(I*A*(R+O)), 5.9x

This commit is contained in:
russell@unturf.com 2026-03-31 12:17:11 -04:00
parent 9fac7766ba
commit bf67964b10
2 changed files with 154 additions and 0 deletions

View file

@ -0,0 +1,35 @@
# UNDF: UNDF-2026-000000969
--- a/renpy/display/image.py
+++ b/renpy/display/image.py
@@ -980,10 +980,10 @@
defaults = f(name)
# The list of attributes a matching image may have.
- optional = list(defaults) if defaults else []
+ optional = set(defaults) if defaults else set()
# The list of attributes a matching image must have.
- required = []
+ required = set()
for i in name[1:]:
if i[0] == "-":
@@ -991,16 +991,16 @@
if i in optional:
- optional.remove(i)
+ optional.discard(i)
if i in required:
- required.remove(i)
+ required.discard(i)
else:
- required.append(i)
+ required.add(i)
return self.choose_image(nametag, required, optional, name)
def choose_image(self, tag, required, optional, exception_name):
# The longest length of an image that matches.
max_len = -1

View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
Unit test for renpy-0001: ShownImageInfo.choose_image required/optional list
membership O(I*A*(R+O)) -> set membership O(I*A)
Defect: In renpy/display/image.py, ShownImageInfo.apply_attributes() builds
`required` and `optional` as Python lists, then passes them to choose_image().
Inside choose_image(), the inner loop checks `i in required` and `i in optional`
for every attribute of every registered image for a tag. With lists, each `in`
check is O(N), making the total O(I * A * (R + O)) where I = images per tag,
A = attributes per image, R = len(required), O = len(optional).
Fix: Change `required` and `optional` from lists to sets. Membership test
becomes O(1), total becomes O(I * A). Use set.add/discard instead of
list.append/remove.
File: renpy/display/image.py
Method: ShownImageInfo.apply_attributes / ShownImageInfo.choose_image
Lines: 984-1002 (apply_attributes), 1011-1032 (choose_image inner loop)
"""
import time
def simulate_defective(image_attrs_by_tag, required_list, optional_list):
"""
Simulates choose_image with required and optional as lists.
O(I * A * (R + O)) due to list membership checks.
"""
matches = []
for attrs in image_attrs_by_tag:
if not all((i in required_list) or (i in optional_list) for i in attrs):
continue
num_required = 0
for i in attrs:
if i in required_list:
num_required += 1
if num_required != len(required_list):
continue
matches.append(attrs)
return matches
def simulate_fixed(image_attrs_by_tag, required_set, optional_set):
"""
Simulates choose_image with required and optional as sets.
O(I * A) due to set membership checks.
"""
matches = []
for attrs in image_attrs_by_tag:
if not all((i in required_set) or (i in optional_set) for i in attrs):
continue
num_required = 0
for i in attrs:
if i in required_set:
num_required += 1
if num_required != len(required_set):
continue
matches.append(attrs)
return matches
def main():
# Simulate a character tag with many image variants.
# Visual novel characters can have dozens of emotion/pose/outfit combos.
num_images = 200
attrs_per_image = 8
num_required = 30
num_optional = 30
# Build a pool of attribute names
all_attrs = [f"attr{i}" for i in range(num_required + num_optional + attrs_per_image)]
required_list = all_attrs[:num_required]
optional_list = all_attrs[num_required:num_required + num_optional]
required_set = set(required_list)
optional_set = set(optional_list)
# Build image attribute tuples. Each image has some required, some optional,
# and some unique attrs.
image_attrs_by_tag = []
for img_idx in range(num_images):
attrs = tuple(
required_list[img_idx % num_required:img_idx % num_required + 3]
+ optional_list[img_idx % num_optional:img_idx % num_optional + 3]
+ [f"unique{img_idx}_{j}" for j in range(attrs_per_image - 6)]
)
image_attrs_by_tag.append(attrs)
# Correctness check
result_defective = simulate_defective(image_attrs_by_tag, required_list, optional_list)
result_fixed = simulate_fixed(image_attrs_by_tag, required_set, optional_set)
assert result_defective == result_fixed, (
f"Results differ: defective={len(result_defective)} fixed={len(result_fixed)}"
)
print(f"PASS correctness: both return {len(result_defective)} matches")
# Benchmark
iterations = 2000
start = time.perf_counter()
for _ in range(iterations):
simulate_defective(image_attrs_by_tag, required_list, optional_list)
defective_time = time.perf_counter() - start
start = time.perf_counter()
for _ in range(iterations):
simulate_fixed(image_attrs_by_tag, required_set, optional_set)
fixed_time = time.perf_counter() - start
ratio = defective_time / fixed_time if fixed_time > 0 else float("inf")
print(f"PASS benchmark: defective={defective_time:.3f}s fixed={fixed_time:.3f}s ratio={ratio:.1f}x")
assert ratio > 2.0, f"Expected at least 2x improvement, got {ratio:.1f}x"
print(f"PASS ratio > 2x confirmed ({ratio:.1f}x)")
if __name__ == "__main__":
main()