Why does making concurrent random writes to a single file on an NVMe SSD not lead to throughput increases?

Viewed 208

I've been experimenting with a random write workload where I use multiple threads to write to disjoint offsets within one or more files on an NVMe SSD. I'm using a Linux machine and the writes are synchronous and are made using direct I/O (i.e., the files are opened with O_DSYNC and O_DIRECT).

I noticed that if the threads write concurrently to a single file, the achieved write throughput does not increase when the number of threads increases (i.e., the writes appear to be applied serially and not in parallel). However if each thread writes to its own file, I do get throughput increases (up to the SSD's manufacturer-advertised random write throughput). See the graph below for my throughput measurements.

I was wondering if anyone knows why I'm not able to get throughput increases if I have multiple threads concurrently writing to non-overlapping regions in the same file?

Achieved write throughput when threads write to a single file versus having a file per thread.

Here are some additional details about my experimental setup.

I'm writing 2 GiB of data (random write) and varying the number of threads used to do the write (from 1 to 16). Each thread writes 4 KiB of data at a time. I'm considering two setups: (1) all threads write to a single file, and (2) each thread writes to its own file. Before starting the benchmark, the file(s) used are opened and are initialized to their final size using fallocate(). The file(s) are opened with O_DIRECT and O_DSYNC. Each thread is assigned a random disjoint subset of the offsets within the file (i.e., the regions the threads write to are non-overlapping). Then, the threads concurrently write to these offsets using pwrite().

Here are the machine's specifications:

  • Linux 5.9.1-arch1-1
  • 1 TB Intel NVMe SSD (model SSDPE2KX010T8)
  • ext4 file system
  • 128 GiB of memory
  • 2.10 GHz 20-core Xeon Gold 6230 CPU

The SSD is supposed to be capable of delivering up to 70000 IOPS of random writes.

I've included a standalone C++ program that I've used to reproduce this behavior on my machine. I've been compiling using g++ -O3 -lpthread <file> (I'm using g++ version 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 Answers

In this scenario, the underlying reason was that ext4 was taking an exclusive lock when writing to the file. To get the multithreaded throughput scaling that we would expect when writing to the same file, I needed to make two changes:

  • The file needed to be "preallocated." This means that we need to make at least one actual write to every block in the file that we plan on writing to (e.g., writing zeros to the whole file).
  • The buffer used for making the write needs to be aligned to the file system's block size. In my case the buffer should have been aligned to 4096.
// What I had
posix_memalign(&data_buf, 512, kBlockSize);

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

With these changes, using multiple threads to make non-overlapping random writes to a single file leads to the same throughput gains as if the threads each wrote to their own file.

Writing to multiple files or the same file leads to the same throughput.

Related