perf(fill): short-circuit default comparisons by count
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Tests.BestFit;
|
||||
|
||||
namespace OpenNest.Tests.Fill;
|
||||
|
||||
@@ -62,8 +64,212 @@ public class DefaultFillComparerTests
|
||||
};
|
||||
Assert.True(comparer.IsBetter(candidate, current, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LowerCount_ReturnsFalse()
|
||||
{
|
||||
var candidate = new List<Part> { TestHelpers.MakePartAt(0, 0, 10) };
|
||||
var current = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(20, 0, 10),
|
||||
};
|
||||
|
||||
Assert.False(comparer.IsBetter(candidate, current, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameCount_LowerDensity_ReturnsFalse()
|
||||
{
|
||||
var candidate = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(50, 0, 10),
|
||||
};
|
||||
var current = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(12, 0, 10),
|
||||
};
|
||||
|
||||
Assert.False(comparer.IsBetter(candidate, current, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactScoreTie_ReturnsFalseInBothOrders()
|
||||
{
|
||||
var candidate = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(5, 7, 10),
|
||||
TestHelpers.MakePartAt(25, 7, 10),
|
||||
};
|
||||
var current = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(20, 0, 10),
|
||||
};
|
||||
|
||||
Assert.Equal(FillScore.Compute(candidate, workArea), FillScore.Compute(current, workArea));
|
||||
Assert.False(comparer.IsBetter(candidate, current, workArea));
|
||||
Assert.False(comparer.IsBetter(current, candidate, workArea));
|
||||
Assert.False(comparer.IsBetter(candidate, candidate, workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnequalCounts_SmallerLayoutIsDenser_ButCountWinsInBothOrders()
|
||||
{
|
||||
var larger = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(40, 0, 10),
|
||||
TestHelpers.MakePartAt(80, 0, 10),
|
||||
};
|
||||
var smaller = new List<Part>
|
||||
{
|
||||
TestHelpers.MakePartAt(0, 0, 10),
|
||||
TestHelpers.MakePartAt(12, 0, 10),
|
||||
};
|
||||
|
||||
Assert.True(FillScore.Compute(smaller, workArea).Density > FillScore.Compute(larger, workArea).Density);
|
||||
Assert.True(comparer.IsBetter(larger, smaller, workArea));
|
||||
Assert.False(comparer.IsBetter(smaller, larger, workArea));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0, false)]
|
||||
[InlineData(0, 1, false)]
|
||||
[InlineData(0, 2, false)]
|
||||
[InlineData(1, 0, false)]
|
||||
[InlineData(1, 1, false)]
|
||||
[InlineData(1, 2, false)]
|
||||
[InlineData(2, 0, true)]
|
||||
[InlineData(2, 1, true)]
|
||||
[InlineData(2, 2, false)]
|
||||
public void NullEmptyAndNonemptyInputs_PreserveGuardOrder(int candidateKind, int currentKind, bool expected)
|
||||
{
|
||||
// 0 = null, 1 = empty, 2 = one valid part; comparing that part to itself ties.
|
||||
var inputs = new List<Part>?[]
|
||||
{
|
||||
null,
|
||||
new(),
|
||||
new() { TestHelpers.MakePartAt(0, 0, 10) },
|
||||
};
|
||||
|
||||
Assert.Equal(expected, comparer.IsBetter(inputs[candidateKind], inputs[currentKind], workArea));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidLayoutMatrix_MatchesReferenceInBothOrdersAndTies_WithoutMutatingInputs()
|
||||
{
|
||||
var layouts = new List<List<Part>> { new() };
|
||||
foreach (var count in new[] { 1, 2, 4, 7 })
|
||||
foreach (var size in new[] { 1.0, 3.0 })
|
||||
foreach (var pitch in new[] { 4.0, 12.0 })
|
||||
foreach (var origin in new[] { new Vector(0, 0), new Vector(5, 9) })
|
||||
{
|
||||
var parts = new List<Part>();
|
||||
for (var i = 0; i < count; i++)
|
||||
parts.Add(TestHelpers.MakePartAt(origin.X + i % 3 * pitch, origin.Y + i / 3 * pitch, size));
|
||||
// Also vary enumeration order; the comparer must not reorder caller lists.
|
||||
if (origin.X > 0)
|
||||
parts.Reverse();
|
||||
layouts.Add(parts);
|
||||
}
|
||||
|
||||
foreach (var parts in layouts)
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var part = parts[i];
|
||||
Assert.True(double.IsFinite(part.BaseDrawing.Area));
|
||||
Assert.True(part.BaseDrawing.Area > 0);
|
||||
Assert.True(workArea.Contains(part.BoundingBox));
|
||||
foreach (var value in new[] { part.Left, part.Right, part.Top, part.Bottom, part.Rotation })
|
||||
Assert.True(double.IsFinite(value));
|
||||
for (var j = 0; j < i; j++)
|
||||
Assert.False(part.BoundingBox.Intersects(parts[j].BoundingBox));
|
||||
}
|
||||
}
|
||||
|
||||
var before = layouts.Select(Snapshot).ToArray();
|
||||
var workAreaBefore = (workArea.X, workArea.Y, workArea.Length, workArea.Width);
|
||||
// Full Cartesian matrix includes both argument orders, self-comparisons,
|
||||
// translated/permuted exact ties, and equal/unequal counts and densities.
|
||||
foreach (var candidate in layouts)
|
||||
foreach (var current in layouts)
|
||||
{
|
||||
var expected = FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
|
||||
Assert.Equal(expected, comparer.IsBetter(candidate, current, workArea));
|
||||
}
|
||||
|
||||
for (var i = 0; i < layouts.Count; i++)
|
||||
Assert.Equal(before[i], Snapshot(layouts[i]));
|
||||
Assert.Equal(workAreaBefore, (workArea.X, workArea.Y, workArea.Length, workArea.Width));
|
||||
}
|
||||
|
||||
private static object[] Snapshot(List<Part> parts)
|
||||
{
|
||||
var values = new List<object>();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
values.Add(part);
|
||||
values.Add(part.BaseDrawing);
|
||||
values.Add(part.BaseDrawing.Area);
|
||||
values.Add(part.Location);
|
||||
values.Add(part.Rotation);
|
||||
values.Add(part.BoundingBox);
|
||||
values.Add((part.Left, part.Right, part.Top, part.Bottom));
|
||||
foreach (var program in new[] { part.Program, part.BaseDrawing.Program })
|
||||
{
|
||||
values.Add(program);
|
||||
values.Add(program.Mode);
|
||||
values.Add(program.Rotation);
|
||||
foreach (var code in program.Codes)
|
||||
{
|
||||
values.Add(code);
|
||||
if (code is Motion motion)
|
||||
values.Add(motion.EndPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
return values.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// PerfCounters is process-wide. This collection excludes all parallel tests;
|
||||
// always clear counters in finally, including when the skipped-work assertion fails.
|
||||
[Collection(nameof(FillCacheCollection))]
|
||||
public class DefaultFillComparerWorkTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(2, 1, 0)]
|
||||
[InlineData(1, 2, 0)]
|
||||
[InlineData(2, 2, 2)]
|
||||
[InlineData(0, 1, 0)]
|
||||
[InlineData(1, 0, 0)]
|
||||
public void IsBetter_ComputesScoresOnlyForNonemptyEqualCounts(int candidateCount, int currentCount, long expectedComputations)
|
||||
{
|
||||
var candidate = Enumerable.Range(0, candidateCount).Select(i => TestHelpers.MakePartAt(i * 20, 0, 10)).ToList();
|
||||
var current = Enumerable.Range(0, currentCount).Select(i => TestHelpers.MakePartAt(i * 20, 0, 10)).ToList();
|
||||
var workArea = new Box(0, 0, 100, 100);
|
||||
var comparer = new DefaultFillComparer();
|
||||
var expected = FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
|
||||
|
||||
PerfCounters.Reset();
|
||||
try
|
||||
{
|
||||
Assert.Equal(expected, comparer.IsBetter(candidate, current, workArea));
|
||||
Assert.Equal(expectedComputations, PerfCounters.FillScoreComputations);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PerfCounters.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
public class VerticalRemnantComparerTests
|
||||
{
|
||||
private readonly IFillComparer comparer = new VerticalRemnantComparer();
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Shapes;
|
||||
using OpenNest.Tests.BestFit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace OpenNest.Tests.Fill;
|
||||
|
||||
[Collection(nameof(FillCacheCollection))]
|
||||
[Trait("Category", "FillPerformance")]
|
||||
public class FillPerformanceTests
|
||||
{
|
||||
private readonly ITestOutputHelper output;
|
||||
|
||||
public FillPerformanceTests(ITestOutputHelper output) => this.output = output;
|
||||
|
||||
[SkippableFact]
|
||||
public void DefaultComparer_ReportsUnequalCountsAndEqualCountControl()
|
||||
{
|
||||
// Xunit.SkippableFact 1.4.13 calls its skip-unless API IfNot.
|
||||
Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_FILL_PERF") == "1",
|
||||
"Set OPENNEST_RUN_FILL_PERF=1 to run opt-in fill microbenchmarks.");
|
||||
|
||||
var workArea = new Box(0, 0, 256, 256);
|
||||
var drawing = new RectangleShape { Length = 2, Width = 1 }.GetDrawing();
|
||||
var larger = MakeGrid(drawing, 2048, 4);
|
||||
var smaller = MakeGrid(drawing, 2047, 3);
|
||||
var equalCountCompact = MakeGrid(drawing, 2048, 3);
|
||||
AssertValidRectangles(larger, workArea);
|
||||
AssertValidRectangles(smaller, workArea);
|
||||
AssertValidRectangles(equalCountCompact, workArea);
|
||||
Assert.True(FillScore.Compute(smaller, workArea).Density > FillScore.Compute(larger, workArea).Density);
|
||||
|
||||
#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("Synthetic 2x1 rectangles, 64 columns, work area=(0,0,256,256). "
|
||||
+ "Larger: 2048 parts, pitch=4; smaller: 2047 parts, pitch=3; equal-count compact: 2048 parts, pitch=3.");
|
||||
output.WriteLine("Each batch alternates argument order (half forward, half reverse). "
|
||||
+ "Actual/reference batch order alternates between repetitions. "
|
||||
+ "Construction, validation, assertions and output excluded; delegate/loop/result-consumption overhead included. "
|
||||
+ "Allocations use GC.GetAllocatedBytesForCurrentThread around synchronous calls only. "
|
||||
+ "These are local microbenchmarks, not timing gates or whole-job speedup estimates.");
|
||||
|
||||
// A large fixed batch also makes the optimized, constant-time path measurable.
|
||||
// Keep these inputs and iteration counts identical for before/after measurements.
|
||||
ReportCase("unequal-counts", larger, smaller, workArea, 500_000);
|
||||
ReportCase("equal-count-control", equalCountCompact, larger, workArea, 10_000);
|
||||
}
|
||||
|
||||
private void ReportCase(string name, List<Part> candidate, List<Part> current, Box workArea, int callsPerBatch)
|
||||
{
|
||||
var comparer = new DefaultFillComparer();
|
||||
var actual = new Func<List<Part>, List<Part>, Box, bool>(comparer.IsBetter);
|
||||
var reference = new Func<List<Part>, List<Part>, Box, bool>((a, b, area) =>
|
||||
FillScore.Compute(a, area) > FillScore.Compute(b, area));
|
||||
var expectedForward = reference(candidate, current, workArea);
|
||||
var expectedReverse = reference(current, candidate, workArea);
|
||||
Assert.Equal(expectedForward, actual(candidate, current, workArea));
|
||||
Assert.Equal(expectedReverse, actual(current, candidate, workArea));
|
||||
var expectedTrueCount = callsPerBatch / 2 * ((expectedForward ? 1 : 0) + (expectedReverse ? 1 : 0));
|
||||
|
||||
var warmupCallsPerBatch = 50_000;
|
||||
var repetitions = 7;
|
||||
// Interleaved warmup allows JIT/tiering and cached drawing/bounds access to settle.
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
Measure(actual, candidate, current, workArea, warmupCallsPerBatch);
|
||||
Measure(reference, candidate, current, workArea, warmupCallsPerBatch);
|
||||
}
|
||||
|
||||
output.WriteLine($"{name}: warmup=2 batches x {warmupCallsPerBatch} calls per implementation; "
|
||||
+ $"measured={repetitions} batches x {callsPerBatch} calls per implementation; "
|
||||
+ $"expected true results/batch={expectedTrueCount}.");
|
||||
var actualSamples = new Sample[repetitions];
|
||||
var referenceSamples = new Sample[repetitions];
|
||||
for (var i = 0; i < repetitions; i++)
|
||||
{
|
||||
if (i % 2 == 0)
|
||||
{
|
||||
actualSamples[i] = Measure(actual, candidate, current, workArea, callsPerBatch);
|
||||
referenceSamples[i] = Measure(reference, candidate, current, workArea, callsPerBatch);
|
||||
}
|
||||
else
|
||||
{
|
||||
referenceSamples[i] = Measure(reference, candidate, current, workArea, callsPerBatch);
|
||||
actualSamples[i] = Measure(actual, candidate, current, workArea, callsPerBatch);
|
||||
}
|
||||
// Consume measured results and check correctness outside the timed region.
|
||||
Assert.Equal(expectedTrueCount, actualSamples[i].TrueCount);
|
||||
Assert.Equal(expectedTrueCount, referenceSamples[i].TrueCount);
|
||||
output.WriteLine(FormattableString.Invariant(
|
||||
$"{name} batch {i + 1}: actual={actualSamples[i].Milliseconds:F6} ms, {actualSamples[i].AllocatedBytes} B; reference={referenceSamples[i].Milliseconds:F6} ms, {referenceSamples[i].AllocatedBytes} B."));
|
||||
}
|
||||
ReportSummary(name, "actual", actualSamples, callsPerBatch);
|
||||
ReportSummary(name, "reference", referenceSamples, callsPerBatch);
|
||||
}
|
||||
|
||||
private void ReportSummary(string name, string implementation, Sample[] 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(
|
||||
$"{name} {implementation}: batch ms min/median/max={times[0]:F6}/{times[median]:F6}/{times[^1]:F6}; ns/call min/median/max={times[0] * 1_000_000 / callsPerBatch:F3}/{times[median] * 1_000_000 / callsPerBatch:F3}/{times[^1] * 1_000_000 / callsPerBatch:F3}; batch bytes min/median/max={bytes[0]}/{bytes[median]}/{bytes[^1]}; B/call min/median/max={(double)bytes[0] / callsPerBatch:F6}/{(double)bytes[median] / callsPerBatch:F6}/{(double)bytes[^1] / callsPerBatch:F6}."));
|
||||
}
|
||||
|
||||
private static Sample Measure(Func<List<Part>, List<Part>, Box, bool> compare,
|
||||
List<Part> candidate, List<Part> current, Box workArea, int calls)
|
||||
{
|
||||
var trueCount = 0;
|
||||
var allocatedBefore = GC.GetAllocatedBytesForCurrentThread();
|
||||
var start = Stopwatch.GetTimestamp();
|
||||
for (var i = 0; i < calls; i++)
|
||||
{
|
||||
var forward = i % 2 == 0;
|
||||
if (compare(forward ? candidate : current, forward ? current : candidate, workArea))
|
||||
trueCount++;
|
||||
}
|
||||
var elapsed = Stopwatch.GetTimestamp() - start;
|
||||
var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore;
|
||||
return new Sample(elapsed * 1000.0 / Stopwatch.Frequency, allocated, trueCount);
|
||||
}
|
||||
|
||||
private static List<Part> MakeGrid(Drawing drawing, int count, double pitch)
|
||||
{
|
||||
var parts = new List<Part>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
parts.Add(new Part(drawing, new Vector(i % 64 * pitch, i / 64 * pitch)));
|
||||
return parts;
|
||||
}
|
||||
|
||||
private static void AssertValidRectangles(List<Part> parts, Box workArea)
|
||||
{
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var part = parts[i];
|
||||
Assert.Equal(2.0, part.BaseDrawing.Area);
|
||||
Assert.Equal(2.0, part.BoundingBox.Length);
|
||||
Assert.Equal(1.0, part.BoundingBox.Width);
|
||||
Assert.True(workArea.Contains(part.BoundingBox));
|
||||
foreach (var value in new[] { part.Left, part.Right, part.Bottom, part.Top, part.Rotation })
|
||||
Assert.True(double.IsFinite(value));
|
||||
for (var j = 0; j < i; j++)
|
||||
Assert.False(part.BoundingBox.Intersects(parts[j].BoundingBox));
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct Sample(double Milliseconds, long AllocatedBytes, int TrueCount);
|
||||
}
|
||||
Reference in New Issue
Block a user