Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ target_link_libraries(myproject PRIVATE decodeless::mappedfile)
- Windows implementation uses unofficial section API for `NtExtendSection` from
`wdm.h`/`ntdll.dll`/"WDK". Please leave a comment if you know of an
alternative. It works well, but technically could change at any time.
- Linux `resizable_file` maps more than the file size and truncates without
remapping. Simple and very fast, although not explicitly supported in the man
pages. Tests indicate the right thing still happens.

## Contributing

Expand Down
58 changes: 25 additions & 33 deletions include/decodeless/detail/mappedfile_linux.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -128,16 +128,14 @@ class MemoryMap {
address_type address(size_t offset) const {
return static_cast<address_type>(static_cast<byte_type*>(m_address) + offset);
}
size_t size() const { return m_size; }
void sync(size_t offset, size_t size) const
size_t size() const { return m_size; }
void sync(size_t offset, size_t size) const
requires Writable
{
assert(offset + size <= m_size);
size_t alignedOffset = offset & ~(pageSize() - 1);
size_t alignedSize = size + offset - alignedOffset;
void* offsetAddress = static_cast<void*>(
static_cast<std::byte*>(const_cast<void*>(m_address)) + alignedOffset);
if (msync(offsetAddress, alignedSize, MS_SYNC | MS_INVALIDATE) == -1)
if (msync(address(alignedOffset), alignedSize, MS_SYNC | MS_INVALIDATE) == -1)
throw LastError();
}
void sync() const
Expand Down Expand Up @@ -223,54 +221,48 @@ class ResizableMappedFile {
ResizableMappedFile(const ResizableMappedFile& other) = delete;
ResizableMappedFile(ResizableMappedFile&& other) noexcept = default;
ResizableMappedFile(const fs::path& path, size_t maxSize)
: m_reserved(nullptr, maxSize, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0)
, m_file(path, O_CREAT | O_RDWR, 0666) {
size_t size = throwIfAbove(m_file.size(), m_reserved.size());
if (size)
map(size);
}
: m_file(path, O_CREAT | O_RDWR, 0666)
, m_size(throwIfAbove(m_file.size(), maxSize))
// Map the entire reserved range (previously a separate MAP_PRIVATE
// mapping was created first). Calling ftruncate() without remapping
// seems to just work. Truncating down releases pages and reading past
// the end of the file raises SIGBUS (not that uses would).
, m_mapped(nullptr, maxSize, MAP_SHARED, m_file, 0) {}
ResizableMappedFile& operator=(const ResizableMappedFile& other) = delete;
void* data() const { return m_mapped ? m_mapped->address() : nullptr; }
size_t size() const { return m_mapped ? m_mapped->size() : 0; }
size_t capacity() const { return m_reserved.size(); }
void* data() const { return m_size != 0 ? m_mapped.address() : nullptr; }
size_t size() const { return m_size; }
size_t capacity() const { return m_mapped.size(); }
void resize(size_t size) {
size = throwIfAbove(size, m_reserved.size());
m_mapped.reset();
m_file.truncate(size);
if (size)
map(size);
m_file.truncate(throwIfAbove(size, capacity()));
m_size = size;
}
void sync() const {
if (m_mapped)
m_mapped->sync();
if (m_size)
m_mapped.sync(0, m_size);
}
void sync(size_t offset, size_t size) const {
if (m_mapped)
m_mapped->sync(offset, size);
if (size)
m_mapped.sync(offset, size);
}

// Override default move assignment so m_reserved outlives m_mapped
// Override default move assignment so m_file outlives m_mapped An
// alternative could be to have the mapping own the file descriptor
ResizableMappedFile& operator=(ResizableMappedFile&& other) noexcept {
m_mapped = std::move(other.m_mapped);
m_size = other.m_size;
m_file = std::move(other.m_file);
m_reserved = std::move(other.m_reserved);
return *this;
}

private:
void map(size_t size) {
// TODO: if m_mapped shrinks, does m_reserved instead need to be
// recreated to fill the gap?
m_mapped.emplace(m_reserved.address(), size, MAP_FIXED | MAP_SHARED, m_file, 0);
}
static size_t throwIfAbove(size_t v, size_t limit) {
if (v > limit)
throw std::bad_alloc();
return v;
}
detail::MemoryMap<PROT_NONE> m_reserved;
FileDescriptor m_file;
std::optional<detail::MemoryMapRW> m_mapped;
FileDescriptor m_file;
size_t m_size;
detail::MemoryMapRW m_mapped;
};

static_assert(std::is_move_constructible_v<ResizableMappedFile>);
Expand Down
197 changes: 133 additions & 64 deletions test/src/mappedfile.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ TEST_F(MappedFileFixture, FileHandle) {
EXPECT_TRUE(file); // a bit pointless - would have thrown if not
}

TEST_F(MappedFileFixture, Create) {
TEST(MappedFile, Create) {
fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat";
EXPECT_FALSE(fs::exists(tmpFile2));
if (fs::exists(tmpFile2))
Expand All @@ -93,7 +93,7 @@ TEST_F(MappedFileFixture, Create) {
EXPECT_FALSE(fs::exists(tmpFile2));
}

TEST_F(MappedFileFixture, Reserve) {
TEST(MappedFile, Reserve) {
fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat";
{
// Create a new file
Expand Down Expand Up @@ -154,7 +154,28 @@ TEST_F(MappedFileFixture, LinuxFileDescriptor) {
EXPECT_NE(fd, -1);
}

TEST_F(MappedFileFixture, LinuxCreate) {
TEST_F(MappedFileFixture, LinuxOvermapResizeWrite) {
size_t overmapSize = 1024 * 1024;
EXPECT_EQ(fs::file_size(m_tmpFile), sizeof(int));
{
detail::FileDescriptor fd(m_tmpFile, O_RDWR);
detail::MemoryMapRW mapped(nullptr, overmapSize, MAP_SHARED, fd, 0);
fd.truncate(overmapSize);

std::span data(reinterpret_cast<uint8_t*>(mapped.address()), mapped.size());
data.back() = 142;
}
EXPECT_EQ(fs::file_size(m_tmpFile), overmapSize);
{
std::ifstream ifile(m_tmpFile, std::ios::binary);
uint8_t lastByte;
ifile.seekg(overmapSize - sizeof(lastByte));
ifile.read(reinterpret_cast<char*>(&lastByte), sizeof(lastByte));
EXPECT_EQ(lastByte, 142);
}
}

TEST(MappedFile, LinuxCreate) {
fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat";
EXPECT_FALSE(fs::exists(tmpFile2));
{
Expand Down Expand Up @@ -186,7 +207,7 @@ TEST_F(MappedFileFixture, LinuxReserve) {
EXPECT_EQ(*reinterpret_cast<const int*>(mapped.address()), 42);
}

TEST_F(MappedFileFixture, LinuxResize) {
TEST(MappedFile, LinuxResize) {
// Reserve some virtual address space
detail::MemoryMap<PROT_NONE> reserved(nullptr, detail::pageSize() * 4,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
Expand Down Expand Up @@ -250,9 +271,86 @@ TEST_F(MappedFileFixture, LinuxResize) {
EXPECT_FALSE(fs::exists(tmpFile2));
}

// TODO:
// - MAP_HUGETLB
// - MAP_HUGE_2MB, MAP_HUGE_1GB
std::vector<unsigned char> getResidency(void* base, size_t size) {
std::vector<unsigned char> result(size / getpagesize(), 0u);
int ret = mincore(base, size, result.data());
if (ret != 0)
throw detail::LastError();
return result;
}

TEST_F(MappedFileFixture, LinuxResidencyAfterTruncate) {
// Map the 1-int sized file to a much larger range
EXPECT_EQ(fs::file_size(m_tmpFile), sizeof(int));
size_t newSize = 16 * 1024 * 1024;
detail::FileDescriptor fd(m_tmpFile, O_RDWR);
detail::MemoryMap<PROT_READ | PROT_WRITE> mapped(nullptr, newSize, MAP_SHARED, fd, 0);

// Increase the file size to match the mapping and check no pages are
// resident yet. Actually the first page is, but at least the rest should be
// untouched.
fd.truncate(newSize);
EXPECT_TRUE(
std::ranges::all_of(getResidency(mapped.address(getpagesize()), newSize - getpagesize()),
[](unsigned char c) { return (c & 1u) == 0; }));

// Fill the conents of the file and confirm pages are allocated
std::ranges::fill(std::span(reinterpret_cast<uint8_t*>(mapped.address()), newSize),
uint8_t(0xff));
EXPECT_TRUE(std::ranges::all_of(getResidency(mapped.address(), newSize),
[](unsigned char c) { return (c & 1u) == 1; }));

// Empty the file and check those pages are no longer resident
fd.truncate(0);
EXPECT_TRUE(std::ranges::all_of(getResidency(mapped.address(), newSize),
[](unsigned char c) { return (c & 1u) == 0; }));
EXPECT_EQ(fs::file_size(m_tmpFile), 0);
}

TEST(MappedMemory, LinuxResidencyAfterDecommit) {
const size_t page_size = getpagesize();
const size_t reserve_size = page_size * 64; // 64 pages total
const size_t commit_size = page_size * 4; // We'll use 4 pages

// Reserve virtual address space (uncommitted, inaccessible)
void* base =
mmap(nullptr, reserve_size, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
ASSERT_NE(base, MAP_FAILED) << "Failed to mmap reserved space";
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
[](unsigned char c) { return (c & 1u) == 0; }));

// Commit a portion with PROT_READ | PROT_WRITE
int prot_result = mprotect(base, commit_size, PROT_READ | PROT_WRITE);
ASSERT_EQ(prot_result, 0) << "Failed to mprotect committed region";
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
[](unsigned char c) { return (c & 1u) == 0; }));

// Touch the memory to ensure it's backed by RAM
std::span committed(static_cast<std::byte*>(base), commit_size);
std::ranges::fill(committed, std::byte(0xAB));

// Verify pages are resident using mincore
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
[](unsigned char c) { return (c & 1u) == 1; }));

// Decommit
#if 0
void* remap = mmap(base, commit_size, PROT_NONE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE | MAP_FIXED, -1, 0);
ASSERT_EQ(remap, base) << "Failed to remap to decommit pages";
#else
// See MADV_FREE discussion here: https://github.com/golang/go/issues/42330
prot_result = mprotect(base, commit_size, PROT_NONE);
ASSERT_EQ(prot_result, 0) << "Failed to mprotect committed region back to PROT_NONE";
int madvise_result = madvise(base, commit_size, MADV_DONTNEED);
ASSERT_EQ(madvise_result, 0) << "Failed to release pages with madvise";
#endif
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
[](uint8_t c) { return (c & 1u) == 0; }));

// Cleanup
munmap(base, reserve_size);
}

#endif

Expand Down Expand Up @@ -456,8 +554,34 @@ TEST_F(MappedFileFixture, ResizableFileSync) {
}
}

TEST_F(MappedFileFixture, Readme) {
fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat";
TEST(MappedFile, Empty) {
fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat";
EXPECT_FALSE(fs::exists(tmpFile2));
{
size_t maxSize = 4096;
resizable_file file(tmpFile2, maxSize);
EXPECT_TRUE(fs::exists(tmpFile2));
EXPECT_EQ(file.size(), 0);
}
EXPECT_TRUE(fs::exists(tmpFile2));
EXPECT_EQ(fs::file_size(tmpFile2), 0);
fs::remove(tmpFile2);
EXPECT_FALSE(fs::exists(tmpFile2));
}

TEST_F(MappedFileFixture, ClearExisting) {
EXPECT_EQ(fs::file_size(m_tmpFile), sizeof(int));
{
size_t maxSize = 4096;
resizable_file file(m_tmpFile, maxSize);
EXPECT_EQ(file.size(), sizeof(int));
file.resize(0);
}
EXPECT_EQ(fs::file_size(m_tmpFile), 0);
}

TEST(MappedFile, Readme) {
fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat";
{
size_t maxSize = 4096;
resizable_file file(tmpFile2, maxSize);
Expand All @@ -477,58 +601,3 @@ TEST_F(MappedFileFixture, Readme) {
fs::remove(tmpFile2);
EXPECT_FALSE(fs::exists(tmpFile2));
}

#ifndef _WIN32
std::vector<uint8_t> getResidency(void* base, size_t size) {
std::vector<unsigned char> result(size / getpagesize(), 0u);
int ret = mincore(base, size, result.data());
if (ret != 0)
throw detail::LastError();
return result;
}

TEST(MappedMemory, PageResidencyAfterDecommit) {
const size_t page_size = getpagesize();
const size_t reserve_size = page_size * 64; // 64 pages total
const size_t commit_size = page_size * 4; // We'll use 4 pages

// Reserve virtual address space (uncommitted, inaccessible)
void* base =
mmap(nullptr, reserve_size, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
ASSERT_NE(base, MAP_FAILED) << "Failed to mmap reserved space";
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
[](uint8_t c) { return (c & 1u) == 0; }));

// Commit a portion with PROT_READ | PROT_WRITE
int prot_result = mprotect(base, commit_size, PROT_READ | PROT_WRITE);
ASSERT_EQ(prot_result, 0) << "Failed to mprotect committed region";
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
[](uint8_t c) { return (c & 1u) == 0; }));

// Touch the memory to ensure it's backed by RAM
std::span committed(static_cast<std::byte*>(base), commit_size);
std::ranges::fill(committed, std::byte(0xAB));

// Verify pages are resident using mincore
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
[](uint8_t c) { return (c & 1u) == 1; }));

// Decommit
#if 0
void* remap = mmap(base, commit_size, PROT_NONE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE | MAP_FIXED, -1, 0);
ASSERT_EQ(remap, base) << "Failed to remap to decommit pages";
#else
// See MADV_FREE discussion here: https://github.com/golang/go/issues/42330
prot_result = mprotect(base, commit_size, PROT_NONE);
ASSERT_EQ(prot_result, 0) << "Failed to mprotect committed region back to PROT_NONE";
int madvise_result = madvise(base, commit_size, MADV_DONTNEED);
ASSERT_EQ(madvise_result, 0) << "Failed to release pages with madvise";
#endif
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
[](uint8_t c) { return (c & 1u) == 0; }));

// Cleanup
munmap(base, reserve_size);
}
#endif
Loading