Restructure stats into delta, mask, and aggregate layers

This commit is contained in:
flamingcow
2026-08-31 09:35:32 -07:00
parent ea272e489c
commit 06fa759b1a
11 changed files with 316 additions and 364 deletions
+25 -54
View File
@@ -5,8 +5,6 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync/atomic"
"time"
) )
// Split by direction of travel so each counter belongs to exactly one // Split by direction of travel so each counter belongs to exactly one
@@ -79,72 +77,45 @@ func sumFields(base string, fields []string) uint64 {
return total return total
} }
// A poll is an ioctl and a handful of sysfs attributes, each costing // A read is an ioctl and a handful of sysfs attributes, each costing
// microseconds of open and read, so it runs here rather than on the goroutine // microseconds of open and read, so it rides the sampler's goroutine and
// that has a frame to draw every sixteen milliseconds. Off that path it can // clock rather than the one that has a frame to draw every sixteen
// match the sampler's cadence, so a link error lands on the panel as quickly // milliseconds; a link error lands on the panel as quickly as a lost frame.
// as a lost frame rather than stepping five times a second. type nicCounters struct {
const nicInterval = sampleInterval txBase string
rx *statReader
type nicPoller struct { raw uint64
txBase string
rx *statReader
total *atomic.Uint64
measuring *atomic.Bool
raw uint64
} }
func newNICPoller(fd int, txName, rxName string, total *atomic.Uint64, measuring *atomic.Bool) (*nicPoller, error) { func newNICCounters(fd int, txName, rxName string) (*nicCounters, error) {
rx, err := newStatReader(fd, rxName, nicRxFields) rx, err := newStatReader(fd, rxName, nicRxFields)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &nicPoller{ return &nicCounters{
txBase: "/sys/class/net/" + txName, txBase: "/sys/class/net/" + txName,
rx: rx, rx: rx,
total: total,
measuring: measuring,
}, nil }, nil
} }
// Carrier is a property of the netdev rather than a counter of either side, and // Carrier is a property of the netdev rather than a counter of either side, and
// is taken from the transmitting interface only: across both directions each // is taken from the transmitting interface only: across both directions each
// interface transmits exactly once, so every flap is counted once. // interface transmits exactly once, so every flap is counted once.
func (p *nicPoller) read() uint64 { func (c *nicCounters) read() uint64 {
return sumFields(p.txBase, nicTxFields) + p.rx.sum() + return sumFields(c.txBase, nicTxFields) + c.rx.sum() +
mustReadUint(p.txBase+"/carrier_down_count") mustReadUint(c.txBase+"/carrier_down_count")
} }
// Nic counters run from boot and restart from zero whenever the driver resets // Nic counters run from boot and restart from zero whenever the driver resets
// its statistics, so only their forward motion is accumulated. Taking raw // its statistics, so only forward motion is the delta: a raw difference would
// differences instead charges a boot's worth of errors to the first sample and // charge a boot's worth of errors to the first collection and turn a reset
// turns a reset into a near-2^64 underflow. The hardware cannot pause its // into a near-2^64 underflow.
// counters for a cable measure, so its blip is suppressed here at the read: func (c *nicCounters) delta() uint64 {
// the baseline tracks the raw values without accumulating until it is over. raw := c.read()
func (p *nicPoller) poll() { var d uint64
raw := p.read() if raw > c.raw {
if raw > p.raw && !p.measuring.Load() { d = raw - c.raw
p.total.Add(raw - p.raw)
}
p.raw = raw
}
// Whatever the interfaces counted before now is not ours, so the first poll has
// something to measure forward motion from and adds nothing.
func (p *nicPoller) prime() {
p.raw = p.read()
}
// Held until the test starts, since polling before the baseline is primed would
// charge everything the interfaces counted since boot to the first sample.
func (p *nicPoller) run(done *atomic.Bool, startTx <-chan struct{}) {
<-startTx
tick := time.NewTicker(nicInterval)
defer tick.Stop()
for !done.Load() {
<-tick.C
p.poll()
} }
c.raw = raw
return d
} }
+118 -135
View File
@@ -21,17 +21,23 @@ type direction struct {
rxFDs []int rxFDs []int
statFD int statFD int
// While a cable measure runs, every failure counter in this direction is // The mask gate reads this: while a cable measure runs, every fault delta
// suppressed at its source rather than counted, hidden and reverted. // is dropped whole, so the measure's own link blip never reaches an
measuring *atomic.Bool // aggregate.
masked *atomic.Bool
// Guards everything the sampler touches. The counters are read on their own // Guards everything the sampler touches. Collection consumes self-clearing
// clock and drawn on another, and the two must not read them at once: // sources, so a second concurrent reader would see a gap.
// sampleDrops consumes what it reads, so a second caller would see a gap. mu sync.Mutex
mu sync.Mutex prev sample
win *rateWindow nic *nicCounters
drops uint64
base counterSet // Since the reset: plain sums of the deltas each tick lets through. Frames
// and bytes are traffic rather than faults and skip the mask gate.
frames uint64
bytes uint64
since errs
window *errsRing
// The smeared receive bucket the display draws its rate from, refreshed by // The smeared receive bucket the display draws its rate from, refreshed by
// the sampler because the buckets are keyed by the shared read clock and // the sampler because the buckets are keyed by the shared read clock and
@@ -50,24 +56,6 @@ type direction struct {
smBytes [smearWindow]uint64 smBytes [smearWindow]uint64
smLen int smLen int
smNext int64 smNext int64
nic atomic.Uint64
poller *nicPoller
}
// Everything the display reads, taken at one instant, so a pair of these
// describes both the rates and the errors over the span between them.
type counterSet struct {
t time.Time
s sample
drops uint64
nic uint64
}
func (d *direction) capture() counterSet {
d.sampleDrops()
s := d.snapshot()
return counterSet{t: time.Now(), s: s, drops: d.drops, nic: d.nic.Load()}
} }
// What someone testing a cable is asking, rather than how each failure happened // What someone testing a cable is asking, rather than how each failure happened
@@ -86,41 +74,34 @@ func (e errs) add(o errs) errs {
} }
} }
// A ring of one bucket per drawn frame, spanning rateWindowSpan. Rates come func (e errs) sub(o errs) errs {
// from the gap between adjacent buckets and errors from the ends of the ring, return errs{
// so both slide forward every frame instead of stepping once a second. lost: e.lost - o.lost, corrupt: e.corrupt - o.corrupt,
type rateWindow struct { link: e.link - o.link, internal: e.internal - o.internal,
buf []counterSet
idx int
filled bool
}
func newRateWindow(n int) *rateWindow {
return &rateWindow{buf: make([]counterSet, n)}
}
func (w *rateWindow) push(c counterSet) {
w.buf[w.idx] = c
w.idx++
if w.idx == len(w.buf) {
w.idx = 0
w.filled = true
} }
} }
func (w *rateWindow) count() int { // The rolling short-term window: one slot of accepted fault deltas per sampler
if w.filled { // tick, spanning rateWindowSpan, with the sum kept current as slots enter and
return len(w.buf) // leave. A masked tick pushes zero, so the window slides on the clock
} // regardless.
return w.idx type errsRing struct {
slots []errs
idx int
sum errs
} }
// Indexed oldest first, so a partly filled ring reads the same as a full one. func newErrsRing(n int) *errsRing {
func (w *rateWindow) at(i int) counterSet { return &errsRing{slots: make([]errs, n)}
if w.filled { }
i += w.idx
func (r *errsRing) push(e errs) {
r.sum = r.sum.sub(r.slots[r.idx]).add(e)
r.slots[r.idx] = e
r.idx++
if r.idx == len(r.slots) {
r.idx = 0
} }
return w.buf[i%len(w.buf)]
} }
// How long the frontier may sit still before the wire is taken to have gone // How long the frontier may sit still before the wire is taken to have gone
@@ -207,28 +188,16 @@ func (d *direction) snapshot() sample {
return s return s
} }
// Counters keep climbing in the workers, so resetting just moves the origin // Counters keep climbing in the workers; a reset zeroes the sums accumulated
// everything is measured from. Rates and the rolling error window are about now // from them. Rates and the rolling window are about now rather than since the
// rather than since the reset, so they keep running; the origin goes into the // reset, so they keep running.
// ring so the newest bucket never sits behind it.
func (d *direction) reset() { func (d *direction) reset() {
d.mu.Lock() d.mu.Lock()
d.base = d.capture() d.frames, d.bytes = 0, 0
d.win.push(d.base) d.since = errs{}
d.mu.Unlock() d.mu.Unlock()
} }
// The socket statistic is read-and-clear, so it is always consumed; a drop
// during a cable measure is the blip's and is discarded at this source.
func (d *direction) sampleDrops() {
for _, fd := range d.rxFDs {
n := packetDrops(fd)
if !d.measuring.Load() {
d.drops += n
}
}
}
func gbps(bytes, frames uint64, secs float64) float64 { func gbps(bytes, frames uint64, secs float64) float64 {
return float64((bytes+frames*wireOverhead)*8) / secs / 1e9 return float64((bytes+frames*wireOverhead)*8) / secs / 1e9
} }
@@ -243,31 +212,51 @@ type view struct {
window errs window errs
} }
func errsBetween(b, n counterSet) errs { // One tick of every counter this direction owns, flattened to deltas: the
// worker counters grow forever, the socket drop statistic clears itself on
// read, and the nic counters re-base, none of which survives past here.
func (d *direction) collect() (frames, bytes uint64, e errs) {
cur := d.snapshot()
prev := d.prev
d.prev = cur
var drops uint64
for _, fd := range d.rxFDs {
drops += packetDrops(fd)
}
return cur.rxFrames - prev.rxFrames, cur.rxBytes - prev.rxBytes,
classify(cur, prev, drops, d.nic.delta())
}
// Which delta feeds which bucket is the whole taxonomy the panel reports.
func classify(cur, prev sample, drops, nic uint64) errs {
return errs{ return errs{
lost: n.s.lost - b.s.lost, lost: cur.lost - prev.lost,
// Four ways of noticing one thing: a payload that does not match its // Four ways of noticing one thing: a payload that does not match its
// checksum, a header that does not match its own, a header that is not // checksum, a header that does not match its own, a header that is not
// ours, and a length that cannot be. // ours, and a length that cannot be.
corrupt: (n.s.crcErr - b.s.crcErr) + (n.s.badHdr - b.s.badHdr) + corrupt: (cur.crcErr - prev.crcErr) + (cur.badHdr - prev.badHdr) +
(n.s.badMagic - b.s.badMagic) + (n.s.badLen - b.s.badLen), (cur.badMagic - prev.badMagic) + (cur.badLen - prev.badLen),
// What the hardware reported. Nothing the host declined to send is here, // What the hardware reported. Nothing the host declined to send is here,
// so this one going red means the cable. // so this one going red means the cable.
link: (n.nic - b.nic) + (n.s.rxErrs - b.s.rxErrs), link: nic + (cur.rxErrs - prev.rxErrs),
// Ours rather than the cable's. A late frame is unreachable while each // Ours rather than the cable's. A late frame is unreachable while each
// stream has a flow rule to its own queue, which is exactly why it is // stream has a flow rule to its own queue, which is exactly why it is
// worth counting. // worth counting.
internal: (n.drops - b.drops) + (n.s.late - b.s.late) + internal: drops + (cur.late - prev.late) + (cur.txErrs - prev.txErrs),
(n.s.txErrs - b.s.txErrs),
} }
} }
func (d *direction) counters(now counterSet) view { // The mask gate: a masked tick is a cable measure's own blip, and its faults
return view{ // stop dead here — only the clock reaches the aggregates.
rxFrames: now.s.rxFrames - d.base.s.rxFrames, func (d *direction) aggregate(frames, bytes uint64, e errs) errs {
rxBytes: now.s.rxBytes - d.base.s.rxBytes, d.frames += frames
since: errsBetween(d.base, now), d.bytes += bytes
if d.masked.Load() {
e = errs{}
} }
d.since = d.since.add(e)
d.window.push(e)
return e
} }
func totalView(views []view) view { func totalView(views []view) view {
@@ -283,41 +272,43 @@ func totalView(views []view) view {
return t return t
} }
func (d *direction) sample() { // Returns the accepted deltas so the sampler can bucket them onward. The
// clock is read after the counters, per docs/measurement.md: stamps come from
// read time, never from the ticker.
func (d *direction) sample() errs {
d.mu.Lock() d.mu.Lock()
d.win.push(d.capture()) defer d.mu.Unlock()
frames, bytes, e := d.collect()
e = d.aggregate(frames, bytes, e)
d.readRateBucket(time.Now()) d.readRateBucket(time.Now())
d.mu.Unlock() return e
} }
// Draws what the sampler last put in the ring rather than reading the counters // Draws what the sampler last aggregated rather than reading the counters
// again, so the display never participates in the measurement. // again, so the display never participates in the measurement.
func (d *direction) displayView() view { func (d *direction) displayView() view {
d.mu.Lock() d.mu.Lock()
n := d.win.count() v := view{
if n == 0 { rxFrames: d.frames,
d.mu.Unlock() rxBytes: d.bytes,
return view{} since: d.since,
window: d.window.sum,
rxPPS: float64(d.rateFrames) / rateBucketSecs,
rxGbps: gbps(d.rateBytes, d.rateFrames, rateBucketSecs),
} }
v := d.counters(d.win.at(n - 1))
if n >= 2 {
v.window = errsBetween(d.win.at(0), d.win.at(n-1))
}
v.rxPPS = float64(d.rateFrames) / rateBucketSecs
v.rxGbps = gbps(d.rateBytes, d.rateFrames, rateBucketSecs)
d.mu.Unlock() d.mu.Unlock()
return v return v
} }
// Whatever the interfaces counted before now is not ours, and no interval has // Whatever was counted before now is not ours: the first collection primes
// elapsed yet, so every baseline starts here and nothing is reported until the // every baseline and is discarded.
// first one completes.
func (d *direction) primeCounters() { func (d *direction) primeCounters() {
d.poller.prime() d.mu.Lock()
d.reset() d.collect()
d.mu.Unlock()
} }
func buildDirection(label string, tx, rx endpoint, measuring *atomic.Bool) (*direction, error) { func buildDirection(label string, tx, rx endpoint, masked *atomic.Bool) (*direction, error) {
// Built before the windows, since each window judges sequence numbers against // Built before the windows, since each window judges sequence numbers against
// the frontier its own sender publishes. // the frontier its own sender publishes.
txs := make([]*txStats, numStreams) txs := make([]*txStats, numStreams)
@@ -325,23 +316,23 @@ func buildDirection(label string, tx, rx endpoint, measuring *atomic.Bool) (*dir
txs[i] = &txStats{} txs[i] = &txStats{}
} }
d := &direction{ d := &direction{
txStats: txs, txStats: txs,
streams: newLossWindows(txs), streams: newLossWindows(txs),
measuring: measuring, masked: masked,
} }
// Held open for the life of the run: the stats ioctl is issued five times a // Held open for the life of the run: the stats ioctl rides every sampler
// second and reopening a socket for each one is pure overhead. // tick and reopening a socket for each one is pure overhead.
statFD, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0) statFD, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s stats socket: %w", label, err) return nil, fmt.Errorf("%s stats socket: %w", label, err)
} }
d.statFD = statFD d.statFD = statFD
d.poller, err = newNICPoller(statFD, tx.name, rx.name, &d.nic, measuring) d.nic, err = newNICCounters(statFD, tx.name, rx.name)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: %w", label, err) return nil, fmt.Errorf("%s: %w", label, err)
} }
d.win = newRateWindow(int(rateWindowSpan/sampleInterval) + 1) d.window = newErrsRing(int(rateWindowSpan/sampleInterval) + 1)
for i := 0; i < numStreams; i++ { for i := 0; i < numStreams; i++ {
et := uint16(etherBase + i) et := uint16(etherBase + i)
@@ -367,13 +358,12 @@ func buildDirection(label string, tx, rx endpoint, measuring *atomic.Bool) (*dir
func (d *direction) start(wg *sync.WaitGroup, done *atomic.Bool, rxReady *sync.WaitGroup, startTx <-chan struct{}) { func (d *direction) start(wg *sync.WaitGroup, done *atomic.Bool, rxReady *sync.WaitGroup, startTx <-chan struct{}) {
for i, fd := range d.txFDs { for i, fd := range d.txFDs {
w := &txWorker{ w := &txWorker{
fd: fd, fd: fd,
stream: uint16(i), stream: uint16(i),
spec: d.specs[i], spec: d.specs[i],
batch: batchSize, batch: batchSize,
stats: d.txStats[i], stats: d.txStats[i],
measuring: d.measuring, startTx: startTx,
startTx: startTx,
} }
wg.Add(1) wg.Add(1)
go func() { go func() {
@@ -384,14 +374,14 @@ func (d *direction) start(wg *sync.WaitGroup, done *atomic.Bool, rxReady *sync.W
} }
for i, fd := range d.rxFDs { for i, fd := range d.rxFDs {
w := &rxWorker{ w := &rxWorker{
fd: fd, fd: fd,
batch: batchSize, batch: batchSize,
stream: uint16(i), stream: uint16(i),
spec: d.specs[i], spec: d.specs[i],
stats: d.rxStats[i], stats: d.rxStats[i],
loss: &d.streams[i], loss: &d.streams[i],
measuring: d.measuring, masked: d.masked,
ready: rxReady, ready: rxReady,
} }
wg.Add(1) wg.Add(1)
go func() { go func() {
@@ -400,13 +390,6 @@ func (d *direction) start(wg *sync.WaitGroup, done *atomic.Bool, rxReady *sync.W
w.run(done) w.run(done)
}() }()
} }
wg.Add(1)
go func() {
defer wg.Done()
defer holdPanic()
d.poller.run(done, startTx)
}()
} }
func (d *direction) close() { func (d *direction) close() {
+2 -2
View File
@@ -4,7 +4,7 @@ Hard-won rules about measuring correctly. Violating these produces numbers that
## Rate buckets: stamp at read time, never at ticker-fire time ## Rate buckets: stamp at read time, never at ticker-fire time
- `direction.capture()` stamps each bucket with `time.Now()` taken immediately after reading the counters — never a timestamp passed in from the sampler ticker. - `direction.sample()` reads the clock immediately after reading the counters — never a timestamp passed in from the sampler ticker.
- Why: a rate is Δcounters/Δt. The sampler runs a variable delay after its tick, so a tick-time label pairs a right numerator with a wrong denominator — and because E[1/x] > 1/E[x], the error biases the rate **upward**; it doesn't cancel. - Why: a rate is Δcounters/Δt. The sampler runs a variable delay after its tick, so a tick-time label pairs a right numerator with a wrong denominator — and because E[1/x] > 1/E[x], the error biases the rate **upward**; it doesn't cancel.
- Measured: tick-time stamping read 27.55 G / 4.88 Mpps against a true 19.86 G — 35% high. Read-time stamping read within 2%. - Measured: tick-time stamping read 27.55 G / 4.88 Mpps against a true 19.86 G — 35% high. Read-time stamping read within 2%.
- A 1 s console interval hides it (jitter < 0.2%); a 16 ms panel window exposes it. - A 1 s console interval hides it (jitter < 0.2%); a 16 ms panel window exposes it.
@@ -22,7 +22,7 @@ This rule covers the **NIC-counter** buckets and the per-stream receive buckets
- `/sys/class/net/*/statistics/*` run from boot, not process start, and reset to zero on driver stats resets. - `/sys/class/net/*/statistics/*` run from boot, not process start, and reset to zero on driver stats resets.
- In unsigned arithmetic that bites twice: a zero baseline charges the machine's whole lifetime to the run; a backward step underflows `now - base` to ~2^64. - In unsigned arithmetic that bites twice: a zero baseline charges the machine's whole lifetime to the run; a backward step underflows `now - base` to ~2^64.
- Rule: accumulate only forward motion into a process-local total, then take every baseline from that total — never from a raw reading. Establish all baselines in one place before traffic starts. - Rule: only forward motion counts — the delta layer clamps a backward step to zero and re-bases, never handing a raw difference upward. Establish all baselines in one place before traffic starts.
- Debugging tell: when a panel's background disagrees with rows drawn from the same value, suspect two vintages of one counter before a color bug. - Debugging tell: when a panel's background disagrees with rows drawn from the same value, suspect two vintages of one counter before a color bug.
## Only zero-baseline-loss runs count; small frames measure the host ## Only zero-baseline-loss runs count; small frames measure the host
+35 -41
View File
@@ -5,11 +5,12 @@ import (
"time" "time"
) )
// The panel's recent-history ring: 18 slots of 5 seconds, sampled every drawn // The panel's recent-history ring: 18 slots of 5 seconds. Fault and corrected
// frame from the same figures the panel paints, so a slot has data within one // deltas arrive straight from the layers below the moment they clear the mask,
// frame of being born. Slots are wall-time and survive a reset — recently is // and the per-frame judgments — measure state, rate, SNR — from the draw loop,
// recently, whatever the counters were re-based to — while the since-reset // so a slot has data within one tick of being born. Slots are wall-time and
// aggregates and fault ages clear. // survive a reset — recently is recently, whatever the aggregates were
// re-based to — while the fault ages and since-reset extremes clear.
const ( const (
histSlots = 18 histSlots = 18
histSlotSpan = 5 * time.Second histSlotSpan = 5 * time.Second
@@ -47,10 +48,6 @@ type history struct {
start time.Time start time.Time
slots [histSlots]histSlot slots [histSlots]histSlot
prevValid bool
prevSince errs
prevCorrected uint64
lastFault [4]time.Time lastFault [4]time.Time
sinceRateLow bool sinceRateLow bool
sinceHaveSNR bool sinceHaveSNR bool
@@ -69,15 +66,27 @@ func (h *history) slot(seq int64) *histSlot {
return s return s
} }
// Counters only move forward between samples except across a reset, where // Bucketing sits above the mask: only fault deltas that survived it arrive
// they re-base to zero; a backward step is that re-base, not negative faults. // here, already flattened, so a slot is a plain sum.
func delta(now, prev uint64) uint64 { func (h *history) addFaults(now time.Time, e errs) {
if now < prev { h.mu.Lock()
return 0 defer h.mu.Unlock()
s := h.slot(int64(now.Sub(h.start) / histSlotSpan))
for i, c := range faultClasses {
if d := c.get(e); d > 0 {
s.faults[i] += d
h.lastFault[i] = now
}
} }
return now - prev
} }
func (h *history) addCorrected(now time.Time, d uint64) {
h.mu.Lock()
h.slot(int64(now.Sub(h.start)/histSlotSpan)).corrected += d
h.mu.Unlock()
}
// The per-frame judgments, from the same figures the panel paints.
func (h *history) sample(now time.Time, v view, phy phyDisplay, func (h *history) sample(now time.Time, v view, phy phyDisplay,
measuring bool, target float64) { measuring bool, target float64) {
h.mu.Lock() h.mu.Lock()
@@ -85,34 +94,20 @@ func (h *history) sample(now time.Time, v view, phy phyDisplay,
s := h.slot(int64(now.Sub(h.start) / histSlotSpan)) s := h.slot(int64(now.Sub(h.start) / histSlotSpan))
if h.prevValid {
for i, c := range faultClasses {
d := delta(c.get(v.since), c.get(h.prevSince))
s.faults[i] += d
if d > 0 {
h.lastFault[i] = now
}
}
s.corrected += delta(phy.corrected, h.prevCorrected)
}
h.prevValid = true
h.prevSince = v.since
h.prevCorrected = phy.corrected
if measuring { if measuring {
s.measuring = true s.measuring = true
} else { return
if v.rxGbps < rateOKFrac*target { }
s.rateLow = true if v.rxGbps < rateOKFrac*target {
h.sinceRateLow = true s.rateLow = true
h.sinceRateLow = true
}
if phy.haveSNR {
if !s.haveSNR || phy.worstMargin < s.snrMin {
s.haveSNR, s.snrMin = true, phy.worstMargin
} }
if phy.haveSNR { if !h.sinceHaveSNR || phy.worstMargin < h.sinceSNRMin {
if !s.haveSNR || phy.worstMargin < s.snrMin { h.sinceHaveSNR, h.sinceSNRMin = true, phy.worstMargin
s.haveSNR, s.snrMin = true, phy.worstMargin
}
if !h.sinceHaveSNR || phy.worstMargin < h.sinceSNRMin {
h.sinceHaveSNR, h.sinceSNRMin = true, phy.worstMargin
}
} }
} }
} }
@@ -121,7 +116,6 @@ func (h *history) sample(now time.Time, v view, phy phyDisplay,
// the reset origin clears. // the reset origin clears.
func (h *history) reset() { func (h *history) reset() {
h.mu.Lock() h.mu.Lock()
h.prevValid = false
h.lastFault = [4]time.Time{} h.lastFault = [4]time.Time{}
h.sinceRateLow = false h.sinceRateLow = false
h.sinceHaveSNR = false h.sinceHaveSNR = false
+22 -21
View File
@@ -12,13 +12,13 @@ const (
) )
type lossWindow struct { type lossWindow struct {
mu sync.Mutex mu sync.Mutex
base uint64 base uint64
inited bool inited bool
quietEra bool maskedEra bool
bits []uint64 bits []uint64
lost atomic.Uint64 lost atomic.Uint64
late atomic.Uint64 late atomic.Uint64
// The sending half of this stream. Both halves are in this process and the // The sending half of this stream. Both halves are in this process and the
// receiving socket ignores its own outgoing frames, so this window is fed by // receiving socket ignores its own outgoing frames, so this window is fed by
@@ -48,24 +48,25 @@ func newLossWindows(tx []*txStats) []lossWindow {
// the run. Nothing that arrives later is evidence enough to undo that, which is // the run. Nothing that arrives later is evidence enough to undo that, which is
// why the sender's own frontier is the guard rather than a limit on how far a // why the sender's own frontier is the guard rather than a limit on how far a
// single step may move. // single step may move.
// quiet slides the window without charging: gaps written off during a cable // masked slides the window without charging. A gap's charge lands at eviction,
// measure are its own link blip, suppressed at the source while the state // which can fall after the mask lifts, so this source judges each charge
// machine stays in sync with the wire. // against the mask itself rather than leaving a deferred judgment to the
func (w *lossWindow) observe(seq uint64, quiet bool) bool { // sampler's gate.
func (w *lossWindow) observe(seq uint64, masked bool) bool {
if seq >= w.sent.Load() { if seq >= w.sent.Load() {
return false return false
} }
w.mu.Lock() w.mu.Lock()
if w.quietEra && !quiet { if w.maskedEra && !masked {
// Leaving a measure: the window was full when it began, so every hole // Leaving a measure: the window was full when it began, so every hole
// still inside went missing while failures were suppressed. Marking // still inside went missing while the mask was up. Marking them
// them delivered keeps the write-off complete however the eviction // delivered keeps the write-off complete however the eviction timing
// timing falls, instead of charging the gap's in-window tail later. // falls, instead of charging the gap's in-window tail later.
for i := range w.bits { for i := range w.bits {
w.bits[i] = ^uint64(0) w.bits[i] = ^uint64(0)
} }
} }
w.quietEra = quiet w.maskedEra = masked
if !w.inited { if !w.inited {
// Start half a window below the first sequence seen, so anything the // Start half a window below the first sequence seen, so anything the
// sender put on the wire before it lands inside the window rather than // sender put on the wire before it lands inside the window rather than
@@ -77,13 +78,13 @@ func (w *lossWindow) observe(seq uint64, quiet bool) bool {
} }
if seq < w.base { if seq < w.base {
w.mu.Unlock() w.mu.Unlock()
if !quiet { if !masked {
w.late.Add(1) w.late.Add(1)
} }
return true return true
} }
if seq >= w.base+lossSlots { if seq >= w.base+lossSlots {
w.evict(seq-lossSlots+1, quiet) w.evict(seq-lossSlots+1, masked)
} }
idx := seq & (lossSlots - 1) idx := seq & (lossSlots - 1)
w.bits[idx>>6] |= 1 << (idx & 63) w.bits[idx>>6] |= 1 << (idx & 63)
@@ -91,7 +92,7 @@ func (w *lossWindow) observe(seq uint64, quiet bool) bool {
return true return true
} }
func (w *lossWindow) evict(newBase uint64, quiet bool) { func (w *lossWindow) evict(newBase uint64, masked bool) {
span := newBase - w.base span := newBase - w.base
if span >= lossSlots { if span >= lossSlots {
var missing uint64 var missing uint64
@@ -99,7 +100,7 @@ func (w *lossWindow) evict(newBase uint64, quiet bool) {
missing += uint64(64 - bits.OnesCount64(w.bits[i])) missing += uint64(64 - bits.OnesCount64(w.bits[i]))
w.bits[i] = 0 w.bits[i] = 0
} }
if !quiet { if !masked {
w.lost.Add(missing + span - lossSlots) w.lost.Add(missing + span - lossSlots)
} }
w.base = newBase w.base = newBase
@@ -115,7 +116,7 @@ func (w *lossWindow) evict(newBase uint64, quiet bool) {
} }
w.bits[word] &^= bit w.bits[word] &^= bit
} }
if !quiet { if !masked {
w.lost.Add(missing) w.lost.Add(missing)
} }
w.base = newBase w.base = newBase
+3 -3
View File
@@ -131,11 +131,11 @@ func TestLossWindowRefusesUnsentSeq(t *testing.T) {
} }
} }
// A gap written off while quiet — a cable measure's own link blip — charges // A gap written off while masked — a cable measure's own link blip — charges
// nothing, however the eviction timing falls: even the gap's tail still // nothing, however the eviction timing falls: even the gap's tail still
// inside the window when the measure ends is presumed delivered. Counting // inside the window when the measure ends is presumed delivered. Counting
// resumes seamlessly and later real gaps are still caught. // resumes seamlessly and later real gaps are still caught.
func TestLossWindowQuietSlidesWithoutCharging(t *testing.T) { func TestLossWindowMaskedSlidesWithoutCharging(t *testing.T) {
w := newWindow(4 * lossSlots) w := newWindow(4 * lossSlots)
for seq := uint64(0); seq < 100; seq++ { for seq := uint64(0); seq < 100; seq++ {
w.observe(seq, false) w.observe(seq, false)
@@ -143,7 +143,7 @@ func TestLossWindowQuietSlidesWithoutCharging(t *testing.T) {
// The blip: a huge jump arriving during the measure. // The blip: a huge jump arriving during the measure.
w.observe(2*lossSlots, true) w.observe(2*lossSlots, true)
if got := w.lost.Load(); got != 0 { if got := w.lost.Load(); got != 0 {
t.Errorf("lost = %d after a quiet write-off, want 0", got) t.Errorf("lost = %d after a masked write-off, want 0", got)
} }
// The measure ends immediately — the worst case, with the gap's tail // The measure ends immediately — the worst case, with the gap's tail
+10 -8
View File
@@ -152,9 +152,11 @@ const (
) )
// One sampler for both directions, so they are read back to back on one clock // One sampler for both directions, so they are read back to back on one clock
// rather than drifting apart on two. // rather than drifting apart on two. Whatever each tick lets through the mask
// goes on into the history's buckets.
type sampler struct { type sampler struct {
dirs []*direction dirs []*direction
hist *history
} }
func (s *sampler) run(done *atomic.Bool, startTx <-chan struct{}) { func (s *sampler) run(done *atomic.Bool, startTx <-chan struct{}) {
@@ -166,7 +168,7 @@ func (s *sampler) run(done *atomic.Bool, startTx <-chan struct{}) {
for !done.Load() { for !done.Load() {
<-tick.C <-tick.C
for _, d := range s.dirs { for _, d := range s.dirs {
d.sample() s.hist.addFaults(time.Now(), d.sample())
} }
} }
} }
@@ -241,7 +243,7 @@ func run(aName, bName string) (err error) {
var dirs []*direction var dirs []*direction
for _, p := range [][2]endpoint{{a, b}, {b, a}} { for _, p := range [][2]endpoint{{a, b}, {b, a}} {
d, err := buildDirection(p[0].name+"->"+p[1].name, p[0], p[1], &diag.measuring) d, err := buildDirection(p[0].name+"->"+p[1].name, p[0], p[1], &diag.masked)
if err != nil { if err != nil {
return err return err
} }
@@ -277,7 +279,8 @@ func run(aName, bName string) (err error) {
for _, d := range dirs { for _, d := range dirs {
d.start(&wg, &done, &rxReady, startTx) d.start(&wg, &done, &rxReady, startTx)
} }
samp := &sampler{dirs: dirs} hist := newHistory(time.Now())
samp := &sampler{dirs: dirs, hist: hist}
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
@@ -289,7 +292,7 @@ func run(aName, bName string) (err error) {
go func() { go func() {
defer wg.Done() defer wg.Done()
defer holdPanic() defer holdPanic()
m.run(&done) m.run(&done, hist)
}() }()
} }
// Every return from here on stops the workers before the deferred closes // Every return from here on stops the workers before the deferred closes
@@ -340,10 +343,9 @@ func run(aName, bName string) (err error) {
} }
start := time.Now() start := time.Now()
hist := newHistory(start)
// A reset resets at the press, then re-measures; the measure's failures // A reset resets at the press, then re-measures; the measure's failures
// are suppressed at their sources while it runs, so there is nothing to // are dropped at the mask gate while it runs, so there is nothing to hide
// hide or revert afterwards. // or revert afterwards.
kickMeasure := func() { kickMeasure := func() {
if diag.kick(&done) { if diag.kick(&done) {
diag.resetLengths() diag.resetLengths()
+53 -46
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"sync/atomic"
"testing" "testing"
"time" "time"
) )
@@ -222,70 +223,76 @@ func TestSmearConservesFramesAndBytes(t *testing.T) {
} }
} }
// Indexed oldest first, so the error window still spans the whole ring once it // The window is a rolling sum of one span of deltas: what leaves the ring
// has wrapped. // leaves the sum, so after a wrap only the last len slots are counted.
func TestRateWindowIndexesOldestFirst(t *testing.T) { func TestErrsRingRollingSum(t *testing.T) {
w := newRateWindow(3) r := newErrsRing(3)
for i := 1; i <= 5; i++ { for i := 1; i <= 5; i++ {
w.push(counterSet{s: sample{rxFrames: uint64(i)}}) r.push(errs{lost: uint64(i), link: 1})
} }
if got := w.count(); got != 3 { want := errs{lost: 3 + 4 + 5, link: 3}
t.Fatalf("count = %d, want 3", got) if r.sum != want {
} t.Errorf("sum = %+v, want %+v", r.sum, want)
if got := w.at(0).s.rxFrames; got != 3 {
t.Errorf("oldest = %d, want 3", got)
}
if got := w.at(2).s.rxFrames; got != 5 {
t.Errorf("newest = %d, want 5", got)
} }
} }
// Which counter feeds which bucket is the whole taxonomy the panel reports, so // Which delta feeds which bucket is the whole taxonomy the panel reports, so
// it is pinned here rather than left to whoever reads errsBetween next. // it is pinned here rather than left to whoever reads classify next.
func TestErrsBetweenBuckets(t *testing.T) { func TestClassifyBuckets(t *testing.T) {
n := counterSet{ cur := sample{
s: sample{ lost: 1, late: 7,
lost: 1, late: 7, crcErr: 2, badMagic: 3, badLen: 4, badHdr: 10,
crcErr: 2, badMagic: 3, badLen: 4, badHdr: 10, txErrs: 6, rxErrs: 5,
txErrs: 6, rxErrs: 5,
},
drops: 9,
nic: 8,
} }
got := errsBetween(counterSet{}, n) got := classify(cur, sample{}, 9, 8)
want := errs{lost: 1, corrupt: 2 + 3 + 4 + 10, link: 8 + 5, internal: 9 + 7 + 6} want := errs{lost: 1, corrupt: 2 + 3 + 4 + 10, link: 8 + 5, internal: 9 + 7 + 6}
if got != want { if got != want {
t.Errorf("errsBetween = %+v, want %+v", got, want) t.Errorf("classify = %+v, want %+v", got, want)
} }
} }
// A reset re-bases from a fresh capture while the ring still holds buckets from // A masked tick's faults stop at the gate while traffic and the window clock
// just before it, so the newest bucket must not be left behind the new origin. // keep moving; the tick after the mask lifts aggregates again.
func TestResetDoesNotUnderflowTotals(t *testing.T) { func TestMaskedTickAggregatesNothing(t *testing.T) {
d := &direction{ var masked atomic.Bool
win: newRateWindow(8), d := &direction{masked: &masked, window: newErrsRing(8)}
rxStats: []*rxStats{{}},
masked.Store(true)
if e := d.aggregate(10, 640, errs{lost: 5, link: 2}); e != (errs{}) {
t.Errorf("accepted %+v from a masked tick, want nothing", e)
}
v := d.displayView()
if v.since != (errs{}) || v.window != (errs{}) {
t.Errorf("since = %+v window = %+v after a masked tick, want both empty", v.since, v.window)
}
if v.rxFrames != 10 || v.rxBytes != 640 {
t.Errorf("traffic = %d frames %d bytes, want it counted through the mask", v.rxFrames, v.rxBytes)
} }
d.rxStats[0].frames.Store(100) masked.Store(false)
d.rxStats[0].bytes.Store(6400) d.aggregate(0, 0, errs{lost: 1})
d.sample() v = d.displayView()
if v.since.lost != 1 || v.window.lost != 1 {
t.Errorf("since.lost = %d window.lost = %d after the mask lifted, want 1 and 1", v.since.lost, v.window.lost)
}
}
d.rxStats[0].frames.Store(150) // A reset zeroes the since-reset sums; the rolling window is about now rather
d.rxStats[0].bytes.Store(9600) // than since the reset, so it keeps what it held.
func TestResetZeroesSinceAndKeepsWindow(t *testing.T) {
var masked atomic.Bool
d := &direction{masked: &masked, window: newErrsRing(8)}
d.aggregate(100, 6400, errs{lost: 3})
d.reset() d.reset()
v := d.displayView() v := d.displayView()
if v.rxFrames != 0 { if v.rxFrames != 0 || v.rxBytes != 0 {
t.Errorf("rxFrames = %d, want 0", v.rxFrames) t.Errorf("traffic = %d frames %d bytes after reset, want 0 and 0", v.rxFrames, v.rxBytes)
} }
if v.rxBytes != 0 { if v.since != (errs{}) {
t.Errorf("rxBytes = %d, want 0", v.rxBytes) t.Errorf("since = %+v after reset, want empty", v.since)
} }
if v.window.lost != 3 {
// The rolling window and the rates are about now rather than since the t.Errorf("window.lost = %d after reset, want the pre-reset window kept", v.window.lost)
// reset, so the buckets from before it have to survive.
if got := d.win.count(); got < 2 {
t.Errorf("ring holds %d buckets after reset, want the pre-reset history kept", got)
} }
} }
+24 -22
View File
@@ -112,9 +112,11 @@ const (
snrWarnMargin = 1.0 snrWarnMargin = 1.0
) )
// While masked a measure owns the module: every register it would poll is the
// measure's own disturbance, so nothing is read at all.
type phyModule struct { type phyModule struct {
dev phyDev dev phyDev
busy atomic.Bool masked atomic.Bool
mu sync.Mutex mu sync.Mutex
sampled bool sampled bool
@@ -131,9 +133,8 @@ type phyModule struct {
notes []string notes []string
} }
// Silent while a measure owns the module. func (m *phyModule) poll(hist *history) error {
func (m *phyModule) poll() error { if m.masked.Load() {
if m.busy.Load() {
return nil return nil
} }
link, linkRaw, err := devLinkUp(m.dev) link, linkRaw, err := devLinkUp(m.dev)
@@ -156,11 +157,11 @@ func (m *phyModule) poll() error {
return err return err
} }
// Setting busy is a measure's first act, so busy still clear here proves // Setting masked is a measure's first act, so masked still clear here
// every read above finished before the measure disturbed anything; a // proves every read above finished before the measure disturbed anything;
// measure that began mid-read makes the samples ambiguous, and forgive() // a measure that began mid-read makes the samples ambiguous, and forgive()
// re-baselines after it anyway. // re-baselines after it anyway.
if m.busy.Load() { if m.masked.Load() {
return nil return nil
} }
@@ -188,6 +189,7 @@ func (m *phyModule) poll() error {
m.ber += ber m.ber += ber
m.retrains += rt m.retrains += rt
m.recentDelta = delta m.recentDelta = delta
hist.addCorrected(time.Now(), delta)
} else { } else {
m.recentDelta = 0 m.recentDelta = 0
m.primed = true m.primed = true
@@ -205,7 +207,7 @@ func (m *phyModule) takeNotes() []string {
return n return n
} }
func (m *phyModule) run(done *atomic.Bool) { func (m *phyModule) run(done *atomic.Bool, hist *history) {
tick := time.NewTicker(phyInterval) tick := time.NewTicker(phyInterval)
defer tick.Stop() defer tick.Stop()
@@ -213,7 +215,7 @@ func (m *phyModule) run(done *atomic.Bool) {
var lastErr error var lastErr error
for !done.Load() { for !done.Load() {
<-tick.C <-tick.C
if err := m.poll(); err != nil { if err := m.poll(hist); err != nil {
dark++ dark++
lastErr = err lastErr = err
if dark >= phyMaxDark { if dark >= phyMaxDark {
@@ -437,12 +439,12 @@ func bcmEnd(mods []*phyModule) *bcm {
// relink, so the MDI resolution is the fresh one. // relink, so the MDI resolution is the fresh one.
func measureCable(mods []*phyModule, done *atomic.Bool) (cableInfo, error) { func measureCable(mods []*phyModule, done *atomic.Bool) (cableInfo, error) {
for _, m := range mods { for _, m := range mods {
m.busy.Store(true) m.masked.Store(true)
} }
defer func() { defer func() {
for _, m := range mods { for _, m := range mods {
m.forgive() m.forgive()
m.busy.Store(false) m.masked.Store(false)
} }
}() }()
@@ -486,10 +488,10 @@ func measureCable(mods []*phyModule, done *atomic.Bool) (cableInfo, error) {
type cableDiag struct { type cableDiag struct {
mods []*phyModule mods []*phyModule
// While set, every failure counter is suppressed at its source: the // The mask itself. While set, every fault delta is dropped at the mask
// measure's own link blip is never counted anywhere, rather than counted, // gate before it can reach an aggregate: the measure's own link blip is
// hidden and reverted. // never counted anywhere, rather than counted, hidden and reverted.
measuring atomic.Bool masked atomic.Bool
mu sync.Mutex mu sync.Mutex
info cableInfo info cableInfo
@@ -503,7 +505,7 @@ func newCableDiag(mods []*phyModule, info cableInfo) *cableDiag {
func (c *cableDiag) snapshot() (cableInfo, cableLengths, bool) { func (c *cableDiag) snapshot() (cableInfo, cableLengths, bool) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
return c.info, c.lengths, c.measuring.Load() return c.info, c.lengths, c.masked.Load()
} }
func (c *cableDiag) resetLengths() { func (c *cableDiag) resetLengths() {
@@ -516,7 +518,7 @@ func (c *cableDiag) resetLengths() {
// tester that cannot run its diagnostics must not keep testing: the failure is // tester that cannot run its diagnostics must not keep testing: the failure is
// fatal, and the reboot's driver reload is also the wedged-µC recovery. // fatal, and the reboot's driver reload is also the wedged-µC recovery.
func (c *cableDiag) kick(done *atomic.Bool) bool { func (c *cableDiag) kick(done *atomic.Bool) bool {
if !c.measuring.CompareAndSwap(false, true) { if !c.masked.CompareAndSwap(false, true) {
return false return false
} }
go func() { go func() {
@@ -533,7 +535,7 @@ func (c *cableDiag) kick(done *atomic.Bool) bool {
} }
} }
c.mu.Unlock() c.mu.Unlock()
c.measuring.Store(false) c.masked.Store(false)
}() }()
return true return true
} }
@@ -575,9 +577,9 @@ func openModules(names [2]string) ([]*phyModule, [2]string, error) {
time.Sleep(moduleUpPoll) time.Sleep(moduleUpPoll)
} }
m := &phyModule{dev: dev} m := &phyModule{dev: dev}
// Born busy: the pollers stay silent through bringup's SETs and // Born masked: the pollers stay silent through bringup's SETs and
// retrains until the first measure completes and lifts the gate. // retrains until the first measure completes and lifts the gate.
m.busy.Store(true) m.masked.Store(true)
mods = append(mods, m) mods = append(mods, m)
} }
return mods, idents, nil return mods, idents, nil
+17 -23
View File
@@ -110,14 +110,14 @@ func fillBack(frames, bytes []uint64) {
} }
type rxWorker struct { type rxWorker struct {
fd int fd int
batch int batch int
stream uint16 stream uint16
spec *frameSpec spec *frameSpec
stats *rxStats stats *rxStats
loss *lossWindow loss *lossWindow
measuring *atomic.Bool masked *atomic.Bool
ready *sync.WaitGroup ready *sync.WaitGroup
} }
func (w *rxWorker) run(done *atomic.Bool) { func (w *rxWorker) run(done *atomic.Bool) {
@@ -133,12 +133,13 @@ func (w *rxWorker) run(done *atomic.Bool) {
w.ready.Done() w.ready.Done()
for !done.Load() { for !done.Load() {
// One flag load covers the batch: failures seen while a cable measure // The worker just counts; the mask gate above decides what a masked
// runs are its own link blip and are never counted. // tick's counts are worth. One flag load covers the batch for the loss
quiet := w.measuring.Load() // window, whose gap charges outlive the tick they happened in.
masked := w.masked.Load()
n, err := recvmmsg(w.fd, hdrs, unix.MSG_WAITFORONE) n, err := recvmmsg(w.fd, hdrs, unix.MSG_WAITFORONE)
if n <= 0 { if n <= 0 {
if err != nil && err != unix.EAGAIN && err != unix.EINTR && !quiet { if err != nil && err != unix.EAGAIN && err != unix.EINTR {
w.stats.rxErrs.Add(1) w.stats.rxErrs.Add(1)
} }
continue continue
@@ -149,9 +150,6 @@ func (w *rxWorker) run(done *atomic.Bool) {
buf := bufs[i][:int(hdrs[i].len)] buf := bufs[i][:int(hdrs[i].len)]
p, st := parseHeader(buf) p, st := parseHeader(buf)
if st != hdrOK { if st != hdrOK {
if quiet {
continue
}
if st == hdrForeign { if st == hdrForeign {
w.stats.badMagic.Add(1) w.stats.badMagic.Add(1)
} else { } else {
@@ -165,22 +163,18 @@ func (w *rxWorker) run(done *atomic.Bool) {
// The ethertype this socket is bound to already says which stream the // The ethertype this socket is bound to already says which stream the
// frame belongs to, so a header naming another one is damaged, as is a // frame belongs to, so a header naming another one is damaged, as is a
// sequence number the sender never reached. // sequence number the sender never reached.
if p.stream != w.stream || !w.loss.observe(p.seq, quiet) { if p.stream != w.stream || !w.loss.observe(p.seq, masked) {
if !quiet { w.stats.badHdr.Add(1)
w.stats.badHdr.Add(1)
}
continue continue
} }
want, ok := w.spec.expectedCRC(p.patIdx, p.payLen) want, ok := w.spec.expectedCRC(p.patIdx, p.payLen)
if !ok { if !ok {
if !quiet { w.stats.badLen.Add(1)
w.stats.badLen.Add(1)
}
continue continue
} }
pay := buf[minFrame : minFrame+p.payLen] pay := buf[minFrame : minFrame+p.payLen]
if crc32.Checksum(pay, crcTable) != want && !quiet { if crc32.Checksum(pay, crcTable) != want {
w.stats.crcErr.Add(1) w.stats.crcErr.Add(1)
} }
} }
+7 -9
View File
@@ -17,13 +17,12 @@ type txStats struct {
} }
type txWorker struct { type txWorker struct {
fd int fd int
stream uint16 stream uint16
spec *frameSpec spec *frameSpec
batch int batch int
stats *txStats stats *txStats
measuring *atomic.Bool startTx <-chan struct{}
startTx <-chan struct{}
} }
func (w *txWorker) run(done *atomic.Bool) { func (w *txWorker) run(done *atomic.Bool) {
@@ -64,8 +63,7 @@ func (w *txWorker) run(done *atomic.Bool) {
} }
// Taking fewer of the vector than offered is the ring's room, not a frame // Taking fewer of the vector than offered is the ring's room, not a frame
// lost: the rest go on the next pass. // lost: the rest go on the next pass.
if n < 0 && err != unix.EINTR && err != unix.EAGAIN && err != unix.ENOBUFS && if n < 0 && err != unix.EINTR && err != unix.EAGAIN && err != unix.ENOBUFS {
!w.measuring.Load() {
w.stats.errs.Add(1) w.stats.errs.Add(1)
} }
} }