mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-08 08:44:37 +08:00
- In a module that counts executions for a profile (--profile-generate, also the context-sensitive second phase), every x86-64-v3 copy keeps its code. A copy that took its baseline copy's body would increment the baseline copy's counters, and a --profile-use build would then treat the x86-64-v3 copy as never run. - With a profile, a copy keeps its code when its baseline copy ran fewer times. A profile collected on an x86-64-v3 machine gives the baseline copies no runs, and their bodies are optimized as cold code.
1254 lines
53 KiB
TypeScript
1254 lines
53 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { createRequire } from "node:module";
|
|
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import { afterEach, describe, expect, test } from "vitest";
|
|
import { nativeCodegenTarget, nativeHelperForTarget } from "../src/backend/targets.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
// Mach-O objects define a local label (ltmp0, ltmp1, ...) at the start of
|
|
// each section; it names no function.
|
|
const machoSectionLabel = /^ltmp\d+$/;
|
|
const target = nativeCodegenTarget();
|
|
const helperSpec = target === null ? null : nativeHelperForTarget(target);
|
|
const helper = (() => {
|
|
if (helperSpec === null || process.platform === "win32") return null;
|
|
try {
|
|
const packageJson = createRequire(import.meta.url).resolve(
|
|
`${helperSpec.packageName}/package.json`,
|
|
);
|
|
return join(dirname(packageJson), "bin", "scriptc-llvm-codegen");
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
const dirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
/** A program whose internal functions, constants and state must stay
|
|
* reachable across partitions. `edit` changes one function's addend. */
|
|
function program(edit = 0, extra = "", count = 24): string {
|
|
const lines = [
|
|
"; partitioned program",
|
|
"%Pair = type { i64, i64 }",
|
|
"@total = internal global i64 0",
|
|
"@pair = internal global %Pair { i64 3, i64 4 }",
|
|
'@label = private unnamed_addr constant [6 x i8] c"total\\00"',
|
|
"declare i64 @strlen(ptr)",
|
|
extra,
|
|
"define internal void @bump(i64 %value) {",
|
|
" %old = load i64, ptr @total",
|
|
" %new = add i64 %old, %value",
|
|
" store i64 %new, ptr @total",
|
|
" ret void",
|
|
"}",
|
|
];
|
|
for (let index = 0; index < count; index++) {
|
|
const addend = index === 5 ? index + edit : index;
|
|
lines.push(
|
|
`define internal void @step${index}(i64 %value) noinline {`,
|
|
` %scaled = mul i64 %value, ${addend}`,
|
|
` call void @bump(i64 %scaled)`,
|
|
...(index + 1 < count ? [` call void @step${index + 1}(i64 %value)`] : []),
|
|
" ret void",
|
|
"}",
|
|
);
|
|
}
|
|
lines.push(
|
|
"define i32 @main() {",
|
|
" %second = load i64, ptr getelementptr (%Pair, ptr @pair, i32 0, i32 1)",
|
|
" call void @step0(i64 %second)",
|
|
// A second caller for every step keeps the steps in separate partitions.
|
|
...Array.from({ length: count - 1 }, (_, index) => ` call void @step${index + 1}(i64 1)`),
|
|
" %length = call i64 @strlen(ptr @label)",
|
|
" call void @bump(i64 %length)",
|
|
" %total = load i64, ptr @total",
|
|
" %low = urem i64 %total, 251",
|
|
" %code = trunc i64 %low to i32",
|
|
" ret i32 %code",
|
|
"}",
|
|
);
|
|
return lines.join("\n") + "\n";
|
|
}
|
|
|
|
function expectedExit(edit = 0, count = 24): number {
|
|
const addends = Array.from({ length: count }, (_, index) => (index === 5 ? index + edit : index));
|
|
let total = 5; // strlen("total")
|
|
for (let start = 0; start < count; start++)
|
|
for (let index = start; index < count; index++)
|
|
total += (start === 0 ? 4 : 1) * addends[index]!;
|
|
return total % 251;
|
|
}
|
|
|
|
async function emit(
|
|
dir: string,
|
|
source: string,
|
|
options: {
|
|
outputs: number;
|
|
optLevel: "0" | "2";
|
|
cache?: string;
|
|
split?: boolean;
|
|
profileGenerate?: string;
|
|
name: string;
|
|
},
|
|
): Promise<string[]> {
|
|
const input = join(dir, `${options.name}.ll`);
|
|
await writeFile(input, source);
|
|
const outputs = Array.from({ length: options.outputs }, (_, index) =>
|
|
join(dir, `${options.name}.${index}.o`),
|
|
);
|
|
await execFileAsync(helper!, [
|
|
"emit",
|
|
"--input",
|
|
input,
|
|
...outputs.flatMap((output) => ["--output", output]),
|
|
"--filetype",
|
|
"obj",
|
|
"--target",
|
|
target!.llvmTriple,
|
|
"--opt-level",
|
|
options.optLevel,
|
|
"--relocation-model",
|
|
"pic",
|
|
"--diagnostic-format",
|
|
"json",
|
|
"--source-path",
|
|
"/src/program.ts",
|
|
...(options.cache === undefined ? [] : ["--cache-dir", options.cache]),
|
|
...(options.split === true ? ["--split", "codegen"] : []),
|
|
...(options.profileGenerate === undefined
|
|
? []
|
|
: ["--profile-generate", options.profileGenerate]),
|
|
]);
|
|
return outputs;
|
|
}
|
|
|
|
async function exitCode(dir: string, objects: string[], name: string): Promise<number> {
|
|
const executable = join(dir, name);
|
|
await execFileAsync(process.env["CC"] ?? "cc", [...objects, "-o", executable]);
|
|
try {
|
|
await execFileAsync(executable, []);
|
|
return 0;
|
|
} catch (error) {
|
|
return (error as { code: number }).code;
|
|
}
|
|
}
|
|
|
|
async function cacheEntries(cache: string, suffix: string): Promise<Set<string>> {
|
|
const entries = new Set<string>();
|
|
for (const bucket of await readdir(cache).catch(() => [])) {
|
|
for (const name of await readdir(join(cache, bucket)).catch(() => []))
|
|
if (name.endsWith(suffix)) entries.add(name);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
describe.runIf(helper !== null)("LLVM program partitions", () => {
|
|
for (const optLevel of ["2", "0"] as const) {
|
|
test(`opt-level ${optLevel} partitions link deterministically and reuse unchanged partitions`, async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-"));
|
|
dirs.push(dir);
|
|
const cache = join(dir, "cache");
|
|
const first = await emit(dir, program(), { outputs: 4, optLevel, cache, name: "first" });
|
|
const whole = await emit(dir, program(), { outputs: 1, optLevel, name: "whole" });
|
|
expect(await exitCode(dir, first, "first")).toBe(expectedExit());
|
|
expect(await exitCode(dir, whole, "whole")).toBe(expectedExit());
|
|
|
|
// The same module always yields the same objects, with or without reuse.
|
|
const fresh = await emit(dir, program(), { outputs: 4, optLevel, name: "fresh" });
|
|
const reused = await emit(dir, program(), { outputs: 4, optLevel, cache, name: "reused" });
|
|
const contents = (paths: string[]) => Promise.all(paths.map((path) => readFile(path)));
|
|
expect(await contents(fresh)).toEqual(await contents(first));
|
|
expect(await contents(reused)).toEqual(await contents(first));
|
|
|
|
// An edit to one function changes one partition's source; the rest
|
|
// are reused.
|
|
const sources = await cacheEntries(cache, optLevel === "0" ? ".o" : ".bc");
|
|
const edited = await emit(dir, program(7), { outputs: 4, optLevel, cache, name: "edited" });
|
|
const after = await cacheEntries(cache, optLevel === "0" ? ".o" : ".bc");
|
|
expect([...after].filter((entry) => !sources.has(entry))).toHaveLength(1);
|
|
expect(await exitCode(dir, edited, "edited")).toBe(expectedExit(7));
|
|
});
|
|
}
|
|
|
|
test("an internal function that other partitions reach through an import keeps its name across edits", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-imports-"));
|
|
dirs.push(dir);
|
|
const count = 24;
|
|
const factor = (index: number, edit: number) => index + 3 + (index === 5 ? edit : 0);
|
|
// Each leaf is internal and called only by its wrapper. The wrappers are
|
|
// small, and main and the steps import them from other partitions, with
|
|
// a reference to the leaf, which its partition then exports.
|
|
const source = (edit: number) => {
|
|
const lines: string[] = [];
|
|
for (let index = 0; index < count; index++)
|
|
lines.push(
|
|
`define internal i64 @leaf${index}(i64 %value) noinline {`,
|
|
` %scaled = mul i64 %value, ${factor(index, edit)}`,
|
|
" ret i64 %scaled",
|
|
"}",
|
|
`define internal i64 @wrap${index}(i64 %value) {`,
|
|
` %leaf = call i64 @leaf${index}(i64 %value)`,
|
|
" %next = add i64 %leaf, 1",
|
|
" ret i64 %next",
|
|
"}",
|
|
`define i64 @step${index}(i64 %value) noinline {`,
|
|
` %wrapped = call i64 @wrap${index}(i64 %value)`,
|
|
" ret i64 %wrapped",
|
|
"}",
|
|
);
|
|
lines.push("define i32 @main() {", "entry:");
|
|
for (let index = 0; index < count; index++)
|
|
lines.push(
|
|
` %s${index} = call i64 @step${index}(i64 1)`,
|
|
` %w${index} = call i64 @wrap${index}(i64 2)`,
|
|
` %a${index} = add i64 ${index === 0 ? "0" : `%t${index - 1}`}, %s${index}`,
|
|
` %t${index} = add i64 %a${index}, %w${index}`,
|
|
);
|
|
lines.push(
|
|
` %low = urem i64 %t${count - 1}, 251`,
|
|
" %code = trunc i64 %low to i32",
|
|
" ret i32 %code",
|
|
"}",
|
|
);
|
|
return lines.join("\n") + "\n";
|
|
};
|
|
const expected = (edit: number) => {
|
|
let total = 0;
|
|
for (let index = 0; index < count; index++) total += 3 * factor(index, edit) + 2;
|
|
return total % 251;
|
|
};
|
|
// ThinLTO renames an exported internal function <name>.llvm.<module
|
|
// hash>. The hash is the partition number, so an edit of the leaf's
|
|
// partition leaves the name, and the importing partitions, unchanged.
|
|
const promoted = async (objects: string[]) => {
|
|
const names = new Set<string>();
|
|
for (const object of objects)
|
|
for (const match of (await execFileAsync("nm", [object])).stdout.matchAll(
|
|
/^[0-9a-fA-F ]+ ([TU]) _?(leaf\d+\.llvm\.\d+)$/gm,
|
|
))
|
|
names.add(`${match[1]} ${match[2]}`);
|
|
return names;
|
|
};
|
|
const first = await emit(dir, source(0), { outputs: 4, optLevel: "2", name: "first" });
|
|
expect(await exitCode(dir, first, "first")).toBe(expected(0));
|
|
const edited = await emit(dir, source(4), { outputs: 4, optLevel: "2", name: "edited" });
|
|
expect(await exitCode(dir, edited, "edited")).toBe(expected(4));
|
|
const names = await promoted(first);
|
|
expect([...names].some((name) => name.startsWith("U "))).toBe(true);
|
|
for (const name of names) expect(name).toMatch(/\.llvm\.[1-4]$/);
|
|
expect(await promoted(edited)).toEqual(names);
|
|
});
|
|
|
|
test("split code generation links deterministically and defines every function once", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-split-codegen-"));
|
|
dirs.push(dir);
|
|
const parts = await emit(dir, program(), {
|
|
outputs: 3,
|
|
optLevel: "2",
|
|
split: true,
|
|
name: "split",
|
|
});
|
|
expect(await exitCode(dir, parts, "split")).toBe(expectedExit());
|
|
const again = await emit(dir, program(), {
|
|
outputs: 3,
|
|
optLevel: "2",
|
|
split: true,
|
|
name: "again",
|
|
});
|
|
const contents = (paths: string[]) => Promise.all(paths.map((path) => readFile(path)));
|
|
expect(await contents(again)).toEqual(await contents(parts));
|
|
|
|
// The module is optimized whole, so the parts define the same functions as
|
|
// a single object, each in one part.
|
|
const defined = async (object: string) => {
|
|
const { stdout } = await execFileAsync("nm", ["--defined-only", object]);
|
|
return stdout
|
|
.split("\n")
|
|
.map((line) => /^[0-9a-fA-F]+ [Tt] _?(\w+)$/.exec(line.trim())?.[1])
|
|
.filter((name): name is string => name !== undefined && !machoSectionLabel.test(name));
|
|
};
|
|
const whole = await defined(
|
|
(await emit(dir, program(), { outputs: 1, optLevel: "2", name: "whole" }))[0]!,
|
|
);
|
|
const split = await Promise.all(parts.map(defined));
|
|
expect(split.flat().sort()).toEqual([...whole].sort());
|
|
for (const names of split) expect(names.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("split code generation keeps local a definition that only its own part uses", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-split-codegen-local-"));
|
|
dirs.push(dir);
|
|
const count = 24;
|
|
const lines = ["@seed = internal global i64 5", "@total = internal global i64 0"];
|
|
for (let index = 0; index < count; index++)
|
|
lines.push(
|
|
`define internal i64 @leaf${index}(i64 %value) noinline {`,
|
|
` %scaled = mul i64 %value, ${index + 3}`,
|
|
" ret i64 %scaled",
|
|
"}",
|
|
`define internal void @step${index}(i64 %value) noinline {`,
|
|
` %scaled = call i64 @leaf${index}(i64 %value)`,
|
|
" %old = load i64, ptr @total",
|
|
" %new = add i64 %old, %scaled",
|
|
" store i64 %new, ptr @total",
|
|
" ret void",
|
|
"}",
|
|
);
|
|
lines.push(
|
|
"define i32 @main() {",
|
|
" %seed = load volatile i64, ptr @seed",
|
|
...Array.from({ length: count }, (_, index) => ` call void @step${index}(i64 %seed)`),
|
|
" %total = load i64, ptr @total",
|
|
" %low = urem i64 %total, 251",
|
|
" %code = trunc i64 %low to i32",
|
|
" ret i32 %code",
|
|
"}",
|
|
);
|
|
const parts = await emit(dir, lines.join("\n") + "\n", {
|
|
outputs: 3,
|
|
optLevel: "2",
|
|
split: true,
|
|
name: "local",
|
|
});
|
|
let sum = 0;
|
|
for (let index = 0; index < count; index++) sum += 5 * (index + 3);
|
|
expect(await exitCode(dir, parts, "local")).toBe(sum % 251);
|
|
const symbols = await Promise.all(
|
|
parts.map(async (part) => (await execFileAsync("nm", ["--defined-only", part])).stdout),
|
|
);
|
|
const bindings = (listing: string, prefix: string) =>
|
|
[...listing.matchAll(new RegExp(`^[0-9a-fA-F]+ ([Tt]) _?${prefix}\\d+$`, "gm"))].map(
|
|
(match) => match[1],
|
|
);
|
|
// A step in another part than main's is called across parts. A leaf is
|
|
// called only by its step, which is in the same part unless a part
|
|
// boundary falls between them.
|
|
const mainPart = symbols.findIndex((listing) => / T _?main$/m.test(listing));
|
|
expect(mainPart).toBeGreaterThan(0);
|
|
const crossPart = symbols
|
|
.filter((_, index) => index !== mainPart)
|
|
.flatMap((listing) => bindings(listing, "step"));
|
|
expect(crossPart.length).toBeGreaterThan(0);
|
|
expect(new Set(crossPart)).toEqual(new Set(["T"]));
|
|
const leaves = symbols.flatMap((listing) => bindings(listing, "leaf"));
|
|
expect(leaves).toHaveLength(count);
|
|
expect(leaves.filter((bound) => bound === "t").length).toBeGreaterThanOrEqual(count - 2);
|
|
});
|
|
|
|
test("split code generation links a thread-local global that only one part uses", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-split-codegen-tls-"));
|
|
dirs.push(dir);
|
|
// @touch and main reach @slots through one constant expression, which
|
|
// outlives their bodies in the part that holds neither.
|
|
const thread = [
|
|
"@calls = internal thread_local(localexec) global i64 0",
|
|
"@slots = internal thread_local(localexec) global [2 x i64] zeroinitializer",
|
|
"define internal void @touch() noinline {",
|
|
" store volatile i64 1, ptr getelementptr inbounds (i8, ptr @slots, i64 8)",
|
|
" ret void",
|
|
"}",
|
|
].join("\n");
|
|
const source = program(0, thread).replace(
|
|
" %total = load i64, ptr @total",
|
|
[
|
|
" store volatile i64 1, ptr @calls",
|
|
" call void @touch()",
|
|
" %slot = load volatile i64, ptr getelementptr inbounds (i8, ptr @slots, i64 8)",
|
|
" %total = load i64, ptr @total",
|
|
].join("\n"),
|
|
);
|
|
const parts = await emit(dir, source, { outputs: 3, optLevel: "2", split: true, name: "tls" });
|
|
expect(await exitCode(dir, parts, "tls")).toBe(expectedExit());
|
|
});
|
|
|
|
test("split code generation keeps a module that one function dominates whole", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-split-codegen-whole-"));
|
|
dirs.push(dir);
|
|
const body = Array.from(
|
|
{ length: 400 },
|
|
(_, index) => ` %v${index + 1} = mul i64 %v${index}, %v${index}`,
|
|
);
|
|
const heavy = [
|
|
"@sink = internal global i64 0",
|
|
"define internal void @heavy(i64 %v0) noinline {",
|
|
...body,
|
|
" store volatile i64 %v400, ptr @sink",
|
|
" ret void",
|
|
"}",
|
|
].join("\n");
|
|
const source = program(0, heavy, 2).replace(
|
|
" %total = load i64, ptr @total",
|
|
" call void @heavy(i64 %second)\n %total = load i64, ptr @total",
|
|
);
|
|
const parts = await emit(dir, source, {
|
|
outputs: 3,
|
|
optLevel: "2",
|
|
split: true,
|
|
name: "whole",
|
|
});
|
|
expect(await exitCode(dir, parts, "whole")).toBe(expectedExit(0, 2));
|
|
const { stdout } = await execFileAsync("nm", ["--defined-only", parts[0]!]);
|
|
expect(stdout).toMatch(/ t _?heavy$/m);
|
|
for (const part of parts.slice(1)) {
|
|
const listed = await execFileAsync("nm", ["--defined-only", part]);
|
|
const symbols = listed.stdout
|
|
.split("\n")
|
|
.filter(
|
|
(line) => line.trim() !== "" && !machoSectionLabel.test(line.trim().split(" ").at(-1)!),
|
|
);
|
|
expect(symbols).toEqual([]);
|
|
}
|
|
// The first output is the object that a single output produces.
|
|
const first = await readFile(parts[0]!);
|
|
const [single] = await emit(dir, source, { outputs: 1, optLevel: "2", name: "whole" });
|
|
expect(await readFile(single!)).toEqual(first);
|
|
// The empty parts name the source path, so they do not depend on the
|
|
// input's file name either.
|
|
const renamed = await emit(dir, source, {
|
|
outputs: 3,
|
|
optLevel: "2",
|
|
split: true,
|
|
name: "renamed",
|
|
});
|
|
for (let index = 0; index < parts.length; index++)
|
|
expect(await readFile(renamed[index]!)).toEqual(await readFile(parts[index]!));
|
|
});
|
|
|
|
test("an optimized module whose largest partition would hold most of its function text is optimized whole", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-dominated-"));
|
|
dirs.push(dir);
|
|
// One function of 400 instructions and four small ones.
|
|
const lines = [
|
|
"@sink = global i64 0",
|
|
"define void @large(i64 %x) noinline {",
|
|
" %v0 = add i64 %x, 1",
|
|
];
|
|
for (let step = 1; step < 400; step++)
|
|
lines.push(` %v${step} = mul i64 %v${step - 1}, ${step}`);
|
|
lines.push(" store volatile i64 %v399, ptr @sink", " ret void", "}");
|
|
for (let index = 0; index < 4; index++)
|
|
lines.push(
|
|
`define void @small${index}(i64 %x) noinline {`,
|
|
` %v = add i64 %x, ${index}`,
|
|
" store volatile i64 %v, ptr @sink",
|
|
" ret void",
|
|
"}",
|
|
);
|
|
lines.push("define i32 @main() {", " call void @large(i64 1)");
|
|
for (let index = 0; index < 4; index++) lines.push(` call void @small${index}(i64 1)`);
|
|
lines.push(" ret i32 0", "}");
|
|
const source = lines.join("\n") + "\n";
|
|
const cache = join(dir, "cache");
|
|
const contents = (paths: string[]) => Promise.all(paths.map((path) => readFile(path)));
|
|
const partitioned = await emit(dir, source, {
|
|
outputs: 4,
|
|
optLevel: "2",
|
|
cache,
|
|
name: "partitioned",
|
|
});
|
|
const whole = await emit(dir, source, {
|
|
outputs: 4,
|
|
optLevel: "2",
|
|
split: true,
|
|
name: "whole",
|
|
});
|
|
expect(await contents(partitioned)).toEqual(await contents(whole));
|
|
// No partition was simplified for a thin link.
|
|
expect(await cacheEntries(cache, ".bc")).toHaveProperty("size", 0);
|
|
expect(await exitCode(dir, partitioned, "partitioned")).toBe(0);
|
|
// Unoptimized partitions are compiled once each, so they stay divided.
|
|
await emit(dir, source, { outputs: 4, optLevel: "0", cache, name: "unoptimized" });
|
|
expect((await cacheEntries(cache, ".o")).size).toBeGreaterThan(1);
|
|
});
|
|
|
|
test("split code generation requires several optimized outputs and no cache", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-split-codegen-invalid-"));
|
|
dirs.push(dir);
|
|
for (const options of [
|
|
{ outputs: 1, optLevel: "2" },
|
|
{ outputs: 3, optLevel: "0" },
|
|
{ outputs: 3, optLevel: "2", cache: join(dir, "cache") },
|
|
] as const) {
|
|
const failure = emit(dir, program(), { ...options, split: true, name: "invalid" });
|
|
await expect(failure).rejects.toMatchObject({
|
|
stderr: expect.stringContaining("invalid_split"),
|
|
});
|
|
}
|
|
});
|
|
|
|
test("in a small module, a function that one function of another source file calls shares its partition", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-chains-"));
|
|
dirs.push(dir);
|
|
const count = 8;
|
|
const lines: string[] = ["; scriptc-group 0"];
|
|
for (let index = 0; index < count; index++)
|
|
lines.push(
|
|
`define internal i64 @helper${index}(i64 %value) noinline {`,
|
|
` %scaled = mul i64 %value, ${index + 2}`,
|
|
" ret i64 %scaled",
|
|
"}",
|
|
);
|
|
lines.push("; scriptc-group 1");
|
|
for (let index = 0; index < count; index++)
|
|
lines.push(
|
|
`define i64 @caller${index}(i64 %value) noinline {`,
|
|
` %helped = call i64 @helper${index}(i64 %value)`,
|
|
" ret i64 %helped",
|
|
"}",
|
|
);
|
|
lines.push("; scriptc-group", "define i32 @main() {", "entry:");
|
|
for (let index = 0; index < count; index++)
|
|
lines.push(
|
|
` %c${index} = call i64 @caller${index}(i64 1)`,
|
|
` %t${index} = add i64 ${index === 0 ? "0" : `%t${index - 1}`}, %c${index}`,
|
|
);
|
|
lines.push(` %code = trunc i64 %t${count - 1} to i32`, " ret i32 %code", "}");
|
|
const objects = await emit(dir, lines.join("\n") + "\n", {
|
|
outputs: 4,
|
|
optLevel: "0",
|
|
name: "chains",
|
|
});
|
|
let sum = 0;
|
|
for (let index = 0; index < count; index++) sum += index + 2;
|
|
expect(await exitCode(dir, objects, "chains")).toBe(sum);
|
|
const listings = await Promise.all(
|
|
objects.map(async (object) => (await execFileAsync("nm", ["--defined-only", object])).stdout),
|
|
);
|
|
const home = (name: string) =>
|
|
listings.findIndex((listing) =>
|
|
new RegExp(`^[0-9a-fA-F]+ [Tt] _?${name}$`, "m").test(listing),
|
|
);
|
|
for (let index = 0; index < count; index++) {
|
|
expect(home(`caller${index}`)).toBeGreaterThanOrEqual(0);
|
|
expect(home(`helper${index}`)).toBe(home(`caller${index}`));
|
|
}
|
|
});
|
|
|
|
test("functions under one group line share a partition", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-groups-"));
|
|
dirs.push(dir);
|
|
// Groups of four steps, each smaller than the mean partition; main ends
|
|
// the last group. Placement by group applies from 32 partitions.
|
|
const steps = 128;
|
|
const grouped = (edit: number, count = steps) =>
|
|
program(edit, "", count)
|
|
.split("\n")
|
|
.flatMap((line) => {
|
|
const step = /^define internal void @step(\d+)\(/.exec(line);
|
|
if (step !== null && Number(step[1]) % 4 === 0)
|
|
return [`; scriptc-group ${Number(step[1]) / 4}`, line];
|
|
return line.startsWith("define i32 @main(") ? ["; scriptc-group", line] : [line];
|
|
})
|
|
.join("\n");
|
|
const cache = join(dir, "cache");
|
|
const options = { outputs: 32, optLevel: "0", cache } as const;
|
|
const objects = await emit(dir, grouped(0), { ...options, name: "grouped" });
|
|
expect(await exitCode(dir, objects, "grouped")).toBe(expectedExit(0, steps));
|
|
const owner = new Map<number, number>();
|
|
for (const [index, object] of objects.entries()) {
|
|
const { stdout } = await execFileAsync("nm", [object]);
|
|
for (const line of stdout.split("\n")) {
|
|
const defined = /^[0-9a-fA-F]+ [A-Za-z] _?step(\d+)$/.exec(line.trim());
|
|
if (defined !== null) owner.set(Number(defined[1]), index);
|
|
}
|
|
}
|
|
expect(owner.size).toBe(steps);
|
|
// step0's only caller is main, so it goes with main.
|
|
for (let group = 0; group < steps / 4; group++) {
|
|
const members = [0, 1, 2, 3].map((offset) => group * 4 + offset).filter((step) => step > 0);
|
|
expect(new Set(members.map((step) => owner.get(step))).size).toBe(1);
|
|
}
|
|
|
|
// An edit to one function still changes one partition's source.
|
|
const sources = await cacheEntries(cache, ".o");
|
|
const edited = await emit(dir, grouped(7), { ...options, name: "edited" });
|
|
const after = await cacheEntries(cache, ".o");
|
|
expect([...after].filter((entry) => !sources.has(entry))).toHaveLength(1);
|
|
expect(await exitCode(dir, edited, "edited")).toBe(expectedExit(7, steps));
|
|
|
|
// With fewer partitions, functions keep their placement by name, and a
|
|
// group line only stops a single-caller chain: step0 joins main without
|
|
// groups, and keeps its own placement in group 0.
|
|
const few = { outputs: 4, optLevel: "0" } as const;
|
|
const placed = async (objects: string[]) => {
|
|
const where = new Map<string, number>();
|
|
for (const [index, object] of objects.entries()) {
|
|
const { stdout } = await execFileAsync("nm", [object]);
|
|
for (const line of stdout.split("\n")) {
|
|
const defined = /^[0-9a-fA-F]+ [A-Za-z] _?(step\d+|main)$/.exec(line.trim());
|
|
if (defined !== null) where.set(defined[1]!, index);
|
|
}
|
|
}
|
|
return where;
|
|
};
|
|
const fewGrouped = await placed(
|
|
await emit(dir, grouped(0, 24), { ...few, name: "few-grouped" }),
|
|
);
|
|
const fewPlain = await placed(await emit(dir, program(), { ...few, name: "few-plain" }));
|
|
expect(fewPlain.get("step0")).toBe(fewPlain.get("main"));
|
|
for (let step = 1; step < 24; step++)
|
|
expect(fewGrouped.get(`step${step}`)).toBe(fewPlain.get(`step${step}`));
|
|
expect(fewGrouped.get("main")).toBe(fewPlain.get("main"));
|
|
});
|
|
|
|
test.runIf(target?.llvmTriple.startsWith("x86_64") === true)(
|
|
"an x86-64-v3 copy shares its baseline copy's partition and takes its code unless it has wider vectors",
|
|
async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-cpu-copies-"));
|
|
dirs.push(dir);
|
|
// Copies as --cpu-dispatch emits them: `<name>.base` in #0 and
|
|
// `<name>.v3` in #1. Two callers each, so that every copy is placed by
|
|
// its own name. Each f calls h in its own tier; f and h have 128-bit
|
|
// vectors, g a 256-bit vector, and s only scalar code.
|
|
const pairs = 4;
|
|
const copies = (tier: "base" | "v3", group: number): string[] => [
|
|
`define internal void @h.${tier}(ptr %p) #${group} {`,
|
|
" %v = load <2 x double>, ptr %p",
|
|
" %w = fmul <2 x double> %v, splat (double 3.0)",
|
|
" store <2 x double> %w, ptr %p",
|
|
" ret void",
|
|
"}",
|
|
...Array.from({ length: pairs }, (_, index) => [
|
|
`define internal void @f${index}.${tier}(ptr %p) #${group} {`,
|
|
" %v = load <2 x double>, ptr %p",
|
|
` %w = fadd <2 x double> %v, splat (double ${index + 1}.0)`,
|
|
" store <2 x double> %w, ptr %p",
|
|
` call void @h.${tier}(ptr %p)`,
|
|
" ret void",
|
|
"}",
|
|
]).flat(),
|
|
`define internal void @g.${tier}(ptr %p) #${group} {`,
|
|
" %v = load <4 x double>, ptr %p",
|
|
" %w = fadd <4 x double> %v, %v",
|
|
" store <4 x double> %w, ptr %p",
|
|
" ret void",
|
|
"}",
|
|
`define internal double @s.${tier}(double %x) #${group} {`,
|
|
" %y = fadd double %x, 1.0",
|
|
" ret double %y",
|
|
"}",
|
|
];
|
|
const calls = (result: string): string[] =>
|
|
(["base", "v3"] as const).flatMap((tier) => [
|
|
...Array.from(
|
|
{ length: pairs },
|
|
(_, index) => ` call void @f${index}.${tier}(ptr @sink)`,
|
|
),
|
|
` call void @g.${tier}(ptr @sink)`,
|
|
` %${result}.${tier} = call double @s.${tier}(double 1.0)`,
|
|
]);
|
|
const source = [
|
|
"@sink = global [4 x double] zeroinitializer",
|
|
...copies("base", 0),
|
|
...copies("v3", 1),
|
|
"define void @other() {",
|
|
...calls("o"),
|
|
" ret void",
|
|
"}",
|
|
"define i32 @main() {",
|
|
...calls("m"),
|
|
" ret i32 0",
|
|
"}",
|
|
"attributes #0 = { noinline uwtable }",
|
|
'attributes #1 = { noinline uwtable "target-cpu"="x86-64-v3" "scriptc-cpu-copy" }',
|
|
].join("\n");
|
|
// Each function's object and disassembly, by its name without the
|
|
// thin link's `.llvm.<n>` suffix.
|
|
const disassemble = async (objects: string[]) => {
|
|
const functions = new Map<string, { object: number; code: string }>();
|
|
for (const [index, object] of objects.entries()) {
|
|
const { stdout } = await execFileAsync("objdump", ["-dr", "--no-show-raw-insn", object]);
|
|
for (const part of stdout.split(/\n(?=[0-9a-f]+ <)/)) {
|
|
const name = /^[0-9a-f]+ <([^>]+)>:/.exec(part)?.[1]?.replace(/\.llvm\.\d+$/, "");
|
|
if (name !== undefined) functions.set(name, { object: index, code: part });
|
|
}
|
|
}
|
|
return (name: string) => {
|
|
const found = functions.get(name);
|
|
expect(found, name).toBeDefined();
|
|
return found!;
|
|
};
|
|
};
|
|
const at = await disassemble(
|
|
await emit(dir, source + "\n", { outputs: 8, optLevel: "2", name: "copies" }),
|
|
);
|
|
for (const stem of [
|
|
"h",
|
|
"g",
|
|
"s",
|
|
...Array.from({ length: pairs }, (_, index) => `f${index}`),
|
|
])
|
|
expect(at(`${stem}.v3`).object, stem).toBe(at(`${stem}.base`).object);
|
|
// 128-bit vectors: the v3 copies run the baseline's code (SSE2, without
|
|
// VEX encodings), and f's still calls h's v3 copy.
|
|
for (let index = 0; index < pairs; index++) {
|
|
const code = at(`f${index}.v3`).code;
|
|
expect(code).toMatch(/\saddpd\s/);
|
|
expect(code).not.toMatch(/\svaddpd\s/);
|
|
expect(code).toMatch(/[\s<]h\.v3(?:\.llvm\.\d+)?[-+>]/);
|
|
}
|
|
expect(at("h.v3").code).toMatch(/\smulpd\s/);
|
|
// A 256-bit vector keeps the v3 code, and so does scalar code.
|
|
expect(at("g.v3").code).toContain("%ymm");
|
|
expect(at("s.v3").code).toMatch(/\svaddsd\s/);
|
|
expect(at("g.base").code).not.toContain("%ymm");
|
|
// An instrumented build keeps every copy's code: a baseline body would
|
|
// count the v3 copy's executions against the baseline copy.
|
|
const counted = await disassemble(
|
|
await emit(dir, source + "\n", {
|
|
outputs: 8,
|
|
optLevel: "2",
|
|
profileGenerate: join(dir, "copies-%p.profraw"),
|
|
name: "counted",
|
|
}),
|
|
);
|
|
for (let index = 0; index < pairs; index++)
|
|
expect(counted(`f${index}.v3`).code).toMatch(/\svaddpd\s/);
|
|
expect(counted("h.v3").code).toMatch(/\svmulpd\s/);
|
|
// A profile that gives f0's baseline copy fewer runs than its v3 copy
|
|
// (collected on an x86-64-v3 machine) keeps f0's v3 code: the baseline
|
|
// body is optimized as colder code. f1 has no profile counts.
|
|
const profiled = source
|
|
.replace(
|
|
"define internal void @f0.v3(ptr %p) #1 {",
|
|
"define internal void @f0.v3(ptr %p) #1 !prof !90 {",
|
|
)
|
|
.replace(
|
|
"define internal void @f0.base(ptr %p) #0 {",
|
|
"define internal void @f0.base(ptr %p) #0 !prof !91 {",
|
|
);
|
|
const runs = await disassemble(
|
|
await emit(
|
|
dir,
|
|
[
|
|
profiled,
|
|
'!90 = !{!"function_entry_count", i64 1000}',
|
|
'!91 = !{!"function_entry_count", i64 0}',
|
|
].join("\n") + "\n",
|
|
{ outputs: 8, optLevel: "2", name: "runs" },
|
|
),
|
|
);
|
|
expect(runs("f0.v3").code).toMatch(/\svaddpd\s/);
|
|
expect(runs("f1.v3").code).not.toMatch(/\svaddpd\s/);
|
|
},
|
|
);
|
|
|
|
test("internal definitions nothing reaches are left out of every partition", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-unreachable-"));
|
|
dirs.push(dir);
|
|
// An unreferenced internal function that uses live code and its own
|
|
// state, which only it references.
|
|
const unused = (name: string) =>
|
|
[
|
|
`@${name}State = internal global i64 7`,
|
|
`define internal void @${name}(i64 %value) {`,
|
|
` %state = load i64, ptr @${name}State`,
|
|
" call void @bump(i64 %state)",
|
|
" ret void",
|
|
"}",
|
|
].join("\n");
|
|
const cache = join(dir, "cache");
|
|
const options = { outputs: 4, optLevel: "0", cache } as const;
|
|
const objects = await emit(dir, program(0, unused("unusedOne")), { ...options, name: "first" });
|
|
expect(await exitCode(dir, objects, "first")).toBe(expectedExit());
|
|
for (const object of objects) {
|
|
const { stdout } = await execFileAsync("nm", [object]);
|
|
expect(stdout).not.toMatch(/unusedOne/);
|
|
}
|
|
// Adding another one changes no partition.
|
|
const before = await cacheEntries(cache, ".o");
|
|
const more = await emit(dir, program(0, `${unused("unusedOne")}\n${unused("unusedTwo")}`), {
|
|
...options,
|
|
name: "more",
|
|
});
|
|
expect(await cacheEntries(cache, ".o")).toEqual(before);
|
|
expect(await exitCode(dir, more, "more")).toBe(expectedExit());
|
|
});
|
|
|
|
test("a helper that one function uses goes to that function's partition", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-helpers-"));
|
|
dirs.push(dir);
|
|
// step7 calls @helper directly, and step9 calls @viaTable through an
|
|
// internal global only step9 reads.
|
|
const helpers = (withHelpers: boolean) =>
|
|
program()
|
|
.split("\n")
|
|
.flatMap((line) => {
|
|
if (!withHelpers) return [line];
|
|
if (line === "define internal void @step7(i64 %value) noinline {")
|
|
return [
|
|
"define internal void @helper(i64 %value) noinline {",
|
|
" call void @bump(i64 %value)",
|
|
" ret void",
|
|
"}",
|
|
"@table = internal constant ptr @viaTable",
|
|
"define internal void @viaTable(i64 %value) noinline {",
|
|
" call void @bump(i64 %value)",
|
|
" ret void",
|
|
"}",
|
|
line,
|
|
" call void @helper(i64 0)",
|
|
];
|
|
if (line === "define internal void @step9(i64 %value) noinline {")
|
|
return [line, " %fn = load ptr, ptr @table", " call void %fn(i64 0)"];
|
|
return [line];
|
|
})
|
|
.join("\n");
|
|
const cache = join(dir, "cache");
|
|
const options = { outputs: 4, optLevel: "0", cache } as const;
|
|
await emit(dir, helpers(false), { ...options, name: "before" });
|
|
const before = await cacheEntries(cache, ".o");
|
|
const objects = await emit(dir, helpers(true), { ...options, name: "after" });
|
|
expect(await exitCode(dir, objects, "after")).toBe(expectedExit());
|
|
const where = new Map<string, number>();
|
|
for (const [index, object] of objects.entries()) {
|
|
const { stdout } = await execFileAsync("nm", [object]);
|
|
for (const line of stdout.split("\n")) {
|
|
const defined = /^[0-9a-fA-F]+ [A-Za-z] _?(step7|step9|helper|viaTable)$/.exec(line.trim());
|
|
if (defined !== null) where.set(defined[1]!, index);
|
|
}
|
|
}
|
|
expect(where.get("helper")).toBe(where.get("step7"));
|
|
expect(where.get("viaTable")).toBe(where.get("step9"));
|
|
// Only the partitions of step7 and step9 change.
|
|
const changed = [...(await cacheEntries(cache, ".o"))].filter((entry) => !before.has(entry));
|
|
expect(changed).toHaveLength(new Set([where.get("step7"), where.get("step9")]).size);
|
|
});
|
|
|
|
test("a function that its referrer only takes the address of is placed by its own name", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-address-"));
|
|
dirs.push(dir);
|
|
// A body of `lines` additions, past the size under which an
|
|
// address-only function still follows its referrer.
|
|
const body = (name: string, lines: number) => [
|
|
`define internal void @${name}(i64 %value) noinline {`,
|
|
...Array.from(
|
|
{ length: lines },
|
|
(_, index) => ` %v${index} = add i64 ${index === 0 ? "%value" : `%v${index - 1}`}, 1`,
|
|
),
|
|
" call void @bump(i64 0)",
|
|
" ret void",
|
|
"}",
|
|
];
|
|
// step7 passes @closure and @trampoline to @spawn; step9 calls
|
|
// @matcher through a mutable global only it reads.
|
|
const source = program()
|
|
.split("\n")
|
|
.flatMap((line) => {
|
|
if (line === "define internal void @step7(i64 %value) noinline {")
|
|
return [
|
|
...body("closure", 40),
|
|
...body("trampoline", 1),
|
|
"define internal void @spawn(ptr %fn) noinline {",
|
|
" call void %fn(i64 1)",
|
|
" ret void",
|
|
"}",
|
|
line,
|
|
" call void @spawn(ptr @closure)",
|
|
" call void @spawn(ptr @trampoline)",
|
|
];
|
|
if (line === "define internal void @step9(i64 %value) noinline {")
|
|
return [
|
|
"@descriptor = internal global ptr @matcher",
|
|
...body("matcher", 40),
|
|
line,
|
|
" %fn = load ptr, ptr @descriptor",
|
|
" call void %fn(i64 0)",
|
|
];
|
|
return [line];
|
|
})
|
|
.join("\n");
|
|
const objects = await emit(dir, source, { outputs: 4, optLevel: "0", name: "address" });
|
|
expect(await exitCode(dir, objects, "address")).toBe(expectedExit());
|
|
const where = new Map<string, number>();
|
|
for (const [index, object] of objects.entries()) {
|
|
const { stdout } = await execFileAsync("nm", [object]);
|
|
for (const line of stdout.split("\n")) {
|
|
const defined = /^[0-9a-fA-F]+ [A-Za-z] _?(step7|step9|closure|trampoline|matcher)$/.exec(
|
|
line.trim(),
|
|
);
|
|
if (defined !== null) where.set(defined[1]!, index);
|
|
}
|
|
}
|
|
expect(where.size).toBe(5);
|
|
expect(where.get("trampoline")).toBe(where.get("step7"));
|
|
expect(where.get("closure")).not.toBe(where.get("step7"));
|
|
expect(where.get("matcher")).not.toBe(where.get("step9"));
|
|
});
|
|
|
|
test("functions are balanced over a few partitions, and a small edit keeps their places", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-balance-"));
|
|
dirs.push(dir);
|
|
// Eight exported functions of about 7 KiB each, which main calls; each
|
|
// returns its argument plus its number of additions. By the hash of
|
|
// their names alone, six of them would share one partition.
|
|
const count = 8;
|
|
const additions = (index: number, edit: number) => 280 + (index === 3 ? edit : 0);
|
|
const source = (edit: number) => {
|
|
const lines: string[] = [];
|
|
for (let index = 0; index < count; index++) {
|
|
lines.push(`define i64 @big${index * 4}(i64 %value) noinline {`);
|
|
for (let step = 0; step < additions(index, edit); step++)
|
|
lines.push(` %v${step} = add i64 ${step === 0 ? "%value" : `%v${step - 1}`}, 1`);
|
|
lines.push(` ret i64 %v${additions(index, edit) - 1}`, "}");
|
|
}
|
|
lines.push("define i32 @main() {");
|
|
for (let index = 0; index < count; index++)
|
|
lines.push(
|
|
` %r${index} = call i64 @big${index * 4}(i64 ${index})`,
|
|
` %t${index} = add i64 ${index === 0 ? "0" : `%t${index - 1}`}, %r${index}`,
|
|
);
|
|
lines.push(
|
|
` %low = urem i64 %t${count - 1}, 251`,
|
|
" %code = trunc i64 %low to i32",
|
|
" ret i32 %code",
|
|
"}",
|
|
);
|
|
return lines.join("\n") + "\n";
|
|
};
|
|
const expected = (edit: number) => {
|
|
let total = 0;
|
|
for (let index = 0; index < count; index++) total += index + additions(index, edit);
|
|
return total % 251;
|
|
};
|
|
const placed = async (objects: string[]) => {
|
|
const where = new Map<string, number>();
|
|
for (const [index, object] of objects.entries()) {
|
|
const { stdout } = await execFileAsync("nm", ["--defined-only", object]);
|
|
for (const line of stdout.split("\n")) {
|
|
const defined = /^[0-9a-fA-F]+ [A-Za-z] _?(big\d+)$/.exec(line.trim());
|
|
if (defined !== null) where.set(defined[1]!, index);
|
|
}
|
|
}
|
|
return where;
|
|
};
|
|
const cache = join(dir, "cache");
|
|
const options = { outputs: 4, optLevel: "0", cache } as const;
|
|
const objects = await emit(dir, source(0), { ...options, name: "balanced" });
|
|
expect(await exitCode(dir, objects, "balanced")).toBe(expected(0));
|
|
const where = await placed(objects);
|
|
expect(where.size).toBe(count);
|
|
for (let partition = 0; partition < 4; partition++)
|
|
expect([...where.values()].filter((home) => home === partition)).toHaveLength(2);
|
|
|
|
// Growing one function by a few hundred bytes recompiles only its
|
|
// partition and moves no function.
|
|
const before = await cacheEntries(cache, ".o");
|
|
const edited = await emit(dir, source(8), { ...options, name: "edited" });
|
|
expect(await exitCode(dir, edited, "edited")).toBe(expected(8));
|
|
expect(await placed(edited)).toEqual(where);
|
|
const changed = [...(await cacheEntries(cache, ".o"))].filter((entry) => !before.has(entry));
|
|
expect(changed).toHaveLength(1);
|
|
});
|
|
|
|
test("the entry line gives the top-level code and its chain a partition of their own", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-entry-"));
|
|
dirs.push(dir);
|
|
// Eight exported functions of about 7 KiB, and the marked top-level
|
|
// function @top, which only main calls, so it follows main.
|
|
const count = 8;
|
|
const body = (header: string, lines: number, ret: string) => [
|
|
header,
|
|
...Array.from(
|
|
{ length: lines },
|
|
(_, step) => ` %v${step} = add i64 ${step === 0 ? "%value" : `%v${step - 1}`}, 1`,
|
|
),
|
|
ret.replace("LAST", `%v${lines - 1}`),
|
|
"}",
|
|
];
|
|
const source = (marked: boolean) => {
|
|
const lines: string[] = [];
|
|
for (let index = 0; index < count; index++)
|
|
lines.push(
|
|
...body(`define i64 @big${index * 4}(i64 %value) noinline {`, 280, " ret i64 LAST"),
|
|
);
|
|
if (marked) lines.push("; scriptc-entry");
|
|
lines.push(
|
|
...body("define internal i64 @top(i64 %value) noinline {", 280, " ret i64 LAST"),
|
|
"define i32 @main() {",
|
|
" %t = call i64 @top(i64 0)",
|
|
);
|
|
for (let index = 0; index < count; index++)
|
|
lines.push(
|
|
` %r${index} = call i64 @big${index * 4}(i64 ${index})`,
|
|
` %s${index} = add i64 ${index === 0 ? "%t" : `%s${index - 1}`}, %r${index}`,
|
|
);
|
|
lines.push(
|
|
` %low = urem i64 %s${count - 1}, 251`,
|
|
" %code = trunc i64 %low to i32",
|
|
" ret i32 %code",
|
|
"}",
|
|
);
|
|
return lines.join("\n") + "\n";
|
|
};
|
|
let total = 280;
|
|
for (let index = 0; index < count; index++) total += index + 280;
|
|
const placed = async (objects: string[]) => {
|
|
const where = new Map<string, number>();
|
|
for (const [index, object] of objects.entries()) {
|
|
const { stdout } = await execFileAsync("nm", ["--defined-only", object]);
|
|
for (const line of stdout.split("\n")) {
|
|
const defined = /^[0-9a-fA-F]+ [A-Za-z] _?(big\d+|top|main)$/.exec(line.trim());
|
|
if (defined !== null) where.set(defined[1]!, index);
|
|
}
|
|
}
|
|
return where;
|
|
};
|
|
for (const marked of [false, true]) {
|
|
const objects = await emit(dir, source(marked), {
|
|
outputs: 4,
|
|
optLevel: "0",
|
|
name: marked ? "marked" : "plain",
|
|
});
|
|
expect(await exitCode(dir, objects, marked ? "marked" : "plain")).toBe(total % 251);
|
|
const where = await placed(objects);
|
|
expect(where.get("top")).toBe(where.get("main"));
|
|
const beside = [...where].filter(
|
|
([name, home]) => name.startsWith("big") && home === where.get("top"),
|
|
);
|
|
// Without the line, balancing places other functions beside main's
|
|
// chain; with it, none.
|
|
if (marked) expect(beside).toEqual([]);
|
|
else expect(beside.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test("a small helper that an edit adds moves no other function", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-helper-roots-"));
|
|
dirs.push(dir);
|
|
// Eight exported functions of these instruction counts and three small
|
|
// exported helpers, each a root of its own. The edit adds a fourth
|
|
// helper, which big1 calls. Counted in the loads, the added helper would
|
|
// raise the cap and move other functions.
|
|
const sizes = [426, 201, 333, 376, 152, 339, 327, 218];
|
|
const helper = (name: string) => [
|
|
`define i64 @${name}(i64 %value) noinline {`,
|
|
" %r = add i64 %value, 1",
|
|
" ret i64 %r",
|
|
"}",
|
|
];
|
|
const source = (edited: boolean) => {
|
|
const lines: string[] = [];
|
|
sizes.forEach((size, index) => {
|
|
lines.push(`define i64 @big${index}(i64 %value) noinline {`);
|
|
for (let step = 0; step < size; step++)
|
|
lines.push(` %v${step} = add i64 ${step === 0 ? "%value" : `%v${step - 1}`}, 1`);
|
|
if (edited && index === 1)
|
|
lines.push(` %h = call i64 @added(i64 %v${size - 1})`, " ret i64 %h");
|
|
else lines.push(` ret i64 %v${size - 1}`);
|
|
lines.push("}");
|
|
});
|
|
for (let index = 0; index < 3; index++) lines.push(...helper(`helper${index}`));
|
|
if (edited) lines.push(...helper("added"));
|
|
lines.push("define i32 @main() {");
|
|
sizes.forEach((_, index) =>
|
|
lines.push(
|
|
` %r${index} = call i64 @big${index}(i64 ${index})`,
|
|
` %t${index} = add i64 ${index === 0 ? "0" : `%t${index - 1}`}, %r${index}`,
|
|
),
|
|
);
|
|
for (let index = 0; index < 3; index++) {
|
|
const operand = index === 0 ? `%t${sizes.length - 1}` : `%h${index - 1}`;
|
|
lines.push(` %h${index} = call i64 @helper${index}(i64 ${operand})`);
|
|
}
|
|
lines.push(
|
|
" %low = urem i64 %h2, 251",
|
|
" %code = trunc i64 %low to i32",
|
|
" ret i32 %code",
|
|
"}",
|
|
);
|
|
return lines.join("\n") + "\n";
|
|
};
|
|
const expected = (edited: boolean) => {
|
|
let total = 3 + (edited ? 1 : 0);
|
|
sizes.forEach((size, index) => (total += index + size));
|
|
return total % 251;
|
|
};
|
|
const placed = async (objects: string[]) => {
|
|
const where = new Map<string, number>();
|
|
for (const [index, object] of objects.entries()) {
|
|
const { stdout } = await execFileAsync("nm", ["--defined-only", object]);
|
|
for (const line of stdout.split("\n")) {
|
|
const defined = /^[0-9a-fA-F]+ [A-Za-z] _?(big\d+|helper\d+|main)$/.exec(line.trim());
|
|
if (defined !== null) where.set(defined[1]!, index);
|
|
}
|
|
}
|
|
return where;
|
|
};
|
|
const cache = join(dir, "cache");
|
|
const options = { outputs: 4, optLevel: "0", cache } as const;
|
|
const objects = await emit(dir, source(false), { ...options, name: "before" });
|
|
expect(await exitCode(dir, objects, "before")).toBe(expected(false));
|
|
const before = await cacheEntries(cache, ".o");
|
|
const edited = await emit(dir, source(true), { ...options, name: "after" });
|
|
expect(await exitCode(dir, edited, "after")).toBe(expected(true));
|
|
expect(await placed(edited)).toEqual(await placed(objects));
|
|
// Only big1's partition and the new helper's change.
|
|
const changed = [...(await cacheEntries(cache, ".o"))].filter((entry) => !before.has(entry));
|
|
expect(changed.length).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
test("small helpers spread when one partition is left below the cap", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-small-spread-"));
|
|
dirs.push(dir);
|
|
// Three exported functions of about 16 KiB, each over the cap of four
|
|
// partitions, whose names hash to partitions 3, 0 and 1, and forty
|
|
// exported helpers of about 2.5 KiB. Partition 2 is the only one left
|
|
// below the cap; counted only against the larger roots' loads, every
|
|
// helper would go there.
|
|
const chain = (header: string, lines: number) => [
|
|
header,
|
|
...Array.from(
|
|
{ length: lines },
|
|
(_, step) => ` %v${step} = add i64 ${step === 0 ? "%value" : `%v${step - 1}`}, 1`,
|
|
),
|
|
` ret i64 %v${lines - 1}`,
|
|
"}",
|
|
];
|
|
const helpers = 40;
|
|
const lines: string[] = [];
|
|
for (let index = 0; index < 3; index++)
|
|
lines.push(...chain(`define i64 @big${index}(i64 %value) noinline {`, 640));
|
|
for (let index = 0; index < helpers; index++)
|
|
lines.push(...chain(`define i64 @helper${index}(i64 %value) noinline {`, 100));
|
|
lines.push("define i32 @main() {");
|
|
const calls = [
|
|
...[0, 1, 2].map((i) => `big${i}`),
|
|
...Array.from({ length: helpers }, (_, i) => `helper${i}`),
|
|
];
|
|
calls.forEach((name, index) =>
|
|
lines.push(
|
|
` %r${index} = call i64 @${name}(i64 ${index})`,
|
|
` %s${index} = add i64 ${index === 0 ? "0" : `%s${index - 1}`}, %r${index}`,
|
|
),
|
|
);
|
|
lines.push(
|
|
` %low = urem i64 %s${calls.length - 1}, 251`,
|
|
" %code = trunc i64 %low to i32",
|
|
" ret i32 %code",
|
|
"}",
|
|
);
|
|
let total = 3 * 640 + helpers * 100;
|
|
calls.forEach((_, index) => (total += index));
|
|
const objects = await emit(dir, lines.join("\n") + "\n", {
|
|
outputs: 4,
|
|
optLevel: "0",
|
|
name: "spread",
|
|
});
|
|
expect(await exitCode(dir, objects, "spread")).toBe(total % 251);
|
|
const homes = new Map<number, number>();
|
|
for (const [index, object] of objects.entries()) {
|
|
const { stdout } = await execFileAsync("nm", ["--defined-only", object]);
|
|
for (const line of stdout.split("\n"))
|
|
if (/^[0-9a-fA-F]+ [A-Za-z] _?helper\d+$/.test(line.trim()))
|
|
homes.set(index, (homes.get(index) ?? 0) + 1);
|
|
}
|
|
expect([...homes.values()].reduce((sum, count) => sum + count, 0)).toBe(helpers);
|
|
expect(Math.max(...homes.values())).toBeLessThan(helpers);
|
|
expect(homes.size).toBeGreaterThan(1);
|
|
});
|
|
|
|
test("a table written one element per line keeps the text partitioner and its reuse", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-table-"));
|
|
dirs.push(dir);
|
|
// The emitter writes interface dispatch tables one element per line.
|
|
const table = [
|
|
"@table = internal constant [2 x ptr] [",
|
|
" ptr @step3,",
|
|
" ptr @step9",
|
|
"]",
|
|
].join("\n");
|
|
const source = (edit: number) =>
|
|
program(edit, table).replace(
|
|
" %total = load i64, ptr @total",
|
|
" %entry = load ptr, ptr @table\n call void %entry(i64 0)\n %total = load i64, ptr @total",
|
|
);
|
|
const cache = join(dir, "cache");
|
|
const first = await emit(dir, source(0), { outputs: 4, optLevel: "2", cache, name: "first" });
|
|
expect(await exitCode(dir, first, "first")).toBe(expectedExit());
|
|
const sources = await cacheEntries(cache, ".bc");
|
|
const edited = await emit(dir, source(7), { outputs: 4, optLevel: "2", cache, name: "edited" });
|
|
const after = await cacheEntries(cache, ".bc");
|
|
expect([...after].filter((entry) => !sources.has(entry))).toHaveLength(1);
|
|
expect(await exitCode(dir, edited, "edited")).toBe(expectedExit(7));
|
|
});
|
|
|
|
test("inputs outside the text partitioner's grammar are still partitioned", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-fallback-"));
|
|
dirs.push(dir);
|
|
const source = program(0, '@"quoted name" = internal global i64 1').replace(
|
|
" %old = load i64, ptr @total",
|
|
' %old = load i64, ptr @total\n %unused = load i64, ptr @"quoted name"',
|
|
);
|
|
for (const optLevel of ["2", "0"] as const) {
|
|
const objects = await emit(dir, source, {
|
|
outputs: 3,
|
|
optLevel,
|
|
name: `fallback${optLevel}`,
|
|
});
|
|
expect(await exitCode(dir, objects, `fallback${optLevel}`)).toBe(expectedExit());
|
|
}
|
|
});
|
|
|
|
test("debug metadata follows the functions that reference it", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "scriptc-partitions-debug-"));
|
|
dirs.push(dir);
|
|
const debug = program()
|
|
.split("\n")
|
|
.map((line) => {
|
|
const definition =
|
|
/^define internal void @step(\d+)(?:\.i32)?\(i64 %value\) noinline \{$/.exec(line);
|
|
if (definition !== null)
|
|
return line.replace(" {", ` !dbg !${10 + Number(definition[1])} {`);
|
|
const call = /^ {2}call void @step(\d+)\(i64 %value\)$/.exec(line);
|
|
return call === null ? line : `${line}, !dbg !${40 + Number(call[1]) - 1}`;
|
|
});
|
|
const source = [
|
|
...debug,
|
|
"!llvm.dbg.cu = !{!0}",
|
|
"!llvm.module.flags = !{!2, !3}",
|
|
'!0 = distinct !DICompileUnit(language: DW_LANG_C, file: !1, producer: "test", isOptimized: false, runtimeVersion: 0, emissionKind: FullDebug)',
|
|
'!1 = !DIFile(filename: "program.ts", directory: "/src")',
|
|
'!2 = !{i32 2, !"Debug Info Version", i32 3}',
|
|
'!3 = !{i32 7, !"Dwarf Version", i32 5}',
|
|
"!4 = !DISubroutineType(types: !{})",
|
|
...Array.from(
|
|
{ length: 24 },
|
|
(_, index) =>
|
|
`!${10 + index} = distinct !DISubprogram(name: "step${index}", scope: !1, file: !1, line: ${index + 1}, type: !4, spFlags: DISPFlagDefinition, unit: !0)`,
|
|
),
|
|
...Array.from(
|
|
{ length: 23 },
|
|
(_, index) =>
|
|
`!${40 + index} = !DILocation(line: ${index + 1}, column: 3, scope: !${10 + index})`,
|
|
),
|
|
"",
|
|
].join("\n");
|
|
const cache = join(dir, "cache");
|
|
const objects = await emit(dir, source, { outputs: 4, optLevel: "0", cache, name: "debug" });
|
|
const bytes = await Promise.all(objects.map((path) => readFile(path)));
|
|
// ELF and COFF name the section .debug_info; Mach-O names it __debug_info.
|
|
expect(bytes.filter((object) => object.includes("debug_info")).length).toBeGreaterThan(1);
|
|
expect(await exitCode(dir, objects, "debug")).toBe(expectedExit());
|
|
|
|
// Partitions number their metadata locally, so a module whose node
|
|
// numbers all shift still reuses every partition.
|
|
const before = await cacheEntries(cache, ".o");
|
|
const shifted = source.replace(/!(\d+)/g, (_, number: string) => `!${Number(number) + 100}`);
|
|
const renumbered = await emit(dir, shifted, {
|
|
outputs: 4,
|
|
optLevel: "0",
|
|
cache,
|
|
name: "renumbered",
|
|
});
|
|
expect(await cacheEntries(cache, ".o")).toEqual(before);
|
|
expect(await Promise.all(renumbered.map((path) => readFile(path)))).toEqual(bytes);
|
|
});
|
|
});
|