使用统一共享内存而不是设备内存时,SYCL 的性能下降了 4000%。

8
在SYCL中,有三种类型的内存:主机内存、设备内存和统一共享内存(USM)。对于主机和设备内存,数据交换需要显式复制。与此同时,从USM到设备和从设备到USM的数据移动由SYCL运行时隐式管理。
不幸的是,在使用SYCL为数值核心实现GPU加速的过程中,我发现只是从`sycl::malloc_device()`切换到`sycl::malloc_shared()`就导致性能下降了最多4000% - 即使我只是重复提交相同的SYCL核心,而没有尝试从主机访问数据。
使用OpenSYCL针对AMD HIP GFX906(Radeon VII / Instinct MI50)构建代码,并使用`sycl::malloc_device()`,程序在0.27秒内完成。
$ time ./fdtd.elf 
simulate 16974593 cells for 10 timesteps.

real    0m0.271s
user    0m0.253s
sys     0m0.020s

使用sycl::malloc_shared()构建相同的代码时,程序完成需要10.6秒:

simulate 16974593 cells for 10 timesteps.

real    0m10.649s
user    0m15.172s
sys     0m0.196s

这是一个3925%的减速。

在BIOS中启用了"超过4G解码"和"可调整大小的BAR"支持后,现在只需要3.8秒而不是10.6秒。但这并未解决无谓的内存传输的实际问题 - 1300%的性能损失仍然相当显著。

我之前还使用英特尔DPC++编译器测试了类似的内核,在相同的硬件上看到了类似的结果。

我怀疑减速是由于无谓的主机和设备拷贝引起的,但我不确定。SYCL运行时使用什么启发式算法来确定是否需要拷贝呢?

下面是示例代码。

ArrayNXYZ.hpp:4维数组(n,x,y,z)的包装类。

#include <sycl/sycl.hpp>

template <typename T>
struct ArrayXYZN
{
    ArrayXYZN() {}

    inline T& operator() (const unsigned int n, const unsigned int x, const unsigned int y, const unsigned int z) const
    {
        size_t offset = n * n_stride + x * x_stride + y * y_stride + z;
        return array[offset];
    }

    unsigned long n_stride, x_stride, y_stride, size;
    T *array;
};

template <typename T>
ArrayXYZN<T>* CreateArrayXYZN(sycl::queue Q, const unsigned int* numLines)
{
    unsigned int n_max = 3;
    unsigned int x_max = numLines[0];
    unsigned int y_max = numLines[1];
    unsigned int z_max = numLines[2];

    unsigned long n_stride = x_max * y_max * z_max;
    unsigned long x_stride = y_max * z_max;
    unsigned long y_stride = z_max;

    if (n_stride % 128 != 0)
    {
        n_stride += 128 - (n_stride % 128);
    }

    // allocate 1D linear buffer
    size_t size = n_stride * n_max;

#ifdef USM
    T *buf = sycl::malloc_shared<T>(size, Q);
#else
    T *buf = sycl::malloc_device<T>(size, Q);
#endif

    // zero memory
    Q.submit([&](sycl::handler& h) {
        h.memset(buf, 0, size * sizeof(T));
    });
    Q.wait();

    // allocate wrapper class
    ArrayXYZN<T>* array = new ArrayXYZN<T>();
    array->n_stride = n_stride;
    array->x_stride = x_stride;
    array->y_stride = y_stride;
    array->size = size * sizeof(T);
    array->array = buf;

    return array;
}

fdtd.cpp:

#include <sycl/sycl.hpp>
#include "ArrayNXYZ.hpp"

/*
 * UpdateVoltages
 *
 * Using Finite Difference Time Domain (FDTD) method,
 * calculate new electric field array "volt" based on
 * magnetic field "curr" and two electromagnetic field
 * operators "vv" and "vi", precalculated from the
 * physical materials before starting up simulation.
 */
void UpdateVoltages(
        const ArrayXYZN<float>& volt,
        const ArrayXYZN<float>& curr,
        const ArrayXYZN<float>& vv,
        const ArrayXYZN<float>& vi,
        int x, int y, int z
)
{
    // note: each (x, y, z) cell has three polarizations
    // x, y, z, these are different from the cell's
    // coordinates (x, y, z)

    //for x polarization
    float volt0 = volt(0, x, y, z);
    volt0 *= vv(0, x, y, z);
    volt0 +=
        vi(0, x, y, z) * (
        curr(2, x, y  , z  ) -
        curr(2, x, y-1, z  ) -
        curr(1, x, y  , z  ) +
        curr(1, x, y  , z-1)
        );

    //for y polarization
    float volt1 = volt(1, x, y, z);
    volt1 *= vv(1, x, y, z);
    volt1 +=
        vi(1, x, y, z) * (
        curr(0, x  , y, z  ) -
        curr(0, x  , y, z-1) -
        curr(2, x  , y, z  ) +
        curr(2, x-1, y, z  )
        );

    //for z polarization
    float volt2 = volt(2, x, y, z);
    volt2 *= vv(2, x, y, z);
    volt2 +=
        vi(2, x, y, z) * (
        curr(1, x  , y  , z) -
        curr(1, x-1, y  , z) -
        curr(0, x  , y  , z) +
        curr(0, x  , y-1, z)
        );

    volt(0, x, y, z) = volt0;
    volt(1, x, y, z) = volt1;
    volt(2, x, y, z) = volt2;
}

int main(void)
{
    const unsigned int numLines[3] = {257, 257, 257};
    const int timesteps = 10;

    sycl::queue Q;

    ArrayXYZN<float>& volt = *CreateArrayXYZN<float>(Q, numLines);
    ArrayXYZN<float>& curr = *CreateArrayXYZN<float>(Q, numLines);
    ArrayXYZN<float>& vv = *CreateArrayXYZN<float>(Q, numLines);
    ArrayXYZN<float>& vi = *CreateArrayXYZN<float>(Q, numLines);

    size_t size = numLines[0] * numLines[1] * numLines[2];
    fprintf(stderr, "simulate %ld cells for %d timesteps.\n", size, timesteps);

    for (int i = 0; i < timesteps; i++) {
        Q.submit([&](sycl::handler &h) {
            h.parallel_for<class Voltage>(
                sycl::range(numLines[0] - 1, numLines[1] - 1, numLines[2] - 1),
                [=](sycl::item<3> itm) {
                    /*
                     * The first cell on each dimension has data dependency
                     * outside the simulation box (boundary condition).
                     * Ignore them for now.
                     */
                    int x = itm.get_id(0) + 1;
                    int y = itm.get_id(1) + 1;
                    int z = itm.get_id(2) + 1;

                    UpdateVoltages(volt, curr, vv, vi, x, y, z);
                }
            );
        });
        Q.wait();
    }
}
1个回答

9

我自己解决了这个问题。有三个问题。

XNACK

根本问题是 xnack 被禁用了。

XNACK 的确切功能以及如何启用它,在除了少数几个地方之外,文档记录得都很差。我相信这个答案是整个网络上唯一全面的指南。

在主机和 GPU 之间进行按需页面迁移时,需要 XNACK (故障页面上的 GPU 重试)。如果没有它,HIP 的共享内存将以降级模式运行 - 根据访问模式,内存将不会自动迁移。因此,如果要使用 USM,则必须启用 XNACK。可以通过查看平台名称来检查是否启用了 xnack。如果它带有 xnack-(例如 gfx906:sramecc+:xnack-),表示 XNACK 被禁用。如果它带有 xnack+,表示 XNACK 已启用。

很遗憾,并非所有的独立显卡都受支持。自 RDNA 以来,大多数来自 GFX10/GFX11 系列的显卡不支持 XNACK。因此,在 SYCL 编程中,使用推荐的统一共享内存方式会受到严重影响。

如果幸运的话,事实证明 GFX9 系列中的许多独立显卡支持 XNACK(基于 ROCm 中的 ISA 表),但默认情况下被 amdgpu 内核驱动禁用。可能是出于稳定性方面的考虑,因为它仍然是一个实验性功能。可以通过 amdgpu 内核模块参数 noretry=0 或者 引导时的内核参数 amdgpu.noretry=0 进行手动启用。

要启用XNACK,需要按照以下步骤进行操作:
0. 硬件必须支持XNACK功能。 1. 在Linux内核中通过设置noretry=0标志来启用XNACK。启用后,clinfo或rocminfo应该在GPU的ISA名称中报告xnack+。 2. 在运行HIP程序之前,必须通过环境变量HSA_XNACK=1来启用运行时的XNACK功能。 3. (可选)将代码编译为xnack+目标(例如,使用gfx906:xnack+而不是普通的目标名称gfx906)。这样可以最大化性能,但您的二进制文件将无法在不支持XNACK的设备上运行。在我的情况下,我发现几乎没有性能差异。
要检查是否真的启用了XNACK,AMD有一个小的演示程序/opt/rocm/hip/bin/hipInfo。使用AMD_LOG_LEVEL=4 HSA_XNACK=1 ./hipInfo运行它,它应该在输出的开头报告xnack: 1。
Initializing HSA stack.
Loading COMGR library.
Numa selects cpu 
HMM support: 1, xnack: 1, direct host access: 0

在我的特定发行版(Gentoo)上,需要使用USE=debug来构建dev-libs/rocr-runtime以允许调试。默认情况下不会构建hipInfo程序,但可以在/usr/share/hip/samples/1_Utils/hipInfo中找到它。将Makefile中的HIPCC=$(HIP_PATH)/bin/hipcc改为HIPCC=hipcc,然后运行make
启用XNACK后,我的代码性能恢复正常,性能损失仅为200%,而不是1000%或4000%。
没有启用XNACK:
$ time HSA_XNACK=0 ./fdtd_usm.elf 
simulate 16974593 cells for 10 timesteps.

real    0m3.345s
user    0m4.272s
sys     0m0.223s

使用XNACK:

$ time HSA_XNACK=1 ./fdtd_usm.elf 
simulate 16974593 cells for 10 timesteps.

real    0m0.385s
user    0m0.343s
sys     0m0.050s

prefetch()mem_advise()

下一个问题是如何在没有启用的情况下实现良好的性能。答案是使用性能提示prefetch()mem_advise()。当禁用了时,这基本上是从主机手动复制到GPU。

此外,由于USM的开销不为零,并且页面迁移不完美,如果支持,还需要它们来最大化性能。

prefetch()

在GPU需要使用数据之前应预取数据。在CreateArrayXYZN()之后立即添加以下行:

/* 
 * Prefetch memory into the GPU. Performance critical!
 */
Q.prefetch(volt.array, volt.size);
Q.prefetch(curr.array, curr.size);
Q.prefetch(vv.array, vv.size);
Q.prefetch(vi.array, vi.size);
Q.wait();

在此更改后,性能损失减少到仅为200%,而不是1000%或4000%。

mem_advise()

然后,使用特定于平台的性能提示来告诉底层运行时我们希望数据保留在GPU上。不幸的是,并没有可用的标准提示。所以这是设备特定的,您可能需要在程序中使用查找表。

对于使用AMD HIP后端的OpenSYCL,它将mem_advise()提示直接传递给HIP的hipMemAdvise()源代码)。 AMD HIP提供了我们感兴趣的以下有用的提示

  • hipMemAdviseSetReadMostly: 数据主要用于读取,偶尔会进行写入操作。
  • hipMemAdviseSetPreferredLocation: 将数据的首选位置设置为指定的设备。
  • hipMemAdviseSetCoarseGrain: 默认的内存模型是细粒度的。这允许在执行内核时,在主机和设备之间进行一致性操作。而粗粒度可以用于只需要在调度边界处保持一致性以获得更好性能的数据。

因此,我添加了以下几行:

#define hipMemAdviseSetReadMostly 1
#define hipMemAdviseSetPreferredLocation 3
#define hipMemAdviseSetCoarseGrain 100

/*
 * Tell the runtime that we prefer data to stay on the GPU, and that
 * data coherency during simultaneously execution on both host and device
 * is not necessary.
 *
 * Note: Q.mem_advise() is the standard but OpenSYCL hasn't added its
 * support yet, so the OpenSYCL synchronous extension sycl::mem_advise
 * is used instead. The advise is hardware-specific! Here we use AMD HIP
 * advise values.
 */
sycl::mem_advise(volt.array, volt.size, hipMemAdviseSetPreferredLocation, Q);
sycl::mem_advise(volt.array, volt.size, hipMemAdviseSetCoarseGrain, Q);

sycl::mem_advise(curr.array, curr.size, hipMemAdviseSetReadMostly, Q);
sycl::mem_advise(curr.array, curr.size, hipMemAdviseSetPreferredLocation, Q);
sycl::mem_advise(curr.array, curr.size, hipMemAdviseSetCoarseGrain, Q);

sycl::mem_advise(vv.array, vv.size, hipMemAdviseSetReadMostly, Q);
sycl::mem_advise(vv.array, vv.size, hipMemAdviseSetPreferredLocation, Q);
sycl::mem_advise(vv.array, vv.size, hipMemAdviseSetCoarseGrain, Q);

sycl::mem_advise(vi.array, vi.size, hipMemAdviseSetReadMostly, Q);
sycl::mem_advise(vi.array, vi.size, hipMemAdviseSetPreferredLocation, Q);
sycl::mem_advise(vi.array, vi.size, hipMemAdviseSetCoarseGrain, Q);

在进行这次修改之后,USM的性能现在几乎与设备内存一样好。
我发现hipMemAdviseSetReadMostlyhipMemAdviseSetPreferredLocation没有任何效果,但是hipMemAdviseSetCoarseGrain能够消除设备内存和USM之间的最后性能差距 - 代价是在主机和设备之间同时执行时数据的一致性可能会受到影响,这可能对您的应用程序可接受也可能不可接受。对于我的使用情况来说,我相信显式的Q.wait()已经足够了。
以上4G解码和可调整大小的BAR
最后,启用"4G解码"和"可调整大小的BAR"可以提高主机到GPU数据传输的性能。在固件中启用这些功能后,我发现没有XNACK或预取的性能损失从4000%降低到1300%。这并不是一个真正的问题解决方案,但可以帮助在使用之前的方法修复USM后最大化性能。
讨论
缺少XNACK 基本问题似乎是大多数AMD独立显卡默认禁用了XNACK功能,或者根本不支持它。尽管从GFX8开始,硅理论上具备这种能力,根据ROCm中的ISA表所示。
关于XNACK到底是什么以及如何启用它,在除了少数几个地方之外,文档都记录得很差。

XNACK是什么

根据AMD的教程:
在MI200 GPU上,有一个选项可以自动迁移内存页面在主机和设备之间。这对于受管理的内存来说非常重要,因为数据的局部性对性能至关重要。根据系统的不同,页面迁移可能默认禁用,在这种情况下,受管理的内存将表现得像固定的主机内存,并且性能会下降。
启用页面迁移允许GPU(或主机)在页面错误(通常是内存访问错误)后进行重试,并获取缺失的页面。在MI200平台上,我们可以通过设置环境变量HSA_XNACK=1来启用页面迁移。虽然这个环境变量在内核运行时是必需的,以启用页面迁移,但在编译时启用这个环境变量也是有帮助的,它可以改变任何已编译内核的性能。 AMD Instinct™ MI200 GPU memory space overview也提到: 奥克里奇国家实验室超级计算机的文档中也提到:
XNACK(发音为 X-knack)指的是 AMD GPU 通过重试由于页面错误导致的内存访问失败的能力。MI250X 的 XNACK 模式可以通过在使用 GPU 的进程启动前设置环境变量 HSA_XNACK 来更改。有效值为 0(禁用)和 1(启用),所有连接到 GPU 的进程必须使用相同的 XNACK 设置。Crusher 上的默认 MI250X 是 HSA_XNACK=0。
如果 HSA_XNACK=0,GPU 内核中的页面错误不会被处理,并将终止内核。因此,GPU 访问的所有内存位置必须位于 GPU HBM 中或者由 HIP 运行时映射。内存区域可以使用显式的 HIP 库函数(如 hipMemAdvise 和 hipPrefetchAsync)在主机 DDR4 和 GPU HBM 之间进行迁移,但仅基于访问模式不会自动迁移内存。
如果 HSA_XNACK=1,GPU 内核中的页面错误将触发页表查找。如果可以使内存位置对 GPU 可访问,可以通过将其迁移到 GPU HBM 或者为远程访问进行映射来执行适当的操作,并重新播放访问。页面迁移将根据页面触摸在 CPU DDR4 和 GPU HBM 之间发生。例外情况是,如果程序员使用诸如 hipPrefetchAsync 的 HIP 库调用来请求迁移,或者通过 hipMemAdvise 设置了首选位置,或者如果 GPU HBM 变满且页面必须被强制驱逐回 CPU DDR4 以为其他数据腾出空间。

如何启用XNACK

  1. 硬件必须支持。

  2. 必须通过在Linux内核中启用noretry=0标志来启用。启用后,clinfoxnack+应该在GPU的ISA名称中报告xnack+

  3. 必须在运行HIP程序之前通过环境变量HSA_XNACK=1启用运行时。

不幸的是,许多专用桌面GPU不支持它,使得USM几乎无用。

如果你很幸运,很多GFX9系列的专用GPU支持XNACK。根据Linux内核源代码

bool kfd_process_xnack_mode(struct kfd_process *p, bool supported)
{
    int i;

    /* On most GFXv9 GPUs, the retry mode in the SQ must match the
     * boot time retry setting. Mixing processes with different
     * XNACK/retry settings can hang the GPU.
     *
     * Different GPUs can have different noretry settings depending
     * on HW bugs or limitations. We need to find at least one
     * XNACK mode for this process that's compatible with all GPUs.
     * Fortunately GPUs with retry enabled (noretry=0) can run code
     * built for XNACK-off. On GFXv9 it may perform slower.
     *
     * Therefore applications built for XNACK-off can always be
     * supported and will be our fallback if any GPU does not
     * support retry.
     */
    for (i = 0; i < p->n_pdds; i++) {
        struct kfd_dev *dev = p->pdds[i]->dev;

        /* Only consider GFXv9 and higher GPUs. Older GPUs don't
         * support the SVM APIs and don't need to be considered
         * for the XNACK mode selection.
         */
        if (!KFD_IS_SOC15(dev))
            continue;
        /* Aldebaran can always support XNACK because it can support
         * per-process XNACK mode selection. But let the dev->noretry
         * setting still influence the default XNACK mode.
         */
        if (supported && KFD_SUPPORT_XNACK_PER_PROCESS(dev))
            continue;

        /* GFXv10 and later GPUs do not support shader preemption
         * during page faults. This can lead to poor QoS for queue
         * management and memory-manager-related preemptions or
         * even deadlocks.
         */
        if (KFD_GC_VERSION(dev) >= IP_VERSION(10, 1, 1))
            return false;

        if (dev->noretry)
            return false;
    }

    return true;
}

事实证明,GFX9系列中的许多专用GPU支持XNACK(根据ROCM中ISA表),但默认情况下由amdgpu内核驱动程序禁用。这可能是因为稳定性问题,因为它仍然是一个实验性功能。可以通过内核参数noretry=0引导时内核参数amdgpu.noretry=0进行手动启用。

Linux内核文档表示:

