《MMO 服务器开发》12.3 兴趣过滤与带宽预算

一个副本里 100 万实体,每个客户端真正看得见的只有周围几百个——兴趣过滤把每帧的增量裁到只剩这一小块。本节用统一网格从零实现 AOI,并按基准三纪律(进程隔离、预热到稳定、工作集超过缓存)实测三种半径下的每客户端字节每秒与扇出放大,数字标明不可移植。

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(基准)层,产出的数字必须带着前提。三条纪律缺一条,数字就不可信:

  1. 进程隔离。基准必须跑在自己的进程里,且独占。同一台机器上跑着 hugo、跑着浏览器、跑着别的 Go 编译,内存带宽和缓存全被抢,测出来的「每客户端字节数」会飘。
  2. 预热到稳定。第一次查询要冷启动缓存、要触发页错误,必须丢弃。下面的基准每种半径先空跑 2000 次再开始计数。
  3. 工作集超过缓存。如果实体数组小到能塞进 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.97.7–9.2 µs1245 B/s1187 MB/s
64(5×5 格)190.714–27 µs4958 B/s4728 MB/s
128(9×9 格)757.641–65 µs19698 B/s18785 MB/s

三件事从表里读得出来。其一,每客户端字节数只由兴趣半径决定,和世界里有 1 万还是 100 万实体无关——这正是兴趣过滤的意义,它把成本从「场景规模」解耦成「视野大小」。其二,查询耗时的波动(14–27 µs)本身就是工作集超过缓存的证据:链表节点散落在 15.5 MB 里,每次跳转都是一次缓存未命中,于是耗时随机器状态大幅抖动。其三,扇出总量随半径平方级放大:半径翻倍,可见实体数约翻四倍,一个进程要发的字节也翻四倍。上面 18.7 GB/s 那行不是「服务器能扛」,恰恰相反,它是「单进程绝对扛不住,必须分片」的量化理由。

最后重复那条基准纪律:以上全部是同机相对量级,绝对数值不可移植。换一台 CPU、换一个 Go 版本、把工作集缩到 L2 以内,任何一个数字都会变。这张表能告诉你的是「半径和成本的关系是平方的」「AOI 查询是访存受限的」,不能告诉你「你的服务器能扛多少客户端」——那要你在自己的机器上、用自己真实的实体密度重跑一遍。

本节小结:兴趣过滤把「发多少」从场景规模里解耦出来,代价是每帧要为每个客户端做一次空间查询,而这次查询是访存受限的。至此第 12 章的三件事——谁算、发多少、发给谁——都落了地。下一章我们站到客户端那一侧:收到状态之后,怎么让画面不跳、操作不跟手,以及服务端为此要提供什么。

阅读导航:上一节:12.2 快照与增量编码 · 下一节:13.1 输入缓冲与本地预测 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标