perf: stop encoding terms for adapters that store them natively#48
Open
MikaAK wants to merge 2 commits into
Open
perf: stop encoding terms for adapters that store them natively#48MikaAK wants to merge 2 commits into
MikaAK wants to merge 2 commits into
Conversation
Cache.TermEncoder.encode/2 ran on every put and decode/1 on every get, in
the generic Cache macro, for every adapter. Most adapters store Erlang
terms natively, so the round trip was pure overhead — and the decode
dominated the lookup it was attached to. On a 500k-entry ETS table, a bare
:ets.lookup of a ~10KB body costs 63 ns while binary_to_term of the same
body costs ~29,000 ns.
Adapters now declare how they store values via an optional
native_term_storage?/1 callback. Cache resolves it at compile time from the
adapter module attribute, so there is no runtime branch on the hot path.
ETS, Agent, PersistentTerm, ConCache and Counter store terms; Redis stores
bytes and DETS owns a durable on-disk format, so both keep encoding. The
callback is optional and defaults to encoding, so third-party adapters are
unaffected.
ETS with :rehydration_path also keeps encoding — it dumps the table with
:ets.tab2file/2 and reads it back on the next boot, so it is a durable
format with the same backward-compatibility constraint as DETS.
HashRing and RefreshAhead call the encoder directly, bypassing the macro, so
they resolve the capability against the adapter they wrap. MultiLayer needed
no change: each layer is a full Cache module that owns its own encoding, so a
[ETS, Redis] stack already stores a term in ETS and bytes in Redis.
Benchmarks (500k entries, ~10KB body, 20k iterations):
get/1 29,981 ns -> 6,211 ns (4.8x)
put/2 23,542 ns -> 10,804 ns (2.2x)
The win holds across payload sizes — a small map goes from 2,105 ns to 302 ns.
This also fixes three bugs that were consequences of encoding unconditionally:
* ConCache.get_or_store/3 wrote through ConCache directly, bypassing the
encode in put/3, so a later get/1 raised trying to binary_to_term/1 a
raw term.
* decode/1 used Jason.decode!/1 on any brace-wrapped binary, so a value
like "{oops}" raised Jason.DecodeError on read. It now falls back to
returning the binary unchanged.
* The raw ETS API this adapter exposes (match_object/1, select/1,
tab2list/0, foldl/2) saw opaque encoded binaries instead of the terms
that were put.
PersistentTerm also regains the zero-copy read it exists for.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #48 +/- ##
==========================================
- Coverage 83.49% 83.20% -0.29%
==========================================
Files 23 23
Lines 636 661 +25
==========================================
+ Hits 531 550 +19
- Misses 105 111 +6 ☔ View full report in Codecov by Harness. |
…al lock The refresh-ahead lock test released the :global lock immediately after a get that had just spawned a refresh task. That task was still racing toward :global.set_lock, so it could reach it after the del_lock, take the freed lock, and refresh the value the very next assertion expects to be untouched — failing with "locked:locked_key" where "original" was expected. The race was always there; making get/1 faster widened the window by getting the test process to del_lock sooner, and it surfaced on a loaded CI runner. Wait for the spawned task to lose the race and clean itself up while the lock is still held, so nothing is in flight when the lock is released.
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.
The problem
Cache.TermEncoder.encode/2was called in the genericCachemacro on everyputanddecode/1on everyget. Because it lives in the macro, it applied to every adapter — including the ones that store Erlang terms natively, where the round trip buys nothing.Cache.ETSCache.AgentCache.PersistentTermCache.ConCacheCache.CounterCache.DETSCache.RedisThe decode is not a rounding error. On a 500k-entry ETS table, a bare
:ets.lookupof a ~10KB body costs 63 ns whilebinary_to_termof that same body costs ~29,000 ns. The decode was ~460x the cost of the lookup it was attached to, on the hot read path, for nothing.The design
Adapters now declare how they store values through an optional callback on the
Cachebehaviour:The
Cachemacro resolves it at compile time from@cache_adapterand the configured opts, then compiles either a real encode or an identity shim — so there is zero runtime branch on the read/write path.The callback is optional and defaults to
false(encode), so any third-party adapter keeps working with no change.Two details worth calling out:
sandbox?: trueresolves against the adapter it stands in for, not againstCache.Sandbox. Otherwise a value would round-trip differently in test than in prod.{app, key}, MFA, zero-arity fun) fall back to encoding. The capability can't be read at compile time there, so it takes the safe path, which is exactly the old behaviour.Composite adapters
HashRing/RefreshAheadcall the encoder directly, bypassing the macro. They now resolve the capability against the adapter they wrap, viaTermEncoder.maybe_encode/3andmaybe_decode/3.MultiLayerneeded no change. Each layer is a fullCachemodule that owns its own encoding, so a[ETS, Redis]stack already stores a term in the ETS layer and bytes in the Redis layer. There's now a test asserting exactly that, reading both underlying stores directly, plus one asserting a value backfilled from Redis lands in ETS as a term.Benchmark
500k entries, ~10KB structured body (13,505 B encoded), 20k iterations, measured through the public API on
mainvs this branch:get/1put/2The win is not confined to large payloads:
Cache.PersistentTermalso regains its zero-copy read —:erts_debug.same/2now confirms two reads return the same term rather than two independently decoded copies.The DETS decision: keep encoding
Cache.DETSstill encodes.:dets.insert/2takes terms and would serialise them itself, so this is a redundant serialisation — and I kept it anyway.Every
.detsfile written by an earlier version holdsterm_to_binaryblobs as its values, and those files outlive the upgrade — that is the entire point of a disk cache. Switching to native terms would make the first read after upgrading hand back a raw binary where the caller expects a term. Silently. With no error to trace it back to.I considered a read-side fallback (try
binary_to_term, fall back to raw) and rejected it: it is unbackable-out-of, it guesses, and it would misfire on any value that is legitimately a binary. A version marker means rewriting the file format for an adapter whose whole job is to not do that.The cost of keeping the encode is one redundant serialisation of data already headed for a disk write, which the I/O dominates. The cost of dropping it is corrupting every existing on-disk cache. That trade isn't close. There's a test asserting a file written in the old format still decodes correctly.
The same reasoning applies to
Cache.ETSwith:rehydration_path— that option dumps the table via:ets.tab2file/2and reloads it on the next boot, making it a durable format with the identical constraint. Sonative_term_storage?/1returnsfalsefor ETS when that option is set. Plain ETS is unaffected. (The repo's existing rehydration test, which writes a hand-encoded value into the dump file, still passes untouched — which is itself the proof.)compression_levelWhen set, encoding is deliberate, so
compression_levelforces encoding on any adapter, native or not. The user asked to compress; they get compression.compression_levelis currently dead config onmain. It is not declared in any adapter'sopts_definition, soNimbleOptionsrejects it on compile-time opts; and via runtime opts the macro's@compression_levelis only read when opts are a compile-time list, so it resolves toniland never reaches the encoder. It's unreachable on every path. I have not fixed that here — it's an orthogonal bug and this PR is already load-bearing. The new code honours it correctly wherever it becomes reachable.The
TermEncoderlandmine (fixed)encode/2regex-checks binaries against~r/^{.*}$/and passes JSON-looking ones through unencoded, while encoding everything else.decode/1then has to guess. It's genuinely broken, and it's worse than "guessing":The first is a silent type change. The second is a crash on read for any brace-wrapped binary that isn't valid JSON.
I did not build on top of it.
decode/1now usesdecode_json/1(which falls back to returning the binary unchanged) instead ofJason.decode!/1, so the crash is gone. The remaining JSON-passthrough asymmetry only affects byte-storing adapters now, and its existing tests still pass — a full fix means picking a wire format and is a breaking change to Redis-stored data, which belongs in its own PR.Other bugs this fixes
Both were direct consequences of encoding unconditionally:
ConCache.get_or_store/3+get/1raised.get_or_store/3writes through ConCache directly, bypassing the encode input/3, so the matchingget/1tried tobinary_to_term/1a raw term and blew up. It works now.Cache.ETSexposesmatch_object/1,select/1,tab2list/0,foldl/2— all of which saw opaque encoded binaries instead of the terms that wereput. You could not match on a value. They now see real terms.Breaking change status
Minor-breaking. Version bumped to 0.5.0.
get/1,put/3,delete/1round-trip exactly as before — the public API is unchanged.:ets.lookup/2,:persistent_term.get/1,ConCache.get/2) or through the raw ETS API. Those now return terms — which is what they were always meant to return.Tests
21 new tests (
test/cache/native_term_storage_test.exs+ 2 inmulti_layer_test.exs) covering: capability resolution per adapter; native adapters storing the raw term (asserted by reading:ets/Agent/:persistent_term/ConCachedirectly); arbitrary terms round-tripping (maps, structs, tuples, keyword lists, pids, floats, nil, raw binaries); Redis still storing an encoded binary; DETS still storing an encoded binary and old-format files still decoding; aMultiLayerof[ETS, Redis]doing the right thing per layer;compression_levelstill compressing on a native adapter; and both crash-on-read regressions.Two
hash_ring_testfixtures were updated: they hand-encoded a value to simulate a remoteCache.ETSnode, which is now the wrong fixture — a real ETS node returns the term.A latent race this surfaced (second commit)
The first CI run failed one test —
RefreshAhead"global lock prevents refresh while lock is held" — which passes locally. It's a real race in the test, not flake to be re-run away:The
getspawns a refresh task that is still racing toward:global.set_lock. Nothing sequences it against thedel_lockon the very next line, so it can reachset_lockafter the lock is freed, acquire it, and refresh the value the next assertion expects to be untouched — failing with exactly the observedright: {:ok, "locked:locked_key"}.The race predates this PR. Making
get/1~5x faster widened the window by getting the test process todel_locksooner, and a loaded CI runner did the rest.Fixed by draining the in-flight task while the lock is still held — it loses the race, cleans up its tracker entry, and nothing is in flight when the lock is released. That makes the assertion deterministic rather than merely likely.