Summary
On a virtio-fs share served by libkrun's Linux passthrough backend, an open() of a file by any guest process drops that file's page cache in the guest, including the pages other processes have mapped. Those processes then fault the pages back in from the host on their next access.
The cause is the default CachePolicy::Auto. The server never sets FOPEN_KEEP_CACHE, so the guest kernel invalidates the inode's mapping on every open (close-to-open consistency). device.rs builds the passthrough::Config with ..Default::default(), so this applies to every virtio-fs device, root included, and the C API has no way to change it.
Impact
With a virtio-fs root this hits shared libraries and mapped data files constantly:
- Every
execin the guest openslibc.so.6. That drops libc's page cache for every running process, and they all take refaults. - Under muvm (Asahi), FEX reopening its code-cache file (
RWCacheDB_*.foz) drops the translated code that a game's main thread is executing.
In a Hearthstone session under muvm (Battle.net / Wine / DXVK / FEX), this showed up as bursts of 20-120 major faults per 100 ms on the game's main thread, coinciding with visible stutters. A probe watching the game's mappings saw the code cache's resident size fall from 1168 KB to 0 KB during play.
Reproducer
Standalone C, no dependencies. It maps a 64 MiB file, has a different process just open() and close() it, then checks residency with mincore() and times touching the mapping again. It exits with 2 when the pages were dropped.
| resident before | after another process's open() | touching the mapping again | |
|---|---|---|---|
| host ext4 (control) | 100% | 100% | 0.1 ms |
| muvm guest, libkrun 1.19.6, virtio-fs root (3 of 3 runs) | 100% | 0.0% | 33-44 ms |
Setup: Arch Linux ARM on an M4 Pro, libkrun 1.19.6 (GPU SND BLK NET INPUT), muvm 0.7.0, guest with 4 KiB pages. Build with cc -O2 -o poc poc.c and run as poc DIR, where DIR is on the virtio-fs share.
poc.c
// virtiofs (libkrun, cache policy "auto") page-cache PoC: an open() of a file by ANY process drops the page
// cache of that file, including pages other processes have mapped, so they major-fault on the next touch.
//
// Usage: poc [--host-change] [DIR] (default DIR: $HOME). Uses DIR/virtiofs-poc.bin (64 MiB), deleted after.
// Expected on a local fs: resident 100% -> 100% and a re-touch in ~0.1 ms. On a libkrun virtiofs (cache
// policy "auto"): 100% -> 0% and the re-touch reads the whole file from the host again.
//
// --host-change checks the other direction (a fix must not serve stale data): after mapping the file, the
// guest creates DIR/virtiofs-poc.ready and waits for DIR/virtiofs-poc.go; the host meanwhile rewrites the
// file with 0xa5 bytes (same size) and creates .go. Another guest process then opens the file, and the
// mapping must show the new bytes.
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#define SIZE (64UL << 20)
static double now_ms(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec * 1e3 + ts.tv_nsec / 1e6;
}
static long majflt(void)
{
struct rusage ru;
getrusage(RUSAGE_SELF, &ru);
return ru.ru_majflt;
}
static double resident_pct(void *p, size_t len, long pg)
{
size_t n = (len + pg - 1) / pg, r = 0;
unsigned char *v = malloc(n);
if (!v || mincore(p, len, v)) { perror("mincore"); exit(1); }
for (size_t i = 0; i < n; i++) r += v[i] & 1;
free(v);
return 100.0 * r / n;
}
static unsigned touch(volatile unsigned char *p, size_t len, long pg)
{
unsigned s = 0;
for (size_t i = 0; i < len; i += pg) s += p[i];
return s;
}
static void other_process_opens(const char *path)
{
pid_t c = fork();
if (c == 0) { int f = open(path, O_RDONLY); if (f >= 0) close(f); _exit(f < 0); }
waitpid(c, NULL, 0);
}
int main(int argc, char **argv)
{
int host_change = argc > 1 && !strcmp(argv[1], "--host-change");
if (host_change) { argc--; argv++; }
const char *dir = argc > 1 ? argv[1] : getenv("HOME");
char path[4096], ready[4096], go[4096];
snprintf(path, sizeof(path), "%s/virtiofs-poc.bin", dir);
snprintf(ready, sizeof(ready), "%s/virtiofs-poc.ready", dir);
snprintf(go, sizeof(go), "%s/virtiofs-poc.go", dir);
unlink(ready);
unlink(go);
long pg = sysconf(_SC_PAGESIZE);
int fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0600);
if (fd < 0) { perror(path); return 1; }
char *buf = malloc(1 << 20);
memset(buf, 0x5a, 1 << 20);
for (size_t off = 0; off < SIZE; off += 1 << 20)
if (write(fd, buf, 1 << 20) != 1 << 20) { perror("write"); return 1; }
fsync(fd);
close(fd);
// Map it through a fresh open, the way a library or a game file that already exists gets mapped.
fd = open(path, O_RDONLY);
if (fd < 0) { perror(path); return 1; }
unsigned char *p = mmap(NULL, SIZE, PROT_READ, MAP_SHARED, fd, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
touch(p, SIZE, pg);
printf("%s (page size %ld)\n", path, pg);
if (host_change) {
int r = open(ready, O_WRONLY | O_CREAT, 0600);
close(r);
for (int i = 0; access(go, F_OK) && i < 600; i++)
usleep(100000);
if (access(go, F_OK)) { fprintf(stderr, "no %s from the host\n", go); return 1; }
other_process_opens(path);
size_t stale = 0;
for (size_t i = 0; i < SIZE; i += pg) stale += p[i] != 0xa5;
munmap(p, SIZE);
close(fd);
unlink(path);
unlink(ready);
unlink(go);
printf("host rewrote the file, another process opened it: %zu of %lu pages still show old data\n",
stale, SIZE / pg);
printf("%s\n", stale ? "STALE: the guest kept serving data the host had replaced" : "ok: no stale data");
return stale ? 3 : 0;
}
printf("after touching the mapping: resident %5.1f%%\n", resident_pct(p, SIZE, pg));
sleep(1); // nothing else happens: residency must not change by itself
printf("after 1 s idle: resident %5.1f%%\n", resident_pct(p, SIZE, pg));
other_process_opens(path); // it only opens and closes the file
double res = resident_pct(p, SIZE, pg);
printf("after another process open(): resident %5.1f%%\n", res);
long m0 = majflt();
double t0 = now_ms();
touch(p, SIZE, pg);
double dt = now_ms() - t0;
printf("re-touching %lu pages: %ld major faults, %.1f ms\n", SIZE / pg, majflt() - m0, dt);
munmap(p, SIZE);
close(fd);
unlink(path);
int bug = res < 50.0;
printf("%s\n", bug ? "BUG: open() by another process dropped the mapped pages"
: "ok: mapped pages survived the open()");
return bug ? 2 : 0;
}The --host-change mode covers the other direction, which any fix has to keep. The guest maps the file, the host rewrites it in place (same size), and then a guest process opens it. The mapping must show the new bytes. Stock libkrun passes it.
Proposed fix
Keep the cache only when the host file is unchanged. Remember each inode's mtime, ctime and size at open, and set FOPEN_KEEP_CACHE when they match the previous open. A file changed on the host is still invalidated on its next open, so this keeps Auto's consistency, unlike switching to CachePolicy::Always. Always would need exclusive access to the directory, which a shared root such as muvm's (the live host /) doesn't have. A PR follows.
Related: #924 asks for a C API to choose cache_policy (and writeback). This issue is about the default doing better without having to give up consistency. The macOS passthrough backend has the same Auto behaviour, but I can't test there.
Activity
0No activity yet.