Skip to content

Cache decoded map keys in the C unpacker - #768

Closed
sebastianbreguel wants to merge 1 commit into
msgpack:mainfrom
sebastianbreguel:perf/map-key-cache
Closed

sebastianbreguel wants to merge 1 commit into
msgpack:mainfrom
sebastianbreguel:perf/map-key-cache

Conversation

@sebastianbreguel

Copy link
Copy Markdown

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.

  • The cache turns on after 32 keys in one unpackb() call or Unpacker, so small messages never touch it.
  • Keys enter the cache in unpack_callback_map_item(), right after PyUnicode_InternInPlace(), so only keys of complete pairs get in. This matters on free-threaded builds, where interned strings are immortal.
  • Only ASCII keys up to 64 bytes are cached, so a cached key's data is exactly its raw bytes.
  • The result is the same object the current code returns: the interned key.

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. With raw=True (no decoding) it runs 39% faster, and the C unpacker was barely faster than json.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:

Workload Before After Change
unpackb(), 300 PR objects (4.8 MB) 8.52 ms 6.29 ms -26%
Unpacker, same objects fed in 64 KiB chunks 7.47 ms 5.67 ms -24%
unpackb(), 10k maps with 9 keys 570 ns/msg 580 ns/msg +1.7%
unpackb(), 10k maps with 1 key 71.1 ns/msg 71.7 ns/msg +0.8%

Packing 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:

import json, timeit, msgpack
packed = msgpack.packb(json.load(open("prs.json")))
print(min(timeit.repeat(lambda: msgpack.unpackb(packed), number=3, repeat=7)) / 3)

How to test

make cython && python setup.py build_ext -i -f
pytest test && MSGPACK_PUREPYTHON=1 pytest test

The new tests in test/test_unpack.py also pass on current main, so they pin today's behavior: repeated keys (equal and interned), Unpacker across 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 the Unpacker is reinitialized or freed. To revert, revert this commit.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant