ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
254 lines
9.0 KiB
C#
254 lines
9.0 KiB
C#
using System.Text.Json;
|
||
using MoShiro.Protocol;
|
||
|
||
namespace MoShiro.Backend.Analysis;
|
||
|
||
public sealed class TestStepResult
|
||
{
|
||
public int Index { get; set; }
|
||
|
||
public double TargetMbps { get; set; }
|
||
|
||
public double LossPct { get; set; }
|
||
|
||
public double? JitterP95Ms { get; set; }
|
||
|
||
public double DeliveredMbps { get; set; }
|
||
}
|
||
|
||
public sealed class TestResult
|
||
{
|
||
/// <summary>导出值:与持续指标同一套 id,另有 <c>hy2.*</c> 等测试专用的值。</summary>
|
||
public Dictionary<string, double> Metrics { get; } = new(StringComparer.Ordinal);
|
||
|
||
/// <summary>逐步或逐秒的曲线,例如码率–丢包曲线。</summary>
|
||
public Dictionary<string, double[]> Series { get; } = new(StringComparer.Ordinal);
|
||
|
||
public List<TestStepResult> Steps { get; } = [];
|
||
|
||
public List<string> Caveats { get; } = [];
|
||
}
|
||
|
||
/// <summary>
|
||
/// 测试结果的计算:所有阶段的块到达之后,按方案算出指标和曲线。
|
||
/// udp_train 的各个阶段作为码率阶梯:可持续码率、有效吞吐、限速开始的位置。
|
||
/// </summary>
|
||
public static class TestEvaluator
|
||
{
|
||
public const double SustainableLossPct = 5;
|
||
public const double SustainableLossLoosePct = 10;
|
||
public const double QosShiftPct = 5;
|
||
|
||
public static TestResult Evaluate(string plan, IReadOnlyList<RawChunk> chunks)
|
||
{
|
||
var result = new TestResult();
|
||
EvaluateMetrics(chunks, result);
|
||
if (chunks.Any(c => c.Header.Kind == Kinds.UdpTrain.Id))
|
||
{
|
||
EvaluateLadder(chunks, result);
|
||
}
|
||
|
||
if (chunks.Any(c => c.Header.Partial))
|
||
{
|
||
result.Caveats.Add("partial_data");
|
||
}
|
||
|
||
if (chunks.Any(c => c.Header.Port is not (0 or 443)))
|
||
{
|
||
result.Caveats.Add("port_differs_from_service");
|
||
}
|
||
|
||
_ = plan;
|
||
return result;
|
||
}
|
||
|
||
private static void EvaluateMetrics(IReadOnlyList<RawChunk> chunks, TestResult result)
|
||
{
|
||
var buckets = new SortedDictionary<long, MetricSet>();
|
||
long span = 24L * 3600 * 1000;
|
||
foreach (RawChunk chunk in chunks)
|
||
{
|
||
WindowEvaluator.Default.Evaluate(chunk, span, buckets);
|
||
}
|
||
|
||
var merged = new MetricSet();
|
||
foreach (MetricSet set in buckets.Values)
|
||
{
|
||
merged.Merge(set);
|
||
}
|
||
|
||
double seconds = chunks.Sum(c => c.Header.DurationMs) / 1000.0;
|
||
foreach ((MetricDef def, MetricExport export) in MetricRegistry.AllExports())
|
||
{
|
||
if (merged.TryGet(def.Id, out Accumulator acc) && MetricDef.Export(acc, export, seconds) is { } value)
|
||
{
|
||
result.Metrics[export.Id] = value;
|
||
}
|
||
}
|
||
}
|
||
|
||
private static void EvaluateLadder(IReadOnlyList<RawChunk> chunks, TestResult result)
|
||
{
|
||
var steps = new List<TestStepResult>();
|
||
var perSecond = new SortedDictionary<long, double>();
|
||
int index = 0;
|
||
|
||
// 一个阶段(一档码率)可能被切成好几个块(一个块最多 50000 条记录,100 Mbit/s 的 10 秒要切 3~4 块):
|
||
// 阶梯里的一步是一个阶段,不是一个块——同一阶段的块先合并,再算丢包、抖动和实际吞吐。
|
||
foreach (IGrouping<int, RawChunk> phase in chunks.Where(c => c.Header.Kind == Kinds.UdpTrain.Id).GroupBy(c => c.Header.Phase ?? 0).OrderBy(g => g.Key))
|
||
{
|
||
RawChunk[] parts = [.. phase.Where(c => c.Find("pkt") is not null).OrderBy(c => c.Header.StartUtcMs)];
|
||
if (parts.Length == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
var merged = new MetricSet();
|
||
long bytes = 0;
|
||
foreach (RawChunk chunk in parts)
|
||
{
|
||
var buckets = new SortedDictionary<long, MetricSet>();
|
||
WindowEvaluator.Default.Evaluate(chunk, 24L * 3600 * 1000, buckets, new HashSet<string> { "train.loss", "train.jitter", "train.rate" });
|
||
foreach (MetricSet set in buckets.Values)
|
||
{
|
||
merged.Merge(set);
|
||
}
|
||
|
||
bytes += ReceivedBytes(chunk.Find("pkt")!);
|
||
|
||
var seconds = new SortedDictionary<long, MetricSet>();
|
||
WindowEvaluator.Default.Evaluate(chunk, 1000, seconds, new HashSet<string> { "train.rate" });
|
||
foreach ((long start, MetricSet set) in seconds)
|
||
{
|
||
if (set.TryGet("train.rate", out Accumulator rate) && rate is HistogramAcc rh && rh.Mean is { } mean)
|
||
{
|
||
perSecond[start] = mean;
|
||
}
|
||
}
|
||
}
|
||
|
||
var step = new TestStepResult
|
||
{
|
||
Index = index++,
|
||
TargetMbps = TargetMbps(parts[0].Header),
|
||
};
|
||
if (merged.TryGet("train.loss", out Accumulator loss) && loss is RatioAcc ratio)
|
||
{
|
||
step.LossPct = ratio.Percent ?? 0;
|
||
}
|
||
|
||
if (merged.TryGet("train.jitter", out Accumulator jitter) && jitter is HistogramAcc jh)
|
||
{
|
||
step.JitterP95Ms = jh.Quantile(0.95);
|
||
}
|
||
|
||
// 阶段的时长:从发送时间戳算(块头里的时长是 Probe 填的,每个块可能都带着整个阶段的时长,不能相加也不能取并集)
|
||
double spanSeconds = SendSpanSeconds(parts) ?? ((parts.Max(c => c.Header.StartUtcMs + c.Header.DurationMs) - parts.Min(c => c.Header.StartUtcMs)) / 1000.0);
|
||
step.DeliveredMbps = spanSeconds > 0 ? bytes * 8 / spanSeconds / 1_000_000 : 0;
|
||
steps.Add(step);
|
||
}
|
||
|
||
if (steps.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
result.Steps.AddRange(steps);
|
||
result.Series["step_mbps"] = [.. steps.Select(s => s.TargetMbps)];
|
||
result.Series["loss_pct"] = [.. steps.Select(s => s.LossPct)];
|
||
result.Series["delivered_mbps"] = [.. steps.Select(s => s.DeliveredMbps)];
|
||
result.Series["jitter_p95_ms"] = [.. steps.Select(s => s.JitterP95Ms ?? double.NaN)];
|
||
|
||
TestStepResult? sustainable = steps.Where(s => s.LossPct <= SustainableLossPct).MaxBy(s => s.TargetMbps);
|
||
TestStepResult? loose = steps.Where(s => s.LossPct <= SustainableLossLoosePct).MaxBy(s => s.TargetMbps);
|
||
result.Metrics["hy2.sustainable_mbps"] = sustainable?.TargetMbps ?? 0;
|
||
result.Metrics["hy2.sustainable10_mbps"] = loose?.TargetMbps ?? 0;
|
||
result.Metrics["hy2.effective_mbps"] = steps.Max(s => s.DeliveredMbps);
|
||
|
||
// 限速开始:丢包率随码率阶梯出现显著跳变的那一档。
|
||
int onset = ChangePoint.FirstJump([.. steps.Select(s => s.LossPct)], QosShiftPct);
|
||
if (onset >= 0)
|
||
{
|
||
result.Metrics["hy2.qos_onset_mbps"] = steps[onset].TargetMbps;
|
||
}
|
||
|
||
if (perSecond.Count >= 6)
|
||
{
|
||
double[] rates = [.. perSecond.Values];
|
||
double peak = rates.Max();
|
||
int second = ChangePoint.Detect(rates, Math.Max(1, peak * 0.2));
|
||
if (second >= 0 && rates[second..].Average() < rates[..second].Average())
|
||
{
|
||
result.Metrics["hy2.qos_onset_s"] = second;
|
||
}
|
||
|
||
result.Series["delivered_mbps_per_s"] = rates;
|
||
}
|
||
}
|
||
|
||
private static double TargetMbps(RawChunkHeader header) =>
|
||
header.Params.TryGetValue("mbps", out JsonElement v) && v.ValueKind == JsonValueKind.Number ? v.GetDouble() : 0;
|
||
|
||
/// <summary>
|
||
/// 发送端的时间戳(Agent 时钟)在 UTC 上的跨度:所有块里第一个包到最后一个包,再补上一个平均间隔(n 个包有 n−1 个间隔)。
|
||
/// 没有时间戳就返回 null。
|
||
/// </summary>
|
||
private static double? SendSpanSeconds(RawChunk[] parts)
|
||
{
|
||
long min = long.MaxValue;
|
||
long max = long.MinValue;
|
||
long count = 0;
|
||
foreach (RawChunk chunk in parts)
|
||
{
|
||
RawStream? pkt = chunk.Find("pkt");
|
||
RawColumn? send = pkt?.Find("t_send");
|
||
if (pkt is null || send is null)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
ChunkClock clock = chunk.Header.Clock;
|
||
for (int i = 0; i < pkt.Count; i++)
|
||
{
|
||
if (!send.IsValid(i))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
long utcUs = (clock.UtcMs * 1000) + (send.Values[i] - clock.AgentMonoUs);
|
||
min = Math.Min(min, utcUs);
|
||
max = Math.Max(max, utcUs);
|
||
count++;
|
||
}
|
||
}
|
||
|
||
if (count < 2 || max <= min)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
return (max - min) / 1_000_000.0 * count / (count - 1);
|
||
}
|
||
|
||
private static long ReceivedBytes(RawStream pkt)
|
||
{
|
||
RawColumn? recv = pkt.Find("t_recv");
|
||
RawColumn? size = pkt.Find("size");
|
||
if (recv is null || size is null)
|
||
{
|
||
return 0;
|
||
}
|
||
|
||
long bytes = 0;
|
||
for (int i = 0; i < pkt.Count; i++)
|
||
{
|
||
if (recv.IsValid(i))
|
||
{
|
||
bytes += size.Values[i];
|
||
}
|
||
}
|
||
|
||
return bytes;
|
||
}
|
||
}
|