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