使用SFML的OpenMP与生活游戏可视化

OpenMP with Game of Life visualization using SFML

本文关键字:生活 游戏 可视化 OpenMP SFML 使用      更新时间:2024-03-29

你好,我正在尝试比较"生命游戏"的串行和并行版本之间的速度。我使用SFML库来可视化这样的生活游戏。SFML窗口串行逻辑如下所示。

for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
int neighbor = 0;
// check 8 cells around.
// 1 2 3  -1
// 4   5  0
// 6 7 8  +1
// (1)
if (gamefieldSerial.isAvailableCell(UP(i), LEFT(j))) {
if(gamefieldSerial[UP(i)][LEFT(j)] == LIVE) neighbor++;
}
// (2)
if (gamefieldSerial.isAvailableCell(UP(i), j)) {
if (gamefieldSerial[UP(i)][j] == LIVE)      neighbor++;
}
// (3)
if (gamefieldSerial.isAvailableCell(UP(i), RIGHT(j))) {
if (gamefieldSerial[UP(i)][RIGHT(j)] == LIVE)   neighbor++;
}
// (4)
if (gamefieldSerial.isAvailableCell(i, LEFT(j))) {
if (gamefieldSerial[i][LEFT(j)] == LIVE)        neighbor++;
}
// (5)
if (gamefieldSerial.isAvailableCell(i, RIGHT(j))) {
if (gamefieldSerial[i][RIGHT(j)] == LIVE)       neighbor++;
}
// (6)
if (gamefieldSerial.isAvailableCell(DOWN(i), LEFT(j))) {
if (gamefieldSerial[DOWN(i)][LEFT(j)] == LIVE)  neighbor++;
}
// (7)
if (gamefieldSerial.isAvailableCell(DOWN(i), j)) {
if (gamefieldSerial[DOWN(i)][j] == LIVE)        neighbor++;
}
// (8)
if (gamefieldSerial.isAvailableCell(DOWN(i), RIGHT(j))) {
if (gamefieldSerial[DOWN(i)][RIGHT(j)] == LIVE) neighbor++;
}
// -- Rule of Game of Life
// Cell borns when exactly 3 neighbor is LIVE
// Cell remains alive when 2 or 3 neighbor is LIVE
// Cell with more than 3 neighbor dies with overpopulation
// Cell with less than 2 neighbor dies with underpopulation
if (gamefieldSerial[i][j] == DEAD) {
if (neighbor == 3) {
gamefieldSerial[i][j] = LIVE;
}
}
else if (gamefieldSerial[i][j] == LIVE) {
if (neighbor < 2 || neighbor > 3) {
gamefieldSerial[i][j] = DEAD;
}
}
}

在768*256个细胞上,用时3940ms,共100代。但在并行版本中,我实现了如下

#pragma omp parallel for num_threads(4)
for (int t = 0; t < width * height; t++) {
int i = t / width;
int j = t % width;
int neighbor = 0;
// check 8 cells around.
// 1 2 3  -1
// 4   5  0
// 6 7 8  +1
// (1)
if (gamefieldParallel.isAvailableCell(UP(i), LEFT(j))) {
if (gamefieldParallel[UP(i)][LEFT(j)] == LIVE) neighbor++;
}
// (2)
if (gamefieldParallel.isAvailableCell(UP(i), j)) {
if (gamefieldParallel[UP(i)][j] == LIVE)      neighbor++;
}
// (3)
if (gamefieldParallel.isAvailableCell(UP(i), RIGHT(j))) {
if (gamefieldParallel[UP(i)][RIGHT(j)] == LIVE)   neighbor++;
}
// (4)
if (gamefieldParallel.isAvailableCell(i, LEFT(j))) {
if (gamefieldParallel[i][LEFT(j)] == LIVE)        neighbor++;
}
// (5)
if (gamefieldParallel.isAvailableCell(i, RIGHT(j))) {
if (gamefieldParallel[i][RIGHT(j)] == LIVE)       neighbor++;
}
// (6)
if (gamefieldParallel.isAvailableCell(DOWN(i), LEFT(j))) {
if (gamefieldParallel[DOWN(i)][LEFT(j)] == LIVE)  neighbor++;
}
// (7)
if (gamefieldParallel.isAvailableCell(DOWN(i), j)) {
if (gamefieldParallel[DOWN(i)][j] == LIVE)        neighbor++;
}
// (8)
if (gamefieldParallel.isAvailableCell(DOWN(i), RIGHT(j))) {
if (gamefieldParallel[DOWN(i)][RIGHT(j)] == LIVE) neighbor++;
}
// -- Rule of Game of Life
// Cell borns when exactly 3 neighbor is LIVE
// Cell remains alive when 2 or 3 neighbor is LIVE
// Cell with more than 3 neighbor dies with overpopulation
// Cell with less than 2 neighbor dies with underpopulation
if (gamefieldParallel[i][j] == DEAD) {
if (neighbor == 3) {
gamefieldParallel[i][j] = LIVE;
}
}
else if (gamefieldParallel[i][j] == LIVE) {
if (neighbor < 2 || neighbor > 3) {
gamefieldParallel[i][j] = DEAD;
}
}
}

