优化 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 渲染是否仍能保持高效。

相关推荐
爱学习的程序媛2 天前
【Web前端】WebAssembly实战项目
前端·web·wasm
REDcker3 天前
Wasm 软解 H.265 方案与原理
wasm·h.265
步步为营DotNet9 天前
ASP.NET Core 10中的Blazor WebAssembly性能优化实践
性能优化·asp.net·wasm
前端之虎陈随易9 天前
Vite 8正式发布,内置devtool,Wasm SSR 支持
前端·人工智能·typescript·npm·node.js·wasm
古城小栈12 天前
Rust 开发 WebAssembly 一眼案例
开发语言·rust·wasm
csdn_aspnet12 天前
.NET 10 中的 Blazor:新增功能及常见问题
wasm·blazor·.net10
zhojiew1 个月前
使用envoy配置jwt校验和ratelimit限流以及通过wasm扩展统计llm消耗token
wasm·envoy
狗都不学爬虫_2 个月前
JS逆向 -最新版 盼之(decode__1174、ssxmod_itna、ssxmod_itna2)纯算
javascript·爬虫·python·网络爬虫·wasm
老百姓懂点AI2 个月前
[WASM实战] 插件系统的安全性:智能体来了(西南总部)AI调度官的WebAssembly沙箱与AI agent指挥官的动态加载
人工智能·wasm