Skip to content

Case 126: SYCL device Impl Pointer โ€” shared_ptr โ†’ Raw Pointer

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux
Flags ABI break, API break
Detected ChangeKinds struct_size_changed
Source files examples/case126_sycl_device_impl_ptr/

Category: Type Layout | Verdict: ๐Ÿ”ด BREAKING

Verdict and consumer impact

sycl::device stores its implementation as a std::shared_ptr<device_impl> in v1 โ€” two pointers wide (16 bytes) on a 64-bit target. v2 replaces it with a raw device_impl * (one word, 8 bytes), so sizeof(sycl::device) shrinks from 16 to 8. Any consumer that holds a device by value โ€” on the stack, embedded in another struct, or inside a container โ€” reserved storage and computed offsets using the old 16-byte size; those computations are now wrong without a recompile. The method mangled names are unchanged, so a pure exported-symbol diff would miss this entirely; it is a pure layout break.

This case is distilled from a real upstream ABI break: Intel DPC++/oneAPI SYCL's intel/llvm#20821 (labeled abi-break), which produced the symptom in intel/llvm#20915 and was stabilized by intel/llvm#21028. The code here is an original, self-contained miniature โ€” it pulls in no SYCL headers.

Old/new diff

v1.h v2.h
std::shared_ptr<detail::device_impl> impl; detail::device_impl *impl;
sizeof(sycl::device) == 16 sizeof(sycl::device) == 8

abicheck command

g++ -shared -fPIC -g -O0 -fvisibility=hidden -fvisibility-inlines-hidden v1.cpp -o libdev_v1.so
g++ -shared -fPIC -g -O0 -fvisibility=hidden -fvisibility-inlines-hidden v2.cpp -o libdev_v2.so
abicheck compare libdev_v1.so libdev_v2.so

Expected abicheck finding

Verdict: BREAKING (exit 4)

- type_size_changed: Size changed: sycl::device (128 -> 64 bits)
  > Old code allocates or copies the type with the old size;
    heap/stack corruption, out-of-bounds access.
- type_field_type_changed: Field type changed: sycl::device::impl
  (shared_ptr<sycl::detail::device_impl> -> device_impl *)
  > Field has different size or representation; old code misinterprets
    the data.

Minimum evidence

min_evidence: L1 โ€” DWARF's record-layout info (DW_TAG_structure_type size + member types) is enough to detect both the 16โ†’8 byte shrink and the field-type swap directly from the compiled .so's debug info; no public headers are required for this case.

Why abicheck catches it

DWARF records sycl::device's total byte size and its impl member's type for both binaries; abicheck's DWARF-layout diff compares them directly. A separate, coarser DWARF-only struct-size check (struct_size_changed) also fires internally on the same fact but is filtered as redundant once the richer type_size_changed/type_field_type_changed pair โ€” which additionally identifies which field changed and how โ€” is already reported for the same symbol. (With a header AST layered on top via --header, the two paths can diverge and both survive, since the AST and DWARF layers sometimes disagree on the fully-qualified symbol name used for dedup.)

Runtime failure demonstration

Severity: real ABI break, but this specific repro shows no crash

# Build old library + app (app embeds `sycl::device dev;` by value, using v1's 16-byte layout)
g++ -shared -fPIC -g -O0 -fvisibility=hidden -fvisibility-inlines-hidden v1.cpp -o libdev.so
g++ -std=c++17 -g app.cpp -I. -L. -ldev -Wl,-rpath,. -o app
./app
# -> app sees sizeof(sycl::device) = 16 (v1 layout = 16)
# -> device id via library = -1
# -> before = BEFORE!
# -> after  = AFTER!!

# Swap in new library (no recompile):
g++ -shared -fPIC -g -O0 -fvisibility=hidden -fvisibility-inlines-hidden v2.cpp -o libdev.so
./app
# -> identical output -- no crash, no corrupted canary

Why no visible corruption here: the size shrank (16 โ†’ 8). The library's device() constructor now writes fewer bytes than the app reserved for its 16-byte dev member, so nothing gets overwritten in this minimal repro โ€” the opposite of case07, where the struct grew and wrote past the app's smaller allocation. The break is still real: any code that computes offsets from sizeof(sycl::device) (arrays of devices, structs with fields after a by-value device, or a second translation unit built against the new 8-byte header) disagrees with this app's 16-byte assumption. That mismatch โ€” not a single-process crash โ€” is exactly what produced the upstream symbol churn in intel/llvm#20915, and exactly why a static layout diff (not a runtime smoke test) is the reliable way to catch this class of break.

Safe redesign

Keep the public class size stable across releases with the PIMPL idiom: store a single opaque pointer whose pointee layout (and ownership model) can change freely, while the public class's size never does. If the ownership model must change from shared to raw, bump the library SONAME so consumers relink and recompute layout.

Real-world example: intel/llvm#20821 is exactly this change, upstream โ€” replacing shared_ptr<device_impl> with a raw pointer inside sycl::device for the DPC++ SYCL runtime.

Cross-tool comparison

abidiff also operates from DWARF and would catch the size/field-type change the same way abicheck's L1 path does โ€” this repro doesn't need headers, so both a symbol-and-DWARF tool and abicheck agree here. Where they diverge is header-only changes (see case124/case125): those have no DWARF trace at all, which abidiff cannot see regardless of flags.


Source files

  • CMakeLists.txt
  • app.cpp
  • v1.cpp
  • v1.h
  • v2.cpp
  • v2.h

See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.