diff --git a/README.md b/README.md index 235c8dc..c90938b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/include/decodeless/detail/mappedfile_linux.hpp b/include/decodeless/detail/mappedfile_linux.hpp index 87e8ea0..d0ed4f6 100644 --- a/include/decodeless/detail/mappedfile_linux.hpp +++ b/include/decodeless/detail/mappedfile_linux.hpp @@ -128,16 +128,14 @@ class MemoryMap { address_type address(size_t offset) const { return static_cast(static_cast(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( - static_cast(const_cast(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 @@ -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 m_reserved; - FileDescriptor m_file; - std::optional m_mapped; + FileDescriptor m_file; + size_t m_size; + detail::MemoryMapRW m_mapped; }; static_assert(std::is_move_constructible_v); diff --git a/test/src/mappedfile.cpp b/test/src/mappedfile.cpp index 06922ef..d168c16 100644 --- a/test/src/mappedfile.cpp +++ b/test/src/mappedfile.cpp @@ -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)) @@ -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 @@ -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(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(&lastByte), sizeof(lastByte)); + EXPECT_EQ(lastByte, 142); + } +} + +TEST(MappedFile, LinuxCreate) { fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat"; EXPECT_FALSE(fs::exists(tmpFile2)); { @@ -186,7 +207,7 @@ TEST_F(MappedFileFixture, LinuxReserve) { EXPECT_EQ(*reinterpret_cast(mapped.address()), 42); } -TEST_F(MappedFileFixture, LinuxResize) { +TEST(MappedFile, LinuxResize) { // Reserve some virtual address space detail::MemoryMap reserved(nullptr, detail::pageSize() * 4, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); @@ -250,9 +271,86 @@ TEST_F(MappedFileFixture, LinuxResize) { EXPECT_FALSE(fs::exists(tmpFile2)); } -// TODO: -// - MAP_HUGETLB -// - MAP_HUGE_2MB, MAP_HUGE_1GB +std::vector getResidency(void* base, size_t size) { + std::vector 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 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(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(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 @@ -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); @@ -477,58 +601,3 @@ TEST_F(MappedFileFixture, Readme) { fs::remove(tmpFile2); EXPECT_FALSE(fs::exists(tmpFile2)); } - -#ifndef _WIN32 -std::vector getResidency(void* base, size_t size) { - std::vector 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(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