为啥对 NVMe SSD 上的单个文件进行并发随机写入不会导致吞吐量增加?

Posted

技术标签:

【中文标题】为啥对 NVMe SSD 上的单个文件进行并发随机写入不会导致吞吐量增加?【英文标题】:Why does making concurrent random writes to a single file on an NVMe SSD not lead to throughput increases?为什么对 NVMe SSD 上的单个文件进行并发随机写入不会导致吞吐量增加? 【发布时间】:2021-05-04 13:27:58 【问题描述】:

我一直在试验一种随机写入工作负载,我使用多个线程写入 NVMe SSD 上一个或多个文件中的不相交偏移量。我使用的是 Linux 机器,写入是同步的,并且是使用直接 I/O 进行的(即,文件使用 O_DSYNCO_DIRECT 打开)。

我注意到,如果线程同时写入单个文件,则实现的写入吞吐量不会随着线程数量的增加而增加(即,写入似乎是串行应用的,而不是并行应用的)。但是,如果每个线程都写入自己的文件,我确实会增加吞吐量(达到 SSD 制造商宣传的随机写入吞吐量)。请参阅下图了解我的吞吐量测量结果。

我想知道如果我有多个线程同时写入同一个文件中的非重叠区域,是否有人知道为什么我无法提高吞吐量?

以下是有关我的实验设置的一些其他详细信息。

我正在写入 2 GiB 的数据(随机写入)并改变用于写入的线程数(从 1 到 16)。每个线程一次写入 4 KiB 数据。我正在考虑两种设置:(1)所有线程都写入一个文件,(2)每个线程都写入自己的文件。在开始基准测试之前,使用的文件被打开并使用fallocate() 初始化为其最终大小。使用O_DIRECTO_DSYNC 打开文件。每个线程被分配一个随机不相交的文件内偏移子集(即,线程写入的区域不重叠)。然后,线程同时使用pwrite() 写入这些偏移量。

这是机器的规格:

Linux 5.9.1-arch1-1 1 TB Intel NVMe SSD(型号 SSDPE2KX010T8) ext4 文件系统 128 GiB 内存 2.10 GHz 20 核 Xeon Gold 6230 CPU

SSD 应该能够提供高达 70000 IOPS 的随机写入。

我已经包含了一个独立的 C++ 程序,用于在我的机器上重现此行为。我一直在使用g++ -O3 -lpthread <file> 进行编译(我使用的是g++ 10.2.0 版)。

#include <algorithm>
#include <cassert>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <random>
#include <thread>
#include <vector>

#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>

constexpr size_t kBlockSize = 4 * 1024;
constexpr size_t kDataSizeMiB = 2048;
constexpr size_t kDataSize = kDataSizeMiB * 1024 * 1024;
constexpr size_t kBlocksTotal = kDataSize / kBlockSize;
constexpr size_t kRngSeed = 42;

void AllocFiles(unsigned num_files, size_t blocks_per_file,
                std::vector<int> &fds,
                std::vector<std::vector<size_t>> &write_pos) 
  std::mt19937 rng(kRngSeed);
  for (unsigned i = 0; i < num_files; ++i) 
    const std::string path = "f" + std::to_string(i);
    fds.push_back(open(path.c_str(), O_CREAT | O_WRONLY | O_DIRECT | O_DSYNC,
                       S_IRUSR | S_IWUSR));
    write_pos.emplace_back();
    auto &file_offsets = write_pos.back();
    int fd = fds.back();
    for (size_t blk = 0; blk < blocks_per_file; ++blk) 
      file_offsets.push_back(blk * kBlockSize);
    
    fallocate(fd, /*mode=*/0, /*offset=*/0, blocks_per_file * kBlockSize);
    std::shuffle(file_offsets.begin(), file_offsets.end(), rng);
  


void ThreadMain(int fd, const void *data, const std::vector<size_t> &write_pos,
                size_t offset, size_t num_writes) 
  for (size_t i = 0; i < num_writes; ++i) 
    pwrite(fd, data, kBlockSize, write_pos[i + offset]);
  


int main(int argc, char *argv[]) 
  assert(argc == 3);
  unsigned num_threads = strtoul(argv[1], nullptr, 10);
  unsigned files = strtoul(argv[2], nullptr, 10);
  assert(num_threads % files == 0);
  assert(num_threads >= files);
  assert(kBlocksTotal % num_threads == 0);

  void *data_buf;
  posix_memalign(&data_buf, 512, kBlockSize);
  *reinterpret_cast<uint64_t *>(data_buf) = 0xFFFFFFFFFFFFFFFF;

  std::vector<int> fds;
  std::vector<std::vector<size_t>> write_pos;
  std::vector<std::thread> threads;

  const size_t blocks_per_file = kBlocksTotal / files;
  const unsigned threads_per_file = num_threads / files;
  const unsigned writes_per_thread_per_file =
      blocks_per_file / threads_per_file;
  AllocFiles(files, blocks_per_file, fds, write_pos);

  const auto begin = std::chrono::steady_clock::now();
  for (unsigned thread_id = 0; thread_id < num_threads; ++thread_id) 
    unsigned thread_file_offset = thread_id / files;
    threads.emplace_back(
        &ThreadMain, fds[thread_id % files], data_buf,
        write_pos[thread_id % files],
        /*offset=*/(thread_file_offset * writes_per_thread_per_file),
        /*num_writes=*/writes_per_thread_per_file);
  
  for (auto &thread : threads) 
    thread.join();
  
  const auto end = std::chrono::steady_clock::now();
  for (const auto &fd : fds) 
    close(fd);
  

  std::cout << kDataSizeMiB /
                   std::chrono::duration_cast<std::chrono::duration<double>>(
                       end - begin)
                       .count()
            << std::endl;

  free(data_buf);
  return 0;

【问题讨论】:

【参考方案1】:

在这种情况下,根本原因是 ext4 在写入文件时使用了排他锁。为了在写入同一个文件时获得我们期望的多线程吞吐量扩展,我需要进行两项更改:

需要“预分配”文件。这意味着我们需要对我们计划写入的文件中的每个块进行至少一次实际写入(例如,将零写入整个文件)。 用于进行写入的缓冲区需要与文件系统的块大小对齐。在我的情况下,缓冲区应该已对齐到 4096。
// What I had
posix_memalign(&data_buf, 512, kBlockSize);

// What I actually needed
posix_memalign(&data_buf, 4096, kBlockSize);

通过这些更改,使用多个线程对单个文件进行非重叠随机写入会带来与线程各自写入自己的文件相同的吞吐量增益。

【讨论】:

以上是关于为啥对 NVMe SSD 上的单个文件进行并发随机写入不会导致吞吐量增加?的主要内容,如果未能解决你的问题,请参考以下文章

NVMe SSD 上的 GFortran 未格式化 I/O 吞吐量

nvme ssd和普通ssd区别

怎样Secure Erase一块NVMe PCI-e SSD

使用 Python 进行 NVMe 吞吐量测试

一种NVMe SSD友好的数据存储系统设计

您如何读取 SPDK 内部 NVME 设备上的封装温度?