Skip to content

fix(memory): use fallocate(PUNCH_HOLE) for discard_range on MAP_SHARED regions - #6168

Open
AdaAibaby wants to merge 1 commit into
firecracker-microvm:mainfrom
AdaAibaby:fix/discard-range-memfd-punch-hole
Open

AdaAibaby wants to merge 1 commit into
firecracker-microvm:mainfrom
AdaAibaby:fix/discard-range-memfd-punch-hole

Conversation

@AdaAibaby

@AdaAibaby AdaAibaby commented Aug 31, 2026

Copy link
Copy Markdown

Summary

GuestRegionMmapExt::discard_range() was calling madvise(MADV_DONTNEED)
for all non-private-file-backed mappings. For anonymous regions this works
correctly, but for MAP_SHARED memfd-backed regions (used when huge pages
are enabled) the kernel ignores MADV_DONTNEED for shared pages and returns
0 — so balloon inflation never freed any host physical memory.

This PR adds a dedicated match arm for MAP_SHARED mappings that calls
fallocate(FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE) instead, which
punches a hole in the backing memfd file and releases the physical frames.
Subsequent guest accesses fault in fresh zero pages, matching the expected
balloon-inflation semantics.

Changes

  • src/vmm/src/vstate/memory.rs: add MAP_SHARED arm to
    GuestRegionMmapExt::discard_range() using fallocate(PUNCH_HOLE)
  • Add test_discard_range_on_memfd that writes data, calls discard_range,
    and asserts the discarded page reads back as zeroes

Testing

The new test test_discard_range_on_memfd covers the happy path (page is
zeroed after discard), the second page is unaffected, out-of-range errors,
and the unaligned-offset error path.

Fixes #6167

Hi @Manciukic @JackThomson2 @JamesC1305 Would you mind taking a look when you have a chance? Thanks!

@AdaAibaby
AdaAibaby force-pushed the fix/discard-range-memfd-punch-hole branch from b682895 to 730c4a9 Compare September 7, 2026 08:03
@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 56.66667% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.99%. Comparing base (7699746) to head (730c4a9).
⚠️ Report is 5 commits behind head on main.

⚠️ Current head 730c4a9 differs from pull request most recent head 98709e1

Please upload reports for the commit 98709e1 to get more accurate results.

Files with missing lines Patch % Lines
src/vmm/src/vstate/memory.rs 56.66% 13 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #6168   +/-   ##
=======================================
  Coverage   82.99%   82.99%           
=======================================
  Files         277      277           
  Lines       31311    31341   +30     
=======================================
+ Hits        25985    26013   +28     
- Misses       5326     5328    +2     
Flag Coverage Δ
5.10-m5n.metal 83.23% <56.66%> (+0.01%) ⬆️
5.10-m6a.metal 82.60% <56.66%> (+0.01%) ⬆️
5.10-m6g.metal 80.06% <56.66%> (+0.01%) ⬆️
5.10-m6i.metal 83.23% <56.66%> (+0.01%) ⬆️
5.10-m7a.metal-48xl 82.59% <56.66%> (+0.01%) ⬆️
5.10-m7g.metal 80.06% <56.66%> (+0.01%) ⬆️
5.10-m7i.metal-24xl 83.20% <56.66%> (+0.01%) ⬆️
5.10-m7i.metal-48xl 83.20% <56.66%> (+<0.01%) ⬆️
5.10-m8g.metal-24xl 80.06% <56.66%> (+0.01%) ⬆️
5.10-m8g.metal-48xl 80.06% <56.66%> (+0.01%) ⬆️
5.10-m8i.metal-48xl 83.20% <56.66%> (+0.01%) ⬆️
5.10-m8i.metal-96xl 83.20% <56.66%> (+0.01%) ⬆️
5.10-m9g.metal-48xl 80.06% <56.66%> (+0.01%) ⬆️
6.1-m5n.metal 83.25% <56.66%> (+<0.01%) ⬆️
6.1-m6a.metal 82.62% <56.66%> (+0.01%) ⬆️
6.1-m6g.metal 80.06% <56.66%> (+0.01%) ⬆️
6.1-m6i.metal 83.25% <56.66%> (+0.01%) ⬆️
6.1-m7a.metal-48xl 82.61% <56.66%> (+0.01%) ⬆️
6.1-m7g.metal 80.06% <56.66%> (+0.01%) ⬆️
6.1-m7i.metal-24xl 83.26% <56.66%> (+<0.01%) ⬆️
6.1-m7i.metal-48xl 83.26% <56.66%> (+<0.01%) ⬆️
6.1-m8g.metal-24xl 80.05% <56.66%> (+<0.01%) ⬆️
6.1-m8g.metal-48xl 80.06% <56.66%> (+0.01%) ⬆️
6.1-m8i.metal-48xl 83.26% <56.66%> (+0.01%) ⬆️
6.1-m8i.metal-96xl 83.26% <56.66%> (+0.01%) ⬆️
6.1-m9g.metal-48xl 80.06% <56.66%> (+0.01%) ⬆️
6.18-m5n.metal 83.24% <56.66%> (+<0.01%) ⬆️
6.18-m6a.metal 82.61% <56.66%> (+<0.01%) ⬆️
6.18-m6g.metal 80.16% <56.66%> (+0.01%) ⬆️
6.18-m6i.metal 83.25% <56.66%> (+0.01%) ⬆️
6.18-m7a.metal-48xl 82.61% <56.66%> (+0.01%) ⬆️
6.18-m7g.metal 80.16% <56.66%> (+0.01%) ⬆️
6.18-m7i.metal-24xl 83.25% <56.66%> (+<0.01%) ⬆️
6.18-m7i.metal-48xl 83.26% <56.66%> (+<0.01%) ⬆️
6.18-m8g.metal-24xl 80.16% <56.66%> (+0.01%) ⬆️
6.18-m8g.metal-48xl 80.16% <56.66%> (+0.01%) ⬆️
6.18-m8i.metal-48xl 83.26% <56.66%> (+0.01%) ⬆️
6.18-m8i.metal-96xl 83.26% <56.66%> (+<0.01%) ⬆️
6.18-m9g.metal-48xl 80.16% <56.66%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@JackThomson2

Copy link
Copy Markdown
Contributor

Hi @AdaAibaby thanks for raising this PR, it was something I looked at in the past in this PR: #5792 before I got pulled onto different tasks. One issue I encountered was in our integration tests was measure the RSS change was trickier and not something I could get consistent. We will need to figure out a way to solve this before we can think about merging it. Also seems the style tests are failing if we can take a look at these.

madvise(MADV_DONTNEED) has no effect on MAP_SHARED file-backed mappings
(such as memfd regions used for huge-pages guest memory): the kernel
ignores it for shared pages because other mappers may still be using
them. As a result, balloon inflation never actually freed host physical
frames when huge pages were enabled.

Add a dedicated match arm in GuestRegionMmapExt::discard_range() for
MAP_SHARED mappings that uses fallocate(FALLOC_FL_PUNCH_HOLE |
FALLOC_FL_KEEP_SIZE) instead. This punches a hole in the backing file,
causing the kernel to release the physical frames while keeping the
virtual mapping intact; subsequent guest accesses fault in fresh zero
pages, matching the expected balloon-inflation semantics.

The existing MAP_PRIVATE (snapshot restore) and anonymous arms are
unchanged. Add test_discard_range_on_memfd to verify the new path
actually zeroes the discarded page.

Fixes firecracker-microvm#6167

Signed-off-by: shaolila <shaolila@buaa.edu.cn>
@AdaAibaby
AdaAibaby force-pushed the fix/discard-range-memfd-punch-hole branch from 730c4a9 to 98709e1 Compare September 8, 2026 03:09
@AdaAibaby

Copy link
Copy Markdown
Author

Thanks @JackThomson2, that's really helpful context — and thanks for the pointer to #5792.

I've pushed a fix for the style/DCO failures (the commit title was >72 chars for gitlint, and a rebase had dropped the Signed-off-by). Code is unchanged.

