Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1431,6 +1431,12 @@ jobs:
- uses: ./.github/actions/setup-llvm22
if: steps.scope.outputs.rust_work == 'true'

- name: Install pinned Node for native regression oracles
if: steps.scope.outputs.rust_work == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .node-version

- name: Install sccache
if: steps.scope.outputs.rust_work == 'true'
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
Expand Down Expand Up @@ -1480,10 +1486,10 @@ jobs:
# RFC-2945 abort guards that a JS throw trips — the opposite of the
# shipped semantics. See the longer note in `cargo-test`.
if printf '%s\n' "$SUITES" | grep -qE '^(perry|perry-stdlib) '; then
if printf '%s\n' "$SUITES" | grep -qE '^perry (bun_text_modules|import_meta_require_value) '; then
if printf '%s\n' "$SUITES" | grep -qE '^perry (bun_text_modules|import_meta_require_value|minsize_inline_policy) '; then
# Prepare all require providers in one graph, outside the fixture's
# timeout. A second perry-dev graph inside cargo test exceeded its
# ten-minute bound on fresh runners (#9989/#9990).
# ten-minute bound on fresh runners (#9989/#9990/#10076).
cargo build --release -p perry -p perry-runtime -p perry-stdlib \
-p perry-runtime-static -p perry-stdlib-static \
-p perry-ext-events -p perry-ext-http -p perry-ext-net \
Expand Down
12 changes: 12 additions & 0 deletions changelog.d/10076-minsize-inline-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Let LLVM choose ordinary shadow-root inlining under `-Oz` instead of forcing
every small-body candidate into its callers. Profitable inlines remain allowed;
normal optimization levels, `-Os`, native-root hints, explicit attributes, and
the separately admitted pre-statepoint inline path keep their existing behavior.

