You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Test collection is 10x slower on 9.1.1 compared to 9.0.3 #14942
Do you think you can try to somehow see which part of is_visibility_more_specific is causing the slowdown, the part inside the if or the return outside the if?
Do you use a lot of overriding fixtures -- that is, fixtures which use the same name (e.g. one at module level, one at class level, etc)?
I reproduced the regression and instrumented is_visibility_more_specific function. The slowdown is on the final Node-based return. But the main problem is how many times it's called.
With 4000 unrelated sibling test classes defining the same fixture name:
pytest 9.0.3: 2.57s
pytest 9.1.1: 9.12s
final Node-based comparisons: 7,998,000
returned True: 0
returned False: 7,998,000
_register_fixture scans all previously registered fixtures with that name for every new definition. Since fixtures on sibling classes have no visibility relationship, every comparison fails and the whole list is scanned. This results N * (N - 1) /2 comparisons even though there are no actual override relationships in this reproducer.
The regression also reproduces without the warning filter and with plugin autoload disabled.
Do you think you can try to somehow see which part of is_visibility_more_specific is causing the slowdown, the part inside the if or the return outside the if?
Sadly pyinstrument doesn't go that deep!
Do you use a lot of overriding fixtures -- that is, fixtures which use the same name (e.g. one at module level, one at class level, etc)?
In the codebase in general there are not many conftest.py files, so the global context is not generally very full but I think in some cases there will be some of these overrides, but not "many".
The vast majority of tests are in classes, which mostly inherit from one base class which does define a quite a few fixtures, which are then used in a lot of the final classes. In some cases there are a bunch of fixtures as well which get overridden from the base class in the final class
Profiling notes. This analysis and the numbers below were produced by Claude Opus 5 via Claude Code, driven by me (@RonnyPfannschmidt). I have reviewed them; the reproducer and the timings are reproducible with the snippets given here.
@Amine-LG's diagnosis is correct, and there is a second, smaller regression from the same release hiding behind it.
Reproducer
test_many_classes.py, N sibling classes each defining the same fixture name (the shape of mikicz's suite: one base class with fixtures, inherited by thousands of test classes — parsefactories registers a fixturedef per class):
Fix: an existing fixturedef can only be more specific than the new one if its node is strictly deeper in the collection tree. Track the deepest registration per fixture name and skip the scan when nothing registered so far can be deeper. Fixturedefs with a legacy string baseid have no node, so a name that has seen one falls back to the full scan.
Collection is top-down, so in practice the scan almost never fires — it only exists for plugins that register fixtures out of tree order (which is exactly what test_register_fixture_ordered_by_visibility covers, and that test still passes: registering node=item before node=session makes max_depth > depth and the scan runs).
Cause 2 — Node.__hash__ in _matchfactories
With cause 1 fixed, N=4000 is 4.62s profiled vs. 9.0.3's 2.13s. The remainder:
_matchfactories is O(number of fixturedefs with that name) per lookup in both 9.0.3 and 9.1 — that quadratic is not new. What is new is node-based matching:
Node.__hash__ is a Python-level function (hash(self._nodeid)), so the inner loop went from "one attribute + one str set lookup" (9.0.3 matched fixturedef.baseid in parentnodeids) to a Python call per fixturedef per lookup. Same 8M iterations, 0.363s in 9.0.3 vs 1.080s on main.
Fix: precompute a _match_key on FixtureDef — id(node) for node-based defs (nodes compare by identity, and the fixturedef holds the node alive so the id cannot be reused), the baseid string for legacy ones. int and str never compare equal, so one set holds both and the loop is a single lookup again.
Numbers
Wall clock, --collect-only, CPython 3.10, not under a profiler:
N classes
9.0.3
main (9.1.x)
patched
1000
0.38s
0.98s
0.56s
2000
0.67s
2.22s
0.94s
4000
1.53s
7.41s
1.83s
Not fixed here
_matchfactories remains O(defs-with-that-name) per lookup, i.e. quadratic in N, in 9.0.3 and after this patch alike. Killing it properly means indexing _arg2fixturedefs[name] by node and iterating the (short) parent chain instead of the (long) fixturedef list — which would also subsume the _register_fixture ordering, since the visible set for a given node is a chain and therefore totally ordered. That is a bigger change and is left out of the cheap regression fix.
@mikicz — the patch is on a branch; a PR will follow. Trying it against your suite would be useful: does it get you back to ~30s, or does something else in your layout still bite?
I installed your latest patch and sadly it hasn't improved things (I did verify I did in fact install the correct patch by going to source code in my venv)
Our main with 9.0.3: 30572/30661 tests collected (89 deselected) in 48.52s
Our main with the patch: 30572/30661 tests collected (89 deselected) in 308.60s (0:05:08)
I ran the profiling on a much smaller sample using pyinstrument (6594 tests, only about 3x slower on the branch compared to 9.0.3), attaching the pyinstrument output:
If you'd like, I can do more profiling on some more examples, possibly using other methods compared to pyinstrument?
Unfortunately I can only probably do for the next hour or and then I am not back until Tuesday
i may not be avaliable much - it would be great if you could create a reproducer script i could integrate in my profiling later as my initial testing makes somethings faster
the stacked pr on top also helps some parts but breaks plugins
@mikicz thanks — that profile is exactly what was needed, and it says my patch is wrong. This analysis was again produced by Claude Opus 5 via Claude Code, driven and reviewed by me.
You did install the right thing
Every line number in your pyinstrument output matches the #14949 branch rather than main: _register_fixture at 2071 (main: 2048), parsefactories 2288 (2220), getfixturedefs 2381 (2341), _matchfactories 2401 (2361), getfixtureinfo 1836, getfixtureclosure 1975. So no stale install — the patch simply does not help your suite.
Why it does not help
The skip condition in #14949 is "no already-registered fixturedef can be deeper in the collection tree than this one", tracked as a running maximum depth per fixture name. That holds only while registrations arrive in non-decreasing depth.
My reproducer was 4000 sibling classes in a single module — all at identical depth — so the guard skipped essentially every scan and the fix looked complete. A real suite has test classes at many different directory depths, and pytest collects depth-first, so the depth of successive registrations goes up and down continuously. Once any deep class has registered a name, the running maximum stays high, and every subsequent shallower registration of that name scans the whole list again.
Instrumented on a suite with classes at depths 1–4: 2920 of 4018 registrations still scan, 5,959,340 is_visibility_more_specific() calls. The guard skips 27% of registrations and buys almost nothing.
Numbers
Same 4000 classes each inheriting the same fixture from a shared base class; only the shape of the directory tree differs. --collect-only, CPython 3.10, wall clock.
The third row is the control: take the same classes at the same depths and order them so depth never decreases, and #14949 works again. So the failure is specifically depth non-monotonicity, not depth.
Where that leaves it
#14949 has two halves. The _matchfactories half — precomputing a match key so the per-fixturedef loop stops hashing Nodes — is a real fix and stands. The registration-scan guard is close to worthless on real trees and should come out; optimising for a shape my reproducer had and real suites do not is exactly the trap.
The approach that actually fixes this is #14950: index the fixturedefs by the node they are visible from and walk the requesting node's (short) parent chain, instead of scanning every definition of that name. That removes the quadratic outright rather than trying to skip it, and it also subsumes the ordering machinery #14513 added. Its problem is different — it assumes _register_fixture() is the only way fixturedefs enter the manager, and it is not: pytest-bdd, pytest-bdd-ng, pytest-psqlgraph, pytest-codspeed, pytest-keyring, pytest-fixture-forms and pykiso all write into FixtureManager._arg2fixturedefs directly. That is being worked on.
@mikicz no need to spend more of your time profiling — the reproducer above reproduces your numbers, so there is something concrete to iterate against now. If you have a spare moment when you are back Tuesday, trying #14950 against your suite would be the useful data point.
Hi, you just caught me before I needed to leave the computer, I ran against exp-14942-index-fixturedefs-by-node from #14950; and the collection is back to it's normal speed (a tiny bit faster even, but that could be just my computer not running something else - the order of magnitude slowdown is no longer present, either way)
30572/30661 tests collected (89 deselected) in 43.69s
I can run more tests on tuesday if the code changes by then
Hmm the many classes scenario is not one I thought about. It does indeed register a bunch of "overrides", though they are all mutually exclusive. That suggests we want a better data structure for the global fixture list, that scales with the depth of the tree rather than the absolute number of fixtures registered with the same name. The problem ~kinda existed before, in that the fixturedefs list would be very long, and _matchfactories would then need to filter through them. The is_visibility_more_specific change made it a lot worse and (hmm) visible :)
@RonnyPfannschmidt regarding the slow Node hash, as part of #14758 we are going to switch to identity-based hash for Nodes: #14758 (comment). Sounds like it would make things simpler for you.
Regarding pytest-bdd, it does use register_fixture these days (well actually still using the private _register_fixture but that's the same). But maybe you are referring to the inject_fixturedefs_for_step code it is using:
Interestingly it looks like they're hand-rolling some version of visibility-ordered insertion.
I'm not sure it would be completely sane to keep the existing pytest-bdd code working, if we choose the replace the data structure. We always to not break plugins but don't guarantee it for internal stuff. In other words, in this I think it would be better if pytest-bdd add compat for new pytest (which we can help with) then for pytest to carry very complex compat.
I went ahead and made my own proposed fix in #14984. It's simpler than #14950, mainly due to avoiding complex compat, which I think is too much for this.
Activity
bluetech commentedon Aug 26, 2026
Hmm sounds painful!
Do you think you can try to somehow see which part of
is_visibility_more_specificis causing the slowdown, the part inside theifor thereturnoutside theif?Do you use a lot of overriding fixtures -- that is, fixtures which use the same name (e.g. one at module level, one at class level, etc)?
Amine-LG commentedon Aug 26, 2026
I reproduced the regression and instrumented
is_visibility_more_specificfunction. The slowdown is on the final Node-basedreturn. But the main problem is how many times it's called.With 4000 unrelated sibling test classes defining the same fixture name:
pytest 9.0.3: 2.57s
pytest 9.1.1: 9.12s
final Node-based comparisons: 7,998,000
returned True: 0
returned False: 7,998,000
_register_fixturescans all previously registered fixtures with that name for every new definition. Since fixtures on sibling classes have no visibility relationship, every comparison fails and the whole list is scanned. This results N * (N - 1) /2 comparisons even though there are no actual override relationships in this reproducer.The regression also reproduces without the warning filter and with plugin autoload disabled.
Reproducer and instrumentation
Create
test_many_classes.py:Create
conftest.py:I used Python 3.12 with two isolated environments:
Compare collection times without loading the diagnostic
conftest.py:Run the instrumentation on 9.1.1:
The instrumentation output is:
mikicz commentedon Aug 28, 2026
Sadly pyinstrument doesn't go that deep!
In the codebase in general there are not many
conftest.pyfiles, so the global context is not generally very full but I think in some cases there will be some of these overrides, but not "many".The vast majority of tests are in classes, which mostly inherit from one base class which does define a quite a few fixtures, which are then used in a lot of the final classes. In some cases there are a bunch of fixtures as well which get overridden from the base class in the final class
RonnyPfannschmidt commentedon Aug 28, 2026
Profiling notes. This analysis and the numbers below were produced by Claude Opus 5 via Claude Code, driven by me (@RonnyPfannschmidt). I have reviewed them; the reproducer and the timings are reproducible with the snippets given here.
@Amine-LG's diagnosis is correct, and there is a second, smaller regression from the same release hiding behind it.
Reproducer
test_many_classes.py,Nsibling classes each defining the same fixture name (the shape ofmikicz's suite: one base class with fixtures, inherited by thousands of test classes —parsefactoriesregisters a fixturedef per class):$ NCLASSES=4000 python -m pytest --collect-only -qq --noconftest -c /dev/null test_many_classes.pyCause 1 —
_register_fixturescan (the 10x)_register_fixturescans the wholefaclistfor the name on every registration to find the insertion point (added in 7186cd4 / #14513):N=4000 → 7,998,000 calls, all returning
False, each walkingiter_parents(). cProfile onmain:11.5s of a 16.2s profiled run.
Fix: an existing fixturedef can only be more specific than the new one if its node is strictly deeper in the collection tree. Track the deepest registration per fixture name and skip the scan when nothing registered so far can be deeper. Fixturedefs with a legacy string baseid have no node, so a name that has seen one falls back to the full scan.
Collection is top-down, so in practice the scan almost never fires — it only exists for plugins that register fixtures out of tree order (which is exactly what
test_register_fixture_ordered_by_visibilitycovers, and that test still passes: registeringnode=itembeforenode=sessionmakesmax_depth > depthand the scan runs).Cause 2 —
Node.__hash__in_matchfactoriesWith cause 1 fixed, N=4000 is 4.62s profiled vs. 9.0.3's 2.13s. The remainder:
_matchfactoriesis O(number of fixturedefs with that name) per lookup in both 9.0.3 and 9.1 — that quadratic is not new. What is new is node-based matching:Node.__hash__is a Python-level function (hash(self._nodeid)), so the inner loop went from "one attribute + onestrset lookup" (9.0.3 matchedfixturedef.baseid in parentnodeids) to a Python call per fixturedef per lookup. Same 8M iterations, 0.363s in 9.0.3 vs 1.080s onmain.Fix: precompute a
_match_keyonFixtureDef—id(node)for node-based defs (nodes compare by identity, and the fixturedef holds the node alive so the id cannot be reused), the baseid string for legacy ones.intandstrnever compare equal, so one set holds both and the loop is a single lookup again.Numbers
Wall clock,
--collect-only, CPython 3.10, not under a profiler:Not fixed here
_matchfactoriesremains O(defs-with-that-name) per lookup, i.e. quadratic in N, in 9.0.3 and after this patch alike. Killing it properly means indexing_arg2fixturedefs[name]by node and iterating the (short) parent chain instead of the (long) fixturedef list — which would also subsume the_register_fixtureordering, since the visible set for a given node is a chain and therefore totally ordered. That is a bigger change and is left out of the cheap regression fix.@mikicz — the patch is on a branch; a PR will follow. Trying it against your suite would be useful: does it get you back to ~30s, or does something else in your layout still bite?
mikicz commentedon Aug 28, 2026
I installed your latest patch and sadly it hasn't improved things (I did verify I did in fact install the correct patch by going to source code in my venv)
Our main with 9.0.3:
30572/30661 tests collected (89 deselected) in 48.52sOur main with the patch:
30572/30661 tests collected (89 deselected) in 308.60s (0:05:08)I ran the profiling on a much smaller sample using
pyinstrument(6594 tests, only about 3x slower on the branch compared to 9.0.3), attaching the pyinstrument output:If you'd like, I can do more profiling on some more examples, possibly using other methods compared to pyinstrument?
Unfortunately I can only probably do for the next hour or and then I am not back until Tuesday
RonnyPfannschmidt commentedon Aug 28, 2026
i may not be avaliable much - it would be great if you could create a reproducer script i could integrate in my profiling later as my initial testing makes somethings faster
the stacked pr on top also helps some parts but breaks plugins
mikicz commentedon Aug 28, 2026
I shall try next Tuesday!
RonnyPfannschmidt commentedon Aug 28, 2026
@mikicz thanks — that profile is exactly what was needed, and it says my patch is wrong. This analysis was again produced by Claude Opus 5 via Claude Code, driven and reviewed by me.
You did install the right thing
Every line number in your pyinstrument output matches the #14949 branch rather than
main:_register_fixtureat 2071 (main: 2048),parsefactories2288 (2220),getfixturedefs2381 (2341),_matchfactories2401 (2361),getfixtureinfo1836,getfixtureclosure1975. So no stale install — the patch simply does not help your suite.Why it does not help
The skip condition in #14949 is "no already-registered fixturedef can be deeper in the collection tree than this one", tracked as a running maximum depth per fixture name. That holds only while registrations arrive in non-decreasing depth.
My reproducer was 4000 sibling classes in a single module — all at identical depth — so the guard skipped essentially every scan and the fix looked complete. A real suite has test classes at many different directory depths, and pytest collects depth-first, so the depth of successive registrations goes up and down continuously. Once any deep class has registered a name, the running maximum stays high, and every subsequent shallower registration of that name scans the whole list again.
Instrumented on a suite with classes at depths 1–4: 2920 of 4018 registrations still scan, 5,959,340
is_visibility_more_specific()calls. The guard skips 27% of registrations and buys almost nothing.Numbers
Same 4000 classes each inheriting the same fixture from a shared base class; only the shape of the directory tree differs.
--collect-only, CPython 3.10, wall clock.The third row is the control: take the same classes at the same depths and order them so depth never decreases, and #14949 works again. So the failure is specifically depth non-monotonicity, not depth.
Where that leaves it
#14949 has two halves. The
_matchfactorieshalf — precomputing a match key so the per-fixturedef loop stops hashingNodes — is a real fix and stands. The registration-scan guard is close to worthless on real trees and should come out; optimising for a shape my reproducer had and real suites do not is exactly the trap.The approach that actually fixes this is #14950: index the fixturedefs by the node they are visible from and walk the requesting node's (short) parent chain, instead of scanning every definition of that name. That removes the quadratic outright rather than trying to skip it, and it also subsumes the ordering machinery #14513 added. Its problem is different — it assumes
_register_fixture()is the only way fixturedefs enter the manager, and it is not: pytest-bdd, pytest-bdd-ng, pytest-psqlgraph, pytest-codspeed, pytest-keyring, pytest-fixture-forms and pykiso all write intoFixtureManager._arg2fixturedefsdirectly. That is being worked on.@mikicz no need to spend more of your time profiling — the reproducer above reproduces your numbers, so there is something concrete to iterate against now. If you have a spare moment when you are back Tuesday, trying #14950 against your suite would be the useful data point.
fixtures: keep the visibility index in sync with external mutation
RonnyPfannschmidt commentedon Aug 28, 2026
@mikicz in case you are still around - take a look at the followup pr to see if that more reliably solves the issue
the new mechanism is a bit more fragile and needs a few extra rounds of due diligence
mikicz commentedon Aug 28, 2026
Hi, you just caught me before I needed to leave the computer, I ran against
exp-14942-index-fixturedefs-by-nodefrom #14950; and the collection is back to it's normal speed (a tiny bit faster even, but that could be just my computer not running something else - the order of magnitude slowdown is no longer present, either way)I can run more tests on tuesday if the code changes by then
fixtures: avoid hashing Nodes when matching fixturedefs
fixtures: index fixturedefs by visibility instead of scanning
bluetech commentedon Sep 5, 2026
Hmm the many classes scenario is not one I thought about. It does indeed register a bunch of "overrides", though they are all mutually exclusive. That suggests we want a better data structure for the global fixture list, that scales with the depth of the tree rather than the absolute number of fixtures registered with the same name. The problem ~kinda existed before, in that the fixturedefs list would be very long, and
_matchfactorieswould then need to filter through them. Theis_visibility_more_specificchange made it a lot worse and (hmm) visible :)@RonnyPfannschmidt regarding the slow Node hash, as part of #14758 we are going to switch to identity-based hash for Nodes: #14758 (comment). Sounds like it would make things simpler for you.
Regarding pytest-bdd, it does use
register_fixturethese days (well actually still using the private_register_fixturebut that's the same). But maybe you are referring to theinject_fixturedefs_for_stepcode it is using:https://github.com/pytest-dev/pytest-bdd/blob/c17dbb2de74bebd4d16cfc865773286460e06e34/src/pytest_bdd/scenario.py#L133-L165
Interestingly it looks like they're hand-rolling some version of visibility-ordered insertion.
I'm not sure it would be completely sane to keep the existing pytest-bdd code working, if we choose the replace the data structure. We always to not break plugins but don't guarantee it for internal stuff. In other words, in this I think it would be better if pytest-bdd add compat for new pytest (which we can help with) then for pytest to carry very complex compat.
RonnyPfannschmidt commentedon Sep 5, 2026
If the compat is bearable we can have it until the next major im still trying to find a good balance
WIP: fixtures: use a better data structure for storing FixtureDefs
fixtures: use a better data structure for storing FixtureDefs
fixtures: use a better data structure for storing FixtureDefs
bluetech commentedon Sep 7, 2026
I went ahead and made my own proposed fix in #14984. It's simpler than #14950, mainly due to avoiding complex compat, which I think is too much for this.
fixtures: use a better data structure for storing FixtureDefs
fixtures: use a better data structure for storing FixtureDefs