在相同的环境下耗时5746ms。我认为在for循环中应用openMP的"for"指令可以提高性能,但事实并非如此。我应该换一种方式吗?

=============GameField Parallel和GameField Serial都是GameField类的实例,该类为单元格动态分配了int**字段变量。我使用运算符重载来访问它,就像二维数组一样。(抱歉英语不好!)

GoL是OpenMP并行化的完美样本,因为它是令人尴尬的并行-计算当前一代中的单元值不取决于相邻单元的计算。这里的问题是,您正在读取和写入同一个数组,从实现的角度来看,这是错误的。在您的顺序代码中,这只会导致错误计算的单元状态,但同时您会遇到错误共享等问题,这些问题会大大降低程序的速度。此外,您已经用一个嵌套循环替换了两个嵌套循环,并使用模运算来计算行和列索引,这可能是速度减慢的最大原因。速度减慢的另一个原因是在内部循环中有一个并行区域——你要为每一代激活该区域中的线程付出代价。

您需要使用两个数组——一个用于上一代,一个用于当前一代。从前一个数组中读取并写入后一个数组。完成后,交换阵列并重复。在带有OpenMP的伪C++中,解决方案如下所示:

#pragma omp parallel
{
// Generations loop (1)
for (int gen = 0; gen < NUM_GENERATIONS; gen++) {
// Compute the new current generation (2)
#pragma omp for
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
// Count the number of live neighbours of current[i][j] (3)
int neighbours = count_neighbours(current, i, j);

// Update the state of the current cell (4)
if (current[i][j] == DEAD && neighbours == 3)
next[i][j] = LIVE;
else if (current[i][j] == LIVE)
next[i][j] = (neighbours < 2 || neighbours > 3) ? DEAD : LIVE;
}
}
// The following block runs in the master thread (5)
#pragma omp master
{
// Swap the current and next arrays
std::swap(current, next);
// Display the board state (if necessary)
display(current);
}
// Synchronise the threads before the next iteration (6)
#pragma omp barrier
}
}

注意事项(数字与代码注释中的数字相对应):

  1. 外部(代)循环位于平行区域内。这消除了在每次迭代中激活和停用区域的开销。

  2. 工作共享构造for应用于在板的各行上运行的回路。这足以使问题最佳地平行化。如果您可以确保width乘以sizeof,则next中元素的类型是64字节(大多数CPU上的缓存行大小)的倍数,则将消除错误共享的可能性。

  3. 计算邻居的数量涉及current阵列中的值。

  4. 新值进入next数组。

  5. 一旦下一代被完全计算出来,我们需要交换数组,并使next成为下一代循环迭代的current。这应该由单个线程完成,在这种情况下,这个负担落在了主线程上。请注意,如果currentnext都是指向实际数组的指针,则此交换最有效。将数组值元素交换为元素的过程很慢。交换指向这些数组的两个指针是非常快的。使用主线程还可以进行GUI调用,例如display()(假设这是将板绘制到屏幕上的函数)。

  6. master构造在退出时没有隐式屏障,因此我们需要显式同步线程,否则一些线程可能会在我们交换数组之前开始进行下一次迭代。