On the RSS measurement flakiness — I dug into the current harness (get_stable_rss_memget_resident_memory, which reads the whole-process rss + HugetlbPages and waits for <1% jitter). I think the instability comes from three things for the memfd + MAP_SHARED + huge-pages case:

  1. Wrong granularity — whole-process RSS mixes in Firecracker's own heap/stack and page-cache noise, and shared-page accounting lags the actual PUNCH_HOLE release.
  2. The "<1% over two reads" stability check is fragile because balloon reclaim is async, so it often bails with the "did not stabilize" warning.
  3. Fixed thresholds (<= 15000, ± 50 MB) don't hold at huge-page granularity.

Rather than guess, I'd like your steer on the metric before I write the test. Options I'm considering, roughly in order of how deterministic they feel:

  • (A) Measure the memfd backing file directlyfstat(fd).st_blocks * 512. This drops deterministically right after PUNCH_HOLE and measures the exact thing the fix changes (physical frames actually released), independent of process-RSS noise. Open question: the cleanest way to get the memfd fd from the test — via /proc/<pid>/fd matching the memfd: link, or would you rather Firecracker expose it more explicitly?
  • (B) cgroup memory.current — the jailer already sets up a cgroup; reading it before/after inflate is a host-level "did we actually free memory" signal that includes the shared pages. More robust than process RSS, at the cost of requiring the cgroup path in the test.
  • (C) Keep process memory but change the sampling — read PSS from /proc/<pid>/smaps_rollup (or just the memfd mapping's Rss/Pss line in smaps), take a median of N samples, and assert a relative delta (freed >= inflate_size * 0.8) after a fixed settle, instead of the magic numbers.

My instinct is (A) as the primary assertion (it's the most direct proof the frames were freed) plus (C)'s median + relative-threshold hardening for any process-level check, with (B) as an optional host-level cross-check. I'd also add a huge-pages-parametrized case, since test_balloon.py doesn't currently exercise that path and the bug only manifests there.

Does that line up with what you hit in #5792, or did st_blocks / cgroup give you trouble too? Happy to go whichever way you prefer before I implement.

@zulinx86 zulinx86 added the Status: Awaiting assignee Indicates that an issue or pull request is awaiting action from its assignee. label Sep 9, 2026

@not4s not4s left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR and the detailed write up on RSS measurement! We want memfd + balloon/virtio-mem to work.

Please use madvise(MADV_REMOVE) for the shared file instead of calling fallocate directly. It does the same thing but goes through uffd_remove() first, so a UFFD handler registered on the mapping still receives the remove event, as does with today's MADV_DONTNEED. We're going to rely on that when memfd backed memory is used under UFFD.

Just left a few comments to look at as well

// (or in general MAP_SHARED of a fd). In those cases we should use
// fallocate64(FALLOC_FL_PUNCH_HOLE|FALLOC_FL_KEEP_SIZE).
// We keep falling to the madvise branch to keep the previous behaviour.
// For MAP_SHARED file-backed mappings (e.g. memfd-backed guest memory used when huge

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

memfd is not selected by huge_pages, only a vhost-user-blk device selects a memfd backend

})?;
// SAFETY: fd is a valid open file descriptor and offset+size are within bounds.
let ret = unsafe {
libc::fallocate(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ret = 0 when the file is hugetlbfs (vhost-user drives makes memory memfd backed and huge_pages is 2M) but if the range is smaller than 2MB or not 2MB aligned. I'd rather fail here for that case

let mut actual_page = vec![0u8; page_size];
mem.read(actual_page.as_mut_slice(), GuestAddress(0))
.unwrap();
assert_eq!(vec![0u8; page_size], actual_page);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This asserts the page reads zero after the discard, but think the point of the PR is to check for the hole in the file. The zeros can appear without a hole (i.e. when the range is remapped with MAP_PRIVATE)

Can you also add the same test with Hugetlbfs2M?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status: Awaiting assignee Indicates that an issue or pull request is awaiting action from its assignee.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: discard_range() silently fails to free physical memory for memfd-backed (MAP_SHARED) guest regions

5 participants