本文分析了开源项目EnTT v3.12.2的原理和实现。述说了ECS中的核心数据结构sparse set。
sparse set
结构介绍
sparse set是一个数据结构,用于极快速地对正整数进行增删改查并能够较好地利用CPU Cache。而EnTT中的Entity
部分正式用正整数实现的。
sparse set由两部分组成:
packed
:一个线性表,用于紧密存储所有正整数。也是真正保存所有数据的地方。sparse
:一个线性表,比较稀疏,用于存储packed
中整数在packed
数组中的下标,主要是为了建立值和下标的映射以加快查找。
需要注意的是,packed
和sparse
必须是内存连续的线性表,这样才能发挥它易命中Cache的优势。
基本操作
增加
增加元素的操作如下,对于任意正整数A
- 将A无条件放在
packed
数组末尾 - 得到A在
packed
数组中的下标I(其实就是packed
数组的长度-1) - 将I放在
sparse
数组的,以A为下标的位置处
下面用一个例子说明:
|
|
查询
很简单:对于任意元素A,看sparse[A]
处是否有值即可。
如果有值,还可以通过sparse[A]
得到A元素在packed
数组中的下标。
删除
删除的话也很简单,对于任意元素A:
- 通过
sparse[A]
得到A在packed
中的下标I - 将
packed[A]
和packed
最后一位互换,记packed
最后一位元素为L - 更新L在
sparse
中的索引:sparse[L] = I
- 置
sparse[A]
为空删除其索引 - 弹出
packed
末尾元素(就是之前换到末尾去的A)
迭代元素
packed
数组中紧密存储着所有元素,所以直接迭代packed
数组就行了(EnTT的迭代器sparse_set_iterator
就是直接拿到packed
数组的迭代器进行迭代)。
顺便说一句,sparse set是无序容器。
和HashMap的对比
sparse set的增删查复杂度都是O(1),而HashMap也是。但HashMap的效率总体来说不如sparse set高。因为sparse set总能在确定步数内完成操作,而HashMap因为冲突的问题,可能需要多次使用散列函数,真正的步骤是不确定的。而如果使用拉链法解决冲突,则更会导致难以命中Cache的问题。
sparse set的缺点就是只能对正整数进行操作。
源码分析
源码位于src/entity/sparse_set.hpp
中。
分页的sparse数组
这个类是个模板类:
|
|
Entity
模板参数是EnTT中实体的类型。Allocator
是内存分配器。
需要注意的是,basic_sparse_set
的sparse
数组不是一维数组,是二维的(用的时候其实还是视为一位数组,会将二维摊开成一维),差不多是std::vector<Entity[PageSize]>
这个类型。本质上是将一位数组分为多个“页”(Page),每个页大小就是PageSize
最终的页大小在src/entt/config/config.h
中有定义:
|
|
分页的原因应该是考虑到CPU的分页机制,当内存过大时方便CPU按照这个大小换页。也有可能是为了方便内存分配器Allocator
一次分配这么多内存。
所以当你插入一个元素A的时候,他会把下标放在sparse[A / PageSize][A % PageSize]
处。
成员变量和一些using
成员变量和其using如下:
|
|
sparse_container_type
和packed_container_type
是std::vector
这在意料之中,但是sparse_container_type
的成员有些不明朗,是Allocator
分配出的指针类型。这个类型在src/entt/entity/fwd.hpp
中有说明:
|
|
就是使用的标准库的allocator
,旨在分配一个Entity
。但实际的sparse_container_type
中则是分配的alloc_traits::pointer
,即Entity*
,并且使用rebind_alloc
将此allocator
重绑定以让其分配Entity*
(对std::allocator
不熟悉可以看这个文章)。
某些函数简述
内部函数的话我觉得没什么好说的,毕竟算法已经说明白了,代码也就是实现的事。简单说一下我比较感兴趣的函数吧:
[[nodiscard]] auto sparse_ref(const Entity entt) const
:得到sparse[entt / PageSize][entt % PageSize]
元素的引用[[nodiscard]] auto sparse_ptr(const Entity entt) const
:得到sparse[entt / PageSize][entt % PageSize]
元素的指针。没有这个元素返回nullptr
[[nodiscard]] auto &assure_at_least(const Entity entt)
:保证当前sparse
数组可以容纳entt
(会自动扩容),并返回容纳entt
的那个元素。这个函数是个很好的辅助函数,因为你可以在任何插入entt
的地方使用assure_at_least(entt) = entt
插入entt倒sparse
中,不需要进行很多if判断template<typename Compare, typename Sort = std_sort, typename... Args> void sort_n(const size_type length, Compare compare, Sort algo = Sort{}, Args &&...args)
:对开头的length个元素排序。EnTT是允许对sparse set排序的,这样遍历的时候会有一个顺序,在某些场景比较有用。