Skip to content

gh-161524: Speed up copying sparse dictionaries - #161636

Open
localzet wants to merge 2 commits into
python:mainfrom
localzet:contrib/gh-161524-sparse-dict-copy
Open

localzet wants to merge 2 commits into
python:mainfrom
localzet:contrib/gh-161524-sparse-dict-copy

Conversation

@localzet

@localzet localzet commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Copy live entries directly into a compact keys table when copying a combined dictionary, rather than reinserting entries through the generic merge path.

  • Preserve insertion order, key/value identities, and cached hashes without invoking user-defined __hash__ methods again.
  • Skip deleted entries with an inner while loop and stop once ma_used entries have been copied. This avoids scanning trailing tombstones.
  • Remove the density-based fallback: the compact-copy path now handles extremely sparse dictionaries without needing an additional branch.
  • Preserve the copying behavior of dict and frozendict subclasses with overridden iterators, and dictionary watcher notifications.

Tests

Verified on the contributor fork with:

  • Debug build: test_dict, test_capi.test_dict, test_capi.test_watchers (251 tests).
  • Debug free-threaded build: the same tests.
  • Reference-leak checks: -R 3:3.
  • AddressSanitizer: the same tests (detect_leaks=0; heap-leak detection is covered separately by the reference-leak checks).
  • Additional randomized stress tests (including collisions and concurrent copying), and front/back/middle/random deletion patterns.
  • Lint.

Verification: https://github.com/localzet/cpython/actions/runs/42806793734

Performance

Compared the original version of this PR (the for loop with a 1/64 density cutoff) with the new while loop without the cutoff, on Linux x86-64 with GCC PGO + LTO. Times are ns/op for source.copy(), averaged across two measurement passes on the same runner, lower is better.

Keys (live / original) Type Old PR Updated PR Speedup
1 / 100,000 str 50,994 34,470 1.48x
1 / 100,000 int 63,264 38,040 1.66x
1,000 / 100,000 str 68,234 37,027 1.84x
1,000 / 100,000 int 79,684 38,783 2.05x
1 / 500,000 str 250,884 162,578 1.54x
30,000 / 100,000 str 233,586 240,909 0.97x

There is a small measured slowdown on some denser string-key cases. This is one CI run, not a controlled multi-machine study; the full raw logs include other densities, both key types, and both benchmark passes.

Three-variant benchmark (regular and PGO/LTO): https://github.com/localzet/cpython/actions/runs/42801499895

Deletion pattern benchmark (regular build): https://github.com/localzet/cpython/actions/runs/42803337022

AI assistance was used to explore the implementation and prepare the experimental variants and test automation. The proposed changes were validated with the tests and benchmarks above.

@bedevere-app bedevere-app Bot added awaiting review type-feature A feature request or enhancement labels Nov 8, 2026
@methane

methane commented Nov 9, 2026

Copy link
Copy Markdown
Member

Keep the existing path for very sparse tables (fewer than approximately 1/64 live entries), where scanning tombstones is less beneficial.

Could you benchmark the case where can_clone_sparse_dict() always returns true and compare the results?

I'm interested in whether this branch is really necessary. If the performance is comparable, there's no need to keep the existing code path. If there's a significant difference, I'd also like to reconsider the similar code in dictresize().

localzet commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Update (2026-11-10): The results below were for the earlier for-loop implementation. After methane's suggestion, I tested a while loop that stops after the live entries, removed the 1/64 cutoff, and updated the PR. The new PGO/LTO comparison and conclusions are in this follow-up.


I tried making can_clone_sparse_dict() always return true and compared it with the current 1/64 threshold. The regular-build benchmark shows a clear regression for very sparse tables.

Some dict.copy() results (ns/op, averages of two measurement passes, lower is better):

Live / original entries Key type 1/64 threshold Always clone
1 / 1,000 str 442 724
100 / 100,000 str 36,892 66,633
1 / 100,000 str 35,066 58,266
1,000 / 500,000 str 299,353 570,655
1 / 100,000 int 57,736 71,241

The difference is small for less sparse cases, but the fallback is noticeably faster once almost all entries have been deleted. Both implementations passed the targeted dict tests, watcher tests, and the sparse-dict stress checks.

I used the same Ubuntu 24.04 runner for each comparison, with measurements in A–B–B–A order. The benchmark and workflow are available here: https://github.com/localzet/cpython/actions/runs/42595205328

The GCC PGO/LTO comparison has now completed, and it gave a different result. For example, with str keys and 1/100,000 live entries, the threshold build measured 50.1 μs/op while always-clone measured 32.9 μs/op. With int keys and 1,600/100,000 live entries, however, the threshold build measured 49.6 μs/op versus 70.0 μs/op for always-clone, even though both variants should take the clone path at that density. These differences indicate build/code-generation or measurement effects, so I don't think this experiment alone establishes the optimal cutoff. I'll leave the code unchanged while investigating with more controlled measurements.

The two configurations and raw logs are in the same workflow run linked above. I'll examine dictresize() separately, since its resize path has different constraints.

Comment thread Objects/dictobject.c Resolved
@methane

methane commented Nov 9, 2026

Copy link
Copy Markdown
Member

When the number of live entries is extremely small, performance is dominated by the code that skips empty entries. The loop in _PyDict_Next() is written as follows:

cpython/Objects/dictobject.c

Lines 3308 to 3310 in d0c69f8

