[RFC] Introduce `llvm.is.debugging.enabled` intrinsic

We at AMD propose a target-independent LLVM IR intrinsic for querying whether debugging is currently enabled for a target-defined execution context. An initial AMDGPU lowering is proposed for selected GFX-family targets.

Disclaimer: AI was used to improve upon the initial draft of this RFC text, and was subsequently reviewed and edited.

Proposal

Introduce the following intrinsic:

declare noundef i1 @llvm.is.debugging.enabled() nomerge memory(inaccessiblemem: readwrite)

The intrinsic returns whether debugging is enabled for a target-defined execution context associated with the current invocation.

Each dynamically executed invocation is a distinct observation of the
debugging state. LLVM must not assume that separate calls return the same value.
Calls cannot be removed when unused, commoned, merged, or reordered relative to
another call.
The inaccessible-memory effect models debugger or runtime state that is not
otherwise represented in the program. nomerge preserves distinct call sites.

On targets that do not support querying debugging state, the intrinsic returns
false and performs no observation.

Motivation

This work is directly motivated by Microsoft’s official HLSL/DX12 Debugging
Intrinsics proposal
.
That proposal introduces DebugBreak() and dx::IsDebuggingEnabled() so shader
authors can enable expensive validation only during debugging and trigger a
breakpoint when a failure is detected.

LLVM already provides llvm.debugtrap for requesting debugger attention, but
there is no target-independent way to query whether debugging is enabled.
Without such an intrinsic, frontends must use target-specific operations or
opaque calls, preventing consistent optimization and backend lowering.

The proposed intrinsic supplies that missing query while allowing each backend
to define the relevant execution context and debugging state.

Example usage

At the HLSL level:

if (dx::IsDebuggingEnabled()) {
  DebugBreak();
}

This can be lowered to roughly the following LLVM IR:

entry:
  %enabled = call i1 @llvm.is.debugging.enabled()
  br i1 %enabled, label %debug, label %normal

debug:
  call void @llvm.debugtrap()
  br label %normal

normal:
   ...

A frontend may independently mark the debugging path as unlikely, e.g.,
using llvm.expect, which lets the optimizer lay that path
out as cold code.

AMDGPU implementation and lowering example

The initial AMDGPU implementation supports GFX11.5 and GFX12/13-family targets.
Other subtargets lower the intrinsic to false.

The target-defined execution context is the current wave. The result is uniform
across the wave’s active lanes, including when the intrinsic is executed in
divergent control flow. A wave is in debugging mode when either COND_DBG_SYS
(system or external-debugger enablement) or COND_DBG_USER (per-dispatch
enablement, configured through CDBG_USER in the kernel descriptor) is set.

Both SelectionDAG and GlobalISel lower the intrinsic in one of two ways:

  • When the result reaches a conditional branch in the same basic block and
    fusion is safe, the query and branch are combined into a single
    s_cbranch_cdbgsys_or_user. Thus, no boolean value is materialized. Canonical
    negated conditions still fuse.
  • For other uses, the result is materialized by reading the
    debugging-enabled bits from the wave status register and comparing them
    against zero. The register and bit positions may vary by generation.

Fusion moves the observation from the intrinsic to the branch, so it is not
performed across an intervening memory operation, call, barrier, fence, or
other observable side effect. Pure arithmetic and other non-observable
operations do not prevent fusion.

For example:

%enabled = call i1 @llvm.is.debugging.enabled()
%expected = call i1 @llvm.expect.i1(i1 %enabled, i1 false)  ; optional
br i1 %expected, label %debug, label %normal

lowers to a single branch instruction:

s_cbranch_cdbgsys_or_user .Ldebug
.Lnormal:
  ...                                                       ; hot path
.Ldebug:
    ...                                                     ; cold debugging code

Other backends

Backends are not required to implement target-specific lowering. By default,
LLVM replaces llvm.is.debugging.enabled with false before instruction
selection.

