一、为什么要做时间剖析?
在开发过程中,我们往往会对某个环节的性能有先入为主的猜想。然而,只有通过剖析工具才能发现真正的性能瓶颈。本文中,我们首先用浏览器内置工具和 Rust 的 console API 对关键代码进行剖析,然后再据此优化。
二、利用 window.performance.now 实现 FPS 计时器
为了实时观察渲染性能,我们在 JavaScript 端添加了一个 FPS 计时器。具体实现思路如下:
- 在
index.js
中创建一个fps
对象。 - 每一帧调用
performance.now
计算与上次渲染的时间差,将时间差转换为帧率(FPS)。 - 只保存最近 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.time
和 console.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% 的时间。为了减少不必要的属性设置,我们可以改进渲染策略:
- 先一次性设置好"存活细胞"的颜色,然后遍历所有存活细胞进行绘制。
- 接着设置好"死亡细胞"的颜色,再遍历所有死亡细胞绘制。
优化前的代码大致如下:
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 bench
与 cargo 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 渲染是否仍能保持高效。