while (i < n && entry_ptr->me_value == NULL) {
entry_ptr++;
i++;

On the other hand, this PR uses the following code:

for (Py_ssize_t i = 0; i < oldkeys->dk_nentries; i++) {
    if (src[i].me_value != NULL) {

If we changed the for loop condition to n < used and skipped empty entries in an inner loop using while (src[i].me_value == NULL) i++;, couldn't we achieve the same performance as _PyDict_Next()?

You're using Codex, right? Codex is good at investigating this kind of low-level implementation detail, so please have it thoroughly investigate whether we really need to introduce another copy path just for this.

@localzet
localzet force-pushed the contrib/gh-161524-sparse-dict-copy branch from 5dae4e0 to 540cab8 Compare November 10, 2026 10:40
@localzet
localzet force-pushed the contrib/gh-161524-sparse-dict-copy branch from 540cab8 to 92f75ad Compare November 10, 2026 11:26

Copy link
Copy Markdown
Contributor Author

Thanks for the suggestion. The inner while made a significant difference.

I compared three variants: the original for loop with the 1/64 cutoff, while with that cutoff, and while without it. Both the regular build and GCC PGO/LTO runs favor the last variant in the very sparse cases. For example, with PGO/LTO, dict.copy() went from 68.2 μs to 37.0 μs for 1,000 string keys out of 100,000, and from 79.7 μs to 38.8 μs for integer keys. At 1/100,000 live entries, the improvements were 51.0 → 34.5 μs (str) and 63.3 → 38.0 μs (int). A denser 30,000/100,000 str case was slightly slower (233.6 → 240.9 μs).

Stopping after ma_used entries also avoids scanning trailing deleted entries. I measured deletions from the front, back, middle, and random positions, as well as both key types.

I've updated this PR to use the inner while and removed can_clone_sparse_dict() and the extra fallback. The new version passed debug, free-threaded, reference-leak, and ASan tests, plus additional patterned and randomized stress checks.

Benchmarks: https://github.com/localzet/cpython/actions/runs/42801499895 and https://github.com/localzet/cpython/actions/runs/42803337022
Verification: https://github.com/localzet/cpython/actions/runs/42806793734

And yes, Codex helped prepare the benchmark variants and tests. I reviewed the changes and checked the results before updating the PR.

Copy link
Copy Markdown
Contributor Author

I looked for standard-library paths where deletions can leave a sparse dict that is subsequently copied:

  • linecache.checkcache(None) snapshots cache.copy().keys(), while stale cache entries are removed with cache.pop().
  • weakref.WeakValueDictionary.copy() snapshots self.data.copy(), while weakref callbacks remove dead entries from that underlying dict.

I added a standalone benchmark on a separate fork branch (no change to this PR) that exercises these existing methods after constructing dictionaries with many deleted entries. I compared the PR base (4575fb9) with the current implementation (eea9266) in regular GCC builds on the same Ubuntu runner, in A–B–B–A order. The numbers below are averages of the two pass medians, in µs/op:

Operation Entries (live/original) Base PR Change
dict.copy() 100/1,000 2.40 0.88 2.72x faster
dict.copy() 1,000/10,000 22.97 8.20 2.80x faster
linecache.checkcache() 100/1,000 9.58 8.24 14.0% lower
linecache.checkcache() 100/10,000 12.97 11.33 12.6% lower
WeakValueDictionary.copy() 100/1,000 71.47 68.35 4.4% lower
WeakValueDictionary.copy() 100/10,000 73.65 71.92 2.3% lower

In both standard-library examples, the underlying dict.copy() itself improves more than the full method, as expected. The smaller full-method differences, especially in WeakValueDictionary.copy(), need more repetitions before drawing strong conclusions. Dense-dict timings were broadly comparable in this run.

These are constructed cache states, not measurements of how frequently real applications enter them. They establish concrete call sites where this path is possible, but do not by themselves settle the concern about the maintenance cost of the extra C code.

Benchmark script · raw workflow results

Copy link
Copy Markdown
Contributor Author

I ran a follow-up GCC PGO/LTO comparison and instrumented a few official pyperformance workloads to get a better sense of how frequently the new path is used.

With PGO/LTO, the speedups in constructed sparse cases are still clear (mean of two medians per variant, same runner, A–B–B–A):

Operation Base PR
dict.copy(), 100 / 1,000 live 2.24 µs 0.80 µs
dict.copy(), 1,000 / 10,000 live 22.55 µs 9.32 µs
linecache.checkcache(), 100 / 1,000 live 8.09 µs 6.59 µs
WeakValueDictionary.copy(), 100 / 10,000 live 52.69 µs 47.88 µs

PGO/LTO workflow

I also added temporary counters to a separate research build of the base commit. They record (a) calls reaching copy_lock_held_untracked() and (b) opportunities to clone a non-empty combined source directly into an empty combined destination in dict_dict_merge(). The two groups can overlap and should not be added together.

In four completed pyperformance workloads (logging, json_dumps, sqlalchemy_declarative, xml_etree), I observed 0 sparse sources among 289,175 direct-copy calls and 0 among 896,558 merge-clone candidates. The django_template benchmark could not run on this development interpreter because it encountered a missing distutils module, so I excluded it. The counts also include benchmark setup and worker initialization; they are not a general frequency estimate for Python applications.

Instrumented pyperformance run

So the speedup is reproducible when a dict is sparse, but I still don't have evidence that this is common in representative workloads. I understand the concern about adding code to a rarely used path. I'll leave the PR unchanged rather than expand it further without a stronger use case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting review type-feature A feature request or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants