《MMO 服务器开发》4.3 基准:三种 AOI 的 CPU 与带宽对照

把暴力扫描、九宫格、十字链表三种 AOI 放在同一批 5000 个玩家、同一张地图上实跑基准:九宫格查询约 890ns,十字链表约 4350ns 且移动慢得多,暴力扫描约 37500ns。再把平均兴趣集合 6.21 人换算成每帧出站消息,量化兴趣管理到底省下了多少带宽。

4.3 基准:三种 AOI 的 CPU 与带宽对照

4.1 与 4.2 各写了一种 AOI,它们都声称「比全量扫描快」。快多少?在什么规模下够用?这一节用基准把话说死。

我们比较三种:暴力扫描(不做任何兴趣管理,查询时线性扫全部实体)、九宫格(4.1)、十字链表(4.2)。三者语义完全相同——都是「半径 r 内的其它实体」——所以可以直接比。这是 B 层基准:产出数字,软告警不硬失败。所有数字来自本机实跑,且是同机相对量级,绝对数值不可移植。

三种 AOI:把基线也写出来

要证明 AOI 有用,先得有一个「没有 AOI」的基线。它就是把所有实体堆在一个 map 里,查询时逐个做距离判断——O(N),没有任何空间剪枝。下面这段是 L1,go test ./internal/aoi/ 可以直接跑:

// file: internal/aoi/brute.go
// tier: L1
// verify: go test ./internal/aoi/
package aoi

// Brute 是「不做兴趣管理」的基线:所有实体放在一个 map 里,查询时线性
// 扫描全部实体做距离判断。它代表最朴素的做法——每个玩家都能看到全服
// 所有人,服务端每帧向所有人广播所有人的状态。它的存在是为了给真正的
// AOI 一个对照,量化兴趣管理省下的到底是时间还是带宽。
type Brute struct {
	pos map[EntityID]Vec2
}

func NewBrute() *Brute { return &Brute{pos: make(map[EntityID]Vec2)} }

func (b *Brute) Add(id EntityID, p Vec2)  { b.pos[id] = p }
func (b *Brute) Move(id EntityID, p Vec2) { b.pos[id] = p }
func (b *Brute) Remove(id EntityID)       { delete(b.pos, id) }

// NeighborsInRadius 是 O(N) 的全量扫描,没有任何空间剪枝。
func (b *Brute) NeighborsInRadius(id EntityID, r float64) []EntityID {
	self, ok := b.pos[id]
	if !ok {
		return nil
	}
	out := make([]EntityID, 0, 16)
	for other, p := range b.pos {
		if other != id && within(self, p, r) {
			out = append(out, other)
		}
	}
	return out
}

这段代码本身没什么可讲的,它的意义在于给出一个「不做兴趣管理」的参照:如果九宫格只比它快一点点,那九宫格就不值得写。真实游戏里,暴力扫描等价于「每个玩家都收到全服所有人的位置」,带宽会先于 CPU 崩掉——这一点会在本节的带宽表里量化。

基准怎么写:场景、预热与 sink

基准的公平性取决于三点:同一批输入、排除构建成本、防止编译器优化掉结果。

场景固定为 5000 个玩家均匀撒在 400×400 的逻辑地图上,AOI 半径 = GridSize = 8。用固定种子的 rand/v2 PCG 生成坐标,保证三套 AOI 面对完全相同的输入。世界构建(把 5000 个实体插进各自结构)放在 b.ResetTimer() 之前,不计入查询耗时——我们量的是「稳态每帧查询」,不是「一次性建表」。结果写进包级变量 sinkIDs,否则编译器会认为查询结果无人使用而把整段消除。下面这段是 B 层,go test ./internal/aoi/ -bench . -benchmem -count=5 可以直接跑:

// file: internal/aoi/bench_test.go
// tier: B
// verify: go test ./internal/aoi/ -bench . -benchmem -count=5
package aoi

import (
	"math/rand/v2"
	"testing"
)

// 基准场景:N 个玩家均匀撒在 W×W 的逻辑地图上,AOI 半径 = GridSize = 8。
// 这个密度下每个玩家的兴趣集合约 6 人,接近真实 MMO 主城一屏的量级。
const (
	benchN = 5000
	benchW = 400.0
	benchR = 8.0
)

// benchPoints 用固定种子的 PCG 生成同一批坐标,保证三套 AOI 面对完全
// 相同的输入,也保证多次 -count 之间可比。
func benchPoints() []Vec2 {
	rng := rand.New(rand.NewPCG(1, 2))
	pts := make([]Vec2, benchN)
	for i := range pts {
		pts[i] = Vec2{X: rng.Float64() * benchW, Y: rng.Float64() * benchW}
	}
	return pts
}

var (
	sinkIDs []EntityID // 包级 sink:防止查询结果被编译器优化掉
	points  = benchPoints()
)

func newGridWorld() (*Grid, []EntityID) {
	g := NewGrid()
	ids := make([]EntityID, benchN)
	for i, p := range points {
		ids[i] = MakeEntityID(1, uint64(i))
		g.Add(ids[i], p)
	}
	return g, ids
}

func newCrossLinkWorld() (*CrossLink, []EntityID) {
	cl := NewCrossLink()
	ids := make([]EntityID, benchN)
	for i, p := range points {
		ids[i] = MakeEntityID(1, uint64(i))
		cl.Add(ids[i], p)
	}
	return cl, ids
}

func newBruteWorld() (*Brute, []EntityID) {
	br := NewBrute()
	ids := make([]EntityID, benchN)
	for i, p := range points {
		ids[i] = MakeEntityID(1, uint64(i))
		br.Add(ids[i], p)
	}
	return br, ids
}

// ---------- 查询:三种 AOI 对同一个玩家的半径查询 ----------

func BenchmarkGridQuery(b *testing.B) {
	g, ids := newGridWorld()
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		sinkIDs = g.NeighborsInRadius(ids[i%benchN], benchR)
	}
}

func BenchmarkCrossLinkQuery(b *testing.B) {
	cl, ids := newCrossLinkWorld()
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		sinkIDs = cl.NeighborsInRadius(ids[i%benchN], benchR)
	}
}

func BenchmarkBruteQuery(b *testing.B) {
	br, ids := newBruteWorld()
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		sinkIDs = br.NeighborsInRadius(ids[i%benchN], benchR)
	}
}

// ---------- 移动:每帧更新一个玩家坐标的成本 ----------

func BenchmarkGridMove(b *testing.B) {
	g, ids := newGridWorld()
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		// 每次挪 0.5 个逻辑单位,多数情况不跨格。
		g.Move(ids[i%benchN], Vec2{X: points[i%benchN].X + 0.5, Y: points[i%benchN].Y})
	}
}

func BenchmarkCrossLinkMove(b *testing.B) {
	cl, ids := newCrossLinkWorld()
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		cl.Move(ids[i%benchN], Vec2{X: points[i%benchN].X + 0.5, Y: points[i%benchN].Y})
	}
}

// TestAOISetSize 报告平均兴趣集合大小,供正文的带宽估算引用。
func TestAOISetSize(t *testing.T) {
	g, ids := newGridWorld()
	total := 0
	for _, id := range ids {
		total += len(g.NeighborsInRadius(id, benchR))
	}
	avg := float64(total) / float64(len(ids))
	t.Logf("avg AOI set size = %.2f (N=%d, W=%.0f, r=%.0f)", avg, benchN, benchW, benchR)
	if avg <= 0 || avg >= float64(benchN) {
		t.Fatalf("unexpected avg set size %.2f", avg)
	}
}

三个查询基准都轮询全部 5000 个实体各做一次查询,模拟「每帧每人查一次」;移动基准每次把实体挪 0.5 个逻辑单位,量「每帧每人更新一次」的成本。TestAOISetSize 顺带报告平均兴趣集合大小,供带宽估算引用。

本机实测:CPU

本机(Apple M1 Pro,Go 1.26.0 darwin/arm64,10 核)实跑 -count=5,下面是其中一轮的代表值:

$ go test ./internal/aoi/ -bench . -benchmem -count=5 -run '^$'
BenchmarkGridQuery-10         1324948     889.3 ns/op    128 B/op    1 allocs/op
BenchmarkCrossLinkQuery-10     285885    4238.0 ns/op    129 B/op    1 allocs/op
BenchmarkBruteQuery-10          31296   38839.0 ns/op    128 B/op    1 allocs/op
BenchmarkGridMove-10         20798007      58.3 ns/op      0 B/op    0 allocs/op
BenchmarkCrossLinkMove-10       45294   26497.0 ns/op      0 B/op    0 allocs/op

汇总成一张表:

AOI查询 ns/op移动 ns/op查询 B/op查询 allocs/op
九宫格~890~581281
十字链表~4350~265001291
暴力扫描~37500~20(map 写)1281

读这张表要抓三点。第一,暴力扫描比九宫格慢一个多数量级:37500ns 对 890ns。N=5000 时线性扫描要逐个算距离,而九宫格只扫九格、每格约两个实体。第二,十字链表的查询比九宫格慢约 5 倍,因为它要沿两条链各推进一段(本例每条约 200 个节点),比扫 9 个格子多。第三,十字链表的移动比九宫格慢约 450 倍——26500ns 对 58ns。这不是实现瑕疵,而是结构本身的 O(n):每次移动都要沿链表重新找位置,而九宫格用格坐标 O(1) 定位。

把这三个数字代回「每帧每人一次」的服务器循环(N=5000):

AOI每帧成本(5000 人查询 + 移动)10Hz 下占用
九宫格5000 × (890 + 58) ns ≈ 4.7 ms约 4.7% 的 100ms 预算
十字链表5000 × (4350 + 26500) ns ≈ 154 ms超过 100ms,单核撑不住 10Hz
暴力扫描5000 × 37500 ns ≈ 188 ms(仅查询)超过 100ms

这张表解释了为什么绝大多数 MMO 默认选九宫格:在均匀密度下,它的常数因子最小。

带宽:AOI 集合大小决定出流量

CPU 只是半边。兴趣管理的另一半价值是减少广播对象。TestAOISetSize 报出本场景平均兴趣集合为 6.21 人——也就是说,每个玩家每帧只需要收到 6 个人的状态,而不是全服 4999 个。

设每帧每人发一条 30 字节的位置同步消息(卷一的 varint MoveNotify 就是 30 字节),10Hz 下:

方案每帧出站消息数10Hz 出站带宽
九宫格 / 十字链表(集合 6.21)5000 × 6.21 ≈ 3105031050 × 30B × 10 ≈ 9.3 MB/s
暴力扫描(全服广播)5000 × 4999 ≈ 24,995,00024995000 × 30B × 10 ≈ 7.5 GB/s

7.5 GB/s 不是一个「慢」的数字,而是一个物理上发不出去的数字。这就是兴趣管理真正的意义:它不是把带宽优化 20%,而是把「不可能」变成「可行」。九宫格与十字链表在这一列上完全相同——因为它们的查询结果集一样大,带宽只取决于「兴趣集合大小」,与用什么结构无关。

本节小结 / 实测结论

三种 AOI 在同一批输入上的对照已经很清楚了:暴力扫描在 CPU 与带宽上都不可用(CPU 慢一个多数量级,带宽高约 800 倍);九宫格在均匀密度下 CPU 最优(查询与移动都最便宜);十字链表查询略慢、移动慢得多,但它不依赖格边长这个参数。

因此选型判据是:分布大致均匀、追求最低 CPU → 九宫格;分布极端不均或世界稀疏、不想调格边长 → 十字链表(并给它配一个索引把移动拉回对数级)。无论选哪种,只要集合大小接近,带宽收益是一样的。这组数字是同机相对量级,绝对数值不可移植——换 CPU、换 Go 版本、换玩家密度,数字都会变,但「暴力扫描不可用、两种 AOI 同量级」这个相对关系稳定。

阅读导航:上一节:4.2 十字链表 AOI 从零实现 · 下一节:5.1 服务器权威移动 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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