【OpenMP】针对 NUMA 架构的性能调优方法
# 【OpenMP】针对 NUMA 架构的性能调优方法## 序
鄙人写了个 C++ 程序,在 Thinkstation P920 服务器上部署,由同事的后端程序使用 `subprocess()` 方式调用,机器是 (https://www.0xaa55.com/thread-27373-1-1.html)。我想着一开始想着按 NUMA 架构的特性:「哪个线程初始化的内存,该内存分配出来距离哪个线程最近」, ***以 C++ 不需要内存池和 GC 的特性,尽量在线程函数里分配内存就好了*** 。后来发现我天真了,因为我需要处理图像图形 Bitmap,而图形的内存是一次性分配出来的,但是需要多个线程一起处理这块图形的内存,这就相当于我强制需要多个 CPU 访问同一块内存, ***必然存在距离远的 CPU 上的线程也得访问这块内存的情况*** 。
经过一番谷式搜索,我发现不能在运行时通过 OpenMP 的 API 来设置 CPU 绑定。因为如果你要想绑定 NUMA Node,你就得写 Vendor code(比如针对 MSVC 和 GCC)。但是只能搜到类似 CPU Affinity 相关的 API。在 Windows 上还好,有 MSDN 和例子代码;在 GNU Linux 的情况下,阅读 (https://www.gnu.org/software/libc/manual/html_node/CPU-Affinity.html),你根本看不明白。而 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 << " 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(" Running with `OMP_PROC_BIND=CLOSE`, proceed to limit CPU usage for ") + std::to_string(NumThreadsToUse) + " threads.\n";
}
}
else
{
if (Verbose)
{
std::cout << " `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 << " `putenv(\"OMP_PROC_BIND=CLOSE\")` failed: `" << strerror(errno) << "`\n";
}
else
{
#if __GNUC__
execv(argv, argv);
#elif _MSC_VER
auto CmdLine = std::string();
for (int i = 1; i < argc; i++)
{
CmdLine += argv;
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, &CmdLine, 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(" 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(" Optimizing for NUMA machine: `CreateProcessA()` failed to run: `") + GetLastErrorAsString() + "`. Proceed to run without \"OMP_PROC_BIND=CLOSE\".\n";
}
#endif
}
}
}
else
{
if (Verbose)
{
std::cout << " 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 的性能调优,设置环境变量就好了。 在多处理器上和集群上开发有一个关键点,处理数据要和提交数据要分开,处理gpu的相关的数据时要考虑gpu共享内存的所在核心,尽可能的避免对非cpu直接控制内存的计算操作,在cpu直接控制内存区域计算完成再进行拷贝,对于处理大量数据的时候整体效率是更高的,多处理器的内存转移是有单独的优化的
_thread proc uses ebx esi edi arg
LOCAL node:dword
xor ebx,ebx
xor esi,esi
invoke NtCurrentTeb
assume eax:ptr _TEB
movzx ebx,.PROCESSOR_NUMBER.uGroup
movzx esi,.PROCESSOR_NUMBER.Number
assume eax:nothing
invoke GetNumaProcessorNode,esi,addr node
invoke GetCurrentThreadId
invoke crt_printf,$CTA0("SetProcessAffinityMask % 8s ,thread % 6d ,Group % 2d ,Number % 4d ,node % 2d\n"),arg,eax,ebx,esi,node
ret
_thread endp
_init proc
invoke GetModuleHandle,$CTA0("KERNEL32")
mov edi,eax
invoke GetProcAddress,edi,$CTA0("GetNumaProcessorNode")
mov pfn_GetNumaProcessorNode,eax
invoke GetCurrentProcess
mov edi,eax
invoke GetProcessAffinityMask,edi,addr pam,addr sam
invoke crt_printf,$CTA0("pam %08X,sam %08X\n"),pam,sam
ret
_init endp
_main proc uses esi edi ebx
LOCAL tid:DWORD
invoke _init
xor ebx,ebx
.repeat
invoke CreateThread,NULL,NULL,offset _thread,$CTA0(""),NULL,addr tid
inc ebx
.until ebx >30
invoke Sleep,1000
mov eax,0fffh
and eax,pam
invoke SetProcessAffinityMask,edi,eax
xor ebx,ebx
.repeat
invoke CreateThread,NULL,NULL,offset _thread,$CTA0("0fffh"),NULL,addr tid
inc ebx
.until ebx >30
invoke Sleep,1000
mov eax,0fff000h
and eax,pam
invoke SetProcessAffinityMask,edi,0fff000h
invoke Sleep,1000
xor ebx,ebx
.repeat
invoke CreateThread,NULL,NULL,offset _thread,$CTA0("0fff000h"),NULL,addr tid
inc ebx
.until ebx >30
invoke Sleep,10000
invoke crt_printf,$CTA0("create thread end\n")
invoke crt_system,$CTA0("pause\n")
ret
_main endp
end _main
不使用线程池的情况下,本身是没啥问题的,等我翻翻线程池的代码
在不设置亲和的情况下,创建线程池也不会不同核心之间来回跳 AyalaRs 发表于 2024-5-17 21:33
在多处理器上和集群上开发有一个关键点,处理数据要和提交数据要分开,处理gpu的相关的数据时要考虑gpu共享 ...
确实如此,但是这样会增加内存分配、释放的开销,每个线程需要给自己分配内存,除非每个 OpenMP Work Thread 使用的内存是钦定的,但我的项目已经有了大量的 OpenMP Clauses,不至于一个个改过来。
另外,这边需要代码的可读性。我把代码按照非 NUMA 方式设计,就是为了好读。等到部署到 NUMA 机器上,我的程序会被自动分配到一个低负载 NUMA 节点,然后只使用那个 NUMA 节点的资源。
你说的不设置 CPU 亲和度和我文中的策略相同,也是不设置亲和度,但是设置 close to 主线程的 NUMA 节点。 0xAA55 发表于 2024-5-20 09:55
确实如此,但是这样会增加内存分配、释放的开销,每个线程需要给自己分配内存,除非每个 OpenMP Work Thr ...
而 Windows 上,你需要大量的 API 调用来判断哪几组线程对应哪个 CPU 核心。
我对这个结论感到意外而已,因为这和我的经验不符,
页:
[1]