12.3 兴趣过滤与带宽预算
12.2 把「每帧发多少」压到了「只发变了的字段」,但还有一层更狠的裁剪没做:一个客户端根本不需要知道整个副本的状态。它在《晨曦大陆》的地图上只看得见周围一小圈,几十米外的怪在干什么与它无关。把这份「谁看得见谁」算清楚,就是兴趣过滤(AOI,Area of Interest)。
这一节有两个任务。第一,从零写一个能真跑的 AOI:统一空间网格(uniform grid)。第二,也是本节作为基准节(B 层)的重点——给出一组本机实测数字,并说清楚这些数字在什么前提下才成立。基准最怕的不是数字不准,是数字被当成结论到处搬。
兴趣过滤:用空间网格把 O(N²) 降到 O(1)
最笨的 AOI 是对每个客户端遍历全场实体算距离,代价是 O(N²)——100 万实体就是每帧 1 万亿次比较,直接判死刑。正解是把世界切成等大的格子,实体只和「自己所在格 + 邻格」里的实体比较。下面这段是 L1,go test 可以直接跑:
// file: internal/sync/aoi.go
// tier: L1
// verify: go test ./internal/sync/ -run 'TestAOIInterest|TestGridMoveIsIncremental|TestInterestDeterministic|TestFanoutReport' -v
package sync
import "math"
// Grid is a uniform spatial hash over dense entity handles. Entity IDs are
// expected to be 0..N-1; the grid keeps its state in flat slices so the working
// set is a contiguous block of memory rather than N map buckets. That choice is
// what makes the 12.3 benchmark's cache story measurable instead of hand-waved.
type Grid struct {
cell float32
cols int32
heads []int32 // cell -> first entity in that cell, -1 when empty
next []int32 // entity -> next entity in the same cell
cellOf []int32 // entity -> current cell index
xs, ys []float32 // entity -> position
}
// NewGrid builds a grid with `cell`-sized cells over a square world `cols`
// cells on a side, sized for `n` dense entities.
func NewGrid(n int, cols int32, cell float32) *Grid {
g := &Grid{
cell: cell,
cols: cols,
heads: make([]int32, int(cols)*int(cols)),
next: make([]int32, n),
cellOf: make([]int32, n),
xs: make([]float32, n),
ys: make([]float32, n),
}
for i := range g.heads {
g.heads[i] = -1
}
for i := range g.next {
g.next[i] = -1
}
for i := range g.cellOf {
g.cellOf[i] = -1
}
return g
}
func (g *Grid) index(p Vec2) int32 {
cx := int32(math.Floor(float64(p.X / g.cell)))
cy := int32(math.Floor(float64(p.Y / g.cell)))
if cx < 0 {
cx = 0
}
if cy < 0 {
cy = 0
}
if cx >= g.cols {
cx = g.cols - 1
}
if cy >= g.cols {
cy = g.cols - 1
}
return cy*g.cols + cx
}
// Move relocates entity id to p, returning true when it changed cell. Only a
// cell change touches the bucket lists; a move inside the same cell is a plain
// position write.
func (g *Grid) Move(id uint32, p Vec2) bool {
g.xs[id], g.ys[id] = p.X, p.Y
ni := g.index(p)
if g.cellOf[id] == ni {
return false
}
g.unlink(id)
g.cellOf[id] = ni
g.next[id] = g.heads[ni]
g.heads[ni] = int32(id)
return true
}
func (g *Grid) unlink(id uint32) {
ci := g.cellOf[id]
if ci < 0 {
return
}
if g.heads[ci] == int32(id) {
g.heads[ci] = g.next[id]
g.next[id] = -1
return
}
for h := g.heads[ci]; h != -1; h = g.next[h] {
if g.next[h] == int32(id) {
g.next[h] = g.next[id]
g.next[id] = -1
return
}
}
}
// Interest appends every entity within `radius` of id (excluding id) to dst and
// returns it. Cells are scanned row-major, so the order is deterministic.
func (g *Grid) Interest(id uint32, radius float32, dst []uint32) []uint32 {
cx := int32(math.Floor(float64(g.xs[id] / g.cell)))
cy := int32(math.Floor(float64(g.ys[id] / g.cell)))
span := int32(math.Ceil(float64(radius / g.cell)))
x0, y0 := g.xs[id], g.ys[id]
r2 := float64(radius) * float64(radius)
for yy := cy - span; yy <= cy+span; yy++ {
if yy < 0 || yy >= g.cols {
continue
}
row := yy * g.cols
for xx := cx - span; xx <= cx+span; xx++ {
if xx < 0 || xx >= g.cols {
continue
}
for e := g.heads[row+xx]; e != -1; e = g.next[e] {
if uint32(e) == id {
continue
}
dx := float64(g.xs[e] - x0)
dy := float64(g.ys[e] - y0)
if dx*dx+dy*dy <= r2 {
dst = append(dst, uint32(e))
}
}
}
}
return dst
}
// Bytes is the grid's working-set size in bytes. The benchmark reports it so
// the reader can compare it against this machine's cache instead of guessing.
func (g *Grid) Bytes() int {
return (len(g.heads)+len(g.next)+len(g.cellOf))*4 + (len(g.xs)+len(g.ys))*4
}
三个设计点。格子里挂的是侵入式链表(heads/next),不是每格一个切片——切片方案每格一个 []uint32,100 万个格子就是 100 万个 header,内存直接爆。移动只在跨格时才动链表,同格内挪一步只是写坐标。Interest 按行优先扫描邻格,结果顺序固定,这样 12.2 的增量编码才有稳定的输入。
基准三纪律:为什么这些数字只能同机比
这一节是 B(基准)层,产出的数字必须带着前提。三条纪律缺一条,数字就不可信:
- 进程隔离。基准必须跑在自己的进程里,且独占。同一台机器上跑着 hugo、跑着浏览器、跑着别的 Go 编译,内存带宽和缓存全被抢,测出来的「每客户端字节数」会飘。
- 预热到稳定。第一次查询要冷启动缓存、要触发页错误,必须丢弃。下面的基准每种半径先空跑 2000 次再开始计数。
- 工作集超过缓存。如果实体数组小到能塞进 L2,测出来的查询延迟是「缓存命中」的理想值,搬到线上就崩。这一条是三条里最容易被忽略的:很多「AOI 很快」的结论,其实只是世界太小。
下面这段是 B,go test 直接跑:
// file: internal/sync/fanout_test.go
// tier: B
// verify: go test ./internal/sync/ -run TestFanoutReport -v -count=1
package sync
import (
"testing"
"time"
)
// splitmix64 is a deterministic PRNG. The benchmark must be reproducible, so it
// never touches math/rand's global state or the clock.
func splitmix64(x uint64) uint64 {
x += 0x9e3779b97f4a7c15
z := x
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9
z = (z ^ (z >> 27)) * 0x94d049bb133111eb
return z ^ (z >> 31)
}
// TestFanoutReport is the 12.3 baseline (tier B). It builds a world larger than
// this machine's L2, warms the query path, then measures the interest set size
// and the resulting per-client and total fanout for three radii.
func TestFanoutReport(t *testing.T) {
const (
n = 1_000_000
cols = 256
cell = 32
samples = 20000
tickRate = 20
dirtyPct = 0.10
deltaB = 13 // 5 B entry header + 8 B for a moved position
)
g := NewGrid(n, cols, cell)
for i := 0; i < n; i++ {
r := splitmix64(uint64(i))
side := float64(cols) * float64(cell)
p := Vec2{X: float32(float64(r%1_000_003) / 1_000_003 * side),
Y: float32(float64((r>>20)%1_000_003) / 1_000_003 * side)}
g.Move(uint32(i), p)
}
t.Logf("working set: %.1f MB over %d entities (grid: heads+next+cellOf+pos)",
float64(g.Bytes())/(1<<20), n)
// Sample clients spread across the world so the measurement is not biased
// toward one corner. Each sample id steps by a large odd stride, wrapped
// back into range so the handle stays valid.
const stride = uint32(499979)
buf := make([]uint32, 0, 4096)
for _, radius := range []float32{32, 64, 128} {
id := uint32(0)
for i := 0; i < 2000; i++ { // warm up the cache, discard the result
buf = g.Interest(id, radius, buf[:0])
id = (id + stride) % n
}
var total int64
start := time.Now()
for i := 0; i < samples; i++ {
buf = g.Interest(id, radius, buf[:0])
total += int64(len(buf))
id = (id + stride) % n
}
nsPerQuery := float64(time.Since(start).Nanoseconds()) / samples
mean := float64(total) / samples
perClient := mean * dirtyPct * deltaB * tickRate
fanout := perClient * n
t.Logf("radius=%3.0f mean interest=%7.1f query=%6.0f ns per-client=%8.1f B/s fanout=%8.1f MB/s",
radius, mean, nsPerQuery, perClient, fanout/(1<<20))
}
// Sanity: the 3x3 span for radius 32 at cell 32 must not be empty.
if len(g.Interest(0, 32, nil)) == 0 {
t.Fatal("interest set is empty; density or grid sizing is wrong")
}
}
工作集 15.5 MB(100 万实体,格边长 32,世界 256×256 格),刻意超过本机 L2。本机实测(Go 1.26,macOS arm64):
| 兴趣半径 | 平均可见实体 | 查询耗时 | 每客户端 | 扇出总量 |
|---|---|---|---|---|
| 32(3×3 格) | 47.9 | 7.7–9.2 µs | 1245 B/s | 1187 MB/s |
| 64(5×5 格) | 190.7 | 14–27 µs | 4958 B/s | 4728 MB/s |
| 128(9×9 格) | 757.6 | 41–65 µs | 19698 B/s | 18785 MB/s |
三件事从表里读得出来。其一,每客户端字节数只由兴趣半径决定,和世界里有 1 万还是 100 万实体无关——这正是兴趣过滤的意义,它把成本从「场景规模」解耦成「视野大小」。其二,查询耗时的波动(14–27 µs)本身就是工作集超过缓存的证据:链表节点散落在 15.5 MB 里,每次跳转都是一次缓存未命中,于是耗时随机器状态大幅抖动。其三,扇出总量随半径平方级放大:半径翻倍,可见实体数约翻四倍,一个进程要发的字节也翻四倍。上面 18.7 GB/s 那行不是「服务器能扛」,恰恰相反,它是「单进程绝对扛不住,必须分片」的量化理由。
最后重复那条基准纪律:以上全部是同机相对量级,绝对数值不可移植。换一台 CPU、换一个 Go 版本、把工作集缩到 L2 以内,任何一个数字都会变。这张表能告诉你的是「半径和成本的关系是平方的」「AOI 查询是访存受限的」,不能告诉你「你的服务器能扛多少客户端」——那要你在自己的机器上、用自己真实的实体密度重跑一遍。
本节小结:兴趣过滤把「发多少」从场景规模里解耦出来,代价是每帧要为每个客户端做一次空间查询,而这次查询是访存受限的。至此第 12 章的三件事——谁算、发多少、发给谁——都落了地。下一章我们站到客户端那一侧:收到状态之后,怎么让画面不跳、操作不跟手,以及服务端为此要提供什么。
阅读导航:上一节:12.2 快照与增量编码 · 下一节:13.1 输入缓冲与本地预测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。