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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Benchmark
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a BenchmarkJob from a JSON manifest that lists DXF files and the
|
||||
/// quantity of each to nest. DXF paths resolve relative to the manifest.
|
||||
/// Sheet sizes come from the manifest or the caller's override; unlike a
|
||||
/// .nest file there is no plate to inherit them from, so a job with none is
|
||||
/// an error. Sheet sizes must use the same units as the DXFs.
|
||||
/// </summary>
|
||||
public static class DxfManifestLoader
|
||||
{
|
||||
/// <summary>Suffix a manifest needs to be picked up when scanning a folder.</summary>
|
||||
public const string FolderSuffix = ".manifest.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
public static BenchmarkJob Load(
|
||||
string manifestPath,
|
||||
IReadOnlyList<Size> sheetSizeOverrides = null,
|
||||
double? partSpacingOverride = null
|
||||
)
|
||||
{
|
||||
var manifest = ReadManifest(manifestPath);
|
||||
var baseDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath));
|
||||
|
||||
if (manifest.Parts == null || manifest.Parts.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest '{manifestPath}' has no parts. Add entries to \"parts\"."
|
||||
);
|
||||
|
||||
var sizes = ResolveSheetSizes(manifest, sheetSizeOverrides, manifestPath);
|
||||
var requests = manifest.Parts.Select(p => BuildRequest(p, baseDir)).ToList();
|
||||
|
||||
return new BenchmarkJob
|
||||
{
|
||||
SourceFile = manifestPath,
|
||||
CandidateSizes = sizes,
|
||||
EdgeSpacing = new Spacing(manifest.EdgeSpacing, manifest.EdgeSpacing),
|
||||
PartSpacing = partSpacingOverride ?? manifest.Spacing,
|
||||
Quadrant = manifest.Quadrant,
|
||||
Requests = requests,
|
||||
};
|
||||
}
|
||||
|
||||
private static Manifest ReadManifest(string manifestPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Manifest>(
|
||||
File.ReadAllText(manifestPath),
|
||||
JsonOptions
|
||||
) ?? throw new InvalidOperationException("The manifest is empty.");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest '{manifestPath}' is not valid JSON: {ex.Message}",
|
||||
ex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Size> ResolveSheetSizes(
|
||||
Manifest manifest,
|
||||
IReadOnlyList<Size> overrides,
|
||||
string manifestPath
|
||||
)
|
||||
{
|
||||
if (overrides != null && overrides.Count > 0)
|
||||
return overrides.ToList();
|
||||
|
||||
var sizes = new List<Size>();
|
||||
|
||||
foreach (var text in manifest.SheetSizes ?? new List<string>())
|
||||
{
|
||||
if (!Size.TryParse(text, out var size))
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest '{manifestPath}': could not parse sheet size '{text}' (expected e.g. \"48x96\")."
|
||||
);
|
||||
|
||||
sizes.Add(size);
|
||||
}
|
||||
|
||||
if (sizes.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest '{manifestPath}' has no sheet sizes. Set \"sheetSizes\" or pass --sheet-sizes."
|
||||
);
|
||||
|
||||
return sizes.Distinct().ToList();
|
||||
}
|
||||
|
||||
private static DrawingRequest BuildRequest(ManifestPart part, string baseDir)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(part.Dxf))
|
||||
throw new InvalidOperationException("A manifest part is missing \"dxf\".");
|
||||
|
||||
if (part.Quantity <= 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Manifest part '{part.Dxf}': quantity must be greater than 0 (was {part.Quantity})."
|
||||
);
|
||||
|
||||
var dxfPath = Path.GetFullPath(Path.Combine(baseDir, part.Dxf));
|
||||
|
||||
if (!File.Exists(dxfPath))
|
||||
throw new FileNotFoundException($"DXF file not found: {dxfPath}", dxfPath);
|
||||
|
||||
Drawing drawing;
|
||||
|
||||
try
|
||||
{
|
||||
drawing = CadImporter.ImportDrawing(
|
||||
dxfPath,
|
||||
new CadImportOptions { Quantity = part.Quantity }
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}", ex);
|
||||
}
|
||||
|
||||
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
||||
throw new InvalidOperationException($"Failed to import DXF: {dxfPath}");
|
||||
|
||||
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
|
||||
if (!part.AllowRotation)
|
||||
{
|
||||
drawing.Constraints ??= new NestConstraints();
|
||||
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI;
|
||||
drawing.Constraints.StartAngle = 0;
|
||||
drawing.Constraints.EndAngle = 0;
|
||||
}
|
||||
|
||||
var constraints = drawing.Constraints;
|
||||
|
||||
return new DrawingRequest
|
||||
{
|
||||
Drawing = drawing,
|
||||
Quantity = part.Quantity,
|
||||
Priority = drawing.Priority,
|
||||
StepAngle = constraints?.StepAngle ?? 0,
|
||||
RotationStart = constraints?.StartAngle ?? 0,
|
||||
RotationEnd = constraints?.EndAngle ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private class Manifest
|
||||
{
|
||||
public List<string> SheetSizes { get; set; }
|
||||
public double Spacing { get; set; }
|
||||
public double EdgeSpacing { get; set; }
|
||||
public int Quadrant { get; set; } = 1;
|
||||
public List<ManifestPart> Parts { get; set; }
|
||||
}
|
||||
|
||||
private class ManifestPart
|
||||
{
|
||||
public string Dxf { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public bool AllowRotation { get; set; } = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,13 @@ namespace OpenNest.Benchmark
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (file.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Hand-written manifests fail loudly rather than being skipped like unreadable .nest files.
|
||||
jobs.Add(DxfManifestLoader.Load(file, sheetSizeOverrides, partSpacingOverride));
|
||||
continue;
|
||||
}
|
||||
|
||||
Nest nest;
|
||||
|
||||
try
|
||||
@@ -79,7 +86,14 @@ namespace OpenNest.Benchmark
|
||||
if (Directory.Exists(inputPath))
|
||||
{
|
||||
return Directory
|
||||
.GetFiles(inputPath, "*.nest", SearchOption.AllDirectories)
|
||||
.EnumerateFiles(inputPath, "*", SearchOption.AllDirectories)
|
||||
.Where(f =>
|
||||
f.EndsWith(".nest", StringComparison.OrdinalIgnoreCase)
|
||||
|| f.EndsWith(
|
||||
DxfManifestLoader.FolderSuffix,
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
)
|
||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ static class BenchmarkConsole
|
||||
if (jobs.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"No benchmark jobs found (no .nest files with any drawing quantity > 0)."
|
||||
"No benchmark jobs found (no .nest files with any drawing quantity > 0, or *.manifest.json files)."
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -83,12 +83,23 @@ static class BenchmarkConsole
|
||||
|
||||
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
|
||||
|
||||
var solves = jobs.Count * engines.Count;
|
||||
|
||||
if (options.Parallel > 1 && solves > 1)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"Running up to {options.Parallel} solves at a time; Time(ms) is measured under that "
|
||||
+ "concurrent load. Use --parallel 1 for strictly isolated timings."
|
||||
);
|
||||
}
|
||||
|
||||
var results = BenchmarkRunner.Run(
|
||||
jobs,
|
||||
engines,
|
||||
options.SalvageRate,
|
||||
options.MinimumSalvageDimension,
|
||||
options.OutputDirectory
|
||||
options.OutputDirectory,
|
||||
options.Parallel
|
||||
);
|
||||
|
||||
Report.PrintDetailed(results);
|
||||
@@ -149,6 +160,15 @@ static class BenchmarkConsole
|
||||
o.OutputDirectory = args[++i];
|
||||
break;
|
||||
|
||||
case "--parallel" when i + 1 < args.Length:
|
||||
if (int.TryParse(args[++i], out var parallel) && parallel >= 1)
|
||||
o.Parallel = parallel;
|
||||
else
|
||||
Console.Error.WriteLine(
|
||||
$"Warning: --parallel needs a whole number >= 1, using {o.Parallel}"
|
||||
);
|
||||
break;
|
||||
|
||||
case "--help":
|
||||
PrintUsage();
|
||||
return null;
|
||||
@@ -215,7 +235,25 @@ static class BenchmarkConsole
|
||||
);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Usage:");
|
||||
Console.Error.WriteLine(" OpenNest.Benchmark <file.nest | folder> [options]");
|
||||
Console.Error.WriteLine(
|
||||
" OpenNest.Benchmark <file.nest | manifest.json | folder> [options]"
|
||||
);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine(
|
||||
"A manifest.json builds a job straight from DXF files (paths relative to the manifest):"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" { \"sheetSizes\": [\"48x96\"], \"spacing\": 0.25, \"edgeSpacing\": 0.25, \"quadrant\": 1,"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" \"parts\": [ { \"dxf\": \"a.dxf\", \"quantity\": 12 }, { \"dxf\": \"b.dxf\", \"quantity\": 4, \"allowRotation\": false } ] }"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"Sheet sizes must use the same units as the DXFs. A folder is scanned for *.nest and"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
"*.manifest.json files. --sheet-sizes and --spacing override the manifest."
|
||||
);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Options:");
|
||||
Console.Error.WriteLine(
|
||||
@@ -242,6 +280,12 @@ static class BenchmarkConsole
|
||||
Console.Error.WriteLine(
|
||||
" --output <directory> Save valid layouts as .nest plus detailed JSON reports"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" --parallel <n> Solves to run at once (default 3; 1 = strictly sequential,"
|
||||
);
|
||||
Console.Error.WriteLine(
|
||||
" which gives the cleanest per-engine timings)"
|
||||
);
|
||||
Console.Error.WriteLine(" --help Show this message");
|
||||
}
|
||||
|
||||
@@ -255,5 +299,6 @@ static class BenchmarkConsole
|
||||
public string OutputDirectory;
|
||||
public double SalvageRate;
|
||||
public double MinimumSalvageDimension;
|
||||
public int Parallel = 3;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user