循环文件映射会降低性能

13

我有一个循环缓冲区,它由文件映射内存支持(缓冲区的大小范围在8GB-512GB之间)。

我以顺序方式写入(8个实例的)内存,从开头到结尾,然后它会循环回到开头。

一切都很好,直到它到达末尾,需要执行两个文件映射并循环遍历内存,此时IO性能完全崩溃,并且无法恢复(即使经过几分钟)。我无法弄清楚原因。

using namespace boost::interprocess;

class mapping
{
public:

  mapping()
  {
  }

  mapping(file_mapping& file, mode_t mode, std::size_t file_size, std::size_t offset, std::size_t size)
    : offset_(offset)
    , mode_(mode)
  {     
    const auto aligned_size         = page_ceil(size + page_size());
    const auto aligned_file_size    = page_floor(file_size);
    const auto aligned_file_offset  = page_floor(offset % aligned_file_size);
    const auto region1_size         = std::min(aligned_size, aligned_file_size - aligned_file_offset);
    const auto region2_size         = aligned_size - region1_size;

    if (region2_size)
    {
      const auto region1_address  = mapped_region(file, read_only, 0, (region1_size + region2_size) * 2).get_address(); 
      const auto region2_address  = reinterpret_cast<char*>(region1_address) + region1_size;  

      region1_ = mapped_region(file, mode, aligned_file_offset, region1_size, region1_address);
      region2_ = mapped_region(file, mode, 0,                   region2_size, region2_address);
    }
    else
    {
      region1_ = mapped_region(file, mode, aligned_file_offset, region1_size);
      region2_ = mapped_region();
    }

    size_ = region1_.get_size() + region2_.get_size();
    offset_ = aligned_file_offset;
  }

  auto offset() const   -> std::size_t  { return offset_; }
  auto size() const     -> std::size_t  { return size_; }
  auto data() const     -> const void*  { return region1_.get_address(); }
  auto data()           -> void*        { return region1_.get_address(); }
  auto flush(bool async = true) -> void
  {
    region1_.flush(async);
    region2_.flush(async);
  }
  auto mode() const -> mode_t { return mode_; }

private:
  std::size_t   offset_ = 0;
  std::size_t   size_ = 0;
  mode_t        mode_;
  mapped_region region1_;
  mapped_region region2_;
};

struct loop_mapping::impl final
{     
  std::tr2::sys::path         file_path_;
  file_mapping                file_mapping_;    
  std::size_t                 file_size_;
  std::size_t                 map_size_     = page_floor(256000000ULL);

  std::shared_ptr<mapping>    mapping_ = std::shared_ptr<mapping>(new mapping());
  std::shared_ptr<mapping>    prev_mapping_;

  bool                        write_;

public:
  impl(std::tr2::sys::path path, bool write)
    : file_path_(std::move(path))
    , file_mapping_(file_path_.string().c_str(), write ? read_write : read_only)
    , file_size_(page_floor(std::tr2::sys::file_size(file_path_)))
    , write_(write)
  {     
    REQUIRE(file_size_ >= map_size_ * 3);
  }

  ~impl()
  {
    prev_mapping_.reset();
    mapping_.reset();
  }

  auto data(std::size_t offset, std::size_t size, boost::optional<bool> write_opt) -> void*
  { 
    offset = offset % page_floor(file_size_);

    REQUIRE(size < file_size_ - map_size_ * 3);

    const auto write = write_opt.get_value_or(write_);

    REQUIRE(!write || write_);          

    if ((write && mapping_->mode() == read_only) || offset < mapping_->offset() || offset + size >= mapping_->offset() + mapping_->size())
    {
      auto new_mapping = std::make_shared<loop::mapping>(file_mapping_, write ? read_write : read_only, file_size_, page_floor(offset), std::max(size + page_size(), map_size_));

      if (mapping_)
        mapping_->flush((new_mapping->offset() % file_size_) < (mapping_->offset() % file_size_));

      if (prev_mapping_)
        prev_mapping_->flush(false);

      prev_mapping_ = std::move(mapping_);
      mapping_    = std::move(new_mapping);
    }

    return reinterpret_cast<char*>(mapping_->data()) + offset - mapping_->offset();
  }
}
// 8 processes to 8 different files 128GB each.
loop_mapping loop(...);
for (auto n = 0; true; ++n)
{
     auto src = get_new_data(5000000/8);
     auto dst = loop.data(n * 5000000/8, 5000000/8, true);
     std::memcpy(dst, src, 5000000/8); // This becomes very slow after loop around.
     std::this_thread::sleep_for(std::chrono::seconds(1));
}

