Skip to content

mm: get uncached internal mappings in the read-locked I/O fast path - #16565

Merged
copybara-service[bot] merged 1 commit into
google:masterfrom
ayushr2:mm-internal-mappings-rlock
Oct 28, 2026
Merged

copybara-service[bot] merged 1 commit into
google:masterfrom
ayushr2:mm-internal-mappings-rlock

Conversation

@ayushr2

@ayushr2 ayushr2 commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

During async page loading, internal mappings for pmas larger than
asyncLoadMapUnit are not cached, so existingPMAsLocked(needInternalMappings)
fails for them. Every CopyIn/CopyOut to such a pma then takes the slow path
in withInternalMappings(), which waits for the accessed pages to load with
activeMu locked for writing. This blocks I/O and page faults from all other
threads of the process while any one of them waits, and repeats for every
access until async page loading completes.

Uncached internal mappings don't mutate the pma, so they only require activeMu
to be locked for reading. Let the read-locked fast path get them from
memmap.File.MapInternal() for just the accessed range. Since restored pmas
already exist, this covers almost all I/O to restored memory. If getting
mappings fails, fall back to the slow path, which handles partial success.

During async page loading, internal mappings for pmas larger than
asyncLoadMapUnit are not cached, so existingPMAsLocked(needInternalMappings)
fails for them. Every CopyIn/CopyOut to such a pma then takes the slow path
in withInternalMappings(), which waits for the accessed pages to load with
activeMu locked for writing. This blocks I/O and page faults from all other
threads of the process while any one of them waits, and repeats for every
access until async page loading completes.

Uncached internal mappings don't mutate the pma, so they only require activeMu
to be locked for reading. Let the read-locked fast path get them from
memmap.File.MapInternal() for just the accessed range. Since restored pmas
already exist, this covers almost all I/O to restored memory. If getting
mappings fails, fall back to the slow path, which handles partial success.
@EtiennePerot
EtiennePerot requested a review from nixprime October 28, 2026 06:00
@ayushr2

ayushr2 commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Measured on a c5d.metal (96 CPUs, Linux 6.12), systrap, master (ffc0480) vs this PR.

The workload maps 4 GiB of anonymous memory with MAP_POPULATE, dirties every page, and runs N hot threads that loop on getcpu(2) with both output pointers in their own page of the mapping, i.e. two 4-byte CopyOuts per call. It is checkpointed and then restored with the default --background and --direct-io=on, so pages.img is read from EBS at ~131 MB/s and async page loading takes ~33 s. The walker variant adds one thread that, after restore, calls getcpu(2) on one page per 2 MiB of the mapping, i.e. syscall I/O to pages that mostly aren't loaded yet.

Throughput is the hot threads' getcpu(2) rate after restore relative to the same run's pre-checkpoint rate. Latency is per call within the first 20 s after restore, in power-of-two buckets. 3 runs per row, interleaved; ranges in parentheses.

Hot threads Walker Build 0-5 s 0-20 s p50 / p99 / p99.9 latency
8 no master 35% (30-38) 40% (38-43) 33 µs / 131 µs / 262 µs
8 no this PR 83% (71-100) 91% (78-103) 16 µs / 66 µs / 66 µs
32 no master 8% (0-20) 21% (19-25) 262 µs / 1 ms / 1 ms
32 no this PR 67% (25-90) 100% (90-112) 66 µs / 131 µs / 262 µs
8 yes master 0.1% 0.1% 131 µs / 537 ms / 537 ms
8 yes this PR 89% (71-100) 95% (81-105) 16 µs / 66 µs / 66 µs

Pre-checkpoint latency is 16 / 66 / 66 µs with 8 hot threads and 66 / 131 / 262 µs with 32, for both builds.

  • On master, every copy into the mapping takes the write-locked slow path until loading completes, so the hot threads run at 21-40% of their steady-state rate for the whole load, and worse with more threads. With this PR, they are back at their steady-state rate once their own pages are loaded; the 0-5 s column includes those first-touch waits.
  • On master, the walker holds activeMu for writing across each storage read, so the hot threads make essentially no progress for the entire load. The loader's summary shows the waits never overlapping: 382 waiters waited 32.458s~32.458s out of a 32.8 s load. With this PR, the hot threads run at 95% while the walker makes similar progress (1263 vs 1320 of 2048 steps in 20 s, -4%, with the hot threads now running alongside it).
Workload

Run with -heap 4 -after 25s -latwin 20s -hot 8|32 -walk 0|1: runsc run --detach, then runsc checkpoint --image-path=img once /out/ready exists, then runsc restore --image-path=img --direct-io=on --detach.

