[RFC] Enforce Single-Operand Format for All .enable Metadata Nodes

Background

This RFC proposes that all .enable metadata
nodes must use a single-operand format (the name string
only, no boolean operand). As a consequence, this RFC also proposes
new .disable nodes for a few.

As LangRef stands today, no .enable node enforces this. The following nodes
are present in LangRef with an i1 boolean (two
operands):

  1. llvm.loop.vectorize.enable
  2. llvm.loop.vectorize.predicate.enable
  3. llvm.loop.vectorize.scalable.enable
  4. llvm.loop.distribute.enable

While the following are without bool (single
operand) as per LangRef:

  1. llvm.loop.unroll.enable
  2. llvm.loop.unroll.disable
  3. llvm.loop.unroll_and_jam.enable
  4. llvm.loop.unroll_and_jam.disable
  5. llvm.loop.unroll.runtime.disable
  6. llvm.loop.licm_versioning.disable
  7. llvm.licm.disable

As one can notice, there is .enable and .disable version for
llvm.loop.unroll but enablement/disablement is done via second argument
for llvm.loop.vectorize.enable. This is inconsistent and causes
unnecessary confusion.

Moreover, the shared reader function
getOptionalBoolLoopAttribute silently accepts both forms
for every .enable node. This means nothing prevents
!{!"llvm.loop.unroll.enable", i1 1} from appearing in IR,
and indeed such forms exist in the wild.

This RFC proposes enforcing that every .enable node takes exactly one operand
(the name string), eliminating the boolean operand entirely.

The below PR tried to add checks considering the current
state of LangRef
, but during review we felt that it is
better to fix the inconsistencies than point fixes.


Motivation/Why?

1. No .enable node enforces a single-operand format

All .enable metadata nodes are read through the same
helper, getOptionalBoolLoopAttribute, which silently accepts
both the single-operand form and the two-operand boolean form:

This means every .enable node — including those that are
conventionally emitted as single-operand — will happily
accept a boolean. For example, the following are all legal
today with no verifier rejection:

!{!"llvm.loop.unroll.enable"}             ; typical form
!{!"llvm.loop.unroll.enable", i1 1}       ; also accepted
!{!"llvm.loop.vectorize.enable", i1 1}    ; typical form
!{!"llvm.loop.vectorize.enable", i1 0}    ; "enable = false"

Some nodes (e.g., llvm.loop.vectorize.enable) are
conventionally emitted with a boolean by Clang and in-tree
code, while others (e.g., llvm.loop.unroll.enable) are
typically emitted without one. But this is merely convention,
not enforcement — nothing prevents either form from appearing
for any node.

2. Confusing double-negative semantics

The boolean form allows constructs like:

!0 = !{!"llvm.loop.distribute.enable", i1 0}

This reads as “enable distribution = false”, which is a confusing
double negative. Is it the same as “disable distribution”? Is it
the same as the attribute being absent? The existing helper
getOptionalBoolLoopAttribute treats i1 0 as “attribute set to
false” while absence means “unspecified”, creating a three-valued
logic that is error-prone and hard to reason about.

3. Malformed IR in the wild

Tests and frontends have produced metadata with incorrect types
(e.g., i32 instead of i1) or incorrect operand counts. Without
strict verifier enforcement, these go undetected and can lead to
crashes in passes (see
#156685
where opt -passes=loop-distribute crashes on malformed metadata).

A single canonical form is easier to verify and less prone to
encoding errors.

4. IR should have one canonical form

As noted in the review discussion:

“This is not a place for user flexibility, this is an IR. There
should be one true form that every consumer can rely on.”

@arsenm


Current State

The following table summarizes all loop metadata nodes with
enable/disable semantics today. The “Boolean operand in
LangRef?” column shows what the upstream LangRef
documents as the canonical format.

# Metadata Boolean operand in LangRef?
1 llvm.loop.unroll.enable No
2 llvm.loop.unroll.disable No
3 llvm.loop.unroll.runtime.disable No
4 llvm.loop.unroll_and_jam.enable No
5 llvm.loop.unroll_and_jam.disable No
6 llvm.loop.vectorize.enable Yes
7 llvm.loop.vectorize.predicate.enable Yes
8 llvm.loop.vectorize.scalable.enable Yes
9 llvm.loop.distribute.enable Yes
10 llvm.loop.licm_versioning.disable No
11 llvm.licm.disable No

Rows 6–9 are documented in LangRef with an i1 boolean
operand (e.g., !{!"llvm.loop.vectorize.enable", i1 1}).
Rows 1–5 and 10–11 are documented as single-operand
(e.g., !{!"llvm.loop.unroll.enable"}). But because all
nodes are read through getOptionalBoolLoopAttribute, every
node accepts a boolean at runtime with no verifier
rejection.

Additionally, the following enable/disable nodes exist in
the implementation but are not documented in LangRef:

  • llvm.loop.pipeline.disable — takes i1 boolean
  • llvm.loop.unswitch.partial.disable — single-operand
  • llvm.loop.unswitch.injection.disable — single-operand

