Skip to content

perf(cache): stop caching the encoder's spare capacity - #252

Merged
henry40408 merged 2 commits into
mainfrom
refactor/cache-entry-memory
Aug 28, 2026
Merged

perf(cache): stop caching the encoder's spare capacity#252
henry40408 merged 2 commits into
mainfrom
refactor/cache-entry-memory

Conversation

@henry40408

@henry40408 henry40408 commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Prompted by Cloudflare's DNS cache memory optimization write-up. Of its five techniques, three are specific to caching parsed records — noadd already stores wire format, which is where that post ends up. The first one applies directly, and its measurement methodology applies more.

The problem

Message::to_vec reserves 512 bytes for every answer it encodes, whatever the answer's size:

let mut buffer = Vec::with_capacity(512);

A typical A response is under 100 bytes. Handing that Vec straight to DnsCache::insert had every entry hold the difference for its lifetime — and twice over once prepare_cached_response stored its TTL-decremented snapshot, which is a second copy of the response.

The change

Both the response and the snapshot are Box<[u8]>. store_patched_bytes takes a slice, so the snapshot is copied to an exact-sized box rather than adopting the caller's buffer. Shrinking costs one memcpy on the cache-miss path, which has already been to the network.

Measurement

tests/cache_memory_bench.rs is new: a tracking allocator over a fill whose record-type mix follows published resolver traffic (56% A, 25% AAAA, 19% TXT), reporting live bytes and allocations per entry. An integration test is its own binary and does not link the mimalloc main.rs installs, so it is free to install its own.

On 10,000 entries, against a 106-byte average wire payload:

before after
cold entry 926.8 B 504.6 B
patched snapshot 512.2 B 106.2 B
served entry 1439.0 B 610.8 B (-57.6%)
overhead vs wire 13.58x 5.76x

The remainder is moka's per-entry bookkeeping and the key, which is where any further work would have to look — not the response bytes.

The file's one non-benchmark test, an_entry_does_not_retain_its_caller_s_spare_capacity, asserts the property rather than the number: it measures both paths twice, once with a buffer sized to the response and once with a heavily over-reserved one, and asserts only on the difference. Comparing the two cancels moka's bookkeeping, so the threshold encodes nothing about how moka is implemented. Observed failing before the change (a 64 KiB buffer cost 66992 bytes to cache where a tight one cost 7256) and passing after.

Cache-hit throughput is unchanged — 4.06M qps before, 4.11M after, mean of three cache_hit_bench runs each, within the ~2% run-to-run spread.

Unrelated: a yanked chacha20

cargo deny started failing on this branch for a reason that has nothing to do with it — chacha20 0.10.0 was yanked, and Cargo.toml/Cargo.lock are untouched here, so main fails the same way. 0.10.1 is yanked too, and the only unyanked 0.10.x is 0.10.2, published 2026-08-27, which is inside this repo's 7-day cooldown on new dependency releases.

Second commit scopes a deny.toml ignore to that exact version rather than relaxing yanked for the whole graph, so any other crate being yanked still fails the check. Removing the entry reproduces the CI failure locally; restoring it gives advisories ok. Tracked in #253, to be dropped from 2026-09-03.

Not done here

The cache is still bounded by entry count rather than bytes (DnsCache::new(10_000)), so the resident cost of a full cache varies with the traffic it saw — a moka weigher would make that an operator-facing MB figure. CacheKey.domain is still a String, duplicated across a domain's ClientResponseProfile variants. Both noted in ARCHITECTURE.md rather than attempted.

🤖 Generated with Claude Code

`Message::to_vec` reserves 512 bytes for every answer it encodes, whatever
the answer's size, and a typical A response is under 100. Handing that `Vec`
straight to the cache had every entry hold the difference for its lifetime,
twice over once `prepare_cached_response` stored its TTL-decremented
snapshot alongside it.

Store both as `Box<[u8]>`, and take the snapshot by slice so it is copied to
an exact-sized box rather than adopting the caller's buffer. Shrinking costs
one memcpy on the cache-miss path, which has already been to the network.

Adds `tests/cache_memory_bench.rs`: a tracking allocator over a fill whose
record-type mix follows published resolver traffic, reporting live bytes and
allocations per entry. On 10,000 entries a served entry goes from 1439 to
611 bytes against a 106-byte average payload — the remainder is moka's
bookkeeping and the key. Its one non-benchmark test asserts the property
rather than the number, comparing what a tight buffer and an over-reserved
one cost to cache; only the difference is asserted on, so the threshold
encodes nothing about moka's internals.

Cache-hit throughput is unchanged: 4.06M qps before, 4.11M after (mean of
three runs each, within the ~2% run-to-run spread).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.89%. Comparing base (236e514) to head (c34d807).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #252      +/-   ##
==========================================
- Coverage   90.89%   90.89%   -0.01%     
==========================================
  Files          29       29              
  Lines       10735    10738       +3     
==========================================
+ Hits         9758     9760       +2     
- Misses        977      978       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

`chacha20 0.10.0` was yanked, and so was 0.10.1. The only unyanked 0.10.x is
0.10.2, published 2026-08-27 — inside the 7-day cooldown this repo keeps on
newly published dependency versions, since fresh releases are the primary
supply-chain attack vector.

Scope the ignore to that exact version rather than relaxing `yanked` for the
whole graph, so any other crate being yanked still fails the check. Revisit
from 2026-09-03; tracked in #253.

chacha20 is transitive — nothing in Cargo.toml names it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@henry40408
henry40408 marked this pull request as ready for review August 28, 2026 12:32
@henry40408
henry40408 merged commit e19a18e into main Aug 28, 2026
5 checks passed
@henry40408
henry40408 deleted the refactor/cache-entry-memory branch August 28, 2026 12:33
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