Skip to content

x86/mem_sharing: complete a fork without re-walking both p2ms per frame - #52

Merged
azenla merged 2 commits into
edera/4.22from
azenla/perf/fork-complete-speed
Oct 17, 2026
Merged

azenla merged 2 commits into
edera/4.22from
azenla/perf/fork-complete-speed

Conversation

@azenla

@azenla azenla commented Oct 17, 2026

Copy link
Copy Markdown
Member

Description

mem_sharing_fork_complete() walks the parent's whole physmap and forks every frame into the child. It did that through get_gfn() for each 4K frame, which costs three walks of the child's p2m plus a lock, unlock and deferred TLB flush per frame.

This takes both p2m locks once per run of frames and calls mem_sharing_fork_page() directly for each hole, so the lookups the loop already has are not repeated and the flush happens once per run. Both locks are dropped before every preemption and every 8192 frames. A frame that cannot be materialised now logs a warning instead of being skipped silently.

Measured on a PVH dom0 with protect, 1 vCPU PVH zones, forking a freshly launched parent. "complete" is the time fork_complete takes, as logged by the toolstack:

zone memory complete, before complete, after
256 MiB 43-46 ms
512 MiB 286 ms 59-88 ms
1 GiB 116-166 ms
2 GiB 248-292 ms

Over 40 forks of a 512 MiB zone, protect zone fork --wait went from a mean of 308 ms (median 328 ms) to a mean of 129 ms (median 121 ms, p90 147 ms).

Copy-on-write checked with a 64 MiB random file in the parent's tmpfs: the child reads the fork-time contents after the parent overwrites them, the parent does not see the child's writes, and the child still reads correctly after the parent is destroyed and can allocate fresh memory. No warnings or asserts appeared in the Xen console.

The measured build also carried 8522d31c98, which this branch does not include.

@azenla
azenla force-pushed the azenla/perf/fork-complete-speed branch from 4ae0032 to eedcd0c Compare October 17, 2026 02:26
…p2ms

Completing a fork is the parent's downtime, and almost all of it: 111ms of the
131ms a 512 MiB PVH guest's parent spends suspended, at some 850ns for each of
its 131072 frames.

Most of that is lookups the loop already has the answers to. Reaching
mem_sharing_fork_page() through get_gfn() costs three walks of the child's p2m
per frame -- one to find the hole, one inside add_to_physmap()'s get_two_gfns(),
and one to read back what was just written -- plus a lock, an unlock and a
deferred TLB flush around each. A bulk materialisation knows all of that without
asking: it is walking the parent's physmap in order, and every frame it has not
reached yet is a hole.

So look the child's entry up once, call the fork-in page directly, and hold the
child's p2m lock across a run of frames instead of taking it per frame -- which
also means the flush the unlock performs happens a few times over a guest rather
than 131072 times. Holding the lock is safe for the same reason the whole
operation is: the child is paused and not yet reachable by anything else. It is
dropped before every preemption, because a continuation returns to the caller,
and dropped and retaken at the same boundary when the call is not preempted, so
the hold stays bounded either way.

The nesting is unchanged. get_gfn() already held the child's p2m across
nominate_page() and add_to_physmap() taking the parent's; this holds the same two
in the same order, just for longer.

Worth 19ms of the parent's downtime: complete goes 111.5ms -> 92.8ms for the
first fork of a parent, and 86ms -> 68.6ms for later ones, where the parent's
frames are already shared and only the child-side insert is left.

Also say when a frame could not be materialised. It leaves a hole in a child that
is about to lose the parent it would have resolved that hole through, and it was
silently ignored before. Still not fatal -- the guest may never touch the frame --
but not something to first learn about from the guest.

Verified against copy-on-write rather than only against the clock: a parent
seeded with 48 MiB of a known pattern, forked, then both sides diverged. The
child reads the fork-time contents after the parent overwrites all of it, the
parent is unaffected by the child's writes, a fork of the fork inherits the
child's state, and a child whose parent has been destroyed still reads 48 MiB it
never touched since the fork. No frames went unmaterialised and the hypervisor
console is clean.

Signed-off-by: Alex Zenla <alex@edera.dev>
Completing a fork took the parent's p2m lock once per frame. That lock
is recursive, so holding it across a run of frames makes
nominate_page()'s lookup and add_to_physmap()'s insert find it already
held and only count the recursion, and the deferred TLB flush each of
their unlocks would have performed happens once at the end of the run
instead of twice per frame.

Holding both across the run is safe for the same reason the operation
is: both domains are paused, and the child is not reachable by anything
else yet. The order is the child's and then the parent's, which is the
order get_gfn() already nested them in, and both are dropped before
every preemption.
@azenla
azenla force-pushed the azenla/perf/fork-complete-speed branch from eedcd0c to 761e04b Compare October 17, 2026 02:26
@azenla
azenla merged commit 01288cc into edera/4.22 Oct 17, 2026
5 checks passed
@azenla
azenla deleted the azenla/perf/fork-complete-speed branch October 17, 2026 02:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants