Skip to content

Commit f127bb3

Browse files
daniel-nolandclaude
andcommitted
build(nix): bound how many ThinLTO links a test archive runs at once
Under cross-language ThinLTO each test binary's link optimizes the whole program, Rust and DPDK together, and holds gigabytes while it does. Cargo cannot see that: `-j` caps rustc processes, any of which may be linking, so how many links overlap is left to chance -- and an unlucky overlap is what keeps running `check/release` out of its 20 GiB. Building `tests.all` at `profile=release` with unbounded links on 10 cores peaked at 36.2 GiB with nine ld.lld processes resident at once. Turning down `--thinlto-jobs` only shrinks every link to make that overlap survivable, and the links keep growing with the code. Bound the overlap instead. rustc now links through nix/scripts/link-slots.sh, which, when `LINK_SLOTS` is set, takes one of that many flock slots before running the real linker and holds it until the linker exits; `LINK_THINLTO_JOBS` gives each bounded link more backend threads. Unset, it is transparent, and only test archives whose profile uses `-Clinker-plugin-lto` (`release`) set them: two slots, four threads each. They are environment rather than flags, so they stay out of cargo's fingerprint. Measured with line-table debug info on 8 cores, three runs each: 2 slots x 4 threads 611-616 s peak 13.2-14.1 GiB 1 slot x 8 threads 913-935 s peak 10.4-12.1 GiB One slot puts the links on the critical path for little memory. A link waiting for a slot still has its rustc parent resident, with its whole compilation session; the next commit releases that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Daniel Noland <daniel@githedgehog.com>
1 parent 6c5e379 commit f127bb3

3 files changed

Lines changed: 81 additions & 4 deletions

File tree

