I've refactored your code a bunch. Nothing major, just a lot of little opinionated optimizations.
- Apply @Andrea's change to your offset calculation
- Use iterators instead of a
for
loop calculate the number of neighbors
pub fn next_step(game_array: [[bool; WIDTH]; HEIGHT]) -> [[bool; WIDTH]; HEIGHT] {
let mut next_state = [[false; WIDTH]; HEIGHT];
const NEIGHBOUR_LIST: [[isize; 2]; 8] = [[-1, -1], [-1, 0], [-1, 1],
[ 0, -1], [ 0, 1],
[ 1, -1], [ 1, 0], [ 1, 1]];
for rownum in 0..HEIGHT {
for cellnum in 0..WIDTH {
// Calculate neighbors
let neighbours = NEIGHBOUR_LIST
.iter()
.filter(|[j, k]| {
game_array[(rownum as isize + j).rem_euclid(HEIGHT as isize) as usize]
[(cellnum as isize + k).rem_euclid(WIDTH as isize) as usize]
})
.count();
// Apply game-of-life game rules
if neighbours == 3 || (game_array[rownum][cellnum] && neighbours == 2) {
next_state[rownum][cellnum] = true;
}
}
}
next_state
}
Further, in a separate code snippet, because it's a more major refactoring:
- Use
array.map
to fill next_state
, to avoid filling it with false
values first
pub fn next_step(game_array: [[bool; WIDTH]; HEIGHT]) -> [[bool; WIDTH]; HEIGHT] {
const NEIGHBOUR_LIST: [[isize; 2]; 8] = [[-1, -1], [-1, 0], [-1, 1],
[ 0, -1], [ 0, 1],
[ 1, -1], [ 1, 0], [ 1, 1]];
let mut rownum: usize = 0;
[[(); WIDTH]; HEIGHT].map(|row| {
let mut cellnum: usize = 0;
let row = row.map(|()| {
// Calculate neighbors
let neighbours = NEIGHBOUR_LIST
.iter()
.filter(|[j, k]| {
game_array[(rownum as isize + j).rem_euclid(HEIGHT as isize) as usize]
[(cellnum as isize + k).rem_euclid(WIDTH as isize) as usize]
})
.count();
let cell_active = game_array[rownum][cellnum];
cellnum += 1;
// Apply game-of-life game rules
neighbours == 3 || (cell_active && neighbours == 2)
});
rownum += 1;
row
})
}
Further remark - Owned vs Referenced
Arrays are usually Copy
. Therefore it could be that you loose a lot of performance (unless the compiler optimizes this away) by using owned values as input/output. For performance reasons, I'd allocate two buffers globally and then pass them in via references:
pub fn next_step(current_step: &[[bool; WIDTH]; HEIGHT], next_step: &mut [[bool; WIDTH]; HEIGHT]) {
const NEIGHBOUR_LIST: [[isize; 2]; 8] = [[-1, -1], [-1, 0], [-1, 1],
[ 0, -1], [ 0, 1],
[ 1, -1], [ 1, 0], [ 1, 1]];
for (rownum, row) in next_step.iter_mut().enumerate() {
for (cellnum, cell) in row.iter_mut().enumerate() {
// Calculate neighbors
let neighbours = NEIGHBOUR_LIST
.iter()
.filter(|[j, k]| {
current_step[(rownum as isize + j).rem_euclid(HEIGHT as isize) as usize]
[(cellnum as isize + k).rem_euclid(WIDTH as isize) as usize]
})
.count();
// Apply game-of-life game rules
*cell = neighbours == 3 || (current_step[rownum][cellnum] && neighbours == 2)
}
}
}
Further remark #2 - Extracting the offset into a function
You could wrap the offset calculation in an inline function, for improved readability:
pub fn next_step(current_step: &[[bool; WIDTH]; HEIGHT], next_step: &mut [[bool; WIDTH]; HEIGHT]) {
const NEIGHBOUR_LIST: [[isize; 2]; 8] = [[-1, -1], [-1, 0], [-1, 1],
[ 0, -1], [ 0, 1],
[ 1, -1], [ 1, 0], [ 1, 1]];
#[inline(always)]
fn wrapping_add<const LIMIT: usize>(value: usize, offset: isize) -> usize {
(value as isize + offset).rem_euclid(LIMIT as isize) as usize
}
for (rownum, row) in next_step.iter_mut().enumerate() {
for (cellnum, cell) in row.iter_mut().enumerate() {
// Calculate neighbors
let neighbours = NEIGHBOUR_LIST
.iter()
.filter(|[j, k]| {
current_step[wrapping_add::<HEIGHT>(rownum, *j)]
[wrapping_add::<WIDTH>(cellnum, *k)]
})
.count();
// Apply game-of-life game rules
*cell = neighbours == 3 || (current_step[rownum][cellnum] && neighbours == 2)
}
}
}
Further remark #3 - Unsafe and extending the slice type
As you can prove fairly easily that your access to game_array
will never be out of bounds, you can use unsafe{ get_unchecked() }
for another small performance increase. If you feel ok with unsafe
.
pub fn next_step(current_step: &[[bool; WIDTH]; HEIGHT], next_step: &mut [[bool; WIDTH]; HEIGHT]) {
const NEIGHBOUR_LIST: [[isize; 2]; 8] = [[-1, -1], [-1, 0], [-1, 1],
[ 0, -1], [ 0, 1],
[ 1, -1], [ 1, 0], [ 1, 1]];
#[inline(always)]
fn wrapping_add<const LIMIT: usize>(value: usize, offset: isize) -> usize {
(value as isize + offset).rem_euclid(LIMIT as isize) as usize
}
for (rownum, row) in next_step.iter_mut().enumerate() {
for (cellnum, cell) in row.iter_mut().enumerate() {
// Calculate neighbors
let neighbours = NEIGHBOUR_LIST
.iter()
.filter(|[j, k]| *unsafe {
current_step
.get_unchecked(wrapping_add::<HEIGHT>(rownum, *j))
.get_unchecked(wrapping_add::<WIDTH>(cellnum, *k))
})
.count();
// Apply game-of-life game rules
*cell = neighbours == 3 || (current_step[rownum][cellnum] && neighbours == 2)
}
}
}
You could even go one step further and add this functionality to the slice type itself:
trait ArrayExt<T, const SIZE: usize> {
fn get_wrapped(&self, index: isize) -> &T;
}
impl<T, const SIZE: usize> ArrayExt<T, SIZE> for [T; SIZE] {
#[inline(always)]
fn get_wrapped(&self, index: isize) -> &T {
unsafe { self.get_unchecked(index.rem_euclid(SIZE as isize) as usize) }
}
}
pub fn next_step(current_step: &[[bool; WIDTH]; HEIGHT], next_step: &mut [[bool; WIDTH]; HEIGHT]) {
const NEIGHBOUR_LIST: [[isize; 2]; 8] = [[-1, -1], [-1, 0], [-1, 1],
[ 0, -1], [ 0, 1],
[ 1, -1], [ 1, 0], [ 1, 1]];
for (rownum, row) in next_step.iter_mut().enumerate() {
for (cellnum, cell) in row.iter_mut().enumerate() {
// Calculate neighbors
let neighbours = NEIGHBOUR_LIST
.iter()
.filter(|[j, k]| {
*current_step
.get_wrapped(rownum as isize + *j)
.get_wrapped(cellnum as isize + *k)
})
.count();
// Apply game-of-life game rules
*cell = neighbours == 3 || (current_step[rownum][cellnum] && neighbours == 2)
}
}
}
Further remark #4 - Dynamically sized slices
You most likely don't want to operate exclusively on compile-time known array sizes, so here is a version that can operate on anything that fullfills &[&[bool]]
:
trait SliceExt<T> {
fn get_wrapped(&self, index: isize) -> &T;
}
impl<T, S> SliceExt<T> for S
where
S: AsRef<[T]>,
{
#[inline(always)]
fn get_wrapped(&self, index: isize) -> &T {
let slice = self.as_ref();
unsafe { slice.get_unchecked(index.rem_euclid(slice.len() as isize) as usize) }
}
}
pub fn next_step<In, NestedIn, Out, NestedOut>(current_step: In, mut next_step: Out)
where
In: AsRef<[NestedIn]>,
Out: AsMut<[NestedOut]>,
NestedIn: AsRef<[bool]>,
NestedOut: AsMut<[bool]>,
{
const NEIGHBOUR_LIST: [[isize; 2]; 8] = [[-1, -1], [-1, 0], [-1, 1],
[ 0, -1], [ 0, 1],
[ 1, -1], [ 1, 0], [ 1, 1]];
for (rownum, row) in next_step.as_mut().iter_mut().enumerate() {
for (cellnum, cell) in row.as_mut().iter_mut().enumerate() {
// Calculate neighbors
let neighbours = NEIGHBOUR_LIST
.iter()
.filter(|[j, k]| {
*current_step
.get_wrapped(rownum as isize + *j)
.get_wrapped(cellnum as isize + *k)
})
.count();
// Apply game-of-life game rules
*cell = neighbours == 3
|| (current_step.as_ref()[rownum].as_ref()[cellnum] && neighbours == 2)
}
}
}
This should work for both [[bool; WIDTH]; HEIGHT]
and Vec<Vec<bool>>
and anything in between.
There might be a very slight performance hit due to the dynamic .len()
instead of a compile time constant. It should be very small, though. But that of course would need to be benchmarked, it's hard to say just by looking at it.
Remark #5 - Multithreading
The advantage of using iterators is that it is now trivial to introduce multithreading using the rayon
library:
use rayon::prelude::*;
trait SliceExt<T> {
fn get_wrapped(&self, index: isize) -> &T;
}
impl<T, S> SliceExt<T> for S
where
S: AsRef<[T]>,
{
#[inline(always)]
fn get_wrapped(&self, index: isize) -> &T {
let slice = self.as_ref();
unsafe { slice.get_unchecked(index.rem_euclid(slice.len() as isize) as usize) }
}
}
pub fn next_step<In, NestedIn, Out, NestedOut>(current_step: In, mut next_step: Out)
where
In: AsRef<[NestedIn]> + Sync,
Out: AsMut<[NestedOut]> + Send,
NestedIn: AsRef<[bool]>,
NestedOut: AsMut<[bool]> + Send,
{
const NEIGHBOUR_LIST: [[isize; 2]; 8] = [[-1, -1], [-1, 0], [-1, 1],
[ 0, -1], [ 0, 1],
[ 1, -1], [ 1, 0], [ 1, 1]];
next_step
.as_mut()
.par_iter_mut()
.enumerate()
.for_each(|(rownum, row)| {
for (cellnum, cell) in row.as_mut().iter_mut().enumerate() {
// Calculate neighbors
let neighbours = NEIGHBOUR_LIST
.iter()
.filter(|[j, k]| {
*current_step
.get_wrapped(rownum as isize + *j)
.get_wrapped(cellnum as isize + *k)
})
.count();
// Apply game-of-life game rules
*cell = neighbours == 3
|| (current_step.as_ref()[rownum].as_ref()[cellnum] && neighbours == 2)
}
})
}
Performance
After running my versions in @DobromirM's benchmarking setup, I realized that the biggest problem with your existing code is indeed the ownership transfer in and out of next_step
, which causes two copies of the entire game_array
.
My fastest version is the one with ArrayExt
. This is the time it got:
game-of-life new impl, (10x10), random state, 10000 steps
time: [14.955 ms 15.036 ms 15.144 ms]
Found 9 outliers among 100 measurements (9.00%)
6 (6.00%) high mild
3 (3.00%) high severe
game-of-life original impl, (10x10), random state, 10000 steps
time: [38.076 ms 38.725 ms 39.437 ms]
Found 9 outliers among 100 measurements (9.00%)
6 (6.00%) high mild
3 (3.00%) high severe
game-of-life finomnis impl, (10x10), random state, 10000 steps
time: [1.9600 ms 1.9819 ms 2.0087 ms]
Found 10 outliers among 100 measurements (10.00%)
3 (3.00%) high mild
7 (7.00%) high severe
game-of-life new impl, (100x100), random state, 1000 steps
time: [165.55 ms 168.42 ms 171.62 ms]
Found 12 outliers among 100 measurements (12.00%)
6 (6.00%) high mild
6 (6.00%) high severe
game-of-life original impl, (100x100), random state, 1000 steps
time: [436.55 ms 441.34 ms 446.67 ms]
Found 3 outliers among 100 measurements (3.00%)
2 (2.00%) high mild
1 (1.00%) high severe
game-of-life finomnis impl, (100x100), random state, 1000 steps
time: [43.646 ms 43.870 ms 44.165 ms]
Found 6 outliers among 100 measurements (6.00%)
2 (2.00%) high mild
4 (4.00%) high severe
Experiment 1 average execution time:
Original implementation: 436.55 ms
DobromirM's implementation: 168.42 ms
My 'ArrayExt' implementation: 43.64 ms
Experiment 2 average execution time:
Original implementation: 38.076 ms
DobromirM's implementation: 14.955 ms
My 'ArrayExt' implementation: 1.960 ms
Fastest version and conclusion
I assume that your field will be quite large, probably more than 1000x1000. With that in mind, combining all the things I said, this is the fastest version I could come up with:
use rayon::prelude::*;
trait ArrayExt<T, const SIZE: usize> {
fn get_wrapped(&self, index: isize) -> &T;
}
impl<T, const SIZE: usize> ArrayExt<T, SIZE> for [T; SIZE] {
#[inline(always)]
fn get_wrapped(&self, index: isize) -> &T {
unsafe { self.get_unchecked(index.rem_euclid(SIZE as isize) as usize) }
}
}
pub fn next_step<const WIDTH: usize, const HEIGHT: usize>(
current_step: &[[bool; WIDTH]; HEIGHT],
next_step: &mut [[bool; WIDTH]; HEIGHT],
) {
const NEIGHBOUR_LIST: [[isize; 2]; 8] = [[-1, -1], [-1, 0], [-1, 1],
[ 0, -1], [ 0, 1],
[ 1, -1], [ 1, 0], [ 1, 1]];
next_step
.par_iter_mut()
.enumerate()
.for_each(|(rownum, row)| {
for (cellnum, cell) in row.iter_mut().enumerate() {
// Calculate neighbors
let neighbours: u8 = NEIGHBOUR_LIST
.iter()
.map(|[j, k]| {
*current_step
.get_wrapped(rownum as isize + *j)
.get_wrapped(cellnum as isize + *k) as u8
})
.sum();
// Apply game-of-life game rules
*cell = neighbours == 3 || (neighbours == 2 && current_step[rownum][cellnum])
}
});
}
1000x1000, 10 steps:
- Orginal: 420.63 ms
- DobromirM: 208.59 ms
- Mine (ArrayExt, without rayon): 54.772 ms
- Mine (ArrayExt, with rayon): 22.385 ms
This was when I hit a limit. The stack started to overflow.
Then I used this post to create a Box<[[bool; WIDTH]; HEIGHT]>
on the heap:
10000x10000, 1 step:
- Orginal: 4123.4 ms
- DobromirM: 1811.1 ms
- Mine (ArrayExt, without rayon): 691.3 ms
- Mine (ArrayExt, with rayon): 179.1 ms
Of course, this performance is nothing compared to what GPUs are capable of.
To close this chapter, the main takeaway for me is: Moves are not free.
Was fun, I hope you took something away from this answer.
And thanks to @DobromirM for providing the benchmarking code.