feat(benchmark): build jobs from DXF manifests and run solves in parallel

Benchmark jobs could only come from .nest files. A JSON manifest now lists
DXF files with quantities (plus sheet sizes, spacing, edge spacing, quadrant
and per-part allowRotation), imported through CadImporter. DXF paths resolve
relative to the manifest; sheet sizes are required from the manifest or
--sheet-sizes and are read in the DXFs' own units. Folder scans pick up
*.nest and *.manifest.json, and invalid manifests fail loudly.

BenchmarkRunner now runs (job x engine) solves concurrently, capped by
--parallel N (CLI default 3; --parallel 1 is sequential). Results are written
by index so report order is unchanged. Concurrent solves compete for cores,
so Time(ms) is only clean at --parallel 1; the run prints a note when N > 1.

Also fixes --output for manifest jobs, which tried to read the manifest as a
.nest to copy metadata from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-20 21:43:41 -04:00
co-authored by Claude Sonnet 5
parent e7cbd99db6
commit 5061b41a5d
9 changed files with 690 additions and 28 deletions
+43 -23
View File
@@ -1,8 +1,10 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace OpenNest.Benchmark
{
@@ -28,28 +30,38 @@ namespace OpenNest.Benchmark
IReadOnlyList<NestingEngineInfo> engines,
double salvageRate = 0,
double minimumSalvageDimension = 0,
string outputDirectory = null
string outputDirectory = null,
int maxParallelism = 1
)
{
var results = new List<JobResult>(jobs.Count * engines.Count);
foreach (var job in jobs)
var pairs = jobs.SelectMany(job => engines.Select(engine => (Job: job, Engine: engine)))
.ToList();
var results = new JobResult[pairs.Count];
var options = new ParallelOptions
{
foreach (var engineInfo in engines)
{
results.Add(
RunOne(
job,
engineInfo,
salvageRate,
minimumSalvageDimension,
outputDirectory
)
);
}
}
MaxDegreeOfParallelism = System.Math.Max(1, maxParallelism),
};
return results;
// NoBuffering hands out one pair at a time: solves run for seconds to minutes,
// so chunked partitioning would leave workers idle behind a slow engine.
Parallel.ForEach(
Partitioner.Create(
Enumerable.Range(0, pairs.Count),
EnumerablePartitionerOptions.NoBuffering
),
options,
i =>
results[i] = RunOne(
pairs[i].Job,
pairs[i].Engine,
salvageRate,
minimumSalvageDimension,
outputDirectory
)
);
// Indexed writes keep the report in job-then-engine order whatever finishes first.
return results.ToList();
}
private static JobResult RunOne(
@@ -101,11 +113,19 @@ namespace OpenNest.Benchmark
{
System.IO.Directory.CreateDirectory(outputDirectory);
// Keep names and job metadata for a useful inspectable output; never modify source.
var source = new OpenNest.IO.NestReader(job.SourceFile).Read();
materialized.Nest.Name = source.Name;
materialized.Nest.Units = source.Units;
materialized.Nest.Material = source.Material;
materialized.Nest.Thickness = source.Thickness;
// Manifest jobs have no source nest to copy from, so they keep the job's name.
if (job.SourceFile.EndsWith(".nest", StringComparison.OrdinalIgnoreCase))
{
var source = new OpenNest.IO.NestReader(job.SourceFile).Read();
materialized.Nest.Name = source.Name;
materialized.Nest.Units = source.Units;
materialized.Nest.Material = source.Material;
materialized.Nest.Thickness = source.Thickness;
}
else
{
materialized.Nest.Name = job.Name;
}
materialized.Nest.SalvageRate = salvageRate;
foreach (var request in job.Requests)
materialized.DrawingsByPartId[request.Drawing.Id.ToString()].Name = request