Merge pull request #12409 from bluetech/reorder-items-perf

fixtures: fix catastrophic performance problem in `reorder_items`
This commit is contained in:
Ran Benita 2024-06-04 10:16:19 +03:00 committed by GitHub
commit d4dbe771f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 95 additions and 64 deletions

View File

@ -0,0 +1 @@
Fix possible catastrophic performance slowdown on a certain parametrization pattern involving many higher-scoped parameters.

View File

@ -21,9 +21,11 @@ from typing import Generic
from typing import Iterable from typing import Iterable
from typing import Iterator from typing import Iterator
from typing import List from typing import List
from typing import Mapping
from typing import MutableMapping from typing import MutableMapping
from typing import NoReturn from typing import NoReturn
from typing import Optional from typing import Optional
from typing import OrderedDict
from typing import overload from typing import overload
from typing import Sequence from typing import Sequence
from typing import Set from typing import Set
@ -76,8 +78,6 @@ if sys.version_info < (3, 11):
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Deque
from _pytest.main import Session from _pytest.main import Session
from _pytest.python import CallSpec2 from _pytest.python import CallSpec2
from _pytest.python import Function from _pytest.python import Function
@ -161,6 +161,12 @@ def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]:
) )
# Algorithm for sorting on a per-parametrized resource setup basis.
# It is called for Session scope first and performs sorting
# down to the lower scopes such as to minimize number of "high scope"
# setups and teardowns.
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class FixtureArgKey: class FixtureArgKey:
argname: str argname: str
@ -169,19 +175,21 @@ class FixtureArgKey:
item_cls: Optional[type] item_cls: Optional[type]
def get_parametrized_fixture_keys( _V = TypeVar("_V")
OrderedSet = Dict[_V, None]
def get_parametrized_fixture_argkeys(
item: nodes.Item, scope: Scope item: nodes.Item, scope: Scope
) -> Iterator[FixtureArgKey]: ) -> Iterator[FixtureArgKey]:
"""Return list of keys for all parametrized arguments which match """Return list of keys for all parametrized arguments which match
the specified scope.""" the specified scope."""
assert scope is not Scope.Function assert scope is not Scope.Function
try: try:
callspec: CallSpec2 = item.callspec # type: ignore[attr-defined] callspec: CallSpec2 = item.callspec # type: ignore[attr-defined]
except AttributeError: except AttributeError:
return return
for argname in callspec.indices:
if callspec._arg2scope[argname] != scope:
continue
item_cls = None item_cls = None
if scope is Scope.Session: if scope is Scope.Session:
@ -197,69 +205,65 @@ def get_parametrized_fixture_keys(
else: else:
assert_never(scope) assert_never(scope)
for argname in callspec.indices:
if callspec._arg2scope[argname] != scope:
continue
param_index = callspec.indices[argname] param_index = callspec.indices[argname]
yield FixtureArgKey(argname, param_index, scoped_item_path, item_cls) yield FixtureArgKey(argname, param_index, scoped_item_path, item_cls)
# Algorithm for sorting on a per-parametrized resource setup basis.
# It is called for Session scope first and performs sorting
# down to the lower scopes such as to minimize number of "high scope"
# setups and teardowns.
def reorder_items(items: Sequence[nodes.Item]) -> List[nodes.Item]: def reorder_items(items: Sequence[nodes.Item]) -> List[nodes.Item]:
argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]] = {} argkeys_by_item: Dict[Scope, Dict[nodes.Item, OrderedSet[FixtureArgKey]]] = {}
items_by_argkey: Dict[Scope, Dict[FixtureArgKey, Deque[nodes.Item]]] = {} items_by_argkey: Dict[
Scope, Dict[FixtureArgKey, OrderedDict[nodes.Item, None]]
] = {}
for scope in HIGH_SCOPES: for scope in HIGH_SCOPES:
scoped_argkeys_cache = argkeys_cache[scope] = {} scoped_argkeys_by_item = argkeys_by_item[scope] = {}
scoped_items_by_argkey = items_by_argkey[scope] = defaultdict(deque) scoped_items_by_argkey = items_by_argkey[scope] = defaultdict(OrderedDict)
for item in items: for item in items:
keys = dict.fromkeys(get_parametrized_fixture_keys(item, scope), None) argkeys = dict.fromkeys(get_parametrized_fixture_argkeys(item, scope))
if keys: if argkeys:
scoped_argkeys_cache[item] = keys scoped_argkeys_by_item[item] = argkeys
for key in keys: for argkey in argkeys:
scoped_items_by_argkey[key].append(item) scoped_items_by_argkey[argkey][item] = None
items_dict = dict.fromkeys(items, None)
items_set = dict.fromkeys(items)
return list( return list(
reorder_items_atscope(items_dict, argkeys_cache, items_by_argkey, Scope.Session) reorder_items_atscope(
items_set, argkeys_by_item, items_by_argkey, Scope.Session
)
) )
def fix_cache_order(
item: nodes.Item,
argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]],
items_by_argkey: Dict[Scope, Dict[FixtureArgKey, "Deque[nodes.Item]"]],
) -> None:
for scope in HIGH_SCOPES:
for key in argkeys_cache[scope].get(item, []):
items_by_argkey[scope][key].appendleft(item)
def reorder_items_atscope( def reorder_items_atscope(
items: Dict[nodes.Item, None], items: OrderedSet[nodes.Item],
argkeys_cache: Dict[Scope, Dict[nodes.Item, Dict[FixtureArgKey, None]]], argkeys_by_item: Mapping[Scope, Mapping[nodes.Item, OrderedSet[FixtureArgKey]]],
items_by_argkey: Dict[Scope, Dict[FixtureArgKey, "Deque[nodes.Item]"]], items_by_argkey: Mapping[
Scope, Mapping[FixtureArgKey, OrderedDict[nodes.Item, None]]
],
scope: Scope, scope: Scope,
) -> Dict[nodes.Item, None]: ) -> OrderedSet[nodes.Item]:
if scope is Scope.Function or len(items) < 3: if scope is Scope.Function or len(items) < 3:
return items return items
ignore: Set[Optional[FixtureArgKey]] = set()
items_deque = deque(items)
items_done: Dict[nodes.Item, None] = {}
scoped_items_by_argkey = items_by_argkey[scope] scoped_items_by_argkey = items_by_argkey[scope]
scoped_argkeys_cache = argkeys_cache[scope] scoped_argkeys_by_item = argkeys_by_item[scope]
ignore: Set[FixtureArgKey] = set()
items_deque = deque(items)
items_done: OrderedSet[nodes.Item] = {}
while items_deque: while items_deque:
no_argkey_group: Dict[nodes.Item, None] = {} no_argkey_items: OrderedSet[nodes.Item] = {}
slicing_argkey = None slicing_argkey = None
while items_deque: while items_deque:
item = items_deque.popleft() item = items_deque.popleft()
if item in items_done or item in no_argkey_group: if item in items_done or item in no_argkey_items:
continue continue
argkeys = dict.fromkeys( argkeys = dict.fromkeys(
(k for k in scoped_argkeys_cache.get(item, []) if k not in ignore), None k for k in scoped_argkeys_by_item.get(item, ()) if k not in ignore
) )
if not argkeys: if not argkeys:
no_argkey_group[item] = None no_argkey_items[item] = None
else: else:
slicing_argkey, _ = argkeys.popitem() slicing_argkey, _ = argkeys.popitem()
# We don't have to remove relevant items from later in the # We don't have to remove relevant items from later in the
@ -268,15 +272,22 @@ def reorder_items_atscope(
i for i in scoped_items_by_argkey[slicing_argkey] if i in items i for i in scoped_items_by_argkey[slicing_argkey] if i in items
] ]
for i in reversed(matching_items): for i in reversed(matching_items):
fix_cache_order(i, argkeys_cache, items_by_argkey)
items_deque.appendleft(i) items_deque.appendleft(i)
break # Fix items_by_argkey order.
if no_argkey_group: for other_scope in HIGH_SCOPES:
no_argkey_group = reorder_items_atscope( other_scoped_items_by_argkey = items_by_argkey[other_scope]
no_argkey_group, argkeys_cache, items_by_argkey, scope.next_lower() for argkey in argkeys_by_item[other_scope].get(i, ()):
other_scoped_items_by_argkey[argkey][i] = None
other_scoped_items_by_argkey[argkey].move_to_end(
i, last=False
) )
for item in no_argkey_group: break
items_done[item] = None if no_argkey_items:
reordered_no_argkey_items = reorder_items_atscope(
no_argkey_items, argkeys_by_item, items_by_argkey, scope.next_lower()
)
items_done.update(reordered_no_argkey_items)
if slicing_argkey is not None:
ignore.add(slicing_argkey) ignore.add(slicing_argkey)
return items_done return items_done

View File

@ -2219,6 +2219,25 @@ class TestAutouseManagement:
reprec = pytester.inline_run("-s") reprec = pytester.inline_run("-s")
reprec.assertoutcome(passed=2) reprec.assertoutcome(passed=2)
def test_reordering_catastrophic_performance(self, pytester: Pytester) -> None:
"""Check that a certain high-scope parametrization pattern doesn't cause
a catasrophic slowdown.
Regression test for #12355.
"""
pytester.makepyfile("""
import pytest
params = tuple("abcdefghijklmnopqrstuvwxyz")
@pytest.mark.parametrize(params, [range(len(params))] * 3, scope="module")
def test_parametrize(a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z):
pass
""")
result = pytester.runpytest()
result.assert_outcomes(passed=3)
class TestFixtureMarker: class TestFixtureMarker:
def test_parametrize(self, pytester: Pytester) -> None: def test_parametrize(self, pytester: Pytester) -> None: