Skip to content

Commit ec11f56

Browse files
committed
linux optimization: frtuncate() while mapped
The main point of this change is to avoid msync() when resizing the file. It turns out remapping is not even needed. Disclaimer: > If the size of the mapped file changes after the call to mmap() as a > result of some other operation on the mapped file, the effect of > references to portions of the mapped region that correspond to added > or removed portions of the file is unspecified. On linux this is a SIGBUS, but by contract of the API the user shouldn't be writing past the resized area anyway. An alternative could be to reserve with anonymous+private and mmap() just the grown region like ResizableMappedMemory. However, mmap is quite slow, even incrementally.
1 parent d2b9b43 commit ec11f56

File tree

3 files changed

+153
-97
lines changed

3 files changed

+153
-97
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ target_link_libraries(myproject PRIVATE decodeless::mappedfile)
9393
- Windows implementation uses unofficial section API for `NtExtendSection` from
9494
`wdm.h`/`ntdll.dll`/"WDK". Please leave a comment if you know of an
9595
alternative. It works well, but technically could change at any time.
96+
- Linux `resizable_file` maps more than the file size and truncates without
97+
remapping. Simple and very fast, although not explicitly supported in the man
98+
pages. Tests indicate the right thing still happens.
9699

97100
## Contributing
98101

include/decodeless/detail/mappedfile_linux.hpp

Lines changed: 25 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,14 @@ class MemoryMap {
128128
address_type address(size_t offset) const {
129129
return static_cast<address_type>(static_cast<byte_type*>(m_address) + offset);
130130
}
131-
size_t size() const { return m_size; }
132-
void sync(size_t offset, size_t size) const
131+
size_t size() const { return m_size; }
132+
void sync(size_t offset, size_t size) const
133133
requires Writable
134134
{
135135
assert(offset + size <= m_size);
136136
size_t alignedOffset = offset & ~(pageSize() - 1);
137137
size_t alignedSize = size + offset - alignedOffset;
138-
void* offsetAddress = static_cast<void*>(
139-
static_cast<std::byte*>(const_cast<void*>(m_address)) + alignedOffset);
140-
if (msync(offsetAddress, alignedSize, MS_SYNC | MS_INVALIDATE) == -1)
138+
if (msync(address(alignedOffset), alignedSize, MS_SYNC | MS_INVALIDATE) == -1)
141139
throw LastError();
142140
}
143141
void sync() const
@@ -223,54 +221,48 @@ class ResizableMappedFile {
223221
ResizableMappedFile(const ResizableMappedFile& other) = delete;
224222
ResizableMappedFile(ResizableMappedFile&& other) noexcept = default;
225223
ResizableMappedFile(const fs::path& path, size_t maxSize)
226-
: m_reserved(nullptr, maxSize, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0)
227-
, m_file(path, O_CREAT | O_RDWR, 0666) {
228-
size_t size = throwIfAbove(m_file.size(), m_reserved.size());
229-
if (size)
230-
map(size);
231-
}
224+
: m_file(path, O_CREAT | O_RDWR, 0666)
225+
, m_size(throwIfAbove(m_file.size(), maxSize))
226+
// Map the entire reserved range (previously a separate MAP_PRIVATE
227+
// mapping was created first). Calling ftruncate() without remapping
228+
// seems to just work. Truncating down releases pages and reading past
229+
// the end of the file raises SIGBUS (not that uses would).
230+
, m_mapped(nullptr, maxSize, MAP_SHARED, m_file, 0) {}
232231
ResizableMappedFile& operator=(const ResizableMappedFile& other) = delete;
233-
void* data() const { return m_mapped ? m_mapped->address() : nullptr; }
234-
size_t size() const { return m_mapped ? m_mapped->size() : 0; }
235-
size_t capacity() const { return m_reserved.size(); }
232+
void* data() const { return m_size != 0 ? m_mapped.address() : nullptr; }
233+
size_t size() const { return m_size; }
234+
size_t capacity() const { return m_mapped.size(); }
236235
void resize(size_t size) {
237-
size = throwIfAbove(size, m_reserved.size());
238-
m_mapped.reset();
239-
m_file.truncate(size);
240-
if (size)
241-
map(size);
236+
m_file.truncate(throwIfAbove(size, capacity()));
237+
m_size = size;
242238
}
243239
void sync() const {
244-
if (m_mapped)
245-
m_mapped->sync();
240+
if (m_size)
241+
m_mapped.sync(0, m_size);
246242
}
247243
void sync(size_t offset, size_t size) const {
248-
if (m_mapped)
249-
m_mapped->sync(offset, size);
244+
if (size)
245+
m_mapped.sync(offset, size);
250246
}
251247

252-
// Override default move assignment so m_reserved outlives m_mapped
248+
// Override default move assignment so m_file outlives m_mapped An
249+
// alternative could be to have the mapping own the file descriptor
253250
ResizableMappedFile& operator=(ResizableMappedFile&& other) noexcept {
254251
m_mapped = std::move(other.m_mapped);
252+
m_size = other.m_size;
255253
m_file = std::move(other.m_file);
256-
m_reserved = std::move(other.m_reserved);
257254
return *this;
258255
}
259256

260257
private:
261-
void map(size_t size) {
262-
// TODO: if m_mapped shrinks, does m_reserved instead need to be
263-
// recreated to fill the gap?
264-
m_mapped.emplace(m_reserved.address(), size, MAP_FIXED | MAP_SHARED, m_file, 0);
265-
}
266258
static size_t throwIfAbove(size_t v, size_t limit) {
267259
if (v > limit)
268260
throw std::bad_alloc();
269261
return v;
270262
}
271-
detail::MemoryMap<PROT_NONE> m_reserved;
272-
FileDescriptor m_file;
273-
std::optional<detail::MemoryMapRW> m_mapped;
263+
FileDescriptor m_file;
264+
size_t m_size;
265+
detail::MemoryMapRW m_mapped;
274266
};
275267

276268
static_assert(std::is_move_constructible_v<ResizableMappedFile>);

test/src/mappedfile.cpp

Lines changed: 125 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ TEST_F(MappedFileFixture, FileHandle) {
6666
EXPECT_TRUE(file); // a bit pointless - would have thrown if not
6767
}
6868

69-
TEST_F(MappedFileFixture, Create) {
69+
TEST(MappedFile, Create) {
7070
fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat";
7171
EXPECT_FALSE(fs::exists(tmpFile2));
7272
if (fs::exists(tmpFile2))
@@ -93,7 +93,7 @@ TEST_F(MappedFileFixture, Create) {
9393
EXPECT_FALSE(fs::exists(tmpFile2));
9494
}
9595

96-
TEST_F(MappedFileFixture, Reserve) {
96+
TEST(MappedFile, Reserve) {
9797
fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat";
9898
{
9999
// Create a new file
@@ -154,7 +154,28 @@ TEST_F(MappedFileFixture, LinuxFileDescriptor) {
154154
EXPECT_NE(fd, -1);
155155
}
156156

157-
TEST_F(MappedFileFixture, LinuxCreate) {
157+
TEST_F(MappedFileFixture, LinuxOvermapResizeWrite) {
158+
size_t overmapSize = 1024 * 1024;
159+
EXPECT_EQ(fs::file_size(m_tmpFile), sizeof(int));
160+
{
161+
detail::FileDescriptor fd(m_tmpFile, O_RDWR);
162+
detail::MemoryMapRW mapped(nullptr, overmapSize, MAP_SHARED, fd, 0);
163+
fd.truncate(overmapSize);
164+
165+
std::span data(reinterpret_cast<uint8_t*>(mapped.address()), mapped.size());
166+
data.back() = 142;
167+
}
168+
EXPECT_EQ(fs::file_size(m_tmpFile), overmapSize);
169+
{
170+
std::ifstream ifile(m_tmpFile, std::ios::binary);
171+
uint8_t lastByte;
172+
ifile.seekg(overmapSize - sizeof(lastByte));
173+
ifile.read(reinterpret_cast<char*>(&lastByte), sizeof(lastByte));
174+
EXPECT_EQ(lastByte, 142);
175+
}
176+
}
177+
178+
TEST(MappedFile, LinuxCreate) {
158179
fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat";
159180
EXPECT_FALSE(fs::exists(tmpFile2));
160181
{
@@ -186,7 +207,7 @@ TEST_F(MappedFileFixture, LinuxReserve) {
186207
EXPECT_EQ(*reinterpret_cast<const int*>(mapped.address()), 42);
187208
}
188209

189-
TEST_F(MappedFileFixture, LinuxResize) {
210+
TEST(MappedFile, LinuxResize) {
190211
// Reserve some virtual address space
191212
detail::MemoryMap<PROT_NONE> reserved(nullptr, detail::pageSize() * 4,
192213
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
@@ -250,9 +271,78 @@ TEST_F(MappedFileFixture, LinuxResize) {
250271
EXPECT_FALSE(fs::exists(tmpFile2));
251272
}
252273

253-
// TODO:
254-
// - MAP_HUGETLB
255-
// - MAP_HUGE_2MB, MAP_HUGE_1GB
274+
std::vector<uint8_t> getResidency(void* base, size_t size) {
275+
std::vector<unsigned char> result(size / getpagesize(), 0u);
276+
int ret = mincore(base, size, result.data());
277+
if (ret != 0)
278+
throw detail::LastError();
279+
return result;
280+
}
281+
282+
TEST_F(MappedFileFixture, LinuxResidencyAfterTruncate) {
283+
EXPECT_EQ(fs::file_size(m_tmpFile), sizeof(int));
284+
size_t newSize = 1024 * 1024 * 1024;
285+
detail::FileDescriptor fd(m_tmpFile, O_RDWR);
286+
detail::MemoryMap<PROT_READ | PROT_WRITE> mapped(nullptr, newSize, MAP_SHARED, fd, 0);
287+
fd.truncate(newSize);
288+
// First page is already resident, but at least the rest should be untouched
289+
EXPECT_TRUE(
290+
std::ranges::all_of(getResidency(mapped.address(getpagesize()), newSize - getpagesize()),
291+
[](uint8_t c) { return (c & 1u) == 0; }));
292+
std::ranges::fill(std::span(reinterpret_cast<uint8_t*>(mapped.address()), newSize),
293+
uint8_t(0xff));
294+
EXPECT_TRUE(std::ranges::all_of(getResidency(mapped.address(), newSize),
295+
[](uint8_t c) { return (c & 1u) == 1; }));
296+
fd.truncate(0);
297+
EXPECT_TRUE(std::ranges::all_of(getResidency(mapped.address(), newSize),
298+
[](uint8_t c) { return (c & 1u) == 0; }));
299+
EXPECT_EQ(fs::file_size(m_tmpFile), 0);
300+
}
301+
302+
TEST(MappedMemory, LinuxResidencyAfterDecommit) {
303+
const size_t page_size = getpagesize();
304+
const size_t reserve_size = page_size * 64; // 64 pages total
305+
const size_t commit_size = page_size * 4; // We'll use 4 pages
306+
307+
// Reserve virtual address space (uncommitted, inaccessible)
308+
void* base =
309+
mmap(nullptr, reserve_size, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
310+
ASSERT_NE(base, MAP_FAILED) << "Failed to mmap reserved space";
311+
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
312+
[](uint8_t c) { return (c & 1u) == 0; }));
313+
314+
// Commit a portion with PROT_READ | PROT_WRITE
315+
int prot_result = mprotect(base, commit_size, PROT_READ | PROT_WRITE);
316+
ASSERT_EQ(prot_result, 0) << "Failed to mprotect committed region";
317+
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
318+
[](uint8_t c) { return (c & 1u) == 0; }));
319+
320+
// Touch the memory to ensure it's backed by RAM
321+
std::span committed(static_cast<std::byte*>(base), commit_size);
322+
std::ranges::fill(committed, std::byte(0xAB));
323+
324+
// Verify pages are resident using mincore
325+
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
326+
[](uint8_t c) { return (c & 1u) == 1; }));
327+
328+
// Decommit
329+
#if 0
330+
void* remap = mmap(base, commit_size, PROT_NONE,
331+
MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE | MAP_FIXED, -1, 0);
332+
ASSERT_EQ(remap, base) << "Failed to remap to decommit pages";
333+
#else
334+
// See MADV_FREE discussion here: https://github.com/golang/go/issues/42330
335+
prot_result = mprotect(base, commit_size, PROT_NONE);
336+
ASSERT_EQ(prot_result, 0) << "Failed to mprotect committed region back to PROT_NONE";
337+
int madvise_result = madvise(base, commit_size, MADV_DONTNEED);
338+
ASSERT_EQ(madvise_result, 0) << "Failed to release pages with madvise";
339+
#endif
340+
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
341+
[](uint8_t c) { return (c & 1u) == 0; }));
342+
343+
// Cleanup
344+
munmap(base, reserve_size);
345+
}
256346

257347
#endif
258348

@@ -456,8 +546,34 @@ TEST_F(MappedFileFixture, ResizableFileSync) {
456546
}
457547
}
458548

459-
TEST_F(MappedFileFixture, Readme) {
460-
fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat";
549+
TEST(MappedFile, Empty) {
550+
fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat";
551+
EXPECT_FALSE(fs::exists(tmpFile2));
552+
{
553+
size_t maxSize = 4096;
554+
resizable_file file(tmpFile2, maxSize);
555+
EXPECT_TRUE(fs::exists(tmpFile2));
556+
EXPECT_EQ(file.size(), 0);
557+
}
558+
EXPECT_TRUE(fs::exists(tmpFile2));
559+
EXPECT_EQ(fs::file_size(tmpFile2), 0);
560+
fs::remove(tmpFile2);
561+
EXPECT_FALSE(fs::exists(tmpFile2));
562+
}
563+
564+
TEST_F(MappedFileFixture, ClearExisting) {
565+
EXPECT_EQ(fs::file_size(m_tmpFile), sizeof(int));
566+
{
567+
size_t maxSize = 4096;
568+
resizable_file file(m_tmpFile, maxSize);
569+
EXPECT_EQ(file.size(), sizeof(int));
570+
file.resize(0);
571+
}
572+
EXPECT_EQ(fs::file_size(m_tmpFile), 0);
573+
}
574+
575+
TEST(MappedFile, Readme) {
576+
fs::path tmpFile2 = fs::path{testing::TempDir()} / "test2.dat";
461577
{
462578
size_t maxSize = 4096;
463579
resizable_file file(tmpFile2, maxSize);
@@ -477,58 +593,3 @@ TEST_F(MappedFileFixture, Readme) {
477593
fs::remove(tmpFile2);
478594
EXPECT_FALSE(fs::exists(tmpFile2));
479595
}
480-
481-
#ifndef _WIN32
482-
std::vector<uint8_t> getResidency(void* base, size_t size) {
483-
std::vector<unsigned char> result(size / getpagesize(), 0u);
484-
int ret = mincore(base, size, result.data());
485-
if (ret != 0)
486-
throw detail::LastError();
487-
return result;
488-
}
489-
490-
TEST(MappedMemory, PageResidencyAfterDecommit) {
491-
const size_t page_size = getpagesize();
492-
const size_t reserve_size = page_size * 64; // 64 pages total
493-
const size_t commit_size = page_size * 4; // We'll use 4 pages
494-
495-
// Reserve virtual address space (uncommitted, inaccessible)
496-
void* base =
497-
mmap(nullptr, reserve_size, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
498-
ASSERT_NE(base, MAP_FAILED) << "Failed to mmap reserved space";
499-
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
500-
[](uint8_t c) { return (c & 1u) == 0; }));
501-
502-
// Commit a portion with PROT_READ | PROT_WRITE
503-
int prot_result = mprotect(base, commit_size, PROT_READ | PROT_WRITE);
504-
ASSERT_EQ(prot_result, 0) << "Failed to mprotect committed region";
505-
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
506-
[](uint8_t c) { return (c & 1u) == 0; }));
507-
508-
// Touch the memory to ensure it's backed by RAM
509-
std::span committed(static_cast<std::byte*>(base), commit_size);
510-
std::ranges::fill(committed, std::byte(0xAB));
511-
512-
// Verify pages are resident using mincore
513-
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
514-
[](uint8_t c) { return (c & 1u) == 1; }));
515-
516-
// Decommit
517-
#if 0
518-
void* remap = mmap(base, commit_size, PROT_NONE,
519-
MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE | MAP_FIXED, -1, 0);
520-
ASSERT_EQ(remap, base) << "Failed to remap to decommit pages";
521-
#else
522-
// See MADV_FREE discussion here: https://github.com/golang/go/issues/42330
523-
prot_result = mprotect(base, commit_size, PROT_NONE);
524-
ASSERT_EQ(prot_result, 0) << "Failed to mprotect committed region back to PROT_NONE";
525-
int madvise_result = madvise(base, commit_size, MADV_DONTNEED);
526-
ASSERT_EQ(madvise_result, 0) << "Failed to release pages with madvise";
527-
#endif
528-
EXPECT_TRUE(std::ranges::all_of(getResidency(base, commit_size),
529-
[](uint8_t c) { return (c & 1u) == 0; }));
530-
531-
// Cleanup
532-
munmap(base, reserve_size);
533-
}
534-
#endif

0 commit comments

Comments
 (0)