Skip to content

perf: Speed up type-erased SourceLink unpacking - #5829

Draft
andiwand wants to merge 3 commits into
acts-project:mainfrom
andiwand:perf-sourcelink-unpack
Draft

perf: Speed up type-erased SourceLink unpacking#5829
andiwand wants to merge 3 commits into
acts-project:mainfrom
andiwand:perf-sourcelink-unpack

Conversation

@andiwand

@andiwand andiwand commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Unpacking a SourceLink back to its concrete type is on the track finding hot path: the calibrator, the measurement selector and the surface accessor each do it for every measurement candidate. The payload is stored inline in the small buffer, so this should be a type check and a load, but three avoidable costs made it an out-of-line call instead.

  • The per-type Handler singleton was initialised by a lambda at first use, so every SourceLink construction paid a thread-safe-static guard check, and as<T>() compared against a typeHash<T>() that was itself a guarded function-local static. Handler is now built by a constexpr function and the singleton is constant-initialized, which removes both guards. typeHash<T>() is gone from the type check entirely: the check leads with a pointer comparison against that singleton and falls back to comparing type_info, which covers handlers duplicated across shared objects.
  • dataPtr() loaded m_handler->heapAllocated and branched on it, even though as<T>/asPtr<T> know T statically. They now use a typed dataPtrFor<T>() with if constexpr, dropping a dependent load.
  • The throw sat inline in as<T>(). It moved into Acts::detail::throwBadAnyCast(), declared in the header and defined in Any.cpp, so it is out of line by linkage and the accessor stays small enough to inline. No compiler-specific attributes are used, only the standard [[likely]].

Also adds unpacking cases to ActsBenchmarkSourceLink, which previously only covered construction and copying and so could not show any of this.

--- END COMMIT MESSAGE ---

Numbers

Measured on both compilers, since the whole point of the type check is that it should not depend on one. ActsBenchmarkSourceLink links libActsCore, so a GCC binary cannot be linked against a Clang build of it; the numbers below come from the same benchmark source rebuilt as a standalone translation unit, minus the two VectorMultiTrajectory cases. Same flags (-O2 -DNDEBUG), only the header tree swapped between main and this branch, macOS arm64. Three repeats each, stable to ±0.01 ns on Clang and ±0.03 ns on GCC. New benchmark cases in bold.

Apple clang 17, ns/iteration:

case main this PR
Creating source link 1.01 0.75 1.3x
Copy construct source link 1.04 1.04 -
Copy then move construct 1.59 1.59 -
Unpack source link with get<T> 1.32 0.66 2.0x
Unpack source link with getPtr<T> 0.67 0.66 -
Unpack geometry id 1.32 0.66 2.0x
Construct and unpack 1.75 0.48 3.6x
Optional assignment 1.85 0.89 2.1x

GCC 15.1, ns/iteration:

case main this PR
Creating source link 0.58 0.55 1.05x
Copy construct source link 0.83 0.83 -
Copy then move construct 2.41 2.14 1.1x
Unpack source link with get<T> 1.33 0.30 4.4x
Unpack source link with getPtr<T> 1.33 0.31 4.3x
Unpack geometry id 1.33 0.31 4.3x
Construct and unpack 1.33 0.27 4.9x
Optional assignment 1.21 0.92 1.3x

Absolute values are not comparable across the two tables — the compilers generate different code for the benchmark harness itself, which is why GCC's create baseline already sits at 0.58 where Clang's is 1.01. The before/after within each compiler is the meaningful part. The two VectorMultiTrajectory cases, measured separately with the in-tree benchmark under Clang, are unchanged: create track state 15.9 -> 15.6, assign source link to track state 16.4 -> 16.2.

Two asymmetries worth calling out:

  • getPtr<T> does not improve under Clang. The type_info fallback contains a call, so asPtr stops being a leaf function and lands back at its main level. GCC improves it 4.3x like everything else. An earlier iteration of this PR, which keyed the check on a constexpr typeHash<T>(), reached 0.55 there under Clang; that is what the type_info comparison gives back, and it only shows up on one compiler. get<T>, which is the path track finding actually takes, is unaffected on both.
  • create barely moves under GCC (0.58 -> 0.55) where Clang gains 1.3x, because GCC was already folding away most of the guard.

as<T>() previously did not inline at all — the call sites emitted a bl to AnyBase::as<IndexSourceLink>. It now inlines to five instructions, and the hot path is identical on both compilers:

