From 019fc51e3137fb7424df5e89dcd8f52fbda551a2 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 26 Oct 2026 19:33:49 -0700 Subject: [PATCH] Keep the generator tag reader and the glibc math unit out of hello The static hello binary grew past the 96 KiB ceiling of the runtime tree-shaking test on Linux hosts that link with GNU ld (99,040 B). CI does not see it: its pinned LLVM ships ld.lld, which the compiler prefers, and hello links to about 65 KB there. The Sandbox gate installs Ubuntu's clang-22 without lld, so it links with GNU ld. dd418b067 crossed the ceiling (98,032 -> 98,960 B). Its scr_glibc_math.c defines floor, fmod, frexp and other glibc names as hidden functions so a program can start without libm. With GNU ld that cannot work: 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 a needed shared library defines, hidden or not. Every executable carried the whole unit (about 2.5 KB of code and unwind data) and still loaded libm. Links without lld now leave the unit out; its functions answer what glibc's do. 192ea3247 installed scr_gen_tag_reader from a load-time constructor in scr_async.c. A constructor is a link root on every format, so each executable kept scr_gen_tag and, with GNU ld, all of scr_async.c's string literals (about 1.1 KB). The generator runtime now installs the reader when it creates a generator, the only time a reader can be needed. Hello is now 93,928 B with GNU ld and 65,472 B with lld. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/link-plan-core.ts | 2 +- packages/compiler/src/backend/linker.ts | 29 ++++++++++--------- packages/compiler/src/backend/lld.test.ts | 9 +++++- packages/compiler/src/backend/lld.ts | 20 ++++++++++++- packages/compiler/src/native/compiler.ts | 14 ++++----- packages/runtime/src/scr_async.c | 9 +++--- packages/runtime/src/scr_generator.h | 5 ++-- packages/runtime/src/scr_inspect.c | 2 +- packages/runtime/src/scr_json.c | 2 +- tests/harness/runtime-tree-shaking.test.ts | 9 ++++-- 10 files changed, 66 insertions(+), 35 deletions(-) diff --git a/packages/compiler/src/backend/link-plan-core.ts b/packages/compiler/src/backend/link-plan-core.ts index fc05d2961..f8ad20e6f 100644 --- a/packages/compiler/src/backend/link-plan-core.ts +++ b/packages/compiler/src/backend/link-plan-core.ts @@ -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, names: readonly string[], diff --git a/packages/compiler/src/backend/linker.ts b/packages/compiler/src/backend/linker.ts index c4f04133e..a29a7c947 100644 --- a/packages/compiler/src/backend/linker.ts +++ b/packages/compiler/src/backend/linker.ts @@ -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"); diff --git a/packages/compiler/src/backend/lld.test.ts b/packages/compiler/src/backend/lld.test.ts index b0a466cf6..048f2ab94 100644 --- a/packages/compiler/src/backend/lld.test.ts +++ b/packages/compiler/src/backend/lld.test.ts @@ -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); +}); diff --git a/packages/compiler/src/backend/lld.ts b/packages/compiler/src/backend/lld.ts index 790e07062..7a9210407 100644 --- a/packages/compiler/src/backend/lld.ts +++ b/packages/compiler/src/backend/lld.ts @@ -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]; +} diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index ea3772db3..942565327 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -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"); diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index aed974892..ec799a4b8 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -5385,6 +5385,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; @@ -5959,10 +5964,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); diff --git a/packages/runtime/src/scr_generator.h b/packages/runtime/src/scr_generator.h index cb61d93e4..4f43892b4 100644 --- a/packages/runtime/src/scr_generator.h +++ b/packages/runtime/src/scr_generator.h @@ -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 */ diff --git a/packages/runtime/src/scr_inspect.c b/packages/runtime/src/scr_inspect.c index b4994cd32..e6df0f4cf 100644 --- a/packages/runtime/src/scr_inspect.c +++ b/packages/runtime/src/scr_inspect.c @@ -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); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 7b0d44069..d1f2af6cc 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -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); diff --git a/tests/harness/runtime-tree-shaking.test.ts b/tests/harness/runtime-tree-shaking.test.ts index b33a0d2f9..92584a59e 100644 --- a/tests/harness/runtime-tree-shaking.test.ts +++ b/tests/harness/runtime-tree-shaking.test.ts @@ -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