measurement: adapt the echo probe rate to loss and RTT, and weight samples by the time they stand for
udp_echo and tcp_echo run at the lowest rate that still proves the loss bound and keeps the RTT p99 confidence interval within tolerance, go to the maximum when the line loses packets or its RTT rises, and back off exponentially when it is unreachable. Every echo row carries a weight (the time it stands for, in grid intervals) and the analysis ops accumulate loss and RTT by it, so p50/p95/p99 and loss stay time-averaged when the rate changes. State changes are logged for audit.
This commit is contained in:
1 parent
57acecdb86
commit
e0376b5e33
15 files changed
+858
-55
No files matched your search
@@ -217,7 +217,7 @@ public sealed class StreamInfo
|
||||
KindSpec spec = Kinds.Find(kind)!;
|
||||
DeclaredPps = kind switch
|
||||
{
|
||||
"udp_echo" or "tcp_echo" => parameters.GetInt("rate_hz"),
|
||||
"udp_echo" or "tcp_echo" => Kinds.PeakRateHz(kind, parameters),
|
||||
"udp_train" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
|
||||
_ => 20,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using Fleet.Core;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
@@ -41,6 +42,76 @@ public abstract class BlockBase : IBlock
|
||||
Env.Fragments!.Ship(Spec.KindId, Spec.Scope, streams, parameters ?? Spec.Params.Values, Env.Clock.UtcMsAt(first), (int)((last - first) / 1000), Spec.Port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按自适应的节奏发包:下一个包的时刻按绝对时间排(不累积误差),每秒评估一次状态,状态变化记进日志(供审计)。
|
||||
/// 落后了就从现在重新排,不补发。
|
||||
/// </summary>
|
||||
protected async Task PaceAsync(AdaptiveRate rate, Func<ValueTask> sendOne, CancellationToken ct)
|
||||
{
|
||||
long due = Env.Clock.NowUs();
|
||||
long lastEvaluation = due;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
due += (long)(rate.NextDelay().TotalMilliseconds * 1000);
|
||||
long wait = due - Env.Clock.NowUs();
|
||||
if (wait > 0)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMicroseconds(wait), Env.Time, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
due = Env.Clock.NowUs();
|
||||
}
|
||||
|
||||
await sendOne();
|
||||
long now = Env.Clock.NowUs();
|
||||
if (now - lastEvaluation >= 1_000_000 || rate.State == ProbeState.Dead)
|
||||
{
|
||||
lastEvaluation = now;
|
||||
if (rate.Evaluate(now) is { } change)
|
||||
{
|
||||
LogTransition(change);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>读这个任务的自适应频率参数(<c>udp_echo</c> 和 <c>tcp_echo</c> 共用)。</summary>
|
||||
protected AdaptiveRate? CreateAdaptiveRate()
|
||||
{
|
||||
if (!Spec.Bool("adaptive"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
double max = Math.Max(Spec.Int("rate_max_hz"), Spec.Int("rate_min_hz"));
|
||||
return new AdaptiveRate(new AdaptiveRateOptions
|
||||
{
|
||||
MinHz = Spec.Int("rate_min_hz"),
|
||||
MaxHz = max,
|
||||
TauPct = Spec.Number("tau_pct"),
|
||||
RttTolPct = Spec.Number("rtt_tol_pct"),
|
||||
Prior = Env.LossPrior,
|
||||
});
|
||||
}
|
||||
|
||||
private void LogTransition(ProbeTransition change)
|
||||
{
|
||||
var fields = new (string, object?)[]
|
||||
{
|
||||
("stream", Spec.StreamId), ("kind", Spec.KindId), ("from", change.From.ToString().ToLowerInvariant()), ("to", change.To.ToString().ToLowerInvariant()), ("hz", Math.Round(change.Hz, 1)),
|
||||
("reason", change.Reason), ("loss_pct", Math.Round(change.LossPct, 3)), ("sent", change.Sent), ("lost", change.Lost),
|
||||
};
|
||||
if (change.To == ProbeState.Dead)
|
||||
{
|
||||
Env.Log.Warn("probe rate changed", fields);
|
||||
}
|
||||
else
|
||||
{
|
||||
Env.Log.Info("probe rate changed", fields);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>什么也不做、一直等到取消(监听方里不需要自己运行逻辑的积木)。</summary>
|
||||
protected async Task WaitUntilCancelledAsync(CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ internal sealed class EchoRows
|
||||
|
||||
private readonly DriverLog _log;
|
||||
private readonly int _size;
|
||||
private readonly int _w;
|
||||
private readonly int _t1;
|
||||
private readonly int _t4;
|
||||
private readonly long _graceUs;
|
||||
@@ -36,26 +37,36 @@ internal sealed class EchoRows
|
||||
StreamSpec spec = kind.FindStream("msg")!;
|
||||
_log = new DriverLog(spec, Side.Dialer, capacity);
|
||||
_size = _log.Store.ColumnIndex("size");
|
||||
_w = _log.Store.ColumnIndex("w");
|
||||
_t1 = _log.Store.ColumnIndex("t1");
|
||||
_t4 = _log.Store.ColumnIndex("t4");
|
||||
}
|
||||
|
||||
public long Count => _log.Count - Interlocked.Read(ref _discarded);
|
||||
|
||||
public uint Add(int size, long t1Us)
|
||||
/// <summary><paramref name="weight"/>:这个包代表多少个基础网格间隔(频率变化时分析按它累计,丢包率和分位数仍是按时间平均的)。</summary>
|
||||
public uint Add(int size, long t1Us, int weight = 1)
|
||||
{
|
||||
long key = _log.Store.Append();
|
||||
_log.Store.Set(key, _size, size);
|
||||
_log.Store.Set(key, _w, weight);
|
||||
_log.Store.Set(key, _t1, t1Us);
|
||||
return (uint)key;
|
||||
}
|
||||
|
||||
public void Reply(uint seq, long t4Us)
|
||||
public bool Reply(uint seq, long t4Us) => Reply(seq, t4Us, out _);
|
||||
|
||||
/// <summary>回填 t4;第一次回复才算,<paramref name="t1Us"/> 是这个包的发出时间。</summary>
|
||||
public bool Reply(uint seq, long t4Us, out long t1Us)
|
||||
{
|
||||
if (_log.Store.Contains(seq) && !_log.Store.IsSet(seq, _t4))
|
||||
t1Us = 0;
|
||||
if (_log.Store.Contains(seq) && !_log.Store.IsSet(seq, _t4) && _log.Store.TryGet(seq, _t1, out t1Us))
|
||||
{
|
||||
_log.Store.Set(seq, _t4, t4Us);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool Discard(uint seq)
|
||||
@@ -83,6 +94,7 @@ public sealed class DatagramEchoDialer : BlockBase
|
||||
private readonly EchoRows _rows;
|
||||
private readonly object _gate = new();
|
||||
private long _nextSendFailureWarningUs;
|
||||
private AdaptiveRate? _rate;
|
||||
|
||||
public DatagramEchoDialer(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
@@ -100,29 +112,50 @@ public sealed class DatagramEchoDialer : BlockBase
|
||||
IPEndPoint target = PeerEndpoint(Spec.Port);
|
||||
int size = (int)Spec.Int("size");
|
||||
byte[] packet = new byte[size];
|
||||
_rate = CreateAdaptiveRate();
|
||||
|
||||
void SendOne()
|
||||
{
|
||||
long t1 = Env.Clock.NowUs();
|
||||
lock (_gate)
|
||||
{
|
||||
uint seq = _rows.Add(size, t1, _rate?.Weight ?? 1);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
|
||||
if (datagrams.Send(session, target, packet))
|
||||
{
|
||||
_rate?.OnSend(t1);
|
||||
return;
|
||||
}
|
||||
|
||||
_rows.Discard(seq);
|
||||
if (t1 >= _nextSendFailureWarningUs)
|
||||
{
|
||||
Env.Log.Warn("QUIC echo datagram was not queued; omitted from loss samples",
|
||||
("stream", Spec.StreamId), ("seq", seq), ("bytes", size));
|
||||
_nextSendFailureWarningUs = t1 + 5_000_000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
datagrams.Register(Spec.StreamId, OnPacket);
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
if (_rate is null)
|
||||
{
|
||||
long t1 = Env.Clock.NowUs();
|
||||
lock (_gate)
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
uint seq = _rows.Add(size, t1);
|
||||
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
|
||||
if (!datagrams.Send(session, target, packet))
|
||||
{
|
||||
_rows.Discard(seq);
|
||||
if (t1 >= _nextSendFailureWarningUs)
|
||||
{
|
||||
Env.Log.Warn("QUIC echo datagram was not queued; omitted from loss samples",
|
||||
("stream", Spec.StreamId), ("seq", seq), ("bytes", size));
|
||||
_nextSendFailureWarningUs = t1 + 5_000_000;
|
||||
}
|
||||
}
|
||||
SendOne();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await PaceAsync(_rate, () =>
|
||||
{
|
||||
SendOne();
|
||||
return ValueTask.CompletedTask;
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -157,7 +190,10 @@ public sealed class DatagramEchoDialer : BlockBase
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_rows.Reply(delivery.Header.Seq, delivery.ReceiveUs);
|
||||
if (_rows.Reply(delivery.Header.Seq, delivery.ReceiveUs, out long t1))
|
||||
{
|
||||
_rate?.OnReply(t1, delivery.ReceiveUs, (delivery.ReceiveUs - t1) / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,6 +247,7 @@ public sealed class TcpEchoDialer : BlockBase
|
||||
{
|
||||
private readonly EchoRows _rows;
|
||||
private readonly TcpInfoRecorder _info;
|
||||
private AdaptiveRate? _rate;
|
||||
|
||||
public TcpEchoDialer(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
@@ -224,12 +261,14 @@ public sealed class TcpEchoDialer : BlockBase
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
var backoff = new Fleet.Core.Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
|
||||
_rate = CreateAdaptiveRate();
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using TcpLink link = await TcpLink.OpenAsync(Env, Spec.Port, Spec.Bool("tls"), Spec.StreamId, sendAuth: true, ct);
|
||||
backoff.Reset();
|
||||
_rate?.Reconnected(Env.Clock.NowUs());
|
||||
await ExchangeAsync(link, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
@@ -300,16 +339,29 @@ public sealed class TcpEchoDialer : BlockBase
|
||||
int min = (int)Spec.Int("min");
|
||||
int max = (int)Spec.Int("max");
|
||||
byte[] body = new byte[Math.Max(max, min)];
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
|
||||
async ValueTask SendOneAsync()
|
||||
{
|
||||
int size = Math.Max(TcpWire.MinEchoBody, max > min ? Random.Shared.Next(min, max + 1) : min);
|
||||
long t1 = Env.Clock.NowUs();
|
||||
uint seq = _rows.Add(size, t1);
|
||||
uint seq = _rows.Add(size, t1, _rate?.Weight ?? 1);
|
||||
body.AsSpan(0, size).Clear();
|
||||
TcpWire.WriteEchoBody(body, seq, t1);
|
||||
await TcpWire.WriteMessageAsync(stream, body.AsMemory(0, size), ct);
|
||||
await stream.FlushAsync(ct);
|
||||
_rate?.OnSend(t1);
|
||||
}
|
||||
|
||||
if (_rate is not null)
|
||||
{
|
||||
await PaceAsync(_rate, SendOneAsync, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
await SendOneAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,9 +376,9 @@ public sealed class TcpEchoDialer : BlockBase
|
||||
}
|
||||
|
||||
long now = Env.Clock.NowUs();
|
||||
if (TcpWire.TryReadEchoBody(reply, out uint seq, out _))
|
||||
if (TcpWire.TryReadEchoBody(reply, out uint seq, out _) && _rows.Reply(seq, now, out long t1))
|
||||
{
|
||||
_rows.Reply(seq, now);
|
||||
_rate?.OnReply(t1, now, (now - t1) / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>探测节奏的状态。<see cref="Dead"/> 是线路几乎全丢:不再按正常频率白白发包,指数退避着探测,等到有回包再恢复。</summary>
|
||||
public enum ProbeState : byte
|
||||
{
|
||||
Quiet = 0,
|
||||
Watch = 1,
|
||||
Active = 2,
|
||||
Dead = 3,
|
||||
}
|
||||
|
||||
/// <summary>自适应探测频率的参数。频率范围、精度目标和各状态的保持时间都在这里。</summary>
|
||||
public sealed record AdaptiveRateOptions
|
||||
{
|
||||
/// <summary>平稳时的最低频率(Hz)。</summary>
|
||||
public double MinHz { get; init; } = 2;
|
||||
|
||||
/// <summary>
|
||||
/// 最高频率(Hz),也是权重的参考网格:每个探测包的权重是它代表的时间除以 <c>1/MaxHz</c>,
|
||||
/// 分析时丢包率和 RTT 分位数按权重累计,所以频率怎么变,得到的仍是按时间平均的值,不会偏向探测更密的时段。
|
||||
/// </summary>
|
||||
public double MaxHz { get; init; } = 10;
|
||||
|
||||
/// <summary>平稳时 RTT 分位数(p99)的 95% 置信区间半宽容差:取 max(下限毫秒, 百分比 × p99)。样本多到超出这个精度才会降频。</summary>
|
||||
public double RttTolPct { get; init; } = 2;
|
||||
|
||||
public double RttTolFloorMs { get; init; } = 0.3;
|
||||
|
||||
/// <summary>平稳线路要能证明的丢包上界(百分比,95% 置信)。三法则:零丢包需要 3 / τ 个样本。</summary>
|
||||
public double TauPct { get; init; } = 0.2;
|
||||
|
||||
/// <summary>证明窗口(秒):平稳频率 = 3 / (τ × 窗口)。</summary>
|
||||
public double CertifySeconds { get; init; } = 300;
|
||||
|
||||
/// <summary>历史上一直平稳的时段可以拉长证明窗口,同样的精度用更少的包。</summary>
|
||||
public double QuietCertifySeconds { get; init; } = 900;
|
||||
|
||||
/// <summary>回包等多久算丢(秒)。</summary>
|
||||
public double GraceSeconds { get; init; } = 3;
|
||||
|
||||
/// <summary>刚启动没有历史:先按 Watch 频率跑这么久(秒)。</summary>
|
||||
public double WarmupSeconds { get; init; } = 300;
|
||||
|
||||
/// <summary>一次丢包事件之后,Watch 至少保持多久(秒)。</summary>
|
||||
public double WatchHoldSeconds { get; init; } = 1800;
|
||||
|
||||
/// <summary>进入 Active 之后,没有新的丢包事件要保持多久才降到 Watch(秒)。</summary>
|
||||
public double ActiveHoldSeconds { get; init; } = 900;
|
||||
|
||||
/// <summary>证明窗口内有这么多个不同的秒出现丢包,就算持续丢包,升到 Active。</summary>
|
||||
public int ActiveLossCount { get; init; } = 3;
|
||||
|
||||
/// <summary>判断线路全丢的窗口(秒)和最少样本数、丢包比例。</summary>
|
||||
public double DeadWindowSeconds { get; init; } = 10;
|
||||
|
||||
public int DeadMinSent { get; init; } = 10;
|
||||
|
||||
public double DeadLossRatio { get; init; } = 0.95;
|
||||
|
||||
/// <summary>Dead 状态的探测间隔:从 1 秒起翻倍,最长这么久(秒),带 ±20% 抖动。</summary>
|
||||
public double DeadMaxIntervalSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>RTT 比长期水平高出这个倍数(再加下面的绝对值)持续几秒,就当作链路在变差,至少升到 Watch。</summary>
|
||||
public double RttFactor { get; init; } = 1.5;
|
||||
|
||||
public double RttFloorMs { get; init; } = 5;
|
||||
|
||||
/// <summary>先验:时间 → 这个时刻丢包的历史概率(0 到 1);null 表示没有历史。</summary>
|
||||
public Func<long, double?>? Prior { get; init; }
|
||||
|
||||
/// <summary>先验里的"丢包高发"阈值:达到它在到来之前 <see cref="PriorLeadSeconds"/> 提前预热。</summary>
|
||||
public double PriorWatchAt { get; init; } = 0.15;
|
||||
|
||||
public double PriorActiveAt { get; init; } = 0.5;
|
||||
|
||||
public double PriorLeadSeconds { get; init; } = 1800;
|
||||
}
|
||||
|
||||
/// <summary>状态或频率变了:<see cref="Reason"/> 是给日志和审计看的固定短语。</summary>
|
||||
public sealed record ProbeTransition(ProbeState From, ProbeState To, double Hz, string Reason, double LossPct, int Sent, int Lost);
|
||||
|
||||
/// <summary>
|
||||
/// 自适应探测频率:按"要证明的精度"决定频率,丢包多了自动升频,线路全丢就指数退避。
|
||||
/// 平稳线路只需要能证明丢包低于 τ 的样本数(三法则 N = 3/τ),有丢包的线路用满频率保精度和丢包的细节(连续丢包、乱序)。
|
||||
/// 状态靠丢包事件的频率升降,带滞回,不会在阈值附近来回跳;时间画像可以让高发时段提前预热。
|
||||
/// 纯逻辑:时间由调用方传入,不依赖时钟,方便测试。
|
||||
/// </summary>
|
||||
public sealed class AdaptiveRate
|
||||
{
|
||||
private const long UsPerSecond = 1_000_000;
|
||||
private const int Buckets = 1200; // 20 分钟,够覆盖最长的统计窗口
|
||||
|
||||
private readonly AdaptiveRateOptions _options;
|
||||
private readonly Random _random;
|
||||
private readonly object _gate = new();
|
||||
private readonly long[] _second = new long[Buckets];
|
||||
private readonly int[] _sent = new int[Buckets];
|
||||
private readonly int[] _replied = new int[Buckets];
|
||||
|
||||
private long _startUs = -1;
|
||||
private long _lastLossUs = long.MinValue;
|
||||
private long _activeUntilUs = long.MinValue;
|
||||
private long _lastReplyUs = long.MinValue;
|
||||
private long _deadSinceUs = long.MinValue;
|
||||
private int _deadProbes;
|
||||
private readonly Queue<(long Second, double Ms)> _rtts = new();
|
||||
private long _lastGuardSecond = long.MinValue;
|
||||
private double _guardHz = double.NaN;
|
||||
private TimeSpan _lastDelay = TimeSpan.Zero;
|
||||
private double _rttFast = double.NaN;
|
||||
private double _rttSlow = double.NaN;
|
||||
private int _rttHighSeconds;
|
||||
private long _lastRttSecond = long.MinValue;
|
||||
|
||||
public AdaptiveRate(AdaptiveRateOptions options, Random? random = null)
|
||||
{
|
||||
_options = options;
|
||||
_random = random ?? Random.Shared;
|
||||
State = ProbeState.Watch;
|
||||
Hz = _options.MaxHz;
|
||||
}
|
||||
|
||||
public ProbeState State { get; private set; }
|
||||
|
||||
/// <summary>当前的探测频率(Hz)。Dead 时是退避后的近似频率。</summary>
|
||||
public double Hz { get; private set; }
|
||||
|
||||
/// <summary>发出一个探测包。</summary>
|
||||
public void OnSend(long nowUs)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_startUs < 0)
|
||||
{
|
||||
_startUs = nowUs;
|
||||
}
|
||||
|
||||
long second = nowUs / UsPerSecond;
|
||||
int slot = Slot(second);
|
||||
if (_second[slot] != second)
|
||||
{
|
||||
_second[slot] = second;
|
||||
_sent[slot] = 0;
|
||||
_replied[slot] = 0;
|
||||
}
|
||||
|
||||
_sent[slot]++;
|
||||
if (State == ProbeState.Dead)
|
||||
{
|
||||
_deadProbes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>收到回包:<paramref name="sentUs"/> 是它发出的时间,<paramref name="rttMs"/> 是往返时延。</summary>
|
||||
public void OnReply(long sentUs, long nowUs, double rttMs)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
long second = sentUs / UsPerSecond;
|
||||
int slot = Slot(second);
|
||||
if (_second[slot] == second && _replied[slot] < _sent[slot])
|
||||
{
|
||||
_replied[slot]++;
|
||||
}
|
||||
|
||||
if (double.IsNaN(_rttSlow))
|
||||
{
|
||||
_rttSlow = _rttFast = rttMs;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 时间常数:快的 5 秒,慢的 15 分钟(按时间而不是按包数,频率变了也一样)
|
||||
double dt = Math.Max(0, (nowUs - Math.Max(_lastReplyUs, 0)) / (double)UsPerSecond);
|
||||
_rttFast += (1 - Math.Exp(-dt / 5)) * (rttMs - _rttFast);
|
||||
_rttSlow += (1 - Math.Exp(-dt / 900)) * (rttMs - _rttSlow);
|
||||
}
|
||||
|
||||
_lastReplyUs = nowUs;
|
||||
_rtts.Enqueue((nowUs / UsPerSecond, rttMs));
|
||||
while (_rtts.Count > 6000)
|
||||
{
|
||||
_rtts.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>连接(重新)建立了:之前线路不通退避着的话,马上恢复探测,不用等退避间隔。</summary>
|
||||
public void Reconnected(long nowUs)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (State == ProbeState.Dead)
|
||||
{
|
||||
_lastReplyUs = nowUs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>距离下一个探测包还有多久(带抖动,平均值等于频率的倒数)。</summary>
|
||||
public TimeSpan NextDelay()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (State == ProbeState.Dead)
|
||||
{
|
||||
double seconds = Math.Min(_options.DeadMaxIntervalSeconds, Math.Pow(2, Math.Min(_deadProbes, 10)));
|
||||
return _lastDelay = TimeSpan.FromSeconds(seconds * (0.8 + 0.4 * _random.NextDouble()));
|
||||
}
|
||||
|
||||
// 平稳时间隔精确(不加抖动):发包时刻规整,列块里的时间戳差分更小;抖动只用在退避里避免多条线路同步重试
|
||||
return _lastDelay = TimeSpan.FromSeconds(1.0 / Hz);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>刚刚等过的那个间隔所代表的权重(以 1/MaxHz 为单位):下一个发出的包用它。平稳时是 MaxHz/Hz,退避时是几十到几百。</summary>
|
||||
public int Weight
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return (int)Math.Clamp(Math.Round(_lastDelay.TotalSeconds * _options.MaxHz), 1, 65535);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>重新评估状态,大约每秒调用一次;状态或频率变了就返回变化。</summary>
|
||||
public ProbeTransition? Evaluate(long nowUs)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_startUs < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long finalSecond = nowUs / UsPerSecond - (long)Math.Ceiling(_options.GraceSeconds);
|
||||
(int sent, int replied, int _) shortWindow = Window(finalSecond, (int)_options.DeadWindowSeconds);
|
||||
(int sent, int replied, int lossSeconds) certify = Window(finalSecond, (int)_options.CertifySeconds);
|
||||
int lostCertify = certify.sent - certify.replied;
|
||||
double lossPct = certify.sent == 0 ? 0 : 100.0 * lostCertify / certify.sent;
|
||||
|
||||
// 最近一次有丢包的时间:用最近 CertifySeconds 里最晚一个有丢包的整秒
|
||||
for (long second = finalSecond; second > finalSecond - (long)_options.CertifySeconds; second--)
|
||||
{
|
||||
int slot = Slot(second);
|
||||
if (_second[slot] == second && _sent[slot] > _replied[slot])
|
||||
{
|
||||
_lastLossUs = Math.Max(_lastLossUs, second * UsPerSecond);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
UpdateRttTrend(nowUs);
|
||||
|
||||
ProbeState next;
|
||||
string reason;
|
||||
if (State == ProbeState.Dead)
|
||||
{
|
||||
if (_lastReplyUs > _deadSinceUs)
|
||||
{
|
||||
next = ProbeState.Watch;
|
||||
reason = "reply received";
|
||||
_deadProbes = 0;
|
||||
_lastLossUs = nowUs;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else if (shortWindow.sent >= _options.DeadMinSent && shortWindow.replied <= shortWindow.sent * (1 - _options.DeadLossRatio))
|
||||
{
|
||||
next = ProbeState.Dead;
|
||||
reason = "line unreachable";
|
||||
_deadSinceUs = nowUs;
|
||||
_deadProbes = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
(next, reason) = Decide(nowUs, certify.lossSeconds, lostCertify, lossPct);
|
||||
}
|
||||
|
||||
UpdateGuard(nowUs);
|
||||
double hz = next switch
|
||||
{
|
||||
ProbeState.Quiet => Quantize(Math.Max(QuietHz(QuietWindowSeconds(nowUs)), double.IsNaN(_guardHz) ? _options.MaxHz : _guardHz)),
|
||||
ProbeState.Watch or ProbeState.Active => _options.MaxHz,
|
||||
_ => Math.Max(_options.MinHz / 4, 1.0 / _options.DeadMaxIntervalSeconds),
|
||||
};
|
||||
hz = Math.Min(_options.MaxHz, hz);
|
||||
|
||||
if (next == State && Math.Abs(hz - Hz) / Hz < 0.05)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ProbeTransition result = new(State, next, hz, reason, lossPct, certify.sent, lostCertify);
|
||||
State = next;
|
||||
Hz = hz;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private (ProbeState State, string Reason) Decide(long nowUs, int lossSeconds, int lostCertify, double lossPct)
|
||||
{
|
||||
ProbeState state = ProbeState.Quiet;
|
||||
string reason = "quiet";
|
||||
|
||||
double sinceLoss = _lastLossUs == long.MinValue ? double.PositiveInfinity : (nowUs - _lastLossUs) / (double)UsPerSecond;
|
||||
if (lossSeconds >= _options.ActiveLossCount || (lossPct >= _options.TauPct * 2 && lossSeconds >= 2))
|
||||
{
|
||||
_activeUntilUs = nowUs + (long)(_options.ActiveHoldSeconds * UsPerSecond);
|
||||
}
|
||||
|
||||
if (nowUs < _activeUntilUs)
|
||||
{
|
||||
return (ProbeState.Active, "sustained loss");
|
||||
}
|
||||
|
||||
if (sinceLoss <= _options.WatchHoldSeconds)
|
||||
{
|
||||
state = ProbeState.Watch;
|
||||
reason = "recent loss";
|
||||
}
|
||||
|
||||
if (_rttHighSeconds >= 3)
|
||||
{
|
||||
state = ProbeState.Watch;
|
||||
reason = "rtt above baseline";
|
||||
}
|
||||
|
||||
double? prior = _options.Prior?.Invoke(nowUs);
|
||||
double? ahead = _options.Prior?.Invoke(nowUs + (long)(_options.PriorLeadSeconds * UsPerSecond));
|
||||
double expected = Math.Max(prior ?? 0, ahead ?? 0);
|
||||
if (expected >= _options.PriorActiveAt)
|
||||
{
|
||||
return (ProbeState.Active, "expected peak");
|
||||
}
|
||||
|
||||
if (expected >= _options.PriorWatchAt && state == ProbeState.Quiet)
|
||||
{
|
||||
state = ProbeState.Watch;
|
||||
reason = "expected loss window";
|
||||
}
|
||||
|
||||
if (state == ProbeState.Quiet && _options.Prior is null && nowUs - _startUs < (long)(_options.WarmupSeconds * UsPerSecond))
|
||||
{
|
||||
state = ProbeState.Watch;
|
||||
reason = "warm-up";
|
||||
}
|
||||
|
||||
return (state, reason);
|
||||
}
|
||||
|
||||
/// <summary>历史上这个时段(现在和将要到来的一段时间)都平稳时,证明窗口可以更长,频率更低。</summary>
|
||||
private double QuietWindowSeconds(long nowUs)
|
||||
{
|
||||
if (_options.Prior is null)
|
||||
{
|
||||
return _options.CertifySeconds;
|
||||
}
|
||||
|
||||
double now = _options.Prior(nowUs) ?? 1;
|
||||
double ahead = _options.Prior(nowUs + (long)(_options.PriorLeadSeconds * UsPerSecond)) ?? 1;
|
||||
return Math.Max(now, ahead) <= 0.02 ? _options.QuietCertifySeconds : _options.CertifySeconds;
|
||||
}
|
||||
|
||||
/// <summary>频率取 MaxHz/k(k 为整数):每个包的权重正好是整数 k,分析时没有舍入误差。</summary>
|
||||
private double Quantize(double hz)
|
||||
{
|
||||
double k = Math.Max(1, Math.Floor(_options.MaxHz / Math.Max(hz, 1e-9)));
|
||||
return _options.MaxHz / k;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分位数精度保护:用最近一个证明窗口里的 RTT 样本估计 p99 的 95% 置信区间(次序统计量),
|
||||
/// 半宽超过容差就升频,远小于容差才降频(单步最多减半),使平稳线路的 RTT 分位数始终保持在设定的精度内。
|
||||
/// </summary>
|
||||
private void UpdateGuard(long nowUs)
|
||||
{
|
||||
long second = nowUs / UsPerSecond;
|
||||
if (_lastGuardSecond != long.MinValue && second - _lastGuardSecond < 10)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastGuardSecond = second;
|
||||
long from = second - (long)_options.CertifySeconds;
|
||||
double[] window = [.. _rtts.Where(r => r.Second >= from).Select(r => r.Ms).Order()];
|
||||
if (window.Length < 200)
|
||||
{
|
||||
_guardHz = double.NaN; // 样本不够估计精度:按最高频率
|
||||
return;
|
||||
}
|
||||
|
||||
int n = window.Length;
|
||||
double rank = n * 0.99;
|
||||
double half = 1.96 * Math.Sqrt(n * 0.99 * 0.01);
|
||||
int lo = Math.Clamp((int)Math.Floor(rank - half), 0, n - 1);
|
||||
int hi = Math.Clamp((int)Math.Ceiling(rank + half), 0, n - 1);
|
||||
double halfWidth = (window[hi] - window[lo]) / 2;
|
||||
double tolerance = Math.Max(_options.RttTolFloorMs, _options.RttTolPct / 100.0 * window[Math.Min(n - 1, (int)rank)]);
|
||||
double ratio = Math.Clamp(halfWidth / tolerance, 0.7, 2.0); // 单步变化有限,避免来回跳
|
||||
double current = Hz;
|
||||
_guardHz = Math.Clamp(current * ratio * ratio, _options.MinHz, _options.MaxHz);
|
||||
}
|
||||
|
||||
private double QuietHz(double windowSeconds) => Math.Min(_options.MaxHz, Math.Max(_options.MinHz, 3.0 / (_options.TauPct / 100.0 * windowSeconds)));
|
||||
|
||||
/// <summary>窗口内发出、回来的包数,以及有丢包的整秒数(一次连续丢包只算一个"事件",不会因为一次短暂中断就判成持续丢包)。</summary>
|
||||
private (int Sent, int Replied, int LossSeconds) Window(long finalSecond, int seconds)
|
||||
{
|
||||
int sent = 0;
|
||||
int replied = 0;
|
||||
int lossSeconds = 0;
|
||||
for (long second = finalSecond; second > finalSecond - seconds; second--)
|
||||
{
|
||||
int slot = Slot(second);
|
||||
if (_second[slot] == second)
|
||||
{
|
||||
sent += _sent[slot];
|
||||
replied += _replied[slot];
|
||||
if (_sent[slot] > _replied[slot])
|
||||
{
|
||||
lossSeconds++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (sent, replied, lossSeconds);
|
||||
}
|
||||
|
||||
private void UpdateRttTrend(long nowUs)
|
||||
{
|
||||
long second = nowUs / UsPerSecond;
|
||||
if (second == _lastRttSecond || double.IsNaN(_rttFast) || double.IsNaN(_rttSlow))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastRttSecond = second;
|
||||
bool high = _rttFast > _rttSlow * _options.RttFactor + _options.RttFloorMs;
|
||||
_rttHighSeconds = high ? _rttHighSeconds + 1 : 0;
|
||||
}
|
||||
|
||||
private static int Slot(long second) => (int)(((second % Buckets) + Buckets) % Buckets);
|
||||
}
|
||||
@@ -186,6 +186,8 @@ public sealed class BlockSpec
|
||||
|
||||
public long Int(string name) => Params.GetInt(name);
|
||||
|
||||
public double Number(string name) => Params.GetNumber(name);
|
||||
|
||||
public bool Bool(string name) => Params.GetBool(name);
|
||||
|
||||
/// <summary>参数或端口变化时调和器重启这个积木(不含流编号:Agent 的流编号每次重建都会重新分配)。</summary>
|
||||
@@ -221,6 +223,9 @@ public sealed class BlockEnv
|
||||
|
||||
public QuicDatagramTransport? Datagrams { get; init; }
|
||||
|
||||
/// <summary>拨号方:这条线路在每个时刻(单调时钟,微秒)丢包的历史概率(0 到 1),自适应探测用它在高发时段提前升频;没有历史为 null。</summary>
|
||||
public Func<long, double?>? LossPrior { get; init; }
|
||||
|
||||
/// <summary>监听方的准入策略(限速等);拨号方为 null。</summary>
|
||||
public IAdmission? Admission { get; init; }
|
||||
|
||||
@@ -257,6 +262,7 @@ public sealed class BlockEnv
|
||||
Log = Log,
|
||||
Session = () => session,
|
||||
Datagrams = Datagrams,
|
||||
LossPrior = LossPrior,
|
||||
Admission = Admission,
|
||||
Counters = Counters,
|
||||
TcpListen = TcpListen,
|
||||
|
||||
@@ -148,7 +148,7 @@ public sealed class BudgetGuard
|
||||
|
||||
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
|
||||
{
|
||||
"udp_echo" or "tcp_echo" => values.GetInt("rate_hz") * 2,
|
||||
"udp_echo" or "tcp_echo" => Kinds.PeakRateHz(kindId, values) * 2,
|
||||
"icmp_echo" => values.GetInt("rate_hz") * 2,
|
||||
"udp_train" => (long)(values.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, values.GetInt("size"))),
|
||||
"connect" => values.GetInt("per_minute") * 6 / 60,
|
||||
|
||||
@@ -8,10 +8,14 @@ public static class Kinds
|
||||
{
|
||||
private static readonly string[] s_directions = ["fwd", "rev", "bidir"];
|
||||
|
||||
/// <summary>这个任务可能达到的最高探测频率(Hz):自适应时是 <c>rate_max_hz</c>,否则是固定的 <c>rate_hz</c>。预算、限速和准入都按它算。</summary>
|
||||
public static long PeakRateHz(string kindId, ParamValues values) =>
|
||||
(kindId is "udp_echo" or "tcp_echo") && values.GetBool("adaptive") ? Math.Max(values.GetInt("rate_max_hz"), values.GetInt("rate_min_hz")) : values.GetInt("rate_hz");
|
||||
|
||||
private static readonly StreamSpec s_echoMsg = new(
|
||||
"msg",
|
||||
[
|
||||
Col.Seq("seq"), Col.U16("size"),
|
||||
Col.Seq("seq"), Col.U16("size"), Col.U16("w"),
|
||||
Col.TsDialer("t1"), Col.TsListener("t2", nullable: true), Col.TsListener("t3", nullable: true), Col.TsDialer("t4", nullable: true),
|
||||
],
|
||||
Side.Dialer,
|
||||
@@ -33,12 +37,17 @@ public static class Kinds
|
||||
Scope.Line,
|
||||
new BlockSet("quic_datagram", "fixed", "fixed", "echo", []),
|
||||
[
|
||||
P.Int("rate_hz", 10, 1, 200, "Hz"),
|
||||
P.Bool("adaptive", true, "Adapt the probe rate to the loss rate: low when the line is quiet, up to rate_max_hz when it loses packets, exponential back-off when it is unreachable."),
|
||||
P.Int("rate_hz", 10, 1, 200, "Hz", "Fixed rate, used when adaptive is false."),
|
||||
P.Int("rate_min_hz", 2, 1, 200, "Hz"),
|
||||
P.Int("rate_max_hz", 10, 1, 200, "Hz", "Highest rate, and the reference grid for sample weights."),
|
||||
P.Number("tau_pct", 0.2, 0.01, 5, "%", "Loss rate a quiet line has to be able to prove below, at 95% confidence."),
|
||||
P.Number("rtt_tol_pct", 2, 0.1, 20, "%", "Half-width of the 95% confidence interval of the RTT p99 a quiet line has to keep, as a share of p99 (never below 0.3 ms)."),
|
||||
P.Int("size", 64, 44, 1400, "B"),
|
||||
P.Int("port", 7443, 1, 65535),
|
||||
],
|
||||
[s_echoMsg],
|
||||
p => Bandwidth.UdpEcho(p.GetInt("rate_hz"), p.GetInt("size")));
|
||||
p => Bandwidth.UdpEcho(PeakRateHz("udp_echo", p), p.GetInt("size")));
|
||||
|
||||
public static readonly KindSpec UdpTrain = new(
|
||||
"udp_train",
|
||||
@@ -66,15 +75,20 @@ public static class Kinds
|
||||
Scope.Line,
|
||||
new BlockSet("tcp", "fixed", "uniform", "echo", ["tcp_info"]),
|
||||
[
|
||||
P.Int("rate_hz", 20, 1, 200, "Hz"),
|
||||
P.Bool("adaptive", true, "Adapt the echo rate: as low as the loss bound and the RTT percentile precision allow when the line is quiet, the maximum when it is not, back-off when it stalls."),
|
||||
P.Int("rate_hz", 20, 1, 200, "Hz", "Fixed rate, used when adaptive is false."),
|
||||
P.Int("rate_min_hz", 2, 1, 200, "Hz"),
|
||||
P.Int("rate_max_hz", 20, 1, 200, "Hz", "Highest rate, and the reference grid for sample weights."),
|
||||
P.Number("tau_pct", 0.2, 0.01, 5, "%", "Share of retransmitted segments a quiet line has to be able to prove below, at 95% confidence."),
|
||||
P.Number("rtt_tol_pct", 2, 0.1, 20, "%", "Half-width of the 95% confidence interval of the RTT p99 a quiet line has to keep, as a share of p99 (never below 0.3 ms)."),
|
||||
P.Int("min", 32, 16, 1400, "B"),
|
||||
P.Int("max", 64, 16, 1400, "B"),
|
||||
P.Int("tcp_info_ms", 100, 10, 1000, "ms"),
|
||||
P.Int("tcp_info_ms", 1000, 10, 1000, "ms"),
|
||||
P.Int("port", 7444, 1, 65535),
|
||||
P.Bool("tls", false),
|
||||
],
|
||||
[s_echoMsg, s_tcpInfo],
|
||||
p => Bandwidth.TcpEcho(p.GetInt("rate_hz"), p.GetInt("max")));
|
||||
p => Bandwidth.TcpEcho(PeakRateHz("tcp_echo", p), p.GetInt("max")));
|
||||
|
||||
public static readonly KindSpec TcpBulk = new(
|
||||
"tcp_bulk",
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using MoShiro.Measurement;
|
||||
|
||||
namespace MoShiro.Measurement.Tests;
|
||||
|
||||
public sealed class AdaptiveRateTests
|
||||
{
|
||||
private const long Second = 1_000_000;
|
||||
|
||||
/// <summary>按当前频率发包、按 <paramref name="lost"/> 决定回不回,每秒评估一次;返回结束时的秒数。</summary>
|
||||
private static long Run(AdaptiveRate rate, long fromSecond, int seconds, Func<long, bool> lost, double rttMs = 20, List<ProbeTransition>? log = null, bool stopAtDead = false)
|
||||
{
|
||||
long second = fromSecond;
|
||||
for (int i = 0; i < seconds; i++, second++)
|
||||
{
|
||||
int probes = Math.Max(1, (int)Math.Round(rate.Hz));
|
||||
if (rate.State == ProbeState.Dead)
|
||||
{
|
||||
probes = i % 4 == 0 ? 1 : 0; // 退避:几秒才发一个
|
||||
}
|
||||
|
||||
for (int k = 0; k < probes; k++)
|
||||
{
|
||||
long sent = second * Second + k * Second / probes;
|
||||
rate.OnSend(sent);
|
||||
if (!lost(second))
|
||||
{
|
||||
rate.OnReply(sent, sent + (long)(rttMs * 1000), rttMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (rate.Evaluate((second + 1) * Second) is { } change)
|
||||
{
|
||||
log?.Add(change);
|
||||
if (stopAtDead && change.To == ProbeState.Dead)
|
||||
{
|
||||
return second + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return second;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuietLine_AfterWarmUp_ProbesJustEnoughToProveTheLossBound()
|
||||
{
|
||||
var rate = new AdaptiveRate(new AdaptiveRateOptions());
|
||||
long at = Run(rate, 0, 600, _ => false);
|
||||
Assert.Equal(ProbeState.Quiet, rate.State);
|
||||
Assert.Equal(5, rate.Hz, 0.01); // 3 / (0.2% × 300 s)
|
||||
Assert.True(at > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SustainedLoss_RaisesToTheMaximum_AndComesBackOnlyAfterTheHoldTimes()
|
||||
{
|
||||
var rate = new AdaptiveRate(new AdaptiveRateOptions());
|
||||
long at = Run(rate, 0, 600, _ => false);
|
||||
at = Run(rate, at, 60, second => second % 10 == 0); // 每 10 秒丢一整秒的包
|
||||
Assert.Equal(ProbeState.Active, rate.State);
|
||||
Assert.Equal(10, rate.Hz);
|
||||
|
||||
at = Run(rate, at, 800, _ => false); // 不到 15 分钟:还在 Active
|
||||
Assert.Equal(ProbeState.Active, rate.State);
|
||||
at = Run(rate, at, 400, _ => false); // 超过 15 分钟:降到 Watch
|
||||
Assert.Equal(ProbeState.Watch, rate.State);
|
||||
Run(rate, at, 1900, _ => false); // 最后一次丢包 30 分钟之后:回到 Quiet
|
||||
Assert.Equal(ProbeState.Quiet, rate.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnIsolatedLoss_OnlyRaisesToWatch()
|
||||
{
|
||||
var rate = new AdaptiveRate(new AdaptiveRateOptions());
|
||||
long at = Run(rate, 0, 600, _ => false);
|
||||
var log = new List<ProbeTransition>();
|
||||
Run(rate, at, 120, second => second == at + 10, log: log);
|
||||
Assert.Equal(ProbeState.Watch, rate.State);
|
||||
Assert.DoesNotContain(log, change => change.To == ProbeState.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unreachable_BacksOffExponentially_AndRecoversOnTheFirstReply()
|
||||
{
|
||||
var rate = new AdaptiveRate(new AdaptiveRateOptions(), new Random(1));
|
||||
long at = Run(rate, 0, 600, _ => false);
|
||||
at = Run(rate, at, 60, _ => true, stopAtDead: true);
|
||||
Assert.Equal(ProbeState.Dead, rate.State);
|
||||
|
||||
var delays = new List<double>();
|
||||
long now = at * Second;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
double delay = rate.NextDelay().TotalSeconds;
|
||||
delays.Add(delay);
|
||||
now += (long)(delay * Second);
|
||||
rate.OnSend(now);
|
||||
}
|
||||
|
||||
Assert.InRange(delays[0], 0.8, 1.2); // 从 1 秒开始
|
||||
Assert.True(delays[3] > delays[0] * 4, string.Join(",", delays)); // 翻倍增长(有抖动)
|
||||
Assert.All(delays, delay => Assert.InRange(delay, 0.8, 36.0)); // 最长 30 秒,抖动 ±20%
|
||||
|
||||
rate.OnReply(now, now + 30_000, 30);
|
||||
Assert.Equal(ProbeState.Watch, rate.Evaluate(now + 2 * Second)!.To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuietLineWithTightRtt_MayDropBelowTheGrid_ButAJitteryOneStaysAtTheMaximum()
|
||||
{
|
||||
var tight = new AdaptiveRate(new AdaptiveRateOptions());
|
||||
Run(tight, 0, 1200, _ => false, rttMs: 20); // RTT 恒定:p99 的置信区间几乎为零,降到丢包下界 5 Hz
|
||||
Assert.Equal(ProbeState.Quiet, tight.State);
|
||||
Assert.Equal(5, tight.Hz, 0.01);
|
||||
|
||||
var jittery = new AdaptiveRate(new AdaptiveRateOptions());
|
||||
var random = new Random(7);
|
||||
long second = 0;
|
||||
for (int i = 0; i < 1200; i++, second++)
|
||||
{
|
||||
int probes = Math.Max(1, (int)Math.Round(jittery.Hz));
|
||||
for (int k = 0; k < probes; k++)
|
||||
{
|
||||
long sent = second * Second + k * Second / probes;
|
||||
double rtt = 20 + random.NextDouble() * random.NextDouble() * 60; // 长尾 20 到 80 ms
|
||||
jittery.OnSend(sent);
|
||||
jittery.OnReply(sent, sent + (long)(rtt * 1000), rtt);
|
||||
}
|
||||
|
||||
jittery.Evaluate((second + 1) * Second);
|
||||
}
|
||||
|
||||
Assert.Equal(10, jittery.Hz, 0.01); // 分位数精度保护:抖动大,样本不能少
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Weight_IsTheTimeAProbeStandsFor_InUnitsOfTheGrid()
|
||||
{
|
||||
var rate = new AdaptiveRate(new AdaptiveRateOptions());
|
||||
Run(rate, 0, 1200, _ => false);
|
||||
Assert.Equal(5, rate.Hz, 0.01);
|
||||
rate.NextDelay();
|
||||
Assert.Equal(2, rate.Weight); // 5 Hz 的一个包代表 2 个 10 Hz 网格间隔
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RttWellAboveTheBaseline_RaisesToWatch()
|
||||
{
|
||||
var rate = new AdaptiveRate(new AdaptiveRateOptions());
|
||||
long at = Run(rate, 0, 900, _ => false, rttMs: 20);
|
||||
Assert.Equal(ProbeState.Quiet, rate.State);
|
||||
Run(rate, at, 30, _ => false, rttMs: 120);
|
||||
Assert.Equal(ProbeState.Watch, rate.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prior_WarmsUpBeforeTheEveningPeak_AndLetsHistoricallyQuietHoursUseTheLowestRate()
|
||||
{
|
||||
const long peakStart = 1_000 * 3600; // 秒
|
||||
double? Prior(long us)
|
||||
{
|
||||
long second = us / Second;
|
||||
return second >= peakStart && second < peakStart + 4 * 3600 ? 0.8 : 0.0;
|
||||
}
|
||||
|
||||
var rate = new AdaptiveRate(new AdaptiveRateOptions { Prior = Prior });
|
||||
long at = Run(rate, 100_000, 1200, _ => false); // 远离高峰,历史平稳:证明窗口拉长,2 Hz
|
||||
Assert.Equal(ProbeState.Quiet, rate.State);
|
||||
Assert.Equal(2, rate.Hz, 0.01);
|
||||
|
||||
Run(rate, peakStart - 1500, 120, _ => false); // 距离高峰不到半小时:提前升到 Active
|
||||
Assert.Equal(ProbeState.Active, rate.State);
|
||||
Assert.True(at > 0);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ public sealed class MeasurementTests
|
||||
{
|
||||
StreamSpec msg = Kinds.UdpEcho.FindStream("msg")!;
|
||||
var dialer = new RowStore(msg, Side.Dialer, 64);
|
||||
Assert.Equal(["size", "t1", "t4"], msg.ColumnsOf(Side.Dialer).Select(c => c.Name));
|
||||
Assert.Equal(["size", "w", "t1", "t4"], msg.ColumnsOf(Side.Dialer).Select(c => c.Name));
|
||||
Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Listener).Select(c => c.Name));
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
@@ -78,7 +78,7 @@ public sealed class MeasurementTests
|
||||
dialer.Set(2, dialer.ColumnIndex("t4"), 5000);
|
||||
RawStream cut = dialer.Cut(1, 4, includeKey: true);
|
||||
Assert.Equal(3, cut.Count);
|
||||
Assert.Equal(["seq", "size", "t1", "t4"], cut.Columns.Select(c => c.Name));
|
||||
Assert.Equal(["seq", "size", "w", "t1", "t4"], cut.Columns.Select(c => c.Name));
|
||||
Assert.Equal([1L, 2L, 3L], cut.Find("seq")!.Values);
|
||||
Assert.Equal([false, true, false], cut.Find("t4")!.Valid!);
|
||||
|
||||
@@ -111,10 +111,10 @@ public sealed class MeasurementTests
|
||||
|
||||
private static RawChunk EchoFragment(params (uint Seq, long T1, long? T4)[] rows)
|
||||
{
|
||||
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
|
||||
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.U16("w"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
|
||||
foreach ((uint seq, long t1, long? t4) in rows)
|
||||
{
|
||||
msg.Add(seq, 64, t1, t4);
|
||||
msg.Add(seq, 64, 1, t1, t4);
|
||||
}
|
||||
|
||||
return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { DialerOffsetUs = 1000 } }, [msg.Build()]);
|
||||
@@ -131,7 +131,7 @@ public sealed class MeasurementTests
|
||||
|
||||
Result<IReadOnlyList<RawStream>> merged = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, source);
|
||||
RawStream msg = Assert.Single(merged.Value);
|
||||
Assert.Equal(["seq", "size", "t1", "t2", "t3", "t4"], msg.Columns.Select(c => c.Name));
|
||||
Assert.Equal(["seq", "size", "w", "t1", "t2", "t3", "t4"], msg.Columns.Select(c => c.Name));
|
||||
Assert.Equal([true, false, true], Enumerable.Range(0, 3).Select(i => msg.Find("t2")!.IsValid(i)));
|
||||
Assert.Equal(2050, msg.Find("t3")!.Values[0]);
|
||||
Assert.False(msg.Find("t4")!.IsValid(1));
|
||||
|
||||
@@ -92,6 +92,7 @@ public sealed class RawChunkCodecTests
|
||||
public void GoldenBody_LayoutIsStable()
|
||||
{
|
||||
// 布局变化(列编码、头部字段、字节序)会让这个测试失败:必须同时升级 container_version 并更新黄金字节。
|
||||
// 只是回显流多了一列权重 w(块头自描述新增列,不用升级 container_version),黄金字节里多出六个 01。
|
||||
RawChunk chunk = SampleChunk();
|
||||
chunk.Header.Clock = new ChunkClock { ListenerMonoUs = 1000, UtcMs = 1_700_000_000_000, DialerOffsetUs = 500 };
|
||||
byte[] body = RawChunkCodec.EncodeBody(chunk);
|
||||
@@ -104,7 +105,7 @@ public sealed class RawChunkCodecTests
|
||||
Assert.Equal(GoldenColumns, columns);
|
||||
}
|
||||
|
||||
private const string GoldenColumns = "00020202020240404040404080fbf3b407c09a0cc09a0cc09a0cc09a0cc09a0c3bc0e0d8b907889c0c90b818889c0c889c0c3ba4e1d8b907889c0c90b818889c0c889c0c2bb4fcf8b407889c0c90b81890b818";
|
||||
private const string GoldenColumns = "00020202020240404040404001010101010180fbf3b407c09a0cc09a0cc09a0cc09a0cc09a0c3bc0e0d8b907889c0c90b818889c0c889c0c3ba4e1d8b907889c0c90b818889c0c889c0c2bb4fcf8b407889c0c90b81890b818";
|
||||
|
||||
[Fact]
|
||||
public void Decode_RejectsBadMagicVersionCrcAndTruncation()
|
||||
|
||||
@@ -33,5 +33,8 @@ public sealed class ChunkView
|
||||
public double ParamNumber(string name, double fallback) =>
|
||||
Header.Params.TryGetValue(name, out JsonElement v) && v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out double d) ? d : fallback;
|
||||
|
||||
public bool ParamBool(string name, bool fallback) =>
|
||||
Header.Params.TryGetValue(name, out JsonElement v) && v.ValueKind is JsonValueKind.True or JsonValueKind.False ? v.GetBoolean() : fallback;
|
||||
|
||||
private static long FloorDiv(long a, long b) => (a / b) - ((a % b != 0 && (a < 0) != (b < 0)) ? 1 : 0);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ internal readonly struct EchoColumns
|
||||
T2 = stream.Find("t2");
|
||||
T3 = stream.Find("t3");
|
||||
T4 = stream.Find("t4");
|
||||
W = stream.Find("w");
|
||||
}
|
||||
|
||||
public int Count { get; }
|
||||
@@ -40,6 +41,11 @@ internal readonly struct EchoColumns
|
||||
|
||||
public RawColumn? T4 { get; }
|
||||
|
||||
/// <summary>每个包的权重(它代表多少个基础网格间隔);旧数据和固定频率的任务没有这一列,按 1 算。</summary>
|
||||
public RawColumn? W { get; }
|
||||
|
||||
public int Weight(int i) => W is null || !W.IsValid(i) ? 1 : (int)Math.Clamp(W.Values[i], 1, 65535);
|
||||
|
||||
public bool HasBasics => T1 is not null && T4 is not null;
|
||||
|
||||
/// <summary>行 i 的发送时刻(UTC 毫秒)。</summary>
|
||||
@@ -75,7 +81,7 @@ internal readonly struct EchoColumns
|
||||
|
||||
internal sealed class RttOp : MetricOp
|
||||
{
|
||||
public override string Signature => "rtt/1";
|
||||
public override string Signature => "rtt/2";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
@@ -100,7 +106,11 @@ internal sealed class RttOp : MetricOp
|
||||
|
||||
if (rttUs >= 0)
|
||||
{
|
||||
sink.Emit(c.SentUtcMs(view, i), rttUs / 1000.0);
|
||||
// 权重 k:这个样本代表 k 个网格间隔,按 k 次累计,分位数就是按时间平均的
|
||||
for (int k = c.Weight(i); k > 0; k--)
|
||||
{
|
||||
sink.Emit(c.SentUtcMs(view, i), rttUs / 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,7 +170,7 @@ internal sealed class SlowShareOp : MetricOp
|
||||
double threshold = Math.Max(baseline * 1.8, baseline + Math.Max(15, baseline * 0.35));
|
||||
foreach ((int row, double ms) in rtts)
|
||||
{
|
||||
sink.Emit(c.SentUtcMs(view, row), ms >= threshold ? 1 : 0, 1);
|
||||
sink.Emit(c.SentUtcMs(view, row), ms >= threshold ? c.Weight(row) : 0, c.Weight(row));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,7 +184,7 @@ internal sealed class LossOp : MetricOp
|
||||
_dir = dir;
|
||||
}
|
||||
|
||||
public override string Signature => "loss/" + _dir + "/1";
|
||||
public override string Signature => "loss/" + _dir + "/2";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
@@ -194,18 +204,18 @@ internal sealed class LossOp : MetricOp
|
||||
switch (_dir)
|
||||
{
|
||||
case Dir.Up:
|
||||
sink.Emit(c.SentUtcMs(view, i), c.IsUpLost(i) ? 1 : 0, 1);
|
||||
sink.Emit(c.SentUtcMs(view, i), c.IsUpLost(i) ? c.Weight(i) : 0, c.Weight(i));
|
||||
break;
|
||||
case Dir.Down:
|
||||
// 只统计上行已经到达 Agent 的包:回复没回来就是下行丢失。
|
||||
if (!c.IsUpLost(i))
|
||||
{
|
||||
sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? 1 : 0, 1);
|
||||
sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? c.Weight(i) : 0, c.Weight(i));
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? 1 : 0, 1);
|
||||
sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? c.Weight(i) : 0, c.Weight(i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -452,7 +462,7 @@ internal sealed class GapOp : MetricOp
|
||||
_thresholdMs = thresholdMs;
|
||||
}
|
||||
|
||||
public override string Signature => $"gap/{_output}/{_rateParam}/{_thresholdMs}/1";
|
||||
public override string Signature => $"gap/{_output}/{_rateParam}/{_thresholdMs}/2";
|
||||
|
||||
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
|
||||
{
|
||||
@@ -462,11 +472,13 @@ internal sealed class GapOp : MetricOp
|
||||
return;
|
||||
}
|
||||
|
||||
double rate = Math.Max(0.1, view.ParamNumber(_rateParam, 1));
|
||||
double expectedMs = 1000.0 / rate;
|
||||
// 自适应频率时,每个包的权重就是它和上一个包的间隔(以 1/rate_max_hz 为单位),期望间隔按它算
|
||||
bool adaptive = view.ParamBool("adaptive", false);
|
||||
double rate = Math.Max(0.1, view.ParamNumber(adaptive ? "rate_max_hz" : _rateParam, 1));
|
||||
int[] order = c.ArrivalOrder();
|
||||
for (int i = 1; i < order.Length; i++)
|
||||
{
|
||||
double expectedMs = 1000.0 * (adaptive ? c.Weight(order[i]) : 1) / rate;
|
||||
double gapMs = (c.T4!.Values[order[i]] - c.T4.Values[order[i - 1]]) / 1000.0;
|
||||
if (gapMs - expectedMs <= _thresholdMs)
|
||||
{
|
||||
|
||||
@@ -31,6 +31,25 @@ public sealed class OpTests
|
||||
Assert.InRange(rtt.Quantile(0.5)!.Value, 50 * 0.955, 50 * 1.045);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SampleWeights_KeepRttQuantilesAndLossTimeAveraged_WhenTheProbeRateChanges()
|
||||
{
|
||||
static EchoPacket At(int i) => new(LostUp: i % 20 < 2, LostDown: false, i % 10 < 2 ? 100_000 : 20_000, 20_000);
|
||||
|
||||
// 每 100 ms 一个包,每个权重 1;对照:只取偶数行,每 200 ms 一个包,每个权重 2——代表同样长的时间
|
||||
RawChunk dense = ChunkFactory.Echo(s_agent, "udp_echo", T0, 100, 100, At);
|
||||
RawChunk sparse = ChunkFactory.Echo(s_agent, "udp_echo", T0, 50, 200, i => At(i * 2), weight: _ => 2);
|
||||
|
||||
MetricSet d = EvaluateOneBucket(dense, "rtt", "loss.up", "loss.total");
|
||||
MetricSet s = EvaluateOneBucket(sparse, "rtt", "loss.up", "loss.total");
|
||||
var dr = (HistogramAcc)d.Items["rtt"];
|
||||
var sr = (HistogramAcc)s.Items["rtt"];
|
||||
Assert.Equal(dr.Count, sr.Count); // 稀疏的 45 个回复各按权重 2 累计,和密集的 90 个一样
|
||||
Assert.Equal(dr.Quantile(0.5), sr.Quantile(0.5));
|
||||
Assert.Equal(dr.Quantile(0.95), sr.Quantile(0.95));
|
||||
Assert.Equal(((RatioAcc)d.Items["loss.up"]).Percent, ((RatioAcc)s.Items["loss.up"]).Percent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loss_SeparatesUpAndDown()
|
||||
{
|
||||
|
||||
@@ -48,7 +48,8 @@ public static class ChunkFactory
|
||||
Func<int, EchoPacket> packet,
|
||||
int durationMs = 10_000,
|
||||
IReadOnlyDictionary<string, JsonElement>? parameters = null,
|
||||
IEnumerable<RawStream>? extraStreams = null)
|
||||
IEnumerable<RawStream>? extraStreams = null,
|
||||
Func<int, int>? weight = null)
|
||||
{
|
||||
var msg = new StreamBuilder(Kinds.Find(kind)!.FindStream("msg")!);
|
||||
for (int i = 0; i < count; i++)
|
||||
@@ -58,7 +59,7 @@ public static class ChunkFactory
|
||||
long t2 = t1 + DialerOffsetUs + p.OwdUpUs;
|
||||
long t3 = t2 + p.ProcessUs;
|
||||
long t4 = t3 - DialerOffsetUs + p.OwdDownUs;
|
||||
msg.Add(i, 64, t1, p.LostUp ? null : t2, p.LostUp ? null : t3, p.LostUp || p.LostDown ? null : t4);
|
||||
msg.Add(i, 64, weight?.Invoke(i) ?? 1, t1, p.LostUp ? null : t2, p.LostUp ? null : t3, p.LostUp || p.LostDown ? null : t4);
|
||||
}
|
||||
|
||||
var streams = new List<RawStream> { msg.Build() };
|
||||
|
||||
@@ -160,10 +160,10 @@ public sealed class DialerSimulator : IAsyncDisposable
|
||||
_echoes.TryRemove(task.StreamId, out _);
|
||||
_quic.Unregister(task.StreamId);
|
||||
|
||||
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
|
||||
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.U16("w"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
|
||||
foreach ((uint seq, long t1) in collector.Sent.OrderBy(p => p.Key))
|
||||
{
|
||||
msg.Add(seq, size, t1, collector.Received.TryGetValue(seq, out long t4) ? t4 : null);
|
||||
msg.Add(seq, size, 1, t1, collector.Received.TryGetValue(seq, out long t4) ? t4 : null);
|
||||
}
|
||||
|
||||
RawChunkHeader header = Header("udp_echo", task.Id, null, null, startUtc, (int)Math.Max(1, count * interval.TotalMilliseconds), task.Params);
|
||||
@@ -195,7 +195,7 @@ public sealed class DialerSimulator : IAsyncDisposable
|
||||
await stream.WriteAsync(auth, ct);
|
||||
|
||||
long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
|
||||
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.U16("w"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
|
||||
int received = 0;
|
||||
for (uint seq = 0; seq < count; seq++)
|
||||
{
|
||||
@@ -207,7 +207,7 @@ public sealed class DialerSimulator : IAsyncDisposable
|
||||
byte[]? reply = await TcpWire.ReadMessageAsync(stream, ct);
|
||||
long? t4 = reply is not null && TcpWire.TryReadEchoBody(reply, out uint echoed, out _) && echoed == seq ? NowUs() : null;
|
||||
received += t4 is null ? 0 : 1;
|
||||
msg.Add(seq, body.Length, t1, t4);
|
||||
msg.Add(seq, body.Length, 1, t1, t4);
|
||||
await Task.Delay(interval, ct);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user