These are out of scope for this RFC but should be documented
in LangRef as a separate cleanup.


Proposed Changes

All .enable nodes become single-operand

Every .enable loop metadata node to take exactly
one operand
— the name string. No boolean operand is
permitted.

This applies to all .enable nodes, not just the ones that
conventionally carry a boolean today. Nodes like
llvm.loop.unroll.enable that are typically emitted as
single-operand will now be required to be single-operand.

For nodes that previously used i1 0 on an .enable
node to mean “disable”, an explicit .disable counterpart
is introduced.

Before (accepted today for any .enable node):

!{!"llvm.loop.unroll.enable"}              ; ok
!{!"llvm.loop.unroll.enable", i1 1}        ; also accepted
!{!"llvm.loop.vectorize.enable", i1 1}     ; enable
!{!"llvm.loop.vectorize.enable", i1 0}     ; disable

After (proposed — single-operand enable/disable pairs):

!{!"llvm.loop.unroll.enable"}              ; enable unrolling
!{!"llvm.loop.vectorize.enable"}           ; enable vectorization
!{!"llvm.loop.vectorize.disable"}          ; disable vectorization

Affected .enable nodes

The following table lists every .enable node and the
change. For nodes that were conventionally emitted without a
boolean, the change is enforcement only (rejecting the
boolean form). For nodes conventionally emitted with a
boolean, the boolean is dropped and a new .disable
counterpart is introduced.

Node Change
llvm.loop.unroll.enable Enforce single-operand (reject i1)
llvm.loop.unroll_and_jam.enable Enforce single-operand (reject i1)
llvm.loop.vectorize.enable Drop boolean; Introduce vectorize.disable
llvm.loop.vectorize.predicate.enable Drop boolean; Introduce predicate.disable
llvm.loop.vectorize.scalable.enable Drop boolean; Introduce scalable.disable
llvm.loop.distribute.enable Drop boolean; Introduce distribute.disable

New .disable nodes

The following new single-operand .disable nodes are
introduced to replace the ..enable, i1 0 pattern:

New node Replaces
llvm.loop.vectorize.disable llvm.loop.vectorize.enable, i1 0
llvm.loop.vectorize.predicate.disable llvm.loop.vectorize.predicate.enable, i1 0
llvm.loop.vectorize.scalable.disable llvm.loop.vectorize.scalable.enable, i1 0
llvm.loop.distribute.disable llvm.loop.distribute.enable, i1 0

Semantics: three-valued logic preserved

The current three-valued interpretation remains valid:

State Meaning
.enable present User explicitly requests the transformation
.disable present User explicitly suppresses the transformation
Neither present Unspecified; pass uses its own heuristics

This maps cleanly onto the existing TransformationMode
enum:

  • TM_ForcedByUser.enable present
  • TM_SuppressedByUser.disable present
  • TM_Unspecified ↔ neither present

Implementation Plan

I plan to do the changes in four phases, one per node
that currently carries a boolean operand (rows 6–9 in the
table above). Each phase is an independent patch series
that can be reviewed and landed separately, limiting blast
radius.

Phase 1 — llvm.loop.distribute.enable

  1. Introduce llvm.loop.distribute.disable
    (single-operand).
  2. Update readers/callers in the loop-distribute pass to
    recognise the new .disable node.
  3. Update Clang to emit the single-operand .enable and
    the new .disable node.
  4. Update LangRef.
  5. Migrate all in-tree tests.
  6. Add a verifier check that rejects the boolean form.

Phase 2 — llvm.loop.vectorize.enable

  1. Introduce llvm.loop.vectorize.disable (single-operand).
  2. Update getOptionalBoolLoopAttribute and all callers in
    the vectorizer to recognise the new .disable node.
  3. Update Clang to emit llvm.loop.vectorize.enable as
    single-operand and llvm.loop.vectorize.disable instead
    of ..enable, i1 0.
  4. Update LangRef to document the new format and deprecate
    the boolean form.
  5. Migrate all in-tree tests.
  6. Add a verifier check that rejects the boolean form for
    llvm.loop.vectorize.enable.

Phase 3 — llvm.loop.vectorize.predicate.enable

  1. Introduce llvm.loop.vectorize.predicate.disable
    (single-operand).
  2. Update readers/callers in the vectorizer to recognise
    the new .disable node.
  3. Update Clang to emit the single-operand .enable and
    the new .disable node.
  4. Update LangRef.
  5. Migrate all in-tree tests.
  6. Add a verifier check that rejects the boolean form.

Phase 4 — llvm.loop.vectorize.scalable.enable

  1. Introduce llvm.loop.vectorize.scalable.disable
    (single-operand).
  2. Update readers/callers in the vectorizer to recognise
    the new .disable node.
  3. Update Clang to emit the single-operand .enable and
    the new .disable node.
  4. Update LangRef.
  5. Migrate all in-tree tests.
  6. Add a verifier check that rejects the boolean form.

