cuDDL
Loading...
Searching...
No Matches
file_writer.hpp
Go to the documentation of this file.
1#pragma once
2
3#include <algorithm>
4#include <array>
5#include <cerrno>
6#include <chrono>
7#include <condition_variable>
8#include <cstdint>
9#include <cstdlib>
10#include <cstring>
11#include <deque>
12#include <filesystem>
13#include <initializer_list>
14#include <memory>
15#include <mutex>
16#include <span>
17#include <string>
18#include <system_error>
19#include <thread>
20#include <vector>
21
22#include <fcntl.h>
23#include <linux/fs.h>
24#include <sys/ioctl.h>
25#include <sys/mman.h>
26#include <sys/vfs.h>
27#include <unistd.h>
28
31
32namespace cuddl::detail {
33
50 public:
51 static constexpr size_t block_bytes = size_t{8} << 20;
53 static constexpr unsigned depth = 4;
56 static constexpr size_t probe_window = size_t{64} << 20;
57
62 std::filesystem::path const& path,
63 bool durable,
64 size_t queued_limit = size_t{1} << 30
65 )
66 : path_(path), queued_limit_(queued_limit), durable_(durable) {
67 int const flags = O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC;
68 fd_ = ablated("direct-io") ? -1 : ::open(path.c_str(), flags | O_DIRECT, 0644);
69 direct_ = fd_ != -1;
70 if (fd_ == -1) fd_ = ::open(path.c_str(), flags, 0644);
71 if (fd_ == -1) fail("cannot write");
72 struct statfs status{};
73 // shortcut: NFS and Lustre only, the network file systems we run on; add others as met.
74 if (direct_ && ::fstatfs(fd_, &status) == 0 &&
75 (status.f_type == 0x6969 || status.f_type == 0x0BD00BD0)) {
76 if (::fcntl(fd_, F_SETFL, ::fcntl(fd_, F_GETFL) & ~O_DIRECT) == -1) {
77 ::close(fd_);
78 fail("cannot write");
79 }
80 direct_ = false;
81 }
82 // Buffered writes through io_uring would run concurrently on kernel workers, and several
83 // writers into one Lustre file were far slower than one.
84 if (direct_ && !ablated("io-uring")) ring_ = io_ring::make(depth);
85 // A drive slower than the page cache leaves a run that need not be durable waiting on
86 // direct writes the cache could absorb: 159 GB to a PM9A3 took 108-158 s direct and
87 // 60-97 s buffered. Such writers time both routes and keep the faster; see work().
88 if (direct_ && !durable_ && !ablated("write-probe")) {
89 buffered_fd_ = ::open(path.c_str(), O_WRONLY | O_CLOEXEC);
90 if (buffered_fd_ == -1) fail("cannot write");
91 // btrfs compresses page-cache writes during writeback, never direct ones; with zstd
92 // a buffered 120 GB run took 58.5 s compressed and 45.8 s not. Other file systems
93 // refuse or ignore the flag.
94 int attributes = 0;
95 if (::ioctl(fd_, FS_IOC_GETFLAGS, &attributes) == 0) {
96 attributes |= FS_NOCOMP_FL;
97 static_cast<void>(::ioctl(fd_, FS_IOC_SETFLAGS, &attributes));
98 }
99 }
100 thread_ = std::thread([this] { work(); });
101 }
102 file_writer(file_writer const&) = delete;
106 stop();
107 if (fd_ != -1) ::close(fd_);
108 if (buffered_fd_ != -1) ::close(buffered_fd_);
109 }
110
114 std::span<char> reserve(size_t bytes) {
115 std::unique_lock lock(append_mutex_);
116 auto const total = carry_bytes_ + bytes;
117 current_ = take_extent((total + 4095) / 4096 * 4096);
118 std::memcpy(current_->data.get(), carry_.data(), carry_bytes_);
119 reserved_ = bytes;
120 reservation_ = std::move(lock);
121 return {current_->data.get() + carry_bytes_, bytes};
122 }
123
126 void commit() {
127 queue_current(false);
128 reservation_.unlock();
129 }
130
133 void append(std::initializer_list<std::span<char const>> chunks) {
134 size_t bytes = 0;
135 for (auto const& chunk : chunks) bytes += chunk.size();
136 auto* out = reserve(bytes).data();
137 for (auto const& chunk : chunks) {
138 if (!chunk.empty()) std::memcpy(out, chunk.data(), chunk.size());
139 out += chunk.size();
140 }
141 commit();
142 }
143
144 void append(std::span<char const> bytes) {
145 append({bytes});
146 }
147
151 void finish() {
152 uint64_t size = 0;
153 {
154 reserve(0);
155 size = offset_ + carry_bytes_;
156 queue_current(true);
157 reservation_.unlock();
158 }
159 {
160 std::unique_lock lock(mutex_);
161 space_.wait(lock, [&] { return failed_ != 0 || (queue_.empty() && in_flight_ == 0); });
162 }
163 stop();
164 int error = failed_;
165 if (error == 0 && direct_ && ::ftruncate(fd_, static_cast<off_t>(size)) != 0) error = errno;
166 if (error == 0 && durable_ && ::fsync(fd_) != 0) error = errno;
167 if (::close(fd_) != 0 && error == 0) error = errno;
168 fd_ = -1;
169 if (buffered_fd_ != -1 && ::close(buffered_fd_) != 0 && error == 0) error = errno;
170 buffered_fd_ = -1;
171 if (error != 0) fail("cannot write", error);
172 }
173
175 [[nodiscard]] bool direct() const noexcept {
176 return direct_;
177 }
178
180 [[nodiscard]] bool asynchronous() const noexcept {
181 return ring_ != nullptr;
182 }
183
184 private:
185 struct free_deleter {
186 void operator()(char* data) const noexcept {
187 std::free(data);
188 }
189 };
190
192 struct extent {
193 std::unique_ptr<char, free_deleter> data;
194 size_t capacity = 0;
195 unsigned pending = 0; // queued pieces not yet written
196 };
197
199 struct queued_block {
200 extent* source = nullptr;
201 char const* data = nullptr;
202 size_t bytes = 0;
203 uint64_t offset = 0;
204 };
205
206 [[noreturn]] void fail(char const* what, int error = errno) const {
207 throw std::system_error(
208 error, std::generic_category(), std::string(what) + " " + path_.string()
209 );
210 }
211
215 extent* take_extent(size_t bytes) {
216 std::unique_lock lock(mutex_);
217 space_.wait(lock, [&] {
218 return failed_ != 0 || extents_.size() - free_.size() < 2 ||
219 queued_ + bytes <= queued_limit_;
220 });
221 if (failed_ != 0) fail("cannot write", failed_);
222 auto const fits =
223 std::ranges::find_if(free_, [&](extent* e) { return e->capacity >= bytes; });
224 extent* chosen = nullptr;
225 if (fits != free_.end()) {
226 chosen = *fits;
227 free_.erase(fits);
228 } else {
229 // Too small for anything to come, so its memory goes to a larger extent.
230 if (!free_.empty()) {
231 auto* const smallest = free_.front();
232 free_.erase(free_.begin());
233 std::erase_if(extents_, [&](auto const& e) { return e.get() == smallest; });
234 }
235 auto fresh = std::make_unique<extent>();
236 // O_DIRECT needs memory aligned to the logical sector, 4096 bytes at most; madvise
237 // needs whole pages, which are larger on some systems.
238 static size_t const alignment =
239 std::max<size_t>(4096, static_cast<size_t>(::sysconf(_SC_PAGESIZE)));
240 fresh->capacity =
241 (std::max<size_t>(bytes, 1) + block_bytes - 1) / block_bytes * block_bytes;
242 fresh->data.reset(static_cast<char*>(std::aligned_alloc(alignment, fresh->capacity)));
243 if (!fresh->data) throw std::bad_alloc();
244 // glibc backs large allocations with transparent huge pages, 512 MiB on a 64 KiB-page
245 // kernel. With 72 threads filling them, a GH200 CLI search writing 120 GB took 15 s
246 // of kernel time and 1.38 s against 1.15 s on small pages. The advice is only a hint.
247 static_cast<void>(::madvise(fresh->data.get(), fresh->capacity, MADV_NOHUGEPAGE));
248 chosen = extents_.emplace_back(std::move(fresh)).get();
249 }
250 queued_ += chosen->capacity;
251 return chosen;
252 }
253
256 void queue_current(bool final) {
257 auto const total = carry_bytes_ + reserved_;
258 size_t written = total;
259 if (direct_) written = final ? (total + 4095) / 4096 * 4096 : total / 4096 * 4096;
260 char* const data = current_->data.get();
261 std::memset(data + total, 0, written > total ? written - total : 0);
262 carry_bytes_ = total - std::min(total, written);
263 std::memcpy(carry_.data(), data + written, carry_bytes_);
264 {
265 std::lock_guard lock(mutex_);
266 for (size_t at = 0; at < written; at += block_bytes) {
267 queue_.push_back(
268 {current_, data + at, std::min(block_bytes, written - at), offset_ + at}
269 );
270 ++current_->pending;
271 }
272 if (current_->pending == 0) release(current_);
273 }
274 offset_ += written;
275 current_ = nullptr;
276 reserved_ = 0;
277 ready_.notify_all();
278 }
279
281 void release(extent* done) {
282 queued_ -= done->capacity;
283 auto const at = std::ranges::lower_bound(free_, done->capacity, {}, &extent::capacity);
284 free_.insert(at, done);
285 }
286
287 void stop() {
288 {
289 std::lock_guard lock(mutex_);
290 stopping_ = true;
291 }
292 ready_.notify_all();
293 if (thread_.joinable()) thread_.join();
294 }
295
297 static int write_all(int fd, queued_block const& piece) {
298 size_t done = 0;
299 while (done < piece.bytes) {
300 auto const wrote = ::pwrite(
301 fd, piece.data + done, piece.bytes - done, static_cast<off_t>(piece.offset + done)
302 );
303 if (wrote < 0) return errno;
304 if (wrote == 0) return EIO;
305 done += static_cast<size_t>(wrote);
306 }
307 return 0;
308 }
309
310 using clock = std::chrono::steady_clock;
311
312 static double seconds_since(clock::time_point start) {
313 return std::chrono::duration<double>(clock::now() - start).count();
314 }
315
317 void retire(queued_block const& written, int error) {
318 {
319 std::lock_guard lock(mutex_);
320 if (error != 0 && failed_ == 0) failed_ = error;
321 if (--written.source->pending == 0) release(written.source);
322 }
323 space_.notify_all();
324 }
325
336 void work() {
337 std::vector<queued_block> slots(ring_ ? depth : 1U);
338 std::vector<unsigned> idle;
339 for (unsigned slot = 0; slot < slots.size(); ++slot) idle.push_back(slot);
340 auto const window = std::min(probe_window, queued_limit_);
341 bool buffered = false, settled = buffered_fd_ == -1, tried = false;
342 uint64_t submitted = 0, window_bytes = 0;
343 double window_seconds = 0, direct_rate = 0;
344 while (true) {
345 if (buffered) {
346 queued_block next;
347 {
348 std::unique_lock lock(mutex_);
349 ready_.wait(lock, [&] { return stopping_ || !queue_.empty(); });
350 if (queue_.empty()) return;
351 next = queue_.front();
352 queue_.pop_front();
353 ++in_flight_;
354 }
355 auto const start = clock::now();
356 int const error = write_all(buffered_fd_, next);
357 window_seconds += seconds_since(start);
358 window_bytes += next.bytes;
359 {
360 std::lock_guard lock(mutex_);
361 --in_flight_;
362 }
363 retire(next, error);
364 if (window_bytes >= (tried ? 16 : 1) * window) {
365 auto const pace = tried ? 0.75 * direct_rate : direct_rate;
366 buffered = static_cast<double>(window_bytes) >= pace * window_seconds;
367 settled = !buffered;
368 tried = true;
369 window_bytes = 0;
370 window_seconds = 0;
371 }
372 continue;
373 }
374 {
375 std::unique_lock lock(mutex_);
376 // With writes in flight, collect their completions before waiting for more.
377 if (idle.size() == slots.size()) {
378 if (!settled && submitted >= window) {
379 direct_rate = static_cast<double>(window_bytes) / window_seconds;
380 buffered = true;
381 window_bytes = 0;
382 window_seconds = 0;
383 continue;
384 }
385 ready_.wait(lock, [&] { return stopping_ || !queue_.empty(); });
386 if (queue_.empty()) return;
387 }
388 for (; !idle.empty() && !queue_.empty() && (settled || submitted < window);
389 idle.pop_back()) {
390 slots[idle.back()] = queue_.front();
391 queue_.pop_front();
392 ++in_flight_;
393 submitted += slots[idle.back()].bytes;
394 if (ring_) {
395 auto const& next = slots[idle.back()];
396 ring_->write(
397 fd_,
398 next.data,
399 static_cast<uint32_t>(next.bytes),
400 next.offset,
401 idle.back()
402 );
403 }
404 }
405 }
406 if (idle.size() == slots.size()) continue;
407 auto const start = clock::now();
408 if (!ring_) {
409 int const error = write_all(fd_, slots[0]);
410 window_seconds += seconds_since(start);
411 window_bytes += slots[0].bytes;
412 finish_slot(slots, idle, 0, error);
413 continue;
414 }
415 int const error = ring_->submit_and_wait(1, [&](uint64_t slot, int result) {
416 auto const expected = static_cast<int64_t>(slots[slot].bytes);
417 window_bytes += slots[slot].bytes;
418 // A short direct write means the device ran out of space or failed.
419 finish_slot(
420 slots,
421 idle,
422 static_cast<unsigned>(slot),
423 result < 0 ? -result : (result == expected ? 0 : EIO)
424 );
425 });
426 window_seconds += seconds_since(start);
427 if (error != 0) {
428 // The kernel may still read the extents in flight, so they are never freed.
429 for (auto& extent : extents_) static_cast<void>(extent->data.release());
430 std::lock_guard lock(mutex_);
431 failed_ = error;
432 space_.notify_all();
433 return;
434 }
435 }
436 }
437
438 void finish_slot(
439 std::vector<queued_block>& slots,
440 std::vector<unsigned>& idle,
441 unsigned slot,
442 int error
443 ) {
444 {
445 std::lock_guard lock(mutex_);
446 --in_flight_;
447 }
448 retire(slots[slot], error);
449 idle.push_back(slot);
450 }
451
452 std::filesystem::path path_;
453 size_t const queued_limit_;
454 bool const durable_;
455 int fd_ = -1;
457 int buffered_fd_ = -1;
458 bool direct_ = false;
459 std::unique_ptr<io_ring> ring_;
460
461 // Producer state, guarded by append_mutex_, which a reservation holds until its commit.
462 std::mutex append_mutex_;
463 std::unique_lock<std::mutex> reservation_;
464 extent* current_ = nullptr;
465 size_t reserved_ = 0;
467 uint64_t offset_ = 0;
469 std::array<char, 4096> carry_{};
470 size_t carry_bytes_ = 0;
471
472 std::mutex mutex_;
473 std::condition_variable ready_, space_;
474 std::deque<queued_block> queue_;
475 std::vector<std::unique_ptr<extent>> extents_;
477 std::vector<extent*> free_;
479 size_t queued_ = 0;
480 unsigned in_flight_ = 0;
481 int failed_ = 0;
482 bool stopping_ = false;
483 std::thread thread_;
484};
485
486} // namespace cuddl::detail
Writes a file from a thread of its own, so the caller produces the next bytes while earlier ones reac...
std::span< char > reserve(size_t bytes)
Space for the next bytes of the file, for the caller to fill and then hand back with commit,...
file_writer & operator=(file_writer const &)=delete
void append(std::initializer_list< std::span< char const > > chunks)
Copies chunks, which reach the file together and in order; waits while the queued bytes reach the lim...
~file_writer()
Stops writing without flushing; finish reports whether every byte arrived.
void append(std::span< char const > bytes)
static constexpr size_t block_bytes
void finish()
Writes everything appended and closes the file, flushing it to storage first if durable.
static constexpr unsigned depth
Writes in flight with io_uring.
bool direct() const noexcept
Whether writes bypass the page cache.
void commit()
Queues the bytes of the last reserve.
bool asynchronous() const noexcept
Whether writes go through io_uring.
file_writer(file_writer const &)=delete
static constexpr size_t probe_window
Bytes timed per route before a writer that need not be durable picks one; it leaves the page cache on...
file_writer(std::filesystem::path const &path, bool durable, size_t queued_limit=size_t{1}<< 30)
static std::unique_ptr< io_ring > make(unsigned)
Definition io_ring.hpp:140
__global__ top_k_slots slots
Definition top_k.cuh:195
auto const total
Definition kernels.cuh:443
constexpr bool ablated(std::string_view) noexcept
Always false: this build compiles the ablation switches out.
Definition ablation.hpp:82