Time Profiling

    Familiarize yourself with the available tools for time profiling Rust andWebAssembly code before continuing.

    This FPS timer will be useful as we investigate speeding up our Game of Life'srendering.

    We start by adding an object to wasm-game-of-life/www/index.js:

    Next we call the fps render function on each iteration of renderLoop:

    1. const renderLoop = () => {
    2. fps.render(); //new
    3. universe.tick();
    4. drawGrid();
    5. drawCells();
    6. animationId = requestAnimationFrame(renderLoop);
    7. };

    Finally, don't forget to add the fps element towasm-game-of-life/www/index.html, just above the <canvas>:

    1. <div id="fps"></div>

    And add CSS to make its formatting nice:

    1. #fps {
    2. white-space: pre;
    3. font-family: monospace;
    4. }

    And voila! Refresh and now wehave an FPS counter!

    To measure how long each invocation of Universe::tick takes, we can useconsole.time and console.timeEnd via the web-sys crate.

    First, add web-sys as a dependency to wasm-game-of-life/Cargo.toml:

    1. [dependencies.web-sys]
    2. version = "0.3"
    3. features = [
    4. "console",
    5. ]

    Because there should be a corresponding console.timeEnd invocation for everyconsole.time call, it is convenient to wrap them both up in an RAII type:

    1. # #![allow(unused_variables)]
    2. #fn main() {
    3. extern crate web_sys;
    4. use web_sys::console;
    5. pub struct Timer<'a> {
    6. name: &'a str,
    7. }
    8. impl<'a> Timer<'a> {
    9. pub fn new(name: &'a str) -> Timer<'a> {
    10. console::time_with_label(name);
    11. Timer { name }
    12. }
    13. }
    14. impl<'a> Drop for Timer<'a> {
    15. fn drop(&mut self) {
    16. console::time_end_with_label(self.name);
    17. }
    18. }
    19. #}

    Then, we can time how long each Universe::tick takes by adding this snippet tothe top of the method:

    The time of how long each call to Universe::tick took are now logged in theconsole:

    Additionally, console.time and console.timeEnd pairs will show up in yourbrowser's profiler's "timeline" or "waterfall" view:ppScreenshot of console.time logs

    What happens if we make our Game of Life universe larger? Replacing the 64 by 64universe with a 128 by 128 universe (by modifying Universe::new inwasm-game-of-life/src/lib.rs) results in FPS dropping from a smooth 60 to achoppy 40-ish on my machine.

    If we record a profile and look at the waterfall view, we see that eachanimation frame is taking over 20 milliseconds. Recall that 60 frames per secondleaves sixteen milliseconds for the whole process of rendering a frame. That'snot just our JavaScript and WebAssembly, but also everything else the browser isdoing, such as painting.

    If we look at what happens within a single animation frame, we see that theCanvasRenderingContext2D.fillStyle setter is very expensive!

    Screenshot of a flamegraph view of rendering a frame

    And we can confirm that this isn't an abnormality by looking at the call tree'saggregation of many frames:

    Nearly 40% of our time is spent in this setter!

    In the drawCells function in wasm-game-of-life/www/index.js, the fillStyleproperty is set once for every cell in the universe, on every animation frame:

    1. for (let row = 0; row < height; row++) {
    2. for (let col = 0; col < width; col++) {
    3. const idx = getIndex(row, col);
    4. ctx.fillStyle = cells[idx] === DEAD
    5. ? DEAD_COLOR
    6. : ALIVE_COLOR;
    7. ctx.fillRect(
    8. col * (CELL_SIZE + 1) + 1,
    9. row * (CELL_SIZE + 1) + 1,
    10. CELL_SIZE,
    11. CELL_SIZE
    12. );
    13. }
    14. }

    Now that we have discovered that setting fillStyle is so expensive, what canwe do to avoid setting it so often? We need to change fillStyle depending onwhether a cell is alive or dead. If we set fillStyle = ALIVE_COLOR and thendraw every alive cell in one pass, and then set fillStyle = DEAD_COLOR anddraw every dead cell in another pass, then we only end setting fillStyletwice, rather than once for every cell.

    1. // Alive cells.
    2. ctx.fillStyle = ALIVE_COLOR;
    3. for (let row = 0; row < height; row++) {
    4. for (let col = 0; col < width; col++) {
    5. const idx = getIndex(row, col);
    6. if (cells[idx] !== Cell.Alive) {
    7. continue;
    8. }
    9. ctx.fillRect(
    10. col * (CELL_SIZE + 1) + 1,
    11. row * (CELL_SIZE + 1) + 1,
    12. CELL_SIZE,
    13. CELL_SIZE
    14. );
    15. }
    16. }
    17. // Dead cells.
    18. ctx.fillStyle = DEAD_COLOR;
    19. for (let row = 0; row < height; row++) {
    20. const idx = getIndex(row, col);
    21. if (cells[idx] !== Cell.Dead) {
    22. continue;
    23. }
    24. ctx.fillRect(
    25. col * (CELL_SIZE + 1) + 1,
    26. row * (CELL_SIZE + 1) + 1,
    27. CELL_SIZE,
    28. CELL_SIZE
    29. );
    30. }
    31. }

    After saving these changes and refreshinghttp://localhost:8080/, rendering is back to a smooth60 frames per second.

    If we take another profile, we can see that only about ten milliseconds arespent in each animation frame now.

    Breaking down a single frame, we see that the fillStyle cost is gone, and mostof our frame's time is spent within fillRect, drawing each cell's rectangle.

    Some folks don't like waiting around, and would prefer if instead of one tick ofthe universe occurred per animation frame, nine ticks did. We can modify therenderLoop function in wasm-game-of-life/www/index.js to do this quiteeasily:

    1. for (let i = 0; i < 9; i++) {
    2. universe.tick();
    3. }

    On my machine, this brings us back down to only 35 frames per second. Nogood. We want that buttery 60!

    Now we know that time is being spent in Universe::tick, so let's add someTimers to wrap various bits of it in console.time and console.timeEndcalls, and see where that leads us. My hypothesis is that allocating a newvector of cells and freeing the old vector on every tick is costly, and takingup a significant portion of our time budget.

    1. # #![allow(unused_variables)]
    2. #fn main() {
    3. pub fn tick(&mut self) {
    4. let _timer = Timer::new("Universe::tick");
    5. let mut next = {
    6. let _timer = Timer::new("allocate next cells");
    7. self.cells.clone()
    8. };
    9. {
    10. let _timer = Timer::new("new generation");
    11. for row in 0..self.height {
    12. for col in 0..self.width {
    13. let idx = self.get_index(row, col);
    14. let cell = self.cells[idx];
    15. let live_neighbors = self.live_neighbor_count(row, col);
    16. let next_cell = match (cell, live_neighbors) {
    17. // Rule 1: Any live cell with fewer than two live neighbours
    18. // dies, as if caused by underpopulation.
    19. (Cell::Alive, x) if x < 2 => Cell::Dead,
    20. // Rule 2: Any live cell with two or three live neighbours
    21. // lives on to the next generation.
    22. (Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,
    23. // Rule 3: Any live cell with more than three live
    24. // neighbours dies, as if by overpopulation.
    25. (Cell::Alive, x) if x > 3 => Cell::Dead,
    26. // Rule 4: Any dead cell with exactly three live neighbours
    27. // becomes a live cell, as if by reproduction.
    28. (Cell::Dead, 3) => Cell::Alive,
    29. // All other cells remain in the same state.
    30. (otherwise, _) => otherwise,
    31. };
    32. next[idx] = next_cell;
    33. }
    34. }
    35. }
    36. let _timer = Timer::new("free old cells");
    37. self.cells = next;
    38. }
    39. #}

    Looking at the timings, it is clear that my hypothesis is incorrect: the vastmajority of time is spent actually calculating the next generation ofcells. Allocating and freeing a vector on every tick appears to have negligiblecost, surprisingly. Another reminder to always guide our efforts with profiling!

    The next section requires the nightly compiler. It's required because ofthe test feature gatewe're going to use for the benchmarks. Another tool we will install is .It's a small utility for comparing micro-benchmarks produced by cargo bench.

    1. # #![allow(unused_variables)]
    2. #![feature(test)]
    3. #fn main() {
    4. extern crate test;
    5. extern crate wasm_game_of_life;
    6. #[bench]
    7. fn universe_ticks(b: &mut test::Bencher) {
    8. let mut universe = wasm_game_of_life::Universe::new();
    9. b.iter(|| {
    10. universe.tick();
    11. });
    12. }
    13. #}

    We also have to comment out all the #[wasm_bindgen] annotations, and the"cdylib" bits from Cargo.toml or else building native code will fail andhave link errors.

    With all that in place, we can run cargo bench | tee before.txt to compile and run ourbenchmark! The | tee before.txt part will take the output from cargo bench and put in a filecalled before.txt.

    This also tells us where the binary lives, and we can run the benchmarks again,but this time under our operating system's profiler. In my case, I'm runningLinux, so perf is the profiler I'll use:

    1. $ perf record -g target/release/deps/bench-8474091a05cfa2d9 --bench
    2. running 1 test
    3. test universe_ticks ... bench: 635,061 ns/iter (+/- 38,764)
    4. test result: ok. 0 passed; 0 failed; 0 ignored; 1 measured; 0 filtered out
    5. [ perf record: Woken up 1 times to write data ]
    6. [ perf record: Captured and wrote 0.178 MB perf.data (2349 samples) ]

    Loading up the profile with perf report shows that all of our time is spent inUniverse::tick, as expected:

    perf will annotate which instructions in a function time is being spent at ifyou press a:

    Screenshot of perf's instruction annotation

    This tells us that 26.67% of time is being spent summing neighboring cells'values, 23.41% of time is spent getting the neighbor's column index, and another15.42% of time is spent getting the neighbor's row index. Of these top threemost expensive instructions, the second and third are both costly divinstructions. These s implement the modulo indexing logic inUniverse::live_neighbor_count.

    Recall the live_neighbor_count definition insidewasm-game-of-life/src/lib.rs:

    1. # #![allow(unused_variables)]
    2. #fn main() {
    3. fn live_neighbor_count(&self, row: u32, column: u32) -> u8 {
    4. let mut count = 0;
    5. for delta_row in [self.height - 1, 0, 1].iter().cloned() {
    6. for delta_col in [self.width - 1, 0, 1].iter().cloned() {
    7. if delta_row == 0 && delta_col == 0 {
    8. }
    9. let neighbor_row = (row + delta_row) % self.height;
    10. let neighbor_col = (column + delta_col) % self.width;
    11. let idx = self.get_index(neighbor_row, neighbor_col);
    12. count += self.cells[idx] as u8;
    13. }
    14. }
    15. count
    16. }
    17. #}

    The reason we used modulo was to avoid cluttering up the code with if branchesfor the first or last row or column edge cases. But we are paying the cost of adiv instruction even for the most common case, when neither row nor columnis on the edge of the universe and they don't need the modulo wrappingtreatment. Instead, if we use ifs for the edge cases and unroll this loop, thebranches should be very well-predicted by the CPU's branch predictor.

    Let's rewrite live_neighbor_count like this:

    1. # #![allow(unused_variables)]
    2. #fn main() {
    3. fn live_neighbor_count(&self, row: u32, column: u32) -> u8 {
    4. let mut count = 0;
    5. let north = if row == 0 {
    6. self.height - 1
    7. } else {
    8. row - 1
    9. };
    10. let south = if row == self.height - 1 {
    11. 0
    12. } else {
    13. row + 1
    14. };
    15. let west = if column == 0 {
    16. self.width - 1
    17. } else {
    18. column - 1
    19. };
    20. let east = if column == self.width - 1 {
    21. 0
    22. } else {
    23. column + 1
    24. };
    25. let nw = self.get_index(north, west);
    26. count += self.cells[nw] as u8;
    27. let n = self.get_index(north, column);
    28. count += self.cells[n] as u8;
    29. let ne = self.get_index(north, east);
    30. count += self.cells[ne] as u8;
    31. let w = self.get_index(row, west);
    32. count += self.cells[w] as u8;
    33. let e = self.get_index(row, east);
    34. count += self.cells[e] as u8;
    35. let sw = self.get_index(south, west);
    36. count += self.cells[sw] as u8;
    37. let s = self.get_index(south, column);
    38. count += self.cells[s] as u8;
    39. let se = self.get_index(south, east);
    40. count += self.cells[se] as u8;
    41. count
    42. }
    43. #}

    Now let's run the benchmarks again! This time output it to after.txt.

    1. $ cargo bench | tee after.txt
    2. Compiling wasm_game_of_life v0.1.0 (file:///home/fitzgen/wasm_game_of_life)
    3. Finished release [optimized + debuginfo] target(s) in 0.82 secs
    4. Running target/release/deps/wasm_game_of_life-91574dfbe2b5a124
    5. running 0 tests
    6. test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
    7. Running target/release/deps/bench-8474091a05cfa2d9
    8. running 1 test
    9. test universe_ticks ... bench: 87,258 ns/iter (+/- 14,632)
    10. test result: ok. 0 passed; 0 failed; 0 ignored; 1 measured; 0 filtered out

    That looks a whole lot better! We can see just how much better it is with the benchcmp tool and the two text files we created before:

    1. $ cargo benchcmp before.txt after.txt
    2. name before.txt ns/iter after.txt ns/iter diff ns/iter diff % speedup
    3. universe_ticks 664,421 87,258 -577,163 -86.87% x 7.61

    Wow! 7.61x speed up!

    WebAssembly intentionally maps closely to common hardware architectures, but wedo need to make sure that this native code speed up translates into aWebAssembly speed up as well.

    Let's rebuild the .wasm with wasm-pack build and refresh. On my machine, the page isrunning at 60 frames per second again, and recording another profile with thebrowser's profiler reveals that each animation frame is taking about tenmilliseconds.

    Success!

    • At this point, the next lowest hanging fruit for speeding up Universe::tickis removing the allocation and free. Implement double buffering of cells,where the Universe maintains two vectors, never frees either of them, andnever allocates new buffers in tick.

    • As our profiling has shown us, 2D <canvas> rendering is not particularlyfast. Replace the 2D canvas renderer with a renderer. How much faster isthe WebGL version? How large can you make the universe before WebGL renderingis a bottleneck?