无需控制 NUMA 共享内存策略即可在基于 NUMA 的 Nehalem-EX 系统上获得较高性能

简介

默认在 Nehalem-EX 上执行的传统 MKL 性能测试中有很多 MKL LAPACK 函数的性能都较低,尤其是 DGETRF。经检测发现,同样的系统在 Linux 下使用诸如 numactll 之类的实用程序(即控制 NUMA 共享内存策略)性能将获得显著提升,即用以下方式启动应用:

numactl --interleave=all <application>

与仅在默认模式下运行应用不同,当 NUMA 内存策略不受控制时:

<application>

提升 MKL LAPACK 函数的性能级别。这同样适用 BLAS 函数。

Windows 不提供此类实用程序,因此其上的性能较差。

经过之前对基于安腾 NUMA 的 SGI Altix 系统(64 节点)的观察发现,在多条线程内初始化分解矩阵(即分配矩阵)有助于提高性能。本文旨在详细介绍 Nehalem-EX 系统上采用的这一方法,并针对如何在不考虑 NUMA 内存策略的情况下在基于 NUMA 的系统上获得最佳性能而提供建议。

1numactl 使用特定的 NUMA 调度或内存放置策略运行进程。此策略针对命令进行设置,并被所有子策略继承。

此外,它可以为共享的内存分段或文件设置持久策略。

策略设置为:

--interleave=nodes, -i nodes

设置内存交替策略。内存将使用节点上的轮询进行分配。当内存无法分配在当前交替上时,目标将退回其他节点。

无 NUMA 内存策略控制的性能问题

MKL DGETRF 示例

当在某节点上初始化一个输入矩阵时,我们无法在默认模式下获得与使用可控 NUMA 内存策略时相同的性能级别。下图显示了默认模式下各种规模的严格性能限制(未使用 numactl)。
 


交替内存策略让数据通常更靠近代码执行处的内核,因而会严重影响性能。

工作数据会占据 LLC(最末级缓存),也就是说数据一旦进入就不会再出来,高速缓存仅在最开始时为空置状态,这解释了 1K-3K 方程式具有高性能级别的原因。

由于 DGETRF 约 80-90% 的时间用在 DGEMM 上,因此,此限制明显继承自 DEGMM 对“远”内存的性能限制。
 

矩阵分配效果

Linpack 示例

首先对 Linpack 进行实验。对不同的矩阵初始化方式进行检查:
 

  1. 传统的单节点分配:整个矩阵在主线程中完成初始化(及之前的分配);
  2. 多节点分配:矩阵使用 1-d 块循环分配在多条线程中完成初始化,即,通过轮询方式,线程 #1 填充列 1:nb,线程 #2 填充列 nb+1:2*nb,以此类推。此处的 nb 取决于问题大小,与 LU 算法的块大小相等。

在两种方式中,Linpack 均以默认模式在 numactl 下运行。

下图显示了矩阵初始化方式对性能的影响。
 


多节点矩阵分配可让性能级别接近最高级,即使默认模式下也是如此。

换言之,在调用 MKL 前将数据分配到多个节点,可达到与使用 numactl 控制 NUMA 内存策略相同的效果。

有效分配参数 nb

接下来的实验显示了 1-d 块循环分配参数 nb 的有效值。如下图所示,不使用 numactl 的情况下,当 nb 值达到 64 时性能最佳。较大值将减慢代码速度,值越大速度越慢。
 


DGEMM 示例

我们来研究一下与 Linpack 有关的 DGEMM 外积案例,即 A、B 未调换顺序,大 M = N、K ~100。运算为 C = C - A*B:
 


其中 A 为 M-by-K,B 为 K-by-N,C 为 M-by-N。

再一次,在不同 K 上进行了各种性能测试:
 

  1. NUMA 交替内存策略(numactl ––interleave=all)开启,矩阵未分配;
  2. 无 NUMA 内存策略(默认模式),矩阵已分配;
  3. 无 NUMA 内存策略(默认模式),矩阵未分配。

结果如下图所示:K=64,K=192。
 

 


以下陈述对于考虑数据分配和 NUMA 内存策略的 DGEMM 性能成立:
 

  1. 如果数据未分配,numactl 允许获得较之默认模式下更高或非常高的性能,K 值越小性能差别越大;
  2. 在默认模式下分配矩阵与应用 NUMA 交替内存策略的效果几乎相同。

如何分配矩阵

此处使用的分配方法是采用 1-d 块循环方式的并行矩阵初始化。以下是一个代码段分配矩阵 a[]:Mxn,列优先布局,主要维度 lda,使用 OpenMP: 的分配因子 nb

#pragma omp parallel for default(shared) private(j) schedule(static,1)
for( j = 0; j < n; j += nb )
	// init_matrix(m,n,a,lda,i,j) initializes sub-matrix a[]:m-by-n 
	// with leading dimension lda, located at (i,j) offset from
	// the top-left corner of some global matrix
	init_matrix( m, min( nb, n-j ), &a[j*lda], lda, 0, j );

当将矩阵 b 复制到 a,以便在进一步计算中使用矩阵时,也可以使用相同的分配方法:

#pragma omp parallel for default(shared) private(j) schedule(static,1)
for( j = 0; j < n; j += nb )
	// copy_matrix(m,n,b,ldb,a,lda) copies matrix b[]:m-by-n 
	// with leading dimension ldb into matrix a[]:m-by-n with
	// leading dimension lda
	copy_matrix( m, min( nb, n-j ), &b[j*ldb], ldb, &a[j*lda], lda );

最后,矩阵可根据之前的多线程函数保持分配状态,函数从不同的线程访问矩阵的不同部分。
 

有关编译器优化的更完整信息,请参阅优化通知