‎default.nix‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,23 @@ let
889889
is-cross-compile = pkgs.stdenv'.buildPlatform.rust.rustcTarget != ctarget;
890890
cc = if is-cross-compile then "${ctarget}-clang" else "clang";
891891
strip = if is-cross-compile then "${ctarget}-strip" else "strip";
892+
# What rustc links with: the cross or native clang, behind nix/scripts/link-slots.sh, which bounds
893+
# how many links run at once where a build sets `LINK_SLOTS` (see `test-builder`) and is
894+
# transparent everywhere else.
895+
#
896+
# The `-clang` is for rustc, which infers how to drive a linker from its name. Under a name it
897+
# does not recognize it falls back to the target's default, which on x86_64-unknown-linux-gnu and
898+
# aarch64-unknown-linux-gnu adds rustc's own self-contained lld (`-fuse-ld=lld` and its `gcc-ld`
899+
# directory). Ending in `-clang` gets the link line it gives clang itself, on every target.
900+
link-slots = pkgs.pkgsBuildHost.writeShellApplication {
901+
name = "link-slots-clang";
902+
runtimeInputs = with pkgs.pkgsBuildHost; [
903+
coreutils
904+
util-linux
905+
];
906+
runtimeEnv.LINK_CC = "${pkgs.pkgsBuildHost.llvmPackages'.clang}/bin/${cc}";
907+
text = builtins.readFile ./nix/scripts/link-slots.sh;
908+
};
892909
objcopy = if is-cross-compile then "${ctarget}-objcopy" else "objcopy";
893910
package-list = builtins.fromJSON (
894911
builtins.readFile (
@@ -1021,7 +1038,7 @@ let
10211038
builtins.concatStringsSep " " (
10221039
profile.RUSTFLAGS
10231040
++ [
1024-
"-Clinker=${pkgs.pkgsBuildHost.llvmPackages'.clang}/bin/${cc}"
1041+
"-Clinker=${link-slots}/bin/link-slots-clang"
10251042
"-Clink-arg=--ld-path=${pkgs.pkgsBuildHost.llvmPackages'.lld}/bin/ld.lld"
10261043
"-Clink-arg=-L${sysroot}/lib"
10271044
# Keep debug paths stable across revisions. Source readers
@@ -1391,6 +1408,23 @@ let
13911408
cargo-features == [ ]
13921409
&& !(builtins.elem "coverage" instrumentations)
13931410
&& (package == null || package == "dataplane-init");
1411+
# Under cross-language ThinLTO every test binary's link optimizes the whole program, and
1412+
# cargo cannot see how many of those overlap. Bound them (nix/scripts/link-slots.sh): two at a
1413+
# time, four backend threads each. Measured building `tests.all` at `profile=release` with
1414+
# line-table debug info on 8 cores, three runs each:
1415+
#
1416+
# links unbounded (10 cores, full debug info) 514 s 36.2 GiB peak
1417+
# 2 slots x 4 threads 614 s 13.2-14.1 GiB
1418+
# 1 slot x 8 threads 924 s 10.4-12.1 GiB
1419+
#
1420+
# One slot makes the links the critical path for little memory; two keep the peak bounded
1421+
# without that.
1422+
lto-link-slots =
1423+
pkgs.lib.optionalAttrs (builtins.elem "-Clinker-plugin-lto" profile-tests'.RUSTFLAGS)
1424+
{
1425+
LINK_SLOTS = "2";
1426+
LINK_THINLTO_JOBS = "4";
1427+
};
13941428
in
13951429
pkgs.callPackage invoke {
13961430
builder = craneLib.mkCargoDerivation;
@@ -1401,6 +1435,7 @@ let
14011435
// pkgs.lib.optionalAttrs wants-gateway {
14021436
DATAPLANE_TEST_GATEWAY_ROOTFS = "${gateway-rootfs}";
14031437
}
1438+
// lto-link-slots
14041439
// {
14051440
buildPhaseCargoCommand =
14061441
(builtins.concatStringsSep " " (

‎nix/profiles.nix‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ let
7676
"-Cdwarf-version=5"
7777
"-Csymbol-mangling-version=v0"
7878
"-Clink-arg=-Wl,--as-needed,--gc-sections" # FRR builds don't like this, but rust does fine
79-
# The backend threads of one ThinLTO link, each holding modules of its own. CI runs up to
80-
# `lto_cores` such links at once in a 20 GiB cgroup, so the peak is roughly the product; 4 left
81-
# too little headroom.
79+
# The backend threads of one ThinLTO link, each holding modules of its own, for links nothing
80+
# bounds: up to `lto_cores` of them can overlap in CI's 20 GiB cgroup. A test archive bounds its
81+
# links instead and gives each more (`LINK_SLOTS` and `LINK_THINLTO_JOBS` in default.nix).
8282
"-Clink-arg=-Wl,--thinlto-jobs=2"
8383
]
8484
++ (

‎nix/scripts/link-slots.sh‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright Open Network Fabric Authors
3+
4+
# The linker rustc runs (`-Clinker`), in front of the real one, `LINK_CC`.
5+
#
6+
# Every ThinLTO link runs an ld.lld that optimizes the whole program, Rust and the C dependencies
7+
# together, and holds gigabytes while it does. Cargo cannot see that: `-j` caps how many rustc
8+
# processes run, and any of them may be linking, so how many links overlap is left to chance -- and
9+
# an unlucky overlap is what runs a build out of memory. With `LINK_SLOTS` set, a link first takes
10+
# one of that many slots and keeps it until the linker exits; the others wait their turn.
11+
#
12+
# `LINK_THINLTO_JOBS` sets each link's ThinLTO backend threads: with the links bounded, the ones
13+
# that run can use more.
14+
#
15+
# Unset (or `LINK_SLOTS=0`), this is transparent. Both are environment rather than flags so that
16+
# they stay out of cargo's fingerprint: builds with and without them share dependency artifacts.
17+
18+
extra=()
19+
if [ -n "${LINK_THINLTO_JOBS:-}" ]; then
20+
# lld takes the last `--thinlto-jobs`, so this overrides the profile's.
21+
extra+=("-Wl,--thinlto-jobs=${LINK_THINLTO_JOBS}")
22+
fi
23+
24+
if [ "${LINK_SLOTS:-0}" -gt 0 ]; then
25+
# One directory per build: the nix sandbox gives each its own `NIX_BUILD_TOP`.
26+
dir="${NIX_BUILD_TOP:-${TMPDIR:-/tmp}}/link-slots"
27+
mkdir -p "${dir}"
28+
while true; do
29+
for ((slot = 1; slot <= LINK_SLOTS; slot++)); do
30+
exec {fd}> "${dir}/${slot}"
31+
if flock --nonblock "${fd}"; then
32+
# The lock is held by the open descriptor, which the linker inherits through `exec`, so
33+
# the slot is released when the linker exits, however it exits.
34+
exec "${LINK_CC}" "$@" "${extra[@]}"
35+
fi
36+
exec {fd}>&-
37+
done
38+
sleep 0.2
39+
done
40+
fi
41+
42+
exec "${LINK_CC}" "$@" "${extra[@]}"

0 commit comments

Comments
 (0)