noretry(int):在GFXv9硬件上默认禁用SQ中的XNACK重试。对于不支持每个进程XNACK的ASIC,这也会禁用重试页面故障。(0 = 启用重试,1 = 禁用重试,-1 = 自动(默认))

支持状态

不幸的是,自RDNA以来(大多数来自GFX10/GFX11系列的GPU),许多专用GPU都不支持XNACK。

页面迁移并不总是可用 - 例如,在AMD RDNA™ 2 GPU上或不支持异构内存管理(HMM)的操作系统中。

即使在像MI100或MI250x这样的超级计算机卡上,直到最近也没有支持,即使现在仍然处于实验阶段。根据2020年的一篇研究论文:

UM只适用于最新的AMD GPU,包括Vega10和MI100。不支持旧的GPU,如Fiji和Polaris。支持有两种模式:启用XNACK和禁用XNACK。在启用XNACK的模式下[...] 启用XNACK的模式仅具有实验性支持。ROCm中包含的所有数学库都不支持当前硬件上的启用XNACK模式。可以在启动时选择模式,默认为禁用XNACK。由于启用XNACK模式存在不确定性,我们的评估仅限于禁用XNACK模式。我们希望在未来的工作中研究启用XNACK模式。 在HIP中评估统一内存性能 AMD ROCm开发人员目前表示该功能仍处于实验阶段:
偶尔,HSA_XNACK会导致一些页面错误,并且在特定情况下内存可能无法按预期初始化。此问题正在调查中,并将在未来的版本中修复。
SYCL
看起来SYCL运行时在管理隐式内存传输方面的能力非常有限。我问了一个问题:“SYCL运行时使用什么启发式算法来确定是否需要复制?”答案是目前几乎没有(不像CPU的内存控制器那样)。在AMD GPU上,OpenSYCL的USM实现为hipMallocManaged(),因此,SYCL的按需分页完全依赖于HIP。
DPC++运行时并不是一个预测神器-它无法在应用程序访问数据之前预测到将要访问的数据。此外,指针分析对于编译器来说仍然是一个非常困难的问题,可能无法准确分析和识别内核中可能使用的每个分配。
因此,通过使用程序员提供的提示可以实现良好的性能。
DPC++给我们提供了一种修改自动迁移机制性能的方法。它通过定义两个函数来实现:prefetch和mem_advise。图6-8展示了每个函数的简单使用情况。这些函数让我们向运行时提供关于内核如何访问数据的提示,以便运行时可以选择在内核尝试访问数据之前开始移动数据。请注意,此示例使用直接在队列对象上调用parallel_for的队列快捷方式方法,而不是在传递给submit方法(命令组)的lambda内部调用。
我们最简单的方法是调用prefetch。该函数作为处理程序或队列类的成员函数调用,并接受基指针和字节数。这样,我们可以通知运行时某些数据即将在设备上使用,以便它可以急切地开始迁移数据。理想情况下,我们应该尽早发出这些预取提示,以便在内核触及数据时,数据已经驻留在设备上,消除了之前描述的延迟。
DPC++提供的另一个函数是mem_advise。该函数允许我们提供关于内核中如何使用内存的设备特定提示。我们可以指定的一个可能的建议示例是数据只会在内核中被读取,而不会被写入。在这种情况下,系统可以意识到它可以在设备上复制或重复数据,这样在内核完成后就不需要更新主机版本。然而,传递给mem_advise的建议是特定于特定设备的,因此在使用此函数之前,请务必查阅硬件文档。
《Data Parallel C++》,第6章。

SYCL 2020 specification 还提到:

用户可以通过在设备上排队预取操作来指定共享分配的性能提示。这些操作通知 SYCL 运行时,指定的共享分配可能会在将来的设备上访问,并且它可以自由地将分配迁移到设备上。有关预取的更多信息,请参见表28和表135。如果设备支持对共享分配的并发访问,则预取操作可以与内核执行重叠。

此外,用户还可以使用 mem_advise 成员函数为共享分配添加建议。有效的建议由设备及其关联的后端定义。有关更多信息,请参见表28和表135。


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