From ac4ac3c961253dd2510e8e8c96a453e6c25e7348 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Wed, 13 May 2026 11:55:42 +0200 Subject: [PATCH 01/10] feat: cross-language LTO to inline C TLS shim into Rust FFI Add an opt-in inline mode via the LIBDD_OTEL_THREAD_CTX_INLINE env var that uses cross-language LTO (clang + lld) to inline the C TLS shim directly into the Rust FFI functions, eliminating a function-call indirection on every TLS access. When the env var is set, build.rs validates that clang and a suitable LLD are available, compiles the C shim as LLVM bitcode (-flto=thin), and emits the linker flags needed for cross-language LTO. A companion build-optimized.sh wrapper sets the target-scoped RUSTFLAGS and env var. When the env var is absent, the previous behavior is preserved: the default cc compiles the shim with -mtls-dialect=gnu2 on x86-64, no LTO, no inlining. Also fixes #[cfg(target_os/arch)] in build scripts to use the correct CARGO_CFG_* env vars for cross-compilation correctness. Co-Authored-By: Claude Opus 4.6 --- build-optimized.sh | 45 ++++++++++++++ libdd-otel-thread-ctx-ffi/build.rs | 95 ++++++++++++++++++++++++------ libdd-otel-thread-ctx/build.rs | 84 +++++++++++++++++++++----- 3 files changed, 191 insertions(+), 33 deletions(-) create mode 100755 build-optimized.sh diff --git a/build-optimized.sh b/build-optimized.sh new file mode 100755 index 0000000000..28bb1275cf --- /dev/null +++ b/build-optimized.sh @@ -0,0 +1,45 @@ +#!/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). +# +# Usage: +# ./build-optimized.sh # auto-detect host triple +# ./build-optimized.sh --target aarch64-unknown-linux-gnu # explicit target +# +# 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 + +exec cargo build --release \ + --target "$TARGET" \ + -p libdd-otel-thread-ctx-ffi \ + "${EXTRA_ARGS[@]}" diff --git a/libdd-otel-thread-ctx-ffi/build.rs b/libdd-otel-thread-ctx-ffi/build.rs index 312820969b..5cc2482ec3 100644 --- a/libdd-otel-thread-ctx-ffi/build.rs +++ b/libdd-otel-thread-ctx-ffi/build.rs @@ -13,9 +13,9 @@ use std::process::Command; /// Passing it via `-B` to the C compiler driver makes it discover rust-lld /// before any system-wide lld, which /// -/// 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. +/// 1. Avoids the need for a system-wide LLD install. +/// 2. Picks a recent LLD, as opposed to e.g. CentOS 7's LLVM 7 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()?; @@ -35,30 +35,87 @@ fn find_rust_lld_dir() -> Option { dir.join("ld.lld").exists().then_some(dir) } +/// Parse the major version from `ld.lld --version` output. +/// +/// Typical formats: +/// "LLD 18.1.3 (compatible with GNU linkers)" +/// "LLD 19.1.0" +fn system_lld_major_version() -> Option { + let output = Command::new("ld.lld").arg("--version").output().ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + text.split_whitespace() + .find_map(|tok| tok.split('.').next()?.parse::().ok()) +} + +const MIN_LLD_VERSION_FOR_TLSDESC: u32 = 18; + +/// Validate that a suitable LLD is available for cross-language LTO. +/// +/// 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_major_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_os("LIBDD_OTEL_THREAD_CTX_INLINE").is_some(); + 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 - // `local: *` that hides all symbols not explicitly allowlisted, and also causes lld to relax - // the TLSDESC access, eliminating the dynsym entry entirely. - // - // Passing our own version script with an explicit `global:` entry for the symbol beats the - // `local: *` wildcard and prevents that relaxation. - // - // 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 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. + 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 only model, so no flag is needed. + 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" - ); } + + // Version script exports the TLS symbol to the dynamic symbol table so + // external readers (eBPF profiler) can discover it. + println!( + "cargo:rustc-cdylib-link-arg=-Wl,--version-script={manifest_dir}/tls-dynamic-list.txt" + ); } diff --git a/libdd-otel-thread-ctx/build.rs b/libdd-otel-thread-ctx/build.rs index 0eb1e232c4..44f0cfb677 100644 --- a/libdd-otel-thread-ctx/build.rs +++ b/libdd-otel-thread-ctx/build.rs @@ -1,20 +1,76 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +use std::env; +use std::path::PathBuf; +use std::process::Command; + +fn clang_is_available() -> bool { + Command::new("clang") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Locate the `gcc-ld/` shim directory shipped with the Rust toolchain. +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) +} + fn main() { - // Only compile the TLS shim on Linux. - #[cfg(target_os = "linux")] - { - let mut build = cc::Build::new(); - - // - 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"); + 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"); + + let inline_mode = env::var_os("LIBDD_OTEL_THREAD_CTX_INLINE").is_some(); + + 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) = find_rust_lld_dir() { + println!("cargo:rustc-link-arg=-B{}", dir.display()); + } + println!("cargo:rustc-link-arg=-fuse-ld=lld"); + } else { + // On x86-64, force TLSDESC via -mtls-dialect=gnu2 (GCC 4.4+, Clang 19+). + // On aarch64, TLSDESC is the only dynamic TLS model so no flag is needed. + if target_arch == "x86_64" { + build.flag("-mtls-dialect=gnu2"); + } } + + build.file("src/tls_shim.c").compile("tls_shim"); } From 1ab1e73d8a37d2ad06e2d3ae4df05fb9f9cad51f Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Wed, 13 May 2026 12:04:11 +0200 Subject: [PATCH 02/10] doc: add README for the FFI crate Co-Authored-By: Claude Opus 4.6 --- libdd-otel-thread-ctx-ffi/README.md | 38 +++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 libdd-otel-thread-ctx-ffi/README.md diff --git a/libdd-otel-thread-ctx-ffi/README.md b/libdd-otel-thread-ctx-ffi/README.md new file mode 100644 index 0000000000..47d15bd71c --- /dev/null +++ b/libdd-otel-thread-ctx-ffi/README.md @@ -0,0 +1,38 @@ +# 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 via the dynamic +symbol table. + +Currently Linux-only (x86-64 and aarch64). + +## Building + +### Default build + +```bash +cargo build --release -p libdd-otel-thread-ctx-ffi +``` + +The C TLS shim is compiled with the system `cc` (gcc or clang). On x86-64, +`-mtls-dialect=gnu2` forces TLSDESC. No cross-language inlining occurs. + +### Optimized build (cross-language LTO) + +```bash +./build-optimized.sh +``` + +This sets `LIBDD_OTEL_THREAD_CTX_INLINE=1` and the appropriate target-scoped +`RUSTFLAGS`, enabling cross-language LTO so the C TLS shim is inlined directly +into the Rust FFI functions. Requires `clang` and `lld` (the toolchain's +bundled `rust-lld` is used automatically when available). + +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`. From 16e73021a4b60544c4147ab0513c9c79f112d3de Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Wed, 13 May 2026 12:19:26 +0200 Subject: [PATCH 03/10] chore: move script to appropriate crate --- .../build-optimized.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename build-optimized.sh => libdd-otel-thread-ctx-ffi/build-optimized.sh (100%) diff --git a/build-optimized.sh b/libdd-otel-thread-ctx-ffi/build-optimized.sh similarity index 100% rename from build-optimized.sh rename to libdd-otel-thread-ctx-ffi/build-optimized.sh From bdd680eff6555bc23bf5737216e226a866d0fb01 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Wed, 13 May 2026 12:27:25 +0200 Subject: [PATCH 04/10] doc: improve otel thread ctx ffi README Co-Authored-By: Claude Opus 4.6 --- libdd-otel-thread-ctx-ffi/README.md | 38 ++++++++++++++++------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/libdd-otel-thread-ctx-ffi/README.md b/libdd-otel-thread-ctx-ffi/README.md index 47d15bd71c..64d6477664 100644 --- a/libdd-otel-thread-ctx-ffi/README.md +++ b/libdd-otel-thread-ctx-ffi/README.md @@ -1,34 +1,38 @@ # 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 via the dynamic -symbol table. +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). -## Building +## Optimized build (cross-language inlining) -### Default build +The OTel thread-level conext 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. -```bash -cargo build --release -p libdd-otel-thread-ctx-ffi -``` +With the right toolchain, it's possible to use Link-Time Optimization (LTO) to +inline the C wrapper at link time. The requirements are: -The C TLS shim is compiled with the system `cc` (gcc or clang). On x86-64, -`-mtls-dialect=gnu2` forces TLSDESC. No cross-language inlining occurs. +- `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 ships `rust-lld` for a long time, something like since 1.53+, however + some musl-based distro like Alpine might have Rust packages without LLD) +- lld version is at least 19 (TLSDESC support) -### Optimized build (cross-language LTO) +If those requirements are met, you can use the small wrapper provided in this +directory to build an optimized release version where the C shim is inlined. ```bash ./build-optimized.sh ``` -This sets `LIBDD_OTEL_THREAD_CTX_INLINE=1` and the appropriate target-scoped -`RUSTFLAGS`, enabling cross-language LTO so the C TLS shim is inlined directly -into the Rust FFI functions. Requires `clang` and `lld` (the toolchain's -bundled `rust-lld` is used automatically when available). - The script auto-detects the host triple. To cross-compile: ```bash From 6de3ad206a70b9bbd7cfc351655d5ddec40caf9d Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Wed, 13 May 2026 12:27:47 +0200 Subject: [PATCH 05/10] feat: add post-build sanity check to build-optimized.sh After a successful build, verify with nm that the C TLS shim symbol was actually inlined away. Warns and exits 1 if inlining failed, or warns (but succeeds) if nm is not available. Co-Authored-By: Claude Opus 4.6 --- libdd-otel-thread-ctx-ffi/build-optimized.sh | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/libdd-otel-thread-ctx-ffi/build-optimized.sh b/libdd-otel-thread-ctx-ffi/build-optimized.sh index 28bb1275cf..4be4a668a7 100755 --- a/libdd-otel-thread-ctx-ffi/build-optimized.sh +++ b/libdd-otel-thread-ctx-ffi/build-optimized.sh @@ -39,7 +39,22 @@ 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 -exec cargo build --release \ +cargo build --release \ --target "$TARGET" \ -p libdd-otel-thread-ctx-ffi \ "${EXTRA_ARGS[@]}" + +# Sanity-check that the C shim was actually inlined. +if ! command -v nm &>/dev/null; then + echo >&2 "WARNING: nm not found — skipping sanity check that the C TLS shim was inlined." +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 compatible with the Rust toolchain's LLVM." + exit 1 + fi +fi From 6a4434201d136735a2b81a60d07053eeca183fea Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Wed, 13 May 2026 14:50:57 +0200 Subject: [PATCH 06/10] doc: explain why the wrapper script is needed Co-Authored-By: Claude Opus 4.6 --- libdd-otel-thread-ctx-ffi/README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/libdd-otel-thread-ctx-ffi/README.md b/libdd-otel-thread-ctx-ffi/README.md index 64d6477664..1e6ca4c541 100644 --- a/libdd-otel-thread-ctx-ffi/README.md +++ b/libdd-otel-thread-ctx-ffi/README.md @@ -26,8 +26,14 @@ inline the C wrapper at link time. The requirements are: some musl-based distro like Alpine might have Rust packages without LLD) - lld version is at least 19 (TLSDESC support) -If those requirements are met, you can use the small wrapper provided in this -directory to build an optimized release version where the C shim is inlined. +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`. The script sets them via the target-scoped +`CARGO_TARGET__RUSTFLAGS` env var so they don't leak to build scripts +or proc-macros. ```bash ./build-optimized.sh From 88d7f6dee1c7a53826c764ea827840eebe1df5e5 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Wed, 13 May 2026 15:17:58 +0200 Subject: [PATCH 07/10] chore: self-review misc fixes --- libdd-otel-thread-ctx-ffi/README.md | 8 ++++---- libdd-otel-thread-ctx-ffi/build-optimized.sh | 5 +++-- libdd-otel-thread-ctx-ffi/build.rs | 13 ++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/libdd-otel-thread-ctx-ffi/README.md b/libdd-otel-thread-ctx-ffi/README.md index 1e6ca4c541..d19299c7dc 100644 --- a/libdd-otel-thread-ctx-ffi/README.md +++ b/libdd-otel-thread-ctx-ffi/README.md @@ -21,16 +21,16 @@ 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 +- Either the Rust toolchain ships `lld` or there's a system-wide `lld` install (Rust ships `rust-lld` for a long time, something like since 1.53+, however - some musl-based distro like Alpine might have Rust packages without LLD) -- lld version is at least 19 (TLSDESC support) + some musl-based distro like Alpine might have the Rust toolchain without LLD) +- `lld` version is at least 19 (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 +be set from a Cargo build script: they must come from `RUSTFLAGS` or `.cargo/config.toml`. The script sets them via the target-scoped `CARGO_TARGET__RUSTFLAGS` env var so they don't leak to build scripts or proc-macros. diff --git a/libdd-otel-thread-ctx-ffi/build-optimized.sh b/libdd-otel-thread-ctx-ffi/build-optimized.sh index 4be4a668a7..3696bcf696 100755 --- a/libdd-otel-thread-ctx-ffi/build-optimized.sh +++ b/libdd-otel-thread-ctx-ffi/build-optimized.sh @@ -7,6 +7,7 @@ # 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: # ./build-optimized.sh # auto-detect host triple @@ -44,9 +45,9 @@ cargo build --release \ -p libdd-otel-thread-ctx-ffi \ "${EXTRA_ARGS[@]}" -# Sanity-check that the C shim was actually inlined. +# Sanity-check that the C shim was actually inlined, if `nm` is available. if ! command -v nm &>/dev/null; then - echo >&2 "WARNING: nm not found — skipping sanity check that the C TLS shim was inlined." + 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)" diff --git a/libdd-otel-thread-ctx-ffi/build.rs b/libdd-otel-thread-ctx-ffi/build.rs index 5cc2482ec3..49a80da0f9 100644 --- a/libdd-otel-thread-ctx-ffi/build.rs +++ b/libdd-otel-thread-ctx-ffi/build.rs @@ -3,9 +3,7 @@ extern crate build_common; use build_common::generate_and_configure_header; -use std::env; -use std::path::PathBuf; -use std::process::Command; +use std::{env, path::PathBuf, process::Command}; /// Locate the `gcc-ld/` shim directory shipped with the Rust toolchain. /// @@ -86,22 +84,23 @@ fn main() { println!("cargo:rerun-if-env-changed=LIBDD_OTEL_THREAD_CTX_INLINE"); - let inline_mode = env::var_os("LIBDD_OTEL_THREAD_CTX_INLINE").is_some(); + let inline_mode = env::var("LIBDD_OTEL_THREAD_CTX_INLINE").unwrap(); let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); - if inline_mode { + if &inline_mode == "1" { 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. + // binaries also link correctly when RUSTFLAGS sets clang as the linker (although we should + // only build 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 only model, so no flag is needed. + // On aarch64 TLSDESC is the default and the only model. if target_arch == "x86_64" { println!("cargo:rustc-link-arg=-Wl,-mllvm,-enable-tlsdesc"); } From d98d13dc6981e4b845db3a1ace27457640b3c00c Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Wed, 13 May 2026 15:25:28 +0200 Subject: [PATCH 08/10] fix: wrong env variable handling --- libdd-otel-thread-ctx-ffi/build.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libdd-otel-thread-ctx-ffi/build.rs b/libdd-otel-thread-ctx-ffi/build.rs index 49a80da0f9..18d8b57b64 100644 --- a/libdd-otel-thread-ctx-ffi/build.rs +++ b/libdd-otel-thread-ctx-ffi/build.rs @@ -84,11 +84,11 @@ fn main() { println!("cargo:rerun-if-env-changed=LIBDD_OTEL_THREAD_CTX_INLINE"); - let inline_mode = env::var("LIBDD_OTEL_THREAD_CTX_INLINE").unwrap(); + 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(); - if &inline_mode == "1" { + 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 From 9227252f3d5f2b3d2a6821034e10bccc9e294452 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Wed, 13 May 2026 15:55:30 +0200 Subject: [PATCH 09/10] refactor: move find_rust_lld_dir to build_common Deduplicate the function that locates the toolchain's bundled rust-lld by moving it into build_common, where both build scripts can reuse it. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1 + build-common/src/lib.rs | 32 ++++++++++++++++++++++++++++++ libdd-otel-thread-ctx-ffi/build.rs | 30 +--------------------------- libdd-otel-thread-ctx/Cargo.toml | 1 + libdd-otel-thread-ctx/build.rs | 30 ++++++---------------------- 5 files changed, 41 insertions(+), 53 deletions(-) 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..1037be0231 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,31 @@ 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, as opposed to e.g. CentOS 7's LLVM 7 which is too old to handle TLSDESC +/// relocations properly. +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/build.rs b/libdd-otel-thread-ctx-ffi/build.rs index 18d8b57b64..81fec66207 100644 --- a/libdd-otel-thread-ctx-ffi/build.rs +++ b/libdd-otel-thread-ctx-ffi/build.rs @@ -2,37 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 extern crate build_common; -use build_common::generate_and_configure_header; +use build_common::{find_rust_lld_dir, generate_and_configure_header}; use std::{env, path::PathBuf, process::Command}; -/// 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, as opposed to e.g. CentOS 7's LLVM 7 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) -} - /// Parse the major version from `ld.lld --version` output. /// /// Typical formats: 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 44f0cfb677..b1c29d62d9 100644 --- a/libdd-otel-thread-ctx/build.rs +++ b/libdd-otel-thread-ctx/build.rs @@ -1,8 +1,8 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +extern crate build_common; use std::env; -use std::path::PathBuf; use std::process::Command; fn clang_is_available() -> bool { @@ -13,26 +13,6 @@ fn clang_is_available() -> bool { .unwrap_or(false) } -/// Locate the `gcc-ld/` shim directory shipped with the Rust toolchain. -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) -} - fn main() { let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); @@ -60,13 +40,15 @@ fn main() { // 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) = find_rust_lld_dir() { + 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"); } else { - // On x86-64, force TLSDESC via -mtls-dialect=gnu2 (GCC 4.4+, Clang 19+). - // On aarch64, TLSDESC is the only dynamic TLS model so no flag is needed. + // - 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. if target_arch == "x86_64" { build.flag("-mtls-dialect=gnu2"); } From 7a5bbe3229270e1b0fb00da075933baa73d482cb Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 18 May 2026 11:43:14 +0200 Subject: [PATCH 10/10] chore: self-review --- build-common/src/lib.rs | 3 +- libdd-otel-thread-ctx-ffi/README.md | 17 +++--- libdd-otel-thread-ctx-ffi/build-optimized.sh | 8 ++- libdd-otel-thread-ctx-ffi/build.rs | 60 +++++++++++++++----- libdd-otel-thread-ctx/build.rs | 8 ++- 5 files changed, 70 insertions(+), 26 deletions(-) diff --git a/build-common/src/lib.rs b/build-common/src/lib.rs index 1037be0231..c0fa19eea7 100644 --- a/build-common/src/lib.rs +++ b/build-common/src/lib.rs @@ -22,8 +22,7 @@ pub use crate::cbindgen::*; /// before any system-wide lld, which /// /// 1. Avoids the need for a system-wide LLD install. -/// 2. Picks a recent LLD, as opposed to e.g. CentOS 7's LLVM 7 which is too old to handle TLSDESC -/// relocations properly. +/// 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()?; diff --git a/libdd-otel-thread-ctx-ffi/README.md b/libdd-otel-thread-ctx-ffi/README.md index d19299c7dc..30286d4741 100644 --- a/libdd-otel-thread-ctx-ffi/README.md +++ b/libdd-otel-thread-ctx-ffi/README.md @@ -8,7 +8,7 @@ Currently Linux-only (x86-64 and aarch64). ## Optimized build (cross-language inlining) -The OTel thread-level conext sharing specification requires the use of the +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 @@ -22,18 +22,21 @@ inline the C wrapper at link time. The requirements are: 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 ships `rust-lld` for a long time, something like since 1.53+, however - some musl-based distro like Alpine might have the Rust toolchain without LLD) -- `lld` version is at least 19 (TLSDESC support) + (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`. The script sets them via the target-scoped -`CARGO_TARGET__RUSTFLAGS` env var so they don't leak to build scripts -or proc-macros. +`.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 diff --git a/libdd-otel-thread-ctx-ffi/build-optimized.sh b/libdd-otel-thread-ctx-ffi/build-optimized.sh index 3696bcf696..3dfcba4419 100755 --- a/libdd-otel-thread-ctx-ffi/build-optimized.sh +++ b/libdd-otel-thread-ctx-ffi/build-optimized.sh @@ -10,8 +10,10 @@ # The requirements are checked by the build.rs script. # # Usage: -# ./build-optimized.sh # auto-detect host triple -# ./build-optimized.sh --target aarch64-unknown-linux-gnu # explicit target +# # 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 @@ -55,7 +57,7 @@ else 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 compatible with the Rust toolchain's LLVM." + 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 81fec66207..5719554176 100644 --- a/libdd-otel-thread-ctx-ffi/build.rs +++ b/libdd-otel-thread-ctx-ffi/build.rs @@ -3,24 +3,46 @@ extern crate build_common; use build_common::{find_rust_lld_dir, generate_and_configure_header}; -use std::{env, path::PathBuf, process::Command}; +use std::{env, fmt::Display, path::PathBuf, process::Command}; -/// Parse the major version from `ld.lld --version` output. +#[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. /// /// Typical formats: /// "LLD 18.1.3 (compatible with GNU linkers)" /// "LLD 19.1.0" -fn system_lld_major_version() -> Option { +fn system_lld_version() -> Option { let output = Command::new("ld.lld").arg("--version").output().ok()?; if !output.status.success() { return None; } - let text = String::from_utf8_lossy(&output.stdout); - text.split_whitespace() - .find_map(|tok| tok.split('.').next()?.parse::().ok()) + 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 }) + }) } -const MIN_LLD_VERSION_FOR_TLSDESC: u32 = 18; +/// 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. /// @@ -32,7 +54,7 @@ fn require_lld_for_inline(target_arch: &str) -> Option { return Some(dir); } - match system_lld_major_version() { + 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 \ @@ -60,12 +82,26 @@ fn main() { 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 + // `local: *` that hides all symbols not explicitly allowlisted, and also causes lld to relax + // the TLSDESC access, eliminating the dynsym entry entirely. + // + // Passing our own version script with an explicit `global:` entry for the symbol beats the + // `local: *` wildcard and prevents that relaxation. + // + // 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 `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 (although we should - // only build the shared object file in inline mode). + // 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()); } @@ -84,8 +120,6 @@ fn main() { println!("cargo:rustc-cdylib-link-arg=-fuse-ld=lld"); } - // Version script exports the TLS symbol to the dynamic symbol table so - // external readers (eBPF profiler) can discover it. println!( "cargo:rustc-cdylib-link-arg=-Wl,--version-script={manifest_dir}/tls-dynamic-list.txt" ); diff --git a/libdd-otel-thread-ctx/build.rs b/libdd-otel-thread-ctx/build.rs index b1c29d62d9..bbffc0ad54 100644 --- a/libdd-otel-thread-ctx/build.rs +++ b/libdd-otel-thread-ctx/build.rs @@ -24,7 +24,10 @@ fn main() { println!("cargo:rerun-if-env-changed=LIBDD_OTEL_THREAD_CTX_INLINE"); println!("cargo:rerun-if-changed=src/tls_shim.c"); - let inline_mode = env::var_os("LIBDD_OTEL_THREAD_CTX_INLINE").is_some(); + // 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(); @@ -44,6 +47,9 @@ fn main() { 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