【问题标题】:mmap: performance when using multithreadingmmap:使用多线程时的性能
【发布时间】:2019-05-03 07:51:16
【问题描述】:

我有一个程序可以对很多文件(> 10 000)执行一些操作。它产生 N 个工作线程,每个线程映射一些文件,执行一些工作并对其进行映射。

我现在面临的问题是,每当我只使用 1 个进程和 N 个工作线程时,它的性能都比生成 2 个进程更差,每个进程都有 N/2 个工作线程。我可以在iotop 中看到这一点,因为 1 个进程+N 个线程仅使用大约 75% 的磁盘带宽,而 2 个进程+N/2 个线程使用全部带宽。

一些注意事项:

  • 仅当我使用 mmap()/munmap() 时才会发生这种情况。我试图用 fopen()/fread() 替换它,它工作得很好。但是由于 mmap()/munmap() 带有 3rd 方库,我想以原始形式使用它。
  • madvise() 使用 MADV_SEQUENTIAL 调用,但如果我删除它或更改建议参数,它似乎并没有改变任何东西(或者它只是减慢它的速度)。
  • 线程亲和性似乎无关紧要。我试图将每个线程限制为特定的核心。我还尝试将线程限制为核心对(超线程)。到目前为止没有结果。
  • htop 报告的负载似乎在两种情况下都相同。

所以我的问题是:

  • mmap() 在多线程环境中使用时有什么我不知道的吗?
  • 如果是这样,为什么 2 个进程的性能更好?

编辑:

  • 正如 cmets 中所指出的,它在具有 2xCPU 的服务器上运行。我可能应该尝试设置线程关联,使其始终在同一个 CPU 上运行,但我想我已经尝试过了,但没有成功。
  • 这是一段代码,我可以用它重现与我的生产软件相同的问题。
#include <condition_variable>
#include <deque>
#include <filesystem>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

#ifndef WORKERS
#define WORKERS 16
#endif

bool stop = false;
std::mutex queue_mutex;
std::condition_variable queue_cv;

std::pair<const std::uint8_t*, std::size_t> map_file(const std::string& file_path)
{
    int fd = open(file_path.data(), O_RDONLY);
    if (fd != -1)
    {
        auto dir_ent = std::filesystem::directory_entry{file_path.data()};
        if (dir_ent.is_regular_file())
        {
            auto size = dir_ent.file_size();
            auto data = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0);
            madvise(data, size, MADV_SEQUENTIAL);
            close(fd);
            return { reinterpret_cast<const std::uint8_t*>(data), size };
        }

        close(fd);
    }

    return { nullptr, 0 };
}

void unmap_file(const std::uint8_t* data, std::size_t size)
{
    munmap((void*)data, size);
}

int main(int argc, char* argv[])
{
    std::deque<std::string> queue;

    std::vector<std::thread> threads;
    for (std::size_t i = 0; i < WORKERS; ++i)
    {
        threads.emplace_back(
            [&]() {
                std::string path;

                while (true)
                {
                    {
                        std::unique_lock<std::mutex> lock(queue_mutex);
                        while (!stop && queue.empty())
                            queue_cv.wait(lock);
                        if (stop && queue.empty())
                            return;
                        path = queue.front();
                        queue.pop_front();
                    }

                    auto [data, size] = map_file(path);
                    std::uint8_t b = 0;
                    for (auto itr = data; itr < data + size; ++itr)
                        b ^= *itr;
                    unmap_file(data, size);

                    std::cout << (int)b << std::endl;
                }
            }
        );
    }

    for (auto& p : std::filesystem::recursive_directory_iterator{argv[1]})
    {
        std::unique_lock<std::mutex> lock(queue_mutex);
        if (p.is_regular_file())
        {
            queue.push_back(p.path().native());
            queue_cv.notify_one();
        }
    }

    stop = true;
    queue_cv.notify_all();

    for (auto& t : threads)
        t.join();

    return 0;
}

【问题讨论】:

  • 为什么 2 个进程的性能更好 - 可能是线程之间的意外交互或错误共享。贴出代码。
  • 我会试着想出一些最小的例子。我只是想我错过了一些关于 mmap() 和线程的东西,但如果确定原因不是那么简单,我会想出一些东西。
  • 在多插槽机器上,内存也可能分配在远程 NUMA 节点而不是本地节点上。查看numactl --hardware 的输出。
  • 如果有任何改变,它确实是具有 2xCPU 的服务器。我会尽快发布最小示例。
  • 我已经添加了一个可以重现问题的代码。

标签: linux multithreading mmap


【解决方案1】:

mmap()在多线程环境中使用时有什么我不知道的吗?

