Keep the static hello binary small under GNU ld (#2373)

This commit is contained in:
Chris Tate
2026-10-26 22:36:03 -05:00
committed by GitHub
10 changed files with 66 additions and 35 deletions
@@ -75,7 +75,7 @@ export function executableLinkInputs(options: {
* links as needed: the runtime defines the libm functions most programs
* reach (scr_glibc_math.c) and Math's transcendentals (scr_ieee754.c), so a
* program that calls no other libm function, such as pow, starts without
* loading libm.so. */
* loading libm.so when it links with lld (see inputsWithoutLld). */
export function systemLibraryLinkerArgs(
target: Pick<NativeTargetSpec, "platform">,
names: readonly string[],
+15 -14
View File
@@ -21,7 +21,7 @@ import {
import { type NativeArtifactDependency } from "./native/contracts.js";
import { RuntimePackError, stageRuntimePackArtifacts } from "./runtime-pack.js";
import type { NativeLinkPlan } from "./link-plan.js";
import { lldLinkerArgs } from "./lld.js";
import { inputsWithoutLld, lldLinkerArgs } from "./lld.js";
import { systemLibraryLinkerArgs } from "./link-plan-core.js";
import {
createDarwinDebugSymbols,
@@ -244,15 +244,19 @@ export async function linkNativeExecutable(
stagedRoot = staged.root;
// Linux links with lld when the default driver finds it.
const lld = explicitLinker === undefined ? lldLinkerArgs(plan.target.platform, linker) : [];
const args = [
...(explicitLinker === undefined ? plan.target.defaultLinkerArgs : []),
...lld,
...plan.driverFlags,
...plan.inputs.map((input) => staged.replacements.get(input) ?? input),
...systemLibraryLinkerArgs(plan.target, plan.systemLibraries),
"-o",
privateOut,
];
const linkArgs = (withLld: boolean): string[] => {
const inputs = plan.inputs.map((input) => staged.replacements.get(input) ?? input);
return [
...(explicitLinker === undefined ? plan.target.defaultLinkerArgs : []),
...(withLld ? lld : []),
...plan.driverFlags,
...(withLld ? inputs : inputsWithoutLld(plan.target.platform, inputs)),
...systemLibraryLinkerArgs(plan.target, plan.systemLibraries),
"-o",
privateOut,
];
};
const args = linkArgs(lld.length !== 0);
// The pack snapshots bracket both verification passes and private staging;
// the program-object snapshot begins before helper emission. Snapshot the
// remaining cache-bearing inputs before the linker consumes them, then
@@ -285,10 +289,7 @@ export async function linkNativeExecutable(
// A failed link with lld runs again with the driver's default linker,
// whose error (if any) is the one reported.
if (lld.length === 0) throw error;
await execFileAsync(
linker,
args.filter((arg) => !lld.includes(arg)),
);
await execFileAsync(linker, linkArgs(false));
});
const output = await stat(privateOut);
if (!output.isFile() || output.size === 0) throw new Error("linker produced no executable");
+8 -1
View File
@@ -2,7 +2,7 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync }
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { driverFindsLld, lldLinkerArgs } from "./lld.js";
import { driverFindsLld, inputsWithoutLld, lldLinkerArgs } from "./lld.js";
const directories: string[] = [];
afterEach(() => {
@@ -59,3 +59,10 @@ test.skipIf(process.platform === "win32")(
expect(lldLinkerArgs("linux", clang, { ...env, SCRIPTC_LINKER: clang })).toEqual([]);
},
);
test("Linux links without lld leave out the glibc math unit", () => {
const inputs = ["/pack/scr_lib.o", "/pack/scr_glibc_math.o", "/tmp/program.o"];
expect(inputsWithoutLld("linux", inputs)).toEqual(["/pack/scr_lib.o", "/tmp/program.o"]);
expect(inputsWithoutLld("darwin", inputs)).toEqual(inputs);
expect(inputsWithoutLld("windows", inputs)).toEqual(inputs);
});
+19 -1
View File
@@ -1,5 +1,5 @@
import { realpathSync, statSync } from "node:fs";
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
function isFile(path: string): boolean {
try {
@@ -47,3 +47,21 @@ export function lldLinkerArgs(
? ["-fuse-ld=lld"]
: [];
}
/**
* The link inputs for a Linux link without lld. The runtime's
* scr_glibc_math.c defines glibc's floor, fmod, frexp and a few others as
* hidden functions so that a program calling no other libm function starts
* without libm.so. That works with lld, which garbage-collects the unused
* definitions and decides `--as-needed` from the live code. GNU ld decides
* `--as-needed` before section GC, so the runtime's unreachable code keeps
* libm needed, and it keeps every regular definition of a name that a needed
* shared library also defines, hidden or not: the unit would add all of its
* code to every executable and save nothing. Its functions answer what
* glibc's do, so linking glibc's own instead is equivalent.
*/
export function inputsWithoutLld(platform: string, inputs: readonly string[]): string[] {
return platform === "linux"
? inputs.filter((input) => basename(input) !== "scr_glibc_math.o")
: [...inputs];
}
+6 -8
View File
@@ -70,7 +70,7 @@ import {
import { splitLlvmLibraryProgram } from "../backend/llvm/split.js";
import { prepareNativeExecutable, prepareNativeLibrary } from "./prepare.js";
import { buildSanitizedRuntime, sanitizerDriver, sanitizerFlags } from "./sanitizer.js";
import { lldLinkerArgs } from "../backend/lld.js";
import { inputsWithoutLld, lldLinkerArgs } from "../backend/lld.js";
import { openNativeExecutableCache } from "./executable-cache.js";
import { LinkerJobs } from "./linker-job.js";
import { nativeFileIdentity } from "./file-identity.js";
@@ -569,15 +569,16 @@ export class NativeCompiler {
});
// Linux links with lld when the driver finds it.
const lld = options.sanitize ? [] : lldLinkerArgs(target.platform, toolchain.linker);
const linkArgs = [
const linkArgsWith = (withLld: boolean): string[] => [
...(options.sanitize ? ["-fsanitize=address"] : toolchain.linkerArgs),
...lld,
...(withLld ? lld : []),
...plan.driverFlags,
...plan.inputs,
...(withLld ? plan.inputs : inputsWithoutLld(target.platform, plan.inputs)),
...systemLibraryLinkerArgs(target, plan.systemLibraries),
"-o",
stagedOutput,
];
const linkArgs = linkArgsWith(lld.length !== 0);
// Host Linux links with lld run the linker job the driver would.
const jobs = LinkerJobs.open(this.cache, toolchain, lld, options.sanitize ?? false);
const job = jobs?.link([...plan.inputs, stagedOutput], stage);
@@ -593,10 +594,7 @@ export class NativeCompiler {
// A failed link with lld runs again with the driver's default
// linker, whose error (if any) is the one reported.
if (lld.length === 0) throw error;
runNativeTool(
driver,
linkArgs.filter((arg) => !lld.includes(arg)),
);
runNativeTool(driver, linkArgsWith(false));
}
}
timing("native-link");
+5 -4
View File
@@ -5393,6 +5393,11 @@ static ScrGen *scr_gen_new_common(void (*entry)(ScrFiber *, void *), void *argpa
bool stackless) {
ScrGen *g = scr_mem_calloc(sizeof *g);
if (!g) scr_oom();
/* Install the tag reader with each generator rather than in a constructor:
* a constructor is a link root, so it would keep scr_gen_tag and this
* unit's string literals in every executable, generators or not. Threads
* store the same value, relaxed. */
__atomic_store_n(&scr_gen_tag_reader, scr_gen_tag, __ATOMIC_RELAXED);
g->rc = 1;
g->receiver = scr_dyn_this_get();
g->state = SCR_GEN_UNSTARTED;
@@ -5967,10 +5972,6 @@ const char *scr_gen_tag(const ScrGen *g) {
return g->tag ? g->tag : g->is_async ? "AsyncGenerator" : "Generator";
}
__attribute__((constructor)) static void scr_gen_tag_reader_init(void) {
scr_gen_tag_reader = scr_gen_tag;
}
void scr_gen_close(ScrGen *g) {
scr_gen_slot_reset(&g->ret);
scr_gen_resume_return(g);
+3 -2
View File
@@ -82,8 +82,9 @@ void scr_gen_set_tag(ScrGen *g, const char *tag);
void scr_gen_copy_tag(ScrGen *g, const ScrGen *from);
const char *scr_gen_tag(const ScrGen *g);
/* scr_gen_tag for units that also link without the generator runtime (a
* library links none): the generator runtime sets it at startup, and only
* then can a generator capsule exist. */
* library links none): the generator runtime sets it when it creates a
* generator, so it is set whenever a generator capsule exists. Read it with
* a relaxed atomic load. */
extern const char *(*scr_gen_tag_reader)(const ScrGen *g);
ScrGen *scr_gen_retain(ScrGen *g);
void scr_gen_release(ScrGen *g); /* NULL-tolerant */
+1 -1
View File
@@ -1613,7 +1613,7 @@ static bool scr_insp_is_generator(const ScrDyn *d) {
}
static ScrStr *scr_insp_generator(const ScrDyn *d) {
const char *tag = scr_gen_tag_reader(d->v.typed_ref.ptr);
const char *tag = __atomic_load_n(&scr_gen_tag_reader, __ATOMIC_RELAXED)(d->v.typed_ref.ptr);
if (tag[0] == '!') {
static const char message[] = "util.inspect of this iterator object is not supported yet";
scr_throw_error_msg(SCR_ERR_ERROR, message, sizeof message - 1);
+1 -1
View File
@@ -9633,7 +9633,7 @@ ScrStr *scr_dyn_object_tag(const ScrDyn *d) {
if (d->kind == SCR_DYN_TYPED_REF) {
if (scr_dyn_ref_is_error(d)) return scr_str_new("[object Error]", 14);
if (scr_dyn_generator(d)) {
const char *gen_tag = scr_gen_tag_reader(d->v.typed_ref.ptr);
const char *gen_tag = __atomic_load_n(&scr_gen_tag_reader, __ATOMIC_RELAXED)(d->v.typed_ref.ptr);
if (gen_tag[0] == '!') {
static const char message[] = "Object.prototype.toString of this iterator is not supported yet";
scr_throw_error_msg(SCR_ERR_ERROR, message, sizeof message - 1);
+7 -2
View File
@@ -142,6 +142,9 @@ nativeToolchainTest(
"scr_url_",
"scr_json_parse",
"scr_date_",
// The generator runtime installs its tag reader with its first
// generator; a load-time constructor would retain it everywhere.
"scr_gen_tag",
]) {
expect(helloSymbols, `hello retains ${family}`).not.toContain(family);
}
@@ -159,8 +162,10 @@ nativeToolchainTest(
// Symbol absence is the primary reachability contract. Keep a deliberately
// roomy, platform-specific hello-world ceiling too: it catches losing
// section GC without pinning an exact linker/SDK byte count. The canonical
// Linux build is about 84KB and current Mach-O builds are about 70KB. Most
// section GC without pinning an exact linker/SDK byte count. The Linux
// build is about 65KB with lld (CI's pinned LLVM provides it) and about
// 94KB with GNU ld, which keeps a unit's string literals whole once any of
// them is reachable; current Mach-O builds are about 70KB. Most
// of Linux hello's runtime code is the cycle collector, which its exit
// path reaches (scr_lib_cleanup releases an array). ELF segments are page
// aligned, so the Linux size moves in 4 KiB steps whenever .text crosses