// crio measures syscall copy throughput into a large anonymous mapping across
// checkpoint/restore.
//
// Hot threads repeatedly call getcpu(2) with output pointers in a fixed page
// of the mapping (one page per thread, spread across the mapping), so each call
// does two small CopyOuts into the mapping. Optional walker threads, started
// after restore, call getcpu(2) once per 2 MiB across the whole mapping, so
// they keep touching pages that may not have been loaded yet.
//
// A reporter samples counters every -interval and detects restore as a
// wall-clock gap. It writes the timeline to -out after -after seconds past
// restore and exits.
package main

import (
	"flag"
	"fmt"
	"math/bits"
	"os"
	"runtime"
	"sync/atomic"
	"syscall"
	"time"
	"unsafe"
)

var (
	heapGiB  = flag.Int("heap", 16, "mapping size in GiB")
	hot      = flag.Int("hot", 8, "hot threads")
	walkers  = flag.Int("walk", 0, "walker threads started after restore")
	after    = flag.Duration("after", 20*time.Second, "run time after restore")
	interval = flag.Duration("interval", 50*time.Millisecond, "sample interval")
	outDir   = flag.String("out", "/out", "output directory")
	latWin   = flag.Duration("latwin", 20*time.Second, "latency window after restore")
)

const (
	sysGetcpu = 309 // amd64

	pageSize = 4096
	walkStep = 2 << 20
)

type counter struct {
	n atomic.Uint64
	_ [56]byte
}

// hist is a per-thread histogram of getcpu(2) latencies in log2(ns) buckets,
// for the steady state before checkpoint and for the window after restore.
type hist [2][64]uint64

// phase is 0 before restore, 1 within -latwin after restore, and 2 after.
var phase atomic.Int32

func getcpu(p uintptr) {
	syscall.Syscall(sysGetcpu, p, p+4, 0)
}

func main() {
	flag.Parse()
	size := *heapGiB << 30
	heap, err := syscall.Mmap(-1, 0, size, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_PRIVATE|syscall.MAP_ANON|syscall.MAP_POPULATE)
	if err != nil {
		panic(err)
	}
	base := uintptr(unsafe.Pointer(&heap[0]))

	// Make every page non-zero, so that checkpoint saves it.
	fillStart := time.Now()
	nfill := runtime.NumCPU()
	done := make(chan struct{})
	for i := 0; i < nfill; i++ {
		go func(i int) {
			chunk := size / nfill
			for off := i * chunk; off < (i+1)*chunk; off += pageSize {
				heap[off] = byte(off>>12) | 1
			}
			done <- struct{}{}
		}(i)
	}
	for i := 0; i < nfill; i++ {
		<-done
	}
	fmt.Fprintf(os.Stderr, "filled %d GiB in %v\n", *heapGiB, time.Since(fillStart))

	hotCtrs := make([]counter, *hot)
	hists := make([]hist, *hot)
	stride := uintptr(size / *hot)
	for i := 0; i < *hot; i++ {
		go func(i int) {
			runtime.LockOSThread()
			p := base + uintptr(i)*stride + stride/2
			for {
				t := time.Now()
				getcpu(p)
				d := time.Since(t)
				if ph := phase.Load(); ph < 2 {
					hists[i][ph][bits.Len64(uint64(d))]++
				}
				hotCtrs[i].n.Add(1)
			}
		}(i)
	}

	var restored atomic.Bool
	walkCtrs := make([]counter, *walkers)
	for i := 0; i < *walkers; i++ {
		go func(i int) {
			runtime.LockOSThread()
			for !restored.Load() {
				time.Sleep(time.Millisecond)
			}
			// Walkers start at different offsets and wrap around.
			start := uintptr(i) * uintptr(size / *walkers)
			for off := uintptr(0); off < uintptr(size); off += walkStep {
				getcpu(base + (start+off)%uintptr(size) + 2*pageSize)
				walkCtrs[i].n.Add(1)
			}
		}(i)
	}

	type sample struct {
		t           int64
		hot, walked uint64
	}
	samples := make([]sample, 0, 1<<16)
	var restoreAt int64
	readyAt := time.Now().Add(3 * time.Second)
	readyWritten := false
	last := time.Now().UnixNano()
	for {
		time.Sleep(*interval)
		now := time.Now().UnixNano()
		var s sample
		s.t = now
		for i := range hotCtrs {
			s.hot += hotCtrs[i].n.Load()
		}
		for i := range walkCtrs {
			s.walked += walkCtrs[i].n.Load()
		}
		samples = append(samples, s)
		if restoreAt == 0 && now-last > int64(time.Second) {
			restoreAt = now
			restored.Store(true)
			phase.Store(1)
		}
		if restoreAt != 0 && now-restoreAt > int64(*latWin) {
			phase.Store(2)
		}
		last = now
		if !readyWritten && time.Now().After(readyAt) {
			os.WriteFile(*outDir+"/ready", nil, 0644)
			readyWritten = true
		}
		if restoreAt != 0 && now-restoreAt > int64(*after) {
			break
		}
	}

	f, err := os.Create(*outDir + "/timeline.txt")
	if err != nil {
		panic(err)
	}
	fmt.Fprintf(f, "# restore_at_ns %d hot %d walkers %d heap_gib %d\n", restoreAt, *hot, *walkers, *heapGiB)
	for _, s := range samples {
		fmt.Fprintf(f, "%d %d %d\n", s.t, s.hot, s.walked)
	}
	f.Close()

	f, err = os.Create(*outDir + "/lat.txt")
	if err != nil {
		panic(err)
	}
	for ph := 0; ph < 2; ph++ {
		var sum [64]uint64
		for i := range hists {
			for b := range sum {
				sum[b] += hists[i][ph][b]
			}
		}
		fmt.Fprintf(f, "%d", ph)
		for _, n := range sum {
			fmt.Fprintf(f, " %d", n)
		}
		fmt.Fprintln(f)
	}
	f.Close()
}

