0xAA55 发表于 2024-5-17 09:51:00

【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 的性能调优,设置环境变量就好了。

AyalaRs 发表于 2024-5-17 21:33:36

在多处理器上和集群上开发有一个关键点,处理数据要和提交数据要分开,处理gpu的相关的数据时要考虑gpu共享内存的所在核心,尽可能的避免对非cpu直接控制内存的计算操作,在cpu直接控制内存区域计算完成再进行拷贝,对于处理大量数据的时候整体效率是更高的,多处理器的内存转移是有单独的优化的

AyalaRs 发表于 2024-5-18 17:25:53



_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-18 18:39:36


在不设置亲和的情况下,创建线程池也不会不同核心之间来回跳

0xAA55 发表于 2024-5-20 09:55:48

AyalaRs 发表于 2024-5-17 21:33
在多处理器上和集群上开发有一个关键点,处理数据要和提交数据要分开,处理gpu的相关的数据时要考虑gpu共享 ...

确实如此,但是这样会增加内存分配、释放的开销,每个线程需要给自己分配内存,除非每个 OpenMP Work Thread 使用的内存是钦定的,但我的项目已经有了大量的 OpenMP Clauses,不至于一个个改过来。

另外,这边需要代码的可读性。我把代码按照非 NUMA 方式设计,就是为了好读。等到部署到 NUMA 机器上,我的程序会被自动分配到一个低负载 NUMA 节点,然后只使用那个 NUMA 节点的资源。

你说的不设置 CPU 亲和度和我文中的策略相同,也是不设置亲和度,但是设置 close to 主线程的 NUMA 节点。

AyalaRs 发表于 2024-5-20 10:12:52

0xAA55 发表于 2024-5-20 09:55
确实如此,但是这样会增加内存分配、释放的开销,每个线程需要给自己分配内存,除非每个 OpenMP Work Thr ...

而 Windows 上,你需要大量的 API 调用来判断哪几组线程对应哪个 CPU 核心。
我对这个结论感到意外而已,因为这和我的经验不符,
页: [1]
查看完整版本: 【OpenMP】针对 NUMA 架构的性能调优方法