找回密码
 立即注册→加入我们

QQ登录

只需一步,快速开始

搜索
热搜: 下载 VB C 实现 编写
查看: 302|回复: 5

【OpenMP】针对 NUMA 架构的性能调优方法

[复制链接]
发表于 2024-5-17 09:51:00 | 显示全部楼层 |阅读模式

欢迎访问技术宅的结界,请注册或者登录吧。

您需要 登录 才可以下载或查看,没有账号?立即注册→加入我们

×

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

回复

使用道具 举报

发表于 2024-5-17 21:33:36 | 显示全部楼层
在多处理器上和集群上开发有一个关键点,处理数据要和提交数据要分开,处理gpu的相关的数据时要考虑gpu共享内存的所在核心,尽可能的避免对非cpu直接控制内存的计算操作,在cpu直接控制内存区域计算完成再进行拷贝,对于处理大量数据的时候整体效率是更高的,多处理器的内存转移是有单独的优化的
回复 赞! 靠!

使用道具 举报

发表于 2024-5-18 17:25:53 | 显示全部楼层


  1. _thread proc uses ebx esi edi arg
  2.         LOCAL node:dword
  3.         xor ebx,ebx
  4.         xor esi,esi
  5.        
  6.        
  7.         invoke NtCurrentTeb
  8.        
  9.         assume eax:ptr _TEB
  10.         movzx ebx,[eax].PROCESSOR_NUMBER.uGroup
  11.         movzx esi,[eax].PROCESSOR_NUMBER.Number
  12.        
  13.         assume eax:nothing
  14.        
  15.        
  16.         invoke GetNumaProcessorNode,esi,addr node
  17.         invoke GetCurrentThreadId
  18.         invoke crt_printf,$CTA0("SetProcessAffinityMask % 8s ,thread % 6d ,Group % 2d ,Number % 4d ,node % 2d\n"),arg,eax,ebx,esi,node
  19.        
  20.         ret
  21. _thread endp


  22. _init proc
  23.        
  24.        
  25.         invoke GetModuleHandle,$CTA0("KERNEL32")
  26.         mov edi,eax
  27.         invoke GetProcAddress,edi,$CTA0("GetNumaProcessorNode")
  28.         mov pfn_GetNumaProcessorNode,eax
  29.        
  30.        
  31.         invoke GetCurrentProcess
  32.         mov edi,eax
  33.         invoke GetProcessAffinityMask,edi,addr pam,addr sam
  34.         invoke crt_printf,$CTA0("pam %08X,sam %08X\n"),pam,sam
  35.         ret
  36. _init endp


  37. _main proc uses esi edi ebx
  38.         LOCAL tid:DWORD

  39.        
  40.        
  41.        
  42.         invoke _init
  43.        
  44.        
  45.        
  46.         xor ebx,ebx
  47.         .repeat
  48.                
  49.                 invoke CreateThread,NULL,NULL,offset _thread,$CTA0(""),NULL,addr tid
  50.                 inc ebx
  51.         .until ebx >30
  52.        
  53.         invoke Sleep,1000
  54.        
  55.        
  56.         mov eax,0fffh
  57.         and eax,pam
  58.         invoke SetProcessAffinityMask,edi,eax
  59.        
  60.        
  61.         xor ebx,ebx
  62.         .repeat
  63.                
  64.                 invoke CreateThread,NULL,NULL,offset _thread,$CTA0("0fffh"),NULL,addr tid
  65.                
  66.                
  67.                 inc ebx
  68.         .until ebx >30
  69.        
  70.         invoke Sleep,1000
  71.         mov eax,0fff000h
  72.         and eax,pam
  73.        
  74.         invoke SetProcessAffinityMask,edi,0fff000h

  75.        
  76.         invoke Sleep,1000
  77.         xor ebx,ebx
  78.         .repeat
  79.                
  80.                 invoke CreateThread,NULL,NULL,offset _thread,$CTA0("0fff000h"),NULL,addr tid
  81.                
  82.                
  83.                 inc ebx
  84.         .until ebx >30
  85.        
  86.         invoke Sleep,10000
  87.         invoke crt_printf,$CTA0("create thread end\n")
  88.         invoke crt_system,$CTA0("pause\n")
  89.         ret
  90. _main endp


  91. end _main
复制代码

不使用线程池的情况下,本身是没啥问题的,等我翻翻线程池的代码
屏幕截图 2024-05-18 171752.png
回复 赞! 靠!

使用道具 举报

发表于 2024-5-18 18:39:36 | 显示全部楼层
屏幕截图 2024-05-18 183817.png
在不设置亲和的情况下,创建线程池也不会不同核心之间来回跳
回复 赞! 靠!

使用道具 举报

 楼主| 发表于 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 节点。
回复 赞! 靠!

使用道具 举报

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

而 Windows 上,你需要大量的 API 调用来判断哪几组线程对应哪个 CPU 核心。
我对这个结论感到意外而已,因为这和我的经验不符,
回复 赞! 靠!

使用道具 举报

本版积分规则

QQ|Archiver|小黑屋|技术宅的结界 ( 滇ICP备16008837号 )|网站地图

GMT+8, 2024-6-24 10:43 , Processed in 0.036569 second(s), 28 queries , Gzip On.

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表