mirror of
https://github.com/golang/go
synced 2024-11-06 09:26:18 -07:00
7e4bc74119
This change makes the memory limit functional by including it in the heap goal calculation. Specifically, we derive a heap goal from the memory limit, and compare that to the GOGC-based goal. If the goal based on the memory limit is lower, we prefer that. To derive the memory limit goal, the heap goal calculation now takes a few additional parameters as input. As a result, the heap goal, in the presence of a memory limit, may change dynamically. The consequences of this are that different parts of the runtime can have different views of the heap goal; this is OK. What's important is that all of the runtime is able to observe the correct heap goal for the moment it's doing something that affects it, like anything that should trigger a GC cycle. On the topic of triggering a GC cycle, this change also allows any manually managed memory allocation from the page heap to trigger a GC. So, specifically workbufs, unrolled GC scan programs, and goroutine stacks. The reason for this is that now non-heap memory can effect the trigger or the heap goal. Most sources of non-heap memory only change slowly, like GC pointer bitmaps, or change in response to explicit function calls like GOMAXPROCS. Note also that unrolled GC scan programs and workbufs are really only relevant during a GC cycle anyway, so they won't actually ever trigger a GC. Our primary target here is goroutine stacks. Goroutine stacks can increase quickly, and this is currently totally independent of the GC cycle. Thus, if for example a goroutine begins to recurse suddenly and deeply, then even though the heap goal and trigger react, we might not notice until its too late. As a result, we need to trigger a GC cycle. We do this trigger in allocManual instead of in stackalloc because it's far more general. We ultimately care about memory that's mapped read/write and not returned to the OS, which is much more the domain of the page heap than the stack allocator. Furthermore, there may be new sources of memory manual allocation in the future (e.g. arenas) that need to trigger a GC if necessary. As such, I'm inclined to leave the trigger in allocManual as an extra defensive measure. It's worth noting that because goroutine stacks do not behave quite as predictably as other non-heap memory, there is the potential for the heap goal to swing wildly. Fortunately, goroutine stacks that haven't been set up to shrink by the last GC cycle will not shrink until after the next one. This reduces the amount of possible churn in the heap goal because it means that shrinkage only happens once per goroutine, per GC cycle. After all the goroutines that should shrink did, then goroutine stacks will only grow. The shrink mechanism is analagous to sweeping, which is incremental and thus tends toward a steady amount of heap memory used. As a result, in practice, I expect this to be a non-issue. Note that if the memory limit is not set, this change should be a no-op. For #48409. Change-Id: Ie06d10175e5e36f9fb6450e26ed8acd3d30c681c Reviewed-on: https://go-review.googlesource.com/c/go/+/394221 Run-TryBot: Michael Knyszek <mknyszek@google.com> TryBot-Result: Gopher Robot <gobot@golang.org> Reviewed-by: Michael Pratt <mpratt@google.com>
1130 lines
40 KiB
Go
1130 lines
40 KiB
Go
// Copyright 2021 The Go Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package runtime_test
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"math/rand"
|
|
. "runtime"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestGcPacer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const initialHeapBytes = 256 << 10
|
|
for _, e := range []*gcExecTest{
|
|
{
|
|
// The most basic test case: a steady-state heap.
|
|
// Growth to an O(MiB) heap, then constant heap size, alloc/scan rates.
|
|
name: "Steady",
|
|
gcPercent: 100,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(33.0),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 12)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if n >= 25 {
|
|
// At this alloc/scan rate, the pacer should be extremely close to the goal utilization.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, GCGoalUtilization, 0.005)
|
|
|
|
// Make sure the pacer settles into a non-degenerate state in at least 25 GC cycles.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.005)
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Same as the steady-state case, but lots of stacks to scan relative to the heap size.
|
|
name: "SteadyBigStacks",
|
|
gcPercent: 100,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(132.0),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 12)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(2048).sum(ramp(128<<20, 8)),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
// Check the same conditions as the steady-state case, except the old pacer can't
|
|
// really handle this well, so don't check the goal ratio for it.
|
|
n := len(c)
|
|
if n >= 25 {
|
|
// For the pacer redesign, assert something even stronger: at this alloc/scan rate,
|
|
// it should be extremely close to the goal utilization.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, GCGoalUtilization, 0.005)
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
|
|
// Make sure the pacer settles into a non-degenerate state in at least 25 GC cycles.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.005)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Same as the steady-state case, but lots of globals to scan relative to the heap size.
|
|
name: "SteadyBigGlobals",
|
|
gcPercent: 100,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 128 << 20,
|
|
nCores: 8,
|
|
allocRate: constant(132.0),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 12)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
// Check the same conditions as the steady-state case, except the old pacer can't
|
|
// really handle this well, so don't check the goal ratio for it.
|
|
n := len(c)
|
|
if n >= 25 {
|
|
// For the pacer redesign, assert something even stronger: at this alloc/scan rate,
|
|
// it should be extremely close to the goal utilization.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, GCGoalUtilization, 0.005)
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
|
|
// Make sure the pacer settles into a non-degenerate state in at least 25 GC cycles.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.005)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// This tests the GC pacer's response to a small change in allocation rate.
|
|
name: "StepAlloc",
|
|
gcPercent: 100,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(33.0).sum(ramp(66.0, 1).delay(50)),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 12)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 100,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if (n >= 25 && n < 50) || n >= 75 {
|
|
// Make sure the pacer settles into a non-degenerate state in at least 25 GC cycles
|
|
// and then is able to settle again after a significant jump in allocation rate.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.005)
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// This tests the GC pacer's response to a large change in allocation rate.
|
|
name: "HeavyStepAlloc",
|
|
gcPercent: 100,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(33).sum(ramp(330, 1).delay(50)),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 12)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 100,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if (n >= 25 && n < 50) || n >= 75 {
|
|
// Make sure the pacer settles into a non-degenerate state in at least 25 GC cycles
|
|
// and then is able to settle again after a significant jump in allocation rate.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.005)
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// This tests the GC pacer's response to a change in the fraction of the scannable heap.
|
|
name: "StepScannableFrac",
|
|
gcPercent: 100,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(128.0),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 12)),
|
|
scannableFrac: constant(0.2).sum(unit(0.5).delay(50)),
|
|
stackBytes: constant(8192),
|
|
length: 100,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if (n >= 25 && n < 50) || n >= 75 {
|
|
// Make sure the pacer settles into a non-degenerate state in at least 25 GC cycles
|
|
// and then is able to settle again after a significant jump in allocation rate.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.005)
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Tests the pacer for a high GOGC value with a large heap growth happening
|
|
// in the middle. The purpose of the large heap growth is to check if GC
|
|
// utilization ends up sensitive
|
|
name: "HighGOGC",
|
|
gcPercent: 1500,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: random(7, 0x53).offset(165),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 12), random(0.01, 0x1), unit(14).delay(25)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if n > 12 {
|
|
if n == 26 {
|
|
// In the 26th cycle there's a heap growth. Overshoot is expected to maintain
|
|
// a stable utilization, but we should *never* overshoot more than GOGC of
|
|
// the next cycle.
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.90, 15)
|
|
} else {
|
|
// Give a wider goal range here. With such a high GOGC value we're going to be
|
|
// forced to undershoot.
|
|
//
|
|
// TODO(mknyszek): Instead of placing a 0.95 limit on the trigger, make the limit
|
|
// based on absolute bytes, that's based somewhat in how the minimum heap size
|
|
// is determined.
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.90, 1.05)
|
|
}
|
|
|
|
// Ensure utilization remains stable despite a growth in live heap size
|
|
// at GC #25. This test fails prior to the GC pacer redesign.
|
|
//
|
|
// Because GOGC is so large, we should also be really close to the goal utilization.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, GCGoalUtilization, GCGoalUtilization+0.03)
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.03)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// This test makes sure that in the face of a varying (in this case, oscillating) allocation
|
|
// rate, the pacer does a reasonably good job of staying abreast of the changes.
|
|
name: "OscAlloc",
|
|
gcPercent: 100,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: oscillate(13, 0, 8).offset(67),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 12)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if n > 12 {
|
|
// After the 12th GC, the heap will stop growing. Now, just make sure that:
|
|
// 1. Utilization isn't varying _too_ much, and
|
|
// 2. The pacer is mostly keeping up with the goal.
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
assertInRange(t, "GC utilization", c[n-1].gcUtilization, 0.25, 0.3)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// This test is the same as OscAlloc, but instead of oscillating, the allocation rate is jittery.
|
|
name: "JitterAlloc",
|
|
gcPercent: 100,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: random(13, 0xf).offset(132),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 12), random(0.01, 0xe)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if n > 12 {
|
|
// After the 12th GC, the heap will stop growing. Now, just make sure that:
|
|
// 1. Utilization isn't varying _too_ much, and
|
|
// 2. The pacer is mostly keeping up with the goal.
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
assertInRange(t, "GC utilization", c[n-1].gcUtilization, 0.25, 0.3)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// This test is the same as JitterAlloc, but with a much higher allocation rate.
|
|
// The jitter is proportionally the same.
|
|
name: "HeavyJitterAlloc",
|
|
gcPercent: 100,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: random(33.0, 0x0).offset(330),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 12), random(0.01, 0x152)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if n > 13 {
|
|
// After the 12th GC, the heap will stop growing. Now, just make sure that:
|
|
// 1. Utilization isn't varying _too_ much, and
|
|
// 2. The pacer is mostly keeping up with the goal.
|
|
// We start at the 13th here because we want to use the 12th as a reference.
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
// Unlike the other tests, GC utilization here will vary more and tend higher.
|
|
// Just make sure it's not going too crazy.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.05)
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[11].gcUtilization, 0.05)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// This test sets a slow allocation rate and a small heap (close to the minimum heap size)
|
|
// to try to minimize the difference between the trigger and the goal.
|
|
name: "SmallHeapSlowAlloc",
|
|
gcPercent: 100,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(1.0),
|
|
scanRate: constant(2048.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 3)),
|
|
scannableFrac: constant(0.01),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if n > 4 {
|
|
// After the 4th GC, the heap will stop growing.
|
|
// First, let's make sure we're finishing near the goal, with some extra
|
|
// room because we're probably going to be triggering early.
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.925, 1.025)
|
|
// Next, let's make sure there's some minimum distance between the goal
|
|
// and the trigger. It should be proportional to the runway (hence the
|
|
// trigger ratio check, instead of a check against the runway).
|
|
assertInRange(t, "trigger ratio", c[n-1].triggerRatio(), 0.925, 0.975)
|
|
}
|
|
if n > 25 {
|
|
// Double-check that GC utilization looks OK.
|
|
|
|
// At this alloc/scan rate, the pacer should be extremely close to the goal utilization.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, GCGoalUtilization, 0.005)
|
|
// Make sure GC utilization has mostly levelled off.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.05)
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[11].gcUtilization, 0.05)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// This test sets a slow allocation rate and a medium heap (around 10x the min heap size)
|
|
// to try to minimize the difference between the trigger and the goal.
|
|
name: "MediumHeapSlowAlloc",
|
|
gcPercent: 100,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(1.0),
|
|
scanRate: constant(2048.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 8)),
|
|
scannableFrac: constant(0.01),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if n > 9 {
|
|
// After the 4th GC, the heap will stop growing.
|
|
// First, let's make sure we're finishing near the goal, with some extra
|
|
// room because we're probably going to be triggering early.
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.925, 1.025)
|
|
// Next, let's make sure there's some minimum distance between the goal
|
|
// and the trigger. It should be proportional to the runway (hence the
|
|
// trigger ratio check, instead of a check against the runway).
|
|
assertInRange(t, "trigger ratio", c[n-1].triggerRatio(), 0.925, 0.975)
|
|
}
|
|
if n > 25 {
|
|
// Double-check that GC utilization looks OK.
|
|
|
|
// At this alloc/scan rate, the pacer should be extremely close to the goal utilization.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, GCGoalUtilization, 0.005)
|
|
// Make sure GC utilization has mostly levelled off.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.05)
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[11].gcUtilization, 0.05)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// This test sets a slow allocation rate and a large heap to try to minimize the
|
|
// difference between the trigger and the goal.
|
|
name: "LargeHeapSlowAlloc",
|
|
gcPercent: 100,
|
|
memoryLimit: math.MaxInt64,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(1.0),
|
|
scanRate: constant(2048.0),
|
|
growthRate: constant(4.0).sum(ramp(-3.0, 12)),
|
|
scannableFrac: constant(0.01),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if n > 13 {
|
|
// After the 4th GC, the heap will stop growing.
|
|
// First, let's make sure we're finishing near the goal.
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
// Next, let's make sure there's some minimum distance between the goal
|
|
// and the trigger. It should be around the default minimum heap size.
|
|
assertInRange(t, "runway", c[n-1].runway(), DefaultHeapMinimum-64<<10, DefaultHeapMinimum+64<<10)
|
|
}
|
|
if n > 25 {
|
|
// Double-check that GC utilization looks OK.
|
|
|
|
// At this alloc/scan rate, the pacer should be extremely close to the goal utilization.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, GCGoalUtilization, 0.005)
|
|
// Make sure GC utilization has mostly levelled off.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.05)
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[11].gcUtilization, 0.05)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// The most basic test case with a memory limit: a steady-state heap.
|
|
// Growth to an O(MiB) heap, then constant heap size, alloc/scan rates.
|
|
// Provide a lot of room for the limit. Essentially, this should behave just like
|
|
// the "Steady" test. Note that we don't simulate non-heap overheads, so the
|
|
// memory limit and the heap limit are identical.
|
|
name: "SteadyMemoryLimit",
|
|
gcPercent: 100,
|
|
memoryLimit: 512 << 20,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(33.0),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 12)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if peak := c[n-1].heapPeak; peak >= (512<<20)-MemoryLimitHeapGoalHeadroom {
|
|
t.Errorf("peak heap size reaches heap limit: %d", peak)
|
|
}
|
|
if n >= 25 {
|
|
// At this alloc/scan rate, the pacer should be extremely close to the goal utilization.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, GCGoalUtilization, 0.005)
|
|
|
|
// Make sure the pacer settles into a non-degenerate state in at least 25 GC cycles.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.005)
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// This is the same as the previous test, but gcPercent = -1, so the heap *should* grow
|
|
// all the way to the peak.
|
|
name: "SteadyMemoryLimitNoGCPercent",
|
|
gcPercent: -1,
|
|
memoryLimit: 512 << 20,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(33.0),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(2.0).sum(ramp(-1.0, 12)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if goal := c[n-1].heapGoal; goal != (512<<20)-MemoryLimitHeapGoalHeadroom {
|
|
t.Errorf("heap goal is not the heap limit: %d", goal)
|
|
}
|
|
if n >= 25 {
|
|
// At this alloc/scan rate, the pacer should be extremely close to the goal utilization.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, GCGoalUtilization, 0.005)
|
|
|
|
// Make sure the pacer settles into a non-degenerate state in at least 25 GC cycles.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.005)
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// This test ensures that the pacer doesn't fall over even when the live heap exceeds
|
|
// the memory limit. It also makes sure GC utilization actually rises to push back.
|
|
name: "ExceedMemoryLimit",
|
|
gcPercent: 100,
|
|
memoryLimit: 512 << 20,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(33.0),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(3.5).sum(ramp(-2.5, 12)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if n > 12 {
|
|
// We're way over the memory limit, so we want to make sure our goal is set
|
|
// as low as it possibly can be.
|
|
if goal, live := c[n-1].heapGoal, c[n-1].heapLive; goal != live {
|
|
t.Errorf("heap goal is not equal to live heap: %d != %d", goal, live)
|
|
}
|
|
}
|
|
if n >= 25 {
|
|
// Due to memory pressure, we should scale to 100% GC CPU utilization.
|
|
// Note that in practice this won't actually happen because of the CPU limiter,
|
|
// but it's not the pacer's job to limit CPU usage.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, 1.0, 0.005)
|
|
|
|
// Make sure the pacer settles into a non-degenerate state in at least 25 GC cycles.
|
|
// In this case, that just means it's not wavering around a whole bunch.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.005)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Same as the previous test, but with gcPercent = -1.
|
|
name: "ExceedMemoryLimitNoGCPercent",
|
|
gcPercent: -1,
|
|
memoryLimit: 512 << 20,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(33.0),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(3.5).sum(ramp(-2.5, 12)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if n < 10 {
|
|
if goal := c[n-1].heapGoal; goal != (512<<20)-MemoryLimitHeapGoalHeadroom {
|
|
t.Errorf("heap goal is not the heap limit: %d", goal)
|
|
}
|
|
}
|
|
if n > 12 {
|
|
// We're way over the memory limit, so we want to make sure our goal is set
|
|
// as low as it possibly can be.
|
|
if goal, live := c[n-1].heapGoal, c[n-1].heapLive; goal != live {
|
|
t.Errorf("heap goal is not equal to live heap: %d != %d", goal, live)
|
|
}
|
|
}
|
|
if n >= 25 {
|
|
// Due to memory pressure, we should scale to 100% GC CPU utilization.
|
|
// Note that in practice this won't actually happen because of the CPU limiter,
|
|
// but it's not the pacer's job to limit CPU usage.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, 1.0, 0.005)
|
|
|
|
// Make sure the pacer settles into a non-degenerate state in at least 25 GC cycles.
|
|
// In this case, that just means it's not wavering around a whole bunch.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.005)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// This test ensures that the pacer maintains the memory limit as the heap grows.
|
|
name: "MaintainMemoryLimit",
|
|
gcPercent: 100,
|
|
memoryLimit: 512 << 20,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(33.0),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(3.0).sum(ramp(-2.0, 12)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if n > 12 {
|
|
// We're trying to saturate the memory limit.
|
|
if goal := c[n-1].heapGoal; goal != (512<<20)-MemoryLimitHeapGoalHeadroom {
|
|
t.Errorf("heap goal is not the heap limit: %d", goal)
|
|
}
|
|
}
|
|
if n >= 25 {
|
|
// At this alloc/scan rate, the pacer should be extremely close to the goal utilization,
|
|
// even with the additional memory pressure.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, GCGoalUtilization, 0.005)
|
|
|
|
// Make sure the pacer settles into a non-degenerate state in at least 25 GC cycles and
|
|
// that it's meeting its goal.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.005)
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
// Same as the previous test, but with gcPercent = -1.
|
|
name: "MaintainMemoryLimitNoGCPercent",
|
|
gcPercent: -1,
|
|
memoryLimit: 512 << 20,
|
|
globalsBytes: 32 << 10,
|
|
nCores: 8,
|
|
allocRate: constant(33.0),
|
|
scanRate: constant(1024.0),
|
|
growthRate: constant(3.0).sum(ramp(-2.0, 12)),
|
|
scannableFrac: constant(1.0),
|
|
stackBytes: constant(8192),
|
|
length: 50,
|
|
checker: func(t *testing.T, c []gcCycleResult) {
|
|
n := len(c)
|
|
if goal := c[n-1].heapGoal; goal != (512<<20)-MemoryLimitHeapGoalHeadroom {
|
|
t.Errorf("heap goal is not the heap limit: %d", goal)
|
|
}
|
|
if n >= 25 {
|
|
// At this alloc/scan rate, the pacer should be extremely close to the goal utilization,
|
|
// even with the additional memory pressure.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, GCGoalUtilization, 0.005)
|
|
|
|
// Make sure the pacer settles into a non-degenerate state in at least 25 GC cycles and
|
|
// that it's meeting its goal.
|
|
assertInEpsilon(t, "GC utilization", c[n-1].gcUtilization, c[n-2].gcUtilization, 0.005)
|
|
assertInRange(t, "goal ratio", c[n-1].goalRatio(), 0.95, 1.05)
|
|
}
|
|
},
|
|
},
|
|
// TODO(mknyszek): Write a test that exercises the pacer's hard goal.
|
|
// This is difficult in the idealized model this testing framework places
|
|
// the pacer in, because the calculated overshoot is directly proportional
|
|
// to the runway for the case of the expected work.
|
|
// However, it is still possible to trigger this case if something exceptional
|
|
// happens between calls to revise; the framework just doesn't support this yet.
|
|
} {
|
|
e := e
|
|
t.Run(e.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
c := NewGCController(e.gcPercent, e.memoryLimit)
|
|
var bytesAllocatedBlackLast int64
|
|
results := make([]gcCycleResult, 0, e.length)
|
|
for i := 0; i < e.length; i++ {
|
|
cycle := e.next()
|
|
c.StartCycle(cycle.stackBytes, e.globalsBytes, cycle.scannableFrac, e.nCores)
|
|
|
|
// Update pacer incrementally as we complete scan work.
|
|
const (
|
|
revisePeriod = 500 * time.Microsecond
|
|
rateConv = 1024 * float64(revisePeriod) / float64(time.Millisecond)
|
|
)
|
|
var nextHeapMarked int64
|
|
if i == 0 {
|
|
nextHeapMarked = initialHeapBytes
|
|
} else {
|
|
nextHeapMarked = int64(float64(int64(c.HeapMarked())-bytesAllocatedBlackLast) * cycle.growthRate)
|
|
}
|
|
globalsScanWorkLeft := int64(e.globalsBytes)
|
|
stackScanWorkLeft := int64(cycle.stackBytes)
|
|
heapScanWorkLeft := int64(float64(nextHeapMarked) * cycle.scannableFrac)
|
|
doWork := func(work int64) (int64, int64, int64) {
|
|
var deltas [3]int64
|
|
|
|
// Do globals work first, then stacks, then heap.
|
|
for i, workLeft := range []*int64{&globalsScanWorkLeft, &stackScanWorkLeft, &heapScanWorkLeft} {
|
|
if *workLeft == 0 {
|
|
continue
|
|
}
|
|
if *workLeft > work {
|
|
deltas[i] += work
|
|
*workLeft -= work
|
|
work = 0
|
|
break
|
|
} else {
|
|
deltas[i] += *workLeft
|
|
work -= *workLeft
|
|
*workLeft = 0
|
|
}
|
|
}
|
|
return deltas[0], deltas[1], deltas[2]
|
|
}
|
|
var (
|
|
gcDuration int64
|
|
assistTime int64
|
|
bytesAllocatedBlack int64
|
|
)
|
|
for heapScanWorkLeft+stackScanWorkLeft+globalsScanWorkLeft > 0 {
|
|
// Simulate GC assist pacing.
|
|
//
|
|
// Note that this is an idealized view of the GC assist pacing
|
|
// mechanism.
|
|
|
|
// From the assist ratio and the alloc and scan rates, we can idealize what
|
|
// the GC CPU utilization looks like.
|
|
//
|
|
// We start with assistRatio = (bytes of scan work) / (bytes of runway) (by definition).
|
|
//
|
|
// Over revisePeriod, we can also calculate how many bytes are scanned and
|
|
// allocated, given some GC CPU utilization u:
|
|
//
|
|
// bytesScanned = scanRate * rateConv * nCores * u
|
|
// bytesAllocated = allocRate * rateConv * nCores * (1 - u)
|
|
//
|
|
// During revisePeriod, assistRatio is kept constant, and GC assists kick in to
|
|
// maintain it. Specifically, they act to prevent too many bytes being allocated
|
|
// compared to how many bytes are scanned. It directly defines the ratio of
|
|
// bytesScanned to bytesAllocated over this period, hence:
|
|
//
|
|
// assistRatio = bytesScanned / bytesAllocated
|
|
//
|
|
// From this, we can solve for utilization, because everything else has already
|
|
// been determined:
|
|
//
|
|
// assistRatio = (scanRate * rateConv * nCores * u) / (allocRate * rateConv * nCores * (1 - u))
|
|
// assistRatio = (scanRate * u) / (allocRate * (1 - u))
|
|
// assistRatio * allocRate * (1-u) = scanRate * u
|
|
// assistRatio * allocRate - assistRatio * allocRate * u = scanRate * u
|
|
// assistRatio * allocRate = assistRatio * allocRate * u + scanRate * u
|
|
// assistRatio * allocRate = (assistRatio * allocRate + scanRate) * u
|
|
// u = (assistRatio * allocRate) / (assistRatio * allocRate + scanRate)
|
|
//
|
|
// Note that this may give a utilization that is _less_ than GCBackgroundUtilization,
|
|
// which isn't possible in practice because of dedicated workers. Thus, this case
|
|
// must be interpreted as GC assists not kicking in at all, and just round up. All
|
|
// downstream values will then have this accounted for.
|
|
assistRatio := c.AssistWorkPerByte()
|
|
utilization := assistRatio * cycle.allocRate / (assistRatio*cycle.allocRate + cycle.scanRate)
|
|
if utilization < GCBackgroundUtilization {
|
|
utilization = GCBackgroundUtilization
|
|
}
|
|
|
|
// Knowing the utilization, calculate bytesScanned and bytesAllocated.
|
|
bytesScanned := int64(cycle.scanRate * rateConv * float64(e.nCores) * utilization)
|
|
bytesAllocated := int64(cycle.allocRate * rateConv * float64(e.nCores) * (1 - utilization))
|
|
|
|
// Subtract work from our model.
|
|
globalsScanned, stackScanned, heapScanned := doWork(bytesScanned)
|
|
|
|
// doWork may not use all of bytesScanned.
|
|
// In this case, the GC actually ends sometime in this period.
|
|
// Let's figure out when, exactly, and adjust bytesAllocated too.
|
|
actualElapsed := revisePeriod
|
|
actualAllocated := bytesAllocated
|
|
if actualScanned := globalsScanned + stackScanned + heapScanned; actualScanned < bytesScanned {
|
|
// actualScanned = scanRate * rateConv * (t / revisePeriod) * nCores * u
|
|
// => t = actualScanned * revisePeriod / (scanRate * rateConv * nCores * u)
|
|
actualElapsed = time.Duration(float64(actualScanned) * float64(revisePeriod) / (cycle.scanRate * rateConv * float64(e.nCores) * utilization))
|
|
actualAllocated = int64(cycle.allocRate * rateConv * float64(actualElapsed) / float64(revisePeriod) * float64(e.nCores) * (1 - utilization))
|
|
}
|
|
|
|
// Ask the pacer to revise.
|
|
c.Revise(GCControllerReviseDelta{
|
|
HeapLive: actualAllocated,
|
|
HeapScan: int64(float64(actualAllocated) * cycle.scannableFrac),
|
|
HeapScanWork: heapScanned,
|
|
StackScanWork: stackScanned,
|
|
GlobalsScanWork: globalsScanned,
|
|
})
|
|
|
|
// Accumulate variables.
|
|
assistTime += int64(float64(actualElapsed) * float64(e.nCores) * (utilization - GCBackgroundUtilization))
|
|
gcDuration += int64(actualElapsed)
|
|
bytesAllocatedBlack += actualAllocated
|
|
}
|
|
|
|
// Put together the results, log them, and concatenate them.
|
|
result := gcCycleResult{
|
|
cycle: i + 1,
|
|
heapLive: c.HeapMarked(),
|
|
heapScannable: int64(float64(int64(c.HeapMarked())-bytesAllocatedBlackLast) * cycle.scannableFrac),
|
|
heapTrigger: c.Triggered(),
|
|
heapPeak: c.HeapLive(),
|
|
heapGoal: c.HeapGoal(),
|
|
gcUtilization: float64(assistTime)/(float64(gcDuration)*float64(e.nCores)) + GCBackgroundUtilization,
|
|
}
|
|
t.Log("GC", result.String())
|
|
results = append(results, result)
|
|
|
|
// Run the checker for this test.
|
|
e.check(t, results)
|
|
|
|
c.EndCycle(uint64(nextHeapMarked+bytesAllocatedBlack), assistTime, gcDuration, e.nCores)
|
|
|
|
bytesAllocatedBlackLast = bytesAllocatedBlack
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type gcExecTest struct {
|
|
name string
|
|
|
|
gcPercent int
|
|
memoryLimit int64
|
|
globalsBytes uint64
|
|
nCores int
|
|
|
|
allocRate float64Stream // > 0, KiB / cpu-ms
|
|
scanRate float64Stream // > 0, KiB / cpu-ms
|
|
growthRate float64Stream // > 0
|
|
scannableFrac float64Stream // Clamped to [0, 1]
|
|
stackBytes float64Stream // Multiple of 2048.
|
|
length int
|
|
|
|
checker func(*testing.T, []gcCycleResult)
|
|
}
|
|
|
|
// minRate is an arbitrary minimum for allocRate, scanRate, and growthRate.
|
|
// These values just cannot be zero.
|
|
const minRate = 0.0001
|
|
|
|
func (e *gcExecTest) next() gcCycle {
|
|
return gcCycle{
|
|
allocRate: e.allocRate.min(minRate)(),
|
|
scanRate: e.scanRate.min(minRate)(),
|
|
growthRate: e.growthRate.min(minRate)(),
|
|
scannableFrac: e.scannableFrac.limit(0, 1)(),
|
|
stackBytes: uint64(e.stackBytes.quantize(2048).min(0)()),
|
|
}
|
|
}
|
|
|
|
func (e *gcExecTest) check(t *testing.T, results []gcCycleResult) {
|
|
t.Helper()
|
|
|
|
// Do some basic general checks first.
|
|
n := len(results)
|
|
switch n {
|
|
case 0:
|
|
t.Fatal("no results passed to check")
|
|
return
|
|
case 1:
|
|
if results[0].cycle != 1 {
|
|
t.Error("first cycle has incorrect number")
|
|
}
|
|
default:
|
|
if results[n-1].cycle != results[n-2].cycle+1 {
|
|
t.Error("cycle numbers out of order")
|
|
}
|
|
}
|
|
if u := results[n-1].gcUtilization; u < 0 || u > 1 {
|
|
t.Fatal("GC utilization not within acceptable bounds")
|
|
}
|
|
if s := results[n-1].heapScannable; s < 0 {
|
|
t.Fatal("heapScannable is negative")
|
|
}
|
|
if e.checker == nil {
|
|
t.Fatal("test-specific checker is missing")
|
|
}
|
|
|
|
// Run the test-specific checker.
|
|
e.checker(t, results)
|
|
}
|
|
|
|
type gcCycle struct {
|
|
allocRate float64
|
|
scanRate float64
|
|
growthRate float64
|
|
scannableFrac float64
|
|
stackBytes uint64
|
|
}
|
|
|
|
type gcCycleResult struct {
|
|
cycle int
|
|
|
|
// These come directly from the pacer, so uint64.
|
|
heapLive uint64
|
|
heapTrigger uint64
|
|
heapGoal uint64
|
|
heapPeak uint64
|
|
|
|
// These are produced by the simulation, so int64 and
|
|
// float64 are more appropriate, so that we can check for
|
|
// bad states in the simulation.
|
|
heapScannable int64
|
|
gcUtilization float64
|
|
}
|
|
|
|
func (r *gcCycleResult) goalRatio() float64 {
|
|
return float64(r.heapPeak) / float64(r.heapGoal)
|
|
}
|
|
|
|
func (r *gcCycleResult) runway() float64 {
|
|
return float64(r.heapGoal - r.heapTrigger)
|
|
}
|
|
|
|
func (r *gcCycleResult) triggerRatio() float64 {
|
|
return float64(r.heapTrigger-r.heapLive) / float64(r.heapGoal-r.heapLive)
|
|
}
|
|
|
|
func (r *gcCycleResult) String() string {
|
|
return fmt.Sprintf("%d %2.1f%% %d->%d->%d (goal: %d)", r.cycle, r.gcUtilization*100, r.heapLive, r.heapTrigger, r.heapPeak, r.heapGoal)
|
|
}
|
|
|
|
func assertInEpsilon(t *testing.T, name string, a, b, epsilon float64) {
|
|
t.Helper()
|
|
assertInRange(t, name, a, b-epsilon, b+epsilon)
|
|
}
|
|
|
|
func assertInRange(t *testing.T, name string, a, min, max float64) {
|
|
t.Helper()
|
|
if a < min || a > max {
|
|
t.Errorf("%s not in range (%f, %f): %f", name, min, max, a)
|
|
}
|
|
}
|
|
|
|
// float64Stream is a function that generates an infinite stream of
|
|
// float64 values when called repeatedly.
|
|
type float64Stream func() float64
|
|
|
|
// constant returns a stream that generates the value c.
|
|
func constant(c float64) float64Stream {
|
|
return func() float64 {
|
|
return c
|
|
}
|
|
}
|
|
|
|
// unit returns a stream that generates a single peak with
|
|
// amplitude amp, followed by zeroes.
|
|
//
|
|
// In another manner of speaking, this is the Kronecker delta.
|
|
func unit(amp float64) float64Stream {
|
|
dropped := false
|
|
return func() float64 {
|
|
if dropped {
|
|
return 0
|
|
}
|
|
dropped = true
|
|
return amp
|
|
}
|
|
}
|
|
|
|
// oscillate returns a stream that oscillates sinusoidally
|
|
// with the given amplitude, phase, and period.
|
|
func oscillate(amp, phase float64, period int) float64Stream {
|
|
var cycle int
|
|
return func() float64 {
|
|
p := float64(cycle)/float64(period)*2*math.Pi + phase
|
|
cycle++
|
|
if cycle == period {
|
|
cycle = 0
|
|
}
|
|
return math.Sin(p) * amp
|
|
}
|
|
}
|
|
|
|
// ramp returns a stream that moves from zero to height
|
|
// over the course of length steps.
|
|
func ramp(height float64, length int) float64Stream {
|
|
var cycle int
|
|
return func() float64 {
|
|
h := height * float64(cycle) / float64(length)
|
|
if cycle < length {
|
|
cycle++
|
|
}
|
|
return h
|
|
}
|
|
}
|
|
|
|
// random returns a stream that generates random numbers
|
|
// between -amp and amp.
|
|
func random(amp float64, seed int64) float64Stream {
|
|
r := rand.New(rand.NewSource(seed))
|
|
return func() float64 {
|
|
return ((r.Float64() - 0.5) * 2) * amp
|
|
}
|
|
}
|
|
|
|
// delay returns a new stream which is a buffered version
|
|
// of f: it returns zero for cycles steps, followed by f.
|
|
func (f float64Stream) delay(cycles int) float64Stream {
|
|
zeroes := 0
|
|
return func() float64 {
|
|
if zeroes < cycles {
|
|
zeroes++
|
|
return 0
|
|
}
|
|
return f()
|
|
}
|
|
}
|
|
|
|
// scale returns a new stream that is f, but attenuated by a
|
|
// constant factor.
|
|
func (f float64Stream) scale(amt float64) float64Stream {
|
|
return func() float64 {
|
|
return f() * amt
|
|
}
|
|
}
|
|
|
|
// offset returns a new stream that is f but offset by amt
|
|
// at each step.
|
|
func (f float64Stream) offset(amt float64) float64Stream {
|
|
return func() float64 {
|
|
old := f()
|
|
return old + amt
|
|
}
|
|
}
|
|
|
|
// sum returns a new stream that is the sum of all input streams
|
|
// at each step.
|
|
func (f float64Stream) sum(fs ...float64Stream) float64Stream {
|
|
return func() float64 {
|
|
sum := f()
|
|
for _, s := range fs {
|
|
sum += s()
|
|
}
|
|
return sum
|
|
}
|
|
}
|
|
|
|
// quantize returns a new stream that rounds f to a multiple
|
|
// of mult at each step.
|
|
func (f float64Stream) quantize(mult float64) float64Stream {
|
|
return func() float64 {
|
|
r := f() / mult
|
|
if r < 0 {
|
|
return math.Ceil(r) * mult
|
|
}
|
|
return math.Floor(r) * mult
|
|
}
|
|
}
|
|
|
|
// min returns a new stream that replaces all values produced
|
|
// by f lower than min with min.
|
|
func (f float64Stream) min(min float64) float64Stream {
|
|
return func() float64 {
|
|
return math.Max(min, f())
|
|
}
|
|
}
|
|
|
|
// max returns a new stream that replaces all values produced
|
|
// by f higher than max with max.
|
|
func (f float64Stream) max(max float64) float64Stream {
|
|
return func() float64 {
|
|
return math.Min(max, f())
|
|
}
|
|
}
|
|
|
|
// limit returns a new stream that replaces all values produced
|
|
// by f lower than min with min and higher than max with max.
|
|
func (f float64Stream) limit(min, max float64) float64Stream {
|
|
return func() float64 {
|
|
v := f()
|
|
if v < min {
|
|
v = min
|
|
} else if v > max {
|
|
v = max
|
|
}
|
|
return v
|
|
}
|
|
}
|
|
|
|
func FuzzPIController(f *testing.F) {
|
|
isNormal := func(x float64) bool {
|
|
return !math.IsInf(x, 0) && !math.IsNaN(x)
|
|
}
|
|
isPositive := func(x float64) bool {
|
|
return isNormal(x) && x > 0
|
|
}
|
|
// Seed with constants from controllers in the runtime.
|
|
// It's not critical that we keep these in sync, they're just
|
|
// reasonable seed inputs.
|
|
f.Add(0.3375, 3.2e6, 1e9, 0.001, 1000.0, 0.01)
|
|
f.Add(0.9, 4.0, 1000.0, -1000.0, 1000.0, 0.84)
|
|
f.Fuzz(func(t *testing.T, kp, ti, tt, min, max, setPoint float64) {
|
|
// Ignore uninteresting invalid parameters. These parameters
|
|
// are constant, so in practice surprising values will be documented
|
|
// or will be other otherwise immediately visible.
|
|
//
|
|
// We just want to make sure that given a non-Inf, non-NaN input,
|
|
// we always get a non-Inf, non-NaN output.
|
|
if !isPositive(kp) || !isPositive(ti) || !isPositive(tt) {
|
|
return
|
|
}
|
|
if !isNormal(min) || !isNormal(max) || min > max {
|
|
return
|
|
}
|
|
// Use a random source, but make it deterministic.
|
|
rs := rand.New(rand.NewSource(800))
|
|
randFloat64 := func() float64 {
|
|
return math.Float64frombits(rs.Uint64())
|
|
}
|
|
p := NewPIController(kp, ti, tt, min, max)
|
|
state := float64(0)
|
|
for i := 0; i < 100; i++ {
|
|
input := randFloat64()
|
|
// Ignore the "ok" parameter. We're just trying to break it.
|
|
// state is intentionally completely uncorrelated with the input.
|
|
var ok bool
|
|
state, ok = p.Next(input, setPoint, 1.0)
|
|
if !isNormal(state) {
|
|
t.Fatalf("got NaN or Inf result from controller: %f %v", state, ok)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestIdleMarkWorkerCount(t *testing.T) {
|
|
const workers = 10
|
|
c := NewGCController(100, math.MaxInt64)
|
|
c.SetMaxIdleMarkWorkers(workers)
|
|
for i := 0; i < workers; i++ {
|
|
if !c.NeedIdleMarkWorker() {
|
|
t.Fatalf("expected to need idle mark workers: i=%d", i)
|
|
}
|
|
if !c.AddIdleMarkWorker() {
|
|
t.Fatalf("expected to be able to add an idle mark worker: i=%d", i)
|
|
}
|
|
}
|
|
if c.NeedIdleMarkWorker() {
|
|
t.Fatalf("expected to not need idle mark workers")
|
|
}
|
|
if c.AddIdleMarkWorker() {
|
|
t.Fatalf("expected to not be able to add an idle mark worker")
|
|
}
|
|
for i := 0; i < workers; i++ {
|
|
c.RemoveIdleMarkWorker()
|
|
if !c.NeedIdleMarkWorker() {
|
|
t.Fatalf("expected to need idle mark workers after removal: i=%d", i)
|
|
}
|
|
}
|
|
for i := 0; i < workers-1; i++ {
|
|
if !c.AddIdleMarkWorker() {
|
|
t.Fatalf("expected to be able to add idle mark workers after adding again: i=%d", i)
|
|
}
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
if !c.AddIdleMarkWorker() {
|
|
t.Fatalf("expected to be able to add idle mark workers interleaved: i=%d", i)
|
|
}
|
|
if c.AddIdleMarkWorker() {
|
|
t.Fatalf("expected to not be able to add idle mark workers interleaved: i=%d", i)
|
|
}
|
|
c.RemoveIdleMarkWorker()
|
|
}
|
|
// Support the max being below the count.
|
|
c.SetMaxIdleMarkWorkers(0)
|
|
if c.NeedIdleMarkWorker() {
|
|
t.Fatalf("expected to not need idle mark workers after capacity set to 0")
|
|
}
|
|
if c.AddIdleMarkWorker() {
|
|
t.Fatalf("expected to not be able to add idle mark workers after capacity set to 0")
|
|
}
|
|
for i := 0; i < workers-1; i++ {
|
|
c.RemoveIdleMarkWorker()
|
|
}
|
|
if c.NeedIdleMarkWorker() {
|
|
t.Fatalf("expected to not need idle mark workers after capacity set to 0")
|
|
}
|
|
if c.AddIdleMarkWorker() {
|
|
t.Fatalf("expected to not be able to add idle mark workers after capacity set to 0")
|
|
}
|
|
c.SetMaxIdleMarkWorkers(1)
|
|
if !c.NeedIdleMarkWorker() {
|
|
t.Fatalf("expected to need idle mark workers after capacity set to 1")
|
|
}
|
|
if !c.AddIdleMarkWorker() {
|
|
t.Fatalf("expected to be able to add idle mark workers after capacity set to 1")
|
|
}
|
|
}
|