Stacktrace#09meta/rocksdb

RocksDB Keeps Score on Whether to Spin or Sleep

Pause for 1 microsecond, yield for up to 100, then sleep. Which of those a writer tries is learned at runtime, one write in 256 at a time.

9 min read

Every write in RocksDB that has to wait for another write runs the same little function. It spins, then it yields, then it gives up and sleeps. The interesting part is that the middle step is a bet, and RocksDB keeps a running score of whether the bet is paying off.

Writers wait for each other a lot

RocksDB is the LSM storage engine under a big chunk of the database world: MyRocks, CockroachDB's old storage layer, TiKV, Kafka Streams state stores, and plenty more. Its write path has a design that makes waiting a very hot operation.

When several threads call Put at once, they don't each grab a lock and write. They line up. The first one in becomes the group leader, collects the batches of everyone queued behind it, writes them all to the WAL in one go, and then tells the followers they're done (or tells them to apply their own memtable inserts in parallel). Followers spend their time in one place: JoinBatchGroup, waiting for the leader to flip their state.

That wait is usually short. A leader writing a WAL record and handing out work is microseconds of work. So the question "how should a thread wait for a few microseconds?" isn't academic here. It's on the path of every concurrent write.

All of it funnels into one function, WriteThread::AwaitState in db/write_thread.cc.

Step one: 200 pauses

The function opens with its whole plan written as a three-line comment, then the first step:

db/write_thread.cc (meta/rocksdb)C++
// 1. Busy loop using "pause" for 1 micro sec
// 2. Else SOMETIMES busy loop using "yield" for 100 micro sec (default)
// 3. Else blocking wait
 
// On a modern Xeon each loop takes about 7 nanoseconds (most of which
// is the effect of the pause instruction), so 200 iterations is a bit
// more than a microsecond.  This is long enough that waits longer than
// this can amortize the cost of accessing the clock and yielding.
for (uint32_t tries = 0; tries < 200; ++tries) {
  state = w->state.load(std::memory_order_acquire);
  if ((state & goal_mask) != 0) {
    return state;
  }
  port::AsmVolatilePause();
}

Look at the reasoning in that comment. It isn't "spin 200 times because 200 felt right". It's: a pause loop iteration costs about 7ns, so 200 of them is about a microsecond, and a microsecond is roughly what it costs to read the clock and call yield. Spin for as long as the next step would cost you anyway, then move on. Cheap waits never touch the clock at all.

Notice the word SOMETIMES in capitals on step 2. That's where it gets fun.

Why not just sleep on a mutex?

Because waking up a sleeping thread is slow. The author measured it, and left the numbers in the comment:

db/write_thread.cc (meta/rocksdb)C++
// If we're only going to end up waiting a short period of time,
// it can be a lot more efficient to call std::this_thread::yield()
// in a loop than to block in StateMutex().  For reference, on my 4.0
// SELinux test server with support for syscall auditing enabled, the
// minimum latency between FUTEX_WAKE to returning from FUTEX_WAIT is
// 2.7 usec, and the average is more like 10 usec.  That can be a big
// drag on RockDB's single-writer design.  Of course, spinning is a
// bad idea if other threads are waiting to run or if we're going to
// wait for a long time.  How do we decide?

10 microseconds on average from "wake up" to "awake", when the thing you're waiting for might take 5. So spinning with sched_yield is tempting. But spinning on a busy machine steals the core from someone who has real work, possibly the very leader you're waiting on.

The comment that follows splits waits into three kinds: short and uncontended (spin, obviously), long (sleep, obviously), and short but contended (depends). And then it says the clever bit out loud: you can tell whether the core is contended by timing your own yield. If nobody else wants the core, sched_yield comes straight back, well under a microsecond. If it takes longer, some other thread actually ran. A slow yield is the signal (lines 98-129).

"Slow" is a tunable, write_thread_slow_yield_usec, default 3 microseconds. The yield phase is capped by write_thread_max_yield_usec, default 100 (options.h). Three slow yields in one wait and the thread stops spinning and goes to sleep. One isn't enough, the comment says, because "solitary slow yields are pretty common (low-priority small jobs ready to run)".

Time spent waiting for the group leader ->Credit below 0 and not sampled: skip the yield loopPause200 x ~7 ns, ~1 µscredit ≥ 0or sampled?yesYield loopUp to 100 µs, 3 slow yields and outBlock on mutexFutex wake 2.7 to 10 µsSampled waits report back:+2^17 if released, -2^17 if notyield_creditOne atomic int32 per wait siteRead by the gate
How a RocksDB writer waits for its group leader. The yield loop runs only while this wait site's credit is zero or above, or on the 1 in 256 waits that are sampled. Sampled waits report back into the credit.

The SOMETIMES: a running score per wait site

Here is the gate in front of the yield loop, with the lines that set it up:

Lines 133 to 147 of db/write_thread.cc. Comments explain that yield credit is added when yielding succeeds before timing out and decreased otherwise. The code declares update_ctx and would_spin_again as false and sampling_base as 256, then, if max_yield_usec_ is above 0, sets update_ctx to Random::GetTLSInstance()->OneIn(sampling_base) and enters the yield loop if update_ctx is true or yield_credit.load(std::memory_order_relaxed) is at least 0.
The gate. One wait in 256 is sampled, and the rest only yield while the credit is zero or above.Source: meta/rocksdb, db/write_thread.cc L133-L147 at 02815ee

yield_credit is a single std::atomic<int32_t> inside an AdaptationContext. There's one context per place in the code that waits, declared static right above the function that uses it, like static WriteThread::AdaptationContext jbg_ctx("JoinBatchGroup"); (line 403). So every thread in the process that waits in JoinBatchGroup shares one score.

