Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions build-common/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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"))]
Expand All @@ -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<PathBuf> {
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)
}
48 changes: 48 additions & 0 deletions libdd-otel-thread-ctx-ffi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 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 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.

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 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)

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_<TRIPLE>_RUSTFLAGS` env var so they don't leak to build scripts
or proc-macros.

```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`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be useful to document a way for downstreams to validate that the optimized build did happen. E.g. I'd love to be able to add a check in https://github.com/DataDog/libdatadog-rb/blob/main/spec/gem_packaging.rb to validate that the LTO was correctly in use.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already automatically verified in the script, but I can surely reproduce the steps here in the readme, if you think that's useful.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thinking is -- the script checks, but who's checking that the script is in use? ;)

Maybe someone redid the ruby build build entirely and forgot to even add the script -- that's the thing I was thinking of catching.

61 changes: 61 additions & 0 deletions libdd-otel-thread-ctx-ffi/build-optimized.sh
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So... I'm curious (and this is not a blocker for the PR) -- does this script need to exist?

That is, do the environment variables need to be defined before cargo build gets invoked, or is there perhaps a path to making this something that we could have as a flag in our builder crate?

I'm thinking that if we could move stuff to rust code, it would probably be a bit more ergonomic for users + easier to maintain and validate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, I think we need the script (or something similar that sets the flags from outside of the build) and can't be fully automatic. This is indeed annoying. Quoting this PR's description:

A wrapper script is needed because build.rs cannot set rustc codegen flags (-Clinker-plugin-lto, -Clinker=clang). Those must come from RUSTFLAGS.

My understanding is that by design (for reasons I'm not sure to understand, to be honest), the build.rs can't set compiler flags, only linker or linking-related flags. Cf https://doc.rust-lang.org/cargo/reference/build-scripts.html#rustc-flags (and found other people wanting to do the same, and couldn't).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering this too. I put a comment further down that we might be able to do this with .cargo/config.toml, but that would be project wide if it did work

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering this too. I put a comment further down that we might be able to do this with .cargo/config.toml, but that would be project wide if it did work

Good point, this is why I didn't do it in libdatadog, but if you're having a libdatadog-mylang facade crate anyway, that might be doable option? Will add this to the readme.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I missed that bit in the PR description, although I'd say that it very much deserves to live in a why does this script need to exist in the script itself :)

Having said that, I understand the limitation around build.rs (thanks for clarifying), yet on our own setup we often use the builder crate that then invokes cargo for us; could we auto-add the flags when libdatadog is built in that manner perhaps?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not familiar with the builder crate, but all it takes is to set two env variable before cargo starts (RUST_FLAGS and the toggle introduced here). So it sounds like we could definitely do that 👍

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Afaik right now the builder crate is the recommended way ™️ to build libdatadog if you're not consuming it from rust.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Although supporting it is not a blocker for this PR, to be clear, although it is a really nice to have ™️)

Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven’t looked deeply into this and it may be a rabbit hole, but could .cargo/config.toml help with the required codegen flags here? My understanding is that it would likely need to be workspace/repo-wide which broadens the impact compared with this wrapper script. But maybe longer-term we want this kind of LTO setup in the release pipeline anyway?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, indeed .cargo/config.toml is an alternative way. However the blast radius is much bigger: as you say, this is repo-wide, and additionally it can't be triggered "dynamically" for different build modes. Typically I fear that might be hell for the CI, and even on downstream SDKs, some aren't necessarily using latest clang versions (I remember the first otel thread context PR broke PHP because they weren't using a recent enough clang). But it's an alternative, and it should be documented 👍

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth smoking it out on the CI for this repo just to see what breaks? Adds weight to the "here's why we have a separate script" thing 🤷 don't feel super strongly about this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CI is annoying but can probably get sorted? The thing I'm more worried about with this is downstream users like dd-trace-php. I can try on the CI, but I think it's insufficient. We would have to make sure it doesn't break any downstream consumer of libdatadog, all the dd-trace-xxx. Even worse, with dd-trace-rs, we impose constraints on the toolchain of end-users/customers directly by transitivity. We can discuss it in the component team but my gut feeling is that it's a a bold move to force everyone to use our specific configuration and clang.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my gut feeling is that it's a a bold move to force everyone to use our specific configuration and clang.

Yeah, I'd +1 that as well. Ideally we can make it as turn-key and easy to do and hard to miss as possible, but not go as far as making it a hard requirement.

# 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:
# ./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_<TRIPLE>_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"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we try preserve existing CARGO_TARGET_${TARGET_ENV}_RUSTFLAGS and append to it? I don't have enough surrounding context on our build infra landscape but blasting over it in its entirety might be unexpected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair!

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.
Copy link
Copy Markdown
Member

@scottgerring scottgerring May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If nm was available

Will this be the case in our CI build pipeline for the repo?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, and this is the point: I don't want to fight against CentOS and Alpine with the requirements for LTO (clang, lld, LLVM 18+, ...). I think this will be on the build workflow of the final dynamic library to check that.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if nm fails it looks like the symbol was absent and stderr is suppressed; maybe we could do something like TODO?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

annnnd I was meant to replace that TODO with a concrete suggestion

echo >&2 "WARNING: build succeeded but the C TLS shim (libdd_get_otel_thread_ctx_v1) was NOT inlined."
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very minor: Since we have the exit 1 at the bottom, this is not really a warning ;)

(And I think having the exit 1 is correct btw!)

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
Comment thread
yannham marked this conversation as resolved.
fi
112 changes: 70 additions & 42 deletions libdd-otel-thread-ctx-ffi/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,63 +2,91 @@
// 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, path::PathBuf, process::Command};

/// Locate the `gcc-ld/` shim directory shipped with the Rust toolchain.
/// Parse the major 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
///
/// 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<PathBuf> {
let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".into());
let target = env::var("TARGET").ok()?;
/// Typical formats:
/// "LLD 18.1.3 (compatible with GNU linkers)"
/// "LLD 19.1.0"
fn system_lld_major_version() -> Option<u32> {
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::<u32>().ok())
}

let output = Command::new(&rustc)
.arg("--print")
.arg("sysroot")
.output()
.ok()?;
const MIN_LLD_VERSION_FOR_TLSDESC: u32 = 18;

let sysroot = std::str::from_utf8(&output.stdout).ok()?.trim();
let dir = PathBuf::from(sysroot)
.join("lib/rustlib")
.join(&target)
.join("bin/gcc-ld");
/// 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<PathBuf> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name reads a little funny to me in terms of what the function does; maybe resolve works better?

Suggested change
fn require_lld_for_inline(target_arch: &str) -> Option<PathBuf> {
fn resolve_lld_for_inline(target_arch: &str) -> Option<PathBuf> {

if let Some(dir) = find_rust_lld_dir() {
return Some(dir);
}

dir.join("ld.lld").exists().then_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");

// 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();
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 {
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).
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"
);
}

// 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"
);
}
1 change: 1 addition & 0 deletions libdd-otel-thread-ctx/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ crate-type = ["lib"]
bench = false

[build-dependencies]
build_common = { path = "../build-common" }
cc = "1.1.31"
56 changes: 47 additions & 9 deletions libdd-otel-thread-ctx/build.rs
Original file line number Diff line number Diff line change
@@ -1,20 +1,58 @@
// 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" {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is somehow unrelated, but using static cfg(target_os = "linux") is actually not the way to go in a build script (during cross-compilation typically, the target_os of the script and of the dynamic library could differ), so fixing it passing by.

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) = build_common::find_rust_lld_dir() {
println!("cargo:rustc-link-arg=-B{}", dir.display());
}
println!("cargo:rustc-link-arg=-fuse-ld=lld");
} 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" {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't really related to this PR, but should we throw if we see Linux with a different architecture from these two? That way in the future if some enterprising engineer adds risc-v or whatever, we'll know that we have to address this.

I figure the linker flags to specify dialect differ between architectures anyway, so it is unlikely this will just work out of the box.

build.flag("-mtls-dialect=gnu2");
}
}

build.file("src/tls_shim.c").compile("tls_shim");
}
Loading