home-mixer: lower post-candidate cache compression from zstd 6 to zstd 1 (5.9x less CPU, same size) - #70
Open
gauss2302 wants to merge 1 commit into
Conversation
RedisPostCandidateCacheSideEffect compresses ~2 MB of serde_json on every cache-miss For You request (MaxPostsToCache = 750 candidates, each a 54-field PostCandidate). The payload is highly repetitive because all 750 share one schema, so zstd's fast strategy already captures nearly all of the redundancy and the higher levels buy very little. Compression was 84% of this side effect's CPU. Serialize-and-compress, per request, measured on a reconstructed 750-candidate slate: serde_json 1.86 ms + zstd 6 (current) 11.43 ms total, 373,127 B on the wire + zstd 1 (this change) 3.33 ms total, 356,580 B on the wire That is 70.8% less CPU and 4.4% fewer bytes. Level sweep: level bytes ms 1 356,580 1.83 2 379,683 2.51 3 405,679 3.55 6 373,127 9.56 9 356,381 13.98 Levels 2-4 are both slower and larger than level 1 here, so this is not a size/speed tradeoff: level 1 dominates them on both axes. Held across three text-entropy models (24-word, 2000-word, random) and two seeds. Since the entry only lives for REDIS_TTL_SECONDS = 180, request-path CPU dominates the value of a marginally smaller blob. zstd frames are self-describing and the cache key does not encode the compression level, so CachedPostsQueryHydrator reads entries written by hosts on either side of a rolling deploy. Added a regression test pinning that so the level stays retunable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gauss2302
force-pushed
the
perf/post-candidate-cache-compression
branch
from
August 16, 2026 16:35
167a2a9 to
1813e13
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.
Lowers
ZSTD_COMPRESSION_LEVELinRedisPostCandidateCacheSideEffectfrom 6 to 1, and adds a regression test pinning that the cache stays readable across a level change.Net effect: 70.8% less CPU in this side effect, and 4.4% fewer bytes on the wire.
Why
RedisPostCandidateCacheSideEffectruns on every For You request not already served from cache (enable=is_prod() && !query.has_cached_posts). It serializes up toMaxPostsToCache= 750PostCandidates to JSON and zstd-compresses them inline on the request path.That payload is ~2 MB and unusually repetitive: 750 records sharing one 54-field schema, 45 of those
Option, so field-name scaffolding repeats every ~2.7 KB. zstd's fast strategy already captures nearly all of it.Compression was 84% of this side effect's CPU. Serialize-and-compress, per request:
serde_jsonaloneLevel sweep on the same payload:
A full 1–9 sweep straight through libzstd (same payload, outside the Rust harness) shows the same shape: levels 2, 4 and 5 all land between these, and 2–4 are each both slower and larger than level 1.
Since the entry only lives for
REDIS_TTL_SECONDS= 180, request-path CPU dominates the value of a marginally smaller blob — but here level 1 is smaller as well as faster, so there is no tradeoff to make.Why level 1 and not level 3
Compressed size is not monotone in level on this payload. Levels 2–4 are both slower and larger than level 1; level 3 is in fact the worst ratio on the whole scale, 8.7% larger than level 6 while still costing ~2× level 1's CPU.
Because that is surprising, I checked it is not an artifact of the reconstructed text distribution. Varying only how
tweet_textis generated:tweet_textmodelReproduced on two independent seeds. Text entropy barely moves the result because post text is only ~15% of the payload; the schema scaffolding dominates.
Compatibility
No cache key version bump and no coordinated rollout:
zstd::decode_allreads a level-6 frame and a level-1 frame with the same call.redis_client::cached_posts_keydoes not encode the compression level.CachedPostsQueryHydratoris unaffected.The new test
payload_written_at_any_zstd_level_round_tripspins this across levels 1/3/6/9 so the constant stays freely retunable.How this was measured
home-mixer/ships noCargo.tomland depends on unpublishedxai_*crates, so it cannot be built from this repository and I could not benchmark the side effect in place. Instead the payload was reconstructed field-for-field fromhome-mixer/models/candidate.rs— all 54 fields in declaration order, 45Option, the nested 26-headPhoenixScores— and driven through the realserde_jsonandzstdcrates. The population is shaped like whatget_candidates_to_cacheactually caches: only the firstTOP_K_CANDIDATES_TO_SELECT= 50 candidates carry post-selection-hydrated fields such asbrand_safety_verdictandtweet_type_metrics; the other 700 carryNonethere.Median of 25 runs after 5 warmup runs, release build, Apple M-series. Happy to re-run in-tree if you have a harness that can link
home-mixer; the measurement code is available on request.Considered and rejected:
skip_serializing_ifonPostCandidateI also prototyped
#[serde(skip_serializing_if = "Option::is_none")]across the 45Optionfields. Two findings argue against it:It buys almost nothing on top of this change. It shrinks the JSON 22% (2.00 MB → 1.56 MB), but on the hot path that is 3.33 ms → 3.03 ms — 0.30 ms, or 2.6% of the original 11.43 ms. Nearly all of the win in this side effect was the compression level, not the payload size.
The naive version silently breaks the read path.
serdesubstitutesNonefor a missingOption, but it does not substitute an emptyVecorString.tweet_text,ancestors,tombstone_ancestor_idsandancestor_usershave no#[serde(default)]incandidate.rs, so omitting them when empty makes an old reader fail outright withmissing field 'ancestors'. Landing this safely would need a two-phase deploy: first a release that adds#[serde(default)]to those fields, then a later one that starts omitting them. That is a lot of ceremony for 0.30 ms.Raising it in case the tradeoff looks different with production data.
Aside, unrelated to this change
While building the compatibility test I found that
serde_json's default float parser is not bit-exact: it writes the correct shortest representation but reads back a value 1 ULP away for 13% of randomf64s (std'sstr::parse::<f64>is exact;serde_json'sfloat_roundtripfeature fixes it). So Phoenix scores that go through this Redis cache come back marginally different from what was computed on a cached-posts request. The relative error is ~1e-16 and I would not change anything for it — noting it only because it is a real property of the current cache path and it is easy to trip over when writing an equality test against it.Risk
Low. One constant, no format change, no API change, no behaviour change on the read path. Worst case is more Redis memory per slate if production data compresses less like the reconstruction than expected — though the measurement says level 1 is smaller, not larger. Either way it is visible immediately in the existing
compressed_sizefield of thetracing::debug!at the end ofside_effect.🤖 Generated with Claude Code