Skip to content
This repository was archived by the owner on Aug 12, 2025. It is now read-only.

Commit 6fe28ec

Browse files
Merge pull request #1 from UniExeterRSE/stephenpcook/material-review
Material review
2 parents 8570e2f + a3a4ced commit 6fe28ec

File tree

9 files changed

+215
-131
lines changed

9 files changed

+215
-131
lines changed

README.md

Lines changed: 86 additions & 67 deletions
Large diffs are not rendered by default.
49.2 KB
Loading

lessons/conways_game_of_life.ipynb

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,19 @@
1616
"In this project, we are going to see implementations of **Conway's Game of Life**, a classic cellular automaton in three ways: a pure python approach (to run on the CPU), a vectorised approach using NumPy (to run on the CPU) and then using CuPy (to run on the GPU). We'll also visualise the evolution of the Game of Life grid to see the computation in action. \n",
1717
"\n",
1818
"## What is Conway's Game of Life?\n",
19+
"\n",
1920
"It's a zero-player game devised by John Conway, where you have a grid of cells that live or die based on a few simple rules:\n",
2021
"- Each cell can be \"alive\" (1) or \"dead\" (0).\n",
2122
"- At each time step (generation), the following rules apply to every cell simultaneously:\n",
22-
"- Any live cell with fewer than 2 live neighbours dies (underpopulation).\n",
23-
"- Any live cell with 2 or 3 live neighbours lives on to the next generation (survival).\n",
24-
"- Any live cell with more than 3 live neighbours dies (overpopulation).\n",
25-
"- Any dead cell with exactly 3 live neighbours becomes a live cell (reproduction).\n",
23+
" - Any live cell with fewer than 2 live neighbours dies (underpopulation).\n",
24+
" - Any live cell with 2 or 3 live neighbours lives on to the next generation (survival).\n",
25+
" - Any live cell with more than 3 live neighbours dies (overpopulation).\n",
26+
" - Any dead cell with exactly 3 live neighbours becomes a live cell (reproduction).\n",
2627
"- Neighbours are the 8 cells touching a given cell horizontally, vertically, or diagonally.\n",
2728
"- From these simple rules emerges a lot of interesting behaviour – stable patterns, oscillators, spaceships (patterns that move), etc. It's a good example of a grid-based simulation that can benefit from parallel computation because the state of each cell for the next generation can be computed independently (based on the current generation).\n",
2829
"\n",
2930
"## Visualisation of Game of Life\n",
31+
"\n",
3032
"To make this project more visually engaging, below is an **animated GIF** showing an example of a Game of Life simulation starting from a random initial configuration. White pixels represent live cells, and black pixels represent dead cells. You can see patterns forming, moving, and changing over time:\n",
3133
"An example evolution of Conway's Game of Life over a few generations (white = alive, black = dead).\n",
3234
"The animation demonstrates how random initial clusters of cells can evolve into interesting patterns. Notice some cells blink on and off or form moving patterns.\n",
@@ -37,25 +39,39 @@
3739
"\n",
3840
"\n",
3941
"## Implementations\n",
42+
"\n",
4043
"All of the implementation for the three different versions (Pure Python, NumPy and CuPy) are contained within the `.py` located at `content/game_of_life.py`. \n",
4144
"\n",
4245
"To run the different versions of the code, you can use:\n",
4346
"\n",
4447
"**Naïve Python Version**\n",
4548
"\n",
46-
"`python game_of_life.py run_life_naive --size 100 --timesteps 50`\n",
49+
"```bash\n",
50+
"python game_of_life.py run_life_naive --size 100 --timesteps 50\n",
51+
"```\n",
52+
"\n",
4753
"which will produce a file called `game_of_life_naive.gif`.\n",
4854
"\n",
4955
"**CPU-Vectorized Version**\n",
50-
"`python game_of_life.py run_life_numpy --size 100 --timesteps 50`\n",
56+
"\n",
57+
"```bash\n",
58+
"python game_of_life.py run_life_numpy --size 100 --timesteps 50\n",
59+
"```\n",
60+
"\n",
5161
"which will produce a file called `game_of_life_cpu.gif`.\n",
5262
"\n",
5363
"**GPU-Accelerated Version**\n",
54-
"`python game_of_life.py run_life_cupy --size 100 --timesteps 50`\n",
64+
"\n",
65+
"```bash\n",
66+
"python game_of_life.py run_life_cupy --size 100 --timesteps 50\n",
67+
"```\n",
68+
"\n",
5569
"which will produce a file called `game_of_life_gpu.gif`.\n",
5670
"\n",
5771
"## Naive Implementation\n",
72+
"\n",
5873
"The core computation that is being performed for the naive implementation is: \n",
74+
"\n",
5975
"```python\n",
6076
"def life_step_naive(grid: np.ndarray) -> np.ndarray:\n",
6177
" N, M = grid.shape\n",
@@ -86,6 +102,7 @@
86102
"### Explanation \n",
87103
"\n",
88104
"There are a number of different reasons that the naive implementation runs slow, including: \n",
105+
"\n",
89106
"- **Nested Python Loops**: Instead of eight `np.roll` calls and one `np.where`, we make two loops over `i, j` (10^4 iterations) and two more loops over `di, dj` (9 checks each), for roughly 9x10^4 Python level operation per step. \n",
90107
"- **Manual edge-wrapping logic**: Branching (`if ni < 0 … elif …`) for each neighbour check, instead of the single fast shift that `np.roll` does in C. \n",
91108
"- **Per-cell rule application** The game of life rule is applied with Python `if/else` instead of the single vectorised Boolean mask. \n",
@@ -122,31 +139,34 @@
122139
"### Explanation\n",
123140
"\n",
124141
"#### From Per-Cell Loops to Whole-Array Operations \n",
142+
"\n",
125143
"In the **naive** version, every one of the NxN cells in Python was traversed within two nested loops; then, for each cell, two more loops over the offsets `di` and `dj` counted its eight neighbours by computing. `(i + di) % N` and `(j + dj) % M` in pure Python. \n",
126144
"**Cost**: ~9·N² Python-level iterations per generation, including branching and modulo arithmetic.\n",
127145
"**Drawback** Thousands of interpreter calls and non-contiguous memory access. \n",
128146
"In the **NumPy** version, no Python loops over individual cells occur. Instead, eight calls to `np.roll` shift the entire grid array (up, down, left, right and on diagonals), automatically handling wrap-around in one C-level operation. Summing those eight arrays gives a full neighbour count in a single, optimised pass. \n",
129147
"\n",
130148
"#### Manual `if/else` vs Vectorised Mask \n",
149+
"\n",
131150
"In the **naive** implementation, after counting neighbours, each cell's fate is determined with a Python `if grid[i,j] == 1: ... else: ...` and assigned via `new[i,j] = ...`. \n",
132151
"In the **NumPy** implementation a single expression of `(neighbours == 3) | ((grid == 1) & (neighbours == 2))` produces an NxN Boolean mask of *cells alive next*. Converting that mask to integers with `np.where(mask, 1, 0)` builds the entire next-generation grid in one C-level operation, resulting in no per-element Python overhead. \n",
133152
"\n",
134-
"\n",
135153
"#### Automatic Wrap-Around vs Manual Modulo Logic\n",
154+
"\n",
136155
"In the **naive** version, every neighbour checks does: \n",
137156
"\n",
138157
"```python \n",
139158
"ni = (i + di) % N\n",
140159
"nj = (j + dj) % M\n",
141160
"```\n",
142161
"\n",
143-
"with Python-level branching and modulo arithmetic on each of the 9 checks per cell. The associated **cost** is thousands of `%` operations and branch instructions per generation. \n",
162+
"with Python-level branching and modulo arithmetic on each of the 9 checks per cell. The associated **cost** is thousands of modulo (`%`) operations and branch instructions per generation. \n",
144163
"\n",
145164
"In the **NumPy** version, a single call to \n",
146165
"\n",
147166
"```python\n",
148167
"np.roll(grid, shift, axis=)\n",
149168
"```\n",
169+
"\n",
150170
"automatically wraps the entire array in one C-level operation. The **benefit** is that all per-cell `%` operations and branching are eliminated, being replaced by a single optimised memory shift over the whole grid. \n",
151171
"\n",
152172
"## GPU-Accelerated Implementation \n",
@@ -194,6 +214,7 @@
194214
"```\n",
195215
"\n",
196216
"#### Random initialisation \n",
217+
"\n",
197218
"**NumPy**: \n",
198219
"```Python \n",
199220
"grid = np.random.choice([0,1], size=(N,N), p=[1-p, p])\n",
@@ -205,18 +226,22 @@
205226
"```\n",
206227
"\n",
207228
"#### Data Transfer\n",
229+
"\n",
208230
"**CuPy**: \n",
231+
"\n",
209232
"```Python \n",
210233
"cp.asnumpy(grid_gpu) # bring a CuPy array back to NumPy\n",
211234
"```\n",
212235
"\n",
213236
"### Which to use?\n",
237+
"\n",
214238
"**Large grids (e.g. N ≥ 500) or many timesteps**: GPU's parallel throughput outweighs kernel-launch and transfer overhead.\n",
215239
"**Small grids (e.g. 10×10)**: GPU overhead may dominate, so you may want to stick with NumPy.\n",
216240
"\n",
217241
"### Why is this quicker?\n",
218242
"\n",
219243
"When a computation can be expressed as the same operation applied independently across many data elements, like counting neighbours on every cell of a large Game of Life grid, GPUs often deliver dramatic speedups compared to CPUs. This advantage stems from several architectural and compiler-related factors that we discussed earlier in the section on theory, including: \n",
244+
"\n",
220245
"- **Massive Data Parallelism**\n",
221246
" - **CPU**: A few (4–16) powerful cores optimised for sequential tasks and complex control flow.\n",
222247
" - **GPU**: Hundreds to thousands of simpler cores running in lock-step.\n",
@@ -239,8 +264,9 @@
239264
"### How much quicker?\n",
240265
"\n",
241266
"Each implementation exhibits a different overall runtime, as you have probably noticed when running them from the command line. We can use the built-in UNIX command line tool `time` to measure the time that is taken to run the code. The `time` command is a simple profiler that measures how long a given program takes to run. It provides three primary metrics, including:\n",
267+
"\n",
242268
"- **real**: The \"wall-clock\" time elapsed from start to finish (i.e. actual elapsed time).\n",
243-
"- **user**: CPU time spent in user-mode *your programs own computations)\n",
269+
"- **user**: CPU time spent in user-mode (your programs own computations)\n",
244270
"- **sys**: CPU time spent in kernel mode (system calls on behalf of your program)."
245271
]
246272
},

