有效分离读取/计算/写入步骤,以便在实体/组件系统中对实体进行并发处理


11

设定

我有一个实体组件体系结构,其中实体可以具有一组属性(它们是纯数据,没有任何行为),并且存在运行运行于该数据上的实体逻辑的系统。本质上,用某种伪代码:

Entity
{
    id;
    map<id_type, Attribute> attributes;
}

System
{
    update();
    vector<Entity> entities;
}

只是以恒定的速率沿所有实体移动的系统可能是

MovementSystem extends System
{
   update()
   {
      for each entity in entities
        position = entity.attributes["position"];
        position += vec3(1,1,1);
   }
}

本质上,我正在尝试尽可能高效地并行化update()。这可以通过并行运行整个系统来完成,也可以通过为一个系统的每个update()提供几个组件来实现,以便不同的线程可以执行同一系统的更新,但对于在该系统中注册的实体的不同子集而言。

问题

对于所示的MovementSystem,并行化是微不足道的。由于实体彼此不依赖,并且不修改共享数据,因此我们可以并行移动所有实体。

但是,这些系统有时要求实体彼此交互(从/向彼此读/写数据),有时在同一系统内,但经常在彼此依赖的不同系统之间。

例如,在物理系统中,有时实体可能会彼此交互。两个对象发生碰撞,它们的位置,速度和其他属性会从它们读取,更新,然后将更新后的属性写回到两个实体。

并且在引擎中的渲染系统可以开始渲染实体之前,它必须等待其他系统完成执行,以确保所有相关属性都是它们所需要的。

如果我们试图盲目地并行化它,这将导致经典的竞争条件,其中不同的系统可能同时读取和修改数据。

理想情况下,将存在一种解决方案,其中所有系统都可以从其希望的任何实体读取数据,而不必担心其他系统同时修改相同的数据,而无需程序员关心正确地排序执行和并行化。这些系统是手动的(有时甚至是不可能的)。

在基本实现中,这可以通过将所有数据读写放在关键部分中(用互斥锁保护它们)来实现。但是,这会导致大量的运行时开销,并且可能不适用于对性能敏感的应用程序。

解?

在我看来,可能的解决方案是将数据的读取/更新和写入分开的系统,以便在一个昂贵的阶段,系统仅读取数据并计算其需要计算的内容,以某种方式缓存结果,然后将所有内容写入将更改后的数据通过单独的写入传递回目标实体。所有系统都将以数据在帧开始时的状态对数据进行操作,然后在帧结束之前,在所有系统完成更新后,会发生序列化写入过程,其中所有不同的缓存结果系统遍历并写回到目标实体。

这是基于(可能错了?)的思想,即简单的并行化胜利可能足以超过结果缓存和写入过程的成本(在运行时性能以及代码开销方面)。

问题

如何实现这样的系统以获得最佳性能?这样的系统的实现细节是什么?要使用此解决方案的实体组件系统的先决条件是什么?

Answers:


1

-----(根据修改后的问题)

第一点:由于您没有提到已经分析了发行版构建运行时并发现了特定需求,因此建议您尽快进行。您的个人资料是什么样的,您是要使用不良的内存布局来破坏高速缓存,还是将一个核心固定为100%,相对于您的ECS与引擎的其余部分花费了多少相对时间,等等。

从实体中读取内容并进行计算...并将结果保留在中间存储区中的某个位置,直到稍后?我认为您无法以您认为的方式将读取+计算+存储区分开,并且期望此中间存储区不是纯粹的开销。

另外,由于您要进行连续处理,因此要遵循的主要规则是每个CPU内核只有一个线程。我认为您是在错误的层面查看此内容,请尝试查看整个系统而不是单个实体。

在您的系统之间创建一个依赖关系图,这是早期系统工作的结果。一旦有了该依赖关系树,就可以轻松地将充满实体的整个系统发送出去,以在线程上进行处理

因此,假设您的依赖关系树是一堆乱七八糟的东西和陷阱,这是一个设计问题,但是我们必须使用已有的东西。最好的情况是,在每个系统内部,每个实体都不依赖于该系统内部的任何其他结果。在这里,您可以轻松地在两个线程上细分线程(0-99和100-199)之间的处理,例如,该系统拥有两个内核和200个实体。

无论哪种情况,在每个阶段都必须等待下一个阶段所依赖的结果。但这是可以的,因为等待批量处理十个大数据块的结果远胜于为小块同步一千次。

建立依赖关系图的想法是通过自动化它来平化“查找和组装其他系统以并行运行”的看似不可能的任务。如果这样的图形显示出不断地等待之前的结果而被阻塞的迹象,那么创建读取+修改和延迟写入只会移动阻塞,并且不会消除处理的串行性质。

串行处理只能在每个序列点之间并行进行,而不能整体进行。但是您意识到了这一点,因为它是问题的核心。即使您从尚未写入的数据缓存读取,您仍然需要等待该缓存变得可用。

如果创建并行架构很容易甚至有这些限制,那么自Bletchley Park以来,计算机科学就不会在这个问题上苦苦挣扎。