A backend that can observe native debugger state may opt in through
TargetMachine::canLowerIsDebuggingEnabled(). It must then:

  • Define the target-specific execution context and debugging-enabled state.
  • Provide lowering for supported subtargets.
  • Lower the intrinsic to false on any unsupported subtargets.

This keeps the intrinsic usable in target-independent IR without imposing
implementation work on existing backends.

Feedback requested

We welcome feedback on the proposed intrinsic, its usefulness to the broader
community, and any concerns not adequately addressed here.

For implementation context and technical details, see the following list of four PR’s that build upon each other:

Thanks in advance!

There is an ongoing effort to implement a similar feature at the library level https://github.com/llvm/llvm-project/pull/81447
Are you aware of this effort and do you see any overlap?

@ldionne

2 Likes

Hey, thanks for pointing that out. I was not aware of this particular effort.

From a quick glance at Debugging Support, it seems to follow the same principle “The goal of the std::is_debugger_present function is to inform when a program is executing under the control of a debugger monitoring program.”, which is closely aligned with llvm.is.debugging.enabled.

This generally sounds like a reasonable feature, but:

I’d really rather not have yet another “target-independent” intrinsic which only actually works on a single target (AMDGPU).

I’d expect that the default lowering for this intrinsic would be a compiler-rt libcall, where the libcall has an implementation along the lines of what the referenced libcxx PR does.

An alternative design would be that llvm.is.debugging.enabled only lowers to something meaningful if the target has microarchitectural state to query for debugger presence (as opposed to having to query the operating system), so that something like the libcxx feature would have to be implemented by using the intrinsic on supported targets and otherwise having its own implementation. If this is the case, we should be clear that this is the intended design (and probably need a way to query whether the intrinsic is “supported” on the clang side?)

I don’t really understand why you need/want nomerge for this intrinsic.

I don’t really understand why you need/want nomerge for this intrinsic.

This is on the design axis to treat each call site as a distinct observation point. Because the debugging-enabled state may change asynchronously (e.g., a debugger attaches or detaches), merging separate call sites into one shared call may change where or when the state is observed.

I’d really rather not have yet another “target-independent” intrinsic which only actually works on a single target (AMDGPU).

I think we agree here. Since the DX12 feature spec and the libcxx function go in a similar direction would give this intrinsic more reason to be target-independent.

I’d expect that the default lowering for this intrinsic would be a compiler-rt libcall, where the libcall has an implementation along the lines of what the referenced libcxx PR does.

Your first alternative also came to my mind, i.e., a compiler-rt implementation akin to the libcxx PR for other targets and retain our specialized AMDGPU lowering.
So the intrinsic may be described/defined roughly as: ““It represents an up-to-date target-defined debugging-state observation. The backend may implement that observation using native state, a target runtime call, or false when no implementation is available.””

    __builtin_is_debugging_enabled()
                    |
                    v
  Clang emits llvm.is.debugging.enabled
                    |
            +-------+--------+-----+
            |                |     |
         AMDGPU             X86   ...
      native lowering    compiler-rt call

For example, on X86, the intrinsic-based check

 %enabled = call i1 @llvm.is.debugging.enabled()
  br i1 %enabled, label %debug, label %normal

lowers to

  declare i32 @__llvm_is_debugging_enabled()

  %raw = call i32 @__llvm_is_debugging_enabled()
  %enabled = icmp ne i32 %raw, 0
  br i1 %enabled, label %debug, label %normal

Here, libcxx then uses

 bool std::is_debugger_present() ... {
    return __builtin_is_debugging_enabled();
 }

and compiler-rt defines it similarly to the current libcxx impl.:

  int __llvm_is_debugging_enabled(void) {
  #if defined(_WIN32)
    return IsDebuggerPresent() != 0;
  #elif defined(__linux__)
    ...
    ...
  #endif
    return 0;
  }