lessons/profiling.ipynb

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"Python has a built-in profiler called **cPython**. It can help you find which functions are taking up the most time in your program. This is key before you go into GPU acceleration; sometimes, you might find bottlenecks in places you didn't expect or identify parts of the code that would benefit the most from being moved to the GPU.\n",
1919
"\n",
2020
"### How to use cProfile \n",
21-
"You can make use of cProfile via the command line: `python -m cProfile -o profile_results.pstats myscript.py`, which will run `myscript.py` under the profiler and output stats to a file.\n",
21+
"You can make use of cProfile via the command line: `python -m cProfile -o profile_results.pstats myscript.py`, which will run `myscript.py` under the profiler and output stats to a file. In the following examples we will instead call cProfile directly within our scripts, and use the `pstat` library to create immediate summaries.\n",
2222
"\n",
2323
"```Python \n",
2424
"import cProfile\n",
@@ -70,17 +70,18 @@
7070
"simulate_life_naive(N=N, timesteps=STEPS, p_alive=P_ALIVE)\n",
7171
"\n",
7272
"profiler.disable() # ── stop profiling ─────────────────\n",
73+
"profiler.dump_file(\"naive.pstat\") # ── save output ────────────────────\n",
7374
"\n",
7475
"stats = pstats.Stats(profiler).sort_stats('cumtime')\n",
7576
"stats.print_stats(10) # print top 10 functions by cumulative time\n",
7677
"\n",
7778
"\n",
7879
"```\n",
7980
"\n",
80-
"- **Interpreting cProfile output**: When you print stats, you'll see a table with columns including: \n",
81-
"- **ncalls**: number of calls to the function. \n",
82-
"- **tottime**: total time spent in the function (excluding sub-function calls). \n",
83-
"- **cumtime**: cumulative time spent in the function includes sub-functions.\n",
81+
"**Interpreting cProfile output**: When you print stats, you'll see a table with columns including: \n",
82+
"- **ncalls**: number of calls to the function \n",
83+
"- **tottime**: total time spent in the function (excluding sub-function calls) \n",
84+
"- **cumtime**: cumulative time spent in the function includes sub-functions\n",
8485
"- The function name\n",
8586
"\n",
8687
"```bash \n",
@@ -89,7 +90,25 @@
8990
" 100 4.147 0.041 4.150 0.041 4263274180.py:9(life_step_naive)\n",
9091
"... (other functions)\n",
9192
"```\n",
92-
"Therefore in the above table `ncalls` (100) tells you `life_step_naive` was invoked 100 times. `tottime` (4.147 s) is the time spent inside `life_step_naive` itself, excluding any functions it calls. `cumtime` (4.150 s) is the total time in `life_step_naive` plus any sub-calls it makes. So in this example, `life_step_naive` spent about 4.147 s in its own Python loops, and an extra ~0.003 s in whatever minor sub-calls it did (array indexing, % operations, etc.), for a total of 4.150 s. The per-call columns are simply` tottime/ncalls` and `cumtime/ncalls`, and the single call to `simulate_life_naive` shows its cumulative 4.312 s includes all the 100 naive steps plus the list-append overhead.\n",
93+
"Therefore in the above table `ncalls` (100) tells you `life_step_naive` was invoked 100 times; `tottime` (4.147 s) is the time spent inside `life_step_naive` itself, excluding any functions it calls; `cumtime` (4.150 s) is the total cumulative time in `life_step_naive` plus any sub-calls it makes. In this example, `life_step_naive` spent about 4.147 s in its own Python loops, and an extra ~0.003 s in whatever minor sub-calls it did (array indexing, % operations, etc.), for a total of 4.150 s. The per-call columns are simply `tottime/ncalls` and `cumtime/ncalls`, and the single call to `simulate_life_naive` shows its cumulative 4.312 s includes all the 100 naive steps plus the list-append overhead.\n",
94+
"\n",
95+
"### Visualising the Output with Snakeviz \n",
96+
"\n",
97+
"Snakeviz is a separate tool that we can use to analyse the output of cProfile. Snakeviz is a stand-alone tool available through PyPI. We can install it with\n",
98+
"\n",
99+
"``` bash\n",
100+
"poetry add snakeviz\n",
101+
"```\n",
102+
"\n",
103+
"We can use it to visualise a cProfile output such as the one generated from the above snippet\n",
104+
"\n",
105+
"``` bash\n",
106+
"poetry run snakeviz naive.pstat\n",
107+
"```\n",
108+
"\n",
109+
"which launches an interactive webapp which we can use to explore the profiling timings.\n",
110+
"\n",
111+
"![Screenshot of SnakeViz](../_static/profiling/snakeviz_output.png)\n",
93112
"\n",
94113
"### Finding Bottlenecks \n",
95114
"\n",
@@ -102,7 +121,7 @@
102121
"- `simulate_life_naive` appears once with `cumtime ≈ 4.312 s`, which covers the single Python loop plus all 100 calls to `life_step_naive`.\n",
103122
"\n",
104123
"Once you’ve identified the culprit:\n",
105-
"- If you have high `tottime` in a Python function, you may want to consider consider vectorising inner loops (e.g. switch to NumPy’s np.roll + np.where) or using a compiled extension.\n",
124+
"- If you have high `tottime` in a Python function, you may want to consider consider vectorising inner loops (e.g. switch to NumPy’s `np.roll` + `np.where`) or using a compiled extension.\n",
106125
"- If you have heavy external calls under your `cumtime`, then you may want to explore hardware acceleration (e.g. GPU via `CuPy`) or more efficient algorithms.\n",
107126
"\n",
108127
"## Profiling the CPU-Vectorised Implementation using NumPy. \n",
@@ -152,6 +171,7 @@
152171
"simulate_life_numpy(N=N, timesteps=STEPS, p_alive=P_ALIVE)\n",
153172
"\n",
154173
"profiler.disable() # ── stop profiling ─────────────────────────\n",
174+
"profiler.dump_file('numpy.pstat') # ── save output ─────────────────────────\n",
155175
"\n",
156176
"stats = (\n",
157177
" pstats.Stats(profiler)\n",
@@ -206,7 +226,7 @@
206226
"\n",
207227
"**NVIDIA Nsight Systems** is a profiler for GPU applications that provides a timeline of CPU and GPU activity. It can show: \n",
208228
"- When your code launched GPU kernels and how long they ran \n",
209-
"- GPU memory transfers between host and device. \n",
229+
"- GPU memory transfers between host and device \n",
210230
"- CPU-side functions as well (to correlate CPU and GPU)\n",
211231
"\n",
212232
"### Using Nsight Systems \n",
@@ -224,10 +244,10 @@
224244
"nsys stats profile_report.nsys-rep\n",
225245
"```\n",
226246
"\n",
227-
"An example `.nsys-rep` file has been included within the GitHub Repo for you to try the command with, at the filepath `_static/profiling/example_data_file.nsys-rep`. We will discuss the contents of the file in the section \"Example Output\" after discussing the needed code changes to generate the file. \n",
247+
"An example `.nsys-rep` file has been included within the GitHub Repo for you to try the command with, at the filepath `_static/profiling/example_data_file.nsys-rep`. We will discuss the contents of the file in the section \"Example Output\" after discussing the necessary code changes to generate the file. \n",
228248
"\n",
229249
"### Code Changes \n",
230-
"To get the fine-tuned profiling, we also need to make some changes to the code. A new version of Conways Game of Life has been created and is located in `game_of_life_profiled.py`, where additional imports are needed: \n",
250+
"To get the fine-tuned profiling, we also need to make some changes to the code. A new version of Conway's Game of Life has been created and is located in `game_of_life_profiled.py`, where additional imports are needed: \n",
231251
"\n",
232252
"```python \n",
233253
"from cupyx.profiler import time_range \n",
@@ -272,7 +292,7 @@
272292
"\n",
273293
"Unfortunately, you can't call the Python script itself as we did before as the Python interpreter obfuscates the profiler, and so there is a need to instead define a new entry point and call that to run the complete experiment run. \n",
274294
"\n",
275-
"Together these are all the changes that are needed to create the data file and be able to understand better how the code is performing and where there are potential for further improvements to optimisation. "
295+
"Together these are all the changes that are needed to create the data file and be able to understand better how the code is performing and where there is potential for further improvements through optimisation. "
276296
]
277297
},
278298
{
@@ -371,7 +391,7 @@
371391
"\n",
372392
"The takeaways that we could take from this include the following:\n",
373393
"- **Python loops severely degrade performance**: Over 72% of run time is in the naive implementations, so vectorisation (NumPy/CuPy) is critical. \n",
374-
"- **Implicit syncs dominate**: `cudaFree` stalls the pipe, and so avoiding per-iteration free(0 calls by reusing buffers is key. \n",
394+
"- **Implicit syncs dominate**: `cudaFree` stalls the pipe, and so avoiding per-iteration free calls by reusing buffers is key. \n",
375395
"- **Kernel work is tiny**: Each kernel takes ~1-2µs; orchestration (kernel launches + memops) is the real bottleneck.\n",
376396
"- **Memcopy patterns matter**: 7200 small transfers add up, so we need to use larger batches of copies to reduce the overhead.\n",
377397
"\n",
@@ -528,7 +548,7 @@
528548
"Bringing everything together, some strategies include:\n",
529549
"\n",
530550
"**On the CPU side (Python)**: \n",
531-
"- **Vectorise Operations**: We saw this with NumPy; doing things in batch is faster than Python loops. \n",
551+
"- **Vectorise Operations**: We saw this with NumPy; doing things in batches is faster than Python loops. \n",
532552
"- **Use efficient libraries**: If a certain computation is slow in Python, see if there is a library (NumPy, SciPy, etc) that does it in C or another language. \n",
533553
"- **Optimise algorithms**: Sometimes, a better algorithm can speed things up more than any level of optimisation. For example, if you find a certain computation is N^2 in complexity and it's slow, see if you can make it N log N or similar.\n",
534554
"- **Consider multiprocessing or parallelisation**: Use multiple CPU cores (with `multiprocessing` or `joblib` or others) if appropriate.\n",

0 commit comments

Comments
 (0)