Restructure stats into delta, mask, and aggregate layers
This commit is contained in:
+25
-54
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user