Fix cache line false sharing on per-IO-thread stat counters#14907
Conversation
🤖 Augment PR SummarySummary: This PR reduces cache-line false sharing for per-IO-thread counters by moving them out of
🤖 Was this summary useful? React with 👍 or 👎 |
| server.repl_good_slaves_count = 0; | ||
| server.last_sig_received = 0; | ||
| memset(server.io_threads_clients_num, 0, sizeof(server.io_threads_clients_num)); | ||
| memset(io_thread_stats, 0, sizeof(io_thread_stats)); |
There was a problem hiding this comment.
memset(io_thread_stats, 0, ...) clears redisAtomic members via raw byte writes; with the C11 _Atomic backend this can be undefined/fragile compared to using the atomicSet* APIs. Consider initializing the atomic members using the atomic helpers instead of bulk memset.
Severity: medium
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
| typedef struct __attribute__((aligned(CACHE_LINE_SIZE))) { | ||
| redisAtomic long long io_reads_processed; /* Number of read events processed */ | ||
| redisAtomic long long io_writes_processed; /* Number of write events processed */ | ||
| int clients_num; /* Number of clients assigned */ |
There was a problem hiding this comment.
clients_num appears to be updated by the main thread (e.g., assignClientToIOThread() / keepClientInMainThread()), so the comment “Each thread updates its own entry” isn’t strictly accurate. With clients_num sharing the same cache line as the per-thread counters, these main-thread updates could still cause cache-line bouncing for busy IO threads.
Severity: low
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
CE Performance Automation : step 1 of 2 (build) DONE.This comment was automatically generated given a benchmark was triggered.
You can check a comparison in detail via the grafana link |
CE Performance Automation : step 2 of 2 (benchmark) RUNNING...This comment was automatically generated given a benchmark was triggered. Started benchmark suite at 2026-05-27 08:49:02.440283 and took 20976.615196 seconds up until now. In total will run 380 benchmarks. |
|
makes sense, i guess this optimization take effect when the IO thread is the bottleneck, right? and would you like to have a look for the following variables let each io thread have separate stas too, to check if there is an improvement. |
themavik
left a comment
There was a problem hiding this comment.
Colocating clients_num with the per-thread read/write atomics in io_thread_stats[] keeps hot counters off the same cache line as colder fields. nit: IOThreadStats only helps if the struct uses the same CACHE_LINE_SIZE padding pattern as other globals in server.h.
Yes I assume the same, when IO threads are concurrently writing their stat counters. For the scalar variables, since they are shared by all threads, true contention but not false sharing, shall we address it in a different PR after seeing the benchmark results first? |
yes, it is contention instead of false sharing. I was thinking we separate them into the stats (IOThreadStats) of different thread to avoid contention, it is okay to verify it in future PRs. |
| * Stat counters are updated by their respective IO thread; | ||
| * clients_num is updated by the main thread but infrequently. */ | ||
| typedef struct __attribute__((aligned(CACHE_LINE_SIZE))) { | ||
| redisAtomic long long io_reads_processed; /* Number of read events processed */ |
There was a problem hiding this comment.
why not put them in struct IOThread
There was a problem hiding this comment.
Currently IOThread is just used in iothread.c and IOThreads[] is declared as static instead of extern. Adding the stats counters into IOThread would also make them share cache lines with the other fields. Ideally the stats counters don't want to share cache lines even with clients_num i think, but the performance impact of doing so is probably neglectable.
There was a problem hiding this comment.
i think we can remove static key word, I think we should put every variable on its own cache line, it is too waste. besides, besides, we may also add some fields into IOThreadStats, and generally only the io thread itself accesses its' fields
for clients_num, only the main thread accesses and changes them, maybe it is better to put them in server struct.
There was a problem hiding this comment.
Are you suggesting a struct layout like below?
typedef struct __attribute__((aligned(CACHE_LINE_SIZE))) {
redisAtomic long long io_reads_processed;
redisAtomic long long io_writes_processed;
redisAtomic long long net_input_bytes;
redisAtomic long long net_output_bytes;
// Other per-thread entries that are under contention
} IOThreadStats;This sounds fair to me.
@fcostaoliveira are the benchmark results available now?
There was a problem hiding this comment.
yes, but i prefer to put them in IOThreads. or put IOThreadStats into IOThread, don't like to have separate variables, cc @sundb
we can handle the io_reads_processed and io_writes_processed first, and mitigate the contention of net_input/output_bytes variables in the future PR.
In this PR it's fine too. Just want to see how the benchmark results look like with the current change. |
|
The initial benchmark used 200 clients with small values, which may not create enough IO contention — results varied across sessions. I retested with 1000 clients and 256-byte values to shift the bottleneck to IO threads. Results are more consistent across repeated runs. Setup: WSL2, 22 cores. Server: --io-threads 8, pinned to cores 0-8. Client: pinned to cores 9-21. 10 runs each.
|
ShooterIT
left a comment
There was a problem hiding this comment.
generally LGTM, please revert unrelated format changes.
|
@fcostaoliveira could you please trigger some benchmark runs, especially when IO threads are bottleneck, maybe |
|
Hi @YangboLong, ran the io-threads=8 string suite against this PR. Setup
Results — single-datapoint, sorted by Δ σ-confirmation on the −12.6 % outlier Re-ran
→ σ-disjoint (max(PR)=433,471 < min(BL)=466,106), Δ_mean = −12.05 %. Reproduces. This is the only test in the suite that σ-confirmed a regression. Worth noting: it's directionally inconsistent with its sibling 5-key MGET variants on the same value-size axis ( Summary
Verdict from our side: net positive, but the σ-confirmed −12 % on |
|
@fcostaoliveira do you want to trigger another mget-1KiB-5keys-pipeline-10 run with the corrected pipeline config, as you mentioned in the other pr? |
|
Hi @YangboLong — re-ran with the corrected pipeline config (the spec was missing Setup
Results — corrected pipeline=10 operating point
→ σ-disjoint (max(PR)=804,411 < min(BL)=874,394). Δ_median = −10.85 %, Δ_mean = −10.64 %. Variance is tight on both sides (cv < 1.3 %), so the gap isn't a noise artifact. Compared to the May 6 numbers (which ran at effective pipeline=1 due to the spec bug): throughput shifted up ~2× as expected for pipeline-10, and the regression magnitude narrowed slightly (−12.6 % → −10.8 % median) but it didn't go away. It also remains specific to this |
|
@YangboLong could you merge unstable into this branch, please? There are ~110 commits since this PR branched, including #15133 (batched MGET/MSET dict prefetch) – directly on the hot path here, so it may be inflating the baseline rather than the PR regressing. Once you do it I can run this specific benchmark again :) |
d85abc5 to
4db925b
Compare
|
@paulorsousa please test again. |
|
@YangboLong rebase did it. Same 5 datapoints per side on
Δ_median = +0.96 %, Δ_mean = +0.77 % — within noise (ranges overlap, cv < 0.7 % on both sides). The previous −10.85 % was the PR branch missing #15133 from the baseline. LGTM from a perf standpoint on this test 👍 |
…ne-10 (v0.3.18) (#394) * fix(test-suites): add missing --pipeline 10 to mget-1KiB-5keys-pipeline-10 The clientconfig.arguments for `memtier_benchmark-5Mkeys-string-mget-1KiB-5keys-pipeline-10` was missing the `--pipeline 10` flag (sibling specs at other value sizes — 100B, 512B — all carry it). Without the flag, memtier_benchmark defaulted to pipeline=1, so the test was not exercising the pipelined-MGET path its name advertises. This made the run produced for redis/redis#14907 misleading: the −12.6 % "regression" on this spec was at effective pipeline=1, while every other pipeline-10 MGET sibling on the same matrix showed +1–7 % wins under proper pipelining. Bump to 0.3.18. * revert stale pyproject.toml version bump (keep --pipeline 10 spec fix) The branch bumped pyproject 0.3.17->0.3.18, which now conflicts with main (already at 0.3.23). Restore pyproject.toml to main so only the test-suite fix (adding the missing --pipeline 10 to the pipeline-10 mget spec) remains. --------- Co-authored-by: fcostaoliveira <filipe@redis.com>
stat_io_reads_processed[]andstat_io_writes_processed[]were per-IO-thread arrays insidestruct redisServerthat suffer from false sharing. This PR moves the two stat counters into the IOThread struct, which is already__attribute__((aligned(CACHE_LINE_SIZE))). Each IO thread's counters now sit on a separate cache line, eliminating the cross-thread contention.server.c,networking.c,replication.cNote
Medium Risk
Touches multi-threaded I/O statistics accounting and makes
IOThreadsglobally visible, which could introduce subtle concurrency or linkage issues if any thread ID/indexing assumptions are wrong.Overview
Moves per-IO-thread read/write processed counters out of
struct redisServerand into the cache-line-alignedIOThreadstruct (io_reads_processed,io_writes_processed) to reduce false sharing under threaded I/O.Updates all call sites to increment/read/reset these counters via
IOThreads[tid](including main-thread replication reads) and exposesIOThreads[]as a non-staticglobal with anexterndeclaration inserver.h.Reviewed by Cursor Bugbot for commit 4db925b. Bugbot is set up for automated code reviews on this repo. Configure here.