Repository navigation
Add fast path to deepcopy() for empty list/tuple/dict/set #121192
Description
Activity
@lgeiger No worries about conflicts with my PR, I can rebase if needed. I will add benchmarks for empty containers as well (I never imagined they would actually matter).
More important is perhaps whether your patch slows down the other cases. Do you have numbers for that? And perhaps also a benchmark for the pydantic models.
Reacted by Lukas Geiger and Raymond HettingerMore important is perhaps whether your patch slows down the other cases. Do you have numbers for that?
I extended the above benchmarks to add a case which doesn't include any empty iterables. It does make this case about 1.09x slower. I'm not sure what kind of noise we'd expect from such a microbenchmark though, since I'm currently just running it on an M3 macbook on battery only.
And perhaps also a benchmark for the pydantic models.
Unfortunately I can't add a proper benchmark for the pydantic case, since it doesn't built with 3.14 yet. But here's a comparison of the example code with 3.12 which should illustrate the problem:
import pyperf runner = pyperf.Runner() setup = """ from pydantic import BaseModel, Field class Foo(pydantic.BaseModel): bar: list[int] = [] baz: dict[str, int] = {} class FooField(pydantic.BaseModel): bar: list[int] = Field(default_factory=list) baz: dict[str, int] = Field(default_factory=dict) """ runner.timeit(name="init pydantic defaults", stmt=f"Foo()", setup=setup) runner.timeit(name="init pydantic default factory", stmt=f"FooField()", setup=setup)
init pydantic defaults: Mean +- std dev: 1.19 us +- 0.01 us init pydantic default factory: Mean +- std dev: 351 ns +- 12 nsOnly profiling the
Foo()calls yields the following flamegraph whereasFooField()doesn't have a need to calldeepcopy():

I could reduce the slowdown of the default case to 1.06x with a slightly simpler version of the patch that actually performs even better for empty lists, sets and dicts but is a bit slower for tuples and frozen sets (although still much faster than the baseline). Let me know if you'd like me to open a PR, maybe then it's easier to discuss with the concrete code.
+------------------------------------+----------+------------------------+--------------------------+ | Benchmark | baseline | optimize-empty-copy | less-optimize-empty-copy | +====================================+==========+========================+==========================+ | deepcopy dict | 1.86 us | 2.01 us: 1.09x slower | 1.97 us: 1.06x slower | +------------------------------------+----------+------------------------+--------------------------+ | deepcopy empty tuple | 285 ns | 48.7 ns: 5.85x faster | 55.0 ns: 5.18x faster | +------------------------------------+----------+------------------------+--------------------------+ | deepcopy empty frozenset | 1.47 us | 49.7 ns: 29.55x faster | 73.8 ns: 19.92x faster | +------------------------------------+----------+------------------------+--------------------------+ | deepcopy empty list | 323 ns | 81.8 ns: 3.95x faster | 75.1 ns: 4.30x faster | +------------------------------------+----------+------------------------+--------------------------+ | deepcopy empty set | 1.46 us | 84.7 ns: 17.18x faster | 77.6 ns: 18.75x faster | +------------------------------------+----------+------------------------+--------------------------+ | deepcopy empty dict | 326 ns | 84.8 ns: 3.84x faster | 78.0 ns: 4.18x faster | +------------------------------------+----------+------------------------+--------------------------+ | deepcopy multiple empty containers | 4.13 us | 1.17 us: 3.51x faster | 1.32 us: 3.14x faster | +------------------------------------+----------+------------------------+--------------------------+ | Geometric mean | (ref) | 5.47x faster | 5.20x faster | +------------------------------------+----------+------------------------+--------------------------+Why check
len(x) == 0instead ofnot x?Why check
len(x) == 0instead ofnot x?Good idea, changed in ba7298c. This makes it slightly faster as well. In my quick benchmark the PR would make the default case only 1.03x slower (see updated numbers in the PR)
- addedperformancePerformance or resource usagePerformance or resource usagestdlibStandard Library Python modules in the Lib/ directoryStandard Library Python modules in the Lib/ directory
on Jul 1, 2024 I think that from 4x to 20x speed up for this quite common case is a good enough reason to slow down for 1.09x in the slow path.
I am +1 on this.
@sobolevn Out of curiosity, what is the typical case for deep copy? Deep cooy for non empty containers or Deepcopy for empty containers? I think the slowdown is not cheap if the nonempty container is more frequently called in the real world.
- addedtype-featureA feature request or enhancementA feature request or enhancement
on Sep 28, 2025 Closing due to #121193 (comment)
deepcopy()can be surprisingly slow when called with empty containers like lists, tuples, dicts, sets or frozensets.Adding a fast path for this case similar to #114266 would significantly speed up such cases by about 4x - 28x while having little impact on the default path and not adding too much complexity. With such a patch the following benchmarking script would show a significant speedup compared to
main:This might conflict with @eendebakpt efforts in #91610 or could be something that should be added to the proposed C version as well.
For context, I noticed this when using pydantic models with mutable default values where pydantic would deep copy the default value upon class instantiation. E.g.:
To be fair the proper fix in this case would be not to use a mutable default value in pydantic and switch to
pydantic.Field(default_factory=list)similar to dataclasses instead which is much faster. But potentially there might be other scenarios where deepcopying empty iterables might be common.I'm happy to make a PR unless it conflicts with the efforts going on in #91610.
Linked PRs
deepcopy()for empty list/tuple/dict/set #121193