ldr  x8, [x0, #16]                 ; load handler
adrp/ldr x9, ...static_handler     ; the per-type singleton
cmp  x8, x9
b.ne <cold>
ldr  w0, [x0, #8]                  ; the actual load

The 0.30 ns on GCC is low enough to suspect the check was hoisted out of the loop, so I checked: feeding a 50/50 mix of two stored types instead of one costs 2.46 ns on GCC and 3.57 ns on Clang, 8x and 5x the all-matching case. The compare really does run every iteration, it is just perfectly predicted.

That mixed case also shows the one regression this change carries: when the type check fails, Clang is about 0.8 ns slower than main (2.75 -> 3.57), because the fallback is now a type_info comparison with a call rather than an inline hash compare. GCC is still faster there (3.31 -> 2.46). This only matters for code running failing is<T>() / getPtr<T> checks in a hot loop, which is not a pattern on the track finding path.

In a benchmark shaped like the CKF per-candidate sequence (adapter-iterator construct, calibrator copy plus unpack, then the stayOnSeed read-back plus unpack) this is 5.5 ns -> 3.2 ns per candidate, about 2.3 ns saved. Against roughly 25 ns per candidate once makeTrackState is included that is around 9% of the per-candidate track state creation work.

Verified with AnyTests, AnyDebug, AnyGridView, AnyTrackProxy, AnyTrackStateProxy, SourceLink, MultiTrajectory and DataHandle on Clang; the headers and AnyTests additionally compile clean under GCC 15.

A note on typeHash<T>()

typeHash<T>() is unchanged from main. It was briefly made constexpr via std::source_location::function_name(), but that string differs between compilers even for a plain struct Foo:

clang: std::string_view Acts::detail::typeIdentity() [T = Foo]
gcc:   constexpr std::string_view Acts::detail::typeIdentity() [with T = Foo; std::string_view = std::basic_string_view<char>]

Normalising it does not help, the spellings still differ (const char * vs const char*). The existing typeid(T).name() basis is stable across compilers because the Itanium ABI fixes the mangling — verified identical hashes under Clang 17 and GCC 15 for Foo, ns::Nested::Inner, Wrap<Foo>, int and const char*. Any no longer needs a compile-time hash at all, since &typeid(T) is already a constant expression and is what the handler stores.

Possible follow-up: const SourceLink& accessor

After this change, copies rather than unpacks dominate what is left: of the remaining ~3.1 ns per candidate, ~1.6 ns is two SourceLink copies. A large part of that is that getUncalibratedSourceLink() returns by value all the way down (TrackStateProxy -> MultiTrajectory -> backend), so every read copies. Measured on VectorMultiTrajectory in the shape of isSeedCandidate, adding a reference accessor gives:

ns/read
getUncalibratedSourceLink() (by value) 2.40
reference accessor (const SourceLink&) 1.63
saving ~0.8 ns/read, 1.45-1.58x

With one to two such reads per candidate (isSeedCandidate for every candidate under stayOnSeed, copyFrom for every selected one) that is a further ~0.8-1.6 ns/candidate, comparable to what this PR saves.

This cannot be a straight signature change though. MultiTrajectoryBackendConcept requires std::same_as<SourceLink>, so it would break every backend including out-of-tree ones; the Podio backend materialises the source link by scanning a link collection and has no stored object to reference; and callers writing const auto& sl = ts.getUncalibratedSourceLink(); would go from a lifetime-extended temporary to a reference into storage that dangles if the container reallocates. An opt-in backend hook selected with if constexpr, falling back to the by-value path, would avoid all three. Happy to follow up with that separately if there is interest.

🤖 Generated with Claude Code

Unpacking a `SourceLink` back to its concrete type is on the track
finding hot path: the calibrator, the measurement selector and the
surface accessor each do it for every measurement candidate. The payload
is stored inline in the small buffer, so this should be a type check and
a load, but three avoidable costs made it an out-of-line call instead.

- `typeHash<T>()` was a runtime value cached in a function-local static,
  so every call site paid a thread-safe-static guard check, and the hash
  not being a constant kept `as<T>()` from being inlined. It is now
  computed at compile time from `std::source_location::function_name()`,
  which embeds the template arguments on both GCC and Clang. A
  `static_assert` on two probe types turns a compiler that does not do
  this into a build failure rather than silently colliding hashes.
- `dataPtr()` loaded `m_handler->heapAllocated` and branched on it, even
  though `as<T>`/`asPtr<T>` know `T` statically. They now use a typed
  `dataPtrFor<T>()` with `if constexpr`, dropping a dependent load.
- The `throw` sat inline in `as<T>()`. It moved into a cold, out-of-line
  helper so the accessor stays small enough to inline.

Two follow-ons come for free once the hash is constant: the per-type
`Handler` singleton is now constant-initialized, which removes the
`__cxa_guard_acquire` from every `SourceLink` construction, and the type
check leads with a pointer comparison against that singleton, keeping
the hash comparison as a fallback in case the handler is duplicated
across shared objects.

Also adds unpacking cases to `ActsBenchmarkSourceLink`, which previously
only covered construction and copying and so could not show any of this.
@andiwand
andiwand force-pushed the perf-sourcelink-unpack branch from 580ceea to 8b6780f Compare August 4, 2026 13:38
@github-actions github-actions Bot added the Component - Core Affects the Core module label Aug 4, 2026
@github-actions github-actions Bot added this to the next milestone Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Public API surface diff

+0 added, 1 breaking.

⚠️ Breaking API changes (source-level)

Removed public data members (1)
  • Acts::AnyBase::Handler::typeHash

@paulgessinger

Copy link
Copy Markdown
Member

Does this work across compilers? I was previously using a macro based compile time solution and that did not.

Comment thread Core/include/Acts/Utilities/Any.hpp Outdated
Comment thread Core/include/Acts/Utilities/Any.hpp
Comment thread Core/include/Acts/Utilities/HashedString.hpp Outdated
@andiwand
andiwand marked this pull request as draft August 4, 2026 14:56
The previous commit made `typeHash<T>()` a compile-time value derived
from `std::source_location::function_name()`. That string is not the
same on GCC and Clang, not even for a plain `struct Foo`:

    clang: std::string_view Acts::detail::typeIdentity() [T = Foo]
    gcc:   constexpr std::string_view Acts::detail::typeIdentity()
           [with T = Foo; std::string_view = std::basic_string_view<char>]

so the hashes differed per compiler, where the previous
`typeid(T).name()` basis was stable because the Itanium ABI fixes the
mangling. The probe `static_assert` could not catch this: it only
checked that two types do not collide, not that the string is
compiler-independent. Normalising the string does not help either, the
spellings still differ (`const char *` vs `const char*`).

`typeHash<T>()` therefore goes back to what it was on main, and the
probe translation unit is dropped.

The compile-time hash was only needed so the per-type `Handler`
singleton could be constant-initialized. `Handler` already carries a
`const std::type_info*`, and `&typeid(T)` is a constant expression, so
dropping the `typeHash` field keeps `makeHandlerValue<T>()` constexpr
and the singleton free of a thread-safe-static guard. The `holds<T>()`
fallback now compares `type_info` directly, which is the same check the
hash of the mangled name was standing in for.

`throwBadAnyCast()` moves out of the class into `Acts::detail` and is
defined in Any.cpp, so it is out of line by linkage rather than by
`[[gnu::noinline, gnu::cold]]`. No compiler-specific attributes remain,
only the standard `[[likely]]`.

The `_ACTS_ANY_DEBUG` handler dump is restored, in `makeHandler<T>()`
rather than in the constant-evaluated `makeHandlerValue<T>()`, and only
compiled in when debug output is enabled.

Hot-path codegen is unchanged and identical on Clang 17 and GCC 15:
four instructions before the load, cold path out of line. The only
measurable difference against the previous commit is `getPtr<T>`, which
returns to its main level because the fallback now contains a call and
`asPtr` is no longer a leaf. `get<T>`, the path track finding takes, is
unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JbikxN3YQZGjqzZiCZKRa
@paulgessinger

Copy link
Copy Markdown
Member

We can try the type id equality again and revisit in case we get complaints

GCC 15 rejects the trivial copy/move paths in `AnyBase` when the stored
type is empty:

    Any.hpp:721: error: '<anonymous>.Acts::AnyBase<512>::m_data' may be
    used uninitialized [-Werror=maybe-uninitialized]

reached through `MagneticFieldProvider::Cache` holding a
`ConstantBField::Cache`, which is an empty class. Placement-new of an
empty type writes nothing, so `m_data = std::move(fromAny.m_data)` copies
a buffer none of whose bytes were ever written.

This is not a compiler defect. The read is real, and it is also on main:
there the per-type `Handler` is initialised by a lambda at first use, so
GCC cannot fold `heapAllocated` and `moveConstruct == nullptr` and never
reaches the array copy. Constant-initializing the handler exposed it. It
is not undefined either -- `m_data` is `std::byte`, so the copied values
are unspecified rather than indeterminate, and they are never read as a
value. GCC draws the line at a buffer where *nothing* was written; a
single `int` payload in the same 512-byte buffer does not warn, nor does
one with padding.

An empty object still occupies one byte of the buffer, so writing that
byte before the object's lifetime starts restores the property the copy
paths already rely on for every other type. Guarded by
`if constexpr (std::is_empty_v<T>)`, so nothing is emitted for any type
that carries state, and the empty case gets a single byte store next to
a placement-new that generates no code. The trivial copy/move paths keep
their fixed-size array assignment and their codegen is untouched.

`ActsUnitTestAny` passes and `ActsBenchmarkSourceLink` is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiFkbFFfQtQxedBUrMwnCb
@sonarqubecloud

sonarqubecloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📊: Physics performance monitoring for 4cf9445

Full contents

physmon summary

❗️: Downstream build failure

  • eic-shell EICrecon (cc @acts-project/epic-contacts)


h.typeHash = typeHash<T>();
h.typeInfo = &typeid(T);
h.typeInfo = &typeid(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 thought typeid is not constexpr?

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.

typeid is like sizeof I think. it returns a const std::type_info&

std::type_info itself does not provide constexpr methods in C++20 but we don't depend on that here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

API breaking Component - Core Affects the Core module

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants