perf(fill): avoid redundant bounds recomputation

This commit is contained in:
aj
2026-09-25 21:45:03 -04:00
parent 4ec92c95ec
commit 4553f8afad
10 changed files with 483 additions and 12 deletions
+187 -3
View File
@@ -64,6 +64,160 @@ public class FillExtentsTests
Assert.Equal(before, Snapshot(drawing));
}
[Theory]
[MemberData(nameof(DifferentialCases))]
public void Fill_Thresholds_MatchPreChangePairFitAndColumnBranches(string shape, double spacing, bool rotated)
{
var drawing = MakeFixture(shape);
var angle = rotated ? System.Math.PI / 6 : 0;
var before = Snapshot(drawing);
var roomy = new Box(3.1, 5.3, 100, 100);
var seed = Invoke(new LegacyFillExtents(roomy, spacing), "BuildPair", drawing, angle);
var bbox = PairBounds(seed);
// The reference BuildPair is identical to pre-Task-3 production. Task 2's
// finite/nonnegative pitch shortcut gives the same column, not different poses.
foreach (var axis in new[] { "length", "width", "column" })
{
foreach (var fits in new[] { false, true })
{
var delta = fits ? 1e-9 : -1e-9;
var length = axis == "length" ? bbox.Length - OpenNest.Math.Tolerance.Epsilon + delta : 100;
var width = axis == "width" ? bbox.Width - OpenNest.Math.Tolerance.Epsilon + delta
: axis == "column" ? 2 * bbox.Width + spacing - OpenNest.Math.Tolerance.Epsilon + delta : 100;
var area = new Box(roomy.X, roomy.Y, length, width);
var areaBefore = Bounds(area);
var legacy = new LegacyFillExtents(area, spacing);
var filler = new FillExtents(area, spacing);
var expectedPair = Invoke(legacy, "BuildPair", drawing, angle);
var actualPair = Invoke(filler, "BuildPair", drawing, angle);
Assert.Equal(axis == "column" || fits, expectedPair != null);
Assert.Equal(expectedPair == null, actualPair == null);
if (expectedPair != null)
{
AssertPairEqual(expectedPair, actualPair!);
var expectedColumn = (List<Part>)Invoke(legacy, "BuildColumn", expectedPair);
var actualColumn = (List<Part>)Invoke(filler, "BuildColumn", actualPair!);
if (axis == "column")
Assert.Equal(fits ? 4 : 2, expectedColumn.Count);
AssertSameLayout(expectedColumn, actualColumn);
}
var expectedProgress = new List<List<Part>>();
var actualProgress = new List<List<Part>>();
var expected = legacy.Fill(drawing, angle, reportProgress: (parts, _) => expectedProgress.Add(parts));
var actual = filler.Fill(drawing, angle, reportProgress: (parts, _) => actualProgress.Add(parts));
AssertSameLayout(expected, actual);
Assert.Equal(expectedProgress.Count, actualProgress.Count);
for (var i = 0; i < expectedProgress.Count; i++)
AssertSameLayout(expectedProgress[i], actualProgress[i]);
Assert.Equal(areaBefore, Bounds(area));
Assert.Equal(before, Snapshot(drawing));
}
}
}
[Theory]
[MemberData(nameof(DifferentialCases))]
public void Fill_AdjacentDoubleThresholds_PreservePairAndColumnDecisions(string shape, double spacing, bool rotated)
{
var drawing = MakeFixture(shape);
var angle = rotated ? System.Math.PI / 6 : 0;
var roomy = new Box(3.1, 5.3, 100, 100);
var seed = Invoke(new LegacyFillExtents(roomy, spacing), "BuildPair", drawing, angle);
var bbox = PairBounds(seed);
foreach (var axis in new[] { "length", "width", "column" })
{
var threshold = (axis == "length" ? bbox.Length : axis == "width" ? bbox.Width
: 2 * bbox.Width + spacing) - OpenNest.Math.Tolerance.Epsilon;
var decisions = new HashSet<bool>();
foreach (var size in AdjacentDoubles(threshold))
{
var area = new Box(roomy.X, roomy.Y, axis == "length" ? size : 100,
axis == "length" ? 100 : size);
var legacy = new LegacyFillExtents(area, spacing);
var filler = new FillExtents(area, spacing);
var expectedPair = Invoke(legacy, "BuildPair", drawing, angle);
var actualPair = Invoke(filler, "BuildPair", drawing, angle);
Assert.Equal(expectedPair == null, actualPair == null);
if (axis != "column")
decisions.Add(expectedPair != null);
if (expectedPair == null)
continue;
AssertPairEqual(expectedPair, actualPair!);
var expected = (List<Part>)Invoke(legacy, "BuildColumn", expectedPair);
var actual = (List<Part>)Invoke(filler, "BuildColumn", actualPair!);
AssertSameLayout(expected, actual);
if (axis == "column")
decisions.Add(expected.Count == 4);
}
Assert.Contains(false, decisions);
Assert.Contains(true, decisions);
}
}
[Theory]
[MemberData(nameof(DifferentialCases))]
public void TryShiftDirection_AdjacentWidthThresholds_PreserveAcceptance(string shape, double spacing, bool rotated)
{
var drawing = MakeFixture(shape);
var angle = rotated ? System.Math.PI / 6 : 0;
var area = new Box(3.1, 5.3, 100, 100);
var legacy = new LegacyFillExtents(area, spacing);
var filler = new FillExtents(area, spacing);
var expectedPair = Invoke(legacy, "BuildPair", drawing, angle);
var actualPair = Invoke(filler, "BuildPair", drawing, angle);
foreach (var shift in new[] { -0.3, 0.3 })
{
// Locate adjacent rejected/accepted doubles using only the frozen pre-change
// path; do not derive the oracle from the implementation under test.
var rejected = 0.0;
var accepted = 100.0;
for (var i = 0; i < 64; i++)
{
var middle = (rejected + accepted) / 2;
if (Invoke(legacy, "TryShiftDirection", expectedPair, shift, middle) == null)
rejected = middle;
else
accepted = middle;
}
Assert.Equal(accepted, System.Math.BitIncrement(rejected));
foreach (var width in new[] { rejected, accepted })
{
var expected = Invoke(legacy, "TryShiftDirection", expectedPair, shift, width);
var actual = Invoke(filler, "TryShiftDirection", actualPair, shift, width);
Assert.Equal(width == accepted, expected != null);
Assert.Equal(expected == null, actual == null);
if (expected != null)
AssertPairEqual(expected, actual!);
}
}
}
private static IEnumerable<double> AdjacentDoubles(double value)
{
yield return value;
var lower = value;
var upper = value;
for (var i = 0; i < 4; i++)
{
lower = System.Math.BitDecrement(lower);
upper = System.Math.BitIncrement(upper);
yield return lower;
yield return upper;
}
}
private static Box PairBounds(object pair) => (Box)pair.GetType().GetProperty("Bbox")!.GetValue(pair)!;
private static void AssertPairEqual(object expected, object actual)
{
Assert.Equal(Bounds(PairBounds(expected)), Bounds(PairBounds(actual)));
var expectedParts = new[] { "Part1", "Part2" }.Select(name =>
(Part)expected.GetType().GetProperty(name)!.GetValue(expected)!).ToList();
var actualParts = new[] { "Part1", "Part2" }.Select(name =>
(Part)actual.GetType().GetProperty(name)!.GetValue(actual)!).ToList();
AssertSameLayout(expectedParts, actualParts);
}
[Theory]
[InlineData(5, 5)]
[InlineData(15, 10)] // One rectangle fits, the pair does not.
@@ -185,6 +339,35 @@ public class FillExtentsTests
}
#if DEBUG
[Theory]
[InlineData(0.0)]
[InlineData(0.5)]
public void Fill_BoundsWork_PerformsFewerUpdates(double spacing)
{
var area = new Box(3, 5, 45, 27);
var drawing = MakeFixture("triangle");
PerfCounters.Reset();
try
{
// Task 2 removed boundary preparation, not Part.UpdateBounds calls, so
// this frozen baseline has the pre-Task-3 full-fill bounds-update count.
var expected = new LegacyFillExtents(area, spacing).Fill(drawing);
var before = PerfCounters.PartBoundsUpdates;
PerfCounters.Reset();
var actual = new FillExtents(area, spacing).Fill(drawing);
var after = PerfCounters.PartBoundsUpdates;
output.WriteLine($"bounds spacing={spacing}: before={before}, actual={after}, parts={actual.Count}");
AssertSameLayout(expected, actual);
Assert.Equal(13, before);
Assert.True(after < before, $"Expected fewer than {before} part bounds updates, actual {after}.");
Assert.Equal(10, after);
}
finally
{
PerfCounters.Reset();
}
}
[Theory]
[InlineData(0.0)]
[InlineData(0.5)]
@@ -284,7 +467,8 @@ public class FillExtentsTests
for (var i = 0; i < expected.Count; i++)
{
Assert.Same(expected[i].BaseDrawing, actual[i].BaseDrawing);
Assert.Equal(expected[i].Location, actual[i].Location);
Assert.Equal((expected[i].Location.X, expected[i].Location.Y),
(actual[i].Location.X, actual[i].Location.Y));
Assert.Equal(expected[i].Rotation, actual[i].Rotation);
Assert.Equal(Bounds(expected[i].BoundingBox), Bounds(actual[i].BoundingBox));
Assert.Equal(ProgramValues(expected[i].Program), ProgramValues(actual[i].Program));
@@ -315,12 +499,12 @@ public class FillExtentsTests
{
values.Add(code.GetType());
if (code is Motion motion)
values.Add(motion.EndPoint);
values.Add((motion.EndPoint.X, motion.EndPoint.Y));
if (code is LinearMove line)
values.Add(line.Layer);
if (code is ArcMove arc)
{
values.Add(arc.CenterPoint);
values.Add((arc.CenterPoint.X, arc.CenterPoint.Y));
values.Add(arc.Rotation);
values.Add(arc.Layer);
}
@@ -139,6 +139,66 @@ public class FillPerformanceTests
ReportGroupSummary("custom", customSamples, callsPerBatch);
}
[SkippableFact]
public void RotatedPattern_ReportsBoundsConstruction()
{
Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_FILL_PERF") == "1",
"Set OPENNEST_RUN_FILL_PERF=1 to run opt-in fill microbenchmarks.");
var drawing = FillExtentsTests.MakeFixture("arc");
var group = Enumerable.Range(0, 32).Select(i =>
new Part(drawing, new Vector(11.25 + i % 8 * 12, 13.5 + i / 8 * 10))).ToList();
var angles = new[] { 0.0, 0.37 };
var builds = angles.Select(angle => new Func<List<Part>>(() =>
FillHelpers.BuildRotatedPattern(group, angle).Parts)).ToArray();
var expected = angles.Select(angle =>
OpenNest.Tests.Strategies.FillHelpersTests.PreChangeRotatedPattern(group, angle).Parts).ToArray();
var warmupCalls = 1_000;
var callsPerBatch = 5_000;
var repetitions = 7;
#if DEBUG
output.WriteLine("Configuration=Debug (diagnostic only; use Release for measurements).");
#else
output.WriteLine("Configuration=Release.");
#endif
output.WriteLine($"Runtime={RuntimeInformation.FrameworkDescription}; OS={RuntimeInformation.OSDescription}; "
+ $"architecture={RuntimeInformation.ProcessArchitecture}; processors={Environment.ProcessorCount}; "
+ $"Stopwatch.Frequency={Stopwatch.Frequency} ticks/s.");
output.WriteLine($"rotated-pattern: 32 native-arc parts on an 8-column 12x10 grid from (11.25,13.5); "
+ $"angle=0 or 0.37; warmup=2 x {warmupCalls}; measured={repetitions} x {callsPerBatch}; "
+ "angle batch order alternates. Real synchronous production construction only; setup, reference, "
+ "assertions/output excluded; clone/rotation/aggregate bounds, GC and count consumption included. "
+ "Warm drawing/JIT; no forced GC/cache reset. Current-thread allocations, not RSS or a whole-job benchmark.");
for (var mode = 0; mode < builds.Length; mode++)
FillExtentsTests.AssertSameLayout(expected[mode], builds[mode]());
for (var batch = 0; batch < 2; batch++)
for (var slot = 0; slot < builds.Length; slot++)
MeasureExtents(builds[(slot + batch) % builds.Length], warmupCalls);
var samples = angles.Select(_ => new ExtentsSample[repetitions]).ToArray();
for (var batch = 0; batch < repetitions; batch++)
{
for (var slot = 0; slot < builds.Length; slot++)
{
var mode = (slot + batch) % builds.Length;
samples[mode][batch] = MeasureExtents(builds[mode], callsPerBatch);
}
for (var mode = 0; mode < builds.Length; mode++)
{
var sample = samples[mode][batch];
Assert.Equal((long)callsPerBatch * group.Count, sample.PartCount);
FillExtentsTests.AssertSameLayout(expected[mode], sample.LastResult);
output.WriteLine(FormattableString.Invariant(
$"rotated-pattern angle={angles[mode]} batch={batch + 1}: ms={sample.Milliseconds:F6}; bytes={sample.AllocatedBytes}; parts={sample.PartCount}."));
}
}
for (var mode = 0; mode < builds.Length; mode++)
{
var times = samples[mode].Select(s => s.Milliseconds).OrderBy(t => t).ToArray();
var bytes = samples[mode].Select(s => s.AllocatedBytes).OrderBy(b => b).ToArray();
output.WriteLine(FormattableString.Invariant(
$"rotated-pattern angle={angles[mode]}: batch ms min/median/max={times[0]:F6}/{times[repetitions / 2]:F6}/{times[^1]:F6}; us/call min/median/max={times[0] * 1000 / callsPerBatch:F3}/{times[repetitions / 2] * 1000 / callsPerBatch:F3}/{times[^1] * 1000 / callsPerBatch:F3}; batch bytes min/median/max={bytes[0]}/{bytes[repetitions / 2]}/{bytes[^1]}; B/call={(double)bytes[repetitions / 2] / callsPerBatch:F3}."));
}
}
[SkippableFact]
public void Extents_ReportsRepeatedColumnRebuilds()
{
+114 -3
View File
@@ -11,6 +11,86 @@ namespace OpenNest.Tests.Strategies;
[Collection(nameof(FillCacheCollection))]
public class FillHelpersTests
{
[Theory]
[InlineData("accumulated-translation", 0.0)]
[InlineData("accumulated-translation", 0.37)]
[InlineData("translated", 0.0)]
[InlineData("translated", 0.37)]
[InlineData("pre-rotated", 0.0)]
[InlineData("pre-rotated", 0.37)]
[InlineData("canonical", 0.0)]
[InlineData("canonical", 0.37)]
[InlineData("arc", 0.0)]
[InlineData("arc", 0.37)]
[InlineData("nonzero-program-origin", 0.0)]
[InlineData("nonzero-program-origin", 0.37)]
public void BuildRotatedPattern_MatchesPreChangePosesBoundsAndOwnership(string scenario, double angle)
{
var drawing = OpenNest.Tests.Fill.FillExtentsTests.MakeFixture(scenario == "arc" ? "arc" : "concave");
if (scenario is "pre-rotated" or "canonical")
drawing.Program.Rotate(0.6);
double canonicalAngle = 0;
if (scenario == "canonical")
{
// Program.Rotate mutates in place; refresh the canonical angle the same way
// a rotated CAD import carries it, or AsCanonicalCopy takes the zero-angle path.
drawing.RecomputeCanonicalAngle();
canonicalAngle = drawing.Source.Angle;
Assert.False(OpenNest.Math.Tolerance.IsEqualTo(canonicalAngle, 0));
drawing = CanonicalFrame.AsCanonicalCopy(drawing);
Assert.Equal(0.0, drawing.Source.Angle);
}
if (scenario == "accumulated-translation")
drawing.Program.Offset(new Vector(0.1, 0.1));
if (scenario == "nonzero-program-origin")
drawing.Program.Offset(new Vector(0.125, -0.375));
var group = new List<Part>
{
new(drawing, new Vector(11.25, 13.5)),
new(drawing, new Vector(31.75, 27.25)),
};
if (scenario == "translated")
foreach (var part in group)
part.Offset(3.5, -2.25);
if (scenario == "accumulated-translation")
{
group = new List<Part> { new(drawing, new Vector(0.1, 0.1)) };
group[0].Offset(1.1, 1.1);
}
var before = Snapshot(group);
var expected = PreChangeRotatedPattern(group, angle);
var actual = FillHelpers.BuildRotatedPattern(group, angle);
OpenNest.Tests.Fill.FillExtentsTests.AssertSameLayout(expected.Parts, actual.Parts);
Assert.Equal(Bounds(expected.BoundingBox), Bounds(actual.BoundingBox));
AssertNoInputPartsReturned(group, actual.Parts);
Assert.Equal(before, Snapshot(group));
for (var i = 0; i < group.Count; i++)
{
Assert.NotSame(group[i].Program, actual.Parts[i].Program);
// Clone must not reapply the baked drawing rotation before adding the angle.
// Normalize both sides: a baked canonical rotation can already sit at exactly 2pi.
Assert.Equal(OpenNest.Math.Angle.NormalizeRad(group[i].Rotation + angle),
OpenNest.Math.Angle.NormalizeRad(actual.Parts[i].Rotation));
}
}
// The exact pre-Task-3 helper, kept local to characterization, never used for timings.
internal static Pattern PreChangeRotatedPattern(List<Part> group, double angle)
{
var pattern = new Pattern();
var center = ((IEnumerable<IBoundable>)group).GetBoundingBox().Center;
foreach (var part in group)
{
var clone = (Part)part.Clone();
clone.UpdateBounds();
if (!OpenNest.Math.Tolerance.IsEqualTo(angle, 0))
clone.Rotate(angle, center);
pattern.Parts.Add(clone);
}
pattern.UpdateBounds();
return pattern;
}
[Theory]
[InlineData(5, 9, 8, 7)]
[InlineData(10, 4, 7, 8)]
@@ -185,6 +265,30 @@ public class FillHelpersTests
}
#if DEBUG
[Theory]
[InlineData(0.0, 2)]
[InlineData(0.37, 4)]
public void BuildRotatedPattern_BoundsWork_RetainsRequiredCloneRecomputation(double angle, long expectedUpdates)
{
var group = MakeGroup();
var expected = PreChangeRotatedPattern(group, angle);
PerfCounters.Reset();
try
{
var pattern = FillHelpers.BuildRotatedPattern(group, angle);
// Removing the clone recompute changes accumulated-translation boxes at angle 0.
// This site was retained, not optimized. Pattern.UpdateBounds aggregates boxes;
// it does not call Part.UpdateBounds and remains outside this counter.
Assert.Equal(expectedUpdates, PerfCounters.PartBoundsUpdates);
AssertSameLayout(expected.Parts, pattern.Parts);
Assert.Equal(Bounds(expected.BoundingBox), Bounds(pattern.BoundingBox));
}
finally
{
PerfCounters.Reset();
}
}
[Theory]
[InlineData(5, 9, 0, 2)]
[InlineData(5, 9, 1, 0)]
@@ -231,7 +335,8 @@ public class FillHelpersTests
for (var i = 0; i < expected.Count; i++)
{
Assert.Same(expected[i].BaseDrawing, actual[i].BaseDrawing);
Assert.Equal(expected[i].Location, actual[i].Location);
Assert.Equal((expected[i].Location.X, expected[i].Location.Y),
(actual[i].Location.X, actual[i].Location.Y));
Assert.Equal(expected[i].Rotation, actual[i].Rotation);
Assert.Equal(Bounds(expected[i].BoundingBox), Bounds(actual[i].BoundingBox));
}
@@ -270,7 +375,7 @@ public class FillHelpersTests
values.Add(part);
values.Add(part.BaseDrawing);
values.Add(part.BaseDrawing.Area);
values.Add(part.Location);
values.Add((part.Location.X, part.Location.Y));
values.Add(part.Rotation);
values.Add(Bounds(part.BoundingBox));
foreach (var program in new[] { part.Program, part.BaseDrawing.Program })
@@ -282,7 +387,13 @@ public class FillHelpersTests
{
values.Add(code);
if (code is Motion motion)
values.Add(motion.EndPoint);
values.Add((motion.EndPoint.X, motion.EndPoint.Y));
if (code is ArcMove arc)
{
values.Add((arc.CenterPoint.X, arc.CenterPoint.Y));
values.Add(arc.Rotation);
values.Add(arc.Layer);
}
}
}
}