Skip to content

Commit dedcb58

Browse files
author
Andrew Baptist
committed
db: Smooth out IO from flushing L0 and compaction
This PR adds a smoother which monitors the average time for flushing and compaction and paces future flush / compaction loops to attempt to have a consistent IO rate at all times rather than being spikey. Spikey IO can result in saturating the underlying device which then slows down writes to the WAL. By having a consistent rate of flushing and compaction the P99 latency is greatly reduced.
1 parent 5fdb3ea commit dedcb58

6 files changed

Lines changed: 253 additions & 1 deletion

File tree

compaction.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2623,8 +2623,20 @@ func (d *DB) runCompaction(
26232623
for key, val := iter.First(); key != nil || !c.rangeDelFrag.Empty() || !c.rangeKeyFrag.Empty(); {
26242624
splitterSuggestion := splitter.onNewOutput(key)
26252625

2626+
startTime := time.Now()
2627+
estimatedSize := uint64(0)
2628+
d.smoother.startWork()
2629+
26262630
// Each inner loop iteration processes one key from the input iterator.
26272631
for ; key != nil; key, val = iter.Next() {
2632+
// Every 4MB of writing, smooth out the writes.
2633+
if tw != nil && tw.EstimatedSize()-estimatedSize > 4*1024*1024 {
2634+
d.smoother.finishWork(time.Since(startTime), true)
2635+
d.smoother.startWork()
2636+
startTime = time.Now()
2637+
estimatedSize = tw.EstimatedSize()
2638+
}
2639+
26282640
if split := splitter.shouldSplitBefore(key, tw); split == splitNow {
26292641
break
26302642
}
@@ -2686,10 +2698,12 @@ func (d *DB) runCompaction(
26862698
}
26872699
if tw == nil {
26882700
if err := newOutput(); err != nil {
2701+
d.smoother.finishWork(time.Since(startTime), false)
26892702
return nil, pendingOutputs, err
26902703
}
26912704
}
26922705
if err := tw.Add(*key, val); err != nil {
2706+
d.smoother.finishWork(time.Since(startTime), false)
26932707
return nil, pendingOutputs, err
26942708
}
26952709
}
@@ -2718,8 +2732,10 @@ func (d *DB) runCompaction(
27182732
splitKey = key.UserKey
27192733
}
27202734
if err := finishOutput(splitKey); err != nil {
2735+
d.smoother.finishWork(time.Since(startTime), false)
27212736
return nil, pendingOutputs, err
27222737
}
2738+
d.smoother.finishWork(time.Since(startTime), true)
27232739
}
27242740

27252741
for _, cl := range c.inputs {

db.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ type DB struct {
254254

255255
commit *commitPipeline
256256

257+
smoother Smoother
258+
257259
// readState provides access to the state needed for reading without needing
258260
// to acquire DB.mu.
259261
readState struct {

open.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,12 @@ func Open(dirname string, opts *Options) (db *DB, _ error) {
7878
logRecycler: logRecycler{limit: opts.MemTableStopWritesThreshold + 1},
7979
closed: new(atomic.Value),
8080
closedCh: make(chan struct{}),
81+
smoother: Smoother{enabled: opts.Experimental.SmoothWriteIO},
8182
}
8283
d.mu.versions = &versionSet{}
8384
d.atomic.diskAvailBytes = math.MaxUint64
8485
d.mu.versions.diskAvailBytes = d.getDiskAvailableBytesCached
86+
d.smoother.start()
8587

8688
defer func() {
8789
// If an error or panic occurs during open, attempt to release the manually

options.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ func (o *IterOptions) getLogger() Logger {
223223
// Specifically, when configured with a RangeKeyMasking.Suffix _s_, and there
224224
// exists a range key with suffix _r_ covering a point key with suffix _p_, and
225225
//
226-
// _s_ ≤ _r_ < _p_
226+
// _s_ ≤ _r_ < _p_
227227
//
228228
// then the point key is elided.
229229
//
@@ -571,6 +571,10 @@ type Options struct {
571571
// ability to optionally schedule additional CPU. See the documentation
572572
// for CPUWorkPermissionGranter for more details.
573573
CPUWorkPermissionGranter CPUWorkPermissionGranter
574+
575+
// SmoothWriteIO will attempt to write to disk at a constant rate from
576+
// compaction and flushing rather than as fast as it can.
577+
SmoothWriteIO bool
574578
}
575579

576580
// Filters is a map from filter policy name to filter policy. It is used for
@@ -1146,6 +1150,9 @@ func (o *Options) Parse(s string, hooks *ParseHooks) error {
11461150
// a backwards incompatible change. Instead, leave in support for parsing the
11471151
// key but simply don't parse the value.
11481152

1153+
// TODO: Remove this line
1154+
o.Experimental.SmoothWriteIO = true
1155+
11491156
switch {
11501157
case section == "Version":
11511158
switch key {
@@ -1279,6 +1286,8 @@ func (o *Options) Parse(s string, hooks *ParseHooks) error {
12791286
o.Experimental.ReadSamplingMultiplier, err = strconv.ParseInt(value, 10, 64)
12801287
case "table_cache_shards":
12811288
o.Experimental.TableCacheShards, err = strconv.Atoi(value)
1289+
case "enable_flush_smoothing":
1290+
o.Experimental.SmoothWriteIO = true
12821291
case "table_format":
12831292
switch value {
12841293
case "leveldb":

smoother.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package pebble
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"sync"
7+
"sync/atomic"
8+
"time"
9+
)
10+
11+
const decayRate = 0.99
12+
const sampleRate = 10 * time.Millisecond
13+
const numSamples = 100
14+
const minUtilization = 0.1
15+
16+
// Smoother will attempt to smooth out a process that runs multiple iterations.
17+
// The goal is not to have it run faster or slower, but simply with pace itself
18+
// to run evenly over time rather than being bunchy.
19+
//
20+
// Each "thread" can be in one of three states, Running, Idle, Sleeping.
21+
// * Running means the thread is currently working (and writing to disk).
22+
// * Idle means this thread is not doing anything as there is no work to do.
23+
// * Sleeping means the thread has active work to do, but is sleeping to smooth load.
24+
//
25+
// The goal of the smoother is to turn all Idle work into Sleeping work while
26+
// preserving the amount of Running work that is being done. The sleeping work
27+
// can be somewhat evenly placed between small Running tasks. The perfect
28+
// smoother would result in the flush / compaction write bandwidth to be a
29+
// constant.
30+
//
31+
// Some simplifying assumptions are made which could be improved upon.
32+
// * All tasks runs for a similar amount of time (estimatedIterDurationNs).
33+
// * All tasks create a similar amount of IO.
34+
// * Different types of tasks (compaction vs flush) are similar cost.
35+
//
36+
// After a Smoother is created, it must be started by calling start.
37+
type Smoother struct {
38+
enabled bool
39+
runningCount int32
40+
sleepingCount int32
41+
stopper chan struct{}
42+
43+
mu struct {
44+
sync.Mutex
45+
estimatedIterDurationNs float64
46+
estimatedUtilization float64
47+
}
48+
}
49+
50+
func (s *Smoother) start() {
51+
s.mu.estimatedUtilization = 1.0
52+
s.stopper = make(chan struct{})
53+
54+
go func() {
55+
ticker := time.NewTicker(sampleRate)
56+
defer ticker.Stop()
57+
58+
var sampleRunning, sampleSleeping int32
59+
var totalSamples int32
60+
61+
for {
62+
select {
63+
case <-s.stopper:
64+
return
65+
66+
case <-ticker.C:
67+
totalSamples++
68+
// NB: The number of running and sleeping can be >1. This allows
69+
// utilization to also be greater than 1. Once util is >1 the smoother
70+
// is disabled. In a single-threaded system this would never occur,
71+
// however our flushing and compaction is multi-threaded, however our
72+
// flushing and compaction is multi-threaded.
73+
sampleRunning += atomic.LoadInt32(&s.runningCount)
74+
// We only care if at least 1 job is sleeping.
75+
if atomic.LoadInt32(&s.sleepingCount) > 1 {
76+
sampleSleeping++
77+
}
78+
79+
// Every 100 iterations, update the estimated utilization under lock.
80+
if totalSamples == numSamples {
81+
// utilRunning may be bigger than 1, utilSleeping is always less than 1.
82+
utilRunning := float64(sampleRunning) / float64(totalSamples)
83+
utilSleeping := float64(sampleSleeping) / float64(totalSamples)
84+
85+
s.mu.Lock()
86+
// The sleep time is multiplied by the estimated prior utilization
87+
// because sleep work should not change the utilization either up or
88+
// down.
89+
util := utilRunning + utilSleeping*s.mu.estimatedUtilization
90+
updatedEstimate := float64(s.mu.estimatedUtilization)*decayRate + float64(util)*(1-decayRate)
91+
// Prevent the utilization from getting too low. At 10% utilization
92+
// there should already be sufficient smoothing. If it gets lower the
93+
// sleeps can get too long, and it may take too long to recover. On
94+
// most systems it will stay above this.
95+
s.mu.estimatedUtilization = math.Max(updatedEstimate, minUtilization)
96+
s.mu.Unlock()
97+
98+
sampleRunning = 0
99+
sampleSleeping = 0
100+
totalSamples = 0
101+
}
102+
time.Sleep(sampleRate)
103+
}
104+
}
105+
}()
106+
}
107+
108+
// stop is called to stop them main thread running.
109+
func (s *Smoother) stop() {
110+
close(s.stopper)
111+
}
112+
113+
// startWork is called before work is started so the smoother can tell if there
114+
// is active work being done.
115+
func (s *Smoother) startWork() {
116+
atomic.AddInt32(&s.runningCount, 1)
117+
}
118+
119+
// finishWork records that work has completed and returns the amount of time the
120+
// process should sleep after this work. Typically, shouldSleep should be set to
121+
// true, but in error cases or at the end of a larger iteration loop it can be
122+
// set to false. Setting to false means to use the measurements in calculations,
123+
// but don't actually sleep.
124+
func (s *Smoother) finishWork(workDuration time.Duration, shouldSleep bool) time.Duration {
125+
atomic.AddInt32(&s.runningCount, -1)
126+
s.mu.Lock()
127+
s.mu.estimatedIterDurationNs = float64(s.mu.estimatedIterDurationNs)*decayRate + float64(workDuration)*(1-decayRate)
128+
sleepTime := time.Duration(0)
129+
if s.mu.estimatedUtilization < 1 {
130+
sleepTime = time.Duration(s.mu.estimatedIterDurationNs * ((1 / s.mu.estimatedUtilization) - 1))
131+
}
132+
s.mu.Unlock()
133+
fmt.Println(sleepTime, s.mu.estimatedIterDurationNs, s.mu.estimatedUtilization)
134+
135+
// Duration to sleep if the caller wants to sleep after a call.
136+
// FIXME
137+
// if !s.enabled {
138+
// return time.Duration(0)
139+
// }
140+
if shouldSleep && sleepTime > 0 {
141+
atomic.AddInt32(&s.sleepingCount, 1)
142+
time.Sleep(sleepTime)
143+
atomic.AddInt32(&s.sleepingCount, -1)
144+
}
145+
// Returned for tests or logging.
146+
return sleepTime
147+
}

smoother_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package pebble
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestSmootherOff(t *testing.T) {
12+
smoother := Smoother{enabled: false}
13+
smoother.mu.estimatedUtilization = 0.5
14+
smoother.startWork()
15+
sleepTime := smoother.finishWork(time.Duration(100), false)
16+
require.Equal(t, time.Duration(0), sleepTime)
17+
}
18+
19+
func TestSleepConverge(t *testing.T) {
20+
smoother := Smoother{enabled: true}
21+
smoother.mu.estimatedUtilization = .1
22+
for x := 0; x < 10000; x++ {
23+
smoother.startWork()
24+
smoother.finishWork(100*time.Millisecond, false)
25+
}
26+
sleepTime := smoother.finishWork(100*time.Millisecond, false)
27+
// Sleep time is 100ms * 9 since it is 10% utilized (so 90% idle)
28+
require.InDelta(t, 900*time.Millisecond, sleepTime, float64(10*time.Microsecond))
29+
}
30+
31+
func TestMultipleRunners(t *testing.T) {
32+
smoother := Smoother{enabled: true}
33+
smoother.start()
34+
smoother.mu.estimatedUtilization = 1
35+
for i := 0; i < 11; i++ {
36+
for j := 0; j < 10; j++ {
37+
smoother.startWork()
38+
}
39+
time.Sleep(102 * time.Millisecond)
40+
for j := 0; j < 10; j++ {
41+
smoother.finishWork(0, true)
42+
}
43+
}
44+
smoother.stop()
45+
fmt.Println(smoother.mu.estimatedUtilization)
46+
// The estimate should be about 1% closer to 10 starting at 1.0.
47+
require.InDelta(t, 1.09, smoother.mu.estimatedUtilization, 0.001)
48+
}
49+
50+
func TestSleep(t *testing.T) {
51+
smoother := Smoother{enabled: true}
52+
smoother.mu.estimatedIterDurationNs = float64(10 * time.Millisecond)
53+
smoother.mu.estimatedUtilization = 0.5
54+
55+
smoother.startWork()
56+
smoother.finishWork(1*time.Millisecond, true)
57+
58+
require.Equal(t, smoother.mu.estimatedUtilization, 0.5)
59+
require.InDelta(t, smoother.mu.estimatedIterDurationNs, float64(9*time.Millisecond), float64(time.Millisecond))
60+
}
61+
62+
func TestAvgUtil(t *testing.T) {
63+
smoother := Smoother{enabled: true}
64+
smoother.start()
65+
smoother.mu.estimatedUtilization = 0.5
66+
for i := 0; i < 11; i++ {
67+
smoother.startWork()
68+
time.Sleep(102 * time.Millisecond)
69+
smoother.finishWork(0, false)
70+
time.Sleep(102 * time.Millisecond)
71+
}
72+
smoother.stop()
73+
// It should never be exactly 0.5, but should be close. This test has a
74+
// potential to randomly fail, so think about ways to fix it.
75+
require.InDelta(t, 0.5, smoother.mu.estimatedUtilization, 0.01)
76+
}

0 commit comments

Comments
 (0)