The shared definition-header renderer carries the policy into textual IR and
native LLVM construction. Independent header tests and a pinned-Node native
fixture cover both transports, both root modes, Os/Oz controls, exception and
object identity, callbacks, and actual moving collections. Scoped native CI
installs the repository's exact Node oracle and prepares the complete coherent
provider graph before entering the bounded fixture. Workflow-selection tests
prevent a second runtime build from consuming the native-test deadline.
84 changes: 81 additions & 3 deletions crates/perry-codegen/src/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,13 @@ impl LlFunction {
/// internal/private definitions so cross-unit calls bind (mirror of
/// `render_fn_external`).
pub fn define_header(&self, force_external: bool) -> String {
self.define_header_with_size_attrs(
force_external,
crate::linker::application_size_function_attrs(),
)
}

fn define_header_with_size_attrs(&self, force_external: bool, size_attrs: &str) -> String {
let param_str = self
.params
.iter()
Expand Down Expand Up @@ -895,16 +902,23 @@ impl LlFunction {
// small helpers eligible; let LLVM decide for larger generated bodies.
// The separate pre-statepoint admission already has its own budget.
let force_inline = self.force_inline && self.estimated_ir_bytes() <= 8 * 1024;
let attrs = if self.pre_statepoint_inline || (force_inline && !rs4gc) {
// Even a bounded small body can grow many cold callers when forced
// into them. Under -Oz, let LLVM's minsize cost model choose ordinary
// shadow-root inlines. This is not `noinline`: profitable inlines are
// still allowed. Keep explicitly admitted pre-statepoint inlines and
// native-root hints unchanged; their early-pass contract is separate.
let minsize = size_attrs
.split_ascii_whitespace()
.any(|attr| attr == "minsize");
let attrs = if self.pre_statepoint_inline || (force_inline && !rs4gc && !minsize) {
" alwaysinline"
} else if self.no_inline {
" noinline"
} else if self.inline_hint || force_inline {
} else if self.inline_hint || (force_inline && rs4gc) {
" inlinehint"
} else {
""
};
let size_attrs = crate::linker::application_size_function_attrs();
// The native-stack walker recovers frames through the x29 chain, so
// every generated function must link one; without the attribute,
// textual-IR input gets no frame-pointer default from the clang
Expand Down Expand Up @@ -1306,6 +1320,70 @@ mod define_header_tests {
}
}

#[test]
fn minsize_defers_ordinary_shadow_inlining_to_llvm_for_both_renderers() {
let _shadow = crate::codegen::helpers::NativeRootsPin::shadow();
let mut function = probe();
function.force_inline = true;
function.linkage = "internal".to_string();
assert!(function.estimated_ir_bytes() <= 8 * 1024);

for external in [false, true] {
for size_attrs in ["", " optsize", " optsize minsize"] {
let header = function.define_header_with_size_attrs(external, size_attrs);
assert_eq!(header.contains("internal"), !external, "{header}");
assert_eq!(
header.contains(" alwaysinline"),
!size_attrs.contains("minsize"),
"only minsize should defer forced shadow inlining: {header}"
);
assert!(!header.contains(" inlinehint"), "{header}");
assert!(!header.contains(" noinline"), "{header}");
assert!(header.contains(size_attrs), "size attributes must survive");
}
}
}

#[test]
fn minsize_preserves_native_root_hints_and_pre_statepoint_admission() {
use crate::codegen::helpers::NativeRootsPin;
for native in [false, true] {
let _pin = if native {
NativeRootsPin::native()
} else {
NativeRootsPin::shadow()
};
for external in [false, true] {
let mut function = probe();
function.force_inline = true;
let header = function.define_header_with_size_attrs(external, " optsize minsize");
assert_eq!(header.contains(" inlinehint"), native, "{header}");
assert!(!header.contains(" alwaysinline"), "{header}");

function.pre_statepoint_inline = true;
let admitted = function.define_header_with_size_attrs(external, " optsize minsize");
assert!(admitted.contains(" alwaysinline"), "{admitted}");
assert!(!admitted.contains(" inlinehint"), "{admitted}");
}
}
}

#[test]
fn minsize_preserves_explicit_noinline_and_hot_hint_requests() {
let _shadow = crate::codegen::helpers::NativeRootsPin::shadow();
let mut function = probe();
function.force_inline = true;
function.no_inline = true;
assert!(function
.define_header_with_size_attrs(false, " optsize minsize")
.contains(" noinline"));
function.no_inline = false;
function.inline_hint = true;
assert!(function
.define_header_with_size_attrs(false, " optsize minsize")
.contains(" inlinehint"));
}

/// The property that was actually lost, asserted directly (#7982) — in
/// **both** lowerings, neither of them dark.
///
Expand Down
21 changes: 21 additions & 0 deletions crates/perry/tests/minsize_inline_policy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//! Independent minsize policy coverage through both actual LLVM transports.
use std::{path::Path, process::Command};

#[test]
fn standalone_minsize_inline_policy_regression() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let output = Command::new("node")
.arg(root.join("scripts/test-minsize-inline-policy.mjs"))
.env("PERRY_BIN", env!("CARGO_BIN_EXE_perry"))
.env("PERRY_WORKSPACE_ROOT", &root)
.env("PERRY_TEST_BUILD_RUNTIME", "1")
.current_dir(&root)
.output()
.expect("run bounded native minsize regression");
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
131 changes: 131 additions & 0 deletions scripts/test-minsize-inline-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Standalone native/IR gate: no application-specific source or patched IR.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { prepareRequireRuntime } from './test-require-runtime.mjs';

// Text emission spells attributes inline; LLVM's native printer interns groups.
function functionAttributes(ir, header) {
const groups = new Map();
for (const [, id, body] of ir.matchAll(/^attributes #(\d+) = \{(.*)\}$/gm)) {
assert(!groups.has(id), `duplicate LLVM attribute group #${id}`);
groups.set(id, body);
}
const unquoted = text => text.replace(/"(?:[^"\\]|\\.)*"/g, '');
const expanded = unquoted(header).replace(/#(\d+)\b/g, (_, id) => {
assert(groups.has(id), `missing LLVM attribute group #${id}`);
return unquoted(groups.get(id));
});
return new Set(expanded.split(/\s+/));
}

function testAttributeParser() {
const inline = functionAttributes('', 'define double @f(double %x) minsize optsize {');
assert(inline.has('minsize') && inline.has('optsize'));
assert(!inline.has('alwaysinline'));
const grouped = functionAttributes('attributes #9 = { alwaysinline optsize }',
'define double @f(double %x) #9 {');
assert(grouped.has('alwaysinline') && grouped.has('optsize'));
assert(!grouped.has('minsize'));
const mixed = functionAttributes('attributes #2 = { minsize "label"="alwaysinline" }',
'define double @f(double %x) inlinehint #2 {');
assert(mixed.has('minsize') && mixed.has('inlinehint') && !mixed.has('alwaysinline'));
assert.throws(() => functionAttributes('', 'define void @f() #9 {'), /missing.*#9/);
assert.throws(() => functionAttributes('attributes #9 = { broken', 'define void @f() #9 {'), /missing.*#9/);
assert.throws(() => functionAttributes('attributes #9 = { minsize }\nattributes #9 = { optsize }',
'define void @f() #9 {'), /duplicate.*#9/);
}

const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
assert.equal(process.versions.node, fs.readFileSync(path.join(root, '.node-version'), 'utf8').trim().replace(/^v/, ''));
testAttributeParser();
if (process.argv.includes('--self-test')) {
console.log('PASS minsize-inline attribute parser: inline/grouped/mixed and negative controls');
process.exit(0);
}
prepareRequireRuntime(root);
const compiler = process.env.PERRY_BIN ?? path.join(root, 'target/perry-dev/perry');
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'perry-minsize-inline-'));
const source = path.join(work, 'fixture.ts');
fs.copyFileSync(path.join(root, 'test-files/test_gap_minsize_inline_policy.ts'), source);
const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const compilerHash = hash(compiler);
const baseEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('PERRY_')));
if (process.env.PERRY_RUNTIME_DIR) baseEnv.PERRY_RUNTIME_DIR = process.env.PERRY_RUNTIME_DIR;
// Optional local toolchains may include wasm-host in their coherent graph.
const wasmArgs = process.env.PERRY_TEST_WASM === '1' ? ['--enable-wasm-runtime'] : [];
const rows = [];
let passed = false;
function run(label, executable, args, timeout, extraEnv = {}) {
const result = spawnSync(executable, args, { cwd: work, env: { ...baseEnv, ...extraEnv },
encoding: 'utf8', timeout, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024 });
fs.writeFileSync(path.join(work, `${label}.stdout`), result.stdout ?? '');
fs.writeFileSync(path.join(work, `${label}.stderr`), result.stderr ?? '');
assert(!result.error && result.status === 0,
`${label}: ${result.error ?? result.status}; signal=${result.signal}\n${result.stderr?.slice(-2000)}`);
return result;
}
try {
const oracle = run('node', process.execPath, [source], 20000).stdout;
assert(oracle.includes('inline-policy-native-complete'), 'Node must finish');
for (const roots of ['shadow', 'native']) {
for (const transport of ['1', 'native']) {
for (const opt of ['s', 'z']) {
const label = `${roots}-${transport}-O${opt}`;
const output = path.join(work, `${label}${process.platform === 'win32' ? '.exe' : ''}`);
const irDir = path.join(work, `${label}-ir`);
fs.mkdirSync(irDir);
const compileEnv = { PERRY_LL_OPT_LEVEL: opt, PERRY_LLVM_INPROCESS: transport,
PERRY_FULL_OUTLINE_IC: '1', PERRY_KEEP_SYMBOLS: '1', PERRY_SAVE_LL: irDir,
PERRY_MODULE_JOBS: '1', PERRY_CODEGEN_UNIT_JOBS: '1',
...(roots === 'shadow' ? { PERRY_RS4GC: '0', PERRY_SHADOW_STACK: '1', PERRY_INLINE_SHADOW_SLOT: '0' } : {}),
};
run(`compile-${label}`, compiler, ['compile', source, '-o', output,
'--cache-dir', path.join(work, `cache-${label}`), '--no-auto-optimize', '--no-color', ...wasmArgs],
120000, compileEnv);
const files = fs.readdirSync(irDir).filter(name => name.endsWith('.ll'));
assert.equal(files.length, 1, 'one complete fixture module must be retained');
const ir = fs.readFileSync(path.join(irDir, files[0]), 'utf8');
const headers = ir.split('\n').filter(line =>
/^define .*@perry_fn_[^(]*__(?:identityLeaf|throwLeaf)\(/.test(line));
assert.equal(headers.length, 2, 'both ordinary forced-inline witnesses must be present');
for (const header of headers) {
const attributes = functionAttributes(ir, header);
assert.equal(attributes.has('minsize'), opt === 'z', header);
assert.equal(attributes.has('alwaysinline'), roots === 'shadow' && opt === 's', header);
assert.equal(attributes.has('inlinehint'), roots === 'native', header);
}
const strategy = ir.includes('gc "statepoint-example"');
assert.equal(strategy, roots === 'native', 'requested root mode must actually be emitted');
// Native execution must not load the source to satisfy the oracle.
fs.renameSync(source, source + '.hidden');
try {
for (const moving of [false, true]) {
const runLabel = `${label}-${moving ? 'moving' : 'ordinary'}`;
const actual = run(runLabel, output, [], 20000, moving ? {
PERRY_GC_SCHEDULE_SEED: '7', PERRY_GC_SCHEDULE_RATE: '0.05', PERRY_GC_SCHEDULE_ALLOC_KB: '0',
PERRY_GC_DIAG: '1', PERRY_GC_VERIFY_EVACUATION: '1', PERRY_GC_PROTECT_FROMSPACE: '1',
} : {});
assert.equal(actual.stdout, oracle, `${runLabel}: exact Node mismatch`);
const counters = actual.stderr.match(/\[gc-schedule\] done:.*copying_minors=(\d+) moved_objects=(\d+) loop_polls=(\d+)/);
if (moving) assert(counters && counters.slice(1).every(n => Number(n) > 0), 'collection must actually move objects');
const row = { roots, transport, opt, moving, bytes: fs.statSync(output).size,
sha256: hash(output), copyingMinors: Number(counters?.[1] ?? 0),
movedObjects: Number(counters?.[2] ?? 0), loopPolls: Number(counters?.[3] ?? 0) };
rows.push(row); console.log('PASS minsize-inline ' + JSON.stringify(row));
}
} finally { fs.renameSync(source + '.hidden', source); }
}
}
}
assert.equal(rows.length, 16);
assert.equal(hash(compiler), compilerHash, 'compiler must not change during validation');
passed = true;
} finally {
fs.writeFileSync(path.join(work, 'result.json'), JSON.stringify({ passed, compiler, compilerHash, rows }, null, 2) + '\n');
console.log(`Retained minsize-inline evidence: ${work}`);
}
53 changes: 53 additions & 0 deletions scripts/test-require-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,56 @@ test('unwind-enabled debug profiles remain rejected', t => {
assert.notEqual(result.status, 0);
assert.match(result.stderr, /panic=abort runtime profile/);
});

// Exercise the checked-in workflow branch with Cargo stubbed: this proves the
// setup protocol, not that a release archive was built or linked successfully.
function ciSetup(suites, cargoExit = 0) {
const workflow = fs.readFileSync(path.join(root, '.github/workflows/test.yml'), 'utf8');
const scoped = workflow.match(/^ {6}- name: Run scoped integration suites\n[\s\S]*?^ {10}status=0$/m);
assert(scoped, 'scoped integration setup must be present');
const start = scoped[0].indexOf(' if printf');
assert(start >= 0, 'runtime selection must be present');
const setup = scoped[0].slice(start);
const script = `set -eu
cargo() { printf 'cargo:%s\\n' "$*"; return ${cargoExit}; }
${setup}
printf 'prepared:%s\\nruntime:%s\\n' "\${PERRY_TEST_RUNTIME_PREBUILT-unset}" "\${PERRY_RUNTIME_DIR-unset}"
`;
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('PERRY_')));
return spawnSync('bash', ['-c', script], { cwd: root, env: { ...env, SUITES: suites },
encoding: 'utf8', timeout: 10_000 });
}

test('scoped CI prepares coherent providers for each standalone native consumer', () => {
for (const suite of ['bun_text_modules', 'import_meta_require_value', 'minsize_inline_policy']) {
const result = ciSetup(`perry-codegen typed_feedback 300\nperry ${suite} 1500`);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /prepared:1\n/, suite);
assert(result.stdout.includes(`runtime:${root}target/release\n`), result.stdout);
const calls = result.stdout.split('\n').filter(line => line.startsWith('cargo:'));
assert.equal(calls.length, 1, suite);
assert.match(calls[0], /^cargo:build --release /);
for (const name of ['perry', 'perry-runtime', 'perry-stdlib', 'perry-runtime-static',
'perry-stdlib-static', 'perry-ext-events', 'perry-ext-http', 'perry-ext-net',
'perry-ext-typescript', 'perry-ext-ws', 'perry-ext-zlib']) {
assert(calls[0].includes(`-p ${name} `), name);
}
assert.match(calls[0], /--features perry-stdlib\/external-net-pump$/);
}
});

test('scoped CI does not mark unrelated or partial runtime setup prepared', () => {
for (const suites of ['', 'perry-codegen minsize_inline_policy 300',
'perry minsize_inline_policy_extra 1500', 'perry unrelated 1500']) {
const result = ciSetup(suites);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /prepared:unset\n/, suites);
assert.doesNotMatch(result.stdout, /-p perry-ext-/);
}
});

test('scoped CI propagates provider-build failure before declaring prepared', () => {
const result = ciSetup('perry minsize_inline_policy 1500', 73);
assert.equal(result.status, 73, result.stderr);
assert.doesNotMatch(result.stdout, /^prepared:/m);
});
Loading
Loading