Skip to content

Skip resident zero pages; share zero-split areas between restart readers - #4

Merged
kbespalov merged 4 commits into
mainfrom
perf/elide-resident-zero-pages
Nov 4, 2026
Merged

kbespalov merged 4 commits into
mainfrom
perf/elide-resident-zero-pages

Conversation

@kbespalov

@kbespalov kbespalov commented Nov 3, 2026 •

Copy link
Copy Markdown

The checkpoint writer saved every resident page of private anonymous memory, zeros included, and restart read them back. This records resident all-zero MiBs as DMTCP_ZERO_PAGE children, the header absent pages already get, and keeps the runs between them on the parallel readers. In a vLLM worker restored with cuda-checkpoint it removes 2.4 of 5.6 GiB (below).

Correction to #3: pinned CUDA host memory (torch pin_memory) is a shared mapping of /dev/zero, which the existing content scan already split into zero runs. So the H200 Qwen3.8 worker's pinned PLE table, whose zero tail motivated #3, was already skipped by main; this change does not shrink that image.

Writer. After the residency scan reports a resident run, it is split by content into MiBs on MiB address boundaries (mtcp_get_next_aligned_range; a partial MiB at either end is data); the check stops at the first nonzero word, so ordinary data costs almost nothing. Resident zeros are not MADV_DONTNEED'd: the process may run on, and a page pinned for DMA must keep its frame. Runs under 1 MiB are saved as before. DMTCP_DISABLE_ZERO_SCAN=1 writes the old images byte for byte.

Restart. Zero runs cut a large area into runs below the 1 GiB one payload needs for the readers, so they would have been read by one thread. With DMTCP_RESTART_READERS, writable payloads of at least 1 MiB are now recorded and skipped, and the helpers read them together (every 1024, and at the end of the image) in equal shares of bytes. Smaller and read-only payloads are read at once: a first version that deferred thousands of small areas made the header pass seek per area, and a cold restart got slower (8.66 → 9.36 s). The batch array is on the restart stack, never at an address an area could need. Readers off: nothing changes.

Compatibility. No format change. Images from this branch restart under main and images from main under this branch, with 1 and 16 readers, for dense, sparse, zeroed and many-area processes (16/16).

Measured on a 24-thread Xeon VM, local disk, 16 readers + huge pages, memory pass from DMTCP_RESTART_PROFILE (cold = image evicted with POSIX_FADV_DONTNEED):

Process Build Image Checkpoint Cold Warm
Worker-like: 12 GiB with 25% zero tail + 10,000 small areas main 14.07 GiB 16.5 s 8.66 s 1.35 s
this 11.07 GiB 14.3 s 6.86 s 1.19 s
this, scan off 14.07 GiB 16.5 s 8.65 s 1.33 s
Zeros in every other 64 MiB stripe of 12 GiB main 12.03 GiB 14.0 s 7.28 s 0.54 s
this 6.03 GiB 9.3 s 3.68 s 0.27 s

In the striped case all 6 GiB were read by the helpers (parallel_kib), though every run is 64 MiB.

Tests.

  • restore-memory-check.py gains zeroed, zeroed16 and mmap-zeroed (the bench clears every other stripe after writing it; the image must hold none of it, every word must return, and with readers the runs must be shared), and many-areas-1/-16: 2,400 areas, writable ones of at least 1 MiB between small read-only ones, so more payloads than one batch holds and shares that start inside areas. It verifies every byte and each protection.
  • A mutation (deferred offsets shifted by 4 KiB) makes the check fail.
  • make check with 1 and with 16 readers: 75/75 unit tests and the same results as main on this VM. checkpoint-open-files-alias, checkpoint-open-files-env and syscall-tester fail in the full run on both main and this branch, and pass on both when run alone.

GPU. warmctl beta.17 build --in-place --backend dmtcp and up of Qwen3-0.6B on one RTX 3090 (Runpod, 8 readers, huge pages), main vs this branch through --dmtcp-dir, two cold (images evicted) and two warm restores each:

main this
Snapshot 10.39 GiB 7.93 GiB
Process images 8,582 MiB 6,065 MiB
vLLM worker image 5,701 MiB 3,215 MiB
Worker memory pass 1.03–1.07 s 0.73–0.74 s
API server / engine core memory pass 0.68–0.76 s (one reader) 0.45–0.49 s (1.0–1.1 GiB shared by the readers)
/health, cold 4.76 / 4.64 s 4.14 / 4.02 s
/health, warm 5.05 / 5.02 s 4.37 / 4.29 s

Every restore answered the same greedy completion. A standalone process with 6 GiB pinned and 6 GiB pageable memory (zero tails) next to 6 GiB on the GPU, checkpointed after cuda-checkpoint: image 17.43 → 14.18 GiB, memory pass 3.10 → 2.38 s.

Fixes #3

The residency scan saved every present page of private anonymous memory,
so a pinned buffer larger than its contents or a cleared pool went into
the image as zeros and was read back on every restart.  On the H200
Qwen3.8-Flash-Next worker a quarter of the process image is such a tail.

The writer now also reads each resident run 1 MiB at a time and records
an all-zero MiB as a DMTCP_ZERO_PAGE child, the header absent pages
already get, so restarts old and new read the image.  The check stops at
the first nonzero word, so ordinary data costs almost nothing.  Resident
zeros are not dropped with MADV_DONTNEED: the process may run on, and a
page pinned for DMA must keep its frame.  DMTCP_DISABLE_ZERO_SCAN=1
writes the old images.

Zero runs cut a large area into runs below the 1 GiB a payload needs
for the parallel readers, which would then be read by one thread.  With
readers, restart now records writable payloads of at least 1 MiB and
seeks past them; the helpers read them together, every 1024 and at the
end of the image, in equal shares of bytes.  Smaller and read-only
payloads are read at once: deferring thousands of small areas made the
header pass seek per area and a cold restart slower.  The batch array
lives on the restart stack.

On a CPU process like that worker (12 GiB with a 25% zero tail, 10,000
small areas; 16 readers, huge pages) the image went from 14.07 to 11.07
GiB and the memory pass from 8.66 to 6.86 s cold, 1.35 to 1.19 s warm.
With zeros in every other 64 MiB stripe it went from 7.28 to 3.68 s cold,
all runs still read in parallel.  Images restart across both versions.

Fixes #3
The writer split a resident run into MiBs counted from the run's start,
which is wherever the mapping begins or merges with its neighbor.  A
zero buffer then lost up to a MiB at each end, and CI's layouts put the
zeroed bench a MiB over its bound.  MiBs now sit on address boundaries;
a partial MiB at either end of a run is data, as a short run was.

The bench aligns its region to 2 MiB, so every zeroed stripe is whole
MiBs and the image bound is exact.  ARM64 has no parallel readers, so
the zeroed16 check for them runs on x86-64 only.
CI images of a 1025 MiB bench carry about 2 MiB more besides the payload
than those of a 257 MiB one, so measuring zeroed16 against the 257 MiB
ordinary image failed by that much, though no zero stripe was saved.
Each zeroed image is now bounded by the dense image of its own size.
On one RTX 3090, warmctl beta.17 baking and restoring Qwen3-0.6B: process
images 8,582 -> 6,065 MiB, the vLLM worker's memory pass 1.05 -> 0.74 s,
/health 4.6-5.1 -> 4.0-4.4 s, same greedy answers.

Pinned CUDA host memory is a shared mapping of /dev/zero, which the content
scan already split into zero runs, so the comments no longer name a CUDA
host buffer as what the resident scan finds.
@kbespalov
kbespalov merged commit 4d537ea into main Nov 4, 2026
4 checks passed
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.

Do not save or read back resident zero pages; keep zero-split areas on the parallel readers

1 participant