Skip to content
Open
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
38 changes: 31 additions & 7 deletions src/trs/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,43 @@ all: install
# link against (docs/TCL-CAPI.md).
#
# The hybrid JIT needs llvm-sys (LLVM 18 dev headers). The prefix is
# AUTO-DETECTED via llvm-config-18 so a plain build produces the full
# product (a lean install silently downgraded every artifact to the
# interpreter until the perf fence caught it); LLVM_SYS_181_PREFIX in
# the environment overrides the probe. Without either, the lean
# interpreter-only fallback builds (no LLVM required).
LLVM_SYS_181_PREFIX ?= $(shell llvm-config-18 --prefix 2>/dev/null)
# AUTO-DETECTED so a plain build produces the full product (a lean
# install silently downgrades every artifact to the interpreter);
# LLVM_SYS_181_PREFIX in the environment overrides the probe. Without
# either, the lean interpreter-only fallback builds (no LLVM required).
#
# The probe takes the first llvm-config that answers "18", because no
# single name finds it everywhere: distros install llvm-config-18,
# while Homebrew's llvm@18 is keg-only, so its llvm-config is spelled
# without a version AND never reaches PATH — the keg has to be asked
# for by name. A plain llvm-config counts only when it is an 18.
# (`case ... in 18.*)` would end the $(shell) early: make balances the
# parens of an expansion before the shell ever sees the text.)
LLVM_CONFIG ?= $(shell \
for c in llvm-config-18 \
"$$(brew --prefix llvm@18 2>/dev/null)/bin/llvm-config" \
llvm-config; do \
if "$$c" --version 2>/dev/null | grep -q '^18\.'; then \
echo "$$c"; break; \
fi; \
done)
ifneq ($(LLVM_CONFIG),)
LLVM_SYS_181_PREFIX ?= $(shell $(LLVM_CONFIG) --prefix 2>/dev/null)
endif
ifneq ($(LLVM_SYS_181_PREFIX),)
export LLVM_SYS_181_PREFIX
TRS_FLAGS = --features jit
CAPI_FLAGS =
else
# lean fallback: no compile tier, but artifact LOADING stays (aot
# needs no LLVM) — artifacts built elsewhere run at full speed
# needs no LLVM) — artifacts built elsewhere run at full speed.
# Loud, because the product it yields looks identical and runs an
# order slower: every artifact this build links falls to the
# interpreter, and the only other thing that notices is the perf fence.
$(warning trs: no LLVM 18 found — building the interpreter-only tier.)
$(warning trs: artifacts from this build will run INTERPRETED. Install)
$(warning trs: LLVM 18 (apt: llvm-18-dev, brew: llvm@18) or point)
$(warning trs: LLVM_SYS_181_PREFIX at one to get the compile tier.)
TRS_FLAGS = --features aot
CAPI_FLAGS = --no-default-features --features aot
endif
Expand Down
24 changes: 19 additions & 5 deletions src/trs/crates/trs-codegen/src/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,22 @@ fn gate_static(e: &Expr) -> bool {
}


/// The portable tuning baseline for the architecture this runs on.
///
/// A CPU name belongs to one architecture: LLVM ignores a name its
/// target does not recognize and warns once per module, which loses the
/// baseline and buries the log. On x86-64, v3 measures within noise of
/// host-native on the bench pool (wide-value memcpy code is the only
/// vector consumer). Elsewhere the empty string leaves the choice to
/// LLVM, whose per-triple default is the portable one.
fn portable_cpu() -> &'static str {
if cfg!(target_arch = "x86_64") {
"x86-64-v3"
} else {
""
}
}

fn aot_target_machine() -> Result<inkwell::targets::TargetMachine, Ineligible> {
use inkwell::targets::{CodeModel, RelocMode, Target, TargetMachine};
llvm_init_once();
Expand All @@ -673,18 +689,16 @@ fn aot_target_machine() -> Result<inkwell::targets::TargetMachine, Ineligible> {
// artifacts are cached and shipped (.so, trs link --exe), and the
// load gates check design identity, not CPU features — a
// host-native artifact from an AVX-512 box SIGILLs elsewhere with
// no fallback. Measured on the bench pool, x86-64-v3 matches
// host-native runtime within noise (wide-value memcpy code is the
// only vector consumer); TRS_JIT_CPU overrides: "native" restores
// host tuning, or any LLVM cpu name (x86-64, x86-64-v4, znver4...).
// no fallback. TRS_JIT_CPU overrides: "native" restores host
// tuning, or any LLVM cpu name (x86-64, x86-64-v4, znver4...).
let cpu_env = std::env::var("TRS_JIT_CPU").ok();
let (cpu, feats) = match cpu_env.as_deref() {
Some("native") => (
TargetMachine::get_host_cpu_name().to_string(),
TargetMachine::get_host_cpu_features().to_string(),
),
Some(name) => (name.to_string(), String::new()),
None => ("x86-64-v3".to_string(), String::new()),
None => (portable_cpu().to_string(), String::new()),
};
target
.create_target_machine(
Expand Down
200 changes: 200 additions & 0 deletions src/trs/crates/trs-interp/src/hostlink.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
//! What the host's linker and assembler want, where the two families
//! disagree.
//!
//! Mach-O and ELF differ in spelling far more than in capability: a C
//! symbol carries a leading underscore in one and not the other, dead
//! stripping and symbol hiding have different flags, the export list is
//! a different file format, `libdl` and `libpthread` are part of
//! libSystem rather than libraries to name, and `.align` counts bytes
//! in one assembler and powers of two in the other.
//!
//! Every one of those differences is a way for a link to fail late and
//! specifically -- or, worse, to succeed while exporting the wrong set.
//! They live here together so that a new link site has one place to ask
//! rather than a GNU spelling to copy.

/// Whether the host links Mach-O. Everything else here follows from it.
pub const MACHO: bool = cfg!(target_os = "macos");

/// A C function's name as the linker spells it. Mach-O prefixes an
/// underscore to every C symbol; ELF does not.
pub fn asm_name(sym: &str) -> String {
if MACHO {
format!("_{sym}")
} else {
sym.to_string()
}
}

/// Demand a symbol nothing references, so that an archive member
/// defining it is pulled in. Takes the source name; supplies the
/// linker's spelling.
pub fn undefined(sym: &str) -> String {
format!("-Wl,-u,{}", asm_name(sym))
}

/// Discard sections nothing reaches.
pub fn dead_strip() -> &'static [&'static str] {
if MACHO {
&["-Wl,-dead_strip"]
} else {
&["-Wl,--gc-sections"]
}
}

/// Drop the symbol table from the output.
pub fn strip_symbols() -> &'static [&'static str] {
// Apple's -s is rejected as unsafe for dynamic libraries; -x drops
// local symbols and keeps the exported surface, which is what the
// export list already restricts.
if MACHO {
&["-Wl,-x"]
} else {
&["-Wl,-s"]
}
}

/// Bind a shared object's calls to its own definitions, so an intra-.so
/// call does not pay a PLT stub and a GOT load. Mach-O's two-level
/// namespace does this without being asked.
pub fn local_binding() -> &'static [&'static str] {
if MACHO {
&[]
} else {
&["-Wl,-Bsymbolic-functions"]
}
}

