我做了个 C# ECS:10 万个实体跑完 9 个系统,一轮更新只用 1.72 毫秒

13 阅读6分钟

谈 ECS 性能,遍历一列 Position 很容易跑出漂亮数字。到了服务端,一轮更新还要处理 AOI、战斗、状态增删、死亡复活,以及子弹的生成和过期。这些事放在一起跑,时延和 GC 还能稳住吗?这是我做 EasyEcs 时更关心的问题。

在项目公开的 BenchmarkDotNet 测试里,一个 World 装着 10 万个实体,跑完 9 个系统只要 1.72 毫秒;预热后,这一轮更新分配 0 B 内存。测试开始前会确认命中、死亡、生成和网格更新真的发生了;计时阶段执行的是 world.Update(),不是 9 个空方法。基准源码和完整性能页都能核对。

原生 .NET 场景耗时预热后分配
一个 World:10 万个实体跑完 9 个系统1.72 毫秒0 B
四个独立 World:每个 2.5 万个实体,各用一个常驻线程四个全部完成共 0.92 毫秒0 B
战斗服示例:10 万个实体,另含状态同步、索引等更多功能3.17 毫秒0 B

这三行的工作量不同。第二行对应把房间或区域拆成独立 World 的做法;0.92 毫秒包含让四个线程开始和等待它们全部结束的同步开销,不能理解为“一个装着 10 万个实体的 World 在 0.92 毫秒内跑完”。测试机是 Ubuntu 24.04 上的 4 核 2.1 GHz Xeon 虚拟机,使用 .NET 10.0.13 和 BenchmarkDotNet 0.15.2;数字不包括网络传输和其他业务代码。后文提到的 1.68 毫秒来自另一组跨库对比,别和开头的 1.72 毫秒当成同一次测试。

更新之外的常见操作,差距更明显

仓库还把相同任务分别移植到 Friflo.Engine.ECS 3.6、Arch 2.1 和 DefaultEcs 0.17,同机测了 37 项。下面挑的是后端常见操作。“最快对手”指这一项中最快的其他库,不一定每次都是同一家。各库采用自己的惯用 API,测试初始化时会检查结果是否等价。

业务动作EasyEcs该项最快对手倍数
给 1 万个实体存档,再读回 World146 μsDefaultEcs 4.33 ms30×
给 10 万实体批量加状态,再全部移除78.7 μsArch 1.21 ms15×
在已有 10 万个实体的 World 里生成并销毁 1 万个实体138 μsFriflo 380 μs2.75×
按评分区间查询 10 万玩家、按区域求和127 μsFriflo 317 μs2.49×
给 10 万个实体中的 5% 加状态,再移除517 μsFriflo 1.00 ms1.94×
从 10 万个实体中找出本轮被写入的 10 个507 nsArch 966 ns1.91×
遍历 10 万实体的一列组件37.7 μsFriflo 59.9 μs1.59×
在 10 万个实体中让 1 万个休眠、再唤醒478 μsDefaultEcs 745 μs1.56×

存档这一项写出再读回 1 万个带位置和库存数据的实体;各库采用不同的序列化格式,所以 30× 比的是整套操作,不是同一个编码器。30× 和 15× 都是单项操作,不代表整个游戏快 30 倍。

把同一套完整模拟移植到四个库后,差距小得多:EasyEcs 1.68 毫秒、Friflo 1.83 毫秒,两者都分配 0 B。37 项对比中,EasyEcs 24 项领先、11 项的差距在测量误差内、2 项较慢。明显落后的两项分别是不用变更过滤器直接扫描,以及不预留容量就在新 World 中逐个创建实体。横向对比页把原因也写了出来。

系统代码长什么样?

组件就是 C# struct。用 in 参数声明只读组件,用 ref 参数声明要修改的组件;遍历循环交给源码生成器:

[ForEach]
static void Move(ref Position p, in Velocity v, in Clock clock)
{
    p.X += v.X * clock.Dt;
    p.Y += v.Y * clock.Dt;
}

如果性能分析发现移动系统成了瓶颈,可以改用批量 [ForEach],一次拿到位置和速度两列连续的 Span,自己写 SIMD。在线粒子 Demo 的移动部分就是这么写的;末尾剩一个粒子时的处理没有贴在这里,完整源码里有:

[ForEach]
static void Move(
    Span<Position> positions,
    ReadOnlySpan<Velocity> velocities,
    in Clock clock)
{
    var p = MemoryMarshal.Cast<Position, Vector128<float>>(positions);
    var v = MemoryMarshal.Cast<Velocity, Vector128<float>>(velocities);
    var dt = Vector128.Create(clock.Dt);
    for (int i = 0; i < p.Length; i++)
        p[i] += v[i] * dt;
}

在只有一种组件组合的微基准里,[ForEach] 更新 1 万个实体的位置耗时 9.1 微秒,约 每个实体 0.91 纳秒。用 Position[] 和 Velocity[] 两个普通数组做同样计算是 12.8 微秒;基准源码公开了两段循环。这只是一个微基准,不能代替前面的完整更新测试。

浏览器 Demo 在 M4 上显示的约 0.1 毫秒,则是 Mono AOT 编译到 WebAssembly 后,阻力和移动两个系统的实时读数,不能和 Xeon 上 CoreCLR 的结果直接比较。

批量改状态的代码也很短:

static readonly QueryFilter Fighters = QueryFilter.Create().With<Vitals>();
world.SetAll(new Stunned(3), in Fighters);
world.RemoveAll<Stunned>();

第一次增加组件可能需要建列和迁移实体;之后这列可以留在表中,只切换存在位。表里的 78.7 μs 测的是预热后的重复往返,不是第一次创建新组合的耗时。批量变化指南还写了什么时候不该用它。

快之外,服务端还需要什么?

一轮更新之外,服务端还要查人、同步状态、保存进度。EasyEcs 把这些能力放在同一套数据模型里:

  • [Changed<T>] 和 [Removed<T>] 找出需要同步给客户端的变化。
  • 值索引和关系支持按值或关联实体查找,不用每次全表扫描。
  • Prefab 和批量生成用来创建一波单位;快照和存档用于回滚与恢复。
  • 没有读写冲突的系统才会被并行调度。

遍历时可以直接增删组件,不用先攒进命令缓冲区;组件被移除后再加回来,通常只需切换存在位,不必迁移整个实体。这也是批量状态切换跑得快的原因之一。

源码生成器还配了 Roslyn 分析器:**23 条核心规则(EECS002–024)**和 10 条性能规则(EECS025–034),部分问题可以一键修复。Native AOT 也受支持。各项 API 和开销见功能页与分析器规则。

“0 B”有前提:创建 World、遇到新的组件组合、第一次使用某种查询,以及容量不足触发扩容时仍会分配;热路径要先预热或预留容量。FAQ写了这些边界。EasyEcs 面向 .NET 10 的游戏服务端、仿真和长期运行的实时后端,不提供网络传输,也不支持 Unity。

想判断它是否适合自己的项目,可以先跑在线 Demo,再按入门教程写一个系统;做服务端可以直接看 30 Hz GameLoop 示例。基准源码也公开,欢迎用自己的负载复测并反馈结果。