唯一真正的解决方案是最小化所有这些依赖性,以使序列点尽可能少地需要。这可能涉及将系统细分为顺序的处理步骤,在这些步骤中,在每个子系统内部,与线程并行变得无关紧要。

最好的解决方法是我,我的建议不过是建议:如果撞到砖墙上撞到头会受伤,然后将其分成较小的砖墙,这样就只会撞到小腿。


很抱歉告诉您,但是这个答案似乎没有用。您只是在告诉我我正在寻找的东西不存在,这在逻辑上似乎是错误的(至少在原则上是错误的),并且还因为我之前已经看到人们在多个地方提到这样的系统(没人能给予足够的支持)但是,这是提出这个问题的主要动机)。虽然,在最初的问题中我可能不够详细,这就是为什么我要对其进行广泛地更新(并且如果我的想法绊倒的话,我将继续对其进行更新)。
TravisG

也没有意图冒犯:P
TravisG

@TravisG正如Patrick指出的那样,通常有一些系统依赖于其他系统。为了避免帧延迟或作为逻辑步骤的一部分避免多次更新,公认的解决方案是对更新阶段进行序列化,在可能的情况下并行运行子系统,对具有依赖性的子系统进行序列化,同时在每个内部批量处理较小的更新子系统使用parallel_for()概念。它是子系统更新通道需求和最灵活的任意组合的理想选择。
Naros 2013年

0

我听说过一个有趣的解决方案:这个想法是将有2个实体数据副本(我知道这很浪费)。一个副本将是当前副本,另一个副本将是过去副本。当前副本严格只写,而过去副本严格只读。我假设系统不想写入相同的数据元素,但是如果不是这样,则这些系统应该在同一线程上。每个线程将具有对数据互斥部分的当前副本的写访问权,并且每个线程都具有对数据的所有过去副本的读访问权,因此可以使用过去副本中的数据更新当前副本,而无需锁定。在每帧之间,当前副本变为过去副本,但是您要处理角色交换。

此方法还消除了竞争条件,因为所有系统都将在陈旧状态下工作,该状态在系统对其进行处理之前/之后不会更改。


那是约翰·卡马克(John Carmack)的堆复制技巧,不是吗?我对此很纳闷,但是它仍然存在相同的问题,即多个线程可能会写入同一输出位置。如果将所有内容都“单次通过”,这可能是一个很好的解决方案,但是我不确定那是多么可行。
TravisG

屏幕显示等待时间的输入将增加1帧时间,包括GUI反应性。这可能对动作/定时游戏或像RTS这样的繁重GUI操作很重要。但是,我喜欢将其作为创意。
Patrick Hughes

我从一个朋友那里听说过此事,但不知道这是一个Carmack的把戏。根据渲染的完成方式,组件的渲染可能落后一帧。您可以将其用于更新阶段,然后在所有内容均为最新时从当前副本进行渲染。
约翰·麦克唐纳

0

我知道处理数据并行处理的3种软件设计:

  1. 顺序处理数据:由于我们想使用多个线程来处理数据,这听起来可能有些奇怪。但是,大多数情况下只需要多个线程即可完成工作,而其他线程则需要等待或执行长时间运行的操作。UI线程最常见的用法是在单个线程中更新用户界面,而其他线程可能在后台运行,但不允许直接访问UI元素。为了传递来自后台线程的结果,将使用作业队列,该作业队列将在下一个合理时机由单个线程处理。
  2. 同步数据访问:这是处理访问同一数据的多个线程的最常用方法。大多数编程语言都内置有类和工具,以锁定多个线程同时读取和/或写入数据的部分。但是,应注意不要阻塞操作。另一方面,这种方法在实时应用程序中会花费很多开销。
  3. 仅在并发修改发生时才进行处理:如果冲突很少发生,则可以采用这种乐观方法。如果根本没有多路访问,将读取和修改数据,但是有一种机制可以检测何时同时更新数据。如果发生这种情况,单个计算将再次执行直到成功。

以下是实体系统中可能使用的每种方法的一些示例:

  1. 让我们考虑一个CollisionSystem读取PositionRigidBody组成部分,应该更新的Velocity。与其Velocity直接操作,不如CollisionSystem将遗嘱放入CollisionEvent的工作队列中EventSystem。然后,该事件将与的其他更新一起顺序处理Velocity
  2. An EntitySystem定义了一组需要读取和写入的组件。对于每个组件,它将为其希望读取的每个组件Entity获取一个读锁,并为希望更新的每个组件获取一个写锁。这样,每个人EntitySystem都可以在同步更新操作的同时读取组件。
  3. 以的示例MovementSystem,该Position组件是不可变的,并且包含修订号。该MovementSystemsavely读取PositionVelocity组件,并计算new Position,从而增加读取的修订版号并尝试更新该Position组件。如果是并发修改,则框架Entity会在更新时对此进行指示,并将放回到必须由进行更新的实体列表中MovementSystem

取决于系统,实体和更新间隔,每种方法可能是好是坏。实体系统框架可能允许用户在这些选项之间进行选择以调整性能。

我希望我可以在讨论中添加一些想法,如果有任何新闻,请告诉我。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.