Skip to content

Commit 72d2aaf

Browse files
authored
fix: Minor performance improvements in hot paths (#263)
* fix: size _incr_decr key in bytes, not codepoints. The struct format spec for incr/decr was sized as COMMANDS[command]['struct'] % len(key), but the value packed into that slot is keybytes (UTF-8). For a non-ASCII key the byte length exceeds the codepoint count, the format spec under-sizes the field, and struct.pack silently truncates the encoded key. The header keylen advertises the full byte length, so the resulting wire packet is shorter than the server expects -- the server blocks reading the "missing" key bytes while the client blocks waiting for a response, deadlocking the connection. * fix: ensure serialize() always returns bytes. For int/long values serialize() did value = str(value), producing a Python 3 str. When that string is large enough to cross the COMPRESSION_THRESHOLD it falls into self.compression.compress(value), which rejects str with "TypeError: a bytes-like object is required". Encode to bytes inline in the int/long branches so every assignment to value in the type-dispatch already produces bytes; the binary, text, and pickler branches all already do. Reachable today via e.g. set('k', 10 ** 200): str(10 ** 200) is 201 chars, exceeds the 128-byte threshold, hits the compress branch, and raises before any caller-side coercion runs. The downstream text_type-to-bytes guards in _set_add_replace, set_multi, and set_multi_cas only cover the small-value path where compression is skipped; they cannot save the compress branch because the crash is inside serialize() itself. Drop the now-redundant guard in _set_add_replace. The function's :rtype: str was already inaccurate -- the text, binary, and pickler paths returned bytes; only int/long returned str. Update it to bytes now that the contract actually holds across every branch. * perf: replace COMMANDS struct format strings with precompiled packers. Each entry in the COMMANDS table now carries a precompiled struct.Struct for the fixed-size prefix of its wire format (HEADER_STRUCT plus any leading "extras" bytes); variable-length tails (key, value, auth payloads) are concatenated as bytes after packer.pack(...). Previously each call site built a fresh format string from HEADER_STRUCT + the per-command 'struct' suffix and substituted in the per-call lengths via "%". This costs a string concat plus a % format on every call, and -- because the resulting format string embeds the per-call lengths ('17s', '23s', ...) -- defeats the LRU cache that the struct module keeps over compiled formats. Once the working set of distinct lengths exceeds that cache (100 entries by default in CPython), every call recompiles its format from scratch. The hot build loops in get_multi, set_multi, and set_multi_cas paid this on every key; additionally bind packer.pack and the relevant COMMANDS / MAGIC / STATUS lookups to locals. Microbench (500-key get_multi request build, no network): 1181us -> 441us, ~2.7x. * perf: use pickle.loads / pickle.dumps when self.(un)pickler is the default. The serialize / deserialize paths construct a BytesIO and a Pickler or Unpickler instance per call to support a user-overridable pickler class. When self.(un)pickler is pickle.(Un)Pickler -- the default -- this is equivalent to pickle.dumps / pickle.loads, which are implemented in C (_pickle) and skip the Python-level allocation. Microbench (round-trip a small dict): BytesIO+Unpickler.load 3.08us vs pickle.loads 1.59us, ~1.94x.
1 parent 1d980c9 commit 72d2aaf

2 files changed

Lines changed: 220 additions & 137 deletions

File tree

0 commit comments

Comments
 (0)