如何使用CUDA固定的“零拷贝”内存来操作内存映射文件?

5

目标/问题

在Python中,我正在寻找快速读写内存映射文件中的数据到GPU的方法。

在之前的一个SO溢出帖子中[Cupy OutOfMemoryError when trying to cupy.load larger dimension .npy files in memory map mode, but np.load works fine]

提到了使用CUDA固定的“零拷贝”内存可能是可行的。此外,似乎这种方法是由这个人开发的[ cuda - Zero-copy memory, memory-mapped file ],虽然那个人是在C++中工作。

我的以前尝试是使用Cupy,但我对任何cuda方法都持开放态度。

我已经尝试过的方法

我提到了我尝试使用Cupy,它允许您以内存映射模式打开numpy文件。

import os
import numpy as np
import cupy

#Create .npy files. 
for i in range(4):
    numpyMemmap = np.memmap( 'reg.memmap'+str(i), dtype='float32', mode='w+', shape=( 2200000 , 512))
    np.save( 'reg.memmap'+str(i) , numpyMemmap )
    del numpyMemmap
    os.remove( 'reg.memmap'+str(i) )

# Check if they load correctly with np.load.
NPYmemmap = []
for i in range(4):
    NPYmemmap.append( np.load( 'reg.memmap'+str(i)+'.npy' , mmap_mode = 'r+' )  )
del NPYmemmap

# Eventually results in memory error. 
CPYmemmap = []
for i in range(4):
    print(i)
    CPYmemmap.append( cupy.load( 'reg.memmap'+str(i)+'.npy' , mmap_mode = 'r+' )  )

我尝试的结果

我的尝试导致了OutOfMemoryError:

有人提到:

似乎cupy.load需要将整个文件先适应主机内存,然后在设备内存中适应。

还有人提到:

CuPy无法处理内存映射。因此,默认情况下CuPy直接使用GPU内存。 https://docs-cupy.chainer.org/en/stable/reference/generated/cupy.cuda.MemoryPool.html#cupy.cuda.MemoryPool.malloc 如果您想使用统一内存,则可以更改默认的内存分配器。

我尝试使用

cupy.cuda.set_allocator(cupy.cuda.MemoryPool(cupy.cuda.memory.malloc_managed).malloc)

但这似乎没有什么效果。在出现错误时,我的CPU Ram约为16 GB,而GPU Ram为0.32 GB。我正在使用Google colab,在那里我的CPU Ram为25 GB,GPU Ram为12 GB。因此,在将整个文件托管在主机内存中后,它检查是否可以适应设备内存,当它看到只有12个需要的16个GB时,它会抛出一个错误(我最好的猜测)。

因此,现在我正在尝试找到一种使用固定的“零拷贝”内存来处理内存映射文件的方法,该文件将向GPU提供数据。

如果重要,我尝试传输的数据类型是浮点数组。通常,对于只读数据,将二进制文件加载到GPU内存中,但我正在处理希望在每个步骤中都进行读写操作的数据。

1个回答

5
我认为目前 cupy 没有提供一个可以用作通常的设备内存分配器替代品,即可用于支持 cupy.ndarray 的固定分配器。如果这对您很重要,您可以考虑提交一个 cupy 问题
然而,似乎可能有可能创建一个。这应该被视为实验代码。并且它的使用存在一些问题。
基本思想是我们将使用 cupy.cuda.set_allocator 来替换 cupy 的默认设备内存分配器。我们需要提供自己的替代 BaseMemory 类,它被用作 cupy.cuda.memory.MemoryPointer 的存储库。在这里的关键区别是,我们将使用固定内存分配器而不是设备分配器。这是下面 PMemory 类的要点。
还有其他几件事需要注意:
  • 在完成对固定内存(分配)的需要操作之后,您应该将 cupy 分配器恢复为其默认值。不幸的是,与 cupy.cuda.set_allocator 不同,我没有找到相应的 cupy.cuda.get_allocator,这让我觉得 cupy 存在一个缺陷,我认为这也值得提交一个 cupy 问题。然而,对于此演示,我们只需恢复为 None 选择,它使用其中一个默认设备内存分配器(但不使用池分配器)。
  • 通过提供这个最小化的固定内存分配器,我们仍然向 cupy 暗示这是普通设备内存。这意味着它不能直接从主机代码访问(实际上可以,但是 cupy 不知道)。因此,各种操作(如 cupy.load)将创建不必要的主机分配和不必要的复制操作。我认为要解决这个问题需要比我建议的这个小改变更多的工作。但至少对于你的测试用例,这种额外开销可能是可管理的。显然,你想从磁盘加载数据一次,然后将其保留在那里。对于这种类型的活动,应该是可管理的,尤其是因为你将它分成块。正如我们将看到的那样,处理四个 5GB 的块对于 25GB 的主机内存来说太多了。我们需要为四个 5GB 的块(实际上是固定的)进行主机内存分配,并且还需要额外的空间用于一个额外的 5GB "overhead" 缓冲区。所以 25GB 对于那个来说不够。但是为了演示目的,如果我们将缓冲区大小减小到 4GB(5x4GB=20GB),我认为它可能适合您的 25GB 主机 RAM 大小。
  • cupy 的默认设备内存分配器关联着特定的设备,而普通的设备内存不需要这样的关联,但是我们用类似的类替换 BaseMemory 的方式意味着我们向 cupy 暗示这种 "设备" 内存,和所有其他普通的设备内存一样,具有特定的设备关联。在像您这样的单设备设置中,这种区别是无意义的。然而,这并不适合于稳健的多设备使用固定内存。为此,建议再次进行更强大的更改,也许是通过提交一个问题来实现。
