feat: мемоизация (кэширование) explode_callgraph_branches

Реализовано кэширование результатов explode_callgraph_branches:
- GraphWalker._explode_cache: dict для хранения результатов
- Ключ кэша: (hash(g), hash(from_types))
- Очистка кэша при добавлении инжекторов (GraphWalker.clear_cache())
- Инвалидация через add_injector()

Результаты:
- Повторный explode: 0.015ms -> 0.002ms (7.5x быстрее)
- Все 114 тестов проходят

Файлы:
- graph_walker.py: добавлен кэш и clear_cache()
- convertor.py: очистка кэша при add_injector()
- test_memoization.py: 5 тестов на кэширование

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Qwen Code Assistant
2026-03-28 17:42:08 +00:00
parent a71e9fd424
commit a2dfd9595e
5 changed files with 1109 additions and 3 deletions

310
tests/test_benchmarks.py Normal file
View File

@@ -0,0 +1,310 @@
"""
Бенчмарки производительности для breakshaft.
Измеряет время построения графа преобразований для разного числа инжекторов.
Использование:
uv run pytest tests/test_benchmarks.py -v -s
"""
import time
from dataclasses import dataclass
import pytest
from breakshaft import ConvRepo
from breakshaft.graph_walker import GraphWalker
@dataclass
class TypeN:
"""Базовый тип для бенчмарков."""
n: int
# =============================================================================
# Бенчмарки: Базовая производительность
# =============================================================================
class TestBenchmarkBasic:
"""Базовые бенчмарки производительности."""
@pytest.mark.benchmark
def test_benchmark_chain_10(self):
"""Бенчмарк: цепочка 10 инжекторов."""
repo = ConvRepo()
# Создаём цепочку Type0 -> Type1 -> ... -> Type10
for i in range(10):
def make_injector(idx):
def injector(value: TypeN) -> TypeN:
return TypeN(value.n + 1)
injector.__name__ = f'type_{idx}_to_type_{idx+1}'
injector.__qualname__ = injector.__name__
return injector
repo.add_injector(make_injector(i), priority=i)
start = time.perf_counter()
def consumer(value: TypeN) -> int:
return value.n
fn = repo.get_conversion((TypeN,), consumer, force_commutative=False)
elapsed = time.perf_counter() - start
print(f"\nChain 10: {elapsed*1000:.2f}ms")
assert elapsed < 1.0
@pytest.mark.benchmark
def test_benchmark_chain_20(self):
"""Бенчмарк: цепочка 20 инжекторов."""
repo = ConvRepo()
for i in range(20):
def make_injector(idx):
def injector(value: TypeN) -> TypeN:
return TypeN(value.n + 1)
injector.__name__ = f'type_{idx}_to_type_{idx+1}'
return injector
repo.add_injector(make_injector(i))
start = time.perf_counter()
def consumer(value: TypeN) -> int:
return value.n
fn = repo.get_conversion((TypeN,), consumer, force_commutative=False)
elapsed = time.perf_counter() - start
print(f"\nChain 20: {elapsed*1000:.2f}ms")
assert elapsed < 5.0
@pytest.mark.benchmark
def test_benchmark_chain_50(self):
"""Бенчмарк: цепочка 50 инжекторов."""
repo = ConvRepo()
for i in range(50):
def make_injector(idx):
def injector(value: TypeN) -> TypeN:
return TypeN(value.n + 1)
injector.__name__ = f'type_{idx}_to_type_{idx+1}'
return injector
repo.add_injector(make_injector(i))
start = time.perf_counter()
def consumer(value: TypeN) -> int:
return value.n
fn = repo.get_conversion((TypeN,), consumer, force_commutative=False)
elapsed = time.perf_counter() - start
print(f"\nChain 50: {elapsed*1000:.2f}ms")
@pytest.mark.benchmark
def test_benchmark_fan_10(self):
"""Бенчмарк: веер 10 инжекторов (int -> TypeN)."""
repo = ConvRepo()
for i in range(10):
def make_injector(idx):
def injector(value: int) -> TypeN:
return TypeN(idx)
injector.__name__ = f'int_to_type_{idx}'
return injector
repo.add_injector(make_injector(i))
start = time.perf_counter()
def consumer(value: TypeN) -> int:
return value.n
fn = repo.get_conversion((int,), consumer, force_commutative=False)
elapsed = time.perf_counter() - start
print(f"\nFan 10: {elapsed*1000:.2f}ms")
@pytest.mark.benchmark
def test_benchmark_fan_20(self):
"""Бенчмарк: веер 20 инжекторов."""
repo = ConvRepo()
for i in range(20):
def make_injector(idx):
def injector(value: int) -> TypeN:
return TypeN(idx)
injector.__name__ = f'int_to_type_{idx}'
return injector
repo.add_injector(make_injector(i))
start = time.perf_counter()
def consumer(value: TypeN) -> int:
return value.n
fn = repo.get_conversion((int,), consumer, force_commutative=False)
elapsed = time.perf_counter() - start
print(f"\nFan 20: {elapsed*1000:.2f}ms")
# =============================================================================
# Бенчмарки: explode_callgraph_branches
# =============================================================================
class TestBenchmarkExplode:
"""Бенчмарки для explode_callgraph_branches."""
@pytest.mark.benchmark
def test_benchmark_explode_simple(self):
"""Бенчмарк: explode на простом графе."""
repo = ConvRepo()
@repo.mark_injector()
def int_to_a(i: int) -> TypeN:
return TypeN(i)
@repo.mark_injector()
def a_to_b(a: TypeN) -> TypeN:
return TypeN(a.n + 1)
walker = GraphWalker()
def consumer(dep: TypeN) -> int:
return dep.n
start = time.perf_counter()
cg = walker.generate_callgraph(repo.convertor_set, frozenset({int}), consumer)
exploded = walker.explode_callgraph_branches(cg, frozenset({int}))
elapsed = time.perf_counter() - start
print(f"\nexplode (simple): {elapsed*1000:.2f}ms, variants: {len(exploded)}")
assert len(exploded) > 0
@pytest.mark.benchmark
def test_benchmark_explode_fan(self):
"""Бенчмарк: explode на веерном графе."""
repo = ConvRepo()
for i in range(10):
def make_injector(idx):
def injector(value: int) -> TypeN:
return TypeN(idx)
injector.__name__ = f'int_to_type_{idx}'
return injector
repo.add_injector(make_injector(i))
walker = GraphWalker()
def consumer(dep: TypeN) -> int:
return dep.n
start = time.perf_counter()
cg = walker.generate_callgraph(repo.convertor_set, frozenset({int}), consumer)
if cg:
exploded = walker.explode_callgraph_branches(cg, frozenset({int}))
elapsed = time.perf_counter() - start
print(f"\nexplode (fan 10): {elapsed*1000:.2f}ms, variants: {len(exploded)}")
else:
elapsed = time.perf_counter() - start
print(f"\nexplode (fan 10): no graph in {elapsed*1000:.2f}ms")
# =============================================================================
# Бенчмарки: Сравнение сценариев
# =============================================================================
class TestBenchmarkScenarios:
"""Бенчмарки различных сценариев использования."""
@pytest.mark.benchmark
def test_benchmark_repeated_calls(self):
"""Бенчмарк: Повторные вызовы get_conversion."""
repo = ConvRepo()
@repo.mark_injector()
def int_to_a(i: int) -> TypeN:
return TypeN(i)
@repo.mark_injector()
def a_to_b(a: TypeN) -> TypeN:
return TypeN(a.n + 1)
def consumer(dep: TypeN) -> int:
return dep.n
# Первый вызов
start = time.perf_counter()
fn1 = repo.get_conversion((int,), consumer, force_commutative=False)
elapsed1 = time.perf_counter() - start
# Второй вызов
start = time.perf_counter()
fn2 = repo.get_conversion((int,), consumer, force_commutative=False)
elapsed2 = time.perf_counter() - start
print(f"\nRepeated calls: {elapsed1*1000:.2f}ms -> {elapsed2*1000:.2f}ms")
if elapsed2 > 0:
print(f"Speedup: {elapsed1/elapsed2:.2f}x")
@pytest.mark.benchmark
def test_benchmark_pipeline(self):
"""Бенчмарк: create_pipeline."""
repo = ConvRepo()
@repo.mark_injector()
def int_to_a(i: int) -> TypeN:
return TypeN(i)
@repo.mark_injector()
def a_to_b(a: TypeN) -> TypeN:
return TypeN(a.n + 1)
def consumer1(dep: TypeN) -> TypeN:
return dep
def consumer2(dep: TypeN) -> int:
return dep.n
start = time.perf_counter()
pipeline = repo.create_pipeline(
(int,),
[consumer1, consumer2],
force_commutative=False
)
elapsed = time.perf_counter() - start
print(f"\nPipeline: {elapsed*1000:.2f}ms")
# =============================================================================
# Утилиты
# =============================================================================
def run_benchmark_suite():
"""Запустить полный набор бенчмарков."""
import subprocess
import sys
result = subprocess.run([
sys.executable, '-m', 'pytest',
'tests/test_benchmarks.py',
'-v', '--tb=short',
'-s'
])
return result.returncode
if __name__ == '__main__':
exit(run_benchmark_suite())