Skip to content

examples: Conway's Game of Life with one on-engine pass per generation - #272

Merged
sbryngelson merged 2 commits into
sbryngelson:mainfrom
axiom-of-choice:feat/game-of-life
Sep 18, 2026
Merged

sbryngelson merged 2 commits into
sbryngelson:mainfrom
axiom-of-choice:feat/game-of-life

Conversation

@axiom-of-choice

@axiom-of-choice axiom-of-choice commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Closes #173.

Conway's Game of Life with every generation as one on-engine forward pass, in the family of reaction_diffusion.py.

  • The 8-neighbour count is a fixed 3x3 conv (all ones except the centre) with pad=1, so the border is zero-padded.
  • The birth/survival rule is elementwise. |nb - k| clipped to [0, 1] is the not-equal indicator, so 1 - that is the exact-match indicator and the rule needs no equality op: max(alive * is2, is3).
  • One program compiled and re-dispatched every step, seeded with a random field.

Writes docs/assets/game_of_life.webp (51 frames, ~620 KB, in the range of the sibling assets).

Verification

  • tests/test_game_of_life_example.py (requires_ane): the on-engine rule matches a numpy zero-padded reference exactly for 5 generations on a random 16x16 field (maxdiff 0.0), and a blinker oscillates with period 2.
  • Full run on M2 Pro: 100 generations at 0.32 ms/step, the field stayed boolean (population 3228 of 36864), PASS.
  • ruff check and pylint (repo flags) on the new test: clean. pyright aneforge is unaffected.

Local run

$ python3 examples/game_of_life.py

  ANEForge   - Conway's Game of Life on the Apple Neural Engine
  a 3x3 neighbour conv + an elementwise birth/survival rule, one fused program per step

  compile OK one generation program (3x3 conv + rule, compiled once, re-dispatched every step)
  evolve  OK 100 generations on the ANE (0.0s wall, 0.16 ms/step)
  field   population 3228 of 36864 cells, boolean
  energy  ~0.0s of ANE step time at the measured ~1.48 W rail ~ 0.0 J for the whole run

  OK wrote docs/assets/game_of_life.webp (animated WebP, 51 frames)
  PASS: the ANE ran 100 Life generations on the engine, the field stayed boolean and alive

@sbryngelson sbryngelson left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rule is right and the example runs clean. I checked the Life logic rather than trusting the
spot test, since the exact-match trick is unusual:

max(alive * is2, is3)  =  (alive & nb==2) | (nb==3)

which is Conway's B3/S23 in its reduced form -- a live cell with 3 neighbours is covered by the
nb==3 term, so the two branches really are all you need. 1 - clip(|nb-k|, 0, 1) is an exact
equality test because the conv output is an integer count and fp16 represents 0..8 exactly. The
numpy reference encodes the same rule independently, so the spot check isn't circular.

End-to-end on device: 100 generations, field stayed boolean, population 3228/36864, writes the
51-frame WebP, exits 0. Tests pass, ruff clean.

One thing to fix before this goes in, and it's presentation rather than correctness. The run is so
fast that three separate numbers all round to zero:

evolve  OK 100 generations on the ANE (0.0s wall, 0.12 ms/step)
energy  ~0.0s of ANE step time at the measured ~1.48 W rail ~ 0.0 J for the whole run

Actual values are 0.0109s and 0.0162 J. For a showpiece, a headline reading ~0.0 J for the whole run looks like broken instrumentation rather than the point you're making, which is that the whole
simulation costs almost nothing. Switching to ms/mJ below 1 says it properly:

evolve  OK 100 generations on the ANE (12 ms wall, 0.11 ms/step)
energy  ~11 ms of ANE step time at the measured ~1.48 W rail ~ 17 mJ for the whole run

Patch below, verified: ruff clean, 2 passed, example exits 0.

diff --git a/examples/game_of_life.py b/examples/game_of_life.py
index cef8f86..44357fc 100644
--- a/examples/game_of_life.py
+++ b/examples/game_of_life.py
@@ -28,6 +28,15 @@ def out(s=""):
     sys.stdout.write(s + "\n"); sys.stdout.flush()
 
 
+def secs(s):
+    """One generation is ~0.1 ms, so a whole 100-step run rounds to '0.0s' in seconds."""
+    return f"{s * 1e3:.0f} ms" if s < 1.0 else f"{s:.1f}s"
+
+
+def joules(j):
+    return f"{j * 1e3:.0f} mJ" if j < 1.0 else f"{j:.1f} J"
+
+
 def neighbor_kernel():
     """The 8-neighbour count as a fixed 3x3 conv: all ones except the centre."""
     k = np.ones((1, 1, 3, 3), np.float32)
@@ -99,11 +108,11 @@ def main():
     energy = ANE_RAIL_W * ane_t
 
     out(f"  {GREY}evolve{R}  {CHECK} {STEPS} generations on the ANE "
-        f"{DIM}({wall:.1f}s wall, {ane_t * 1e3 / STEPS:.2f} ms/step){R}")
+        f"{DIM}({secs(wall)} wall, {ane_t * 1e3 / STEPS:.2f} ms/step){R}")
     out(f"  {GREY}field{R}   {DIM}population {pop:.0f} of {N * N} cells, "
         f"{'boolean' if binary else 'NON-BOOLEAN'}{R}")
-    out(f"  {GREY}energy{R}  {DIM}~{ane_t:.1f}s of ANE step time at the measured "
-        f"~{ANE_RAIL_W} W rail ~ {BOLD}{energy:.1f} J{R}{DIM} for the whole run{R}")
+    out(f"  {GREY}energy{R}  {DIM}~{secs(ane_t)} of ANE step time at the measured "
+        f"~{ANE_RAIL_W} W rail ~ {BOLD}{joules(energy)}{R}{DIM} for the whole run{R}")
     out()
 
     wrote = render(frames)

closes sbryngelson#173)

The 8-neighbour count is a fixed 3x3 conv (all ones except the centre) with
zero-padding at the border, and the birth/survival rule is elementwise:
`|nb - k|` clipped to [0, 1] is the not-equal indicator, so the rule needs no
equality op. The generation compiles once and is re-dispatched every step,
like reaction_diffusion.

Writes docs/assets/game_of_life.webp. The on-engine rule matches a numpy
zero-padded reference exactly (maxdiff 0.0) and a blinker oscillates with
period 2.
@axiom-of-choice

Copy link
Copy Markdown
Contributor Author

Applied and pushed. Output now reads 12 ms wall / 17 mJ instead of 0.0s / 0.0 J. Also rebased onto current main (the CONFLICTING from merged #273/#274/#268-#270 is resolved).

@sbryngelson sbryngelson left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied as suggested -- thanks, and thanks for the rebase. Verified on device against current main: ruff clean, 2 passed, and the run reads 9 ms wall / 13 mJ instead of three zeros. Merging.

@sbryngelson
sbryngelson merged commit eb17660 into sbryngelson:main Sep 18, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

examples: Conway's Game of Life on the ANE

2 participants