ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

删一个元素,EasyECS 为什么要搬整片内存?

2026/9/2 5:09:57 拓冰建站 浏览量
删一个元素,EasyECS 为什么要搬整片内存? 写 SoA 最舒服的时候是读数据。比如hp[i] - damage;连续、直接、Cache 友好。但当我准备让ECSList真正支持list.RemoveAt(index);的时候SoA 开始露出另一面。因为删除一个元素不再是删除“一块 Struct”。而是所有属于这个元素的数据都必须在不同的存储区域里同时消失。这时候我才真正感觉到写一个快的 SoA 不难把 SoA 做成一个能像 List 一样用的容器才难。普通 List 删除其实很简单先看ListRoleData假设数据是Role0 Role1 Role2 Role3 Role4执行list.RemoveAt(1);为了保持顺序后面的数据整体往前移动Role0 Role2 Role3 Role4底层本质上是一整片连续的RoleData RoleData RoleData RoleData...所以搬的是一块完整 Struct 数据。SoA 就完全不一样了EasyECS 当前 Benchmark 使用的RoleData是[ECS] public struct RoleData { public int mHP; public float mSpeed; public float mPositionX; public float mPositionY; [NotECS] public int mID; [NotECS] public int mModelID; [NotECS] public int mCamp; }它并不是 7 个字段全部拆成 7 个数组。当前布局更接近mHP[] ┐ mSpeed[] │ mPositionX[] ├─ SoA mPositionY[] ┘ mAoS[] { ID ModelID Camp }也就是说删除一条 RoleData 时实际至少要同步处理HP Column Speed Column PositionX Column PositionY Column AoS Block5 份独立数据。如果以后一个 Struct 有十几个 SoA 字段那么需要同步移动的 Column 还会更多。删除 index1到底发生了什么原来HP: 100 200 300 400 Speed: 1 2 3 4 PosX: 10 20 30 40 AoS: A B C D删除第二个list.RemoveAt(1);不能只移动 HP。必须同时变成HP: 100 300 400 Speed: 1 3 4 PosX: 10 30 40 AoS: A C D否则下一次RoleDataRef role list[1];拿到的可能是HP → Role2 Speed → Role1 Position → Role3 ID → Role2整个数据已经彻底错位。所以 SoA 有一个非常重要的规则不同 Column 的 index本质上共同组成一个逻辑对象。只要结构发生变化所有 Column 必须保持完全同步。所以 RemoveAt 是 O(n)要保持顺序0 1 2 3 4 5删除2后面的3 4 5都要向前移动。普通 List 搬一整块。EasyECS 则需要让每一个 Storage 都执行同样的移动。所以RemoveAt(index)天然是O(n)这一点 EasyECS 没办法通过 SoA 魔法消掉。我专门给它做了结构操作 BenchmarkEasyECS 1.1.0 的结构测试不是几十条数据随便跑一下。测试参数是BaseEntityCount 20000 OperationCount 256 SampleCount 9 WarmupCount 2然后连续做 256 次删除。中间删除的结果很有意思。Unsafe BackendListRoleData 12.944 ms 50.561 us/op EasyECS 4.074 ms 15.914 us/opEasyECS 只用了 List 的0.315x也就是这个测试里大约快了3.18 倍。删除头部也是类似List 26.147 ms EasyECS 8.170 ms约3.2 倍。明明要搬多份数据为什么反而更快这也是这组结果最有意思的地方。直觉上List → 搬一块 RoleData而EasyECS → 搬多个 ColumnEasyECS 应该更吃亏才对。但RoleData中实际上包含int float float float int int int普通ListRoleData删除时后方所有完整 Struct 都需要整体移动。而 SoA 可以分别处理紧凑的int[] float[] float[] float[]以及一个较小的 AoS Block。在当前 Unsafe 路径下最终反而表现得非常好。这也说明一个很容易被忽略的问题SoA 的优势不仅出现在“读取某一列”结构移动时数据布局一样会产生影响。但换成 SafeSpan故事马上又不一样了SafeSpan 的结果List 12.977 ms EasyECS 12.883 ms比例0.993x基本一样。为什么因为不同 Backend 的结构移动实现并不完全相同。当前 1.1.0Unsafe Insert → Buffer.MemoryCopy Unsafe RemoveAt Native → Forward Loop Managed 部分 → Array.Copy SafeSpan → Array.Copy所以不能简单地说EasyECS 的 RemoveAt 一定比 List 快 3 倍。真正准确的说法应该是当前 Unsafe Backend 在这组结构操作测试中表现出了明显优势而 SafeSpan 基本与 List 持平。这才是 Benchmark 应该告诉我们的东西。Managed 字段进来以后成本又上去了我还专门测了 Hybrid 数据[ECS] public struct ManagedRoleData { public int mHP; public string mName; public object mPayload; [NotECS] public int mID; [NotECS] public string mPath; }RemoveAt 中间位置UnsafeList 18.349 ms EasyECS 15.448 msSafeSpanList 18.528 ms EasyECS 16.649 ms优势还在但已经明显缩小。原因也很好理解。现在不只是移动 Native 数据。还要处理string[] object[] Managed AoS而且删除后尾部引用还必须清空。否则逻辑上已经删除的对象可能仍然被数组引用着无法被 GC 回收。RemoveAt 还会让 Ref 变得麻烦假设var ref2 list[2]; var ref8 list[8];然后list.RemoveAt(5);ref2指向的位置没有变化。但是原来的index 8现在已经变成index 7所以从删除位置开始后面的 Ref 都受到影响。EasyECS 在 Editor 下还专门做了invalidateRefsFrom(index) invalidateColumn()用于尽早发现这种生命周期错误。所以一个看起来很简单的RemoveAt(index)背后实际上同时涉及多 Column 数据移动 AoS 数据移动 Managed 引用清理 Count 修改 Ref 失效 Direct Column 失效 边界检查这才是一个真正可用的 SoA 容器必须处理的东西。然后我突然意识到为什么一定要保持顺序做到这里一个非常自然的问题出现了。假设我有20000 个怪物我要删除第 10000 个。为了保持10001 10002 10003 ...原来的顺序我要把后面接近一万条数据全部往前搬。但对于怪物 子弹 Buff 粒子这种数据来说顺序真的重要吗如果不重要我完全可以把最后一个元素直接塞到被删除的位置。从A B C D E删除 B直接变成A E C D只移动一个元素。复杂度瞬间从O(n)变成O(1)于是 EasyECS 有了另一个 APIRemoveAtSwapBack(index);而它的 Benchmark 数据更夸张。同样是中间删除RemoveAt 4.093 ms RemoveAtSwapBack 0.001 ms下一篇就专门讲这个。O(n) 删除太贵我给 EasyECS 加了一个不讲武德的 RemoveAtSwapBack它牺牲的只有一件东西顺序。但换来的性能差距可能比前面所有 SoA 优化都更夸张。项目地址EasyECS 是 MyFramework 中的独立 Unity Package。MyFrameworkhttps://github.com/ZHOURUIH/MyFrameworkEasyECS UPMhttps://github.com/ZHOURUIH/MyFramework.git?path/Packages/com.zhourui.easyecs