[local-task] Clamp file read/write lengths to the platform transfer limit (#24816)
currently loading parameter files with "file" mode fails with:
```
IndexError: Error creating vm context with modules: external/+_repo_rules4+iree/runtime/src/iree/hal/drivers/local_task/task_queue.c:2714: OUT_OF_RANGE; short read: requested 13282859520 bytes, got 397957632; while invoking native function hal.fence.await; while calling variadic import;
```
The io_uring and IOCP backends narrowed the span length to their native
transfer count (sqe->len is 32 bits, ReadFile/WriteFile take a DWORD).
Spans over 4GB transferred the wrong amount, and a span that is an exact
multiple of 4GB wrapped to zero: the operation completed successfully
having moved no bytes, which the caller cannot distinguish from EOF.
Clamp instead. Short transfers are part of the file read/write contract,
so callers resubmit for the remainder. The ceiling is INT_MAX rounded
down to a page, matching the kernel's MAX_RW_COUNT.
Adds a CTS test covering the exact-4GB case, using a sparse reservation
so the oversized span costs address space rather than committed memory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Thomas Ziereis <ziereis@roofline.ai>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
diff --git a/runtime/src/iree/async/cts/file/BUILD.bazel b/runtime/src/iree/async/cts/file/BUILD.bazel
index aaf6772..cc7fdea 100644
--- a/runtime/src/iree/async/cts/file/BUILD.bazel
+++ b/runtime/src/iree/async/cts/file/BUILD.bazel
@@ -32,6 +32,7 @@
"//runtime/src/iree/async/cts/util:registry",
"//runtime/src/iree/async/cts/util:test_base",
"//runtime/src/iree/base",
+ "//runtime/src/iree/base:core_headers",
"//runtime/src/iree/io:file_handle",
"//runtime/src/iree/testing:temp_file",
],
diff --git a/runtime/src/iree/async/cts/file/CMakeLists.txt b/runtime/src/iree/async/cts/file/CMakeLists.txt
index dc1107b..138098c 100644
--- a/runtime/src/iree/async/cts/file/CMakeLists.txt
+++ b/runtime/src/iree/async/cts/file/CMakeLists.txt
@@ -23,6 +23,7 @@
iree::async::cts::util::registry
iree::async::cts::util::test_base
iree::base
+ iree::base::core_headers
iree::io::file_handle
iree::testing::temp_file
TESTONLY
diff --git a/runtime/src/iree/async/cts/file/file_test.cc b/runtime/src/iree/async/cts/file/file_test.cc
index a2920a0..22d3b3e 100644
--- a/runtime/src/iree/async/cts/file/file_test.cc
+++ b/runtime/src/iree/async/cts/file/file_test.cc
@@ -13,10 +13,19 @@
#include "iree/async/file.h"
#include <algorithm>
+#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
+#include "iree/base/target_platform.h"
+
+#if defined(IREE_PLATFORM_WINDOWS)
+#include <windows.h>
+#else
+#include <sys/mman.h>
+#endif // IREE_PLATFORM_WINDOWS
+
#include "iree/async/cts/file/native_file.h"
#include "iree/async/cts/util/registry.h"
#include "iree/async/cts/util/test_base.h"
@@ -28,6 +37,54 @@
namespace iree::async::cts {
//===----------------------------------------------------------------------===//
+// Sparse buffer
+//===----------------------------------------------------------------------===//
+
+// A large writable byte range backed by lazily faulted pages, so only the pages
+// actually touched cost physical memory. Lets a test build a span whose length
+// is the thing under test without committing gigabytes for I/O that touches a
+// few kilobytes.
+//
+// Holds nothing (`operator bool` is false) if the reservation is refused, which
+// lets callers skip rather than fail on hosts short on address space.
+class SparseBuffer {
+ public:
+ explicit SparseBuffer(uint64_t length) : length_(length) {
+ if (length_ > (uint64_t)SIZE_MAX) return;
+#if defined(IREE_PLATFORM_WINDOWS)
+ // MEM_COMMIT charges the system commit limit even for untouched pages, so
+ // this can fail on machines with little swap configured.
+ data_ = (uint8_t*)VirtualAlloc(NULL, (SIZE_T)length_,
+ MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
+#else
+ void* mapping =
+ mmap(NULL, (size_t)length_, PROT_READ | PROT_WRITE,
+ MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, /*fd=*/-1, /*off=*/0);
+ if (mapping != MAP_FAILED) data_ = (uint8_t*)mapping;
+#endif // IREE_PLATFORM_WINDOWS
+ }
+
+ ~SparseBuffer() {
+ if (!data_) return;
+#if defined(IREE_PLATFORM_WINDOWS)
+ VirtualFree(data_, 0, MEM_RELEASE);
+#else
+ munmap(data_, (size_t)length_);
+#endif // IREE_PLATFORM_WINDOWS
+ }
+
+ SparseBuffer(const SparseBuffer&) = delete;
+ SparseBuffer& operator=(const SparseBuffer&) = delete;
+
+ explicit operator bool() const { return data_ != nullptr; }
+ uint8_t* data() const { return data_; }
+
+ private:
+ uint64_t length_;
+ uint8_t* data_ = nullptr;
+};
+
+//===----------------------------------------------------------------------===//
// Test fixture with temp file helpers
//===----------------------------------------------------------------------===//
@@ -275,6 +332,71 @@
iree_async_file_release(file);
}
+// A span longer than what a backend can transfer in one call must be clamped
+// down to a short read, never handed through as-is.
+//
+// Backends fail this two different ways. Where the native count is 32 bits
+// (io_uring's sqe->len, IOCP's DWORD), a bare cast of exactly 4GB wraps to zero
+// and completes having moved nothing, which the caller cannot distinguish from
+// EOF. Where the count is a size_t (pread/pwrite), there is no wrap but the
+// syscall may reject the length outright -- Darwin returns EINVAL above
+// INT_MAX, while Linux quietly caps at MAX_RW_COUNT.
+//
+// The span is address space, not committed memory, so only the handful of pages
+// the read fills are ever faulted in.
+TEST_P(FileTest, ReadSpanLargerThan4GiBIsClampedNotTruncated) {
+ if (sizeof(iree_host_size_t) < 8) {
+ GTEST_SKIP() << "span lengths over 4GB are unrepresentable on 32-bit";
+ }
+
+ // Exactly 4GB: the length a 32-bit narrowing cast turns into zero.
+ static constexpr uint64_t kSpanLength = 4ull * 1024 * 1024 * 1024;
+
+ static constexpr size_t kFileSize = 8192;
+ std::vector<uint8_t> pattern(kFileSize);
+ for (size_t i = 0; i < kFileSize; ++i) {
+ pattern[i] = static_cast<uint8_t>(i & 0xFF);
+ }
+ std::string path = CreateTempFileWithContents(pattern.data(), kFileSize);
+
+ SparseBuffer span_memory(kSpanLength);
+ if (!span_memory) {
+ GTEST_SKIP() << "unable to reserve a 4GB sparse buffer";
+ }
+
+ iree_async_file_t* file = ImportTempFileForRead(path);
+ ASSERT_NE(file, nullptr);
+
+ iree_async_file_read_operation_t read_op;
+ memset(&read_op, 0, sizeof(read_op));
+ read_op.base.type = IREE_ASYNC_OPERATION_TYPE_FILE_READ;
+ read_op.file = file;
+ read_op.offset = 0;
+ read_op.buffer = iree_async_span_from_ptr(span_memory.data(),
+ (iree_host_size_t)kSpanLength);
+ read_op.bytes_read = 0;
+
+ CompletionTracker tracker;
+ read_op.base.completion_fn = CompletionTracker::Callback;
+ read_op.base.user_data = &tracker;
+
+ IREE_ASSERT_OK(iree_async_proactor_submit_one(proactor_, &read_op.base));
+ PollUntil(/*min_completions=*/1,
+ /*total_budget=*/iree_make_duration_ms(10000));
+
+ EXPECT_EQ(tracker.call_count, 1);
+ IREE_EXPECT_OK(tracker.ConsumeStatus());
+
+ // The file fits inside the clamp, so a correct backend reads all of it and
+ // reports a short read against the oversized span.
+ EXPECT_EQ(read_op.bytes_read, kFileSize)
+ << "expected the file contents; 0 means the transfer length was "
+ "truncated to zero rather than clamped";
+ EXPECT_EQ(memcmp(span_memory.data(), pattern.data(), kFileSize), 0);
+
+ iree_async_file_release(file);
+}
+
// Read at offset beyond EOF returns 0 bytes (EOF).
TEST_P(FileTest, ReadBeyondEOFReturnsZeroBytes) {
const char kTestData[] = "data";
diff --git a/runtime/src/iree/async/platform/io_uring/proactor_submit.c b/runtime/src/iree/async/platform/io_uring/proactor_submit.c
index f80e59c..136c8a5 100644
--- a/runtime/src/iree/async/platform/io_uring/proactor_submit.c
+++ b/runtime/src/iree/async/platform/io_uring/proactor_submit.c
@@ -725,6 +725,22 @@
sqe->user_data = (uint64_t)(uintptr_t)base_operation;
}
+// Maximum bytes transferred by a single READ/WRITE SQE: INT_MAX rounded down to
+// a 4KB page.
+//
+// sqe->len is 32 bits, so longer spans must be clamped instead of narrowed: a
+// span that is an exact multiple of 4GB would wrap to zero and complete with no
+// bytes transferred, which the caller cannot distinguish from EOF. Short
+// transfers are part of the file read/write contract, so callers resubmit for
+// the remainder.
+//
+// Any ceiling below 4GB would be correct; this one matches MAX_RW_COUNT
+// (INT_MAX & PAGE_MASK) on a 4KB-page kernel. Kernels with larger pages cap
+// slightly lower and shorten the transfer themselves, which is the same short
+// read callers already handle.
+#define IREE_ASYNC_IO_URING_MAX_RW_LENGTH \
+ ((uint32_t)INT32_MAX & ~UINT32_C(4095))
+
// Fills an SQE for a FILE_READ operation.
// Uses IORING_OP_READ for positioned file I/O (pread semantics).
//
@@ -732,7 +748,7 @@
// fd = file descriptor
// off = file offset
// addr = buffer address
-// len = buffer length
+// len = buffer length (clamped, see IREE_ASYNC_IO_URING_MAX_RW_LENGTH)
static void iree_async_proactor_io_uring_fill_file_read(
iree_io_uring_sqe_t* sqe, iree_async_operation_t* base_operation) {
iree_async_file_read_operation_t* read_op =
@@ -743,7 +759,8 @@
sqe->fd = read_op->file->primitive.value.fd;
sqe->off = read_op->offset;
sqe->addr = (uint64_t)(uintptr_t)iree_async_span_ptr(read_op->buffer);
- sqe->len = (uint32_t)read_op->buffer.length;
+ sqe->len = (uint32_t)iree_min(read_op->buffer.length,
+ IREE_ASYNC_IO_URING_MAX_RW_LENGTH);
sqe->user_data = (uint64_t)(uintptr_t)base_operation;
}
@@ -754,7 +771,7 @@
// fd = file descriptor
// off = file offset
// addr = buffer address
-// len = buffer length
+// len = buffer length (clamped, see IREE_ASYNC_IO_URING_MAX_RW_LENGTH)
static void iree_async_proactor_io_uring_fill_file_write(
iree_io_uring_sqe_t* sqe, iree_async_operation_t* base_operation) {
iree_async_file_write_operation_t* write_op =
@@ -765,7 +782,8 @@
sqe->fd = write_op->file->primitive.value.fd;
sqe->off = write_op->offset;
sqe->addr = (uint64_t)(uintptr_t)iree_async_span_ptr(write_op->buffer);
- sqe->len = (uint32_t)write_op->buffer.length;
+ sqe->len = (uint32_t)iree_min(write_op->buffer.length,
+ IREE_ASYNC_IO_URING_MAX_RW_LENGTH);
sqe->user_data = (uint64_t)(uintptr_t)base_operation;
}
diff --git a/runtime/src/iree/async/platform/iocp/proactor_submit.c b/runtime/src/iree/async/platform/iocp/proactor_submit.c
index c546e72..8262f5c 100644
--- a/runtime/src/iree/async/platform/iocp/proactor_submit.c
+++ b/runtime/src/iree/async/platform/iocp/proactor_submit.c
@@ -1060,6 +1060,16 @@
// File I/O submit handlers
//===----------------------------------------------------------------------===//
+// Maximum bytes transferred by a single ReadFile/WriteFile call. Held to the
+// same ceiling as the io_uring backend so both clamp identically.
+//
+// The count argument is a DWORD, so longer spans must be clamped instead of
+// narrowed: a span that is an exact multiple of 4GB would wrap to zero and
+// complete with no bytes transferred, which the caller cannot distinguish from
+// EOF. Short transfers are part of the file read/write contract, so callers
+// resubmit for the remainder.
+#define IREE_ASYNC_IOCP_MAX_RW_LENGTH ((uint32_t)INT32_MAX & ~UINT32_C(4095))
+
static iree_status_t iree_async_proactor_iocp_submit_file_open(
iree_async_proactor_iocp_t* proactor,
iree_async_file_open_operation_t* open_op) {
@@ -1177,7 +1187,8 @@
carrier->overlapped.OffsetHigh = (DWORD)(read_op->offset >> 32);
void* buffer_ptr = iree_async_span_ptr(read_op->buffer);
- DWORD buffer_length = (DWORD)read_op->buffer.length;
+ DWORD buffer_length =
+ (DWORD)iree_min(read_op->buffer.length, IREE_ASYNC_IOCP_MAX_RW_LENGTH);
BOOL read_ok = ReadFile(file_handle, buffer_ptr, buffer_length, NULL,
&carrier->overlapped);
@@ -1220,7 +1231,8 @@
carrier->overlapped.OffsetHigh = (DWORD)(write_op->offset >> 32);
const void* buffer_ptr = iree_async_span_ptr(write_op->buffer);
- DWORD buffer_length = (DWORD)write_op->buffer.length;
+ DWORD buffer_length =
+ (DWORD)iree_min(write_op->buffer.length, IREE_ASYNC_IOCP_MAX_RW_LENGTH);
BOOL write_ok = WriteFile(file_handle, buffer_ptr, buffer_length, NULL,
&carrier->overlapped);
diff --git a/runtime/src/iree/async/platform/posix/worker.c b/runtime/src/iree/async/platform/posix/worker.c
index 169f805..a359534 100644
--- a/runtime/src/iree/async/platform/posix/worker.c
+++ b/runtime/src/iree/async/platform/posix/worker.c
@@ -81,6 +81,16 @@
return status;
}
+// Maximum bytes transferred by a single pread/pwrite call: INT_MAX rounded down
+// to a 4KB page.
+//
+// POSIX leaves the result implementation-defined once nbyte exceeds SSIZE_MAX,
+// and implementations diverge well below that: Darwin fails with EINVAL for
+// nbyte > INT_MAX while Linux silently caps at MAX_RW_COUNT. Clamping under
+// both turns an oversized span into a short transfer everywhere instead of an
+// error on some platforms, and callers resubmit for the remainder.
+#define IREE_ASYNC_POSIX_MAX_RW_LENGTH ((size_t)INT32_MAX & ~(size_t)4095)
+
// Executes a FILE_READ operation using the pread() syscall.
static iree_status_t iree_async_posix_execute_file_read(
iree_async_file_read_operation_t* op) {
@@ -88,7 +98,7 @@
int fd = op->file->primitive.value.fd;
void* buffer = iree_async_span_ptr(op->buffer);
- size_t length = op->buffer.length;
+ size_t length = iree_min(op->buffer.length, IREE_ASYNC_POSIX_MAX_RW_LENGTH);
off_t offset = (off_t)op->offset;
// Execute the pread syscall (blocking).
@@ -114,7 +124,7 @@
int fd = op->file->primitive.value.fd;
const void* buffer = iree_async_span_ptr(op->buffer);
- size_t length = op->buffer.length;
+ size_t length = iree_min(op->buffer.length, IREE_ASYNC_POSIX_MAX_RW_LENGTH);
off_t offset = (off_t)op->offset;
// Execute the pwrite syscall (blocking).