Repository navigation
Cache decoded map keys in the C unpacker - #768
Closed
sebastianbreguel wants to merge 1 commit into
Closed
sebastianbreguel wants to merge 1 commit into
sebastianbreguel wants to merge 1 commit into
Conversation
Repeated map keys are decoded and interned once per occurrence. Reuse the interned key from a small per-context cache once a context has seen 32 keys, and fill the cache only with keys of complete pairs.
sebastianbreguel
force-pushed
the
perf/map-key-cache
branch
from
November 4, 2026 12:45
ad589de to
8fa9e09
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This makes
unpackb()about 25% faster on data where the same map keys repeat. The cost is about 80 lines of C and roughly 10 ns more per small message (+1.7% on a 9-key map).I know you chose plain interning over a memo dict in #373/#381. This keeps interning and only skips decoding keys it has already interned. If the trade-off is not worth it for msgpack, I'm fine closing this; I'd also take a pointer to a shape you would prefer.
What
The C unpacker now remembers the map keys it has already decoded. When it reads a key, it looks the raw bytes up in a small cache (256 slots) and reuses the interned str it built last time. On a miss, it decodes as before.
unpackb()call orUnpacker, so small messages never touch it.unpack_callback_map_item(), right afterPyUnicode_InternInPlace(), so only keys of complete pairs get in. This matters on free-threaded builds, where interned strings are immortal.Why
I unpacked 300 pull requests from the GitHub API (4.8 MB of msgpack). That is 106,085 map keys, but only 144 distinct ones:
"id","url","state"and friends, over and over.unpackb()decodes and interns every one of them. Withraw=True(no decoding) it runs 39% faster, and the C unpacker was barely faster thanjson.loads()on the same data as JSON (8.85 ms vs 9.87 ms).Benchmark
CPython 3.12, macOS arm64, best of 7, with the base and patched builds alternated 3 times:
unpackb(), 300 PR objects (4.8 MB)Unpacker, same objects fed in 64 KiB chunksunpackb(), 10k maps with 9 keysunpackb(), 10k maps with 1 keyPacking does not change. The small-map runs overlap, so I read them as about 10 ns per 9-key message: the cost of checking each key against the threshold.
To reproduce, save any list of JSON objects, for example
gh api "repos/numpy/numpy/pulls?state=all&per_page=100" > prs.json:How to test
The new tests in
test/test_unpack.pyalso pass on currentmain, so they pin today's behavior: repeated keys (equal and interned),Unpackeracross objects,raw=True, more distinct keys than cache slots, the empty key, and the error paths with the cache already warm (max_str_len, invalid UTF-8, reuse after an error). The last test checks that a key whose value never arrives is not interned. It caught a bug in my first version, which interned keys at decode time, when run on 3.13t.I ran the suite on 3.12 (extension and pure Python) and on 3.13t. A 3.13 tracemalloc loop over 2,000 rounds of unique keys grows exactly as much as on
main.Risk
The change is limited to the C extension's map-key path. The pure Python fallback does not change. Each unpack context gets a 2 KiB array, and the cache holds up to 256 key references until the
unpackb()call returns or theUnpackeris reinitialized or freed. To revert, revert this commit.