Skip to content
Merged
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
24 changes: 24 additions & 0 deletions challenges/conway-life/Solution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
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
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
25 changes: 0 additions & 25 deletions challenges/conway-life/src/csharp/benchmark/Program.cs

This file was deleted.

39 changes: 38 additions & 1 deletion challenges/conway-life/src/csharp/benchmark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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`.
Expand Down
Loading
Loading