From b2441ba64af3416e05b871c985501695e74bd511 Mon Sep 17 00:00:00 2001 From: flamingcow Date: Sat, 25 Jul 2026 21:30:37 -0700 Subject: [PATCH] Redraw at 60Hz with adaptive rate smoothing, console output at 1Hz --- main.go | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++------ ui.go | 22 ++++-- 2 files changed, 224 insertions(+), 30 deletions(-) diff --git a/main.go b/main.go index 150c0c1..482aa3b 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + "math" "net" "os" "os/signal" @@ -45,12 +46,138 @@ type direction struct { rxFDs []int reports chan string - prev sample - drops uint64 - errBase sample - dropBase uint64 - nicTX nicCounters - nicRX nicCounters + prevConsole sample + win *rateWindow + est *rateEstimators + heldFrames heldValue + heldSent heldValue + 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 { @@ -168,20 +295,11 @@ type view struct { kdrop, errors uint64 } -func (d *direction) view(secs float64) view { - now := d.snapshot() - p := d.prev - d.prev = now - d.sampleDrops() - - txF := now.txFrames - p.txFrames - rxF := now.rxFrames - p.rxFrames +// Cumulative fields, which need no rate window and are identical for both the +// console and the display. +func (d *direction) counters(now sample) view { b := d.errBase 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, txSent: now.txBytes, rxFrames: now.rxFrames, @@ -196,6 +314,53 @@ func (d *direction) view(secs float64) view { 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 { return []string{ 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, nStreams, batch int, duplex bool) error { @@ -474,8 +648,15 @@ func run(aName, bName, sizesArg, patArg string, close(startTx) tick := time.NewTicker(reportInterval) defer tick.Stop() + frame := time.NewTicker(displayInterval) + defer frame.Stop() 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} for { select { @@ -490,14 +671,18 @@ func run(aName, bName, sizesArg, patArg string, } stats.sinceHeader = 0 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: secs := now.Sub(last).Seconds() last = now elapsed := now.Sub(start) - views := make([]view, len(dirs)) - for i, d := range dirs { - views[i] = d.view(secs) - for _, line := range stats.emit(d.row(elapsed, views[i], target)) { + for _, d := range dirs { + v := d.view(&d.prevConsole, secs) + for _, line := range stats.emit(d.row(elapsed, v, target)) { fmt.Println(line) } for _, line := range d.reportNIC() { @@ -513,7 +698,6 @@ func run(aName, bName, sizesArg, patArg string, break } } - disp.render(dirs, views, elapsed, target) } } } diff --git a/ui.go b/ui.go index 6bf8793..24743b9 100644 --- a/ui.go +++ b/ui.go @@ -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) } +// 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 { if n == 0 { return uiGreen @@ -156,7 +167,7 @@ func (d *display) render(dirs []*direction, views []view, elapsed time.Duration, avail := fb.h - (bandY + bandH) - 16 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(colTxPPSEnd, y, "TX pps", 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) v := views[i] 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(colRxPPSEnd, y, commas(uint64(v.rxPPS)), uiFg) + d.rightAt(colRxPPSEnd, y, commas(roundPPS(v.rxPPS)), uiFg) y += lineH } y += gap - y = d.section(y, "CUMULATIVE", "since reset") + y = d.section(y, "OVERALL") d.rightAt(colFramesEnd, y, "frames", uiDim) d.rightAt(colDataEnd, y, "data", uiDim) d.rightAt(colLostEnd, y, "lost", uiDim) @@ -196,9 +207,8 @@ func (d *display) render(dirs []*direction, views []view, elapsed time.Duration, 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+(len(title)+2)*d.small.cellW, y, sub, uiDim) ruleY := y + d.small.cellH + 3 d.fb.rect(uiMargin, ruleY, d.fb.w-2*uiMargin, 1, uiRule) return ruleY + 7