The rule:

  • Credit is zero or positive: yielding has been working lately. Everyone tries the yield loop.
  • Credit is negative: yielding has been a waste. Skip it, go straight from the 200 pauses to the mutex.
  • Either way, 1 wait in 256 is sampled (OneIn(256), a thread-local RNG). A sampled wait runs the yield loop even when the credit says don't, and its outcome gets written back.

That last bullet is what keeps it from getting stuck. If the score went negative during a noisy minute and nobody ever yielded again, it would stay negative forever. The sampled 1-in-256 waits keep testing the road not taken. If you've seen an epsilon-greedy bandit, it's that, in about ten lines of C++.

There's one more way the score gets updated: a wait that hits three slow yields sets update_ctx = true on the spot, sampled or not (lines 175-180). Bad news is reported immediately, good news only on a sample.

The update is one line

After the wait finishes, a thread that's supposed to report does this:

db/write_thread.cc (meta/rocksdb)C++
v = v - (v / 1024) + (would_spin_again ? 1 : -1) * 131072;

would_spin_again is true only if the leader released us during the yield loop. Timed out, or bailed after three slow yields, and it's false.

So each report shaves 1/1024 off the old score and then adds or subtracts 2^17. That's an exponential moving average of +1 / -1 outcomes in fixed point, all integer math, no floats. The sign is all the gate reads: positive means more recent samples said "yielding worked" than "it didn't". The comment above it does the overflow math: the score can't drift past 2^17 x 2^10 = 2^27, comfortably inside an int32 (lines 196-203).

Lines 192 to 206 of db/write_thread.cc. Inside if (update_ctx), a comment says that because the update is sample based it is ok if a thread overwrites the updates by other threads, so the update does not have to be atomic. The code loads yield_credit with relaxed ordering, a comment explains the fixed point decay of 1/1024 and that a step of 2^17 keeps the credit under 2^27, then v = v - (v / 1024) + (would_spin_again ? 1 : -1) * 131072 and a relaxed store.
The whole update at the pinned commit, comments included. Both of them come up below.Source: meta/rocksdb, db/write_thread.cc L192-L206 at 02815ee

And it's racy on purpose. Load, compute, store, with relaxed atomics, no CAS loop. Two threads updating at once means one update is lost. The code says so: "Since our update is sample based, it is ok if a thread overwrites the updates by other threads" (lines 193-194). A lost vote in a running average is noise. A CAS loop on a counter every waiter hammers would be the contention you were trying to avoid.

The decay that grew

That v - (v / 1024) has a history. The adaptive yield came in with concurrent memtable writes in 2015 (7d87f02, off by default then; enable_write_thread_adaptive_yield defaults to true today). In 2017 the undefined behavior sanitizer in RocksDB's crash test flagged a signed integer overflow in exactly this spot, -2147471630 + -16384 (issue 2559).

Here is that spot in the fix's own diff (58410ae):

Diff of db/write_thread.cc in commit 58410ae. Removed: v = v + (v / 1024) + (would_spin_again ? 1 : -1) * 16384; and ctx->value.store(v, std::memory_order_relaxed);. Added: a comment explaining the 1/1024 decay and that a step of 2^17 keeps the credit under 2^27, then v = v - (v / 1024) + (would_spin_again ? 1 : -1) * 131072; and yield_credit.store(v, std::memory_order_relaxed);.
The 2017 fix. The removed line adds v / 1024 back in; the new one takes it away.Source: meta/rocksdb, commit 58410ae, db/write_thread.cc

A plus where the minus should be. The "decay" grew the score by 0.1% on every update instead of shrinking it, so whichever way it leaned, it leaned harder each time until it ran off the end of an int32. The fix flipped the sign, bumped the step to 2^17 and wrote the 2^27 bound into the comment.

Size of yield_credit, log scale2^162^202^242^31int32 limit2^2701,0002,0003,0004,0005,0006,000Reports into the score, all saying the same thingOverflows atreport 4,9792015: v + v/1024 ± 2^142017 fix: v - v/1024 ± 2^17
The score's size when every report says the same thing (log scale). The fixed update levels off at 2^27, as its comment promises. The 2015 line compounds by 0.1% per report and runs off the end of an int32 after 4,979 reports.
The bug was faster

The fix commit includes benchmarks. With the sign corrected, CPU use went down a little (1323% to 1301% on the 16-thread run), and throughput went down too: about 1.6% at 16 threads, 2% at 64. The commit's own explanation: "Apparently the bug had favored using the spinning more often." They shipped the slower, correct version.

What it teaches

  • Spin for as long as the next step costs. 200 pauses is about a microsecond, about the cost of a clock read plus a yield. That's the whole justification, and it's in the comment.
  • Measure contention with the thing you're already doing. No getrusage, no scheduler stats. A yield that takes 3 microseconds or more means someone else ran.
  • An adaptive heuristic needs exploration. Without the 1-in-256 samples, one bad stretch could switch yielding off for good.
  • A sloppy counter is fine when it's a vote. Lost updates to a moving average don't matter. Contention on it would.
  • Sanitizers find math bugs, not just memory bugs. A sign error in a heuristic doesn't crash anything. It just quietly makes decisions. UBSan only noticed because the number finally overflowed.

The whole thing is about 150 lines, more than half of them comments, and the comments are the best part. Someone measured futex wake latency on their test server, wrote it down, and built a tiny learning loop around it.

Sources:

RocksDB is dual licensed under GPLv2 and Apache 2.0; excerpts above are quoted from the source with credit.