如果不显示中间板状态,则显示以下代码:

// The following block runs in the master thread (5)
#pragma omp master
{
// Swap the current and next arrays
std::swap(current, next);
// Display the board state (if necessary)
display(current);
}
// Synchronise the threads before the next iteration (6)
#pragma omp barrier

可以替换为:

#pragma omp single
std::swap(current, next);

single构造在出口处有一个隐式屏障,因此不需要添加显式屏障。


我将为您提供另一个关于加快计算的主动建议。拥有所有这些条件

if (gamefield.isAvailableCell(UP(i), LEFT(j))) {
...
}

降低代码的速度,因为如果没有条件,现代CPU会做得更好。此代码仅用于捕捉模拟阵列边界处的单元。因此,与其检查每个单元格在给定方向上是否有邻居,不如简单地使板上的两个单元格更宽(一个在行的开头,一个在末尾),两个单元格高(一行在顶部,一行在底部),并保持多余的单元格为空(DEAD)。那么isAvailableCell()将始终是true,并且可以去掉条件句。只需记住运行从1width/height的循环。

在串行版本和并行版本中,您都在迭代游戏字段时更新游戏字段。这是两个版本的错误。假设您计算gameField[0][0]的新状态,然后更新它。现在转到gameField[0][1]——作为计算其新状态的一部分,您向左看gameField[0][0],它已经包含该单元格的新更新状态,但生命游戏规则必须应用于第一个单元格的状态。

换句话说,您应该有一个只读(const)oldGameField,然后将新状态填充到newGameField中。计算完所有单元格后,可以将新字段用作下一次游戏迭代的旧字段。

修复这个错误实际上是解决性能问题的一个重要部分。

多线程

与其考虑由4个处理器进行这些更新,不如想象由4个人用铅笔和纸进行更新。因为您现在将把oldGameField视为只读,所以可以安全地复印旧页面并给每个人一份。每个人都知道没有人会更改旧页面,所以他们不必担心自己的副本会过时。

但是newGameField只有一页。在你的串行方法中,只有一个人,他拥有自己独有的页面和铅笔。现在有四个人试图在同一时间在同一页上画画。他们互相递铅笔和纸的时间比他们花在计算上的时间还要多!实际上,四个人做这项工作的时间比一个人单独做的时间要长。

这并不意味着要准确地表示硬件内部的情况,但当我们考虑OpenMP可能使用的任何锁定和/或原子,以及处理器核心中的内存缓存时,这是非常接近的。

那么我们该如何解决呢

你和你的三个朋友可以决定每个人更新四分之一的字段。也许你占据了整个董事会的前四分之一。下一个人拿下第二节,以此类推。你们每个人都有自己的一张纸,只画出新州的四分之一,而不是为了一张纸来画新的游戏场。

完成后,您可以快速将四张纸粘贴在一起,形成新的游戏区域页面。

这里的重点是确保每个线程都在从没有人更改的内存中读取,并且每个线程只向没有其他线程访问的内存写入。这意味着内核不会用内存写入来相互阻塞,当它们看到其他内核写入共享内存时,也不必刷新它们的缓存。

确保每个线程的新游戏场内存不够近,不会对其他线程使用的内存造成干扰,这很棘手。你通常需要知道一些关于核心中缓存行大小的信息,无论你的平台是否使用了"NUMA"等。

我不知道OpenMP,但也许它有一些内置的支持来帮助解决这个问题。

总结

  • 将旧状态与新状态分离
  • 您的线程都可以愉快地共享旧状态(因为在迭代过程中没有人更改它)
  • 将工作分成块-每个线程一个
  • 给每个线程自己的一块内存来存储其结果
  • 一旦所有线程都完成了,让你的主线程把它们的所有结果放在一个组合的新游戏场中