Opened 3 weeks ago
Last modified 6 days ago
#37214 assigned Cleanup/optimization
Performance regression with end to end migrations on v5.x
| Reported by: | William Yardley | Owned by: | Akshat Sparsh |
|---|---|---|---|
| Component: | Migrations | Version: | 5.0 |
| Severity: | Normal | Keywords: | |
| Cc: | Nick Pope | Triage Stage: | Accepted |
| Has patch: | no | Needs documentation: | no |
| Needs tests: | no | Patch needs improvement: | no |
| Easy pickings: | no | UI/UX: | no |
Description
We've recently updated from Django 4.2 to 5.2. We have a nightly job that runs all our migrations end to end.
Even after squashing _all_ our migrations (basically declaring migration bankruptcy fully) recently, there are ~ 800 migrations across ~ 95 apps.
The nightly run doubled in time from 45m to 90m just from this update. Profiling a from-scratch migration shows about 93% of the time in ProjectState rendering and almost none in cursor.execute.
Some quick Claude analysis (so the usual caveats apply) suggests this could relate to changes with caching of swappable-settings lookups, and _possibly_ also how field choices get normalized?
I know there have been some (long) past discussions mentioning that Django isn't trying to optimize this path too much. But to me, it does seem like a pretty significant regression for this to take twice as long.
Happy to provide other details if it's helpful; unfortunately, providing a full clean reproduction may be difficult, since this is happening in a fairly large internal codebase.
Change History (7)
comment:1 by , 3 weeks ago
| Resolution: | → needsinfo |
|---|---|
| Status: | new → closed |
comment:2 by , 2 weeks ago
| Resolution: | needsinfo |
|---|---|
| Status: | closed → new |
Hi - thanks. Would appreciate the opportunity to provide input / examples vs. just having the ticket closed. I can put up a very basic skeleton project that should help demonstrate the issue on a smaller scale, or at least be testable independently.
https://github.com/wyardley/django-42-52-migrations
Does this (AI generated) repro repo work as a starting point to testing / reproducing the issue?
comment:3 by , 2 weeks ago
| Triage Stage: | Unreviewed → Accepted |
|---|---|
| Version: | 5.2 → 5.0 |
Thank you for the test project and analysis! Performance regression mostly due to 500e01073adda32d5149624ee9a5cb7aa3d3583f (Refs #31262)
On my machine, between 4.2 and 5.0 the migrations take almost 3x more time. The performance hasn't changed in such a significant way between 5.0 and 6.1 but I have seen this improve significantly on main, though still about x2 than 4.2. Would be interesting to see if others experience the same?
Within the "why" of the test project README:
Field.choicesbecame a property that runsnormalize_choiceson every assignment. In 4.2 it was a plain attribute. Rebuilding state clones every field of every affected model on each operation, so the same choice lists get normalized repeatedly.
This refers to this commit 500e01073adda32d5149624ee9a5cb7aa3d3583f (#31262)
We also saw this caused a performance regression visible on django-asv: https://django.github.io/django-asv/#form_benchmarks.select_date_widget.benchmark.DateWidget.time_selectdatewidget?machine=ubuntu-latest&python=3.10&commits=68a8996b-9a9620dd
I also saw that python bench.py large take 60% more time after 500e01073adda32d5149624ee9a5cb7aa3d3583f
A rough solution for the above could be:
-
django/utils/choices.py
diff --git a/django/utils/choices.py b/django/utils/choices.py index 6b355d2324..20b8b5779b 100644
a b def flatten_choices(choices): 69 69 yield value_or_group, label_or_nested 70 70 71 71 72 class NormalizedChoiceList(list): 73 """Concrete choices already in canonical form.""" 74 75 72 76 def normalize_choices(value, *, depth=0): 73 77 """Normalize choices values consistently for fields and widgets.""" 74 78 # Avoid circular import when importing django.forms. 75 79 from django.db.models.enums import ChoicesType 76 80 77 81 match value: 82 case NormalizedChoiceList(): 83 # Avoid renormalizing already normalized choices. 84 return value 78 85 case BaseChoiceIterator() | Promise() | bytes() | str(): 79 86 # Avoid prematurely normalizing iterators that should be lazy. 80 87 # Because string-like types are iterable, return early to avoid … … def normalize_choices(value, *, depth=0): 107 114 108 115 try: 109 116 # Recursive call to convert any nested values to a list of 2-tuples. 110 return [(k, normalize_choices(v, depth=depth + 1)) for k, v in value] 117 return NormalizedChoiceList( 118 (k, normalize_choices(v, depth=depth + 1)) for k, v in value 119 ) 111 120 except (TypeError, ValueError): 112 121 # Return original value for the system check to raise if it has items 113 122 # that are not iterable or not 2-tuples: -
tests/utils_tests/test_choices.py
diff --git a/tests/utils_tests/test_choices.py b/tests/utils_tests/test_choices.py index e3e3766ea9..cd87a48ad0 100644
a b class NormalizeFieldChoicesTests(SimpleTestCase): 395 395 normalize_choices((lambda: (yield from value))()), 396 396 value, 397 397 ) 398 399 def test_normalized_choices_does_not_renormalize(self): 400 choices = normalize_choices({"A": "Alpha"}) 401 self.assertEqual(choices, [("A", "Alpha")]) 402 self.assertIs(normalize_choices(choices), choices)
This resulted in a speed up to be similar to 4.2 levels. But this needs a lot more investigation and testing
Performance improvement is not my strong point so I would appreciate further input here
As a side note, tickets are closed when they are waiting for further input from authors but are reopened when new information is given.
A closed state isn't final but allows us to avoid reminding individuals periodically to provide more information and then closing after an arbitrary amount of time if they don't get back to us.
comment:4 by , 12 days ago
| Cc: | added |
|---|
comment:5 by , 9 days ago
| Owner: | set to |
|---|---|
| Status: | new → assigned |
comment:6 by , 9 days ago
Thanks for the pointer, Sarah. I reproduced this on current main and looked more closely at the normalized-list approach.
Using the public large benchmark on Python 3.13.12, current main (957d0cee71) had a median of 7.861 s and my local draft had a median of 4.318 s across 12 randomized pairs. The draft was faster in all 12 pairs. On Python 3.12.13 it was still 13.3% slower than Django 4.2.20, so this seems to recover most, but not all, of the regression.
I found one issue with returning a marked list unchanged: choices are mutable. If an already-normalized list or one of its nested groups is changed, the next assignment still needs to normalize the new value.
My current draft shares a dirty flag across each normalized choices tree. Ordinary list mutations mark the tree dirty, and the next call rebuilds it. Copying and pickling produce regular lists. The focused tests pass on Python 3.12, 3.13, and 3.14. A fresh full serial run completed successfully: 19,015 tests, with 1,676 skips and 4 expected failures.
One compatibility concern remains. The result is still a list subclass, so isinstance(choices, list) works, but exact-type consumers can notice the difference. PyYAML's safe_dump() is one example unless the value is converted to a regular list first.
Would you prefer that I continue with the mutation-aware list approach, or look for a narrower optimization in the field or migration cloning path?
AI disclosure: I used GPT 5.6 Sol to help trace the call path, draft the local implementation and tests, and orchestrate the local verification above. All timings quoted here are from those local runs.
comment:7 by , 6 days ago
Hi Akshat, I don't have a strong preference for the implementation direction at this point. If you have multiple ideas for addressing the regression, I'd encourage exploring them and comparing both their performance and complexity. It would be useful to see the trade-offs between the approaches before settling on one. Thank you!
Thanks for the report. Some additional information will be needed to move this forward, I'm afraid. If you have a suspicion choices are involved, then scripting out a test project with a dozen migrations that only tinker with choices, and measuring if it's slower on 5.2 would help.