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
+118 -135
View File
@@ -21,17 +21,23 @@ type direction struct {
rxFDs []int
statFD int
// While a cable measure runs, every failure counter in this direction is
// suppressed at its source rather than counted, hidden and reverted.
measuring *atomic.Bool
// The mask gate reads this: while a cable measure runs, every fault delta
// is dropped whole, so the measure's own link blip never reaches an
// aggregate.
masked *atomic.Bool
// Guards everything the sampler touches. The counters are read on their own
// clock and drawn on another, and the two must not read them at once:
// sampleDrops consumes what it reads, so a second caller would see a gap.
mu sync.Mutex
win *rateWindow
drops uint64
base counterSet
// Guards everything the sampler touches. Collection consumes self-clearing
// sources, so a second concurrent reader would see a gap.
mu sync.Mutex
prev sample
nic *nicCounters
// 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 sampler because the buckets are keyed by the shared read clock and
@@ -50,24 +56,6 @@ type direction struct {
smBytes [smearWindow]uint64
smLen int
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
@@ -86,41 +74,34 @@ func (e errs) add(o errs) errs {
}
}
// A ring of one bucket per drawn frame, spanning rateWindowSpan. Rates come
// from the gap between adjacent buckets and errors from the ends of the ring,
// so both slide forward every frame instead of stepping once a second.
type rateWindow struct {
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 (e errs) sub(o errs) errs {
return errs{
lost: e.lost - o.lost, corrupt: e.corrupt - o.corrupt,
link: e.link - o.link, internal: e.internal - o.internal,
}
}
func (w *rateWindow) count() int {
if w.filled {
return len(w.buf)
}
return w.idx
// The rolling short-term window: one slot of accepted fault deltas per sampler
// tick, spanning rateWindowSpan, with the sum kept current as slots enter and
// leave. A masked tick pushes zero, so the window slides on the clock
// regardless.
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 (w *rateWindow) at(i int) counterSet {
if w.filled {
i += w.idx
func newErrsRing(n int) *errsRing {
return &errsRing{slots: make([]errs, n)}
}
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
@@ -207,28 +188,16 @@ func (d *direction) snapshot() sample {
return s
}
// Counters keep climbing in the workers, so resetting just moves the origin
// everything is measured from. Rates and the rolling error window are about now
// rather than since the reset, so they keep running; the origin goes into the
// ring so the newest bucket never sits behind it.
// Counters keep climbing in the workers; a reset zeroes the sums accumulated
// from them. Rates and the rolling window are about now rather than since the
// reset, so they keep running.
func (d *direction) reset() {
d.mu.Lock()
d.base = d.capture()
d.win.push(d.base)
d.frames, d.bytes = 0, 0
d.since = errs{}
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 {
return float64((bytes+frames*wireOverhead)*8) / secs / 1e9
}
@@ -243,31 +212,51 @@ type view struct {
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{
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
// checksum, a header that does not match its own, a header that is not
// ours, and a length that cannot be.
corrupt: (n.s.crcErr - b.s.crcErr) + (n.s.badHdr - b.s.badHdr) +
(n.s.badMagic - b.s.badMagic) + (n.s.badLen - b.s.badLen),
corrupt: (cur.crcErr - prev.crcErr) + (cur.badHdr - prev.badHdr) +
(cur.badMagic - prev.badMagic) + (cur.badLen - prev.badLen),
// What the hardware reported. Nothing the host declined to send is here,
// 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
// stream has a flow rule to its own queue, which is exactly why it is
// worth counting.
internal: (n.drops - b.drops) + (n.s.late - b.s.late) +
(n.s.txErrs - b.s.txErrs),
internal: drops + (cur.late - prev.late) + (cur.txErrs - prev.txErrs),
}
}
func (d *direction) counters(now counterSet) view {
return view{
rxFrames: now.s.rxFrames - d.base.s.rxFrames,
rxBytes: now.s.rxBytes - d.base.s.rxBytes,
since: errsBetween(d.base, now),
// The mask gate: a masked tick is a cable measure's own blip, and its faults
// stop dead here — only the clock reaches the aggregates.
func (d *direction) aggregate(frames, bytes uint64, e errs) errs {
d.frames += frames
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 {
@@ -283,41 +272,43 @@ func totalView(views []view) view {
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.win.push(d.capture())
defer d.mu.Unlock()
frames, bytes, e := d.collect()
e = d.aggregate(frames, bytes, e)
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.
func (d *direction) displayView() view {
d.mu.Lock()
n := d.win.count()
if n == 0 {
d.mu.Unlock()
return view{}
v := view{
rxFrames: d.frames,
rxBytes: d.bytes,
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()
return v
}
// Whatever the interfaces counted before now is not ours, and no interval has
// elapsed yet, so every baseline starts here and nothing is reported until the
// first one completes.
// Whatever was counted before now is not ours: the first collection primes
// every baseline and is discarded.
func (d *direction) primeCounters() {
d.poller.prime()
d.reset()
d.mu.Lock()
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
// the frontier its own sender publishes.
txs := make([]*txStats, numStreams)
@@ -325,23 +316,23 @@ func buildDirection(label string, tx, rx endpoint, measuring *atomic.Bool) (*dir
txs[i] = &txStats{}
}
d := &direction{
txStats: txs,
streams: newLossWindows(txs),
measuring: measuring,
txStats: txs,
streams: newLossWindows(txs),
masked: masked,
}
// Held open for the life of the run: the stats ioctl is issued five times a
// second and reopening a socket for each one is pure overhead.
// Held open for the life of the run: the stats ioctl rides every sampler
// tick and reopening a socket for each one is pure overhead.
statFD, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0)
if err != nil {
return nil, fmt.Errorf("%s stats socket: %w", label, err)
}
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 {
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++ {
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{}) {
for i, fd := range d.txFDs {
w := &txWorker{
fd: fd,
stream: uint16(i),
spec: d.specs[i],
batch: batchSize,
stats: d.txStats[i],
measuring: d.measuring,
startTx: startTx,
fd: fd,
stream: uint16(i),
spec: d.specs[i],
batch: batchSize,
stats: d.txStats[i],
startTx: startTx,
}
wg.Add(1)
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 {
w := &rxWorker{
fd: fd,
batch: batchSize,
stream: uint16(i),
spec: d.specs[i],
stats: d.rxStats[i],
loss: &d.streams[i],
measuring: d.measuring,
ready: rxReady,
fd: fd,
batch: batchSize,
stream: uint16(i),
spec: d.specs[i],
stats: d.rxStats[i],
loss: &d.streams[i],
masked: d.masked,
ready: rxReady,
}
wg.Add(1)
go func() {
@@ -400,13 +390,6 @@ func (d *direction) start(wg *sync.WaitGroup, done *atomic.Bool, rxReady *sync.W
w.run(done)
}()
}
wg.Add(1)
go func() {
defer wg.Done()
defer holdPanic()
d.poller.run(done, startTx)
}()
}
func (d *direction) close() {