如何在 C++ 中急切提交分配的内存?

How to eager commit allocated memory in C++?

概况

带宽、CPU 使用率和 GPU 使用率都非常高的应用程序需要每秒从一个 GPU 向另一个 GPU 传输大约 10-15GB 的数据。它使用 DX11 API 访问 GPU,因此上传到 GPU 只能发生在每次上传都需要映射的缓冲区中。上传一次以 25MB 的块进行,16 个线程同时将缓冲区写入映射缓冲区。关于这一切,我们无能为力。如果不是下面的bug,实际的写入并发级别应该更低。

这是一个功能强大的工作站,配备 3 个 Pascal GPU、一个高端 Haswell 处理器和四通道 RAM。硬件上没有太多可以改进的地方。 运行 Windows 10.

的桌面版

实际问题

一旦我通过 ~50% CPU 负载,MmPageFault() 中的某些东西(在 Windows 内核中,在访问已映射到您的地址的内存时调用 space,但尚未由 OS 提交)严重中断,剩余的 50% CPU 负载被浪费在 MmPageFault() 内的自旋锁上。 CPU 变为 100% 利用率,应用程序性能完全下降。

我必须假设这是由于每秒需要分配给进程的大量内存,并且每次取消映射 DX11 缓冲区时也完全从进程中取消映射。相应地,它实际上是每秒对 MmPageFault() 的数千次调用,随着 memcpy() 顺序写入缓冲区而顺序发生。对于遇到的每个未提交页面。

一旦 CPU 负载超过 50%,Windows 内核中保护页面管理的乐观自旋锁完全降低了性能。

注意事项

缓冲区由DX11驱动程序分配。分配策略没有什么可以调整的。使用不同的内存 API 尤其是重复使用是不可能的。

对 DX11 API(mapping/unmapping 缓冲区)的调用全部发生在单个线程中。实际的复制操作可能发生在比系统中的虚拟处理器更多的线程上。

无法降低内存带宽要求。它是一个实时应用程序。事实上,目前的硬限制是主 GPU 的 PCIe 3.0 16x 带宽。如果可以的话,我已经需要更进一步了。

避免多线程副本是不可能的,因为有独立的生产者-消费者队列,无法简单地合并。

自旋锁性能下降似乎非常罕见(因为用例将它推到那么远),以至于在 Google 上,您找不到自旋名称的单个结果-锁定功能。

正在升级到 API,以便更好地控制映射 (Vulkan),但它不适合作为短期修复。出于同样的原因,目前不能切换到更好的 OS 内核。

减少CPU负载也不起作用;除了(通常是微不足道且廉价的)缓冲区副本之外,还有太多工作需要完成。

问题

可以做什么?

我需要显着减少单个页面错误的数量。我知道已经映射到我的进程中的缓冲区的地址和大小,我也知道内存还没有被提交。

如何确保以尽可能少的事务提交内存?

DX11 的特殊标志可以防止在取消映射后取消分配缓冲区,Windows APIs 强制在单个事务中提交,几乎任何东西都是受欢迎的。

当前状态

// In the processing threads
{
    DX11DeferredContext->Map(..., &buffer)
    std::memcpy(buffer, source, size);
    DX11DeferredContext->Unmap(...);
}

当前的解决方法,简化的伪代码:

// During startup
{
    SetProcessWorkingSetSize(GetCurrentProcess(), 2*1024*1024*1024, -1);
}
// In the DX11 render loop thread
{
    DX11context->Map(..., &resource)
    VirtualLock(resource.pData, resource.size);
    notify();
    wait();
    DX11context->Unmap(...);
}
// In the processing threads
{
    wait();
    std::memcpy(buffer, source, size);
    signal();
}

VirtualLock() forces the kernel to back the specified address range with RAM immediately. The call to the complementing VirtualUnlock() 函数是可选的,当地址范围从进程中取消映射时,它会隐式发生(无需额外成本)。 (如果显式调用,它的成本约为锁定成本的 1/3。)

为了让VirtualLock()工作,需要先调用SetProcessWorkingSetSize(),因为VirtualLock()锁定的所有内存区域的总和不能超过最小工作集为进程配置的大小。将 "minimum" 工作集大小设置为高于进程的基线内存占用量没有副作用,除非您的系统实际上可能正在交换,您的进程仍然不会消耗比实际工作集大小更多的 RAM。


只是使用 VirtualLock(),尽管是在单独的线程中,并且对 Map / Unmap 调用使用延迟的 DX11 上下文,确实立即将性能损失从 40-50% 降低到稍微更容易接受的 15%。

放弃使用延迟上下文,并且专门触发所有软故障,以及取消映射时相应的解除分配 在单个线程上,提供了必要的性能提升。该自旋锁的总成本现已降至 CPU 总使用量的 <1%。


总结?

当您预计 Windows 上会出现软故障时,尽您所能将它们都放在同一个线程中。执行并行 memcpy 本身是没有问题的,在某些情况下甚至需要充分利用内存带宽。但是,只有当内存已经提交给 RAM 时才会这样。 VirtualLock() 是确保这一点的最有效方法。

(除非您使用像 DirectX 这样的 API 将内存映射到您的进程,否则您不太可能经常遇到未提交的内存。如果您只是使用标准 C++ newmalloc 无论如何,你的内存在你的进程中被汇集和回收,所以软故障很少见。)

只要确保在使用 Windows 时避免任何形式的并发页面错误。