Skip to content
Merged

Life #14

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion challenges/conway-life/Solution.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,79 @@ 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.
**_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)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsBenchmarkProject>true</IsBenchmarkProject>
<StartupObject>Squire.ConwayLifeChallenge.Benchmark.EntryPoint</StartupObject>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ private static HashSet<Coordinate> CreateCells(int count, int boxSize)
{
var x = random.Next(-half, half);
var y = random.Next(-half, half);

_ = cells.Add(new Coordinate(x, y));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,6 @@ private static void ProcessCellWithVisitedSet(Coordinate cell,
}

var liveNeighbors = 0;

++depth;

foreach (var neighborCell in cell.GetNeighbors())
Expand Down Expand Up @@ -185,7 +184,6 @@ private static void ProcessCellWithoutVisitedSet(Coordinate cell,
}

var liveNeighbors = 0;

++depth;

foreach (var neighborCell in cell.GetNeighbors())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace Squire.ConwayLifeChallenge.Benchmark;
/// Hosts the entry point for the benchmark suite.
/// </summary>
///
public static class Program
public static class EntryPoint
{
/// <summary>
/// Runs the benchmark suite.
Expand All @@ -20,6 +20,12 @@ public static void Main(string[] args)
return;
}

if (args.Length > 0 && args[0] == "--unified-path")
{
_ = BenchmarkRunner.Run<UnifiedPathBenchmarks>();
return;
}

_ = BenchmarkRunner.Run<DensityAnalysisBenchmarks>();
}
}
189 changes: 189 additions & 0 deletions challenges/conway-life/src/csharp/benchmark/InlinedUnifiedStrategy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
using System.Runtime.CompilerServices;
using Squire.ConwayLifeChallenge.Infrastructure;

namespace Squire.ConwayLifeChallenge.Benchmark;

/// <summary>
/// A benchmark-only variant of <see cref="Squire.ConwayLifeChallenge.Strategies.ActiveRegionOptimizedStrategy" />
/// that runs the single-pass form of the generation methods with aggressive inlining.
/// </summary>
///
/// <remarks>
/// 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 <see cref="Squire.ConwayLifeChallenge.StrategyBase.CreateStrategy" />.
///
/// The algorithm is identical to the optimized strategy, and identical to <see cref="UnifiedPathStrategy" />,
/// which is the same single-pass form without inlining. The generation methods here are marked
/// <see cref="MethodImplAttribute" /> with <c>AggressiveInlining</c>, 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 <see cref="UnifiedPathStrategy" />,
/// because the null check can't be inlined away.
/// </remarks>
///
internal sealed class InlinedUnifiedStrategy : StrategyBase
{
private const double DensityThreshold = 0.25;

/// <inheritdoc />
public override Strategy Strategy => Strategy.ActiveRegionOptimized;

/// <inheritdoc />
public override HashSet<Coordinate> Simulate(HashSet<Coordinate> cells,
int generations)
{
ArgumentNullException.ThrowIfNull(cells, nameof(cells));

if (generations <= 0)
{
return [ ..cells ];
}

var startingAliveCells = generations switch
{
> 1 => new HashSet<Coordinate>(cells, cells.Comparer),
_ => cells
};

var endingAliveCells = new HashSet<Coordinate>(cells.Count);
var visitedCells = default(HashSet<Coordinate>?);

while (startingAliveCells.Count > 0)
{
var generationVisitedCells = ComputeDensity(startingAliveCells) >= DensityThreshold
? (visitedCells ??= new HashSet<Coordinate>(startingAliveCells.Count))
: default(HashSet<Coordinate>?);

SimulateGeneration(startingAliveCells, endingAliveCells, generationVisitedCells);

if (--generations <= 0)
{
return endingAliveCells;
}

(startingAliveCells, endingAliveCells) = (endingAliveCells, startingAliveCells);
endingAliveCells.Clear();
visitedCells?.Clear();
}

return endingAliveCells;
}

/// <summary>
/// Executes a run for a single generation of the simulation.
/// </summary>
///
/// <param name="startingAliveCells">The initial set of live cells for the generation.</param>
/// <param name="endingAliveCells">The set of live cells after the generation has been simulated.</param>
/// <param name="generationVisitedCells">Optional. The set of cells that have been visited during the generation.</param>
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void SimulateGeneration(HashSet<Coordinate> startingAliveCells,
HashSet<Coordinate> endingAliveCells,
HashSet<Coordinate>? generationVisitedCells)
{
foreach (var cell in startingAliveCells)
{
ProcessCell(cell, startingAliveCells, endingAliveCells, generationVisitedCells);
}
}

/// <summary>
/// Processes a cell and, if needed, it's direct neighbors to determine if the cell(s) will be alive
/// in the next generation.
/// </summary>
///
/// <param name="cell">The cell to process.</param>
/// <param name="startingAliveCells">The initial set of live cells for the generation.</param>
/// <param name="endingAliveCells">The set of live cells after the generation has been simulated.</param>
/// <param name="generationVisitedCells">Optional. The set of cells that have been visited during the generation.</param>
/// <param name="depth">Optional. The current recursive depth of processing.</param>
///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void ProcessCell(Coordinate cell,
HashSet<Coordinate> startingAliveCells,
HashSet<Coordinate> endingAliveCells,
HashSet<Coordinate>? 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
};
}

/// <summary>
/// Computes the density of a population as the ratio of live cells to bounding box area.
/// </summary>
///
/// <param name="cells">The population to measure.</param>
///
private static double ComputeDensity(HashSet<Coordinate> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ private static void ProcessCell(Coordinate cell,
}

var liveNeighbors = 0;

++depth;

foreach (var neighborCell in cell.GetNeighbors())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ private static HashSet<Coordinate> CreateCells(int count, int boxSize)
{
var x = random.Next(-half, half);
var y = random.Next(-half, half);

_ = cells.Add(new Coordinate(x, y));
}

Expand Down
Loading
Loading