优化 WebAssembly 中的 Game of Life

一、为什么要做时间剖析?

在开发过程中,我们往往会对某个环节的性能有先入为主的猜想。然而,只有通过剖析工具才能发现真正的性能瓶颈。本文中,我们首先用浏览器内置工具和 Rust 的 console API 对关键代码进行剖析,然后再据此优化。

二、利用 window.performance.now 实现 FPS 计时器

为了实时观察渲染性能,我们在 JavaScript 端添加了一个 FPS 计时器。具体实现思路如下:

  1. index.js 中创建一个 fps 对象。
  2. 每一帧调用 performance.now 计算与上次渲染的时间差,将时间差转换为帧率(FPS)。
  3. 只保存最近 100 帧的帧率,并计算平均值、最小值和最大值,方便直观查看性能变化。

以下是核心代码片段:

js 复制代码
const fps = new class {
  constructor() {
    this.fps = document.getElementById("fps");
    this.frames = [];
    this.lastFrameTimeStamp = performance.now();
  }

  render() {
    const now = performance.now();
    const delta = now - this.lastFrameTimeStamp;
    this.lastFrameTimeStamp = now;
    const fps = 1 / delta * 1000;

    // 保存最新的 100 次帧率数据
    this.frames.push(fps);
    if (this.frames.length > 100) {
      this.frames.shift();
    }

    let min = Infinity;
    let max = -Infinity;
    let sum = 0;
    for (let i = 0; i < this.frames.length; i++) {
      sum += this.frames[i];
      min = Math.min(this.frames[i], min);
      max = Math.max(this.frames[i], max);
    }
    let mean = sum / this.frames.length;

    // 显示统计数据
    this.fps.textContent = `
Frames per Second:
         latest = ${Math.round(fps)}
avg of last 100 = ${Math.round(mean)}
min of last 100 = ${Math.round(min)}
max of last 100 = ${Math.round(max)}
`.trim();
  }
};

在主渲染循环中,每次调用 fps.render() 即可更新 FPS 数据:

js 复制代码
const renderLoop = () => {
    fps.render(); // 更新 FPS

    universe.tick();
    drawGrid();
    drawCells();

    animationId = requestAnimationFrame(renderLoop);
};

同时,我们在 HTML 中添加了对应的元素,并利用简单的 CSS 保证数据显示清晰:

html 复制代码
<div id="fps"></div>
<canvas id="game-of-life-canvas"></canvas>
css 复制代码
#fps {
  white-space: pre;
  font-family: monospace;
}

三、在 Rust 端用 console.time 对 Universe::tick 进行计时

为了更精确地分析 Rust 代码的性能,我们使用了 web-sys 提供的 console.timeconsole.timeEnd。我们首先定义了一个 RAII 风格的 Timer 类型,让计时操作更简洁安全:

rust 复制代码
extern crate web_sys;
use web_sys::console;

pub struct Timer<'a> {
    name: &'a str,
}

impl<'a> Timer<'a> {
    pub fn new(name: &'a str) -> Timer<'a> {
        console::time_with_label(name);
        Timer { name }
    }
}

impl<'a> Drop for Timer<'a> {
    fn drop(&mut self) {
        console::time_end_with_label(self.name);
    }
}

Universe::tick 方法开头,我们通过创建一个 Timer 实例来记录每次 tick 的耗时:

rust 复制代码
pub fn tick(&mut self) {
    let _timer = Timer::new("Universe::tick");

    // 复制当前细胞状态作为下一代状态的初始值
    let mut next = {
        let _timer = Timer::new("allocate next cells");
        self.cells.clone()
    };

    {
        let _timer = Timer::new("new generation");
        for row in 0..self.height {
            for col in 0..self.width {
                let idx = self.get_index(row, col);
                let cell = self.cells[idx];
                let live_neighbors = self.live_neighbor_count(row, col);

                let next_cell = match (cell, live_neighbors) {
                    // 规则 1: 活细胞少于两个邻居则死亡(欠产)
                    (Cell::Alive, x) if x < 2 => Cell::Dead,
                    // 规则 2: 活细胞有两个或三个邻居则存活
                    (Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,
                    // 规则 3: 活细胞超过三个邻居则死亡(过度拥挤)
                    (Cell::Alive, x) if x > 3 => Cell::Dead,
                    // 规则 4: 死细胞有正好三个邻居则复活
                    (Cell::Dead, 3) => Cell::Alive,
                    // 其他情况保持原状态
                    (otherwise, _) => otherwise,
                };

                next[idx] = next_cell;
            }
        }
    }

    let _timer = Timer::new("free old cells");
    self.cells = next;
}

通过在浏览器控制台和性能剖析工具中查看日志,我们可以清晰地看到每个阶段的耗时。

四、渲染性能瓶颈:Canvas 2D 的 fillStyle 调用

在最初的实现中,drawCells 函数会为每个细胞调用一次 ctx.fillStyle,导致大量的属性设置调用。据浏览器的性能剖析显示,这一部分占用了接近 40% 的时间。为了减少不必要的属性设置,我们可以改进渲染策略:

  1. 先一次性设置好"存活细胞"的颜色,然后遍历所有存活细胞进行绘制。
  2. 接着设置好"死亡细胞"的颜色,再遍历所有死亡细胞绘制。

优化前的代码大致如下:

js 复制代码
for (let row = 0; row < height; row++) {
  for (let col = 0; col < width; col++) {
    const idx = getIndex(row, col);
    ctx.fillStyle = cells[idx] === DEAD ? DEAD_COLOR : ALIVE_COLOR;
    ctx.fillRect(
      col * (CELL_SIZE + 1) + 1,
      row * (CELL_SIZE + 1) + 1,
      CELL_SIZE,
      CELL_SIZE
    );
  }
}

优化后的代码改为:

js 复制代码
// 绘制所有存活细胞
ctx.fillStyle = ALIVE_COLOR;
for (let row = 0; row < height; row++) {
  for (let col = 0; col < width; col++) {
    const idx = getIndex(row, col);
    if (cells[idx] !== Cell.Alive) {
      continue;
    }
    ctx.fillRect(
      col * (CELL_SIZE + 1) + 1,
      row * (CELL_SIZE + 1) + 1,
      CELL_SIZE,
      CELL_SIZE
    );
  }
}

// 绘制所有死亡细胞
ctx.fillStyle = DEAD_COLOR;
for (let row = 0; row < height; row++) {
  for (let col = 0; col < width; col++) {
    const idx = getIndex(row, col);
    if (cells[idx] !== Cell.Dead) {
      continue;
    }
    ctx.fillRect(
      col * (CELL_SIZE + 1) + 1,
      row * (CELL_SIZE + 1) + 1,
      CELL_SIZE,
      CELL_SIZE
    );
  }
}

这样仅需两次设置 fillStyle,大大降低了渲染开销,刷新率从之前的 40 fps 回升到了平滑的 60 fps。

五、优化 Universe::tick 的关键:重写 live_neighbor_count

进一步的性能剖析显示,在 Universe::tick 中,计算细胞周围存活邻居时,使用 modulo 运算来处理边界条件,导致了大量的昂贵除法操作。原始代码如下:

rust 复制代码
fn live_neighbor_count(&self, row: u32, column: u32) -> u8 {
    let mut count = 0;
    for delta_row in [self.height - 1, 0, 1].iter().cloned() {
        for delta_col in [self.width - 1, 0, 1].iter().cloned() {
            if delta_row == 0 && delta_col == 0 {
                continue;
            }
            let neighbor_row = (row + delta_row) % self.height;
            let neighbor_col = (column + delta_col) % self.width;
            let idx = self.get_index(neighbor_row, neighbor_col);
            count += self.cells[idx] as u8;
        }
    }
    count
}

在大多数情况下,细胞并不处于边界,因此可以利用 if 判断来替代 modulo 运算,从而避开昂贵的 div 操作。重写后的代码如下:

rust 复制代码
fn live_neighbor_count(&self, row: u32, column: u32) -> u8 {
    let mut count = 0;

    let north = if row == 0 { self.height - 1 } else { row - 1 };
    let south = if row == self.height - 1 { 0 } else { row + 1 };
    let west  = if column == 0 { self.width - 1 } else { column - 1 };
    let east  = if column == self.width - 1 { 0 } else { column + 1 };

    count += self.cells[self.get_index(north, west)] as u8;
    count += self.cells[self.get_index(north, column)] as u8;
    count += self.cells[self.get_index(north, east)] as u8;
    count += self.cells[self.get_index(row, west)] as u8;
    count += self.cells[self.get_index(row, east)] as u8;
    count += self.cells[self.get_index(south, west)] as u8;
    count += self.cells[self.get_index(south, column)] as u8;
    count += self.cells[self.get_index(south, east)] as u8;

    count
}

使用该改进方案后,通过 cargo benchcargo benchcmp 工具的基准测试结果显示,Universe::tick 的执行时间从大约 664,421 ns/iter 降低到 87,258 ns/iter,实现了约 7.61 倍的加速!

六、从原生代码到 WebAssembly 的完美迁移

经过一系列的基准测试和性能优化,我们将改进后的代码重新编译为 WebAssembly,并刷新页面。最终在浏览器中观察到,每一帧的渲染耗时降低到大约 10 毫秒,整个 Game of Life 运行达到平滑的 60 fps,用户体验显著提升。

七、进一步的优化思路

本文介绍的优化方法仅仅是一个开始。接下来你可以尝试以下几种方法进一步优化系统:

  • 双缓冲技术

    避免在每次 tick 中分配和释放细胞状态的内存,采用双缓冲技术保持两个固定的缓冲区进行交替更新。

  • Delta-Based 更新设计

    改变设计思想,仅传递状态变化的细胞列表给 JavaScript,而不是每次都传递整个细胞数组。如何实现这一点同时避免频繁的内存分配也是一个有趣的挑战。

  • 采用 WebGL 渲染

    由于 2D canvas 渲染在大量绘制时存在瓶颈,可以考虑替换为 WebGL 渲染。尝试测试在使用 WebGL 渲染时的性能表现,以及在更大规模的细胞网格下,WebGL 渲染是否仍能保持高效。

相关推荐
Hello.Reader2 天前
基于 WebAssembly 的 Game of Life 交互实现
交互·wasm
自不量力的A同学4 天前
微软开源 “Hyperlight Wasm”,将轻量级虚拟机技术扩展至 WASM
wasm
墨雪遗痕7 天前
WebAssembly实践,性能也有局限性
wasm
勇敢牛牛_8 天前
【Rust基础】使用Rust和WASM开发的图片压缩工具
开发语言·rust·wasm·图片压缩
eqwaak09 天前
基于Wasm的边缘计算Pandas:突破端侧AI的最后一公里——让数据分析在手机、IoT设备上飞驰
人工智能·深度学习·架构·pandas·边缘计算·wasm
Hello.Reader13 天前
Rust + WebAssembly 实现康威生命游戏
游戏·rust·wasm
Hello.Reader14 天前
调试 Rust + WebAssembly 版康威生命游戏
游戏·rust·wasm
Hello.Reader15 天前
Rust + WebAssembly 开发环境搭建指南
开发语言·rust·wasm
Hello.Reader16 天前
为什么选择 Rust 和 WebAssembly?
开发语言·rust·wasm