有什么想法吗?

目标系统:

  • 1x 3TB 希捷星际系列 ES.3 硬盘
  • 2x Xeon E5-2400(6核心,2.6GHz)
  • 6x 8GB DDR3 1600MHz ECC内存
  • Windows Server 2012操作系统

“预分配”是什么意思?文件在内存映射之前创建其最终大小。不确定这会有什么区别?在问题开始发生之前/之时,我已经向整个文件写入了内容。 - ronag
7
听起来你正在尝试将内容交换到你的进程地址空间。仅仅因为文件被映射并不意味着它被提交到物理内存中;它仅仅是有一个映射的逻辑地址。以每个“项”244.14 MB(256000000字节)的大小计算,我可以很容易地看到这种情况的发生。如果读操作的目标也在需要交换到物理存储器的页面上,问题将会更加严重。你是否进行了进程评估,以查看此操作生成了多少页错误(触发从物理存储器读取到你的地址空间中的缺失)? - WhozCraig
在循环结束后,它会触发大量页面错误。我不太明白你的解释如何说明问题只在从开头重新开始时才会发生?请注意,我已经尝试使用8个128GB文件进行操作,当循环到达末尾并重新开始时,问题总是出现,否则一切正常。有趣的是,当仅运行2个8GB文件(计算机具有24 GB RAM)时,我不会遇到这个问题。 - ronag
谢谢。顺便说一下,这是一个有趣的问题。在更新您的问题信息时,请包括您在评论中提到的统计数据(内存、磁盘、机器等)和您正在使用的操作系统。这个论坛上有很多非常精明的人,他们拥有更多这样的信息,就能做得更好。 - WhozCraig
1
@ronag 这个IO性能下降听起来像是内核不高效地刷新修改过的页面。我见过这种情况。你期望的是顺序IO,但你(部分地)得到了高达100倍性能损失的随机IO。如果你手动操作,这种情况永远不会发生。据我所知,操作系统会同步文件缓冲区。也许你可以通过其他方式与其他进程进行同步,并使用文件IO传输数据,或者使用内存中的共享区域。 - usr
显示剩余19条评论
5个回答

1
如果您能够使用页面文件而不是特定文件来支持内存映射,您可以在VirtualAlloc中使用MEM_RESET标志来防止Windows分页旧内容。我预计使用这种方法的主要问题是完成后无法轻松恢复磁盘空间。此外,这可能需要更改系统的页面文件设置;我相信它将与默认设置一起工作,但如果已设置最大页面文件大小,则无法正常工作。

不确定如何将内存映射支持页面文件而非特定文件,但我会调查一下。看起来是一个可能的解决方案。 - ronag
传递NULL而不是文件句柄。 - Harry Johnston

1

在物理内存为48GiB的系统上,每个大小为8到512GiB的缓冲区有8个,这意味着您的映射将不得不被交换。毫不奇怪。
问题是,正如您自己已经指出的那样,在能够写入页面之前,您会遇到一个故障,并且页面会被读取。第一次运行时不会发生这种情况,因为仅使用了零页面。更糟糕的是,重新读取页面会与脏页面的后台写入竞争。

现在,不幸的是,无法告诉Windows“我将无论如何覆盖此内容”,也没有任何方法使磁盘更快地加载您的内容。但是,您可以在传输过程中提前开始(也许当您完成缓冲区的3/4时)。

Windows Server 2012(您正在使用的版本)支持PrefetchVirtualMemory,它是POSIX madvise(MADV_WILLNEED)的某种半吊子替代品。

