[PATCH v1] mm/mremap: do not charge a shrinking move twice

From: Nickolai Zeldovich

Date: Sat Oct 17 2026 - 19:35:48 EST


Since v6.15 every mremap() that shrinks an accounted mapping and moves
it with MREMAP_MAYMOVE | MREMAP_FIXED leaves old_len - new_len bytes
charged to vm_committed_as for good. An unprivileged process that
repeats such a call keeps raising Committed_AS in /proc/meminfo; with
vm.overcommit_memory=2 it reaches CommitLimit, after which every
accounted allocation in the system (private writable mmap(), brk(),
fork()) fails with ENOMEM until reboot. A loop of 1000 calls moving a
2 MiB private anonymous mapping to a 1 MiB fixed destination raises
Committed_AS by 1 GiB.

mremap_to() first unmaps the tail of the source with shrink_vma(),
which uncharges it, and then sets old_len to new_len so that the move
looks like a same-size one. vrm->delta, the absolute difference of the
two lengths computed in do_mremap(), is not updated, and
vrm_calc_charge() charges delta pages for the move. For a move that
does not unmap the source the charge only has to cover the growth from
old_len to new_len, which here is zero; nothing ever releases the extra
charge. Before the vma_remap_struct conversion move_vma() charged
new_len - old_len with the updated old_len.

Recompute delta after the shrink.

Tested with the program in the notes under QEMU (x86_64 defconfig):
Committed_AS grows by about 1 GiB per run without this patch and stays
flat with it.

Fixes: b714ccb02a76 ("mm/mremap: complete refactor of move_vma()")
Assisted-by: LLM
Signed-off-by: Nickolai Zeldovich <nickolai@xxxxxxxxxxxxx>
---

Notes:
Reproducer (any accounted private mapping; watch Committed_AS):

#define _GNU_SOURCE
#include <stdio.h>
#include <sys/mman.h>

static long committed_kb(void)
{
char line[128];
long kb = -1;
FILE *f = fopen("/proc/meminfo", "r");

while (f && fgets(line, sizeof(line), f))
if (sscanf(line, "Committed_AS: %ld kB", &kb) == 1)
break;
if (f)
fclose(f);
return kb;
}

int main(void)
{
const size_t old_len = 2 << 20, new_len = 1 << 20;
long before = committed_kb();

for (int i = 0; i < 1000; i++) {
char *src = mmap(NULL, old_len, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
char *dst = mmap(NULL, new_len, PROT_NONE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

if (mremap(src, old_len, new_len, MREMAP_MAYMOVE | MREMAP_FIXED,
dst) != dst)
return perror("mremap"), 1;
munmap(dst, new_len);
}
printf("leaked %ld kB\n", committed_kb() - before);
return 0;
}

$ gcc -O2 -o mremap-shrink-charge mremap-shrink-charge.c && ./mremap-shrink-charge
before: leaked 1024000 kB (every run; Committed_AS 3744 -> 1027744 -> 2051744 kB)
after: leaked 0 kB

mm/mremap.c | 1 +
1 file changed, 1 insertion(+)

diff --git a/mm/mremap.c b/mm/mremap.c
index 444f37d7cd28..2dd41c611fdd 100644
--- a/mm/mremap.c
+++ b/mm/mremap.c
@@ -1523,6 +1523,7 @@ static unsigned long mremap_to(struct vma_remap_struct *vrm)

/* Set up for the move now shrink has been executed. */
vrm->old_len = vrm->new_len;
+ vrm_set_delta(vrm);
}

/* MREMAP_DONTUNMAP expands by old_len since old_len == new_len */

base-commit: 70dd4ee72eb2d5793c62e71c14e3aba78e5a4e56
--
2.53.0