feat(benchmark): add --progress logging for engine solves

Long whole-job solves ran silently, so there was no way to tell a slow
engine from a hung one until the timeout fired. --progress hands each
solve a JobProgressLog that prints [job/engine] lines for start, finish
(or failure/timeout), every plate commit, and candidate evaluations
throttled to one line per 2 s so parallel runs stay readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-25 14:03:10 -04:00
co-authored by Claude Opus 5.5
parent 695ccc0a3b
commit 23dd99fa2f
7 changed files with 193 additions and 7 deletions
+14 -4
View File
@@ -33,7 +33,8 @@ namespace OpenNest.Benchmark
double? salvageRate = null,
double? minimumSalvageDimension = null,
string outputDirectory = null,
int maxParallelism = 1
int maxParallelism = 1,
System.IO.TextWriter progressLog = null
)
{
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
@@ -68,7 +69,8 @@ namespace OpenNest.Benchmark
pairs[i].Engine,
salvageRate,
minimumSalvageDimension,
outputDirectory
outputDirectory,
progressLog
)
);
@@ -223,10 +225,15 @@ namespace OpenNest.Benchmark
NestingEngineInfo engineInfo,
double? salvageRate,
double? minimumSalvageDimension,
string outputDirectory
string outputDirectory,
System.IO.TextWriter progressLog
)
{
var requested = job.TotalRequestedQuantity;
var log = progressLog == null
? null
: new JobProgressLog(progressLog, $"{job.Name}/{engineInfo.Name}");
log?.Started();
var sw = Stopwatch.StartNew();
try
@@ -234,7 +241,8 @@ namespace OpenNest.Benchmark
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
var engine = engineInfo.Factory();
using var cts = new CancellationTokenSource(SolveTimeout);
var jobResult = engine.Solve(nestJob, null, cts.Token);
var jobResult = engine.Solve(nestJob, log, cts.Token);
log?.Finished(jobResult, sw.ElapsedMilliseconds);
var materialized = NestResultMaterializer.Materialize(nestJob, jobResult);
var plateRuns = materialized
@@ -357,6 +365,7 @@ namespace OpenNest.Benchmark
catch (OperationCanceledException)
{
sw.Stop();
log?.Failed("timed out", sw.ElapsedMilliseconds);
return new JobResult
{
EngineName = engineInfo.Name,
@@ -371,6 +380,7 @@ namespace OpenNest.Benchmark
catch (Exception ex)
{
sw.Stop();
log?.Failed($"{ex.GetType().Name}: {ex.Message}", sw.ElapsedMilliseconds);
return new JobResult
{
EngineName = engineInfo.Name,
+88
View File
@@ -0,0 +1,88 @@
using System;
using System.Diagnostics;
using System.IO;
using OpenNest.Engine.Jobs;
namespace OpenNest.Benchmark
{
/// <summary>
/// Writes one solve's NestJobProgress as log lines prefixed with "[job/engine]". Every
/// PlateCommitted is written; EvaluatingCandidate is throttled to one line per interval so a
/// chatty engine cannot flood the console. One instance per solve; Report is thread-safe.
/// </summary>
public sealed class JobProgressLog : IProgress<NestJobProgress>
{
public static readonly TimeSpan DefaultInterval = TimeSpan.FromSeconds(2);
private readonly TextWriter writer;
private readonly string label;
private readonly TimeSpan interval;
private readonly Func<TimeSpan> clock;
private readonly object sync = new();
private TimeSpan? lastEvaluating;
public JobProgressLog(
TextWriter writer,
string label,
TimeSpan? interval = null,
Func<TimeSpan> clock = null
)
{
this.writer = writer ?? throw new ArgumentNullException(nameof(writer));
this.label = label;
this.interval = interval ?? DefaultInterval;
if (clock == null)
{
var stopwatch = Stopwatch.StartNew();
clock = () => stopwatch.Elapsed;
}
this.clock = clock;
}
public void Started() => Write("started");
public void Finished(NestJobResult result, long elapsedMs) =>
Write(
$"finished in {elapsedMs} ms: {result.Status} ({result.StopReason}), "
+ $"{result.Plates.Count} plate(s)"
);
public void Failed(string error, long elapsedMs) =>
Write($"failed after {elapsedMs} ms: {error}");
public void Report(NestJobProgress value)
{
if (value == null)
return;
if (value.Stage == NestJobStage.PlateCommitted)
{
Write(
$"committed plate {value.CommittedPlates} on stock {value.StockId} "
+ $"({value.CommittedParts} parts placed)"
);
return;
}
lock (sync)
{
var now = clock();
if (lastEvaluating.HasValue && now - lastEvaluating.Value < interval)
return;
lastEvaluating = now;
}
var plate = value.PlateIndex >= 0 ? value.PlateIndex + 1 : value.CommittedPlates + 1;
Write(
$"evaluating plate {plate} on stock {value.StockId} "
+ $"({value.CommittedPlates} plate(s), {value.CommittedParts} parts committed)"
);
}
private void Write(string message)
{
lock (sync)
writer.WriteLine($"[{label}] {message}");
}
}
}
+10 -1
View File
@@ -123,7 +123,8 @@ static class BenchmarkConsole
options.SalvageRate,
options.MinimumSalvageDimension,
options.OutputDirectory,
options.Parallel
options.Parallel,
options.Progress ? Console.Out : null
);
Report.PrintDetailed(results);
@@ -196,6 +197,10 @@ static class BenchmarkConsole
);
break;
case "--progress":
o.Progress = true;
break;
case "--help":
PrintUsage();
return null;
@@ -322,6 +327,9 @@ static class BenchmarkConsole
Console.Error.WriteLine(
" which gives the cleanest per-engine timings)"
);
Console.Error.WriteLine(
" --progress Log each solve's start, engine progress and finish"
);
Console.Error.WriteLine(" --help Show this message");
}
@@ -336,5 +344,6 @@ static class BenchmarkConsole
public double? SalvageRate;
public double? MinimumSalvageDimension;
public int Parallel = 3;
public bool Progress;
}
}