diff --git a/.github/workflows/slang-mode-benchmark.yaml b/.github/workflows/slang-mode-benchmark.yaml new file mode 100644 index 000000000..77d11ef4a --- /dev/null +++ b/.github/workflows/slang-mode-benchmark.yaml @@ -0,0 +1,157 @@ +name: Slang Pool-Mode Benchmark + +# Wall-clock comparison of the two compilation-dispatch modes (in-process +# rayon threads vs SOLX_SUBPROCESS worker pool) across operating systems. +# Opt-in: label a PR with ci:mode-bench, or dispatch manually. + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: + +permissions: + contents: read + packages: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + + label-check: + # Checks the PR's current label set, not the triggering action (see + # slang-tests.yaml for the rationale). + if: >- + github.event_name == 'workflow_dispatch' + || (contains(github.event.pull_request.labels.*.name, 'ci:mode-bench') + && !github.event.pull_request.head.repo.fork) + runs-on: ubuntu-24.04 + steps: + - run: 'true' + + benchmark: + needs: label-check + permissions: + contents: read + packages: read + env: + CARGO_TARGET_DIR: target-slang + CARGO_INCREMENTAL: "0" + strategy: + fail-fast: false + matrix: + include: + - name: "MacOS x86" + runner: macos-15-intel + - name: "MacOS arm64" + runner: macos-15 + - name: "Linux x86 gnu" + runner: ubuntu-24.04 + image: ghcr.io/nomicfoundation/solx-ci-runner@sha256:cd5a37f2630fdf1898ddb2ca11f8c2ecc4d572c60011fe923b27b1625f92ba15 + target: "x86_64-unknown-linux-gnu" + # rustflags: aarch64-linux links with strict single-pass bfd + # (x86-64 rust uses order-tolerant rust-lld), which cannot + # resolve the crate-graph archive order rustc emits for the + # lld/LLVM static libs. Link with the CI image's lld instead. + # Durable fix: #[link] attributes in the inkwell fork. + - name: "Linux ARM64 gnu" + runner: ubuntu-24.04-arm + image: ghcr.io/nomicfoundation/solx-ci-runner@sha256:cd5a37f2630fdf1898ddb2ca11f8c2ecc4d572c60011fe923b27b1625f92ba15 + target: "aarch64-unknown-linux-gnu" + rustflags: "-C link-arg=-fuse-ld=lld -C link-arg=-B/usr/lib/llvm-21/bin" + # Windows is parked: the slang release build has never linked on + # MinGW. Two layers fixed here (cc-rs must use gcc to match the + # gcc-built LLVM tree; MLIR TypeID data symbols), the next one + # (mass undefined llvm::detail::UniqueFunctionBase/function_ref + # template instantiations) is a real porting effort. Re-add the + # entry once slang-release links on Windows: + # - name: "Windows" + # runner: windows-2025 + # target: "x86_64-pc-windows-gnu" + runs-on: ${{ matrix.runner }} + container: + image: ${{ matrix.image || '' }} + name: ${{ matrix.name }} + # The LLVM "cache" is a ccache (timestamp-keyed), so every run + # recompiles LLVM with warm objects; Windows additionally cold-builds + # solx in release and needs the most headroom. + timeout-minutes: 480 + steps: + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + + - name: Build toolchain + uses: ./.github/actions/build-toolchain + with: + solc: 'false' + + # See slang-tests.yaml (rust-lang/rust-bindgen#1760). + - name: Set bindgen target for MinGW + if: runner.os == 'Windows' + shell: bash + run: echo "BINDGEN_EXTRA_CLANG_ARGS=--target=x86_64-w64-windows-gnu" >> "${GITHUB_ENV}" + + # The cargo cache must be keyed on the solx-llvm pin: Cargo.lock does + # not change when the submodule moves, and restored build-script + # outputs from an older LLVM tree poison the link (undefined lldC + # symbols). + - name: Compute solx-llvm pin + shell: bash + run: echo "LLVM_PIN=$(git rev-parse HEAD:solx-llvm)" >> "${GITHUB_ENV}" + + - name: Cache cargo artifacts + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + prefix-key: slang-mode-bench-v2-${{ env.LLVM_PIN }} + workspaces: ". -> target-slang" + cache-on-failure: true + + - name: Install build target + if: matrix.target != '' + uses: ./.github/actions/setup-rust + with: + target: ${{ matrix.target }} + + # The runner context is not available in a workflow step's `shell:` + # (composite actions only), so the Windows msys2 build is its own step. + - name: Build solx (slang, release) + if: runner.os != 'Windows' + shell: bash + env: + RUSTFLAGS: ${{ matrix.rustflags || '' }} + run: | + ${SFW_PREFIX:-} cargo fetch --locked + ${SFW_PREFIX:-} cargo build-slang --release \ + ${{ matrix.target && format('--target {0}', matrix.target) || '' }} + + # CC/CXX pinned to gcc: cc-rs otherwise picks msys2 clang for the + # mlir-sys/solx-mlir C++ glue, whose MLIR TypeID data symbols don't + # resolve against the gcc-built LLVM tree (undefined .refptr at link). + - name: Build solx (slang, release, Windows) + if: runner.os == 'Windows' + shell: msys2 {0} + env: + CC: gcc + CXX: g++ + run: | + ${SFW_PREFIX:-} cargo fetch --locked + ${SFW_PREFIX:-} cargo build-slang --release \ + ${{ matrix.target && format('--target {0}', matrix.target) || '' }} + + - name: Run benchmark + shell: bash + run: | + PY=$(command -v python3 || command -v python) + EXE=${{ runner.os == 'Windows' && '.exe' || '' }} + SOLX=target-slang/${{ matrix.target && format('{0}/', matrix.target) || '' }}release/solx${EXE} + "${PY}" tests/benchmark/pool-modes/gen_corpus.py "${RUNNER_TEMP}/pool-modes-corpus" + echo "cores: $("${PY}" -c 'import os; print(os.cpu_count())')" + for corpus in tiny-clean tiny-overflow big-clean big-overflow mixed; do + "${PY}" tests/benchmark/pool-modes/bench.py \ + --solx "${SOLX}" --corpus "${RUNNER_TEMP}/pool-modes-corpus/${corpus}" --pairs 5 + done diff --git a/solx-codegen-evm/build.rs b/solx-codegen-evm/build.rs new file mode 100644 index 000000000..f31b7f5cb --- /dev/null +++ b/solx-codegen-evm/build.rs @@ -0,0 +1,23 @@ +//! +//! The build script for `solx-codegen-evm`. +//! + +fn main() { + println!("cargo:rerun-if-env-changed=LLVM_SYS_211_PREFIX"); + + let prefix = std::env::var("LLVM_SYS_211_PREFIX") + .expect("LLVM_SYS_211_PREFIX must be set — point it to the solx-llvm build output"); + + let lib_path = std::path::PathBuf::from(&prefix).join("lib"); + println!("cargo:rustc-link-search=native={}", lib_path.display()); + + // LLD C API — LLVMAssembleEVM/LLVMLinkEVM and friends, referenced by + // inkwell's memory_buffer. The directives must come from this crate + // (the one that owns the inkwell dependency): strict single-pass + // linkers (bfd on aarch64-linux and mingw) resolve archives in + // command-line order, and directives emitted by an unrelated crate + // (solx-mlir) can land before the inkwell rlib that needs them. + println!("cargo:rustc-link-lib=static=lldC"); + println!("cargo:rustc-link-lib=static=lldCommon"); + println!("cargo:rustc-link-lib=static=lldELF"); +} diff --git a/solx-codegen-evm/src/codegen/context/mod.rs b/solx-codegen-evm/src/codegen/context/mod.rs index 9f1680d83..76f626dcd 100644 --- a/solx-codegen-evm/src/codegen/context/mod.rs +++ b/solx-codegen-evm/src/codegen/context/mod.rs @@ -171,6 +171,35 @@ impl<'ctx> Context<'ctx> { ) -> anyhow::Result { let contract_path = self.module.get_name().to_str().expect("Always valid"); + let diagnostics = crate::diagnostics::Capture::install(self.llvm); + + // Per-unit codegen parameters ride the module as flags. The presence + // guards keep the first pass's values through the size-fallback + // recursion, whose settings do not carry them. + if let Some(size) = self.optimizer.settings().spill_area_size() + && self.module().get_flag("evm-stack-region-size").is_none() + { + self.module().add_basic_value_flag( + "evm-stack-region-offset", + inkwell::module::FlagBehavior::Error, + self.llvm.i64_type().const_int(self.memory_guard, false), + ); + self.module().add_basic_value_flag( + "evm-stack-region-size", + inkwell::module::FlagBehavior::Error, + self.llvm.i64_type().const_int(size, false), + ); + } + if let Some(size) = self.optimizer.settings().metadata_size + && self.module().get_flag("evm-metadata-size").is_none() + { + self.module().add_basic_value_flag( + "evm-metadata-size", + inkwell::module::FlagBehavior::Error, + self.llvm.i64_type().const_int(size, false), + ); + } + let run_init_verify = profiler.start_evm_translation_unit( contract_path, self.code_segment, @@ -178,11 +207,8 @@ impl<'ctx> Context<'ctx> { self.optimizer.settings(), ); let spill_area_size = self.optimizer.settings().spill_area_size(); - let target_machine = TargetMachine::new( - self.optimizer.settings(), - self.llvm_options.as_slice(), - spill_area_size.map(|size| (self.memory_guard, size)), - )?; + let target_machine = + TargetMachine::new(self.optimizer.settings(), self.llvm_options.as_slice())?; target_machine.set_target_data(self.module()); target_machine.set_asm_verbosity(true); @@ -270,6 +296,7 @@ impl<'ctx> Context<'ctx> { inkwell::targets::FileType::Assembly, ) .map_err(|error| anyhow::anyhow!("assembly emitting: {error}"))?; + diagnostics.check(is_size_fallback)?; if let Some(output_config) = self.output_config.as_ref() { let assembly_text = String::from_utf8_lossy(assembly_buffer.as_slice()); @@ -317,6 +344,7 @@ impl<'ctx> Context<'ctx> { })?; (bytecode_buffer, None) }; + diagnostics.check(is_size_fallback)?; run_emit_bytecode.borrow_mut().finish(); let immutables = match self.code_segment { @@ -333,8 +361,6 @@ impl<'ctx> Context<'ctx> { let bytecode_size = bytecode_buffer.as_slice().len(); if bytecode_size > bytecode_size_limit { if needs_size_fallback { - crate::codegen::IS_SIZE_FALLBACK - .store(true, std::sync::atomic::Ordering::Relaxed); let mut size_fallback_settings = OptimizerSettings::size(); size_fallback_settings.metadata_size = self.optimizer.settings().metadata_size; self.optimizer = Optimizer::new(size_fallback_settings); diff --git a/solx-codegen-evm/src/codegen/mod.rs b/solx-codegen-evm/src/codegen/mod.rs index a9fef93cf..4660467db 100644 --- a/solx-codegen-evm/src/codegen/mod.rs +++ b/solx-codegen-evm/src/codegen/mod.rs @@ -10,7 +10,6 @@ pub mod profiler; pub mod warning; use std::collections::BTreeMap; -use std::sync::atomic::AtomicBool; use self::context::Context; @@ -37,10 +36,6 @@ pub fn append_metadata( .map_err(|error| anyhow::anyhow!("bytecode metadata appending error: {error}")) } -/// Whether the size fallback is activated during the compilation. -/// Only set once, as we're only compiling one traslation unit in a process. -pub static IS_SIZE_FALLBACK: AtomicBool = AtomicBool::new(false); - /// /// Assembles the main buffer and its dependencies from `bytecode_buffers`. /// diff --git a/solx-codegen-evm/src/diagnostics.rs b/solx-codegen-evm/src/diagnostics.rs new file mode 100644 index 000000000..1b89c8e1c --- /dev/null +++ b/solx-codegen-evm/src/diagnostics.rs @@ -0,0 +1,170 @@ +//! +//! Per-context capture of LLVM diagnostics emitted by the EVM backend. +//! + +use std::cell::Cell; +use std::cell::RefCell; +use std::ffi::c_void; + +use inkwell::context::AsContextRef; +use inkwell::llvm_sys::LLVMDiagnosticSeverity; +use inkwell::llvm_sys::core::LLVMContextSetDiagnosticHandler; +use inkwell::llvm_sys::core::LLVMDisposeMessage; +use inkwell::llvm_sys::core::LLVMGetDiagInfoDescription; +use inkwell::llvm_sys::core::LLVMGetDiagInfoSeverity; +use inkwell::llvm_sys::prelude::LLVMBool; +use inkwell::llvm_sys::prelude::LLVMDiagnosticInfoRef; + +unsafe extern "C" { + /// + /// The EVM-local C API accessor for the stack-region-overflow diagnostic payload. + /// + fn LLVMGetDiagInfoEVMStackRegionOverflow( + info: LLVMDiagnosticInfoRef, + total_stack_size: *mut u64, + stack_region_size: *mut u64, + ) -> LLVMBool; +} + +/// +/// The stack-region-overflow report captured from an EVM backend diagnostic. +/// +/// Surfaced as a typed error so the driver can retry codegen with +/// `total_stack_size` as the new spill area size. +/// +#[derive(Debug, Clone, Copy)] +pub struct StackRegionOverflow { + /// The total stack size the module requires. + pub total_stack_size: u64, + /// The stack region size the module was compiled with. + pub stack_region_size: u64, + /// Whether the overflowing pass was the size fallback. + pub is_size_fallback: bool, +} + +impl std::fmt::Display for StackRegionOverflow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "total stack size ({}) exceeds the allocated stack region size ({})", + self.total_stack_size, self.stack_region_size + ) + } +} + +impl std::error::Error for StackRegionOverflow {} + +/// +/// The diagnostics recorded by the installed handler. +/// +#[derive(Debug, Default)] +struct Captured { + /// The last stack-region-overflow report. + overflow: Cell>, + /// The first non-overflow error diagnostic. + error: RefCell>, +} + +/// +/// Captures EVM backend diagnostics on an LLVM context. +/// +/// Installing replaces LLVM's default handler, which exits the process on +/// error diagnostics, so `check` must be called after every emission. +/// Dropping uninstalls the handler. +/// +pub(crate) struct Capture<'ctx> { + /// The context the handler is installed on. + llvm: &'ctx inkwell::context::Context, + /// The recorded diagnostics the installed handler writes to. + captured: Box, +} + +impl<'ctx> Capture<'ctx> { + /// + /// Installs the capturing handler on `llvm`. + /// + pub fn install(llvm: &'ctx inkwell::context::Context) -> Self { + let captured = Box::::default(); + unsafe { + LLVMContextSetDiagnosticHandler( + llvm.as_ctx_ref(), + Some(handle), + std::ptr::from_ref::(captured.as_ref()) + .cast_mut() + .cast::(), + ); + } + Self { llvm, captured } + } + + /// + /// Returns the error diagnostic recorded since the last check, if any. + /// + /// A stack-region-overflow report becomes the typed `StackRegionOverflow` + /// error the driver retries on, tagged with `is_size_fallback` so the + /// driver knows which pass overflowed. + /// + pub fn check(&self, is_size_fallback: bool) -> anyhow::Result<()> { + if let Some(mut overflow) = self.captured.overflow.take() { + overflow.is_size_fallback = is_size_fallback; + return Err(anyhow::Error::new(overflow)); + } + if let Some(error) = self.captured.error.borrow_mut().take() { + anyhow::bail!("LLVM diagnostic: {error}"); + } + Ok(()) + } +} + +impl Drop for Capture<'_> { + fn drop(&mut self) { + unsafe { + LLVMContextSetDiagnosticHandler(self.llvm.as_ctx_ref(), None, std::ptr::null_mut()); + } + } +} + +/// +/// Records stack-region-overflow reports and other error diagnostics, and +/// forwards the rest to `stderr` in place of LLVM's default handler. +/// +extern "C" fn handle(info: LLVMDiagnosticInfoRef, captured: *mut c_void) { + let captured = unsafe { &*captured.cast_const().cast::() }; + + let mut total_stack_size = 0u64; + let mut stack_region_size = 0u64; + let is_overflow = unsafe { + LLVMGetDiagInfoEVMStackRegionOverflow(info, &mut total_stack_size, &mut stack_region_size) + } != 0; + if is_overflow { + captured.overflow.set(Some(StackRegionOverflow { + total_stack_size, + stack_region_size, + is_size_fallback: false, + })); + return; + } + + let severity = unsafe { LLVMGetDiagInfoSeverity(info) }; + if !matches!( + severity, + LLVMDiagnosticSeverity::LLVMDSError | LLVMDiagnosticSeverity::LLVMDSWarning + ) { + return; + } + + let description = unsafe { LLVMGetDiagInfoDescription(info) }; + let message = unsafe { std::ffi::CStr::from_ptr(description) } + .to_string_lossy() + .into_owned(); + unsafe { LLVMDisposeMessage(description) }; + + if let LLVMDiagnosticSeverity::LLVMDSError = severity { + let mut error = captured.error.borrow_mut(); + if error.is_none() { + *error = Some(message); + } + return; + } + eprintln!("LLVM warning: {message}"); +} diff --git a/solx-codegen-evm/src/lib.rs b/solx-codegen-evm/src/lib.rs index 17386f6f0..1d9d10a7e 100644 --- a/solx-codegen-evm/src/lib.rs +++ b/solx-codegen-evm/src/lib.rs @@ -10,11 +10,11 @@ pub(crate) mod r#const; pub(crate) mod context; pub(crate) mod debug_config; pub(crate) mod dependencies; +pub(crate) mod diagnostics; pub(crate) mod optimizer; pub(crate) mod target_machine; pub use self::codegen::DummyLLVMWritable; -pub use self::codegen::IS_SIZE_FALLBACK; pub use self::codegen::WriteLLVM; pub use self::codegen::append_metadata; pub use self::codegen::assemble; @@ -71,6 +71,7 @@ pub use self::debug_config::DebugConfig; pub use self::debug_config::OutputConfig; pub use self::debug_config::ir_type::IRType; pub use self::dependencies::Dependencies; +pub use self::diagnostics::StackRegionOverflow; pub use self::optimizer::Optimizer; pub use self::optimizer::settings::Settings as OptimizerSettings; pub use self::optimizer::settings::size_level::SizeLevel; diff --git a/solx-codegen-evm/src/target_machine.rs b/solx-codegen-evm/src/target_machine.rs index be855305f..62e887b90 100644 --- a/solx-codegen-evm/src/target_machine.rs +++ b/solx-codegen-evm/src/target_machine.rs @@ -22,32 +22,21 @@ impl TargetMachine { /// /// A shortcut constructor. /// - /// Supported LLVM options: - /// `-evm-stack-region-size ` - /// `-evm-stack-region-offset ` - /// `-evm-metadata-size ` - /// - /// LLVM command line options are process-global, so their occurrences are reset before - /// each parse: a unit never inherits an option set by a previous one in the same worker. + /// Per-unit codegen parameters (stack region, metadata size) travel as + /// module flags set in `Context::build`, not as LLVM options: options are + /// process-global, module flags are per-module. /// pub fn new( optimizer_settings: &OptimizerSettings, llvm_options: &[String], - spill_area: Option<(u64, u64)>, ) -> anyhow::Result { - let mut arguments = Vec::with_capacity(4 + llvm_options.len()); + let mut arguments = Vec::with_capacity(1 + llvm_options.len()); arguments.push(Self::TARGET.to_string()); arguments.extend_from_slice(llvm_options); - if let Some((offset, size)) = spill_area { - arguments.push(format!("-evm-stack-region-offset={offset}")); - arguments.push(format!("-evm-stack-region-size={size}")); - } - if let Some(size) = optimizer_settings.metadata_size { - arguments.push(format!("-evm-metadata-size={size}")); + if arguments.len() > 1 { + let arguments: Vec<&str> = arguments.iter().map(|argument| argument.as_str()).collect(); + inkwell::support::parse_command_line_options(arguments.as_slice(), "LLVM options"); } - let arguments: Vec<&str> = arguments.iter().map(|argument| argument.as_str()).collect(); - inkwell::support::reset_all_option_occurrences(); - inkwell::support::parse_command_line_options(arguments.as_slice(), "LLVM options"); let target_machine = inkwell::targets::Target::from_name(Self::TARGET.to_string().as_str()) .ok_or_else(|| anyhow::anyhow!("LLVM target machine `{}` not found", Self::TARGET))? diff --git a/solx-core/src/arguments.rs b/solx-core/src/arguments.rs index 964efb718..ae9e565f4 100644 --- a/solx-core/src/arguments.rs +++ b/solx-core/src/arguments.rs @@ -187,7 +187,7 @@ pub struct Arguments { #[arg(long, help_heading = "Compilation Settings")] pub via_ir: bool, - /// Sets the number of threads, where each thread compiles its own translation unit in a child process. + /// Sets the number of threads, where each thread compiles its own translation unit. #[arg(short, long, help_heading = "Compilation Settings")] pub threads: Option, diff --git a/solx-core/src/error/mod.rs b/solx-core/src/error/mod.rs index 2bf7b5dc0..b6f0d0517 100644 --- a/solx-core/src/error/mod.rs +++ b/solx-core/src/error/mod.rs @@ -51,7 +51,12 @@ impl Error { impl From for Error { fn from(error: anyhow::Error) -> Self { - Error::Generic(error.to_string()) + match error.downcast::() { + Ok(overflow) => { + Error::stack_too_deep(overflow.total_stack_size, overflow.is_size_fallback) + } + Err(error) => Error::Generic(error.to_string()), + } } } diff --git a/solx-core/src/process/child.rs b/solx-core/src/process/child.rs index d6de6063b..4daea592c 100644 --- a/solx-core/src/process/child.rs +++ b/solx-core/src/process/child.rs @@ -2,7 +2,6 @@ //! The subprocess-side worker: reads a session, then compiles jobs until `stdin` closes. //! -use std::sync::atomic::Ordering; use std::thread::Builder; use crate::error::Error; @@ -25,13 +24,7 @@ pub fn run() -> anyhow::Result<()> { .recv()? .ok_or_else(|| anyhow::anyhow!("The worker received no session"))?; - inkwell::support::error_handling::install_stack_error_handler(evm_stack_error_handler); - while let Some(job) = stdin.recv::()? { - solx_codegen_evm::IS_SIZE_FALLBACK.store( - job.optimizer_settings.is_fallback_to_size_active(), - Ordering::Relaxed, - ); let result = Contract::compile_to_evm( session.language, session.solc_version.clone(), @@ -66,23 +59,3 @@ pub fn run() -> anyhow::Result<()> { .join() .expect("Threading error") } - -/// -/// Handles LLVM stack-too-deep errors. -/// -/// # Safety -/// -/// This function is unsafe because it is called from the LLVM stackifier. -/// The function must terminate the process after handling the error. -/// -unsafe extern "C" fn evm_stack_error_handler(spill_area_size: u64) { - let result: crate::Result = Err(Error::stack_too_deep( - spill_area_size, - solx_codegen_evm::IS_SIZE_FALLBACK.load(Ordering::Relaxed), - )); - std::io::stdout() - .send(&result) - .unwrap_or_else(|error| panic!("Stack-too-deep response writing error: {error}")); - unsafe { inkwell::support::shutdown_llvm() }; - std::process::exit(solx_utils::EXIT_CODE_SUCCESS); -} diff --git a/solx-core/src/process/pool.rs b/solx-core/src/process/pool.rs index 7383d950d..d15f0a2eb 100644 --- a/solx-core/src/process/pool.rs +++ b/solx-core/src/process/pool.rs @@ -4,16 +4,21 @@ use std::path::PathBuf; use std::sync::Mutex; +use std::sync::Once; use crate::error::Error; use crate::process::job::Job; use crate::process::output::Output as EVMOutput; use crate::process::session::Session; use crate::process::worker::Worker; +use crate::project::contract::Contract; /// The lock-poisoning invariant shared by the idle-pool accessors. const POISON: &str = "lock is never poisoned because worker threads do not panic"; +/// One-time installation of the in-process fatal error handler. +static FATAL_ERROR_HANDLER: Once = Once::new(); + /// /// The pool of persistent worker subprocesses. /// @@ -22,6 +27,14 @@ const POISON: &str = "lock is never poisoned because worker threads do not panic /// after which the child exits. The number of live workers never exceeds the number of /// dispatching threads. /// +/// By default jobs compile on the dispatching threads themselves and no subprocess is ever +/// spawned: codegen state is per-`LLVMContext` and per-module, so concurrent in-process jobs +/// do not interfere. Windows is the exception — concurrent in-process codegen still corrupts +/// LLVM's `PrettyStackTrace` entry stack there ("destruction is out of order" abort), so the +/// worker pool stays its default. `SOLX_SUBPROCESS` overrides both ways: `0` forces +/// in-process, any other value forces the pool — the isolation escape hatch: a crash or LLVM +/// fatal error takes down one worker, not the compiler. +/// pub struct Pool { /// The worker executable path. executable: PathBuf, @@ -29,13 +42,24 @@ pub struct Pool { session: Session, /// The idle workers available for checkout. idle: Mutex>, + /// Whether jobs compile on the dispatching threads instead of worker subprocesses. + in_process: bool, } impl Pool { /// - /// Creates a pool that dispatches jobs of `session` to worker subprocesses. + /// Creates a pool that dispatches jobs of `session`. /// pub fn new(session: Session) -> anyhow::Result { + let in_process = match std::env::var_os("SOLX_SUBPROCESS") { + Some(value) => value == "0", + None => !cfg!(windows), + }; + if in_process { + FATAL_ERROR_HANDLER.call_once(|| unsafe { + inkwell::support::error_handling::install_fatal_error_handler(fatal_error_handler); + }); + } let executable = crate::process::EXECUTABLE .get() .cloned() @@ -46,6 +70,7 @@ impl Pool { executable, session, idle: Mutex::new(Vec::new()), + in_process, }) } @@ -56,6 +81,9 @@ impl Pool { /// A transport failure or a `StackTooDeep` reply retires it instead. /// pub fn execute(&self, job: &Job) -> crate::Result { + if self.in_process { + return self.execute_in_process(job); + } let mut worker = match self.idle.lock().expect(POISON).pop() { Some(worker) => worker, None => Worker::spawn(self.executable.as_path(), &self.session)?, @@ -73,4 +101,62 @@ impl Pool { } } } + + /// + /// Compiles one translation unit on the calling thread. + /// + /// The job takes the same serde roundtrip a worker subprocess would receive, so both + /// modes compile identical inputs. + /// + fn execute_in_process(&self, job: &Job) -> crate::Result { + let mut buffer = Vec::with_capacity(crate::r#const::DEFAULT_SERDE_BUFFER_SIZE); + ciborium::into_writer(job, &mut buffer) + .map_err(|error| anyhow::anyhow!("In-process job serializing error: {error}"))?; + let job: Job = + ciborium::de::from_reader_with_recursion_limit(buffer.as_slice(), usize::MAX) + .map_err(|error| anyhow::anyhow!("In-process job deserializing error: {error}"))?; + + let contract_path = job.contract_name.path.clone(); + Contract::compile_to_evm( + self.session.language, + self.session.solc_version.clone(), + job.contract_name, + job.contract_ir, + job.code_segment, + self.session.evm_version, + job.debug_info, + &self.session.output_selection, + job.immutables, + job.metadata_bytes, + job.optimizer_settings, + self.session.llvm_options.clone(), + self.session.output_config.clone(), + ) + .map(EVMOutput::new) + .map_err(|error| match error { + Error::Generic(error) => solx_standard_json::OutputError::new_error_contract( + Some(contract_path.as_str()), + error, + ) + .into(), + error => error, + }) + } +} + +/// +/// Reproduces the default handler's diagnostics on LLVM fatal errors, but leaves via +/// `_exit`: the default handler's `exit(1)` runs atexit destructors that tear down LLVM +/// globals under concurrently compiling threads, and `abort` raises a signal that LLVM's +/// crash handler decorates with bug-report boilerplate. +/// +extern "C" fn fatal_error_handler(message: *const std::ffi::c_char) { + let message = unsafe { std::ffi::CStr::from_ptr(message) }.to_string_lossy(); + eprintln!("LLVM ERROR: {message}"); + unsafe { _exit(1) } +} + +unsafe extern "C" { + /// Process exit without atexit handlers; provided by every libc, including mingw's. + fn _exit(code: i32) -> !; } diff --git a/solx-llvm b/solx-llvm index 7d0702e16..f6ecd1d22 160000 --- a/solx-llvm +++ b/solx-llvm @@ -1 +1 @@ -Subproject commit 7d0702e169889fe4f1a2241c57bef7d2c1c68737 +Subproject commit f6ecd1d22f3c1a9316b0eeb191e7dec49030bec5 diff --git a/solx-mlir/sol_attr_stubs.cpp b/solx-mlir/sol_attr_stubs.cpp index 3c49d1988..1d9cd1411 100644 --- a/solx-mlir/sol_attr_stubs.cpp +++ b/solx-mlir/sol_attr_stubs.cpp @@ -107,7 +107,10 @@ MlirType solxCreateArrayType(MlirContext ctx, int64_t size, MlirType elementType if (dataLocation > 5) abort(); auto *context = unwrap(ctx); auto location = static_cast(dataLocation); - return wrap(mlir::sol::ArrayType::get(context, size, unwrap(elementType), location)); + std::optional sizeOpt; + if (size >= 0) + sizeOpt = llvm::APInt(256, size); + return wrap(mlir::sol::ArrayType::get(context, sizeOpt, unwrap(elementType), location)); } MlirType solxCreateMappingType(MlirContext ctx, MlirType keyType, MlirType valType) { diff --git a/tests/benchmark/pool-modes/bench.py b/tests/benchmark/pool-modes/bench.py new file mode 100644 index 000000000..cff926cfe --- /dev/null +++ b/tests/benchmark/pool-modes/bench.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""A/B wall-clock benchmark: in-process threads vs SOLX_SUBPROCESS workers. + +Runs the two modes in interleaved pairs so turbo/thermal drift cancels - +single-shot timings mislead (the first heavy run rides boost clocks). +Pair 0 is warmup and excluded from the medians. + +Contracts are passed as basenames with cwd at the corpus dir to stay +under the Windows command-line length limit. + +Usage: bench.py --solx --corpus [--threads N] [--pairs N] +""" +import argparse +import os +import statistics +import subprocess +import sys +import time + +parser = argparse.ArgumentParser() +parser.add_argument("--solx", required=True) +parser.add_argument("--corpus", required=True) +parser.add_argument("--threads", type=int, default=os.cpu_count()) +parser.add_argument("--pairs", type=int, default=5) +parser.add_argument("--label", default=None) +args = parser.parse_args() +label = args.label or os.path.basename(os.path.normpath(args.corpus)) + +solx = os.path.abspath(args.solx) +files = sorted(f for f in os.listdir(args.corpus) if f.endswith(".sol")) +if not files: + sys.exit(f"no .sol files in {args.corpus}") +cmd = [solx, "--threads", str(args.threads), "--bin", *files] + + +def run(subprocess_mode): + env = dict(os.environ) + env.pop("SOLX_SUBPROCESS", None) + if subprocess_mode: + env["SOLX_SUBPROCESS"] = "1" + t0 = time.monotonic() + r = subprocess.run(cmd, cwd=args.corpus, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + wall = time.monotonic() - t0 + binaries = r.stdout.decode(errors="replace").count("\nBinary:") + if r.returncode != 0 or binaries != len(files): + sys.exit(f"mode={'subprocess' if subprocess_mode else 'threads'} " + f"rc={r.returncode} binaries={binaries}/{len(files)}\n" + f"{r.stderr.decode(errors='replace')[-2000:]}") + return wall + + +threads, workers = [], [] +for i in range(args.pairs): + a = run(subprocess_mode=False) + b = run(subprocess_mode=True) + print(f"[{label}] pair {i}{' (warmup)' if i == 0 else ''}: " + f"threads={a:.2f}s subprocess={b:.2f}s", flush=True) + if i > 0: + threads.append(a) + workers.append(b) + +mt, ms = statistics.median(threads), statistics.median(workers) +print(f"RESULT label={label} files={len(files)} threads_n={args.threads} " + f"threads_median={mt:.2f}s subprocess_median={ms:.2f}s " + f"ratio={ms / mt:.2f}") diff --git a/tests/benchmark/pool-modes/gen_corpus.py b/tests/benchmark/pool-modes/gen_corpus.py new file mode 100644 index 000000000..0f577ccb0 --- /dev/null +++ b/tests/benchmark/pool-modes/gen_corpus.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Generates the pool-mode benchmark corpora. + +Five corpora as subdirectories of the output dir, isolating the job-cost +x stack-too-deep matrix (one contract per file - the slang frontend +compiles only the first contract of a file, so file count is the unit +of parallelism): + +- tiny-clean: minimal contracts; measures pure per-job dispatch + overhead (serde, pipes, worker checkout). +- tiny-overflow: minimal contracts that hit stack-too-deep; the retry + costs one extra in-process compile on threads, but a + worker retire + respawn on the subprocess pool. +- big-clean: ~3s-of-codegen contracts (40 functions x 64-way + chains); measures long-lived jobs. +- big-overflow: the same big body plus overflowing functions - the + whole module recompiles on retry, so this is the + most expensive divergence point between the modes. +- mixed: all four classes interleaved round-robin, the + realistic blend. + +Usage: gen_corpus.py [scale] +""" +import os +import sys + +outdir = sys.argv[1] +scale = float(sys.argv[2]) if len(sys.argv) > 2 else 1.0 + + +def tiny_clean(name, i): + return f"""contract {name} {{ + function f(uint a) public pure returns (uint) {{ + return a * {i + 3} + {i}; + }} +}}""" + + +def overflow_fn(fname, n): + # Forward-order consumption of n live gasleft() values cannot be + # scheduled on the EVM stack; n >= 48 reliably diagnoses overflow + # and takes the module-flag retry path. + decls = "\n ".join(f"uint x{j} = gasleft();" for j in range(n)) + total = " + ".join(f"x{j}" for j in range(n)) + return f""" function {fname}() public view returns (uint) {{ + {decls} + return {total}; + }}""" + + +def tiny_overflow(name, i): + return f"contract {name} {{\n{overflow_fn('f', 48 + i % 8)}\n}}" + + +def big_fns(count, salt): + fns = [] + for f in range(count): + cases = "\n ".join( + f"if (k == {j * 3 + f + salt}) return k * {j + 1} + {f};" + for j in range(64)) + fns.append(f""" function w{f}(uint k) public pure returns (uint) {{ + {cases} + return k + {f}; + }}""") + return "\n".join(fns) + + +def big_clean(name, i): + return f"contract {name} {{\n{big_fns(40, i)}\n}}" + + +def big_overflow(name, i): + return (f"contract {name} {{\n{big_fns(40, i)}\n" + f"{overflow_fn('o0', 48 + i % 8)}\n" + f"{overflow_fn('o1', 52 + i % 4)}\n}}") + + +CLASSES = { + "tiny-clean": (tiny_clean, 200), + "tiny-overflow": (tiny_overflow, 100), + "big-clean": (big_clean, 12), + "big-overflow": (big_overflow, 6), +} + + +def emit(subdir, fname, text): + d = os.path.join(outdir, subdir) + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, f"{fname}.sol"), "w") as f: + f.write(text + "\n") + + +for sub, (gen, count) in CLASSES.items(): + n = max(1, int(count * scale)) + cname = sub.title().replace("-", "") + for i in range(n): + emit(sub, f"{cname}{i}", gen(f"{cname}{i}", i)) + +# Mixed: half-scale of each class, round-robin interleaved by filename +# so the pool schedules big/tiny/overflow jobs together. +queues = [ + (sub, gen, list(range(max(1, int(count * scale) // 2)))) + for sub, (gen, count) in CLASSES.items() +] +seq = 0 +while any(q for _, _, q in queues): + for sub, gen, q in queues: + if not q: + continue + i = q.pop(0) + cname = "Mx" + sub.title().replace("-", "") + emit("mixed", f"m{seq:04d}_{cname}{i}", gen(f"{cname}{i}", i)) + seq += 1 + +print(f"generated corpora under {outdir}: " + + ", ".join(f"{s}={max(1, int(c * scale))}" for s, (_, c) in CLASSES.items()) + + f", mixed={sum(max(1, int(c * scale)) // 2 for _, c in CLASSES.values())}")