Dialect notes #
Where LLVM-rs deliberately differs from upstream, and why. Anything not listed here is meant to match; a difference found in the wild is a bug until it appears in this file with a reason.
The dialect is LLVM 21 with opaque pointers.
Accepted on purpose #
The ; ModuleID = '...' comment is read, not ignored. Upstream treats it
as a comment and regenerates it from the input path, so llvm-dis a.bc and
llvm-dis b.bc of the same module differ in line 1. Keeping it makes a round
trip exact and makes a corpus file self-describing. Nothing downstream reads
it.
Unnamed values and blocks may be numbered non-consecutively. Upstream
renumbers on output and, for values, rejects a gap. We keep a map from the
number as written and assign print slots independently, which accepts
strictly more input and changes nothing for canonical files. The cost is that
we accept some modules upstream rejects, which shows up in the
llvm-upstream-assembler count.
Modelled syntactically rather than semantically #
Specialized debug-info nodes. !DISubprogram(...) keeps its tag and its
fields as written, in order, rather than becoming a typed object. Every field
survives a round trip, including ones this tier has never heard of. Typed
debug info belongs in llvm-debuginfo at T1.
Attributes with bespoke argument grammars. memory(...), captures(...),
nofpclass(...), uwtable(...), allockind(...) and initializes(...) keep
their argument text. The keyword itself is checked, so an attribute upstream
adds is a loud error rather than a silent drop; only the inside of the
parentheses is uninterpreted.
Not accepted #
Typed pointers. i8* is an error naming the dialect, not an alias for
ptr. PLAN.md section 1.2.
Constant expressions upstream removed. The removals were selective, and
measuring beat guessing: add, sub and xor still parse in LLVM 21,
while mul, and, or, shl, icmp and select do not. What remains
here is exactly that set plus getelementptr, the surviving casts,
extractelement, insertelement and shufflevector; anything else is an
error naming the opcode.
A numbered metadata string. !0 = !"text" is refused, because upstream
refuses it: a string is an operand and a node definition is a tuple or a
specialized node. Accepting it would let us print something llvm-as will
not read back, which the builder smoke test caught.
Use-list order directives. uselistorder is an error. Re-emit without
-preserve-ll-uselistorder.
Filled in rather than carried #
Two things upstream computes when the text leaves them out, and prints either way, so a module that omits them and one that spells them out are the same module:
- Alignment. An
allocawith noaligntakes the preferred alignment of its type from the data layout; aload,store,cmpxchgoratomicrmwtakes the ABI alignment. The parser fills these in, which is why the verifier's "has no alignment" rule is unreachable from text. - Function attribute groups. Upstream never prints function attributes inline: it hoists every distinct set into a numbered group and writes a reference. The printer builds that table itself, in upstream's discovery order (globals, then functions, then the call sites of each body), rather than echoing whichever groups the input happened to have.
Uniqued rather than kept #
Metadata nodes are uniqued: two structurally identical non-distinct nodes are
one node, and distinct is the keyword that opts out. A module that writes
the same tuple twice prints it once with both references pointing at the
survivor, and node numbers come from walking the module rather than from the
input. DIExpression and DIArgList are never numbered at all; they print
in place at every use.
We do this at print time rather than at parse time. The distinction is real but invisible from outside: nothing between parsing and printing needs uniqued metadata yet, and keeping the parsed numbering makes a parse error easier to trace back to the text that caused it.
Looser than it looks #
Three places where upstream accepts what a reading of LangRef would refuse, and we follow it because a compatibility project follows the implementation:
- An attribute group nothing defines is an empty set.
define void @f() #0with noattributes #0parses, and prints with no attributes at all. - A call need not match the signature its callee was declared with.
Opaque pointers put the signature at the call site, so
call void @g()againstdeclare void @g(i32)is accepted, and so is a call whose result type differs from the declaration's. - An instruction after a terminator opens a new anonymous block. Five
invokes written one after another with no labels between them are five blocks, not one block with five terminators.
Each of these was a verifier rule here first, found by the upstream suites rejecting IR that upstream accepts.
Known gaps, measured #
llvm-upstream-assembler and llvm-upstream-verifier run upstream's own
suites and hold an agreement count. The two ratchets are the honest number,
and the gap is a to-do list rather than a divergence. As of 2026-07-27 the
recurring reasons are:
- Semantic rules the verifier does not have yet, which is most of the Verifier suite: we accept modules upstream rejects.
- Structural checks the parser does not make: duplicate symbol definitions, alignment bounds, attribute argument validation.
- Syntax outside this tier: module summary index entries (
^0 = ...), target-specific calling conventions, metadata integers wider than 64 bits, and thealign(16)spelling of a parameter attribute.
Each of those is a bug, not a decision. When one is fixed the ratchet moves up in the same commit. Two passes so far have taken the Assembler suite from 146 to 175 and the Verifier suite from 70 to 117, by adding the structural rules the parser was missing and then the semantic ones that come in clusters: which types can be stored, which may only cross an intrinsic boundary, and what shape the globals upstream reserves have to have.
Checked syntactically rather than semantically #
Specialized metadata nodes have a grammar and we enforce it. Debug info
is modelled syntactically here, which was in danger of meaning that
!DILocation(bad: 0, line: 1, line: 2) parsed happily. Upstream treats the
shape of these nodes as a parser matter rather than a verifier one, so
crates/llvm-ir-parse/src/md_schema.rs carries a table: which field names
each node kind has, which are required, which may not be null or empty,
which have a numeric range, and which nodes have to be written distinct.
What that table deliberately does not carry is the DWARF vocabulary. A
tag: field holding DW_TAG_badtag is an error upstream and is accepted
here, because rejecting it needs a list of every valid DW_TAG_*,
DW_LANG_* and DIFlag*, and no specification we are allowed to read
enumerates them. Guessing the list would reject valid input, which is the
worse of the two failures.
Checked for intrinsics only #
A call is not compared against its callee's declaration, unless the
callee is an intrinsic. Opaque pointers put the signature at the call
site, and real llvm-as accepts call void @g() against declare void @g(i32), so a general rule here would reject IR upstream reads. An
intrinsic is the exception: it is selected by its name and its mangled
suffix together, so call void @llvm.made.up.name.i32(i32 1, i32 2)
against a one-argument declaration names something that does not exist,
and upstream says so.
Tied rather than fixed #
Positions of an intrinsic that share one overloaded type have to agree
about what it is. LangRef documents an overloaded intrinsic once per
instantiation, so two positions whose types vary together across all of
them are one type rather than two: llvm.umax is i32, i32 -> i32 and
<4 x i32>, <4 x i32> -> <4 x i32>, so llvm.umax(i8 0, i16 1) names no
instantiation there is. corpus/intrinsic-overloads.nu measures which
positions those are, counting the result as position nought.
The conclusion is drawn narrowly. A position whose type never varies is
fixed rather than tied, and table::signature is what states those, so
llvm.ctlz returns what its first argument is and takes an i1 second
whatever the first is. An intrinsic LangRef documents once says nothing:
two positions agreeing in the only instantiation written down is not
evidence they are one type.
Upstream reports the mismatch in two places and so does this. A call to an intrinsic nothing declares is a parse error, there being no declaration left to build from it; a declaration written out with the same mismatch parses and fails the verifier.
Replaced rather than carried #
An intrinsic's attributes are the intrinsic's, not the module's.
Upstream reads whatever a declaration was written with and puts the
intrinsic's own set there instead, parameter attributes included, so
declare void @llvm.assume(i1 nonnull) #7 comes back
declare void @llvm.assume(i1 noundef) with #7's contents gone. So does
this, from the table corpus/intrinsic-attributes.nu measures by writing
each declare line LangRef documents and reading back what upstream
replaced it with.
Two limits are deliberate, and both err towards writing nothing rather than writing something upstream would not. A declaration whose types are not the intrinsic's is not that intrinsic, and upstream leaves it alone: this leaves it alone too, but can only check the argument positions LangRef pins, so where the fit cannot be told the attributes are left off. And a variadic intrinsic is skipped entirely, there being no arity to check a declaration against. That is four intrinsics.
Preserved rather than regenerated #
The ThinLTO summary index prints back what was written. llvm-dis
rebuilds the index from the bitcode instead: it takes the module path and
hash from the file it read, fills in defaults it knows about
(visibility: default, importType: definition), appends a blockcount
entry and a ; guid = N comment. None of that is reproducible from the
text alone, and reproducing it means modelling ThinLTO rather than its
syntax. So the corpus cannot pin this one and a dedicated round-trip test
holds the property that is ours: what we print is what was written.
Bytes where the bytes come from outside #
Metadata text is bytes; the rest is UTF-8.
!DIFile(filename: "\00\01\80\FF") and !\FFfoo are modules llvm-as
reads, and they round-trip here now: llvm_ir::ByteString holds them, and
it compares equal to a &str so that attachment.kind == "prof" still
reads the way it did.
The line is drawn deliberately rather than by what the tests happened to
cover. Debug info carries file paths, and a path is not text on every
system, so MdField::Str, Metadata::String, MdOperand::String,
NamedMetadata::name and MdAttachment::kind are bytes. Symbol names,
section names, attribute keys and values, summary strings and block labels
are still String: those are identifiers the compiler chose, and a
non-UTF-8 one is now a parse error that says so rather than a lexer
accident. If a real module ever carries one, the error names the place.
Refused although upstream reads it #
Typed-pointer syntax. llvm-as in LLVM 21 still parses i8* and folds
it to ptr; the typed-pointer type system is gone but the spelling
survives as a parse-level alias. This dialect refuses it instead, which
PLAN §1.2 asks for. It is measured rather than assumed: three files in
upstream's suites are refused here for that reason alone, and they are
counted in the "refused but valid" column in STATUS.md rather than written
off.