Repository navigation
Dataclasses - Improve the performance of asdict/astuple for common types and default values #103000
Description
Activity
- addedtype-featureA feature request or enhancementA feature request or enhancement
on Mar 24, 2023 - addedperformancePerformance or resource usagePerformance or resource usagestdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directory3.12only security fixesonly security fixes
on Mar 24, 2023 Since the PR has been merged, presumably the issue can be closed?
Since the PR has been merged, presumably the issue can be closed?
There was another possible optimisation that was originally included in the PR, but which we decided to postpone to a separate PR, since it was a distinct change that deserved to be considered separately.
@DavidCEllis mentioned that he might not be interested in filing a followup PR anymore. But I'd still like to do some more benchmarks before closing this issue — if the second optimization provides a decent speedup, I might file my own PR (listing @DavidCEllis as a co-author).
Reacted by PythonixThe implementation I had originally included the recursion skip for things that weren't 'atomic'
if dict_factory is dict: result = {} for f in fields(obj): value = getattr(obj, f.name) if type(value) not in _ATOMIC_TYPES: value = _asdict_inner(value, dict_factory) result[f.name] = value return result
My thinking is that without the recursion skip while you could still write it in the same way:
if dict_factory is dict: result = {} for f in fields(obj): result[f.name] = _asdict_inner(getattr(obj, f.name), dict_factory)) return result
You would probably write it as a comprehension instead:
if dict_factory is dict: return {f.name: _asdict_inner(value, dict_factory) for f in fields(obj)}
This currently introduces additional overhead associated with the implementation of comprehensions. Based on testing a 'walrus' comprehension implementation1 with the recursion skip I would expect this to be slower than the regular loop, though I could be wrong.
However if/when PEP-709 for inline comprehensions lands, then I would expect this to no longer matter and the comprehension is clearly a better fit. Given that I probably wouldn't submit a PR until it was possible to compare with PEP-709 implemented. I wouldn't want to reject it as an insignificant improvement if this was due to the comprehension overhead.
Footnotes
-
I never submitted this implementation as it was slower and also much harder to read. ↩
Reacted by Alex Waygood-
This currently introduces additional overhead associated with the implementation of comprehensions. Based on testing a 'walrus' comprehension implementation1 with the recursion skip I would expect this to be slower than the regular loop, though I could be wrong.
Yeah, I tried this patch out locally:
Details
diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 4026c8b779..079c28fae3 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -1317,11 +1317,18 @@ def _asdict_inner(obj, dict_factory): if type(obj) in _ATOMIC_TYPES: return obj elif _is_dataclass_instance(obj): - result = [] - for f in fields(obj): - value = _asdict_inner(getattr(obj, f.name), dict_factory) - result.append((f.name, value)) - return dict_factory(result) + # fast path for the common case + if dict_factory is dict: + return { + f.name: _asdict_inner(getattr(obj, f.name), dict_factory) + for f in fields(obj) + } + else: + result = [] + for f in fields(obj): + value = _asdict_inner(getattr(obj, f.name), dict_factory) + result.append((f.name, value)) + return dict_factory(result)
And measured it with this benchmark:
Details
from dataclasses import dataclass, asdict import timeit @dataclass class Foo: x: int @dataclass class Bar: x: Foo y: Foo z: Foo @dataclass class Baz: x: Bar y: Bar z: Bar foo = Foo(42) bar = Bar(foo, foo, foo) baz = Baz(bar, bar, bar) print(timeit.timeit(lambda: asdict(baz), number=50_000))
And it does indeed seem to be slower than the current
mainbranch. So let's put this on hold for now, and try again once PEP-709 has been decided on (hopefully it'll be accepted!).That's two great
dataclassesoptimisations landed in 3.12 -- thanks again @DavidCEllis!Reacted by David Ellis and Itamar Oren- added 2 commits that reference this issue
on May 10, 2023
Feature or enhancement
Improve the performance of asdict/astuple in common cases by making a shortcut for common types that are unaffected by deepcopy in the inner loop. Also special casing for the default
dict_factory=dictto construct the dictionary directly.The goal here is to improve performance in common cases without significantly impacting less common cases, while not changing the API or output in any way.
Pitch
In cases where a dataclass contains a lot of data of common python types (eg: bool/str/int/float) currently the inner loops for
asdictandastuplerequire the values to be compared to check if they are dataclasses, namedtuples, lists, tuples, and then dictionaries before passing them todeepcopy. This proposes to special case and shortcut objects of types wheredeepcopyreturns the object unchanged.It is much faster for these cases to instead check for them at the first opportunity and shortcut their return, skipping the recursive call and all of the other comparisons. In the case where this is being used to prepare an object to serialize to JSON this can be quite significant as this covers most of the remaining types handled by the stdlib
jsonmodule.Note: Anything that skips deepcopy with this alteration is already unchanged as
deepcopy(obj) is objis always True for these types.Currently when constructing the
dictfor a dataclass, a list of tuples is created and passed to thedict_factoryconstructor. In the case where thedict_factoryconstructor is the default -dict- it is faster to construct the dictionary directly.Previous discussion
Discussed here with a few more details and earlier examples: https://discuss.python.org/t/dataclasses-make-asdict-astuple-faster-by-skipping-deepcopy-for-objects-where-deepcopy-obj-is-obj/24662
Code Details
Types to skip deepcopy
This is the current set of types to be checked for and shortcut returned, ordered in a way that I think makes more sense for
dataclassesthan the original ordering copied from thecopymodule. These are known to be safe to skip as they are all sent to_deepcopy_atomic(which returns the original object) in thecopymodule.Function changes
With that added the change is essentially replacing each instance of
inside
_asdict_inner, withInstances of subclasses of these types are not guaranteed to have
deepcopy(obj) is objso this checks specifically for instances of the base types.Performance tests
Test file: https://gist.github.com/DavidCEllis/a2c2ceeeeda2d1ac509fb8877e5fb60d
Results on my development machine (not a perfectly stable test machine, but these differences are large enough).
Main
Current Main python branch:
Modified
Modified Branch:
Linked PRs
dataclasses.asdictfor the common case #104364