/// Refuse output that still has unresolved symbols. Mach-O already
/// does, and says `-undefined error` is deprecated when asked.
pub fn no_undefined() -> &'static [&'static str] {
if MACHO {
&[]
} else {
&["-Wl,--no-undefined"]
}
}

/// The C++ runtime the LLVM libraries were built against.
pub fn cxx_runtime() -> &'static str {
if MACHO {
"-lc++"
} else {
"-lstdc++"
}
}

/// Support libraries that llvm-sys's bindings reference and a shared
/// libLLVM does not re-export. Only a fallback: `llvm-config
/// --system-libs --link-static` answers this for the LLVM actually in
/// use, and these names are a guess at a Debian-shaped one.
pub fn llvm_support_libs() -> &'static [&'static str] {
if MACHO {
&["-lzstd", "-lcurses"]
} else {
&["-ltinfo", "-lzstd"]
}
}

/// Where a package manager keeps libraries the compiler driver does not
/// search by default. Homebrew installs outside the SDK, so a library
/// LLVM was built against can be present and still unfindable.
pub fn lib_search_paths() -> Vec<String> {
if MACHO {
["/opt/homebrew/lib", "/usr/local/lib"]
.into_iter()
.filter(|p| std::path::Path::new(p).is_dir())
.map(|p| format!("-L{p}"))
.collect()
} else {
Vec::new()
}
}

/// Keep an executable's own symbols visible to what it dlopens.
pub fn export_dynamic() -> &'static [&'static str] {
if MACHO {
&["-Wl,-export_dynamic"]
} else {
&["-Wl,--export-dynamic"]
}
}

/// Keep a dependency recorded even if nothing resolves against it yet.
/// Apple's linker has no as-needed pass to turn off.
pub fn no_as_needed() -> &'static [&'static str] {
if MACHO {
&[]
} else {
&["-Wl,--no-as-needed"]
}
}

/// dlopen and pthreads: separate libraries under glibc, part of
/// libSystem on Mach-O.
pub fn system_libs() -> &'static [&'static str] {
if MACHO {
&[]
} else {
&["-lpthread", "-ldl"]
}
}

/// Name one shared library to link against. `-l:<file>` is a GNU
/// extension; elsewhere the path itself is the argument.
pub fn link_shared(path: &std::path::Path, soname: &str) -> String {
if MACHO {
path.display().to_string()
} else {
format!("-l:{soname}")
}
}

/// Write the export list in the host's format and return the flags that
/// point the linker at it.
///
/// The two formats disagree about more than syntax: a version script
/// names what stays global *and* hides the rest, while an exported
/// symbols list is only the keep-set, with the hiding implied. Both
/// take globs, and Mach-O wants each pattern in the linker's spelling.
pub fn export_list(
path: &std::path::Path,
keep: &[&str],
) -> std::io::Result<Vec<String>> {
if MACHO {
let body: String =
keep.iter().map(|p| format!("{}\n", asm_name(p))).collect();
std::fs::write(path, body)?;
Ok(vec![format!("-Wl,-exported_symbols_list,{}", path.display())])
} else {
let body = format!("{{ global: {}; local: *; }};\n", keep.join("; "));
std::fs::write(path, body)?;
Ok(vec![format!("-Wl,--version-script={}", path.display())])
}
}

/// An assembly stub that embeds a file and brackets it with two
/// symbols, in the host assembler's dialect.
///
/// Neither the section name nor the alignment directive carries over:
/// Mach-O has no `.rodata` and no GNU-stack note, and its `.align`
/// counts powers of two where the GNU assembler counts bytes, so `8`
/// would mean 256. `.p2align` means the same thing to both.
pub fn incbin_stub(file: &std::path::Path, start: &str, end: &str) -> String {
let (sec, note) = if MACHO {
("\t.section __TEXT,__const\n", "")
} else {
("\t.section .rodata\n", "\t.section .note.GNU-stack,\"\",@progbits\n")
};
format!(
"{note}{sec}\t.p2align 3\n\
\t.globl {s}\n\
{s}:\n\
\t.incbin \"{f}\"\n\
\t.globl {e}\n\
{e}:\n",
s = asm_name(start),
e = asm_name(end),
f = file.display(),
)
}
Loading