Skip to content

Commit 0856a06

Browse files
authored
Merge pull request #834 from Dstack-TEE/perf/mkosi-parallel-squashfs
perf(os/mkosi): make cached builds reuse what has not changed
2 parents 4d68758 + ec4c68e commit 0856a06

9 files changed

Lines changed: 256 additions & 8 deletions

File tree

os/mkosi/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ DSTACK_DEV_CACHE_DIR="$HOME/.cache/dstack/mkosi-dev" \
8888
./os/mkosi/build.sh --no-cache image "$PWD/os/mkosi/build"
8989
```
9090

91+
A cached build also skips the two release tarballs, which are roughly a minute
92+
of gzip over artifacts that already sit unpacked beside them. `disk.raw`, the
93+
measurements and `metadata.json` are still produced, so QEMU smoke-testing and
94+
measurement inspection are unaffected; `--archive` asks a cached build for the
95+
tarballs anyway. A cold build always archives, and `repro-check` archives
96+
unconditionally because the tarballs are what it compares.
97+
9198
The cache covers dstack Rust, image tools, the container stack, Sysbox, nvattest, the
9299
kernel build tree, NVIDIA, ZFS and both OVMF variants. Its key conservatively
93100
includes the inputs, tools, packages and component dependencies declared by

os/mkosi/build.sh

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,26 @@ ROOT=$(cd "$SELF/../.." && pwd)
77
# shellcheck source=/dev/null
88
source "$SELF/versions.env"
99
FLAVORS=${FLAVORS:-prod}
10-
usage="Usage: $0 [--no-cache] {image|repro-check|lint} [build-dir]"
10+
usage="Usage: $0 [--no-cache] [--archive] {image|repro-check|lint} [build-dir]"
1111
# The component cache is keyed on every declared input of each component, so a
1212
# hit reproduces the same output a cold build would have produced. Reusing it is
1313
# therefore the sensible default; --no-cache forces the cold path for a release
1414
# build or when the key itself is what needs auditing. repro-check ignores both
1515
# and always builds cold, because a cache hit would answer the wrong question.
1616
cache=${DSTACK_COMPONENT_CACHE:-1}
17+
# The two release tarballs are ~58 s of gzip over artifacts that already exist
18+
# unpacked beside them. A cached build is an iteration build, and iterating on
19+
# the guest wants disk.raw and the measurements, not a redistributable archive
20+
# -- so a cached build skips them and a cold build still produces them. Pass
21+
# --archive to get them out of a cached build anyway. Left unset here so the
22+
# default can be derived from the final value of $cache below.
23+
archive=${DSTACK_TAR_RELEASE:-}
1724
action=
1825
BUILD_DIR=
1926
while [ $# -gt 0 ]; do
2027
case "$1" in
2128
--no-cache) cache=0 ;;
29+
--archive) archive=1 ;;
2230
-h|--help) echo "$usage"; exit 0 ;;
2331
-*) echo "Unknown option: $1" >&2; echo "$usage" >&2; exit 2 ;;
2432
*)
@@ -40,6 +48,18 @@ if [[ $action == repro-check ]]; then cache=0; fi
4048
[[ $cache == 1 || $cache == 0 ]] || {
4149
echo "DSTACK_COMPONENT_CACHE must be 0 or 1, got: $cache" >&2; exit 2;
4250
}
51+
# Derived after repro-check has forced the cold path, so repro-check always
52+
# archives: it compares the two release tarballs, and skipping them would leave
53+
# it comparing nothing and passing vacuously.
54+
archive=${archive:-$(( cache == 1 ? 0 : 1 ))}
55+
# Not merely defaulted: forced. repro-check compares the two release tarballs,
56+
# so an environment that switched archiving off would leave it comparing files
57+
# that do not exist -- a check that fails for the wrong reason, or worse, is
58+
# read as "no difference found".
59+
if [[ $action == repro-check ]]; then archive=1; fi
60+
[[ $archive == 1 || $archive == 0 ]] || {
61+
echo "DSTACK_TAR_RELEASE must be 0 or 1, got: $archive" >&2; exit 2;
62+
}
4363
if [[ $action == lint ]]; then exec "$SELF/tests/acceptance.sh"; fi
4464
command -v mkosi >/dev/null || { echo "mkosi $MKOSI_VERSION is required" >&2; exit 1; }
4565
actual=$(mkosi --version | awk '{print $2}' | cut -d. -f1)
@@ -81,6 +101,39 @@ if [[ $cache == 0 ]]; then
81101
mkosi --directory "$SELF" --output-directory="$BUILD_DIR/out" clean -f
82102
fi
83103

104+
# Digest of every input that determines the incrementally cached tree: the
105+
# package lists and distribution pins in the configs, and the skeleton files
106+
# copied in before packages are installed. Deliberately not the dstack sources
107+
# or the component definitions -- those are consumed by the build script, which
108+
# runs after the cache is restored, and folding them in would defeat the cache
109+
# on every source edit. Sorted for a stable digest, and the file list itself is
110+
# hashed too so that deleting a skeleton file also moves the key.
111+
base_inputs_digest() {
112+
local flavor=$1 skeleton
113+
local skeletons=("$SELF/mkosi.skeleton")
114+
if [[ -d $SELF/mkosi.profiles/$flavor/mkosi.skeleton ]]; then
115+
skeletons+=("$SELF/mkosi.profiles/$flavor/mkosi.skeleton")
116+
fi
117+
{
118+
# Hash relative names, types and modes as well as contents. Relative names
119+
# let worktrees share a cache; types and modes cover inputs that sha256sum
120+
# alone cannot distinguish.
121+
for skeleton in "${skeletons[@]}"; do
122+
echo "-- skeleton ${skeleton#"$SELF"/}"
123+
(cd "$skeleton" && find . -mindepth 1 -printf '%P %y %m\n' | sort)
124+
(cd "$skeleton" && find . -type f -print0 | sort -z | xargs -0 -r sha256sum)
125+
(cd "$skeleton" && find . -type l -print0 | sort -z | \
126+
while IFS= read -r -d '' link; do
127+
printf '%s -> %s\n' "${link#./}" "$(readlink "$link")"
128+
done)
129+
done
130+
echo "-- mkosi.conf"
131+
cat "$SELF/mkosi.conf" "$SELF/mkosi.tools.conf"
132+
echo "-- profile/$flavor/mkosi.conf"
133+
cat "$SELF/mkosi.profiles/$flavor/mkosi.conf"
134+
} | sha256sum | cut -c1-32
135+
}
136+
84137
build_one() {
85138
local out=$1 flavor=$2 jobs=${3:-${JOBS:-$(nproc)}}
86139
mkdir -p "$out"
@@ -91,6 +144,7 @@ build_one() {
91144
--profile="$flavor"
92145
--source-date-epoch="$SOURCE_DATE_EPOCH"
93146
--environment="DSTACK_COMPONENT_CACHE=$cache"
147+
--environment="DSTACK_TAR_RELEASE=$archive"
94148
--environment="DSTACK_BUILD_GIT_REVISION=$DSTACK_BUILD_GIT_REVISION"
95149
--environment="DSTACK_SOURCE_REVISION=$revision"
96150
--environment="JOBS=$jobs"
@@ -109,6 +163,25 @@ build_one() {
109163
mkosi_args+=(--build-directory="$cache_root/build")
110164
mkosi_args+=(--build-sources="$cache_root/manifest:component-cache")
111165
mkosi_args+=(--environment="DSTACK_SOURCE_MANIFEST=/work/src/component-cache/source-manifest")
166+
# mkosi's incremental cache captures the tree after the distribution and
167+
# build packages are installed and before any build script runs, which is
168+
# exactly the ~70 s this build otherwise repeats verbatim every time.
169+
#
170+
# It cannot be enabled as-is. mkosi keys the cache on CacheKey=, whose
171+
# default is &d~&r~&a~&I -- distribution, release, architecture, image id --
172+
# and whose specifier set contains no digest of the inputs that actually
173+
# determine that tree. Editing Packages= in mkosi.conf, or any file under
174+
# mkosi.skeleton/, leaves the key untouched, so mkosi would restore the
175+
# stale tree and the change would silently not be in the image. Mixing the
176+
# digest below into the key restores the invalidation mkosi does not do.
177+
base_digest=$(base_inputs_digest "$flavor")
178+
mkosi_args+=(--incremental=yes)
179+
mkosi_args+=(--cache-directory="$cache_root/incremental")
180+
mkosi_args+=(--cache-key="&d~&r~&a~&I~$base_digest")
181+
# Package downloads are content-addressed by the pinned Snapshot=, so this
182+
# only avoids refetching identical files. Release builds still take the
183+
# cold path and fetch from the snapshot themselves.
184+
mkosi_args+=(--package-cache-directory="$cache_root/packages")
112185
fi
113186
mkosi "${mkosi_args[@]}" build
114187
# mkosi's own output is a plain Debian rootfs: unmeasured, not part of the

os/mkosi/components/dstack-rust/dstack-rust-build.sh

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,40 @@ install -m0644 "$ROOT/os/common/rootfs/containerd.service.d/"* \
2828
if [[ ${DSTACK_SKIP_RUST:-0} != 1 ]]; then
2929
export CARGO_INCREMENTAL=0 CARGO_NET_OFFLINE=${CARGO_NET_OFFLINE:-false}
3030
build_root=$(dirname "$DEST")
31-
export CARGO_TARGET_DIR="$build_root/dstack-cargo-target"
31+
ephemeral_target="$build_root/dstack-cargo-target"
32+
target_remap=
33+
if [[ -n ${DSTACK_CARGO_TARGET_DIR:-} ]]; then
34+
# A cached build keeps the target directory outside the per-build sandbox
35+
# so an edit rebuilds only the touched crates. That moves it out from under
36+
# the $build_root remap below, and rustc embeds absolute paths from the
37+
# target directory (build script OUT_DIRs among them). Without mapping it
38+
# back onto the path the throwaway layout produced, a cached build would
39+
# differ from a cold one byte for byte -- silently, and only inside the
40+
# binaries. Remapped first so it wins over the broader $build_root rule.
41+
export CARGO_TARGET_DIR="$DSTACK_CARGO_TARGET_DIR"
42+
target_remap="--remap-path-prefix=$CARGO_TARGET_DIR=/usr/src/dstack-build/dstack-cargo-target"
43+
else
44+
export CARGO_TARGET_DIR="$ephemeral_target"
45+
fi
46+
# CARGO_HOME needs the same treatment as the target directory, and for a
47+
# sharper reason: it holds the git checkouts of dependencies, and rustc
48+
# embeds their source paths in the binary (panic locations, tracing call
49+
# sites). Moving it without remapping made a cached build's binaries differ
50+
# from a cold build's -- the rootfs hash and every measurement downstream
51+
# with them -- while every path that was remapped looked identical.
52+
# Not `[[ ... ]] && export ...`: under set -e a false test makes the whole
53+
# list non-zero and kills the build on the cold path, where this is unset.
54+
if [[ -n ${DSTACK_CARGO_HOME:-} ]]; then export CARGO_HOME="$DSTACK_CARGO_HOME"; fi
55+
# Remapped unconditionally, not only when it moved: both layouts have to land
56+
# on the same string for their binaries to match, so the inherited path needs
57+
# the rule as much as the persisted one does.
58+
# Keep the cold build's historical canonical path. Cached builds map their
59+
# persisted CARGO_HOME onto it, preserving both warm/cold parity and release
60+
# artifact bytes from before the cache optimization.
61+
home_remap="--remap-path-prefix=${CARGO_HOME:?}=/var/tmp/dstack-cargo-home"
3262
# A single codegen unit avoids LLVM partition/scheduling differences across
3363
# hosts with different CPU counts while retaining parallel crate builds.
34-
export RUSTFLAGS="${RUSTFLAGS:-} --remap-path-prefix=$ROOT=/usr/src/dstack --remap-path-prefix=$build_root=/usr/src/dstack-build -C codegen-units=1 -C strip=debuginfo"
64+
export RUSTFLAGS="${RUSTFLAGS:-} $target_remap $home_remap --remap-path-prefix=$ROOT=/usr/src/dstack --remap-path-prefix=$build_root=/usr/src/dstack-build -C codegen-units=1 -C strip=debuginfo"
3565
cargo build --locked --release --manifest-path "$ROOT/dstack/Cargo.toml" \
3666
-p dstack-guest-agent -p dstack-util
3767
install -m0755 "$CARGO_TARGET_DIR/release/dstack-guest-agent" \

os/mkosi/mkosi.build

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ if [[ $cache_enabled == 1 ]]; then
3333
: "${BUILDDIR:?dev component cache requires mkosi BuildDirectory}"
3434
export DSTACK_DEV_CACHE_DIR="$BUILDDIR/components"
3535
toolchain_downloads="$BUILDDIR/toolchains"
36+
# A source edit misses the dstack-rust component cache by design, and the
37+
# rebuild that follows was recompiling the entire workspace -- every
38+
# dependency crate, and re-downloading the registry -- because both the
39+
# target directory and CARGO_HOME lived in the per-build sandbox. Keeping
40+
# them here leaves only the touched crates to rebuild. Release builds still
41+
# get the throwaway layout below.
42+
export DSTACK_CARGO_TARGET_DIR="$BUILDDIR/cargo-target"
43+
export DSTACK_CARGO_HOME="$BUILDDIR/cargo-home"
3644
else
3745
toolchain_downloads=/var/tmp/dstack-toolchain-downloads
3846
fi
@@ -45,8 +53,45 @@ printf 'cmake: '; cmake --version | head -1
4553
dpkg-query -W -f='build-package: ${binary:Package}=${Version}\n' \
4654
gcc g++ binutils cmake make ninja-build dwarves
4755

48-
work=/var/tmp/dstack-component-work
56+
# component-stages/ is where cached component archives get extracted. Keeping
57+
# it in the per-build sandbox means every cache hit still pays a full
58+
# decompression -- the kernel archive alone unpacks 6.4 GiB and costs ~12 s --
59+
# so a cached build persists it and dev-cache.sh skips extracting a tree that
60+
# is already staged under the same key.
61+
#
62+
# Only that subdirectory. The rest of the work directory holds source checkouts
63+
# and object trees, and the component scripts assume those start empty: sysbox
64+
# runs `git submodule update` there and fails outright on a leftover tree.
65+
# Persisting the staging trees buys the decompression; persisting the build
66+
# scratch buys nothing and breaks the builds.
67+
#
68+
# The kernel staging tree is not a cache either. component_assemble merges into
69+
# it on every build, cache hit or not, under strict conflict detection, so a
70+
# persisted one collides with the previous build's merged files.
71+
#
72+
# downloads/ and image-tools-target/ are kept for the same reason: the first is
73+
# fetched tarballs and submodules, the second a cargo target directory, and
74+
# neither holds git or make state that a rerun could trip over. Everything else
75+
# is scratch and is deleted by exclusion, so a directory added later is dropped
76+
# until it is named here deliberately.
77+
work_keep=(component-stages downloads image-tools-target)
78+
if [[ $cache_enabled == 1 ]]; then
79+
work="$BUILDDIR/component-work"
80+
mkdir -p "$work"
81+
prune=(-mindepth 1 -maxdepth 1)
82+
for keep in "${work_keep[@]}"; do
83+
mkdir -p "$work/$keep"
84+
prune+=(! -name "$keep")
85+
done
86+
# Deleting by exclusion rather than symlinking the kept directories
87+
# elsewhere: tar replaces a symlink standing where it wants a directory, so
88+
# a staging tree would silently stop being the persisted one.
89+
find "$work" "${prune[@]}" -exec rm -rf {} +
90+
else
91+
work=/var/tmp/dstack-component-work
92+
fi
4993
kernel_stage=/var/tmp/dstack-kernel-stage
94+
rm -rf "$kernel_stage"
5095
mkdir -p "$work" "$kernel_stage"
5196
args=()
5297
[[ $cache_enabled == 1 ]] && args+=(--dev-cache)

os/mkosi/mkosi.postoutput

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ WORK="$OUTPUTDIR/.dstack-$FLAVOR"
1212
DSTACK_MR_BIN="$OUTPUTDIR/.image-tools/dstack-mr" \
1313
NITRO_TPM_PCR_COMPUTE_BIN="$OUTPUTDIR/.image-tools/nitro-tpm-pcr-compute" \
1414
DIST_DIR="$OUTPUTDIR" \
15+
DSTACK_TAR_RELEASE="${DSTACK_TAR_RELEASE:-1}" \
1516
TAR_OPTIONS="--sort=name --mtime=@$SOURCE_DATE_EPOCH --owner=0 --group=0 --numeric-owner" \
1617
"$ROOT/os/image/assemble.sh" --manifest "$WORK/artifacts/artifact-manifest.json"
1718

os/mkosi/scripts/dev-cache.sh

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,23 +27,65 @@ dev_cache_run() {
2727
checksum="$archive.sha256"
2828
mkdir -p "$dir" "$base"
2929

30+
# Records which key the staging tree currently holds. It lives beside the
31+
# archive rather than in the staging tree's parent: mkosi.build wipes
32+
# everything in that parent except the staging trees themselves, so a stamp
33+
# kept there is deleted before every build and the tree is then always
34+
# re-extracted -- the persistence buys nothing and the wipe costs extra.
35+
local stamp="$dir/staged"
36+
37+
# Anything staged under a different key -- or under no key we can account
38+
# for -- is removed before it can be reused. Skipping this is how files
39+
# from a previous build survive into a later image, which is far worse than
40+
# the extraction it would have saved. Only the component's declared outputs
41+
# are touched, so components sharing a base do not disturb each other.
42+
discard_staged() {
43+
(cd "$base" && rm -rf -- "${outputs[@]}")
44+
rm -f "$stamp"
45+
}
46+
47+
staged_outputs_present() {
48+
local out
49+
for out in "${outputs[@]}"; do
50+
[[ -e $base/$out ]] || return 1
51+
done
52+
}
53+
3054
(
3155
flock 9
3256
if [[ -f $archive && -f $checksum ]] &&
3357
(cd "$dir" && sha256sum --check --status "${checksum##*/}"); then
58+
# The stamp alone is not enough. It outlives the staging tree, so
59+
# anything that removes the tree without clearing it -- a manual
60+
# wipe, a pruned build directory -- would make this skip an
61+
# extraction whose output is not there, and the component would be
62+
# silently missing from the image.
63+
if [[ -f $stamp && $(cat "$stamp") == "$key" ]] && staged_outputs_present; then
64+
echo "development cache hit (already staged): $component"
65+
exit
66+
fi
67+
discard_staged
3468
tar --zstd -xf "$archive" -C "$base"
69+
printf '%s' "$key" > "$stamp"
3570
echo "development cache hit: $component"
3671
exit
3772
fi
3873

3974
local tmp
4075
rm -f "$archive" "$checksum"
4176
echo "development cache miss: $component"
77+
discard_staged
4278
"$@"
4379
tmp=$(mktemp "$dir/.${key}.XXXXXX.tar.zst")
4480
tar --zstd -cf "$tmp" -C "$base" "${outputs[@]}"
4581
mv "$tmp" "$archive"
4682
(cd "$dir" && sha256sum "${archive##*/}" > "${checksum##*/}.tmp")
4783
mv "$checksum.tmp" "$checksum"
48-
) 9>"$dir/$key.lock"
84+
# Written last: a stamp is a claim that the tree matches the archive,
85+
# so it must not exist if any step above failed.
86+
printf '%s' "$key" > "$stamp"
87+
# The staged tree and its stamp are shared by every key for this component.
88+
# Lock the component, not an individual archive: otherwise two builds with
89+
# different keys can concurrently delete and extract the same outputs.
90+
) 9>"$dir/staging.lock"
4991
}

os/mkosi/scripts/make-release-artifacts.sh

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,19 @@ truncate -s 0 "$rootfs"
2525
# Privileged mkosi runs can inherit host default ACLs from their workspace.
2626
# The guest rootfs does not rely on xattrs, so exclude this host-only metadata.
2727
# A sorted tar stream also removes backing-filesystem directory/inode order and
28-
# hardlink topology from the input. A single compressor worker then avoids
29-
# host-CPU-dependent fragment ordering.
28+
# hardlink topology from the input, which is what makes the compressor's worker
29+
# count irrelevant to the output: with -no-tailends and -no-hardlinks there are
30+
# no cross-file fragments left for workers to pack in a racy order. The worker
31+
# count therefore tracks the build's job count, and repro-check is what proves
32+
# it: its two legs deliberately run with different job counts, so a worker-count
33+
# dependency in this output fails the check rather than hiding in it.
34+
# Serializing it instead costs ~285 s of a ~500 s incremental build.
3035
(cd "$TREE" && tar --sort=name --format=gnu \
3136
--mtime="@$SOURCE_DATE_EPOCH" --owner=0 --group=0 --numeric-owner \
3237
--mode=g-s --hard-dereference -cf - .) | \
3338
env -u SOURCE_DATE_EPOCH mksquashfs - "$rootfs" -tar \
3439
-noappend -all-root -no-progress -exports -no-hardlinks -no-tailends \
35-
-no-xattrs -processors 1 -comp zstd -mkfs-time "$SOURCE_DATE_EPOCH" \
40+
-no-xattrs -processors "${JOBS:-1}" -comp zstd -mkfs-time "$SOURCE_DATE_EPOCH" \
3641
-all-time "$SOURCE_DATE_EPOCH" >/dev/null
3742
data_size=$(stat -c %s "$rootfs")
3843
data_size=$(( (data_size + 4095) / 4096 * 4096 ))

os/mkosi/tests/acceptance.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ grep -q 'Archiving UKI image' "$D/../image/assemble.sh"
183183
grep -Fq "FLAVORS=\${FLAVORS:-prod}" "$D/build.sh"
184184
# A cache hit must never be able to answer the question repro-check asks.
185185
grep -Fq "if [[ \$action == repro-check ]]; then cache=0; fi" "$D/build.sh"
186+
# repro-check compares the release tarballs, so it must archive unconditionally
187+
# -- otherwise it compares absent files and reports no difference.
188+
grep -Fq "if [[ \$action == repro-check ]]; then archive=1; fi" "$D/build.sh"
189+
grep -Fq -e "--environment=\"DSTACK_TAR_RELEASE=\$archive\"" "$D/build.sh"
190+
grep -Fq "DSTACK_TAR_RELEASE=\"\${DSTACK_TAR_RELEASE:-1}\"" "$D/mkosi.postoutput"
186191
grep -Fq -e "--environment=\"DSTACK_COMPONENT_CACHE=\$cache\"" "$D/build.sh"
187192
grep -q 'write-source-manifest.py' "$D/build.sh"
188193
grep -q 'DSTACK_SOURCE_REVISION.*revision' "$D/build.sh"

0 commit comments

Comments
 (0)