如何在C++中快速提交已分配的内存?

22

总体情况

一款对带宽、CPU使用率和GPU使用率要求非常高的应用程序需要从一个GPU传输每秒约10-15GB到另一个GPU。它使用DX11 API访问GPU,所以上传至GPU只能使用需要为每个单独上传进行映射的缓冲区。上传每次以25MB的块进行,并发地有16个线程正在同时写入缓冲区。在这方面无法做太多改变。如果没有以下bug,实际并发写入的级别应该会更低。

这是一台配备了3个Pascal GPU、高端Haswell处理器和四通道RAM的强大工作站。硬件上几乎不能再改进了。它运行着Windows 10桌面版。

实际问题

一旦CPU负载超过50%,位于MmPageFault()中(Windows内核内部,在访问已映射到您的地址空间但尚未被操作系统提交的内存时调用)的某些东西会出现严重错误,并且剩余的50% CPU负载将浪费在MmPageFault()内部的自旋锁上。CPU利用率达到100%,应用程序性能完全下降。

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

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

考虑因素

缓冲区由DX11驱动程序分配。无法调整分配策略。不能使用不同的内存API,特别是无法重复使用。

DX11 API(映射/取消映射缓冲区)的所有调用都来自单个线程。实际的复制操作可能在比系统中的虚拟处理器更多的线程上进行。

减少内存带宽要求是不可能的。这是一个实时应用程序。事实上,硬限制目前是主GPU的PCIe 3.0 16x带宽。如果可以的话,我已经需要推得更远了。

避免多线程复制是不可能的,因为有独立的生产者-消费者队列,不能轻松合并。

自旋锁性能退化似乎非常罕见(因为使用情况被推得如此之远),在Google上,您找不到一个单独的自旋锁函数的结果。

升级到提供更多映射控制的API(Vulkan)正在进行中,但它不适合作为短期修复。出于同样的原因,目前不能切换到更好的操作系统内核。

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

问题

有什么办法吗?

我需要大幅减少单独页面故

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

1
听起来你的所有16个线程总共只有大约400M。相当低的。你能验证一下在你的应用程序中没有超过这个值吗?那里的峰值内存消耗是多少?我想知道你是否有内存泄漏。 - Serge
峰值消耗约为7-8GB,但考虑到整个处理流程需要>1s的缓冲来弥补各种瓶颈,这是正常的。是的,它只有“400MB”,每秒25次。一切都很好,直到基本CPU负载超过50%,自旋锁的性能突然从几乎0飙升到完全CPU利用率的40-50%左右。同时还影响系统上的其他进程。 - Ext3h
1
  1. 你的物理内存是多少?你能杀死所有其他活动进程吗?
  2. 如果你看到50%的阈值,就猜测第二个问题,你可能会遇到一些超线程问题。你有多少个物理核心?8个?你能禁用超线程吗?尝试在干净的机器上运行与物理CPU数量相同的线程。
- Serge
@Serge 16GB,基线为2.5到4GB,具体取决于是否同时运行Visual Studio。它没有进行交换,这是我检查的第一件事。它在有或没有其他进程运行时都会发生。有6个核心,但是是的,超线程处于活动状态,并且我还没有考虑过不使用它。我将在周一尝试,但这可能会导致CPU性能成为瓶颈。 - Ext3h
2
Win10似乎存在问题(参见https://dev59.com/jlcO5IYBdhLWcg3w-VxX#Ym4eoYgBc1ULPQZFRnb6),当有许多线程引起页面错误时。这会造成大约两倍的代价。你的解决方法仍然是你能做的最好的方法。如果他们能够处理早期Windows版本中更便宜的热锁问题,你应该在微软支持中心开一个工单。 - Alois Kraus
显示剩余2条评论
1个回答

13

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

// 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()函数可以强制内核立即将指定的地址范围与RAM关联起来。调用相应的VirtualUnlock() 函数是可选的,当该地址范围从进程中取消映射时,它会隐式地发生(并且不需要额外的成本)。如果显式调用,则成本约为锁定成本的1/3。

为了使VirtualLock()正常工作,必须首先调用SetProcessWorkingSetSize(), 因为由VirtualLock()锁定的所有内存区域的总和不能超过为进程配置的最小工作集大小。将“最小”工作集大小设置为高于进程的基线内存占用量的值没有任何副作用,除非系统实际上可能交换,否则进程仍然不会消耗比实际工作集大小更多的RAM。


仅使用VirtualLock(),即使在单个线程中,使用延迟的DX11上下文来进行Map/Unmap调用,也可以将性能惩罚立刻降低到稍微可接受的15%。

放弃使用延迟上下文,并在单个线程上独占地触发所有软故障以及取消映射时的相应的释放,可以获得所需的性能提升。该自旋锁的总成本现在已降至总CPU使用量的<1%。


总结?

当您预期在Windows上出现软故障时,请尽可能将它们全部保留在同一线程中。执行并行的memcpy本身没有问题,在某些情况下,甚至需要充分利用内存带宽。但是,仅当内存已经提交到RAM时才适用。 VirtualLock()是确保这一点最有效的方法。

(除非您正在使用类似于DirectX的API将内存映射到进程中,否则您不太可能频繁遇到未提交的内存。如果您只是使用标准C++newmalloc,则内存在进程内池化和回收,因此软故障很少见。)

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


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接