当你已经知道你将覆盖整个内存页面(或其中的几个)时,当然,并不是你想要做的事情,但这是你能得到的最好的结果。在任何情况下都值得一试。
理想情况下,你会想要做类似于Linux(我相信FreeBSD也是)中实现的破坏性madvise(MADV_DONTNEED),在覆盖页面之前立即执行,但我不知道在Windows下如何实现这一点(除了销毁视图和映射并从头开始映射,但那样会丢失所有数据,所以有点无用)。
即使提前预取,你仍然会受到磁盘I / O带宽的限制,但至少可以隐藏延迟。
另一个“显而易见”的(但可能不那么容易的)解决方案是使消费者更快。这将允许开始时使用较小的缓冲区,即使在巨大的缓冲区上,它也会使工作集更小(生产者和消费者在访问数据时都将强制将页面加载到RAM中,因此如果消费者在生产者写入数据后更少延迟地访问数据,则它们将同时使用大多数相同页集。)更小的工作集更容易适应RAM。
但我意识到你可能没有没有理由选择几GB的缓冲区。

1
请注意,VirtualAlloc确实允许您丢弃内存映射页面,但前提是它们由页面文件支持,我猜对于如此大的映射来说这是不可能的。 - Harry Johnston
@HarryJohnston: 确实,那将是MEM_RESET标志。不幸的是,与mmap不同,您不能在任何地址上使用VirtualAlloc。它必须是块的基地址(而不仅仅是某个页面的地址)。因此,您将扔掉所有东西,这几乎肯定不是所需的。或者,您需要进行成千上万次的小型分配... - Damon
我不相信那是真的。在非基地址上使用MEM_RESET不会返回错误。我想不到任何直接的方法来判断它是否实际起作用,但它声称已经成功了。同样地,您可以提交现有保留的一部分,这肯定是有效的。 - Harry Johnston
好的,我现在已经确认了在非基地址上使用MEM_RESET的效果如预期。只有被重置的页面,在系统内存受到压力后才会丢失其内容。 - Harry Johnston
@HarryJohnston:这很令人惊讶,但这是个好消息!如果这真的有效,你应该将其发布为备选答案,因为这正是OP想要的。 - Damon

1
由于您的代码没有任何注释,充满了自动变量,无法直接编译,而且我也没有512GB的PC空间来测试它,所以这只是我头脑中的一个想法。
每个进程只写入几百KB/s,因此在后台刷新到磁盘应该有充足的时间。
然而,根据您神秘的偏移量计算,似乎您正在要求boost映射系统同步或异步刷新先前的块。
mapping_->flush((new_mapping->offset() % file_size_) < (mapping_->offset() % file_size_));

我猜测这个"rollover"触发了一个同步刷新操作,这很可能是突然减速的罪魁祸首。

此时操作系统所执行的操作取决于Boost实现的方式,这并没有被描述清楚(或者至少在粗略查看他们的man手册后对我来说不够明显)。 如果Boost用未刷新的页面填满了你的48GB内存,那么你肯定会遇到突然而持续的减速。

如果这个神秘的代码行做了一些聪明而完全不同的事情,那么至少值得在你的代码中进行一些注释,以防我完全看漏了。


0
这里的问题是,当在内存中覆盖有效页面时,首先必须从驱动器读取该页面,然后再进行覆盖。据我所知,在使用内存映射文件时,没有办法避免这个问题。
之所以在第一次过程中不会发生这种情况,是因为被覆盖的页面不是“有效”的,因此它们不需要被读回来。

0

我将假设“循环”意味着 RAM 已满。 所发生的情况是,在 RAM 未满之前,您只需要分配一个页面并在其中写入 (RAM 速度),在 RAM 满后,每个页面分配都会变为两个操作: 1. 必须写回脏页面 (磁盘速度) 2. 分配页面 (RAM 速度)

在最坏的情况下,如果要从磁盘中读取某些内容,则还必须从文件中获取页面 (磁盘速度)。 因此,每个页面分配都以磁盘速度运行,而不仅是 RAM 速度。 这在 2x8GB 上不会发生,因为它足够小,可以使两个文件的所有内存完全保留在 RAM 中。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接