Skip to content

vcpu: lazy per-fork kvm_run mapping + kvm_vcpu_id/guest_cpu_index split - #93

Open
perbu wants to merge 3 commits into
masterfrom
pr2-lazy-kvm-run
Open

vcpu: lazy per-fork kvm_run mapping + kvm_vcpu_id/guest_cpu_index split#93
perbu wants to merge 3 commits into
masterfrom
pr2-lazy-kvm-run

Conversation

@perbu

@perbu perbu commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Step 2 of the ladder in #91. Applies to master independently of the VmGroup core.

  • Lazy kvm_run mapping (MachineOptions::lazy_vcpu_mmap): each vCPU's kvm_run page is a non-coalescible VMA, and every subsequent KVM_CREATE_VM walks all VMAs under mm_take_all_locks — building a pool of N forks in one address space is O(N²), ~8–10 ms/fork at 100k parked forks. A parked fork doesn't need the mapping (only GPRs/sregs go through it, kept in a userspace shadow that the register accessors re-point at); it is materialized on first run. A fork that never runs costs zero host VMAs.
  • Unit tests for the lazy mapping.
  • cpu_id split into kvm_vcpu_id (KVM_CREATE_VCPU argument, unique per struct kvm) and guest_cpu_index (PerVCPUTable slot, SMP TSS/IST math). Always equal today; under VmGroup the KVM id becomes group-unique while every fork's main vCPU stays guest CPU 0. No behavior change; cpu_id is removed, not aliased, so stale uses fail to compile.

Unit suite matches the master baseline on x86-64.

🤖 Generated with Claude Code

perbu and others added 3 commits August 1, 2026 08:59
Each vCPU's kvm_run mapping is one non-coalescible VMA (a distinct
anon_inode per vcpu fd, so vma_merge can never merge them), and every
subsequent KVM_CREATE_VM registers an MMU notifier through
mm_take_all_locks, which walks and locks every VMA in the address
space. With N forks of one master sharing one address space that makes
building the pool O(N^2): measured ~0.105 us per resident VMA per
KVM_CREATE_VM here, i.e. ~8-10 ms/fork at 100k parked forks.

A parked fork does not need the mapping: only two of the pieces of vCPU
state a fork copies from its master go through it (GPRs and sregs), and
KVM has no ordering requirement between mmap(vcpu_fd) and the vCPU
ioctls. With MachineOptions::lazy_vcpu_mmap, a fork's vCPU therefore
skips the mmap at construction and keeps GPRs and sregs in a userspace
shadow instead. The four register accessors are re-pointed at that
shadow, so it is canonical storage -- necessary because the accessors
hand out mutable references that callers read-modify-write, and the
fork path itself reads freshly staged state back (set_tls_base, and
reset_to's enter_usermode reading sregs.cs.dpl). Pure ioctl staging
would read zeros back.

On the first run the mapping is created, the shadow is blitted into
kvm_run->s.regs with both KVM_SYNC_X86_REGS and KVM_SYNC_X86_SREGS
marked dirty (without which the guest would enter with CR3=0 and fail
entry), and the accessor pointers flip permanently. The flip happens in
run_once() only, so no caller can hold a register reference across it.
Cold pool builds then keep one VMA per fork that has *ever run* rather
than one per fork that exists.

The mapping is never released early: munmap fires an mmu-notifier
invalidation to every VM registered in the address space, which is the
same O(N) fan-out in the other direction.

Notes:
 - Masters always map eagerly. Fork construction reads the master's
   registers through the mapping precisely because a process that
   inherited the master over fork() may not ioctl its vCPU fd (-EIO),
   see the #error at the top of vcpu.cpp.
 - init() gained an explicit m_initialized flag: the old
   "if (kvm_run == nullptr)" guard doubled as "not initialized yet"
   and wraps KVM_SET_CPUID2, which the master reset path relies on
   being issued once.
 - SMP vCPUs keep mapping eagerly; they are only ever created to run.
 - vCPU gained a destructor that releases the shadow: deinit() runs
   from ~Machine only, which a constructor that throws after vCPU
   init (a fork running out of working memory in setup_cow_mode)
   never reaches.
 - AMD64 only. The arm64 run loop writes kvm_run->immediate_exit, and
   already has its own ioctl-plus-cache register model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers a lazily mapped fork that is constructed and destroyed without
ever running, one that is reset_to() repeatedly while parked (which
enters usermode, reading the staged sregs back), and one that runs after
staging: the shadowed CR3 has to survive the transition, or the guest
fails entry.

The load-bearing check is the VMA accounting: the number of
anon_inode:kvm-vcpu lines in /proc/self/maps must equal the number of
vCPUs that have ever run, not the number that exist. A control case
with the option off asserts the opposite (one mapping per fork at
construction), so the counter cannot pass vacuously.

TINYKVM_LAZY_VCPU_MMAP_DEFAULT flips the MachineOptions default, so the
whole suite can be built and run against the lazy path:

  cmake ../unit -DCMAKE_BUILD_TYPE=Debug \
    -DCMAKE_CXX_FLAGS_DEBUG="-g -DTINYKVM_LAZY_VCPU_MMAP_DEFAULT=true"

Both ways round the suite gives identical results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two roles of cpu_id are unrelated: the KVM_CREATE_VCPU argument
(unique per struct kvm) and the guest-visible CPU index (PerVCPUTable
slot, SMP TSS/IST math). Today they are always equal; under VM pooling
(VmGroup) many forks share one struct kvm, so the KVM id becomes
group-unique while every fork's main vCPU stays guest CPU 0.

No behavior change: all call sites pass (id, id) or (0, 0). The only
live guest-visible consumer is vcpu_table_addr(); the two dead-code
sites (vcpu_run.cpp integrity check, remote.cpp thread selection) are
converted to guest_cpu_index since both express the guest-visible role.
cpu_id is removed, not aliased, so stale uses fail to compile.

Unit suite: failure set identical to baseline (fork.cpp:411/reset/
tegridy, the known environmental failures on this host).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant