Redraw at 60Hz with adaptive rate smoothing, console output at 1Hz
This commit is contained in:
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -45,12 +46,138 @@ type direction struct {
|
|||||||
rxFDs []int
|
rxFDs []int
|
||||||
reports chan string
|
reports chan string
|
||||||
|
|
||||||
prev sample
|
prevConsole sample
|
||||||
drops uint64
|
win *rateWindow
|
||||||
errBase sample
|
est *rateEstimators
|
||||||
dropBase uint64
|
heldFrames heldValue
|
||||||
nicTX nicCounters
|
heldSent heldValue
|
||||||
nicRX nicCounters
|
drops uint64
|
||||||
|
errBase sample
|
||||||
|
dropBase uint64
|
||||||
|
nicTX nicCounters
|
||||||
|
nicRX nicCounters
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smoothing has to be steady against high-frequency noise yet still chase a
|
||||||
|
// real change quickly, with bounded state. So the gain is not fixed: the
|
||||||
|
// innovation is compared against a running estimate of the noise itself (mean
|
||||||
|
// absolute deviation, as in TCP's rtt/rttvar), and only an innovation that
|
||||||
|
// stands out above that noise is chased hard.
|
||||||
|
const (
|
||||||
|
estAlphaCalm = 0.015
|
||||||
|
estAlphaSnap = 0.45
|
||||||
|
estMADBeta = 0.05
|
||||||
|
estNoiseK = 3.0
|
||||||
|
)
|
||||||
|
|
||||||
|
type rateEstimator struct {
|
||||||
|
minStep float64
|
||||||
|
relStep float64
|
||||||
|
|
||||||
|
est float64
|
||||||
|
mad float64
|
||||||
|
shown float64
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *rateEstimator) update(x float64) {
|
||||||
|
if e.n == 0 {
|
||||||
|
e.est, e.shown, e.n = x, x, 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := x - e.est
|
||||||
|
abs := math.Abs(err)
|
||||||
|
if e.n == 1 {
|
||||||
|
e.mad, e.n = abs, 2
|
||||||
|
} else {
|
||||||
|
e.mad += (abs - e.mad) * estMADBeta
|
||||||
|
}
|
||||||
|
|
||||||
|
a := estAlphaCalm
|
||||||
|
if e.mad > 0 {
|
||||||
|
if excess := abs/(estNoiseK*e.mad) - 1; excess > 0 {
|
||||||
|
a = estAlphaCalm + (estAlphaSnap-estAlphaCalm)*math.Min(excess, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.est += err * a
|
||||||
|
|
||||||
|
// A deadband on top, so the drawn text only changes when the estimate has
|
||||||
|
// actually moved rather than on every frame.
|
||||||
|
if math.Abs(e.est-e.shown) > math.Max(e.minStep, e.relStep*math.Abs(e.est)) {
|
||||||
|
e.shown = e.est
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *rateEstimator) value() float64 { return e.shown }
|
||||||
|
|
||||||
|
type rateEstimators struct {
|
||||||
|
txGbps, rxGbps rateEstimator
|
||||||
|
txPPS, rxPPS rateEstimator
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateEstimators() *rateEstimators {
|
||||||
|
return &rateEstimators{
|
||||||
|
txGbps: rateEstimator{minStep: 0.02, relStep: 0.001},
|
||||||
|
rxGbps: rateEstimator{minStep: 0.02, relStep: 0.001},
|
||||||
|
txPPS: rateEstimator{minStep: 2000, relStep: 0.002},
|
||||||
|
rxPPS: rateEstimator{minStep: 2000, relStep: 0.002},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monotonic totals climb by tens of thousands per frame, which is unreadable
|
||||||
|
// churn at 60Hz, so the drawn value is held and refreshed a few times a second.
|
||||||
|
type heldValue struct {
|
||||||
|
v uint64
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *heldValue) get(now time.Time, cur uint64) uint64 {
|
||||||
|
if now.Sub(h.at) >= totalsHold {
|
||||||
|
h.v, h.at = cur, now
|
||||||
|
}
|
||||||
|
return h.v
|
||||||
|
}
|
||||||
|
|
||||||
|
type rateSample struct {
|
||||||
|
t time.Time
|
||||||
|
txFrames, txBytes, rxFrames, rxBytes uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sliding window: the counters are sampled every frame and the rate is taken
|
||||||
|
// across the whole window, so the figure moves every frame while still being
|
||||||
|
// measured over a long enough span to be steady.
|
||||||
|
type rateWindow struct {
|
||||||
|
samples []rateSample
|
||||||
|
idx int
|
||||||
|
filled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRateWindow(n int) *rateWindow {
|
||||||
|
return &rateWindow{samples: make([]rateSample, n)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rateWindow) push(s rateSample) {
|
||||||
|
w.samples[w.idx] = s
|
||||||
|
w.idx++
|
||||||
|
if w.idx == len(w.samples) {
|
||||||
|
w.idx = 0
|
||||||
|
w.filled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *rateWindow) span() (oldest, newest rateSample, ok bool) {
|
||||||
|
if !w.filled && w.idx < 2 {
|
||||||
|
return oldest, newest, false
|
||||||
|
}
|
||||||
|
n := w.idx - 1
|
||||||
|
if n < 0 {
|
||||||
|
n = len(w.samples) - 1
|
||||||
|
}
|
||||||
|
o := 0
|
||||||
|
if w.filled {
|
||||||
|
o = w.idx
|
||||||
|
}
|
||||||
|
return w.samples[o], w.samples[n], true
|
||||||
}
|
}
|
||||||
|
|
||||||
type sample struct {
|
type sample struct {
|
||||||
@@ -168,20 +295,11 @@ type view struct {
|
|||||||
kdrop, errors uint64
|
kdrop, errors uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *direction) view(secs float64) view {
|
// Cumulative fields, which need no rate window and are identical for both the
|
||||||
now := d.snapshot()
|
// console and the display.
|
||||||
p := d.prev
|
func (d *direction) counters(now sample) view {
|
||||||
d.prev = now
|
|
||||||
d.sampleDrops()
|
|
||||||
|
|
||||||
txF := now.txFrames - p.txFrames
|
|
||||||
rxF := now.rxFrames - p.rxFrames
|
|
||||||
b := d.errBase
|
b := d.errBase
|
||||||
v := view{
|
v := view{
|
||||||
txPPS: float64(txF) / secs,
|
|
||||||
rxPPS: float64(rxF) / secs,
|
|
||||||
txGbps: gbps(now.txBytes-p.txBytes, txF, secs),
|
|
||||||
rxGbps: gbps(now.rxBytes-p.rxBytes, rxF, secs),
|
|
||||||
txFrames: now.txFrames,
|
txFrames: now.txFrames,
|
||||||
txSent: now.txBytes,
|
txSent: now.txBytes,
|
||||||
rxFrames: now.rxFrames,
|
rxFrames: now.rxFrames,
|
||||||
@@ -196,6 +314,53 @@ func (d *direction) view(secs float64) view {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *direction) view(prev *sample, secs float64) view {
|
||||||
|
now := d.snapshot()
|
||||||
|
p := *prev
|
||||||
|
*prev = now
|
||||||
|
d.sampleDrops()
|
||||||
|
|
||||||
|
v := d.counters(now)
|
||||||
|
txF := now.txFrames - p.txFrames
|
||||||
|
rxF := now.rxFrames - p.rxFrames
|
||||||
|
v.txPPS = float64(txF) / secs
|
||||||
|
v.rxPPS = float64(rxF) / secs
|
||||||
|
v.txGbps = gbps(now.txBytes-p.txBytes, txF, secs)
|
||||||
|
v.rxGbps = gbps(now.rxBytes-p.rxBytes, rxF, secs)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *direction) displayView(t time.Time) view {
|
||||||
|
now := d.snapshot()
|
||||||
|
d.sampleDrops()
|
||||||
|
d.win.push(rateSample{t, now.txFrames, now.txBytes, now.rxFrames, now.rxBytes})
|
||||||
|
|
||||||
|
v := d.counters(now)
|
||||||
|
v.txFrames = d.heldFrames.get(t, v.txFrames)
|
||||||
|
v.txSent = d.heldSent.get(t, v.txSent)
|
||||||
|
|
||||||
|
o, n, ok := d.win.span()
|
||||||
|
if !ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
secs := n.t.Sub(o.t).Seconds()
|
||||||
|
if secs <= 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
txF := n.txFrames - o.txFrames
|
||||||
|
rxF := n.rxFrames - o.rxFrames
|
||||||
|
d.est.txPPS.update(float64(txF) / secs)
|
||||||
|
d.est.rxPPS.update(float64(rxF) / secs)
|
||||||
|
d.est.txGbps.update(gbps(n.txBytes-o.txBytes, txF, secs))
|
||||||
|
d.est.rxGbps.update(gbps(n.rxBytes-o.rxBytes, rxF, secs))
|
||||||
|
|
||||||
|
v.txPPS = d.est.txPPS.value()
|
||||||
|
v.rxPPS = d.est.rxPPS.value()
|
||||||
|
v.txGbps = d.est.txGbps.value()
|
||||||
|
v.rxGbps = d.est.rxGbps.value()
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func (d *direction) row(elapsed time.Duration, v view, target float64) []string {
|
func (d *direction) row(elapsed time.Duration, v view, target float64) []string {
|
||||||
return []string{
|
return []string{
|
||||||
uptime(elapsed),
|
uptime(elapsed),
|
||||||
@@ -328,7 +493,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reportInterval = 500 * time.Millisecond
|
const (
|
||||||
|
reportInterval = time.Second
|
||||||
|
// Redraw fast so the panel feels live, but measure rates over a much longer
|
||||||
|
// window than a frame, since a frame's worth of a bursty sender is noise.
|
||||||
|
displayInterval = 16 * time.Millisecond
|
||||||
|
// Only long enough to take the edge off one frame's sample; the estimator
|
||||||
|
// does the real smoothing, so this stays small and bounded.
|
||||||
|
rateWindowSpan = 250 * time.Millisecond
|
||||||
|
totalsHold = 50 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
func run(aName, bName, sizesArg, patArg string,
|
func run(aName, bName, sizesArg, patArg string,
|
||||||
nStreams, batch int, duplex bool) error {
|
nStreams, batch int, duplex bool) error {
|
||||||
@@ -474,8 +648,15 @@ func run(aName, bName, sizesArg, patArg string,
|
|||||||
close(startTx)
|
close(startTx)
|
||||||
tick := time.NewTicker(reportInterval)
|
tick := time.NewTicker(reportInterval)
|
||||||
defer tick.Stop()
|
defer tick.Stop()
|
||||||
|
frame := time.NewTicker(displayInterval)
|
||||||
|
defer frame.Stop()
|
||||||
|
|
||||||
last := time.Now()
|
last := time.Now()
|
||||||
|
views := make([]view, len(dirs))
|
||||||
|
for _, d := range dirs {
|
||||||
|
d.win = newRateWindow(int(rateWindowSpan/displayInterval) + 1)
|
||||||
|
d.est = newRateEstimators()
|
||||||
|
}
|
||||||
stats := &streamTable{cols: intervalCols, headerEvery: 20}
|
stats := &streamTable{cols: intervalCols, headerEvery: 20}
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -490,14 +671,18 @@ func run(aName, bName, sizesArg, patArg string,
|
|||||||
}
|
}
|
||||||
stats.sinceHeader = 0
|
stats.sinceHeader = 0
|
||||||
fmt.Println(stats.rule("error counts reset"))
|
fmt.Println(stats.rule("error counts reset"))
|
||||||
|
case now := <-frame.C:
|
||||||
|
for i, d := range dirs {
|
||||||
|
views[i] = d.displayView(now)
|
||||||
|
}
|
||||||
|
disp.render(dirs, views, now.Sub(start), target)
|
||||||
case now := <-tick.C:
|
case now := <-tick.C:
|
||||||
secs := now.Sub(last).Seconds()
|
secs := now.Sub(last).Seconds()
|
||||||
last = now
|
last = now
|
||||||
elapsed := now.Sub(start)
|
elapsed := now.Sub(start)
|
||||||
views := make([]view, len(dirs))
|
for _, d := range dirs {
|
||||||
for i, d := range dirs {
|
v := d.view(&d.prevConsole, secs)
|
||||||
views[i] = d.view(secs)
|
for _, line := range stats.emit(d.row(elapsed, v, target)) {
|
||||||
for _, line := range stats.emit(d.row(elapsed, views[i], target)) {
|
|
||||||
fmt.Println(line)
|
fmt.Println(line)
|
||||||
}
|
}
|
||||||
for _, line := range d.reportNIC() {
|
for _, line := range d.reportNIC() {
|
||||||
@@ -513,7 +698,6 @@ func run(aName, bName, sizesArg, patArg string,
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
disp.render(dirs, views, elapsed, target)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,17 @@ func (d *display) center(f *textFace, y int, s string, col rgb) {
|
|||||||
f.draw(d.fb, (d.fb.w-len([]rune(s))*f.cellW)/2, y, s, col)
|
f.draw(d.fb, (d.fb.w-len([]rune(s))*f.cellW)/2, y, s, col)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rendered rates are quantised so the text only changes when the value moves
|
||||||
|
// meaningfully. Without this the low digits churn every frame no matter how
|
||||||
|
// long the averaging window is.
|
||||||
|
func roundPPS(v float64) uint64 {
|
||||||
|
const unit = 1000
|
||||||
|
if v < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return uint64((v+unit/2)/unit) * unit
|
||||||
|
}
|
||||||
|
|
||||||
func errColor(n uint64) rgb {
|
func errColor(n uint64) rgb {
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return uiGreen
|
return uiGreen
|
||||||
@@ -156,7 +167,7 @@ func (d *display) render(dirs []*direction, views []view, elapsed time.Duration,
|
|||||||
avail := fb.h - (bandY + bandH) - 16
|
avail := fb.h - (bandY + bandH) - 16
|
||||||
y := bandY + bandH + 8 + (avail-2*sectionH-gap)/2
|
y := bandY + bandH + 8 + (avail-2*sectionH-gap)/2
|
||||||
|
|
||||||
y = d.section(y, "INSTANT", "this interval")
|
y = d.section(y, "RATE")
|
||||||
d.rightAt(colTxGbEnd, y, "TX Gb/s", uiDim)
|
d.rightAt(colTxGbEnd, y, "TX Gb/s", uiDim)
|
||||||
d.rightAt(colTxPPSEnd, y, "TX pps", uiDim)
|
d.rightAt(colTxPPSEnd, y, "TX pps", uiDim)
|
||||||
d.rightAt(colRxGbEnd, y, "RX Gb/s", uiDim)
|
d.rightAt(colRxGbEnd, y, "RX Gb/s", uiDim)
|
||||||
@@ -166,14 +177,14 @@ func (d *display) render(dirs []*direction, views []view, elapsed time.Duration,
|
|||||||
d.dirTag(dir, y)
|
d.dirTag(dir, y)
|
||||||
v := views[i]
|
v := views[i]
|
||||||
d.rightAt(colTxGbEnd, y, fmt.Sprintf("%.2f", v.txGbps), rateColor(v.txGbps, target))
|
d.rightAt(colTxGbEnd, y, fmt.Sprintf("%.2f", v.txGbps), rateColor(v.txGbps, target))
|
||||||
d.rightAt(colTxPPSEnd, y, commas(uint64(v.txPPS)), uiFg)
|
d.rightAt(colTxPPSEnd, y, commas(roundPPS(v.txPPS)), uiFg)
|
||||||
d.rightAt(colRxGbEnd, y, fmt.Sprintf("%.2f", v.rxGbps), rateColor(v.rxGbps, target))
|
d.rightAt(colRxGbEnd, y, fmt.Sprintf("%.2f", v.rxGbps), rateColor(v.rxGbps, target))
|
||||||
d.rightAt(colRxPPSEnd, y, commas(uint64(v.rxPPS)), uiFg)
|
d.rightAt(colRxPPSEnd, y, commas(roundPPS(v.rxPPS)), uiFg)
|
||||||
y += lineH
|
y += lineH
|
||||||
}
|
}
|
||||||
|
|
||||||
y += gap
|
y += gap
|
||||||
y = d.section(y, "CUMULATIVE", "since reset")
|
y = d.section(y, "OVERALL")
|
||||||
d.rightAt(colFramesEnd, y, "frames", uiDim)
|
d.rightAt(colFramesEnd, y, "frames", uiDim)
|
||||||
d.rightAt(colDataEnd, y, "data", uiDim)
|
d.rightAt(colDataEnd, y, "data", uiDim)
|
||||||
d.rightAt(colLostEnd, y, "lost", uiDim)
|
d.rightAt(colLostEnd, y, "lost", uiDim)
|
||||||
@@ -196,9 +207,8 @@ func (d *display) render(dirs []*direction, views []view, elapsed time.Duration,
|
|||||||
fb.flush()
|
fb.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *display) section(y int, title, sub string) int {
|
func (d *display) section(y int, title string) int {
|
||||||
d.small.draw(d.fb, uiMargin, y, title, uiFg)
|
d.small.draw(d.fb, uiMargin, y, title, uiFg)
|
||||||
d.small.draw(d.fb, uiMargin+(len(title)+2)*d.small.cellW, y, sub, uiDim)
|
|
||||||
ruleY := y + d.small.cellH + 3
|
ruleY := y + d.small.cellH + 3
|
||||||
d.fb.rect(uiMargin, ruleY, d.fb.w-2*uiMargin, 1, uiRule)
|
d.fb.rect(uiMargin, ruleY, d.fb.w-2*uiMargin, 1, uiRule)
|
||||||
return ruleY + 7
|
return ruleY + 7
|
||||||
|
|||||||
Reference in New Issue
Block a user