From 8188533d7203bfaf74eaa254416f6cb0623f216c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 26 Sep 2026 00:02:28 -0400 Subject: [PATCH] perf(ml): support scalar-only angle features --- OpenNest.Core/PerfCounters.cs | 6 + OpenNest.Engine/ML/FeatureExtractor.cs | 18 +- OpenNest.Tests/Fill/FillPerformanceTests.cs | 97 +++++ OpenNest.Tests/ML/FeatureExtractorTests.cs | 385 ++++++++++++++++++++ README.md | 2 + docs/performance/fill-performance.md | 63 ++++ 6 files changed, 569 insertions(+), 2 deletions(-) create mode 100644 OpenNest.Tests/ML/FeatureExtractorTests.cs diff --git a/OpenNest.Core/PerfCounters.cs b/OpenNest.Core/PerfCounters.cs index 5035dab..95383f7 100644 --- a/OpenNest.Core/PerfCounters.cs +++ b/OpenNest.Core/PerfCounters.cs @@ -15,6 +15,7 @@ namespace OpenNest private static long fillScoreComputations; private static long partBoundaryPreparations; private static long partBoundsUpdates; + private static long featureBitmaskCells; public static long FindBestFits => Interlocked.Read(ref findBestFits); public static long OffsetPerimeterEntities => Interlocked.Read(ref offsetPerimeterEntities); @@ -22,6 +23,7 @@ namespace OpenNest public static long FillScoreComputations => Interlocked.Read(ref fillScoreComputations); public static long PartBoundaryPreparations => Interlocked.Read(ref partBoundaryPreparations); public static long PartBoundsUpdates => Interlocked.Read(ref partBoundsUpdates); + public static long FeatureBitmaskCells => Interlocked.Read(ref featureBitmaskCells); [Conditional("DEBUG")] public static void CountFindBestFits() => Interlocked.Increment(ref findBestFits); @@ -42,6 +44,9 @@ namespace OpenNest [Conditional("DEBUG")] public static void CountPartBoundsUpdate() => Interlocked.Increment(ref partBoundsUpdates); + [Conditional("DEBUG")] + public static void CountFeatureBitmaskCell() => Interlocked.Increment(ref featureBitmaskCells); + public static void Reset() { Interlocked.Exchange(ref findBestFits, 0); @@ -50,6 +55,7 @@ namespace OpenNest Interlocked.Exchange(ref fillScoreComputations, 0); Interlocked.Exchange(ref partBoundaryPreparations, 0); Interlocked.Exchange(ref partBoundsUpdates, 0); + Interlocked.Exchange(ref featureBitmaskCells, 0); } } } diff --git a/OpenNest.Engine/ML/FeatureExtractor.cs b/OpenNest.Engine/ML/FeatureExtractor.cs index 5a09e0e..7fb146e 100644 --- a/OpenNest.Engine/ML/FeatureExtractor.cs +++ b/OpenNest.Engine/ML/FeatureExtractor.cs @@ -25,7 +25,19 @@ namespace OpenNest.Engine.ML public static class FeatureExtractor { - public static PartFeatures Extract(Drawing drawing) + /// + /// Extract scalar features plus the 32x32 training bitmap. This is the training + /// entry point; inference callers that never read + /// should use with includeBitmask: false. + /// + public static PartFeatures Extract(Drawing drawing) => Extract(drawing, includeBitmask: true); + + /// + /// Extract part features. When is false the + /// 1024-cell bitmask scan is skipped and is null; + /// every scalar feature is computed identically to the bitmap-including overload. + /// + public static PartFeatures Extract(Drawing drawing, bool includeBitmask) { // Normalize to canonical frame so features are invariant to import orientation. var canonical = CanonicalFrame.AsCanonicalCopy(drawing); @@ -55,7 +67,7 @@ namespace OpenNest.Engine.ML AspectRatio = bb.Length / (bb.Width > 0 ? bb.Width : 1.0), BoundingBoxFill = canonical.Area / (bb.Area() > 0 ? bb.Area() : 1.0), VertexCount = polygon.Vertices.Count, - Bitmask = GenerateBitmask(polygon, 32), + Bitmask = includeBitmask ? GenerateBitmask(polygon, 32) : null, }; // Circularity = 4 * PI * Area / Perimeter^2 @@ -77,6 +89,8 @@ namespace OpenNest.Engine.ML { for (int x = 0; x < size; x++) { + PerfCounters.CountFeatureBitmaskCell(); + // Map grid coordinate (0..size) to bounding box coordinate var px = bb.Left + (x + 0.5) * (bb.Length / size); var py = bb.Bottom + (y + 0.5) * (bb.Width / size); diff --git a/OpenNest.Tests/Fill/FillPerformanceTests.cs b/OpenNest.Tests/Fill/FillPerformanceTests.cs index 2a27790..f0fee20 100644 --- a/OpenNest.Tests/Fill/FillPerformanceTests.cs +++ b/OpenNest.Tests/Fill/FillPerformanceTests.cs @@ -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.FeatureExtractor.Extract(drawing)); + var scalar = new Func(() => 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 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> fill, int calls) { var partCount = 0L; diff --git a/OpenNest.Tests/ML/FeatureExtractorTests.cs b/OpenNest.Tests/ML/FeatureExtractorTests.cs new file mode 100644 index 0000000..4a27efc --- /dev/null +++ b/OpenNest.Tests/ML/FeatureExtractorTests.cs @@ -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 Shapes() + { + var data = new TheoryData(); + 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 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(() => FeatureExtractor.Extract(drawing)); + Assert.Throws( + () => FeatureExtractor.Extract(drawing, includeBitmask: false)); + } + + [Fact] + public void NullDrawing_ThrowsIdenticallyForBothOverloads() + { + // Pre-existing: CanonicalFrame tolerates null but Program dereference throws. + Assert.Throws(() => FeatureExtractor.Extract(null!)); + Assert.Throws( + () => 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(() => FeatureExtractor.Extract(drawing)); + cells = PerfCounters.FeatureBitmaskCells; + } + finally + { + PerfCounters.Reset(); + } + + Assert.Equal(0, cells); + } +#endif +} diff --git a/README.md b/README.md index 4a553ac..cb0f665 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ The extents measurement exercises repeated column rebuilding with a closed trian The synchronous rotated-pattern construction control uses a 32-part native-arc group at angles 0 and 0.37. Run it with `--filter "FullyQualifiedName~RotatedPattern_ReportsBoundsConstruction"`. Task 3 reuses the extents benchmark above; Debug `--filter "FullyQualifiedName~BoundsWork"` checks part-bounds work. Only three extents recomputations were removable: anchor, vertical-shift, and group-clone recomputations remain because removing them changes exact floating-point layouts. See the measured report for the partial-delivery evidence and inconclusive timing results. +The feature-extraction measurement compares default `FeatureExtractor.Extract` (32×32 training bitmask) against `Extract(drawing, includeBitmask: false)` on a synthetic ring; run it with `--filter "FullyQualifiedName~FeatureExtraction_ReportsFullAndScalarOnly"`. The one-argument overload keeps generating the bitmap for training callers; scalar-only inference callers receive `Bitmask = null` with identical scalar features. Behavior and Debug bitmask-scan counter checks use `--filter "FullyQualifiedName~FeatureExtractorTests"`. + ### Quick start 1. File > New Nest diff --git a/docs/performance/fill-performance.md b/docs/performance/fill-performance.md index 06fe9b0..9299e43 100644 --- a/docs/performance/fill-performance.md +++ b/docs/performance/fill-performance.md @@ -469,3 +469,66 @@ dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Debug \ ``` This is a partial delivery by design: three of six candidate recomputations were removable; the three retained sites are documented with their observed ulp deltas, and their `UpdateBounds` calls must not be removed without re-running the threshold and overlap-fallback characterization. Timing is inconclusive; only allocation and work-counter removal are demonstrated. No representative production corpus, Windows UI runtime test, or ONNX inference was run. Task 4 and gated follow-ups are not included. + +## Task 4a — scalar-only feature extraction — 2026-09-25 + +### Scope + +`FeatureExtractor.Extract` always built a 32×32 bitmask — 1024 `Polygon.ContainsPoint` ray-cast tests plus a 1,024-byte array — even though the only production consumer at runtime (the `AngleCandidateBuilder` ML branch) reads nothing from `PartFeatures.Bitmask`. This slice adds `Extract(Drawing, bool includeBitmask)`; the original one-argument signature is preserved as a source- and binary-compatible wrapper that keeps the training bitmap. Every scalar formula, tolerance, material filter, and the canonical-frame normalization run identically in both modes. Training consumers (`OpenNest.Training`) are unchanged, and `AngleCandidateBuilder` is not yet switched over — Task 4b gates the inference call site. No production behavior changes for any existing caller. + +`PerfCounters.FeatureBitmaskCells` (Debug-only, incremented inside the bitmask scan loop) was added to prove the point-in-polygon work is genuinely skipped. + +### Source provenance + +- Base: `6863c8bdb1c7d98eb5b7c45153e062e7cb335464` on `master`. Final implementation is delivered with this report. +- `FeatureExtractor.cs` SHA-256 before `1094d386595c0513fcb6db1e5ba7b2c7a0b8c19cf351f6cf9a462356156fdd15` → after `2d7b45350abd94d7e728c962aca06f4a86920d0328d8025c75f51ebc7e5ed6b3`. +- `PerfCounters.cs` before `0790401ae0433e416c4ac40d678403407497226d6bd40057b7c4e959da6e0582` → after `b596437210719981940ebf7ae03b31b7493f44002cf545bbe638c35bebf5b65d`. +- Harness `FillPerformanceTests.cs` at measurement time: before/after runs identical at `c257b27a5a4335278c99b8f623540b5580b51d1cc9d31a04bc8d8641a9c722a0` (new method added to base `93ec06c5b59aecfe5cb9311380afea507cc134e77df7535036fb451ab0f63e29`; existing methods byte-identical). The delivered tree ships `27c213f202e46cdd9a1e46bb0263784277f2aecc17469ddd83c5db26c1b129aa` — disclosure/comment wording only, timed code paths identical. +- Environment: same shared KVM VM as prior slices — Ubuntu 24.04.5 LTS x64, four vCPUs presented as AMD Ryzen 9 5900X, SDK 10.0.112, .NET 8.0.31 adapters confirmed, 1 GHz stopwatch, serial Release. + +### Work-removal evidence (genuine red/green) + +The change is additive, so red was demonstrated by neutralizing the new flag in the delivered overload (forcing always-bitmap construction — exactly the original production behavior): on the final tree 8 of 38 Debug cases failed — the five `ScalarOnlyOverload_OmitsBitmap` theory cases, `ScribeAndRapidMarks_DoNotAffectEitherOverload`, and `RotatedInput_FeaturesMatchAxisAlignedEquivalent` with `Assert.Null() Failure: Value is not null`, plus `ScalarOnlyOverload_RunsNoBitmaskCellScan` failing the same assertion inside its counted window. Restoring the flag turned all 38 Debug tests green. On the delivered tree: default extraction counts exactly 1,024 bitmask cells per call (matching the documented scan), scalar-only counts 0, and failed extraction (rapid-only program) counts 0. + +New `OpenNest.Tests/ML/FeatureExtractorTests.cs` (35 behavior cases plus 3 Debug counter cases, serialized in `FillCacheCollection` for the counter tests): exact-equality scalar snapshots across both overloads on five shapes (rectangle, right triangle, L-shape concave profile, native-arc ring, native circle); explicit-true bitmap equality bit-for-bit against the default overload; default training-bitmap shape/content preservation (1,024 cells, 0/1 only, mixed for ring/L-shape, all-set for a full rectangle); rotated-input invariance against an axis-aligned equivalent; canonical-copy input; scribe/rapid marks ignored by both overloads with identical bitmaps; input non-mutation; determinism across repeated extraction. Because `ExplicitTrue_MatchesDefaultOverloadBitForBit` alone cannot detect a rasterization change applied to both overloads (the default delegates to it), the five fixtures are additionally pinned against frozen SHA-256 hashes of the bitmaps emitted by the actual pre-change implementation at `6863c8b` (captured by running the original code, cross-checked by the spec reviewer against an in-memory HEAD compilation). Pre-existing edge behavior is characterized, not changed: rapid-only drawings throw `ArgumentOutOfRangeException` in `ShapeProfile` and null drawings throw `NullReferenceException` identically through both overloads. + +### Tests + +| Suite | Passed | Skipped | Failed | +| --- | ---: | ---: | ---: | +| Targeted Release (`FeatureExtractorTests|CanonicalFrameTests`) | 40 | 0 | 0 | +| Targeted Debug (`FeatureExtractorTests`) | 38 | 0 | 0 | +| Full main Release | 1,301 | 17 | 0 | +| Full main Debug | 1,321 | 17 | 0 | +| Full engine Release | 300 | 0 | 0 | + +Skips: 12 optional CHR-font fixtures plus five opt-in benchmarks (the new feature-extraction case adds one). Gate verified: unset skips all five category tests. Changed-file `dotnet format whitespace --verify-no-changes` and `git diff --check` passed; no new production warnings. + +### Results + +Same-harness, same-machine, serial Release; ring OD=20 ID=8 (perimeter plus one circular cutout); warmup 2×200, measured 7×1,000 calls, alternating mode order, correctness checks outside timing; allocations are current-thread synchronous only. The before run exercised the original always-bitmap implementation in both modes (harness scalar lambda temporarily pointed at the full path); the after run is the delivered tree. + +| Batch | Before full ms / B | Before scalar-mode ms / B | After full ms / B | After scalar-only ms / B | +| ---: | --- | --- | --- | --- | +| 1 | 154.692010 / 35,338,120 | 154.066548 / 35,332,896 | 197.474351 / 35,338,120 | 31.020699 / 34,252,896 | +| 2 | 161.643928 / 35,338,120 | 151.896080 / 35,332,896 | 157.026495 / 35,332,896 | 29.342949 / 34,252,896 | +| 3 | 149.279498 / 35,332,896 | 155.797847 / 35,332,896 | 154.219132 / 35,332,896 | 32.988987 / 34,268,256 | +| 4 | 153.772483 / 35,332,896 | 153.226512 / 35,332,896 | 161.453458 / 35,332,688 | 41.092334 / 34,252,688 | +| 5 | 155.591257 / 35,338,120 | 155.760388 / 35,332,896 | 151.984770 / 35,332,688 | 30.768833 / 34,252,688 | +| 6 | 153.993059 / 35,332,896 | 154.410087 / 35,332,896 | 153.549576 / 35,332,688 | 29.824879 / 34,252,688 | +| 7 | 155.984019 / 35,338,120 | 159.061932 / 35,332,896 | 157.327152 / 35,332,688 | 29.530524 / 34,252,688 | + +Medians (µs/call): before scalar-mode (original behavior) 154.410 → after scalar-only 30.769 (−80.1%). Allocations: medians 35,332.896 → 34,252.688 B/call. Within the after run, full minus scalar-only per batch is 1,085.224 / 1,080.000 / 1,064.640 / 1,080.000 / 1,080.000 / 1,080.000 / 1,080.000 B/call (median 1,080.000): the 1,048-byte `byte[1024]` allocation plus ~32 B from the harness's in-window bitmap-count consumption (a reviewer probe on .NET 8.0.31 measured 1,048 and 32 respectively) — i.e. extraction-plus-consumption overhead, not 1,080 B of production extraction alone; the production `GenerateBitmask` loop itself allocates nothing beyond the array. Before scalar-mode and before-full rows agree within noise, confirming the harness adaptation introduced no bias. Timing likewise includes that enumeration in full mode. + +Default-overload (training) timing is inconclusive: after-full median 157.026 vs before-full 154.692 µs/call (+1.5%, inside the overlapping batch ranges; the after run's batch 1 at 197.474 is a warmup-adjacent outlier included unmodified). Overlapping ranges establish neither regression nor non-regression for the full mode. + +### Reproduction and limitations + +```bash +OPENNEST_RUN_FILL_PERF=1 dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \ + --filter 'FullyQualifiedName~FeatureExtraction_ReportsFullAndScalarOnly' --logger 'console;verbosity=detailed' +dotnet test OpenNest.Tests/OpenNest.Tests.csproj -c Release \ + --filter 'FullyQualifiedName~FeatureExtractorTests|FullyQualifiedName~CanonicalFrameTests' +``` + +This is a capability slice, not yet a production speedup: the inference call site keeps using the default overload until Task 4b gates predictor availability, so real nesting runs show no change from this commit. The measured gain applies to inference callers once wired. No representative production corpus, Windows UI runtime test, or ONNX inference was performed; the Debug counter increments compile away in Release.