Comment thread pkg/sentry/mm/pma.go Resolved
copybara-service Bot pushed a commit that referenced this pull request Oct 28, 2026
During async page loading, internal mappings for pmas larger than
asyncLoadMapUnit are not cached, so existingPMAsLocked(needInternalMappings)
fails for them. Every CopyIn/CopyOut to such a pma then takes the slow path
in withInternalMappings(), which waits for the accessed pages to load with
activeMu locked for writing. This blocks I/O and page faults from all other
threads of the process while any one of them waits, and repeats for every
access until async page loading completes.

Uncached internal mappings don't mutate the pma, so they only require activeMu
to be locked for reading. Let the read-locked fast path get them from
memmap.File.MapInternal() for just the accessed range. Since restored pmas
already exist, this covers almost all I/O to restored memory. If getting
mappings fails, fall back to the slow path, which handles partial success.

FUTURE_COPYBARA_INTEGRATE_REVIEW=#16565 from ayushr2:mm-internal-mappings-rlock 7a8074c
PiperOrigin-RevId: 1009063547
copybara-service Bot pushed a commit that referenced this pull request Oct 28, 2026
During async page loading, internal mappings for pmas larger than
asyncLoadMapUnit are not cached, so existingPMAsLocked(needInternalMappings)
fails for them. Every CopyIn/CopyOut to such a pma then takes the slow path
in withInternalMappings(), which waits for the accessed pages to load with
activeMu locked for writing. This blocks I/O and page faults from all other
threads of the process while any one of them waits, and repeats for every
access until async page loading completes.

Uncached internal mappings don't mutate the pma, so they only require activeMu
to be locked for reading. Let the read-locked fast path get them from
memmap.File.MapInternal() for just the accessed range. Since restored pmas
already exist, this covers almost all I/O to restored memory. If getting
mappings fails, fall back to the slow path, which handles partial success.

FUTURE_COPYBARA_INTEGRATE_REVIEW=#16565 from ayushr2:mm-internal-mappings-rlock 7a8074c
PiperOrigin-RevId: 1009063547
copybara-service Bot pushed a commit that referenced this pull request Oct 28, 2026
During async page loading, internal mappings for pmas larger than
asyncLoadMapUnit are not cached, so existingPMAsLocked(needInternalMappings)
fails for them. Every CopyIn/CopyOut to such a pma then takes the slow path
in withInternalMappings(), which waits for the accessed pages to load with
activeMu locked for writing. This blocks I/O and page faults from all other
threads of the process while any one of them waits, and repeats for every
access until async page loading completes.

Uncached internal mappings don't mutate the pma, so they only require activeMu
to be locked for reading. Let the read-locked fast path get them from
memmap.File.MapInternal() for just the accessed range. Since restored pmas
already exist, this covers almost all I/O to restored memory. If getting
mappings fails, fall back to the slow path, which handles partial success.

FUTURE_COPYBARA_INTEGRATE_REVIEW=#16565 from ayushr2:mm-internal-mappings-rlock 7a8074c
PiperOrigin-RevId: 1009063547
@copybara-service
copybara-service Bot merged commit ae286a2 into google:master Oct 28, 2026
16 checks passed
@ayushr2
ayushr2 deleted the mm-internal-mappings-rlock branch November 12, 2026 07:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants