实现邻域搜索并利用稀疏网格节约显存

大家好,在此分享一个邻域搜索的实现。邻域搜索常常用于SPH等粒子法中,是一种比较常见的需求。

另外,这也是一个很好的使用太极的稀疏数据结构的例子。在这个例子中你将会看到:使用稀疏数据结构并不复杂,只需要更改5行代码即可。使用时和原本稠密的field是完全一样的,不需要做任何的修改。

下面是代码的分享:

chunleili/neighborhoodSearch (github.com)

使用方法

neighborhood_search.py是类的实现,而main.py是可以运行的测试。

运行main.py将会产生neighbors.txt 和 num_neighbors.txt两个文件。分别代表每个粒子的邻居的编号和邻居的数量。

  1. 建立类的时候需要传入三个参数:

例如


ns = NeighborhoodSearchSparse(positions, particle_max_num, 0.04, domain_size)

  • positions是粒子的位置的三维的taichi field.

  • particle_max_num 是粒子的最大数量

  • 0.04 是邻域的半径,通常与SPH的核函数的支持半径相同。

  • domain_size 是模拟区域的大小,是一个三维的numpy array,例如np.array([10.0, 10.0, 10.0])。

  1. 使用run_search方法进行搜索

ns.run_search()

  1. 搜索结果将保存在neighbors和num_neighbors这两个变量中
  • num_neighbors[i]代表第i个粒子的邻居的数量。

  • neighbors[i, k]代表第i个粒子的第k个邻居的编号。

利用taichi稀疏数据结构节约显存

读者可能已经注意到,目前的类名是NeighborhoodSearchSparse,其中Sparse代表稀疏网格。该特殊的数据结构在网格数量较多的时候会起作用。应当注意:假如网格数量不多,使用稀疏网格反而会增加显存占用。

与正常的taichi field 相比,我们只在grid_particles_num和particles_in_grid这两个变量上做了新的改动。

改动如下

原稠密网格


self.grid_particles_num = ti.field(int, shape=(self.grid_num))

self.particles_in_grid = ti.field(int, shape=(*self.grid_num, self.max_num_particles_in_grid))

稀疏网格


self.particles_in_grid = ti.field(int)

self.grid_particles_num = ti.field(int)

self.grid_snode = ti.root.bitmasked(ti.ijk, self.grid_num)

self.grid_snode.place(self.grid_particles_num)

self.grid_snode.bitmasked(ti.l, self.max_num_particles_in_grid).place(self.particles_in_grid)

我们只需要在__init__函数中替换上面的代码即可使用稀疏网格。

另外,为了展示网格到底有多稀疏(实际被占用的比例),我增加了一个简单的函数grid_usage。该函数只是遍历所有网格,然后统计。注意:必须使用struct-for 遍历才能保持稀疏性,range for会破坏稀疏性。

那么,内存的节约效果到底有多好呢?我们可以做一个简单的测试进行对比。我们通过改动ti.init(arch=ti.gpu, device_memory_GB=xxx)来进行测试。显存不足时会发生报错。以此来测试显存占用。

main.py中的test_small对应的是区域大小为1.0的小区域,这时候我们会发现0.15GB的显存足以应对稠密网格,而不足以应对稀疏网格。这是因为稀疏数据结构带来了额外的开销。这时候使用稀疏数据结构反而是得不偿失的。

main.py中的test_large对应的是区域大小为10.0的大区域,这时候我们会发现3.3GB的显存足以应对稀疏网格,而不足以应对稠密网格。这时候就体现了稀疏网格的优越性。

最后我们需要指出:该程序中内存占用最多的变量实际上就是网格相关的变量。因为三维情况下网格的数量通常远超粒子的数量。所以我们才仅仅考虑优化网格相关的变量,而不必考虑其他变量的优化。

邻域搜索的原理

邻域搜索是借助网格来进行空间搜索的。它的原理有些类似桶排序(但它不排序)。核心哲学就是一句话:一个粒子的邻居仅可能在其相邻的空间区域内。

因此,我们首先找到粒子所在的网格的3x3的stencil,然后在该stencil内逐个比较距离即可。

为此,我们先要知道每个粒子在哪一个网格之中。这个容易,只需要用粒子的位置整除网格的大小即可。为了后续的使用,我们必须要有这样一个变量:particles_in_grid。顾名思义,particles_in_grid代表某个网格里有哪些粒子。例如particles_in_grid[grid_index, k]即第grid_index个网格里的第k个粒子的编号。当然,我们还需要一个变量记忆网格内有几个粒子,这就不再赘述。这里grid_index可以使用三维下标,也可以使用展开后的一维下标。本程序里我们使用的是三维下标。上述过程在update_grid函数中实现。

必须要特别注意的是:由于我们在编写并行的程序,所以必须要考虑读写冲突的问题。多个粒子可能会同时写入到同一个网格当中。所以下面这种写法几乎是唯一正确的写法。


k = ti.atomic_add(self.grid_particles_num[grid_index], 1)

self.particles_in_grid[grid_index, k] = i

然后,我们进行真正的搜索,将搜索结果保存在neighbors和num_neighbors这两个变量中。这个过程在store_neighbors函数中实现。这里同样要注意读写冲突的问题,多个粒子可能会同时写入到同一个粒子的邻居当中。所以下面这种写法几乎是唯一正确的写法。


kk = ti.atomic_add(self.num_neighbors[p_i], 1)

self.neighbors[p_i, kk] = p_j

还需要额外注意的一点是使用stencil时必须考虑超出边界的问题。例如在边界0处的网格的stencil会出现网格下标为-1。is_in_grid函数就是为了防止超出边界的。

另外,在执行store_neighbors和update_grid之前,注意要清空已经使用了的数据结构。

上面所述的所有过程,实际上都是在run_search函数中完成的。

读者若有兴趣,还可以思考借助排序来进行邻域搜索。这样有两点好处:

1)无需particles_in_grid等占用大量显存的变量,因为排序后的粒子本身就是按照空间位置顺序来的。甚至无需存储neighbors这个变量。

2)由于排序后的数组是按照空间位置连续的,而使用邻域搜索的大多数操作都是基于近邻的。所以对于内存读写速度有一定的提升,因而性能上有所增益。

但是,这样做也是有代价的。由于排序会打乱原数组的顺序,所以后续假如有基于数组顺序的操作,就会变得麻烦。或许可以考虑额外存储particle_id来记忆原粒子顺序,并随之排序,然后在需要粒子原下标时取出particle_id。

2 个赞

当然,spatial hasing可以绕过网格的限制。但是几乎必然会导致哈希冲突。不过在邻域搜索这个任务中哈希冲突不会造成什么大的问题。因为总会进行距离的判断来保证真正的近邻。

但是,除了太极稀疏网格,空间哈希之外,还有另外一种节约显存的方法。这是我个人想到的,我还没有看到别人有使用过。即将粒子的位置normalize。也就是总是先找到粒子位置的最大最小值,然后用粒子位置除以范围得到0-1的位置。最后总是划分固定的网格大小,比如100100100。这就是借鉴了bucket sort。当粒子分布越平均的时候,效率会越高。当然坏处是网格大小已经不再能人为控制,选择stencil大小时可能会有一些困难。

1 个赞