import os
import numpy as np
import cupy



class PMemory(cupy.cuda.memory.BaseMemory):
    def __init__(self, size):
        self.size = size
        self.device_id = cupy.cuda.device.get_device_id()
        self.ptr = 0
        if size > 0:
            self.ptr = cupy.cuda.runtime.hostAlloc(size, 0)
    def __del__(self):
        if self.ptr:
            cupy.cuda.runtime.freeHost(self.ptr)

def my_pinned_allocator(bsize):
    return cupy.cuda.memory.MemoryPointer(PMemory(bsize),0)

cupy.cuda.set_allocator(my_pinned_allocator)

#Create 4 .npy files, ~4GB each
for i in range(4):
    print(i)
    numpyMemmap = np.memmap( 'reg.memmap'+str(i), dtype='float32', mode='w+', shape=( 10000000 , 100))
    np.save( 'reg.memmap'+str(i) , numpyMemmap )
    del numpyMemmap
    os.remove( 'reg.memmap'+str(i) )

# Check if they load correctly with np.load.
NPYmemmap = []
for i in range(4):
    print(i)
    NPYmemmap.append( np.load( 'reg.memmap'+str(i)+'.npy' , mmap_mode = 'r+' )  )
del NPYmemmap

# allocate pinned memory storage
CPYmemmap = []
for i in range(4):
    print(i)
    CPYmemmap.append( cupy.load( 'reg.memmap'+str(i)+'.npy' , mmap_mode = 'r+' )  )
cupy.cuda.set_allocator(None)

我没有在拥有25GB主机内存和这些文件大小的设置中进行过测试。但我已经使用其他超过我的GPU设备内存的文件大小进行了测试,并且似乎可以工作。

再次声明,这是实验性代码,未经彻底测试,您的结果可能会有所不同,最好通过提交cupy github问题来获得此功能。 此外,正如我之前提到的那样,这种“设备内存”通常比普通的cupy设备内存从设备代码访问速度要慢得多。

最后,这不是真正的“内存映射文件”,因为所有文件内容都将加载到主机内存中,并且此方法还“使用了”主机内存。 如果您有20GB的文件要访问,您需要超过20GB的主机内存。 只要这些文件已“加载”,就会使用20GB的主机内存。

更新:cupy现在提供对固定分配器的支持,请参见此处。 本答案仅供历史参考。


解决方案运行得非常好!“看起来你想从磁盘加载数据一次,然后让它留在那里”,不完全是这样,在机器学习训练期间,我在每个训练步骤中切换可训练变量的值(例如在此处https://colab.research.google.com/drive/188ClgrxKJ6zDuvPbEvZUuCle-g6e5wrZ),因此每个会话大约有100,000次读写操作,但是使用您的解决方案没有发生内存泄漏。它似乎有点慢,但足够快以成为一个非常可用的解决方案。 - SantoshGupta7
最后,这并不是真正的“内存映射文件”,因为所有文件内容都将被加载到主机内存中,而且,这种方法会“消耗”主机内存。因此,我在想是否应该切换到常规的cupy数组。既然所有内容都已加载到内存中,使用内存映射模式使用cupy没有任何优势,对吧?或者说,在memmap模式下使用cupy数组仍然有一些优势吗? - SantoshGupta7
1
如果您使用常规的Cupy数组,那么您将受到GPU RAM数量的限制。因此,在K80上,您无法拥有20GB的这种数据。也许您没有掌握主机内存和设备内存之间的区别。本答案中的分配使用主机内存,该内存映射到设备地址空间中。它不使用设备内存。如果您使用设备内存分配器,则在K80上对于此类分配,您将受到设备内存大小的限制。当然,您可以同时使用两者。将一些数据放入此类映射分配中,将一些数据放入普通的Cupy数组中。 - Robert Crovella
啊,我本意是说Pytorch数组。你可以将它们放在CPU和GPU上,看起来你也可以将它们固定在内存上 https://pytorch.org/docs/stable/tensors.html#torch.Tensor.pin_memory。所以我在考虑,使用固定的CPU pytorch张量,这些张量是在内存中实时存在的。从我的理解来看,由于cupy内存映射数组已经存在于CPU内存中,似乎使用它们并没有优势,而使用固定的cpu pytorch数组仍有某种RAM节省吗? - SantoshGupta7
1
我不能对Pytorch数组与此进行比较发表评论。使用pytorch pinned张量可能更明智。我不会期望通过这种方法获得任何内存“节省”。 - Robert Crovella
1
更新,这种技术非常有效,比Pytorch固定数组要好得多,我基于它制作了一个库 https://github.com/Santosh-Gupta/SpeedTorch - SantoshGupta7

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