From 7f7d5ec5b100ca89b19178d06a86636acad7cec3 Mon Sep 17 00:00:00 2001 From: Jesse Squire Date: Sat, 31 Oct 2026 13:28:29 -0700 Subject: [PATCH 1/3] Adding additional benchmarking data and optimizations for the initial active reagion approach. --- challenges/conway-life/Solution.md | 24 ++ .../benchmark/DensityAnalysisBenchmarks.cs | 1 + .../csharp/benchmark/DensityGatedStrategy.cs | 2 - .../benchmark/InlinedUnifiedStrategy.cs | 189 +++++++++ .../csharp/benchmark/NoVisitedSetStrategy.cs | 1 - .../benchmark/PerGenerationBenchmarks.cs | 1 + .../src/csharp/benchmark/Program.cs | 6 + .../src/csharp/benchmark/README.md | 39 +- .../csharp/benchmark/UnifiedPathBenchmarks.cs | 123 ++++++ .../csharp/benchmark/UnifiedPathStrategy.cs | 183 +++++++++ .../src/csharp/src/Infrastructure/Strategy.cs | 9 +- .../csharp/src/Infrastructure/StrategyBase.cs | 1 + .../ActiveRegionOptimizedStrategy.cs | 367 ++++++++++++++++++ .../src/Strategies/ActiveRegionStrategy.cs | 2 +- .../ActiveRegionOptimizedStrategyTests.cs | 146 +++++++ 15 files changed, 1088 insertions(+), 6 deletions(-) create mode 100644 challenges/conway-life/src/csharp/benchmark/InlinedUnifiedStrategy.cs create mode 100644 challenges/conway-life/src/csharp/benchmark/UnifiedPathBenchmarks.cs create mode 100644 challenges/conway-life/src/csharp/benchmark/UnifiedPathStrategy.cs create mode 100644 challenges/conway-life/src/csharp/src/Strategies/ActiveRegionOptimizedStrategy.cs create mode 100644 challenges/conway-life/src/csharp/tests/Strategies/ActiveRegionOptimizedStrategyTests.cs diff --git a/challenges/conway-life/Solution.md b/challenges/conway-life/Solution.md index bca73fb..2100c1b 100644 --- a/challenges/conway-life/Solution.md +++ b/challenges/conway-life/Solution.md @@ -69,6 +69,30 @@ Population growth increases both the work and storage required. The working set The processing rate and memory allowance in the [problem-space discussion](./README.md#problem-space) are planning estimates. They are not measured throughput or peak-memory results for this implementation. +### The optimized active region approach + +**_Concept_** + +This approach builds on the active region strategy. The benchmarking analysis of the initial approach found that the value of the per-generation visited set depends on the density of the population. Below a crossover between 15% and 20% density the visited set does not help, and at 15% density it is slightly slower than not using it. Above the crossover the benefit grows with density, measuring 11% faster at 25% and 22.5% faster at 35%. + +The optimized strategy therefore computes the density of the population at the start of each generation and uses the visited set only when the density is at or above a threshold of 25%. The density is the population size divided by the area of its bounding box, a single pass over the live cells. That check measured under 0.1% of a generation's cost in every case, so it is cheap enough to pay for on every generation. The full analysis, including the per-density and per-workload data, is in the [benchmark area](./src/csharp/benchmark/README.md#density-and-the-per-generation-visited-set). + +**_Optimizations_** + +- **Density-gated visited set**: Each generation picks its own path. Populations at or above the 25% threshold use the visited set, and those below it skip it. The gated approach measured within 3.4% of the best of always-use or never-use in every workload, and it is on the winning side in every workload. + +- **Lazy allocated visited set**: The visited set is allocated on the first generation that uses it, not up front. Benchmarking surfaced that eager allocation paid about 31,000 bytes even in workloads that never used the set, so the allocation only happens when the set earns its cost. + +- **Split generation paths**: The generation methods keep a dedicated path for the visited set and one for not, instead of a single path with a nullable visited set. That duplication is deliberate. Benchmarking the three forms found the single-pass form 0.9% to 4.6% slower with identical allocations, and aggressive inlining of the single-pass methods did not recover the difference. The full analysis, including the inlining probe, is in the [benchmark area](./src/csharp/benchmark/README.md#split-paths-or-a-single-nullable-path). + +**_Complexity_** + +Expected time complexity is `O(g * n)` per generation for a nonempty simulation, the same as the initial approach, with an additional `O(n)` density pass per generation. Space complexity is `O(n)` for the population sets and visited candidates. With zero generations, copying the input takes `O(n)` time and space. + +**_Practical Performance_** + +The strategy keeps the active region strengths while dropping the work that does not pay off. For the 82% density workload it matches the always-use behavior, which measured 53,696.06 ns against 138,056.70 ns for the never-use behavior. For the sparse workload it matches the never-use behavior, which measured 748,564.00 ns against 999,270.61 ns for the always-use behavior, and it allocates 31,024 B where the always-use behavior allocates 546,533 B. The cost of that choice is the per-generation density pass and, at most, one generation on the other path when a population crosses the threshold mid-simulation. + ### Other approaches Hashlife is a more advanced algorithm used by modern programs such as Golly. It represents patterns as a tree structure, which allows it to simulate very large patterns efficiently. It is a candidate for the efficiency iteration. diff --git a/challenges/conway-life/src/csharp/benchmark/DensityAnalysisBenchmarks.cs b/challenges/conway-life/src/csharp/benchmark/DensityAnalysisBenchmarks.cs index ede2058..3cc5d40 100644 --- a/challenges/conway-life/src/csharp/benchmark/DensityAnalysisBenchmarks.cs +++ b/challenges/conway-life/src/csharp/benchmark/DensityAnalysisBenchmarks.cs @@ -192,6 +192,7 @@ private static HashSet CreateCells(int count, int boxSize) { var x = random.Next(-half, half); var y = random.Next(-half, half); + _ = cells.Add(new Coordinate(x, y)); } diff --git a/challenges/conway-life/src/csharp/benchmark/DensityGatedStrategy.cs b/challenges/conway-life/src/csharp/benchmark/DensityGatedStrategy.cs index 5c61f81..a44b791 100644 --- a/challenges/conway-life/src/csharp/benchmark/DensityGatedStrategy.cs +++ b/challenges/conway-life/src/csharp/benchmark/DensityGatedStrategy.cs @@ -139,7 +139,6 @@ private static void ProcessCellWithVisitedSet(Coordinate cell, } var liveNeighbors = 0; - ++depth; foreach (var neighborCell in cell.GetNeighbors()) @@ -185,7 +184,6 @@ private static void ProcessCellWithoutVisitedSet(Coordinate cell, } var liveNeighbors = 0; - ++depth; foreach (var neighborCell in cell.GetNeighbors()) diff --git a/challenges/conway-life/src/csharp/benchmark/InlinedUnifiedStrategy.cs b/challenges/conway-life/src/csharp/benchmark/InlinedUnifiedStrategy.cs new file mode 100644 index 0000000..07ee485 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/InlinedUnifiedStrategy.cs @@ -0,0 +1,189 @@ +using System.Runtime.CompilerServices; +using Squire.ConwayLifeChallenge.Infrastructure; + +namespace Squire.ConwayLifeChallenge.Benchmark; + +/// +/// A benchmark-only variant of +/// that runs the single-pass form of the generation methods with aggressive inlining. +/// +/// +/// +/// This exists only to measure whether the cost that the split form of the optimized strategy wins +/// is the cost of separate methods for each path. It is not part of the application and is never +/// selected by . +/// +/// The algorithm is identical to the optimized strategy, and identical to , +/// which is the same single-pass form without inlining. The generation methods here are marked +/// with AggressiveInlining, so the only difference +/// between the two single-pass forms is the inlining directive on those methods. +/// +/// If the single-pass cost is call overhead, this form should close the gap toward the split form. +/// If it is the cost of the null check in the guard, it lands near , +/// because the null check can't be inlined away. +/// +/// +internal sealed class InlinedUnifiedStrategy : StrategyBase +{ + private const double DensityThreshold = 0.25; + + /// + public override Strategy Strategy => Strategy.ActiveRegionOptimized; + + /// + public override HashSet Simulate(HashSet cells, + int generations) + { + ArgumentNullException.ThrowIfNull(cells, nameof(cells)); + + if (generations <= 0) + { + return [ ..cells ]; + } + + var startingAliveCells = generations switch + { + > 1 => new HashSet(cells, cells.Comparer), + _ => cells + }; + + var endingAliveCells = new HashSet(cells.Count); + var visitedCells = default(HashSet?); + + while (startingAliveCells.Count > 0) + { + var generationVisitedCells = ComputeDensity(startingAliveCells) >= DensityThreshold + ? (visitedCells ??= new HashSet(startingAliveCells.Count)) + : default(HashSet?); + + SimulateGeneration(startingAliveCells, endingAliveCells, generationVisitedCells); + + if (--generations <= 0) + { + return endingAliveCells; + } + + (startingAliveCells, endingAliveCells) = (endingAliveCells, startingAliveCells); + endingAliveCells.Clear(); + visitedCells?.Clear(); + } + + return endingAliveCells; + } + + /// + /// Executes a run for a single generation of the simulation. + /// + /// + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// Optional. The set of cells that have been visited during the generation. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void SimulateGeneration(HashSet startingAliveCells, + HashSet endingAliveCells, + HashSet? generationVisitedCells) + { + foreach (var cell in startingAliveCells) + { + ProcessCell(cell, startingAliveCells, endingAliveCells, generationVisitedCells); + } + } + + /// + /// Processes a cell and, if needed, it's direct neighbors to determine if the cell(s) will be alive + /// in the next generation. + /// + /// + /// The cell to process. + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// Optional. The set of cells that have been visited during the generation. + /// Optional. The current recursive depth of processing. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void ProcessCell(Coordinate cell, + HashSet startingAliveCells, + HashSet endingAliveCells, + HashSet? generationVisitedCells, + byte depth = 0) + { + if ((depth > 1) + || (generationVisitedCells is not null && generationVisitedCells.Contains(cell)) + || (endingAliveCells.Contains(cell))) + { + return; + } + + var liveNeighbors = 0; + ++depth; + + foreach (var neighborCell in cell.GetNeighbors()) + { + if (startingAliveCells.Contains(neighborCell)) + { + ++liveNeighbors; + } + else + { + ProcessCell(neighborCell, startingAliveCells, endingAliveCells, generationVisitedCells, depth); + } + } + + _ = startingAliveCells.Contains(cell) switch + { + true when liveNeighbors is 2 or 3 => endingAliveCells.Add(cell), + false when liveNeighbors is 3 => endingAliveCells.Add(cell), + false => generationVisitedCells?.Add(cell) is true, + _ => false + }; + } + + /// + /// Computes the density of a population as the ratio of live cells to bounding box area. + /// + /// + /// The population to measure. + /// + private static double ComputeDensity(HashSet cells) + { + if (cells.Count == 0) + { + return 0; + } + + var minX = long.MaxValue; + var maxX = long.MinValue; + var minY = long.MaxValue; + var maxY = long.MinValue; + + foreach (var cell in cells) + { + if (cell.X < minX) + { + minX = cell.X; + } + + if (cell.X > maxX) + { + maxX = cell.X; + } + + if (cell.Y < minY) + { + minY = cell.Y; + } + + if (cell.Y > maxY) + { + maxY = cell.Y; + } + } + + var width = maxX - minX + 1; + var height = maxY - minY + 1; + var area = width * height; + + return (double)cells.Count / area; + } +} diff --git a/challenges/conway-life/src/csharp/benchmark/NoVisitedSetStrategy.cs b/challenges/conway-life/src/csharp/benchmark/NoVisitedSetStrategy.cs index 8be6efc..8eaa9de 100644 --- a/challenges/conway-life/src/csharp/benchmark/NoVisitedSetStrategy.cs +++ b/challenges/conway-life/src/csharp/benchmark/NoVisitedSetStrategy.cs @@ -96,7 +96,6 @@ private static void ProcessCell(Coordinate cell, } var liveNeighbors = 0; - ++depth; foreach (var neighborCell in cell.GetNeighbors()) diff --git a/challenges/conway-life/src/csharp/benchmark/PerGenerationBenchmarks.cs b/challenges/conway-life/src/csharp/benchmark/PerGenerationBenchmarks.cs index fa90457..8456949 100644 --- a/challenges/conway-life/src/csharp/benchmark/PerGenerationBenchmarks.cs +++ b/challenges/conway-life/src/csharp/benchmark/PerGenerationBenchmarks.cs @@ -167,6 +167,7 @@ private static HashSet CreateCells(int count, int boxSize) { var x = random.Next(-half, half); var y = random.Next(-half, half); + _ = cells.Add(new Coordinate(x, y)); } diff --git a/challenges/conway-life/src/csharp/benchmark/Program.cs b/challenges/conway-life/src/csharp/benchmark/Program.cs index 6e80a40..fbcc037 100644 --- a/challenges/conway-life/src/csharp/benchmark/Program.cs +++ b/challenges/conway-life/src/csharp/benchmark/Program.cs @@ -20,6 +20,12 @@ public static void Main(string[] args) return; } + if (args.Length > 0 && args[0] == "--unified-path") + { + _ = BenchmarkRunner.Run(); + return; + } + _ = BenchmarkRunner.Run(); } } diff --git a/challenges/conway-life/src/csharp/benchmark/README.md b/challenges/conway-life/src/csharp/benchmark/README.md index 193ed88..fd14105 100644 --- a/challenges/conway-life/src/csharp/benchmark/README.md +++ b/challenges/conway-life/src/csharp/benchmark/README.md @@ -4,7 +4,7 @@ This project holds the benchmark suite for the Conway's Game of Life challenge. Each entry in the suite is a measurement we made to answer a specific question with data, and the findings are recorded here as design input for how the solution evolves. -The benchmarks support our iteration on efficiency. We pick a question, design benchmarks that answer it, run them, and write down what the data shows. Findings here are evidence for design decisions, not decoration. Strategies used only by the benchmarks, such as `NoVisitedSetStrategy` and `DensityGatedStrategy`, live in this project and are never selected by `StrategyBase.CreateStrategy`. +The benchmarks support our iteration on efficiency. We pick a question, design benchmarks that answer it, run them, and write down what the data shows. Findings here are evidence for design decisions, not decoration. Strategies used only by the benchmarks, such as `NoVisitedSetStrategy`, `DensityGatedStrategy`, `UnifiedPathStrategy`, and `InlinedUnifiedStrategy`, live in this project and are never selected by `StrategyBase.CreateStrategy`. ### How an analysis is structured @@ -69,6 +69,36 @@ The crossover is between 15% and 20% density. Below it the visited set does not **Design issue the data surfaced.** The gated approach allocates the visited set eagerly, even when it is not used. For the sparse workload it allocates 62,024 B while the never-use approach allocates 31,024 B. The extra 31,000 B is the visited set, and the same gap appears in the mixed workload. The strategy allocates the set once at the start of the simulation and clears it at the end of each generation, so when the density is below the threshold the set is allocated but never used. This is a cost that lazy allocation, only when first needed, would avoid. It does not change the timing results, because the allocation is a one-time cost amortized across the generations, but it does change the allocation numbers. +#### Split paths or a single nullable path + +**Question.** The `ActiveRegionOptimizedStrategy` runs the generation methods in a split form, one dedicated method per path, with the with-visited-set and without-visited-set paths sharing no code. A single method with a nullable visited set parameter would remove the duplication. In the generations that do not use the set, though, a null check would be paid at every cell and neighbor. Does the deduplication cost anything measurable, and if it does, can aggressive inlining recover it? + +**What was measured.** `UnifiedPathBenchmarks` compares three strategies that are algorithmically identical, including the density gate and the lazy allocation of the visited set. The only difference is the structure of the generation methods. `ActiveRegionOptimizedStrategy` is the split form, `UnifiedPathStrategy` is the single-pass form that carries the visited set as a nullable parameter, and `InlinedUnifiedStrategy` is that same single-pass form with its generation methods marked aggressive inline. If the single-pass cost is the cost of separate methods, the inlined form should close the gap toward the split form. If it is the cost of the null check, the inlined form lands next to the non-inlined form, because a null check can't be inlined away. The sparse, mixed, dense, and R-pentomino workloads are each simulated for thirty generations, measuring time and allocations. + +**The data.** + +| Workload | Split form | Single-pass form | Single-pass, inlined | +|---|---|---|---| +| Sparse (0.025%, 30 gen) | 762,000 ns | 793,600 ns | 796,900 ns | +| Mixed (10%, 30 gen) | 6,159,000 ns | 6,244,300 ns | 6,213,200 ns | +| Dense (82%, 30 gen) | 1,912,800 ns | 1,943,900 ns | 1,940,500 ns | +| R-pentomino (30 gen) | 269,000 ns | 279,400 ns | 272,900 ns | + +Both single-pass forms are slower than the split form in every workload. Against the split form, the deltas are 4.1% and 4.6% sparse, 1.4% and 0.9% mixed, 1.6% and 1.4% dense, and 3.9% and 1.4% R-pentomino. Between the two single-pass forms, the deltas are 0.4%, 0.5%, 0.2%, and 2.3% on the same workloads, all inside their error bands. In the sparse workload, inlining made the single-pass form slightly worse. + +Allocations are identical for all three forms in every workload. 60.57 KB sparse, 60.57 KB mixed, 90.84 KB dense, 13.13 KB R-pentomino. + +**Findings.** + +- The split form wins in every workload. The single-pass forms are 0.9% to 4.6% slower. +- Aggressive inlining does not recover the cost. The inlined and non-inlined forms sit next to each other, within their error bands in every workload, and both sit 0.9% to 4.6% behind the split form. +- That is the signature of a branch, not a call. The deduplication trades a duplicated method body for a null check at every cell and neighbor, and the null check is what the split form pays the least of. Inlining changes the call overhead, which is not where the cost lives. +- The largest deltas are on the two workloads where the gate is off for most of the run, sparse and R-pentomino. That is where the null check is paid at every cell and neighbor with the set never used. +- The single-pass forms win nothing in allocations. The deduplication has no offsetting benefit and only a time cost. +- No free form of the deduplication exists in the data. The cost is the null check, and every structure that carries the visited set as an optional parameter pays it. + +**Decision.** Keep the split form. The deduplication removes a duplicated method body to add a null check at roughly four times as many call sites, and aggressive inlining does not recover the cost, so there is no version of the deduplication that costs nothing. The duplication is deliberate, and the `ActiveRegionOptimizedStrategy` documents that. If the maintenance case ever outweighs the 1 to 5% cost, the single-pass form is already measured and waiting in this project. + ### Running the benchmarks From the `benchmark` directory: @@ -83,6 +113,13 @@ To run a single case, filter by name: dotnet run -c Release -- --filter "DensityAnalysisBenchmarks.GatedSparse" ``` +To run one analysis, pass its flag: + +```shell +dotnet run -c Release -- --evolution +dotnet run -c Release -- --unified-path +``` + ### Notes - The benchmark has its own solution, `ConwayLifeChallenge.Benchmark.slnx`, and is not part of the main `ConwayLifeChallenge.slnx`. diff --git a/challenges/conway-life/src/csharp/benchmark/UnifiedPathBenchmarks.cs b/challenges/conway-life/src/csharp/benchmark/UnifiedPathBenchmarks.cs new file mode 100644 index 0000000..7fb8671 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/UnifiedPathBenchmarks.cs @@ -0,0 +1,123 @@ +using BenchmarkDotNet.Attributes; +using Squire.ConwayLifeChallenge.Infrastructure; +using Squire.ConwayLifeChallenge.Strategies; + +namespace Squire.ConwayLifeChallenge.Benchmark; + +/// +/// Measures the cost of the split form of the optimized strategy against the single-pass form +/// that carries an optional visited set. +/// +/// +/// +/// Both strategies are algorithmically identical, including the density gate and the lazy +/// allocation of the visited set. The only difference is the structure of the generation +/// methods. The split form runs a dedicated method for each path, and the single-pass form +/// runs one method that carries the visited set as a nullable parameter and checks it for null +/// on every cell and neighbor processed. +/// +/// The workloads cover the three regimes the gate produces. Sparse and mixed stay well below +/// the threshold for their entire run, where the null check is paid on every cell and neighbor. +/// Dense crosses above the threshold partway through the run, where the null check is paid in +/// addition to the set lookup. The R-pentomino crosses the threshold over its lifetime. The +/// time difference is the cost of the null check in the single-pass form, and the allocation +/// should be identical if the form is neutral there. +/// +/// The single-pass form is also measured with its generation methods marked aggressive inline. +/// If the single-pass cost is the cost of separate methods, the inlined form should close the +/// gap toward the split form. If it is the cost of the null check, the inlined form lands +/// near the non-inlined form, because a null check can't be inlined away. +/// +/// +[MemoryDiagnoser] +public class UnifiedPathBenchmarks +{ + /// 1,000 cells in a 2,000 by 2,000 box, a density of 0.025 percent. + private readonly HashSet Sparse = CreateCells(1_000, 2_000); + + /// 1,000 cells in a 100 by 100 box, a density of 10 percent. + private readonly HashSet Mixed = CreateCells(1_000, 100); + + /// 1,000 cells in a 35 by 35 box, a density of 82 percent. + private readonly HashSet Dense = CreateCells(1_000, 35); + + /// The R-pentomino, a methuselah with five live cells. + private readonly HashSet RPentomino = + [ + new(1, 0), new(2, 0), new(0, 1), new(1, 1), new(1, 2) + ]; + + /// Simulates the sparse population for thirty generations with the split form. + [Benchmark] + public HashSet SplitSparse() => new ActiveRegionOptimizedStrategy().Simulate(Sparse, 30); + + /// Simulates the sparse population for thirty generations with the single-pass form. + [Benchmark] + public HashSet UnifiedSparse() => new UnifiedPathStrategy().Simulate(Sparse, 30); + + /// Simulates the sparse population for thirty generations with the inlined single-pass form. + [Benchmark] + public HashSet InlinedSparse() => new InlinedUnifiedStrategy().Simulate(Sparse, 30); + + /// Simulates the mixed population for thirty generations with the split form. + [Benchmark] + public HashSet SplitMixed() => new ActiveRegionOptimizedStrategy().Simulate(Mixed, 30); + + /// Simulates the mixed population for thirty generations with the single-pass form. + [Benchmark] + public HashSet UnifiedMixed() => new UnifiedPathStrategy().Simulate(Mixed, 30); + + /// Simulates the mixed population for thirty generations with the inlined single-pass form. + [Benchmark] + public HashSet InlinedMixed() => new InlinedUnifiedStrategy().Simulate(Mixed, 30); + + /// Simulates the dense population for thirty generations with the split form. + [Benchmark] + public HashSet SplitDense() => new ActiveRegionOptimizedStrategy().Simulate(Dense, 30); + + /// Simulates the dense population for thirty generations with the single-pass form. + [Benchmark] + public HashSet UnifiedDense() => new UnifiedPathStrategy().Simulate(Dense, 30); + + /// Simulates the dense population for thirty generations with the inlined single-pass form. + [Benchmark] + public HashSet InlinedDense() => new InlinedUnifiedStrategy().Simulate(Dense, 30); + + /// Simulates the R-pentomino for thirty generations with the split form. + [Benchmark] + public HashSet SplitRPentomino() => new ActiveRegionOptimizedStrategy().Simulate(RPentomino, 30); + + /// Simulates the R-pentomino for thirty generations with the single-pass form. + [Benchmark] + public HashSet UnifiedRPentomino() => new UnifiedPathStrategy().Simulate(RPentomino, 30); + + /// Simulates the R-pentomino for thirty generations with the inlined single-pass form. + [Benchmark] + public HashSet InlinedRPentomino() => new InlinedUnifiedStrategy().Simulate(RPentomino, 30); + + /// + /// Creates a deterministic random population of distinct live cells within a square box. + /// + /// + /// The number of distinct live cells to create. + /// The side length of the square box the cells are placed in. + /// + /// A set of distinct live cells. + /// + private static HashSet CreateCells(int count, int boxSize) + { + var random = new Random(12345); + var half = boxSize / 2; + var cells = new HashSet(count); + + while (cells.Count < count) + { + var x = random.Next(-half, half); + var y = random.Next(-half, half); + + _ = cells.Add(new Coordinate(x, y)); + } + + return cells; + } +} diff --git a/challenges/conway-life/src/csharp/benchmark/UnifiedPathStrategy.cs b/challenges/conway-life/src/csharp/benchmark/UnifiedPathStrategy.cs new file mode 100644 index 0000000..99e20e0 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/UnifiedPathStrategy.cs @@ -0,0 +1,183 @@ +using Squire.ConwayLifeChallenge.Infrastructure; + +namespace Squire.ConwayLifeChallenge.Benchmark; + +/// +/// A benchmark-only variant of +/// that runs the single-pass form of the generation methods. +/// +/// +/// +/// This exists only to measure the cost of the proposed refactor of the optimized strategy from its +/// split form of dedicated generation methods for each path to a single pass that carries an +/// optional visited set for each generation. It is not part of the application and is never +/// selected by . +/// +/// The algorithm is identical to the optimized strategy. The only difference is that +/// SimulateGeneration and ProcessCell are single methods that take a nullable +/// visited set, which is passed as null for generations where the density gate is off and +/// checked for null on every cell and neighbor processed. +/// +/// +internal sealed class UnifiedPathStrategy : StrategyBase +{ + private const double DensityThreshold = 0.25; + + /// + public override Strategy Strategy => Strategy.ActiveRegionOptimized; + + /// + public override HashSet Simulate(HashSet cells, + int generations) + { + ArgumentNullException.ThrowIfNull(cells, nameof(cells)); + + if (generations <= 0) + { + return [ ..cells ]; + } + + var startingAliveCells = generations switch + { + > 1 => new HashSet(cells, cells.Comparer), + _ => cells + }; + + var endingAliveCells = new HashSet(cells.Count); + var visitedCells = default(HashSet?); + + while (startingAliveCells.Count > 0) + { + var generationVisitedCells = ComputeDensity(startingAliveCells) >= DensityThreshold + ? (visitedCells ??= new HashSet(startingAliveCells.Count)) + : default(HashSet?); + + SimulateGeneration(startingAliveCells, endingAliveCells, generationVisitedCells); + + if (--generations <= 0) + { + return endingAliveCells; + } + + (startingAliveCells, endingAliveCells) = (endingAliveCells, startingAliveCells); + endingAliveCells.Clear(); + visitedCells?.Clear(); + } + + return endingAliveCells; + } + + /// + /// Executes a run for a single generation of the simulation. + /// + /// + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// Optional. The set of cells that have been visited during the generation. + /// + private static void SimulateGeneration(HashSet startingAliveCells, + HashSet endingAliveCells, + HashSet? generationVisitedCells) + { + foreach (var cell in startingAliveCells) + { + ProcessCell(cell, startingAliveCells, endingAliveCells, generationVisitedCells); + } + } + + /// + /// Processes a cell and, if needed, it's direct neighbors to determine if the cell(s) will be alive + /// in the next generation. + /// + /// + /// The cell to process. + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// Optional. The set of cells that have been visited during the generation. + /// Optional. The current recursive depth of processing. + /// + private static void ProcessCell(Coordinate cell, + HashSet startingAliveCells, + HashSet endingAliveCells, + HashSet? generationVisitedCells, + byte depth = 0) + { + if ((depth > 1) + || (generationVisitedCells is not null && generationVisitedCells.Contains(cell)) + || (endingAliveCells.Contains(cell))) + { + return; + } + + var liveNeighbors = 0; + ++depth; + + foreach (var neighborCell in cell.GetNeighbors()) + { + if (startingAliveCells.Contains(neighborCell)) + { + ++liveNeighbors; + } + else + { + ProcessCell(neighborCell, startingAliveCells, endingAliveCells, generationVisitedCells, depth); + } + } + + _ = startingAliveCells.Contains(cell) switch + { + true when liveNeighbors is 2 or 3 => endingAliveCells.Add(cell), + false when liveNeighbors is 3 => endingAliveCells.Add(cell), + false => generationVisitedCells?.Add(cell) is true, + _ => false + }; + } + + /// + /// Computes the density of a population as the ratio of live cells to bounding box area. + /// + /// + /// The population to measure. + /// + private static double ComputeDensity(HashSet cells) + { + if (cells.Count == 0) + { + return 0; + } + + var minX = long.MaxValue; + var maxX = long.MinValue; + var minY = long.MaxValue; + var maxY = long.MinValue; + + foreach (var cell in cells) + { + if (cell.X < minX) + { + minX = cell.X; + } + + if (cell.X > maxX) + { + maxX = cell.X; + } + + if (cell.Y < minY) + { + minY = cell.Y; + } + + if (cell.Y > maxY) + { + maxY = cell.Y; + } + } + + var width = maxX - minX + 1; + var height = maxY - minY + 1; + var area = width * height; + + return (double)cells.Count / area; + } +} diff --git a/challenges/conway-life/src/csharp/src/Infrastructure/Strategy.cs b/challenges/conway-life/src/csharp/src/Infrastructure/Strategy.cs index 2407dea..94edf84 100644 --- a/challenges/conway-life/src/csharp/src/Infrastructure/Strategy.cs +++ b/challenges/conway-life/src/csharp/src/Infrastructure/Strategy.cs @@ -10,5 +10,12 @@ public enum Strategy /// An intuitive approach should be applied, staying close to the original inspiration for solving the challenge. /// /// - ActiveRegion + ActiveRegion, + + /// + /// An intuitive approach should be applied, optimized for greater performance and efficiency. This is a + /// version of the original inspiration, optimized based on the data from the challenge's benchmarking. + /// + /// + ActiveRegionOptimized } diff --git a/challenges/conway-life/src/csharp/src/Infrastructure/StrategyBase.cs b/challenges/conway-life/src/csharp/src/Infrastructure/StrategyBase.cs index 6d87e7c..f0ffb9f 100644 --- a/challenges/conway-life/src/csharp/src/Infrastructure/StrategyBase.cs +++ b/challenges/conway-life/src/csharp/src/Infrastructure/StrategyBase.cs @@ -42,6 +42,7 @@ public abstract HashSet Simulate(HashSet cells, internal static StrategyBase CreateStrategy(Strategy strategy) => strategy switch { Strategy.ActiveRegion => new ActiveRegionStrategy(), + Strategy.ActiveRegionOptimized => new ActiveRegionOptimizedStrategy(), _ => throw new ArgumentException($"Unknown strategy: `{ strategy }`.", nameof(strategy)) }; } diff --git a/challenges/conway-life/src/csharp/src/Strategies/ActiveRegionOptimizedStrategy.cs b/challenges/conway-life/src/csharp/src/Strategies/ActiveRegionOptimizedStrategy.cs new file mode 100644 index 0000000..6fc9d4d --- /dev/null +++ b/challenges/conway-life/src/csharp/src/Strategies/ActiveRegionOptimizedStrategy.cs @@ -0,0 +1,367 @@ +using Squire.ConwayLifeChallenge.Infrastructure; + +namespace Squire.ConwayLifeChallenge.Strategies; + +/// +/// An optimized approach for simulating Conway's Game of Life. +/// +/// +/// +/// This is a version of the active region strategy, optimized for greater performance and efficiency. +/// The optimization is based on the benchmarking data collected for the challenge. That data shows the +/// per-generation visited set helps only when the population is dense, so the strategy computes the +/// density of the population at the start of each generation and uses the visited set only when the +/// density is at or above the threshold. +/// +/// The generation methods are split into a dedicated path for each option. A single method with a +/// nullable visited set would remove the duplication, but it would pay a null check at every cell and +/// neighbor in the generations that do not use the set. A measurement of the three forms, including +/// the single-pass form with aggressive inlining, shows the split form is 0.9% to 4.6% faster with +/// identical allocations and inlining does not recover the difference, so the split form is kept. +/// The full analysis is in the challenge's benchmark area. +/// +/// +/// +/// +internal sealed class ActiveRegionOptimizedStrategy : StrategyBase +{ + /// + /// The population density at or above which the per-generation visited set is used for the + /// generation. + /// + /// + /// + /// The value comes from the challenge's benchmark data. The visited set starts to pay off above a + /// crossover between 15% and 20% density, so the threshold sits above that crossover. At 25% + /// density the visiting set measured 11% faster than without it, and the threshold gated the + /// approach to within 3.4% of the best of always-use or never-use in every workload measured. The + /// full analysis is in the challenge's benchmark area. + /// + /// + private const double DensityThreshold = 0.25; + + /// + public override Strategy Strategy => Strategy.ActiveRegionOptimized; + + /// + public override HashSet Simulate(HashSet cells, + int generations) + { + ArgumentNullException.ThrowIfNull(cells, nameof(cells)); + + // If there are no generations to simulate, then the result is simply + // the starting set of live cells. + + if (generations <= 0) + { + return [ ..cells ]; + } + + // The simulation requires that each generation be simulated as a pure function, + // which means that three sets are needed for tracking: + // + // 1. The starting set of live cells for the generation. + // 2. The ending set of live cells for the generation. + // 3. The set of dead cells that have been visited during the generation. + // + // Simulating generations will mutate the sets of cells by design. To avoid + // mutating the set of live cells provided by the caller, the initial + // starting set must be a copy if there are multiple generations to simulate. + // + // Unlike the active region strategy, the visited set is allocated lazily on the first + // generation that uses it. That avoids paying the allocation and garbage collection + // cost for populations that stay below the threshold and never use the set. + + var startingAliveCells = generations switch + { + > 1 => new HashSet(cells, cells.Comparer), + _ => cells + }; + + var endingAliveCells = new HashSet(cells.Count); + var visitedCells = default(HashSet?); + + while (startingAliveCells.Count > 0) + { + // The visited set de-duplicates dead cells that are neighbors of more than one live cell, + // which saves work for dense populations and is wasted overhead for sparse ones. The + // density check is a single pass over the live cells and measured under 0.1% of a + // generation's cost, so it is safe to pay for it to pick the cheaper path. + + if (ComputeDensity(startingAliveCells) >= DensityThreshold) + { + visitedCells ??= new HashSet(startingAliveCells.Count); + SimulateGeneration(startingAliveCells, endingAliveCells, visitedCells); + } + else + { + SimulateGenerationNoVistedTracking(startingAliveCells, endingAliveCells); + } + + // If there are no more generations to simulate, then the result is the + // current set of live cells after the last generation has been simulated. + + if (--generations <= 0) + { + return endingAliveCells; + } + + // Swap the starting and ending sets for the next generation and clear the working + // sets for the next generation. + + (startingAliveCells, endingAliveCells) = (endingAliveCells, startingAliveCells); + endingAliveCells.Clear(); + visitedCells?.Clear(); + } + + return endingAliveCells; + } + + /// + /// Executes a run for a single generation of the simulation using the visited set. + /// + /// + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// The set of cells that have been visited during the generation. + /// + /// + /// The caller retains ownership of all parameters but must be aware that + /// and sets will be mutated during simulation. + /// + /// At the end of the simulation, the contents of will reflect the state of the + /// cells for generation. The content of will reflect scratch work for + /// tracking used by the simulation and has no meaning to the caller. + /// + /// + private static void SimulateGeneration(HashSet startingAliveCells, + HashSet endingAliveCells, + HashSet generationVisitedCells) + { + foreach (var cell in startingAliveCells) + { + ProcessCell(cell, startingAliveCells, endingAliveCells, generationVisitedCells); + } + } + + /// + /// Executes a run for a single generation of the simulation without the visited set. + /// + /// + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// + /// + /// The caller retains ownership of all parameters but must be aware that + /// will be mutated during simulation. The ending set will reflect the state of the cells for the + /// generation at the end of the simulation. + /// + /// + private static void SimulateGenerationNoVistedTracking(HashSet startingAliveCells, + HashSet endingAliveCells) + { + foreach (var cell in startingAliveCells) + { + ProcessCellNoVisitedTracking(cell, startingAliveCells, endingAliveCells); + } + } + + /// + /// Processes a cell and, if needed, it's direct neighbors to determine if the cell(s) will be alive + /// in the next generation, using the visited set. + /// + /// + /// The cell to process. + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// The set of cells that have been visited during the generation. + /// Optional. The current recursive depth of processing. + /// + /// + /// The caller retains ownership of all parameters but must be aware that + /// and sets will be mutated during simulation. + /// + /// At the end of processing, the cell and any direct neighbors will be added to the contents of + /// if determined to be alive. Otherwise, they will be added to . + /// + /// + private static void ProcessCell(Coordinate cell, + HashSet startingAliveCells, + HashSet endingAliveCells, + HashSet generationVisitedCells, + byte depth = 0) + { + // Only the direct neighbors of a cell should be processed, so limit the recursive + // depth to 1. If the depth is above that or the cell has already been visited or + // ruled alive for the generation, skip it. + + if ((depth > 1) + || (generationVisitedCells.Contains(cell)) + || (endingAliveCells.Contains(cell))) + { + return; + } + + // Consider the cell by processing its neighbors. Because dead neighbors need to + // be considered for the next generation, direct neighbors are processed recursively with + // a depth guard to limit considering neighbors-of-neighbors. + + var liveNeighbors = 0; + + ++depth; + + foreach (var neighborCell in cell.GetNeighbors()) + { + if (startingAliveCells.Contains(neighborCell)) + { + ++liveNeighbors; + } + else + { + ProcessCell(neighborCell, startingAliveCells, endingAliveCells, generationVisitedCells, depth); + } + } + + // Determine if the cell will be alive in the next generation. The rules for Conway's Game of Life are: + // + // - If the cell was alive, it survives to the next generation if it has 2 or 3 live neighbors. + // - If the cell was dead, it becomes alive in the next generation if it has exactly 3 live neighbors. + // - If the cell does not meet the above conditions, it will be dead in the next generation. + + _ = startingAliveCells.Contains(cell) switch + { + true when liveNeighbors is 2 or 3 => endingAliveCells.Add(cell), + false when liveNeighbors is 3 => endingAliveCells.Add(cell), + false => generationVisitedCells.Add(cell), + + // Cells that were in the starting set of live cells but die don't need to be + // separately tracked as visited, as they're already tracked in the starting set. + + _ => false + }; + } + + /// + /// Processes a cell and, if needed, it's direct neighbors to determine if the cell(s) will be alive + /// in the next generation, without the visited set. + /// + /// + /// The cell to process. + /// The initial set of live cells for the generation. + /// The set of live cells after the generation has been simulated. + /// Optional. The current recursive depth of processing. + /// + /// + /// The caller retains ownership of all parameters but must be aware that + /// sets will be mutated during simulation. At the end of processing, the cell and any direct + /// neighbors will be added to the contents of if determined + /// to be alive. + /// + /// The split of this method and is deliberate. A single + /// method shared by both paths pays a null check at every cell and neighbor, and measured that + /// cost is 0.9% to 4.6% of the simulation in every workload. + /// + /// + private static void ProcessCellNoVisitedTracking(Coordinate cell, + HashSet startingAliveCells, + HashSet endingAliveCells, + byte depth = 0) + { + // Only the direct neighbors of a cell should be processed, so limit the recursive + // depth to 1. If the depth is above that or the cell has already been ruled alive + // for the generation, skip it. + + if ((depth > 1) + || (endingAliveCells.Contains(cell))) + { + return; + } + + // Consider the cell by processing its neighbors. Because dead neighbors need to + // be considered for the next generation, direct neighbors are processed recursively with + // a depth guard to limit considering neighbors-of-neighbors. + + var liveNeighbors = 0; + + ++depth; + + foreach (var neighborCell in cell.GetNeighbors()) + { + if (startingAliveCells.Contains(neighborCell)) + { + ++liveNeighbors; + } + else + { + ProcessCellNoVisitedTracking(neighborCell, startingAliveCells, endingAliveCells, depth); + } + } + + // Determine if the cell will be alive in the next generation. The rules for Conway's Game of Life are: + // + // - If the cell was alive, it survives to the next generation if it has 2 or 3 live neighbors. + // - If the cell was dead, it becomes alive in the next generation if it has exactly 3 live neighbors. + // - If the cell does not meet the above conditions, it will be dead in the next generation. + + _ = startingAliveCells.Contains(cell) switch + { + true when liveNeighbors is 2 or 3 => endingAliveCells.Add(cell), + false when liveNeighbors is 3 => endingAliveCells.Add(cell), + + // Cells that were in the starting set of live cells but die don't need to be + // separately tracked as visited, as they're already tracked in the starting set. + + _ => false + }; + } + + /// + /// Computes the density of a population as the ratio of live cells to bounding box area. + /// + /// + /// The population to measure. + /// + /// The density as a value between 0 and 1, or 0 for an empty population. + /// + private static double ComputeDensity(HashSet cells) + { + if (cells.Count == 0) + { + return 0; + } + + var minX = long.MaxValue; + var maxX = long.MinValue; + var minY = long.MaxValue; + var maxY = long.MinValue; + + foreach (var cell in cells) + { + if (cell.X < minX) + { + minX = cell.X; + } + + if (cell.X > maxX) + { + maxX = cell.X; + } + + if (cell.Y < minY) + { + minY = cell.Y; + } + + if (cell.Y > maxY) + { + maxY = cell.Y; + } + } + + var width = maxX - minX + 1; + var height = maxY - minY + 1; + var area = width * height; + + return (double)cells.Count / area; + } +} diff --git a/challenges/conway-life/src/csharp/src/Strategies/ActiveRegionStrategy.cs b/challenges/conway-life/src/csharp/src/Strategies/ActiveRegionStrategy.cs index 4b92b62..c635917 100644 --- a/challenges/conway-life/src/csharp/src/Strategies/ActiveRegionStrategy.cs +++ b/challenges/conway-life/src/csharp/src/Strategies/ActiveRegionStrategy.cs @@ -133,7 +133,7 @@ private static void ProcessCell(Coordinate cell, // depth to 1. If the depth is above that or the cell has already been visited or // ruled alive for the generation, skip it. - if ((depth > 1) + if ((depth > 1) || (generationVisitedCells.Contains(cell)) || (endingAliveCells.Contains(cell))) { diff --git a/challenges/conway-life/src/csharp/tests/Strategies/ActiveRegionOptimizedStrategyTests.cs b/challenges/conway-life/src/csharp/tests/Strategies/ActiveRegionOptimizedStrategyTests.cs new file mode 100644 index 0000000..9b8bfc7 --- /dev/null +++ b/challenges/conway-life/src/csharp/tests/Strategies/ActiveRegionOptimizedStrategyTests.cs @@ -0,0 +1,146 @@ +using NUnit.Framework; +using Squire.ConwayLifeChallenge.Infrastructure; +using Squire.ConwayLifeChallenge.Strategies; + +namespace Squire.ConwayLifeChallenge.Tests.Strategies; + +/// +/// The suite of tests for the class. +/// +/// +[TestFixture] +public class ActiveRegionOptimizedStrategyTests +{ + /// + /// Verifies functionality of the method when + /// no generations are requested. + /// + /// + [Test] + [TestCase(0)] + [TestCase(-1)] + [TestCase(int.MinValue)] + public void SimulateReturnsACopyOfThePopulationWhenNoGenerationsAreRequested(int generations) + { + var population = new HashSet + { + new(1, 2), + new(3, 4) + }; + + var result = new ActiveRegionOptimizedStrategy().Simulate(population, generations); + + Assert.That(result, Is.EquivalentTo(population), "Zero generations should return the starting population."); + Assert.That(ReferenceEquals(result, population), Is.False, "No-generations results should not alias the caller's set."); + } + + /// + /// Verifies functionality of the method for the + /// blinker, the simplest known oscillator. + /// + /// + [Test] + public void SimulateOscillatesTheBlinkerBetweenHorizontalAndVertical() + { + var horizontal = new HashSet + { + new(0, 1), + new(1, 1), + new(2, 1) + }; + + var vertical = new HashSet + { + new(1, 0), + new(1, 1), + new(1, 2) + }; + + var strategy = new ActiveRegionOptimizedStrategy(); + + Assert.That(strategy.Simulate(horizontal, 1), Is.EquivalentTo(vertical), "One generation should rotate the blinker."); + Assert.That(strategy.Simulate(horizontal, 2), Is.EquivalentTo(horizontal), "Two generations should return the blinker to its start."); + Assert.That(strategy.Simulate(horizontal, 31), Is.EquivalentTo(vertical), "An odd generation count should leave the blinker vertical."); + } + + /// + /// Verifies functionality of the method for a + /// sparse population, which stays below the density threshold and exercises the path without the + /// per-generation visited set. + /// + /// + [Test] + public void SimulateMatchesActiveRegionOnSparsePopulations() + { + // The cells are spread far enough apart that the population density is about three percent, + // well below the strategy's threshold. Each cell has one live neighbor, so every cell dies. + + var sparse = new HashSet + { + new(0, 0), + new(0, 10), + new(10, 0), + new(10, 10) + }; + + var expected = new ActiveRegionStrategy().Simulate(new HashSet(sparse), 5); + var result = new ActiveRegionOptimizedStrategy().Simulate(sparse, 5); + + Assert.That(result, Is.EquivalentTo(expected), "Sparse simulations should match the active region strategy."); + Assert.That(result, Is.Empty, "Cells with a single live neighbor should die."); + } + + /// + /// Verifies functionality of the method for a + /// dense population that evolves across many generations, which exercises the path with the + /// per-generation visited set. + /// + /// + [Test] + public void SimulateMatchesActiveRegionOnDensePopulations() + { + // The R-pentomino is small and dense, so the populations it produces stay at or above the + // strategy's threshold for their early generations. + + var rPentomino = new HashSet + { + new(1, 0), + new(2, 0), + new(0, 1), + new(1, 1), + new(1, 2) + }; + + foreach (var generations in new int[] { 1, 10, 30 }) + { + var expected = new ActiveRegionStrategy().Simulate(new HashSet(rPentomino), generations); + var result = new ActiveRegionOptimizedStrategy().Simulate(rPentomino, generations); + + Assert.That(result, Is.EquivalentTo(expected), $"The {generations} generation result should match the active region strategy."); + } + } + + /// + /// Verifies that the method does not mutate + /// the population provided by the caller. + /// + /// + [Test] + public void SimulateDoesNotMutateTheCallerPopulation() + { + var rPentomino = new HashSet + { + new(1, 0), + new(2, 0), + new(0, 1), + new(1, 1), + new(1, 2) + }; + + var original = new HashSet(rPentomino); + + _ = new ActiveRegionOptimizedStrategy().Simulate(rPentomino, 10); + + Assert.That(rPentomino, Is.EquivalentTo(original), "Simulation should not mutate the caller's population."); + } +} From ecaae261d52ac9e0617c79193ae9491f81625dd9 Mon Sep 17 00:00:00 2001 From: Jesse Squire Date: Sat, 31 Oct 2026 13:31:27 -0700 Subject: [PATCH 2/3] Rename entry point class to conform to conventions --- .../ConwayLifeChallenge.Benchmark.csproj | 1 + .../src/csharp/benchmark/Program.cs | 31 ------------------- 2 files changed, 1 insertion(+), 31 deletions(-) delete mode 100644 challenges/conway-life/src/csharp/benchmark/Program.cs diff --git a/challenges/conway-life/src/csharp/benchmark/ConwayLifeChallenge.Benchmark.csproj b/challenges/conway-life/src/csharp/benchmark/ConwayLifeChallenge.Benchmark.csproj index 2d04b48..b3f1007 100644 --- a/challenges/conway-life/src/csharp/benchmark/ConwayLifeChallenge.Benchmark.csproj +++ b/challenges/conway-life/src/csharp/benchmark/ConwayLifeChallenge.Benchmark.csproj @@ -5,6 +5,7 @@ Exe false true + Squire.ConwayLifeChallenge.Benchmark.EntryPoint diff --git a/challenges/conway-life/src/csharp/benchmark/Program.cs b/challenges/conway-life/src/csharp/benchmark/Program.cs deleted file mode 100644 index fbcc037..0000000 --- a/challenges/conway-life/src/csharp/benchmark/Program.cs +++ /dev/null @@ -1,31 +0,0 @@ -using BenchmarkDotNet.Running; - -namespace Squire.ConwayLifeChallenge.Benchmark; - -/// -/// Hosts the entry point for the benchmark suite. -/// -/// -public static class Program -{ - /// - /// Runs the benchmark suite. - /// - /// - public static void Main(string[] args) - { - if (args.Length > 0 && args[0] == "--evolution") - { - PopulationEvolution.Run(); - return; - } - - if (args.Length > 0 && args[0] == "--unified-path") - { - _ = BenchmarkRunner.Run(); - return; - } - - _ = BenchmarkRunner.Run(); - } -} From 4635cd16a63c2f1849695c8961efd50722d651f0 Mon Sep 17 00:00:00 2001 From: Jesse Squire Date: Sun, 8 Nov 2026 11:21:53 -0800 Subject: [PATCH 3/3] Completing solution write-up with analysis of other well-known algoritms. --- challenges/conway-life/Solution.md | 45 ++++++++++++++++++- .../src/csharp/benchmark/EntryPoint.cs | 31 +++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 challenges/conway-life/src/csharp/benchmark/EntryPoint.cs diff --git a/challenges/conway-life/Solution.md b/challenges/conway-life/Solution.md index 2100c1b..6f41b5f 100644 --- a/challenges/conway-life/Solution.md +++ b/challenges/conway-life/Solution.md @@ -95,10 +95,53 @@ The strategy keeps the active region strengths while dropping the work that does ### Other approaches -Hashlife is a more advanced algorithm used by modern programs such as Golly. It represents patterns as a tree structure, which allows it to simulate very large patterns efficiently. It is a candidate for the efficiency iteration. +**_Bit-parallel row encoding_** + +This well-known, moderate-complexity improvement was considered as an alternate implementation. Instead of a set of coordinates, each row of the board is stored as machine integers with one bit per cell, and a cell is alive when that bit is set. Neighbor counting then becomes shifts and bit-wise additions across a whole row, and the rule evaluation compresses into two mask operations that apply to all 64 columns at once. That keeps the approach within a self-contained single strategy class, which is the constraint this challenge already imposes. + +The cost of an approach follows from what its representation stores. The active region approach stores the live cells, and its cost tracks that count, which the constraints bound. Bit-parallel stores the board's rectangle, and its cost tracks that bounding box whether it is populated or not. The constraints cap the live cell count at 10,000 and the generation count at 30, but they allow coordinates to fall anywhere in the signed 64-bit range, so the bounding box contains the entire possible universe. + +For a compact, dense pattern, a 100 by 100 block of 10,000 cells, the comparison favors the bit-parallel approach. + +| Measure | Bit-parallel | Active region | +| --- | --- | --- | +| Live cells | 10,000 | 10,000 | +| Memory per generation | 100 by 100 = 10,000 bits, about 1.2 KB | 10,000 cells times 28 B, about 280 KB | +| Work per generation | about 200 word operations, with a fixed number of operations per word | 10,000 cells times 8 = 80,000 neighbor lookups | + +For a sparse, wide pattern, the same logic inverts. The bounding box is set by the outermost live cell on each axis, not by their count. Ten thousand cells on ten thousand distinct columns can span nearly the full width of the signed 64-bit range. + +| Measure | Bit-parallel | Active region | +| --- | --- | --- | +| Live cells | 10,000 | 10,000 | +| Bounding box width | up to 2^64 columns, about 1.8 x 10^19 | - | +| Memory per generation | one spread row alone, about 2.3 x 10^18 B, roughly 2.3 EB | 10,000 cells times 28 B, about 280 KB | +| Memory budget | about 5 GB | about 5 GB | +| Work per generation | touches every column of the bounding box | 10,000 cells times 8 | + +Even the sample input in the problem statement spreads its 8 live cells across about 2 trillion columns. One bit-parallel row across that span is about 250 GB, 50 times the 5 GB budget, and the full bounding box is orders of magnitude larger still. The upper bound is the 2^128 full coordinate space that the problem space section already rejects as infeasible. + +The decision is not a matter of speed, but size. In the compact, dense case the bit-parallel approach is markedly faster and smaller. The active region approach cannot produce a worst case that breaks the budget, because it stores only the live cells and the constraints bound those. The bit-parallel approach can, because it stores the whole rectangle and the constraints do not bound it. Valid inputs that fit the rules exceed the memory and time budgets by orders of magnitude, and that worst case is a property of the representation, not of an unlucky input. On that basis the approach was not implemented. + +**_HashLife_** + +HashLife is a more advanced algorithm used by modern programs such as Golly. It decomposes a pattern into a tree structure with hash consing and reuses the computed results of unchanged regions, which lets it simulate very large patterns efficiently. + +It carries significantly more implementation complexity than the active region approach. It needs its own tree, hash consing, memoization, and a dedicated garbage collector, and it performs much more poorly on high-entropy patterns. Because the active region approach already fits within the budget for this challenge, that extra machinery offers no benefit. + +**_QuickLife_** + +QuickLife is the conventional, non-hashing algorithm used by Golly. It is the fallback Golly's documentation recommends when patterns are too chaotic for HashLife. Its cost tracks the live cell population rather than the bounding box. + +The active region approach scales with the live cell population already, and it avoids reevaluating dead cells on a dense board. A conventional per-cell algorithm such as QuickLife offers no benefit for this challenge on that basis. ### Additional resources - [Conway's Game of Life (Wikipedia)](https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life) - [Life 1.06 format (LifeWiki)](https://conwaylife.com/wiki/Life_1.06) - [.NET HashSet implementation](https://github.com/dotnet/runtime/blob/v10.0.0/src/libraries/System.Private.CoreLib/src/System/Collections/Generic/HashSet.cs) +- [Bit-Packed Conway's Game of Life in GLSL](https://salivity.github.io/glsl/article/bit-packed-conway-s-game-of-life-in-glsl) +- [HashLife (Wikipedia)](https://en.wikipedia.org/wiki/HashLife) +- [Golly, HashLife algorithm](https://golly.sourceforge.io/Help/Algorithms/HashLife.html) +- [Golly, QuickLife algorithm](https://golly.sourceforge.io/Help/Algorithms/QuickLife.html) +- [Golly, bounded grids](https://golly.sourceforge.io/Help/bounded.html) diff --git a/challenges/conway-life/src/csharp/benchmark/EntryPoint.cs b/challenges/conway-life/src/csharp/benchmark/EntryPoint.cs new file mode 100644 index 0000000..e21eef0 --- /dev/null +++ b/challenges/conway-life/src/csharp/benchmark/EntryPoint.cs @@ -0,0 +1,31 @@ +using BenchmarkDotNet.Running; + +namespace Squire.ConwayLifeChallenge.Benchmark; + +/// +/// Hosts the entry point for the benchmark suite. +/// +/// +public static class EntryPoint +{ + /// + /// Runs the benchmark suite. + /// + /// + public static void Main(string[] args) + { + if (args.Length > 0 && args[0] == "--evolution") + { + PopulationEvolution.Run(); + return; + } + + if (args.Length > 0 && args[0] == "--unified-path") + { + _ = BenchmarkRunner.Run(); + return; + } + + _ = BenchmarkRunner.Run(); + } +}