diff --git a/Cargo.lock b/Cargo.lock index 4839ed758a..dc9fc43b31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3132,6 +3132,7 @@ dependencies = [ name = "libdd-otel-thread-ctx" version = "1.0.0" dependencies = [ + "build_common", "cc", ] diff --git a/build-common/src/lib.rs b/build-common/src/lib.rs index 4d27ea5dd5..c0fa19eea7 100644 --- a/build-common/src/lib.rs +++ b/build-common/src/lib.rs @@ -1,6 +1,10 @@ // Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +use std::env; +use std::path::PathBuf; +use std::process::Command; + #[cfg(not(feature = "cbindgen"))] pub fn generate_and_configure_header(_header_name: &str) {} #[cfg(not(feature = "cbindgen"))] @@ -10,3 +14,30 @@ pub fn copy_and_configure_headers() {} mod cbindgen; #[cfg(feature = "cbindgen")] pub use crate::cbindgen::*; + +/// Locate the `gcc-ld/` shim directory shipped with the Rust toolchain. +/// +/// This directory contains an `ld.lld` wrapper that delegates to `rust-lld`. +/// Passing it via `-B` to the C compiler driver makes it discover rust-lld +/// before any system-wide lld, which +/// +/// 1. Avoids the need for a system-wide LLD install. +/// 2. Picks a recent LLD that match the Rust toolchain's LLVM version +pub fn find_rust_lld_dir() -> Option { + let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".into()); + let target = env::var("TARGET").ok()?; + + let output = Command::new(&rustc) + .arg("--print") + .arg("sysroot") + .output() + .ok()?; + + let sysroot = std::str::from_utf8(&output.stdout).ok()?.trim(); + let dir = PathBuf::from(sysroot) + .join("lib/rustlib") + .join(&target) + .join("bin/gcc-ld"); + + dir.join("ld.lld").exists().then_some(dir) +} diff --git a/libdd-otel-thread-ctx-ffi/README.md b/libdd-otel-thread-ctx-ffi/README.md new file mode 100644 index 0000000000..30286d4741 --- /dev/null +++ b/libdd-otel-thread-ctx-ffi/README.md @@ -0,0 +1,51 @@ +# libdd-otel-thread-ctx-ffi + +FFI bindings for the OTel thread-level context publisher. Exposes a C API for +attaching, detaching, and updating per-thread OpenTelemetry context records +that external readers (e.g. the eBPF profiler) can discover. + +Currently Linux-only (x86-64 and aarch64). + +## Optimized build (cross-language inlining) + +The OTel thread-level context sharing specification requires the use of the +TLSDESC dialect for the thread-local variable that holds the current context. +Because (stable) `rustc` doesn't currently provide a way to control the TLS +dialect, we need to use a small C shim that defines the variable and expose a +one-line getter. This unfortunately adds one level of indirection (a function +call) when attaching or detaching a context. + +With the right toolchain, it's possible to use Link-Time Optimization (LTO) to +inline the C wrapper at link time. The requirements are: + +- `clang` is available to compile the C shim to LLVM IR (version requirements + aren't clear -- tested with clang18 and clang20, but ideally the version + should be the same or close to the LLVM version shipped with `rustc`) +- Either the Rust toolchain ships `lld` or there's a system-wide `lld` install + (Rust has been shipping `rust-lld` for a long time now, something like since + 1.53+, however some musl-based distro like Alpine might have the Rust + toolchain without `rust-lld`) +- `lld` version is at least 18.1 (TLSDESC support) + +If those requirements are met, you can use the small wrapper script provided in +this directory to build an optimized release version where the C shim is +inlined. A wrapper script is needed because cross-language LTO requires two +`rustc` codegen flags (`-Clinker-plugin-lto` and `-Clinker=clang`) that cannot +be set from a Cargo build script: they must come from `RUSTFLAGS` or +`.cargo/config.toml`, which can't be entirely automated from Rust only. The +script sets them via the target-scoped `CARGO_TARGET__RUSTFLAGS` env +var so they don't leak to build scripts or proc-macros if cross-compiling. + +### Example usage + +```bash +./build-optimized.sh +``` + +The script auto-detects the host triple. To cross-compile: + +```bash +./build-optimized.sh --target aarch64-unknown-linux-gnu +``` + +Extra arguments are forwarded to `cargo build`. diff --git a/libdd-otel-thread-ctx-ffi/build-optimized.sh b/libdd-otel-thread-ctx-ffi/build-optimized.sh new file mode 100755 index 0000000000..3dfcba4419 --- /dev/null +++ b/libdd-otel-thread-ctx-ffi/build-optimized.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 +# +# Build libdd-otel-thread-ctx-ffi with cross-language LTO so the C TLS shim is +# inlined into the Rust FFI functions, eliminating a function-call indirection +# on every TLS access. +# +# Requirements: clang, lld (rust-lld from the toolchain is used automatically). +# The requirements are checked by the build.rs script. +# +# Usage: +# # auto-detect host triple +# ./build-optimized.sh +# # explicit target +# ./build-optimized.sh --target aarch64-unknown-linux-gnu +# +# Any extra arguments are forwarded to `cargo build`. +set -euo pipefail + +# Parse --target from args, or auto-detect the host triple. +TARGET="" +EXTRA_ARGS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + TARGET="$2"; shift 2 ;; + --target=*) + TARGET="${1#--target=}"; shift ;; + *) + EXTRA_ARGS+=("$1"); shift ;; + esac +done + +if [[ -z "$TARGET" ]]; then + TARGET=$(rustc -vV | sed -n 's/host: //p') +fi + +# CARGO_TARGET__RUSTFLAGS scopes the flags to the target only, keeping +# build scripts and proc-macros unaffected. +TARGET_ENV=$(echo "$TARGET" | tr 'a-z-' 'A-Z_') +export "CARGO_TARGET_${TARGET_ENV}_RUSTFLAGS=-Clinker-plugin-lto -Clinker=clang" +export LIBDD_OTEL_THREAD_CTX_INLINE=1 + +cargo build --release \ + --target "$TARGET" \ + -p libdd-otel-thread-ctx-ffi \ + "${EXTRA_ARGS[@]}" + +# Sanity-check that the C shim was actually inlined, if `nm` is available. +if ! command -v nm &>/dev/null; then + echo >&2 "WARNING: skipping sanity check that the C TLS shim was inlined (\`nm\` not found)" +else + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + SO="$REPO_ROOT/target/$TARGET/release/liblibdd_otel_thread_ctx_ffi.so" + + if [[ -f "$SO" ]] && nm "$SO" 2>/dev/null | grep -q 'libdd_get_otel_thread_ctx'; then + echo >&2 "WARNING: build succeeded but the C TLS shim (libdd_get_otel_thread_ctx_v1) was NOT inlined." + echo >&2 "Cross-language LTO may not be working. Check that clang and lld versions are recent enough and compatible with the Rust toolchain's LLVM." + exit 1 + fi +fi diff --git a/libdd-otel-thread-ctx-ffi/build.rs b/libdd-otel-thread-ctx-ffi/build.rs index 312820969b..5719554176 100644 --- a/libdd-otel-thread-ctx-ffi/build.rs +++ b/libdd-otel-thread-ctx-ffi/build.rs @@ -2,42 +2,85 @@ // SPDX-License-Identifier: Apache-2.0 extern crate build_common; -use build_common::generate_and_configure_header; -use std::env; -use std::path::PathBuf; -use std::process::Command; +use build_common::{find_rust_lld_dir, generate_and_configure_header}; +use std::{env, fmt::Display, path::PathBuf, process::Command}; -/// Locate the `gcc-ld/` shim directory shipped with the Rust toolchain. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] +struct LldVersion { + major: u32, + minor: u32, +} + +impl Display for LldVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}", self.major, self.minor) + } +} + +/// Parse the major and minor version from `ld.lld --version` output. /// -/// This directory contains an `ld.lld` wrapper that delegates to `rust-lld`. -/// Passing it via `-B` to the C compiler driver makes it discover rust-lld -/// before any system-wide lld, which +/// Typical formats: +/// "LLD 18.1.3 (compatible with GNU linkers)" +/// "LLD 19.1.0" +fn system_lld_version() -> Option { + let output = Command::new("ld.lld").arg("--version").output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .find_map(|tok| { + let mut splitted = tok.split('.'); + let major = splitted.next()?.parse::().ok()?; + let minor = splitted.next()?.parse::().ok()?; + + Some(LldVersion { major, minor }) + }) +} + +/// TLSDESC is supported in LLD from version 18.1. +const MIN_LLD_VERSION_FOR_TLSDESC: LldVersion = LldVersion { + major: 18, + minor: 1, +}; + +/// Validate that a suitable LLD is available for cross-language LTO. /// -/// 1. Avoid the need of a system-wide LLD install -/// 2. Pick a recent LLD, as opposed to e.g. CentOS 7' LLVM7 which is too old to handle TLSDESC -/// relocations properly. -fn find_rust_lld_dir() -> Option { - let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".into()); - let target = env::var("TARGET").ok()?; - - let output = Command::new(&rustc) - .arg("--print") - .arg("sysroot") - .output() - .ok()?; - - let sysroot = std::str::from_utf8(&output.stdout).ok()?.trim(); - let dir = PathBuf::from(sysroot) - .join("lib/rustlib") - .join(&target) - .join("bin/gcc-ld"); - - dir.join("ld.lld").exists().then_some(dir) +/// Returns the rust-lld `gcc-ld/` directory if found; `None` means the system +/// `ld.lld` will be used instead. Panics with a clear message when the +/// requirements are not met. +fn require_lld_for_inline(target_arch: &str) -> Option { + if let Some(dir) = find_rust_lld_dir() { + return Some(dir); + } + + match system_lld_version() { + Some(v) if target_arch != "x86_64" || v >= MIN_LLD_VERSION_FOR_TLSDESC => None, + Some(v) => panic!( + "LIBDD_OTEL_THREAD_CTX_INLINE requires LLD >= {MIN_LLD_VERSION_FOR_TLSDESC} on \ + x86-64 (for -mllvm -enable-tlsdesc), but system ld.lld is version {v}. \ + Install a newer LLD or use a Rust toolchain that bundles rust-lld." + ), + None => panic!( + "LIBDD_OTEL_THREAD_CTX_INLINE requires LLD for cross-language LTO, but neither \ + rust-lld nor a system ld.lld was found." + ), + } } fn main() { generate_and_configure_header("otel-thread-ctx.h"); + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); + if target_os != "linux" { + return; + } + + println!("cargo:rerun-if-env-changed=LIBDD_OTEL_THREAD_CTX_INLINE"); + + let inline_mode = env::var("LIBDD_OTEL_THREAD_CTX_INLINE").is_ok_and(|v| v == "1"); + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); // Export the TLSDESC thread-local variable to the dynamic symbol table so external readers // (e.g. the eBPF profiler) can discover it. Rust's cdylib linker applies a version script with @@ -50,15 +93,34 @@ fn main() { // Merging multiple version scripts is not supported by GNU ld, so we need lld. We prefer the // toolchain's bundled rust-lld (LLD 19+ since Rust 1.84) over the system lld (if it even // exists). If rust-lld is not found we fall back to whatever `lld` the system provides. - if target_os == "linux" { - let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + // If `LIBDD_OTEL_THREAD_CTX_INLINE` is set to `1`, we try to inline the C shim. See the README + // for more details. + if inline_mode { + let rust_lld_dir = require_lld_for_inline(&target_arch); + + // Emit link args for ALL link types (not just cdylib) so that test binaries also link + // correctly when RUSTFLAGS sets clang as the linker (in practice we should only build/care + // about the shared object file in inline mode). + if let Some(dir) = rust_lld_dir { + println!("cargo:rustc-link-arg=-B{}", dir.display()); + } + println!("cargo:rustc-link-arg=-fuse-ld=lld"); + + // On x86-64, tell the LLVM backend to use TLSDESC during LTO codegen. + // On aarch64 TLSDESC is the default and the only model. + if target_arch == "x86_64" { + println!("cargo:rustc-link-arg=-Wl,-mllvm,-enable-tlsdesc"); + } + } else { + // Default mode: only the cdylib needs lld (for the version script). if let Some(gcc_ld_dir) = find_rust_lld_dir() { println!("cargo:rustc-cdylib-link-arg=-B{}", gcc_ld_dir.display()); } println!("cargo:rustc-cdylib-link-arg=-fuse-ld=lld"); - println!( - "cargo:rustc-cdylib-link-arg=-Wl,--version-script={manifest_dir}/tls-dynamic-list.txt" - ); } + + println!( + "cargo:rustc-cdylib-link-arg=-Wl,--version-script={manifest_dir}/tls-dynamic-list.txt" + ); } diff --git a/libdd-otel-thread-ctx/Cargo.toml b/libdd-otel-thread-ctx/Cargo.toml index 7c4d1af083..bd8ce4c372 100644 --- a/libdd-otel-thread-ctx/Cargo.toml +++ b/libdd-otel-thread-ctx/Cargo.toml @@ -17,4 +17,5 @@ crate-type = ["lib"] bench = false [build-dependencies] +build_common = { path = "../build-common" } cc = "1.1.31" diff --git a/libdd-otel-thread-ctx/build.rs b/libdd-otel-thread-ctx/build.rs index 0eb1e232c4..bbffc0ad54 100644 --- a/libdd-otel-thread-ctx/build.rs +++ b/libdd-otel-thread-ctx/build.rs @@ -1,20 +1,64 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +extern crate build_common; + +use std::env; +use std::process::Command; + +fn clang_is_available() -> bool { + Command::new("clang") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} fn main() { - // Only compile the TLS shim on Linux. - #[cfg(target_os = "linux")] - { - let mut build = cc::Build::new(); + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); + let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); + + if target_os != "linux" { + return; + } + + println!("cargo:rerun-if-env-changed=LIBDD_OTEL_THREAD_CTX_INLINE"); + println!("cargo:rerun-if-changed=src/tls_shim.c"); + // The otel-thread-ctx FFI crate has a special flag to inline the C shim inside the final + // library. This setup has additional requirements for the build of this crate, which are + // enforced below when the flag is set. + let inline_mode = env::var_os("LIBDD_OTEL_THREAD_CTX_INLINE").is_some_and(|v| v == "1"); + + let mut build = cc::Build::new(); + + if inline_mode { + assert!( + clang_is_available(), + "LIBDD_OTEL_THREAD_CTX_INLINE is set but `clang` was not found. \ + Cross-language LTO requires clang as the C compiler." + ); + build.compiler("clang"); + build.flag("-flto=thin"); + + // Any binary linking this crate in inline mode (including test + // binaries) needs lld, because -Clinker-plugin-lto passes LTO plugin + // options that only lld understands. + if let Some(dir) = build_common::find_rust_lld_dir() { + println!("cargo:rustc-link-arg=-B{}", dir.display()); + } + println!("cargo:rustc-link-arg=-fuse-ld=lld"); + + // Note: in the inline setup, TLS dialect selection is handled by the linker and is taken + // care of by the build script of otel-thread-ctx-ffi + } else { // - On aarch64, TLSDESC is already the only dynamic TLS model so no flag is needed. // - On x86-64, we use `-mtls-dialect=gnu2` (supported since GCC 4.4 and Clang 19+) to force // the use of TLSDESC as mandated by the spec. If it's not supported, this build will // fail. - #[cfg(target_arch = "x86_64")] - build.flag("-mtls-dialect=gnu2"); - - build.file("src/tls_shim.c").compile("tls_shim"); - println!("cargo:rerun-if-changed=src/tls_shim.c"); + if target_arch == "x86_64" { + build.flag("-mtls-dialect=gnu2"); + } } + + build.file("src/tls_shim.c").compile("tls_shim"); }