是的。 mmap() 需要大量的虚拟内存操作——有效地单线程处理你的进程。每this post from one Linus Torvalds:

...用虚拟内存映射玩游戏非常昂贵 在自身。它有许多人们倾向于的非常实际的缺点 忽略,因为内存复制被视为非常缓慢的事情,并且 有时优化该副本被视为一种明显的改进。

mmap 的缺点:

  • 相当可观的安装和拆卸成本。我的意思是引人注目。 就像按照页表取消映射所有内容一样 干净地。它是用于维护所有清单的簿记 映射。这是取消映射后需要的 TLB 刷新。

  • 页面错误代价高昂。这就是映射被填充的方式, 而且速度很慢。

请注意,以上大部分内容还必须是跨整个机器的单线程,例如物理内存的实际映射。

因此,映射文件所需的虚拟内存操作不仅昂贵,而且实际上不能并行完成 - 内核必须跟踪的实际物理内存只有一块,并且多个线程不能并行化更改进程的虚拟地址空间。

几乎可以肯定,为每个文件重用内存缓冲区会获得更好的性能,其中每个缓冲区创建一次,并且足够大以容纳读取到其中的任何文件,然后使用从文件中读取低级 POSIX read() 调用。您可能想尝试使用页面对齐缓冲区并使用直接 IO,方法是调用带有 O_DIRECT 标志(Linux 特定)的 open() 来绕过页面缓存,因为您显然从未重新读取任何数据并且任何缓存都是浪费内存和 CPU 周期。

重用缓冲区也完全消除了任何munmap()delete/free()

不过,您必须管理缓冲区。也许用 N 个预先创建的缓冲区预填充队列,并在处理完文件后将缓冲区返回到队列?

至于

如果是,为什么 2 个进程的性能更好?

使用两个进程将由mmap() 调用引起的特定于进程的虚拟内存操作拆分为两个可并行运行的可分离集合。

【讨论】:

  • 自 2000 年那篇文章以来,mmap 一直在优化,但是,正如您所说,操作 vm_area_struct、页面映射和 TLB 仍然非常昂贵且单线程。
  • @MaximEgorushkin 同意,但这是我所知道的最好的权威来源,它清楚而简洁地总结了mmap() 的真实成本。在只读取一次的文件上使用mmap() 可能是mmap() 最糟糕的用例,至少在性能方面是这样。我个人认为mmap() 主要是一个功能,它可以大大简化代码,但性能成本
  • 同意。 IMO,mmap 的最佳情况是使用MAP_POPULATEMAP_LOCKED 标志映射一个大文件,以最大限度地减少页面错误,可能还有MAP_HUGETLB。 +1。
  • 谢谢,这很有道理。我知道mmap() 可能不是在这里使用的最佳选择,但我使用的是mmap() 的第三方库,所以上面的最小示例只是库的简化版本。我可能最终会自己修改库,因为当我开始读取文件而不是映射它时一切都很好。
【解决方案2】:

一些注意事项:

  1. 尝试使用perf stat -ddd &lt;app&gt; 运行您的应用程序,并查看上下文切换、cpu 迁移和页面错误数。
  2. 线程可能在mmap 和页面错误的内核进程结构中竞争vm_area_struct。尝试将 MAP_POPULATEMAP_LOCKED 标志传递到 mmap 以最大限度地减少页面错误。或者,仅在主线程中尝试 mmapMAP_POPULATEMAP_LOCKED 标志(在这种情况下,您可能希望确保所有线程都在同一个 NUMA 节点上运行)。
  3. 您可能还想尝试MAP_HUGETLBMAP_HUGE_2MB, MAP_HUGE_1GB 标志之一。
  4. 尝试使用numactl 将线程绑定到同一个NUMA 节点,以确保线程仅访问本地NUMA 内存。例如。 numactl --membind=0 --cpunodebind=0 &lt;app&gt;
  5. stop = true之前锁定互斥锁,否则条件变量通知可能会丢失并永远死锁等待线程。
  6. p.is_regular_file() 检查不需要锁定互斥体。
  7. std::deque 可以替换为 std::list 并使用 splice 推送和弹出元素,以最大限度地减少互斥锁的锁定时间。

【讨论】:

  • 谢谢。我已经开始尝试您提到的标志。我还找到了set_mempolicy,它基本上是numactl。到目前为止没有结果,但至少我不再被困住了。我会在尝试所有选项时回复您。
猜你喜欢
  • 2021-04-28
  • 2021-06-13
  • 1970-01-01
  • 2022-01-26
  • 2012-09-01
  • 2017-05-01
  • 2015-08-21
相关资源
最近更新 更多