c++如何设计命令队列

C++ How to Design Command Queue

本文关键字:命令 队列 c++      更新时间:2023-10-16

我有8个线程提供需要在GPU上执行的命令,我希望GPU执行通过由8个其他线程填充的缓冲区发生在同一线程上。我认为这将是相当简单,但我有问题设计正确。

struct CommandStruct {
CComPtr<IDirect3DPixelShader9> Shader;
const char* Param[9];
bool IsCompleted;
};
concurrent_queue<CommandStruct*> Shader::cmdBuffer;
std::mutex Shader::startLock;
std::thread* Shader::WorkerThread = NULL;
void Shader::AddCommandToQueue(CommandStruct* cmd) {
// Add command to queue.
cmdBuffer.push(cmd);
if (WorkerThread == NULL) {
startLock.lock();
if (WorkerThread == NULL) {
WorkerThread = new std::thread(StartWorkerThread, env);
}
startLock.unlock();
}
}

StartWorkerThread是一个函数,它接受cmdBuffer中的所有元素并一个接一个地执行它们,直到缓冲区为空并在空闲几秒钟后停止。

我遇到的问题是如何使调用者等待,直到执行完成?起初我尝试了SetEvent,但性能非常差。然后我试着用Sleep(10)循环,但是它浪费了整个CPU。

理想情况下,一旦命令完成,AddCommandToQueue将返回,但随后我有关于如何等待完成的相同问题。

实现这样一个队列的正确方法是什么?它需要速度快,因为它包含了许多GPU的指令。我尝试了一个事件,我尝试了一个IsCompleted标志的结构,但设计还没有工作。

我意识到我晚了几年,但我想我应该提供一些想法。由于您正在查看命令系统,因此我将花时间确保您的命令是类型擦除的或抽象的。下面是一个可能对您有用的示例系统。你也可以看看他的博客上的外部和内部参考:

https://blog.molecular-matters.com/2014/12/16/stateless-layered-multi-threaded-rendering-part-3-api-design-details/

我保持命令系统类型为擦除或抽象的原因是为了维护某种层次的分层体系结构。以下是实时游戏引擎架构的典型图解:http://hightalestudios.com/2017/03/game-engine-architecture-2nd-edition-overview-ch-1-part-2/

在上面的图中需要注意的一点是,整个层都是专门用于平台独立性的。在这个级别以上,我会避免使用com指针之类的结构,因为它们在很大程度上依赖于平台。相反,可以考虑使用池或竞技场分配器创建一个资源管理系统,您可以在其中存储、缓存和重用资源、顶点缓冲区等。如果你采取这一步,你将为成功做好准备。

这也为你在创建命令系统时的成功做好了准备。在较高的层次上,可以将命令看作是保存数据管理器所保存的数据句柄的小结构。同样,通过这种方式,你不需要加载/重新加载vos, vbo, ibo,着色器,材料等。如果正确设置句柄系统,您将获得对数据管理器中相应数据的O(1)次访问,并且您的命令将很小,这允许您对它们进行排序。在渲染绘制调用时,排序将变得很重要,因为您将希望在绘制较近的项目之前绘制较远的项目。

如果您遵循上述建议,则命令的内容应该是少量数据(仅句柄/指针)和分派函数。以下是我自制GUI/游戏引擎中的一些命令的具体示例:

struct make_viewport
{
int width = platform::default_viewport_width;
int height = platform::default_viewport_height;
static auto dispatch(const void* data) {
auto command = static_cast<const make_viewport*>(data);
auto handle = platform::make_viewport(command->width, command->height);
resource::manager::store(handle);
};
};

在上面的例子中,屏幕宽度和高度足够小,使用句柄并将大小存储在资源管理器中是没有意义的。还要注意,该命令在此级别上对平台细节一无所知。有一个定制的基于连接器的接口,用于各种操作系统、cpu、gpu等,并在主机上编译。还有其他方法可以做到这一点,但这是我首先想到的解决方案。下面是我找到的一个库,用于抽象一些您可能感兴趣的特定于平台的东西:https://github.com/ThePhD/infoware

那么这个分派函数到底是做什么的呢?大多数命令系统使用某种形式的调度机制来延迟执行命令。我看到的一些常见的实现方法如下:

auto execute() noexcept -> void
{
// do stuff with command data
}
auto dispatch() noexcept -> void
{
// do stuff with command data
}
auto operator()() noexcept -> void
{
// do stuff with command data
}

调度函数的签名并不重要,但重要的是保持每个命令之间的签名一致,以便您可以创建命令的多态数组。您将需要能够对这个多态数组进行排序和执行,并且它的内容无法在编译时确定,因此您将无法使用std::tuple(您可以使用std::variant,但您的数据局部性将不会像它可能的那样好)。

如果我必须提出建议,我会倾向于超载operator().为什么?如果您注意到,这将使任何命令都成为有效的函子。这允许与现代c++技术(如lambdas和标准算法)进行深度集成。换句话说,从上面重写make_viewport命令可以像下面这样简单:

auto make_viewport = [width = platform::default_viewport_width, height = platform::default_viewport_height](const void* data)
{
auto command = static_cast<const make_viewport*>(data);
auto handle = platform::make_viewport(command->width, command->height);
resource::manager::store(handle);
};

当然,您需要找到一种方法来处理多态性/排序/执行,但是如果您找到了这样做的方法,lambda方法可能会非常有趣。

如果你对处理类型擦除的方法感兴趣,我是这样做的:

struct command_packet
{
void* command = nullptr;
dispatch_type dispatch = nullptr;
};
template<typename command_t> [[nodiscard]]
constexpr auto make_packet(command_t&& command) noexcept
{
return command_packet
{
.command = &command,
.dispatch = command.dispatch
};
}

您也可以使用虚拟基类来完成此操作,但我希望能够拥有POD命令,以便我可以推断命令的大小。然后我创建一个命令数组来执行每一帧。理想情况下,该数组应该具有线程本地存储,这使得很难从多个线程填充。但好消息是,您可以根据需要创建多个命令队列。例如,绘制调用总是需要按深度排序,因此需要在同一个队列中。但是如果你够聪明的话,你可以选择用基数排序来排序这个队列。在实现基数排序时,由于基数排序不需要任何比较操作,您可以将来自多个线程的数据排序到桶中。如果您的命令足够小且数量足够多,那么在图形应用程序中,您的性能将大大超过std::sort。

最后,如果您需要执行剔除、合并和冗余优化,您可以在管道中添加步骤来执行这些操作。我还没有在我的系统上实现这些优化,但应该不难想象,可以用一点额外的逻辑删除/添加/修改/合并/链接命令,使上述优化成为可能。

总之,一个适合您需要的命令系统应该具有以下属性:

  • 命令是POD或者它们的大小可以根据
  • 命令多态
  • 命令不包含平台特定代码
  • 命令系统与资源管理器相结合
  • 命令被设计成与缓存行对齐(换句话说,static_cast(sizeof(command))/sizeof(void*)是一个整数,分配器提供适当的填充/对齐以避免缓存丢失)

你的命令管道看起来像这样:

  1. 将数据(最好是异步的,因为句柄可以立即返回,而无需等待资源加载)加载到资源管理器中,并获取该数据的句柄
  2. 使用任务/作业系统使用上述句柄创建命令
  3. 将每个命令塞到命令队列中(如果使用基数排序,可以直接将它们塞到第一个桶中以提高效率)。
  4. 在帧结束时,对数据进行排序(最好使用基数排序)。
  5. 删除多余的命令
  6. 在单个线程上顺序地从队列中提交命令。我相信你已经意识到基于这个问题,但图形api通常是有状态的,并要求你在每次渲染调用之前配置状态(这使得它不可能并行执行……更不用说您的平台上可能存在的任何基于硬件的并行性限制)。这里需要注意的是,如果你正确设置了排序标准,你应该只需要在着色器/纹理/材料/顶点缓冲等发生变化时更换它们。这进一步提高了效率,摆脱了冗余的api调用和副本。清除您的队列。例如,如果使用std::vector<T,>,调用std::vector<T,>::clear()就足够了。其思想不是释放内存,而是将当前索引移回容器的开头。你甚至不需要删除命令,如果它们是POD,你可以简单地在下一帧覆盖它,如果你小心你是如何设置的。

,,我只是想告诉你,你所做的事情可以有多难就有多难。从小事做起,简单地让你的系统工作起来。您可以慢慢地添加我所讨论的一些特性,因为您可以使每个部件工作并验证它是否有效。在大多数使用图形命令的系统中,我称之为关键瓶颈,所以帮自己一个忙,创建一个可以优化的架构,为成功做好准备,如果你有理由这样做的话。例如,许多命令系统不允许排序,这是一个错失的巨大机会。即使您没有立即实现排序,如果您在一定程度上遵循烹饪书,您也可以在以后的时间实现排序,而不会有太大的痛苦。您所做的工作几乎需要一种面向数据的方法来提高效率,因此请考虑以一种提供O(1)访问和O(1)存储的方式来布局数据,这种方式不会导致冗余,并且与平台上的缓存线保持一致。花点时间在这上面,你会避免自己受到伤害。也许你可以在这本书中找到一些额外的有用的技巧:https://www.amazon.com/Engine-Architecture-Third-Jason-Gregory/dp/1138035459/ref=pd_lpo_14_t_0/137-2195898-2699658?_encoding=UTF8&pd_rd_i=1138035459&pd_rd_r=100afaaf-5542-476a-89eb-87ae8bed3571&pd_rd_w=AaXTZ&pd_rd_wg=E6BRu&pf_rd_p=7b36d496-f366-4631-94d3-61b87b52511b&pf_rd_r=KQ0CFH5Y2JCA4817ATNM&psc=1&refRID=KQ0CFH5Y2JCA4817ATNM