376 lines
17 KiB
C#
376 lines
17 KiB
C#
using System.Globalization;
|
|
using StellaOps.Bench.LinkNotMerge.Baseline;
|
|
using StellaOps.Bench.LinkNotMerge.Reporting;
|
|
|
|
namespace StellaOps.Bench.LinkNotMerge;
|
|
|
|
internal static class Program
|
|
{
|
|
public static async Task<int> Main(string[] args)
|
|
{
|
|
try
|
|
{
|
|
var options = ProgramOptions.Parse(args);
|
|
var config = await BenchmarkConfig.LoadAsync(options.ConfigPath).ConfigureAwait(false);
|
|
var baseline = await BaselineLoader.LoadAsync(options.BaselinePath, CancellationToken.None).ConfigureAwait(false);
|
|
|
|
var results = new List<ScenarioResult>();
|
|
var reports = new List<BenchmarkScenarioReport>();
|
|
var failures = new List<string>();
|
|
|
|
foreach (var scenario in config.Scenarios)
|
|
{
|
|
var iterations = scenario.ResolveIterations(config.Iterations);
|
|
var runner = new LinkNotMergeScenarioRunner(scenario);
|
|
var execution = runner.Execute(iterations, CancellationToken.None);
|
|
|
|
var totalStats = DurationStatistics.From(execution.TotalDurationsMs);
|
|
var insertStats = DurationStatistics.From(execution.InsertDurationsMs);
|
|
var correlationStats = DurationStatistics.From(execution.CorrelationDurationsMs);
|
|
var allocationStats = AllocationStatistics.From(execution.AllocatedMb);
|
|
var throughputStats = ThroughputStatistics.From(execution.TotalThroughputsPerSecond);
|
|
var insertThroughputStats = ThroughputStatistics.From(execution.InsertThroughputsPerSecond);
|
|
|
|
var thresholdMs = scenario.ThresholdMs ?? options.ThresholdMs ?? config.ThresholdMs;
|
|
var throughputFloor = scenario.MinThroughputPerSecond ?? options.MinThroughputPerSecond ?? config.MinThroughputPerSecond;
|
|
var insertThroughputFloor = scenario.MinInsertThroughputPerSecond ?? options.MinInsertThroughputPerSecond ?? config.MinInsertThroughputPerSecond;
|
|
var allocationLimit = scenario.MaxAllocatedMb ?? options.MaxAllocatedMb ?? config.MaxAllocatedMb;
|
|
|
|
var result = new ScenarioResult(
|
|
scenario.ScenarioId,
|
|
scenario.DisplayLabel,
|
|
iterations,
|
|
execution.ObservationCount,
|
|
execution.AliasGroups,
|
|
execution.LinksetCount,
|
|
totalStats,
|
|
insertStats,
|
|
correlationStats,
|
|
throughputStats,
|
|
insertThroughputStats,
|
|
allocationStats,
|
|
thresholdMs,
|
|
throughputFloor,
|
|
insertThroughputFloor,
|
|
allocationLimit);
|
|
|
|
results.Add(result);
|
|
|
|
if (thresholdMs is { } threshold && result.TotalStatistics.MaxMs > threshold)
|
|
{
|
|
failures.Add($"{result.Id} exceeded total latency threshold: {result.TotalStatistics.MaxMs:F2} ms > {threshold:F2} ms");
|
|
}
|
|
|
|
if (throughputFloor is { } floor && result.TotalThroughputStatistics.MinPerSecond < floor)
|
|
{
|
|
failures.Add($"{result.Id} fell below throughput floor: {result.TotalThroughputStatistics.MinPerSecond:N0} obs/s < {floor:N0} obs/s");
|
|
}
|
|
|
|
if (insertThroughputFloor is { } insertFloor && result.InsertThroughputStatistics.MinPerSecond < insertFloor)
|
|
{
|
|
failures.Add($"{result.Id} fell below insert throughput floor: {result.InsertThroughputStatistics.MinPerSecond:N0} ops/s < {insertFloor:N0} ops/s");
|
|
}
|
|
|
|
if (allocationLimit is { } limit && result.AllocationStatistics.MaxAllocatedMb > limit)
|
|
{
|
|
failures.Add($"{result.Id} exceeded allocation budget: {result.AllocationStatistics.MaxAllocatedMb:F2} MB > {limit:F2} MB");
|
|
}
|
|
|
|
baseline.TryGetValue(result.Id, out var baselineEntry);
|
|
var report = new BenchmarkScenarioReport(result, baselineEntry, options.RegressionLimit);
|
|
reports.Add(report);
|
|
failures.AddRange(report.BuildRegressionFailureMessages());
|
|
}
|
|
|
|
TablePrinter.Print(results);
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.CsvOutPath))
|
|
{
|
|
CsvWriter.Write(options.CsvOutPath!, results);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.JsonOutPath))
|
|
{
|
|
var metadata = new BenchmarkJsonMetadata(
|
|
SchemaVersion: "linknotmerge-bench/1.0",
|
|
CapturedAtUtc: (options.CapturedAtUtc ?? DateTimeOffset.UtcNow).ToUniversalTime(),
|
|
Commit: options.Commit,
|
|
Environment: options.Environment);
|
|
|
|
await BenchmarkJsonWriter.WriteAsync(options.JsonOutPath!, metadata, reports, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(options.PrometheusOutPath))
|
|
{
|
|
PrometheusWriter.Write(options.PrometheusOutPath!, reports);
|
|
}
|
|
|
|
if (failures.Count > 0)
|
|
{
|
|
Console.Error.WriteLine();
|
|
Console.Error.WriteLine("Benchmark failures detected:");
|
|
foreach (var failure in failures.Distinct())
|
|
{
|
|
Console.Error.WriteLine($" - {failure}");
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"linknotmerge-bench error: {ex.Message}");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
private sealed record ProgramOptions(
|
|
string ConfigPath,
|
|
int? Iterations,
|
|
double? ThresholdMs,
|
|
double? MinThroughputPerSecond,
|
|
double? MinInsertThroughputPerSecond,
|
|
double? MaxAllocatedMb,
|
|
string? CsvOutPath,
|
|
string? JsonOutPath,
|
|
string? PrometheusOutPath,
|
|
string BaselinePath,
|
|
DateTimeOffset? CapturedAtUtc,
|
|
string? Commit,
|
|
string? Environment,
|
|
double? RegressionLimit)
|
|
{
|
|
public static ProgramOptions Parse(string[] args)
|
|
{
|
|
var configPath = DefaultConfigPath();
|
|
var baselinePath = DefaultBaselinePath();
|
|
|
|
int? iterations = null;
|
|
double? thresholdMs = null;
|
|
double? minThroughput = null;
|
|
double? minInsertThroughput = null;
|
|
double? maxAllocated = null;
|
|
string? csvOut = null;
|
|
string? jsonOut = null;
|
|
string? promOut = null;
|
|
DateTimeOffset? capturedAt = null;
|
|
string? commit = null;
|
|
string? environment = null;
|
|
double? regressionLimit = null;
|
|
|
|
for (var index = 0; index < args.Length; index++)
|
|
{
|
|
var current = args[index];
|
|
switch (current)
|
|
{
|
|
case "--config":
|
|
EnsureNext(args, index);
|
|
configPath = Path.GetFullPath(args[++index]);
|
|
break;
|
|
case "--iterations":
|
|
EnsureNext(args, index);
|
|
iterations = int.Parse(args[++index], CultureInfo.InvariantCulture);
|
|
break;
|
|
case "--threshold-ms":
|
|
EnsureNext(args, index);
|
|
thresholdMs = double.Parse(args[++index], CultureInfo.InvariantCulture);
|
|
break;
|
|
case "--min-throughput":
|
|
EnsureNext(args, index);
|
|
minThroughput = double.Parse(args[++index], CultureInfo.InvariantCulture);
|
|
break;
|
|
case "--min-insert-throughput":
|
|
EnsureNext(args, index);
|
|
minInsertThroughput = double.Parse(args[++index], CultureInfo.InvariantCulture);
|
|
break;
|
|
case "--max-allocated-mb":
|
|
EnsureNext(args, index);
|
|
maxAllocated = double.Parse(args[++index], CultureInfo.InvariantCulture);
|
|
break;
|
|
case "--csv":
|
|
EnsureNext(args, index);
|
|
csvOut = args[++index];
|
|
break;
|
|
case "--json":
|
|
EnsureNext(args, index);
|
|
jsonOut = args[++index];
|
|
break;
|
|
case "--prometheus":
|
|
EnsureNext(args, index);
|
|
promOut = args[++index];
|
|
break;
|
|
case "--baseline":
|
|
EnsureNext(args, index);
|
|
baselinePath = Path.GetFullPath(args[++index]);
|
|
break;
|
|
case "--captured-at":
|
|
EnsureNext(args, index);
|
|
capturedAt = DateTimeOffset.Parse(args[++index], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
|
|
break;
|
|
case "--commit":
|
|
EnsureNext(args, index);
|
|
commit = args[++index];
|
|
break;
|
|
case "--environment":
|
|
EnsureNext(args, index);
|
|
environment = args[++index];
|
|
break;
|
|
case "--regression-limit":
|
|
EnsureNext(args, index);
|
|
regressionLimit = double.Parse(args[++index], CultureInfo.InvariantCulture);
|
|
break;
|
|
case "--help":
|
|
case "-h":
|
|
PrintUsage();
|
|
System.Environment.Exit(0);
|
|
break;
|
|
default:
|
|
throw new ArgumentException($"Unknown argument '{current}'.");
|
|
}
|
|
}
|
|
|
|
return new ProgramOptions(
|
|
configPath,
|
|
iterations,
|
|
thresholdMs,
|
|
minThroughput,
|
|
minInsertThroughput,
|
|
maxAllocated,
|
|
csvOut,
|
|
jsonOut,
|
|
promOut,
|
|
baselinePath,
|
|
capturedAt,
|
|
commit,
|
|
environment,
|
|
regressionLimit);
|
|
}
|
|
|
|
private static string DefaultConfigPath()
|
|
{
|
|
var binaryDir = AppContext.BaseDirectory;
|
|
var projectDir = Path.GetFullPath(Path.Combine(binaryDir, "..", "..", ".."));
|
|
var benchRoot = Path.GetFullPath(Path.Combine(projectDir, ".."));
|
|
return Path.Combine(benchRoot, "config.json");
|
|
}
|
|
|
|
private static string DefaultBaselinePath()
|
|
{
|
|
var binaryDir = AppContext.BaseDirectory;
|
|
var projectDir = Path.GetFullPath(Path.Combine(binaryDir, "..", "..", ".."));
|
|
var benchRoot = Path.GetFullPath(Path.Combine(projectDir, ".."));
|
|
return Path.Combine(benchRoot, "baseline.csv");
|
|
}
|
|
|
|
private static void EnsureNext(string[] args, int index)
|
|
{
|
|
if (index + 1 >= args.Length)
|
|
{
|
|
throw new ArgumentException("Missing value for argument.");
|
|
}
|
|
}
|
|
|
|
private static void PrintUsage()
|
|
{
|
|
Console.WriteLine("Usage: linknotmerge-bench [options]");
|
|
Console.WriteLine();
|
|
Console.WriteLine("Options:");
|
|
Console.WriteLine(" --config <path> Path to benchmark configuration JSON.");
|
|
Console.WriteLine(" --iterations <count> Override iteration count.");
|
|
Console.WriteLine(" --threshold-ms <value> Global latency threshold in milliseconds.");
|
|
Console.WriteLine(" --min-throughput <value> Global throughput floor (observations/second).");
|
|
Console.WriteLine(" --min-insert-throughput <value> Insert throughput floor (ops/second).");
|
|
Console.WriteLine(" --max-allocated-mb <value> Global allocation ceiling (MB).");
|
|
Console.WriteLine(" --csv <path> Write CSV results to path.");
|
|
Console.WriteLine(" --json <path> Write JSON results to path.");
|
|
Console.WriteLine(" --prometheus <path> Write Prometheus exposition metrics to path.");
|
|
Console.WriteLine(" --baseline <path> Baseline CSV path.");
|
|
Console.WriteLine(" --captured-at <iso8601> Timestamp to embed in JSON metadata.");
|
|
Console.WriteLine(" --commit <sha> Commit identifier for metadata.");
|
|
Console.WriteLine(" --environment <name> Environment label for metadata.");
|
|
Console.WriteLine(" --regression-limit <value> Regression multiplier (default 1.15).");
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static class TablePrinter
|
|
{
|
|
public static void Print(IEnumerable<ScenarioResult> results)
|
|
{
|
|
Console.WriteLine("Scenario | Observations | Aliases | Linksets | Total(ms) | Correl(ms) | Insert(ms) | Min k/s | Ins k/s | Alloc(MB)");
|
|
Console.WriteLine("---------------------------- | ------------- | ------- | -------- | ---------- | ---------- | ----------- | -------- | --------- | --------");
|
|
foreach (var row in results)
|
|
{
|
|
Console.WriteLine(string.Join(" | ", new[]
|
|
{
|
|
row.IdColumn,
|
|
row.ObservationsColumn,
|
|
row.AliasColumn,
|
|
row.LinksetColumn,
|
|
row.TotalMeanColumn,
|
|
row.CorrelationMeanColumn,
|
|
row.InsertMeanColumn,
|
|
row.ThroughputColumn,
|
|
row.InsertThroughputColumn,
|
|
row.AllocatedColumn,
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
internal static class CsvWriter
|
|
{
|
|
public static void Write(string path, IEnumerable<ScenarioResult> results)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
|
ArgumentNullException.ThrowIfNull(results);
|
|
|
|
var resolved = Path.GetFullPath(path);
|
|
var directory = Path.GetDirectoryName(resolved);
|
|
if (!string.IsNullOrEmpty(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
using var stream = new FileStream(resolved, FileMode.Create, FileAccess.Write, FileShare.None);
|
|
using var writer = new StreamWriter(stream);
|
|
writer.WriteLine("scenario,iterations,observations,aliases,linksets,mean_total_ms,p95_total_ms,max_total_ms,mean_insert_ms,mean_correlation_ms,mean_throughput_per_sec,min_throughput_per_sec,mean_insert_throughput_per_sec,min_insert_throughput_per_sec,max_allocated_mb");
|
|
|
|
foreach (var result in results)
|
|
{
|
|
writer.Write(result.Id);
|
|
writer.Write(',');
|
|
writer.Write(result.Iterations.ToString(CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.ObservationCount.ToString(CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.AliasGroups.ToString(CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.LinksetCount.ToString(CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.TotalStatistics.MeanMs.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.TotalStatistics.P95Ms.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.TotalStatistics.MaxMs.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.InsertStatistics.MeanMs.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.CorrelationStatistics.MeanMs.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.TotalThroughputStatistics.MeanPerSecond.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.TotalThroughputStatistics.MinPerSecond.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.InsertThroughputStatistics.MeanPerSecond.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.InsertThroughputStatistics.MinPerSecond.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.Write(',');
|
|
writer.Write(result.AllocationStatistics.MaxAllocatedMb.ToString("F4", CultureInfo.InvariantCulture));
|
|
writer.WriteLine();
|
|
}
|
|
}
|
|
}
|