An alternative design would be that llvm.is.debugging.enabled only lowers to something meaningful if the target has microarchitectural state to query for debugger presence […]

For this alternative, to make sure I understood you correctly, you meant something like the following?

  Clang-based native-lowering feature query (new)
     |
     +-- supported target (AMDGPU for now) --> Clang builtin (new, see above) --> the LLVM intrinsic --> native lowering
     |
     +-- unsupported target --> source/library fallback (as implemented in libcxx already)

@ldionne Friendly ping. I’d appreciate any comments on this. Particularly if you see a synergistic implementation/solution aligned with the libcxx efforts.

I’d expect that the default lowering for this intrinsic would be a compiler-rt libcall, where the libcall has an implementation along the lines of what the referenced libcxx PR does.

Would the code be duplicated in libcxx and compiler-rt? If not, wouldn’t libcxx support for the function then depend on compiler-rt?

I’m having a hard time working out the right layering, but at a high level it makes sense to me that it should be at a lower level, so e.g. non-C++ frontends can also share the implementation.

An alternative design would be that llvm.is.debugging.enabled only lowers to something meaningful if the target has microarchitectural state to query for debugger presence (as opposed to having to query the operating system), so that something like the libcxx feature would have to be implemented by using the intrinsic on supported targets and otherwise having its own implementation. If this is the case, we should be clear that this is the intended design (and probably need a way to query whether the intrinsic is “supported” on the clang side?)

If we engaged with GCC to agree on a builtin, then we could use __has_builtin from libcxx, and not have the compiler-rt dependence. Is that in line with your alternative here?

From the initial response on the libcxx review, and thinking about the layering issues a bit more, I think I would lean towards @nikic’s second suggestion:

  • A new clang builtin which is defined as only lowering to something when queryable microarch-state is available
    • Question: do we need two builtins, one to query if the other is meaningful? Is there precedent for that?
    • Question: what is the “not meaningful” result of lowering the intrinsic otherwise?
  • libcxx uses the builtin when available and meaningful, and can fallback to its current library code otherwise.
  • libcxx uses the builtin when available and meaningful, and can fallback to its current library code otherwise.

I’d prefer this exist in compiler-rt. Why would it be preferable to leak compiler implementation details into libcxx sources?

An alternative design would be that llvm.is.debugging.enabled only lowers to something meaningful if the target has microarchitectural state to query for debugger presence

Question: what is the “not meaningful” result of lowering the intrinsic otherwise?

Given this, I initially thought a design like llvm.readcyclecounter , which returns 0 on unsupported platforms, would be appropriate for this.

@nikic is that behavior generally undesirable for our new intrinsic, and we should rather come up with a definition of what meaningful/“not meaningful” is?

The purely-library code conceivably functions in libcxx regardless of the compiler, whereas pushing it into compiler-rt/libgcc is demanding an implementation from every compiler libcxx supports. At least, that was my understanding.

Just having read-write on inaccessiblemem already prevents merging two adjacent calls.

nomerge is a very exotic requirement, which e.g. would prevent something like if (x) { llvm.is.debugging.enabled } else { llvm.is.debugging.enabled } to be converted to just llvm.is.debugging.enabled. I’m not even sure when this comes up in practice, but possibly if there is some kind of binary introspection going on and you actually need to retain each distinct call-site in the binary. In any case, I don’t believe this is relevant for this intrinsic.


Regarding the over question of whether (in the cases where runtime support is necessary), this should lower to a compiler-rt builtin: I think this is in theory the best way to do it, but it runs up against the problem that most people, even when building with clang, are not actually using compiler-rt but libgcc, so this would require coordination to have the builtin there as well.

As such, the most pragmatic approach is probably to only have the builtin for the hardware-supported case, and have the rest (requiring OS queries) implemented in libcxx. The compiler-rt vs libgcc issue is a recurring pain point, and I don’t think this proposal needs to be blocked on resolving it … somehow.