perf(ml): support scalar-only angle features

This commit is contained in:
aj
2026-09-26 00:02:28 -04:00
parent 6863c8bdb1
commit 8188533d72
6 changed files with 569 additions and 2 deletions
@@ -275,6 +275,103 @@ public class FillPerformanceTests
}
}
[SkippableFact]
public void FeatureExtraction_ReportsFullAndScalarOnly()
{
Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_FILL_PERF") == "1",
"Set OPENNEST_RUN_FILL_PERF=1 to run opt-in fill microbenchmarks.");
var drawing = new RingShape { OuterDiameter = 20, InnerDiameter = 8 }.GetDrawing();
var full = new Func<OpenNest.Engine.ML.PartFeatures>(() => OpenNest.Engine.ML.FeatureExtractor.Extract(drawing));
var scalar = new Func<OpenNest.Engine.ML.PartFeatures>(() => OpenNest.Engine.ML.FeatureExtractor.Extract(drawing, includeBitmask: false));
var fullBaseline = full();
var scalarBaseline = scalar();
Assert.NotNull(fullBaseline.Bitmask);
Assert.Null(scalarBaseline.Bitmask);
var expectedOnes = fullBaseline.Bitmask.Count(cell => cell == 1);
// Perimeter-only rasterization of the circle silhouette leaves corners clear but center set.
Assert.InRange(expectedOnes, 1, BitmaskCells - 1);
var warmupCalls = 200;
var callsPerBatch = 1_000;
var repetitions = 7;
#if DEBUG
output.WriteLine("Configuration=Debug (diagnostic only; use Release for measurements).");
#else
output.WriteLine("Release.");
#endif
output.WriteLine($"Runtime={RuntimeInformation.FrameworkDescription}; OS={RuntimeInformation.OSDescription}; "
+ $"architecture={RuntimeInformation.ProcessArchitecture}; processors={Environment.ProcessorCount}; "
+ $"Stopwatch.Frequency={Stopwatch.Frequency} ticks/s.");
output.WriteLine("feature-extraction: synthetic ring OD=20 ID=8 (perimeter + one circular cutout); "
+ $"full=default overload (32x32 bitmask) vs scalar-only=includeBitmask:false; warmup=2 x {warmupCalls}; "
+ $"measured={repetitions} x {callsPerBatch}; mode batch order alternates. "
+ "Real synchronous production extraction only; setup/assertions/output excluded; canonical copy, "
+ "geometry conversion, hull, bitmask scan (full mode only), GC and result consumption included. "
+ "The per-call bitmap-count consumption also runs inside the window and allocates only in full mode. "
+ "Current-thread allocations, not RSS or a whole-job benchmark.");
for (var batch = 0; batch < 2; batch++)
{
MeasureFeature(batch % 2 == 0 ? full : scalar, warmupCalls);
MeasureFeature(batch % 2 == 0 ? scalar : full, warmupCalls);
}
var samples = new (FeatureSample Full, FeatureSample Scalar)[repetitions];
for (var batch = 0; batch < repetitions; batch++)
{
if (batch % 2 == 0)
{
samples[batch].Full = MeasureFeature(full, callsPerBatch);
samples[batch].Scalar = MeasureFeature(scalar, callsPerBatch);
}
else
{
samples[batch].Scalar = MeasureFeature(scalar, callsPerBatch);
samples[batch].Full = MeasureFeature(full, callsPerBatch);
}
var fullSample = samples[batch].Full;
var scalarSample = samples[batch].Scalar;
Assert.Equal((long)expectedOnes * callsPerBatch, fullSample.BitmaskOnes);
Assert.Equal(0, scalarSample.BitmaskOnes);
Assert.Equal(scalarBaseline.Area, scalarSample.Area);
Assert.Equal(scalarBaseline.Area, fullSample.Area);
output.WriteLine(FormattableString.Invariant(
$"feature-extraction batch={batch + 1}: full ms={samples[batch].Full.Milliseconds:F6} bytes={samples[batch].Full.AllocatedBytes}."));
output.WriteLine(FormattableString.Invariant(
$"feature-extraction batch={batch + 1}: scalar ms={samples[batch].Scalar.Milliseconds:F6} bytes={samples[batch].Scalar.AllocatedBytes}."));
}
ReportFeatureSummary("full", samples.Select(s => s.Full).ToArray(), callsPerBatch);
ReportFeatureSummary("scalar-only", samples.Select(s => s.Scalar).ToArray(), callsPerBatch);
}
private const int BitmaskCells = 32 * 32;
private static FeatureSample MeasureFeature(Func<OpenNest.Engine.ML.PartFeatures> extract, int calls)
{
var ones = 0L;
var lastArea = 0.0;
var allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
var start = Stopwatch.GetTimestamp();
for (var i = 0; i < calls; i++)
{
var features = extract();
ones += features.Bitmask?.Count(cell => cell == 1) ?? 0;
lastArea = features.Area;
}
var elapsed = Stopwatch.GetTimestamp() - start;
var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;
return new FeatureSample(elapsed * 1000.0 / Stopwatch.Frequency, allocated, ones, lastArea);
}
private void ReportFeatureSummary(string mode, FeatureSample[] samples, int callsPerBatch)
{
var times = samples.Select(s => s.Milliseconds).OrderBy(t => t).ToArray();
var bytes = samples.Select(s => s.AllocatedBytes).OrderBy(b => b).ToArray();
var median = samples.Length / 2;
output.WriteLine(FormattableString.Invariant(
$"feature-extraction {mode}: batch ms min/median/max={times[0]:F6}/{times[median]:F6}/{times[^1]:F6}; us/call min/median/max={times[0] * 1000 / callsPerBatch:F3}/{times[median] * 1000 / callsPerBatch:F3}/{times[^1] * 1000 / callsPerBatch:F3}; batch bytes min/median/max={bytes[0]}/{bytes[median]}/{bytes[^1]}; B/call min/median/max={(double)bytes[0] / callsPerBatch:F3}/{(double)bytes[median] / callsPerBatch:F3}/{(double)bytes[^1] / callsPerBatch:F3}."));
}
private readonly record struct FeatureSample(double Milliseconds, long AllocatedBytes, long BitmaskOnes, double Area);
private static ExtentsSample MeasureExtents(Func<List<Part>> fill, int calls)
{
var partCount = 0L;
+385
View File
@@ -0,0 +1,385 @@
using OpenNest.CNC;
using OpenNest.Engine;
using OpenNest.Engine.ML;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.Shapes;
namespace OpenNest.Tests.ML;
// PerfCounters assertions in the DEBUG section are global; the class runs inside the
// nonparallel FillCacheCollection so counter resets cannot race other fill tests.
[Collection(nameof(OpenNest.Tests.BestFit.FillCacheCollection))]
public class FeatureExtractorTests
{
private const int BitmaskCells = 32 * 32;
private static Drawing MakeRect(double w, double h, double rotation)
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, 0)));
pgm.Codes.Add(new LinearMove(new Vector(w, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, h)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
if (!Tolerance.IsEqualTo(rotation, 0))
pgm.Rotate(rotation, pgm.BoundingBox().Center);
return new Drawing("rect", pgm) { Source = new SourceInfo { Angle = -rotation } };
}
private static Drawing Fixture(string shape) => shape switch
{
"rect" => new RectangleShape { Length = 10, Width = 20 }.GetDrawing(),
"triangle" => new RightTriangleShape { Width = 10, Height = 8 }.GetDrawing(),
"lshape" => new LShape { Width = 10, Height = 20, LegWidth = 5, LegHeight = 10 }.GetDrawing(),
"ring" => new RingShape { OuterDiameter = 20, InnerDiameter = 8 }.GetDrawing(),
"circle" => new CircleShape { Diameter = 15 }.GetDrawing(),
_ => throw new ArgumentOutOfRangeException(nameof(shape), shape, null),
};
public static TheoryData<string> Shapes()
{
var data = new TheoryData<string>();
foreach (var shape in new[] { "rect", "triangle", "lshape", "ring", "circle" })
data.Add(shape);
return data;
}
private static (double[] Scalars, byte[]? Bitmap) Snapshot(PartFeatures? features)
{
Assert.NotNull(features);
var scalars = new[]
{
features.Area,
features.Convexity,
features.AspectRatio,
features.BoundingBoxFill,
features.Circularity,
features.PerimeterToAreaRatio,
features.VertexCount,
};
return (scalars, features.Bitmask is null ? null : (byte[])features.Bitmask.Clone());
}
private static void AssertScalarsEqual(PartFeatures expected, PartFeatures actual)
{
// Exact equality: both overloads must run identical scalar arithmetic.
Assert.Equal(expected.Area, actual.Area);
Assert.Equal(expected.Convexity, actual.Convexity);
Assert.Equal(expected.AspectRatio, actual.AspectRatio);
Assert.Equal(expected.BoundingBoxFill, actual.BoundingBoxFill);
Assert.Equal(expected.Circularity, actual.Circularity);
Assert.Equal(expected.PerimeterToAreaRatio, actual.PerimeterToAreaRatio);
Assert.Equal(expected.VertexCount, actual.VertexCount);
}
private static string ProgramValues(Program program) =>
string.Join(";", program.Codes.Select(code => code switch
{
RapidMove rapid => $"R{rapid.EndPoint.X:R},{rapid.EndPoint.Y:R}",
LinearMove linear => $"L{linear.EndPoint.X:R},{linear.EndPoint.Y:R}:{linear.Layer}",
ArcMove arc => $"A{arc.EndPoint.X:R},{arc.EndPoint.Y:R}c{arc.CenterPoint.X:R},{arc.CenterPoint.Y:R}:{arc.Layer}",
_ => code.ToString() ?? string.Empty,
}));
[Theory]
[MemberData(nameof(Shapes))]
public void DefaultOverload_PreservesTrainingBitmap(string shape)
{
var features = FeatureExtractor.Extract(Fixture(shape));
Assert.NotNull(features);
Assert.NotNull(features.Bitmask);
Assert.Equal(BitmaskCells, features.Bitmask.Length);
Assert.All(features.Bitmask, cell => Assert.True(cell is 0 or 1));
Assert.Contains((byte)1, features.Bitmask);
}
[Theory]
[MemberData(nameof(Shapes))]
public void ScalarOnlyOverload_OmitsBitmap(string shape)
{
var features = FeatureExtractor.Extract(Fixture(shape), includeBitmask: false);
Assert.NotNull(features);
Assert.Null(features.Bitmask);
}
[Theory]
[MemberData(nameof(Shapes))]
public void Scalars_AreIdenticalWithAndWithoutBitmap(string shape)
{
var drawing = Fixture(shape);
var full = FeatureExtractor.Extract(drawing);
var scalar = FeatureExtractor.Extract(drawing, includeBitmask: false);
AssertScalarsEqual(full!, scalar!);
}
[Theory]
[MemberData(nameof(Shapes))]
public void ExplicitTrue_MatchesDefaultOverloadBitForBit(string shape)
{
var drawing = Fixture(shape);
var full = FeatureExtractor.Extract(drawing);
var explicitTrue = FeatureExtractor.Extract(drawing, includeBitmask: true);
AssertScalarsEqual(full!, explicitTrue!);
Assert.NotNull(full.Bitmask);
Assert.NotNull(explicitTrue.Bitmask);
Assert.Equal(full.Bitmask, explicitTrue.Bitmask);
}
// Frozen SHA-256 of the 1024-byte default-overload bitmap produced by the pre-change
// implementation at base 6863c8b (captured by running the original code on these exact
// fixtures). Independent historical oracle: the overload-equivalence tests alone could
// not detect a rasterization change applied to both overloads.
private static readonly System.Collections.Generic.Dictionary<string, string> FrozenBitmapSha256 = new()
{
["rect"] = "5A648D8015900D89664E00E125DF179636301A2D8FA191C1AA2BD9358EA53A69",
["triangle"] = "EEB88FFCC8B5E31777995FF0A3EEECE3C1CCF4BF9221D9241A40CD8FC055C708",
["lshape"] = "D6C2CC56414ADE01E7BA5E3030E6009391EBA28BB07DDF5938F10CB2638B82A7",
["ring"] = "151B0A0790A3D60749527427C2F31D4E2D2470E4F1F0C5AA6F643994C70F4223",
["circle"] = "151B0A0790A3D60749527427C2F31D4E2D2470E4F1F0C5AA6F643994C70F4223",
};
[Theory]
[MemberData(nameof(Shapes))]
public void DefaultOverload_PreservesBaseImplementationBitmap(string shape)
{
var features = FeatureExtractor.Extract(Fixture(shape));
Assert.NotNull(features?.Bitmask);
var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(features.Bitmask));
Assert.Equal(FrozenBitmapSha256[shape], hash);
}
[Fact]
public void RectangleBitmap_CoversEveryCell()
{
// The material fills the bounding box, so all 32x32 sample centres are interior points.
var features = FeatureExtractor.Extract(new RectangleShape { Length = 10, Width = 20 }.GetDrawing());
Assert.NotNull(features?.Bitmask);
Assert.Equal(BitmaskCells, features.Bitmask.Count(cell => cell == 1));
}
[Theory]
[InlineData("lshape")]
[InlineData("ring")]
public void ConcaveAndRingBitmaps_MixSetAndClearCells(string shape)
{
// FeatureExtractor rasterizes only ShapeProfile.Perimeter: the L-shape mix comes
// from its concave outline, and the ring's mix from its circular outer silhouette
// leaving bounding-box corners clear (the cutout is never rasterized).
var features = FeatureExtractor.Extract(Fixture(shape));
Assert.NotNull(features?.Bitmask);
Assert.Contains((byte)1, features.Bitmask);
Assert.Contains((byte)0, features.Bitmask);
}
[Fact]
public void RotatedInput_FeaturesMatchAxisAlignedEquivalent()
{
var rotated = MakeRect(100, 50, 0.6);
var axisAligned = MakeRect(100, 50, 0);
var full = FeatureExtractor.Extract(rotated);
var scalar = FeatureExtractor.Extract(rotated, includeBitmask: false);
var reference = FeatureExtractor.Extract(axisAligned);
Assert.NotNull(full);
Assert.NotNull(scalar);
Assert.NotNull(reference);
AssertScalarClose(reference, full);
AssertScalarClose(reference, scalar);
Assert.NotNull(full.Bitmask);
Assert.Null(scalar.Bitmask);
}
[Fact]
public void CanonicalCopyInput_ExtractsSameScalarsAsSource()
{
var drawing = Fixture("lshape");
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
// Exercise the canonical-copy path: extraction consumes the transient copy, not the source.
Assert.NotSame(drawing.Program, canonical.Program);
var source = FeatureExtractor.Extract(drawing);
var fromCanonical = FeatureExtractor.Extract(canonical, includeBitmask: false);
Assert.NotNull(source);
Assert.NotNull(fromCanonical);
AssertScalarsEqual(source, fromCanonical);
}
[Fact]
public void ScribeAndRapidMarks_DoNotAffectEitherOverload()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(10, 20)));
pgm.Codes.Add(new LinearMove(new Vector(0, 20)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
var plain = new Drawing("rect", pgm);
var marked = new Program();
marked.Codes.Add(new RapidMove(new Vector(0, 0)));
marked.Codes.Add(new LinearMove(new Vector(10, 0)));
marked.Codes.Add(new LinearMove(new Vector(10, 20)));
marked.Codes.Add(new LinearMove(new Vector(0, 20)));
marked.Codes.Add(new LinearMove(new Vector(0, 0)));
marked.Codes.Add(new RapidMove(new Vector(3, 5)));
marked.Codes.Add(new LinearMove(new Vector(7, 5)) { Layer = LayerType.Scribe });
var markedDrawing = new Drawing("rect-marked", marked);
var expected = Snapshot(FeatureExtractor.Extract(plain));
var actualFull = Snapshot(FeatureExtractor.Extract(markedDrawing));
var actualScalar = Snapshot(FeatureExtractor.Extract(markedDrawing, includeBitmask: false));
Assert.Equal(expected.Scalars, actualFull.Scalars);
Assert.Equal(expected.Bitmap!, actualFull.Bitmap!);
Assert.Equal(expected.Scalars, actualScalar.Scalars);
Assert.Null(actualScalar.Bitmap);
}
[Fact]
public void GeometryWithoutMaterial_ThrowsIdenticallyForBothOverloads()
{
// Pre-existing behavior: ShapeProfile.Update unconditionally indexes shapes[0],
// so extraction throws rather than returning null when no entities chain into a
// shape. Both overloads must fail the same way.
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(1, 2)));
pgm.Codes.Add(new RapidMove(new Vector(3, 4)));
var drawing = new Drawing("rapid-only", pgm);
Assert.Throws<ArgumentOutOfRangeException>(() => FeatureExtractor.Extract(drawing));
Assert.Throws<ArgumentOutOfRangeException>(
() => FeatureExtractor.Extract(drawing, includeBitmask: false));
}
[Fact]
public void NullDrawing_ThrowsIdenticallyForBothOverloads()
{
// Pre-existing: CanonicalFrame tolerates null but Program dereference throws.
Assert.Throws<NullReferenceException>(() => FeatureExtractor.Extract(null!));
Assert.Throws<NullReferenceException>(
() => FeatureExtractor.Extract(null!, includeBitmask: false));
}
[Fact]
public void Extraction_DoesNotMutateInputDrawing()
{
var drawing = Fixture("lshape");
var beforeProgram = ProgramValues(drawing.Program);
var beforeArea = drawing.Area;
var beforeAngle = drawing.Source.Angle;
FeatureExtractor.Extract(drawing);
FeatureExtractor.Extract(drawing, includeBitmask: false);
Assert.Equal(beforeProgram, ProgramValues(drawing.Program));
Assert.Equal(beforeArea, drawing.Area);
Assert.Equal(beforeAngle, drawing.Source.Angle);
}
[Fact]
public void RepeatedExtraction_IsDeterministic()
{
var drawing = Fixture("ring");
var first = Snapshot(FeatureExtractor.Extract(drawing));
var second = Snapshot(FeatureExtractor.Extract(drawing));
var scalarFirst = Snapshot(FeatureExtractor.Extract(drawing, includeBitmask: false));
var scalarSecond = Snapshot(FeatureExtractor.Extract(drawing, includeBitmask: false));
Assert.Equal(first.Scalars, second.Scalars);
Assert.Equal(first.Bitmap!, second.Bitmap!);
Assert.Equal(scalarFirst.Scalars, scalarSecond.Scalars);
}
private static void AssertScalarClose(PartFeatures reference, PartFeatures actual)
{
Assert.Equal(reference.Area, actual.Area, precision: 6);
Assert.Equal(reference.Convexity, actual.Convexity, precision: 6);
Assert.Equal(reference.AspectRatio, actual.AspectRatio, precision: 6);
Assert.Equal(reference.BoundingBoxFill, actual.BoundingBoxFill, precision: 6);
Assert.Equal(reference.Circularity, actual.Circularity, precision: 6);
Assert.Equal(reference.PerimeterToAreaRatio, actual.PerimeterToAreaRatio, precision: 6);
Assert.Equal(reference.VertexCount, actual.VertexCount);
}
#if DEBUG
[Fact]
public void DefaultOverload_RunsFullBitmaskCellScan()
{
var drawing = Fixture("lshape");
FeatureExtractor.Extract(drawing); // warm canonical/JIT path outside the counted window
PerfCounters.Reset();
long cells;
try
{
FeatureExtractor.Extract(drawing);
cells = PerfCounters.FeatureBitmaskCells;
}
finally
{
PerfCounters.Reset();
}
Assert.Equal(BitmaskCells, cells);
}
[Fact]
public void ScalarOnlyOverload_RunsNoBitmaskCellScan()
{
var drawing = Fixture("lshape");
FeatureExtractor.Extract(drawing, includeBitmask: false);
PerfCounters.Reset();
long cells;
try
{
var features = FeatureExtractor.Extract(drawing, includeBitmask: false);
cells = PerfCounters.FeatureBitmaskCells;
Assert.Null(features.Bitmask);
}
finally
{
PerfCounters.Reset();
}
// Genuine removal: every point-in-polygon cell test in the 32x32 scan is skipped.
Assert.Equal(0, cells);
}
[Fact]
public void FailedExtraction_DoesNotRunBitmaskScan()
{
// Extraction fails before bitmap construction, so the 1024-cell scan never runs.
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(1, 2)));
var drawing = new Drawing("rapid-only", pgm);
PerfCounters.Reset();
long cells;
try
{
Assert.Throws<ArgumentOutOfRangeException>(() => FeatureExtractor.Extract(drawing));
cells = PerfCounters.FeatureBitmaskCells;
}
finally
{
PerfCounters.Reset();
}
Assert.Equal(0, cells);
}
#endif
}