【OpenMP】针对 NUMA 架构的性能调优方法
序
鄙人写了个 C++ 程序,在 Thinkstation P920 服务器上部署,由同事的后端程序使用 subprocess()
方式调用,机器是 NUMA 机器。我想着一开始想着按 NUMA 架构的特性:「哪个线程初始化的内存,该内存分配出来距离哪个线程最近」, 以 C++ 不需要内存池和 GC 的特性,尽量在线程函数里分配内存就好了 。后来发现我天真了,因为我需要处理图像图形 Bitmap,而图形的内存是一次性分配出来的,但是需要多个线程一起处理这块图形的内存,这就相当于我强制需要多个 CPU 访问同一块内存, 必然存在距离远的 CPU 上的线程也得访问这块内存的情况 。
经过一番谷式搜索,我发现不能在运行时通过 OpenMP 的 API 来设置 CPU 绑定。因为如果你要想绑定 NUMA Node,你就得写 Vendor code(比如针对 MSVC 和 GCC)。但是只能搜到类似 CPU Affinity 相关的 API。在 Windows 上还好,有 MSDN 和例子代码;在 GNU Linux 的情况下,阅读 GNU 官网上的 API 文档,你根本看不明白。而 Windows 上,你需要大量的 API 调用来判断哪几组线程对应哪个 CPU 核心。一个 CPU 核心的线程组的 ID 并不是连续的。由此我得出以下结论:
针对 NUMA 的性能调优,任何与 CPU Affinity 相关的代码编写不管是 Windows 还是 Linux 都是深坑。
网上靠谱的解决方案只有一个,那就是设置环境变量 OMP_PROC_BIND=CLOSE
,然后再运行你的程序,这样的话 OpenMP 的线程池会尽量让线程去处理最靠近它的内存 。你如果中途使用 putenv()
的方式设置了这个环境变量,那是不起作用的,只对你的子进程起作用。抓耳挠腮之际,我想出了一个馊主意:检测环境变量,如果没有这条环境变量的设定,则设置环境变量,然后重新运行自身程序,再返回它的返回值,以此模拟自身已经是设置好了环境变量后再运行的情形,就不需要后端同事改代码了。
设置环境变量,然后启动子进程
我尝试了针对 Linux GCC 的 fork()
方式,但是因为一些不知道啥的原因冒出了 Segmentation Fault
。而 exec()
的方式则经过测试发现有用,所以我检测是否 gcc 编译,是的话使用 putenv()
设置该环境变量,再 execv()
,传递 argv[]
,子进程就能检测到自身环境变量预先设置了。
针对 Windows 的情况,我使用 CreateProcessA()
来执行我的子进程,同样有效。而且 Windows 可以只在检测到 NUMA 机器的时候进行这个环境变量的设置,并且 Windows 平台针对 NUMA 的 API 也比较完善方便调用。
C++ 代码实现
在 int main()
所在的文件里,使用以下代码。
#if __GNUC__
#include <unistd.h>
#include <numa.h>
#elif _MSC_VER
#define NOMINMAX 1
#include <Windows.h>
std::string GetLastErrorAsString()
{
DWORD errorMessageID = GetLastError();
if (errorMessageID == 0) return "";
LPSTR messageBuffer = nullptr;
size_t size = FormatMessageA
(
FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
NULL,
errorMessageID,
MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
(LPSTR)&messageBuffer,
0,
NULL
);
auto ret= std::string(messageBuffer, size);
LocalFree(messageBuffer);
return ret;
}
#endif
int GetNumCPU()
{
#if __GNUC__
return numa_max_node() + 1;
#elif _MSC_VER
ULONG HighestNodeNumber = 0;
if (GetNumaHighestNodeNumber(&HighestNodeNumber))
{ // 判断是不是 NUMA 机器。
return int(HighestNodeNumber) + 1;
}
else
{
std::cerr << "[WARN] Could not determine if the machine is a NUMA machine.\n";
return 1;
}
#endif
}
注意要依赖 libnuma-dev 库。
在 int main()
里,使用以下代码。
int NumCPU = GetNumCPU();
if (NumCPU > 1)
{
// 使 OpenMP 的多线程绑定核心。
auto Env_OMP_PROC_BIND = getenv("OMP_PROC_BIND");
if (Env_OMP_PROC_BIND && !strcmp(Env_OMP_PROC_BIND, "CLOSE"))
{
int NumThreadsToUse = omp_get_max_threads() / NumCPU;
omp_set_num_threads(NumThreadsToUse);
if (Verbose)
{
std::cout << std::string("[INFO] Running with `OMP_PROC_BIND=CLOSE`, proceed to limit CPU usage for ") + std::to_string(NumThreadsToUse) + " threads.\n";
}
}
else
{
if (Verbose)
{
std::cout << "[WARN] `OMP_PROC_BIND=CLOSE` not set, will set it and rerun for better performance on NUMA machine.\n";
}
char EnvForOMP[] = "OMP_PROC_BIND=CLOSE";
if (putenv(EnvForOMP))
{
std::cerr << "[WARN] `putenv(\"OMP_PROC_BIND=CLOSE\")` failed: `" << strerror(errno) << "`\n";
}
else
{
#if __GNUC__
execv(argv[0], argv);
#elif _MSC_VER
auto CmdLine = std::string();
for (int i = 1; i < argc; i++)
{
CmdLine += argv[i];
CmdLine += " ";
}
CmdLine.back() = '\0';
PROCESS_INFORMATION ProcInfo;
STARTUPINFOA StartupInfo =
{
sizeof(STARTUPINFOA), NULL, NULL, NULL, 0, 0, 0, 0, 0, 0, 0,
STARTF_USESTDHANDLES,
0,
0,
NULL,
GetStdHandle(STD_INPUT_HANDLE),
GetStdHandle(STD_OUTPUT_HANDLE),
GetStdHandle(STD_ERROR_HANDLE)
};
if (CreateProcessA(argv[0], &CmdLine[0], NULL, NULL, TRUE, 0, 0, NULL, &StartupInfo, &ProcInfo))
{
DWORD ExitCode = 0;
do
{
if (GetExitCodeProcess(ProcInfo.hProcess, &ExitCode))
{
if (ExitCode == STILL_ACTIVE)
Sleep(100);
else
break;
}
else
{
std::cerr << std::string("[WARN] Optimizing for NUMA machine: `GetExitCodeProcess()` failed: `") + GetLastErrorAsString() + "`. Will not monitor the subprocess.\n";
break;
}
} while (true);
CloseHandle(ProcInfo.hThread);
CloseHandle(ProcInfo.hProcess);
return int(ExitCode);
}
else
{
std::cerr << std::string("[WARN] Optimizing for NUMA machine: `CreateProcessA()` failed to run: `") + GetLastErrorAsString() + "`. Proceed to run without \"OMP_PROC_BIND=CLOSE\".\n";
}
#endif
}
}
}
else
{
if (Verbose)
{
std::cout << "[INFO] Detected non-NUMA machine. Proceed to run without \"OMP_PROC_BIND=CLOSE\".\n";
}
}
测试效果
在 NUMA 机器上
- 不使用这段代码的时候,我的程序需要消耗 80000 ms 以上。
- 使用这段代码的时候,我的程序运行需要消耗 1000 ms 左右。
在非 NUMA 的机器上
- 不管用不用这段代码,我的程序都只需要使用 900 ms 左右。
结论
通过设置环境变量然后将自身程序当作子进程来调用,即可使 OpenMP 的环境变量调整生效。
不要去碰 CPU Affinity 相关代码,你要做到跨平台需要付出大量的努力。
针对 OpenMP 的性能调优,设置环境变量就好了。