Final cleanup

After all four phases land:

  1. Remove the case 2: (boolean) handling from
    getOptionalBoolLoopAttribute so that any residual
    two-operand .enable metadata is caught at parse time.
  2. Add a blanket verifier rule: every .enable node must
    have exactly one operand.

Proof-of-Concept:

A working POC for Phase 1 (llvm.loop.distribute.enable) is available at the PR

This branch demonstrates the end-to-end change pattern that
each phase will follow. In summary it:

  1. Introduces llvm.loop.distribute.disable as a
    single-operand node, replacing the old
    !{!"llvm.loop.distribute.enable", i1 0} pattern.

  2. Updates Clang codegen (CGLoopInfo.cpp) to emit
    llvm.loop.distribute.enable without a boolean operand
    and to emit llvm.loop.distribute.disable instead of
    ..enable, i1 0.

  3. Updates LLVM passes and analysis
    LoopDistribute.cpp and LoopAccessAnalysis.cpp now
    query for .enable and .disable as separate
    single-operand nodes via getBooleanLoopAttribute,
    replacing the old findStringMetadataForLoop +
    ConstantInt extraction.

  4. Updates LoopUtils.cpp so that
    hasDistributeTransformation checks for the new
    .disable node and returns TM_SuppressedByUser.

  5. Updates LoopConstrainer.cpp to emit
    llvm.loop.distribute.disable instead of
    ..enable, i1 0 when disabling all loop opts on
    pre/post loops created by IRCE.

  6. Updates LangRef to document the single-operand
    .enable/.disable pair for llvm.loop.distribute.

  7. Updates MLIR loop-annotation import/export to
    round-trip the new format correctly.

  8. Migrates all affected in-tree tests (34 files)
    across Clang, LLVM, and MLIR.

(The PR is currently marked as Draft but once I see consensus,
I will open it for review.)

This branch serves as the template for Phases 2–4; each
will follow the same pattern for its respective node.


Alternatives Considered

Fused boolean form: keep only .enable with i1

An alternative is to go the opposite direction: make
every .enable node take a mandatory i1 boolean and
remove all existing .disable nodes. Under this scheme,
enablement and disablement would both be expressed through
a single node:

!{!"llvm.loop.vectorize.enable", i1 1}   ; enable
!{!"llvm.loop.vectorize.enable", i1 0}   ; disable
!{!"llvm.loop.unroll.enable", i1 1}      ; enable
!{!"llvm.loop.unroll.enable", i1 0}      ; disable

I am open to listen opinions of this approach but I am not
keen on it due to the following reasons:

  1. Double-negative confusion.
    !{!"llvm.loop.distribute.enable", i1 0} reads as
    “enable = false”, which is a double negative. In
    contrast, !{!"llvm.loop.distribute.disable"} states
    the intent directly and unambiguously.

  2. Existing convention already uses .disable nodes.
    Upstream already has llvm.loop.unroll.disable,
    llvm.loop.unroll_and_jam.disable,
    llvm.loop.unroll.runtime.disable,
    llvm.loop.licm_versioning.disable, and
    llvm.licm.disable. Moving away from .disable nodes
    would require changing all of these, breaking more
    existing IR and tests for no readability gain.

  3. Less self-documenting IR. When reading a metadata
    dump, !{!"llvm.loop.vectorize.disable"} communicates
    intent at a glance, while
    !{!"llvm.loop.vectorize.enable", i1 0} requires the
    reader to parse the boolean and mentally negate the
    name.

The single-operand .enable/.disable pair proposed in
this RFC is more consistent with existing upstream
conventions and produces clearer, more readable IR.

Looking for opinions, suggestions and comments.

@arsenm @Meinersbur


References

Assisted by: Claude Opus 4.6

@arsenm @Meinersbur Ping!

Adding @nikic in case he has any thoughts.

I’m fine with this direction. I agree that we should have consistency here. I don’t have a super strong opinion on .enable+.disable vs .enable with argument, but I’m inclined to say that the former is preferable due to the tri-state being represented here (it’s really “transform is force-enabled”, “transform is force-disabled” and “transform is driven by heuristics”).

cc @fhahn as the new .disable nodes would be mostly in the loop vectorizer.

By the way, was this RFC written with AI assistance? I felt like there was a lot of unnecessary repetition in the text, and the RFC could have been a lot more compact.

By the way, was this RFC written with AI assistance? I felt like there was a lot of unnecessary repetition in the text, and the RFC could have been a lot more compact.

Yes, apologies. I should have put “Assisted by:” line at the end. Done now.

Overall making this uniform across passes seems great to me.

I think a better takeaway would be to in the future try to manually edit RFCs for brevity to make it easier for readers.

1 Like

PR for the first phase - Enforce single-operand form for llvm.loop.distribute metadata by madhur13490 · Pull Request #201077 · llvm/llvm-project · GitHub