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