Lowering AArch64 NEON vfmaq_v in Clang CIR
Lowering AArch64 NEON vfmaq_v in Clang CIR
Acronyms and Terms
AArch64: the 64-bit Arm architecture.NEON: Arm Advanced SIMD.SIMDmeans Single Instruction, Multiple Data.NEON intrinsics: compiler-recognized C/C++ APIs that express Arm NEON operations, which the backend can lower to target instructions.ACLE: Arm C Language Extensions, the C interface that exposes Arm architecture features such as NEON intrinsics.AST: Abstract Syntax Tree, Clang’s structured representation of parsed and semantically checked source code.IR: intermediate representation, a compiler-internal form between source code and machine code.LLVM IR: (originally) Low-Level Virtual Machine IR, the main intermediate representation consumed by LLVM optimizers and backends.CIR: Clang Intermediate Representation, also calledClangIR.CIR-to-LLVM: the lowering path that translates CIR into LLVM IR.CodeGen: Code Generation, the compiler stage that lowers checked source constructs into compiler IR.FMA: fused multiply-add, a floating-point operation that computesa * b + cwith one final rounding step.FileCheck: the LLVM test tool used to match expected text patterns in compiler output.MLIR: Multi-Level Intermediate Representation, the infrastructure used by CIR.RUNlines: commands embedded in LLVM test files that tell the test runner how to compile the file and check the output.builtin: a compiler-recognized operation. It may look like a function call in source or in a target header, but the compiler handles it specially instead of treating it as an ordinary library function call.vfmaq: vector fused multiply-accumulate quad form. In ACLE source, it is written accumulator-first:vfmaq(a, b, c)meansa + b * c.lowering: translating something from a higher-level source or frontend representation into a lower-level IR that later compiler stages can optimize or turn into machine code.
High-Level Overview
This note is a write-up of LLVM commit
cbf814d239b7a434d4a67606f5fbc4a234d0b51d.
The commit teaches CIR CodeGen how to lower the NEON
BI__builtin_neon_vfmaq_v builtin for AArch64. The useful part to write down
is the path from source-level NEON intrinsic, through Clang’s builtin handling,
to the final LLVM fma intrinsic, because that is what makes the patch more
interesting than the number of changed lines suggests.
In this context, to “lower” means to translate a compiler-recognized operation into the IR form used by the next compiler stage. A “builtin” is the compiler-recognized operation itself: it may be reached through a normal-looking C wrapper, but Clang handles it directly rather than treating it as an ordinary library call.
In user-facing terms, this is the builtin used by ACLE wrappers such as
vfmaq_f16, vfmaq_f32, and vfmaq_f64. The change is small in code, but it
sits at an important point in Clang’s compilation pipeline: the point where
frontend semantic information is translated into compiler IR.
A note on the commit’s stated scope: the upstream commit message is titled
“Lower vfmaq_v f32/f64” and explicitly says vfmaq_f16 is “outside this PR”,
yet the same diff adds a vfmaq_f16 test under +fullfp16. There is no
contradiction in the actual implementation. All three wrappers — vfmaq_f16,
vfmaq_f32, and vfmaq_f64 — lower through the same canonical builtin,
BI__builtin_neon_vfmaq_v, so implementing that one CIR case enables all
three at once. This post describes what the diff actually does, which is to
cover f16, f32, and f64.
Clang CodeGen
Clang does not compile C or C++ source directly into machine code. The frontend first parses the source, performs semantic analysis, and builds an AST. After that, the CodeGen stage lowers the AST into an intermediate representation that later compiler stages can optimize and translate into target-specific code.
For the traditional Clang path, CodeGen emits LLVM IR directly. For example, a
C function using a NEON intrinsic from arm_neon.h is parsed and type checked
by the frontend, and then Clang CodeGen emits LLVM IR that represents the
operation. LLVM’s optimizer and backend then lower that IR toward real AArch64
instructions.
Target builtins need special handling during CodeGen. A function such as
vfmaq_f32(a, b, c) looks like a C function call in source, but it represents a
specific NEON operation. The header maps it to a Clang builtin, and CodeGen is
responsible for lowering that builtin to the correct IR operation.
At a high level, Clang treats a builtin as a function-like operation with a
known compiler identity. The source may contain a normal-looking call, or a
wrapper in arm_neon.h may call something like __builtin_neon_vfmaq_v.
During parsing and semantic analysis, Clang recognizes that builtin name and
records it as a call to a specific builtin ID rather than as an unresolved
external function. Later, CodeGen switches on that builtin ID and emits the IR
sequence that represents the operation for the target.
What CIR Adds
CIR is about giving Clang a middle layer between the AST and LLVM IR. Classic CodeGen lowers C/C++ semantics directly into LLVM IR, which is already fairly low-level. CIR is a newer CodeGen path that may eventually replace parts of that direct AST-to-LLVM IR path. It keeps more source-language meaning alive for longer, using MLIR infrastructure for verification, passes, and staged lowering, while making the lowering pipeline easier to inspect and evolve.
CIR is an MLIR-based intermediate representation emitted by Clang before lowering to LLVM IR. Instead of translating the AST directly to LLVM IR, the CIR path translates it to CIR first:
C/C++ source
-> Clang AST
-> CIR
-> LLVM IR
-> LLVM optimization and backend
-> machine code
This makes CIR-to-LLVM the bridge from ClangIR into the existing LLVM pipeline.
If a builtin is not implemented in CIR CodeGen, compiling with -fclangir can
fail even when normal LLVM IR CodeGen already supports the same source program.
That is the gap this commit addresses for BI__builtin_neon_vfmaq_v.
CodeGen Tests Under clang/test/CodeGen/AArch64/
The tests under clang/test/CodeGen/AArch64/ are regression tests for the
frontend CodeGen stage. They compile small C functions and use FileCheck to
verify the generated IR.
Historically, many AArch64 NEON tests lived in broad files such as:
clang/test/CodeGen/AArch64/neon-intrinsics.cclang/test/CodeGen/AArch64/v8.2a-neon-intrinsics.c
Those files mainly check the traditional direct-to-LLVM CodeGen path.
The CIR-enabled tests being used here live under:
clang/test/CodeGen/AArch64/neon/
These files are organized by intrinsic family, and the relevant tests include RUN lines for both direct LLVM CodeGen and ClangIR. A typical CIR-enabled test can check three related outputs:
- direct LLVM IR CodeGen, without
-fclangir - CIR-to-LLVM output, with
-fclangir -emit-llvm - raw CIR output, with
-fclangir -emit-cir
This is useful because the direct LLVM path is the historical behavior, while the CIR paths prove that ClangIR emits equivalent semantics and also produces the expected CIR operations.
In this commit, the existing vfmaq coverage was moved into CIR-enabled test
files:
vfmaq_f32andvfmaq_f64moved intoclang/test/CodeGen/AArch64/neon/fused-multiply.cvfmaq_f16moved intoclang/test/CodeGen/AArch64/neon/fused-multiple-fullfp16.c(the filename uses “multiple” upstream, which is a typo for “multiply”; the blog reproduces the exact filename so paths match the tree)
The old checks were preserved for LLVM IR, and new CIR checks were added. The move is not just file cleanup: it lets one focused test file verify both the existing direct LLVM CodeGen behavior and the new CIR implementation.
Detailed Walkthrough
What vfmaq_v Represents
The ACLE vfmaq_* intrinsics are vector fused multiply-accumulate operations.
At a high level:
vfmaq(a, b, c)
means:
a + b * c
with fused floating-point semantics. “Fused” means the multiply and add are treated as one operation for rounding purposes, rather than rounding after the multiply and then rounding again after the add.
The q in vfmaq means this is the quad-vector form. The covered wrappers
operate on these vector shapes:
vfmaq_f16: 8 lanes of half precision, represented in LLVM IR as<8 x half>vfmaq_f32: 4 lanes of single precision, represented as<4 x float>vfmaq_f64: 2 lanes of double precision, represented as<2 x double>
Although the C wrappers have different element types, they lower through the
same canonical Clang builtin: BI__builtin_neon_vfmaq_v.
Operand Order
The NEON intrinsic and the LLVM intrinsic express the same mathematical operation, but their operand order is different.
The NEON wrapper is accumulator-first:
vfmaq(a, b, c) // a + b * c
This is not just an interpretation of the ACLE name. The existing traditional
CodeGen path has an explicit comment for the same builtin in
clang/lib/CodeGen/TargetBuiltins/ARM.cpp:
// NEON intrinsic puts accumulator first, unlike the LLVM fma.
return emitCallMaybeConstrainedFPBuiltin(
*this, Intrinsic::fma, Intrinsic::experimental_constrained_fma, Ty,
{Ops[1], Ops[2], Ops[0]});
That code handles BI__builtin_neon_vfmaq_v by passing operand 0 last to LLVM
fma, which identifies operand 0 as the accumulator. The generated
arm_neon.h wrapper supports the same reading: vfmaq_f32(__p0, __p1, __p2)
forwards __p0, __p1, and __p2 to __builtin_neon_vfmaq_v in that order.
In arm_neon.td, this builtin is generated by three SInst<"vfma", "....", X>
definitions, one per element type:
def VFMA : SInst<"vfma", "....", "fQf">; // f32, single + quad
def FMLA : SInst<"vfma", "....", "dQd">; // f64, scalar + quad (AArch64)
def VFMAH : SInst<"vfma", "....", "hQh">; // f16, single + quad (+fullfp16)
The third string is a list of type modifiers. Lower-case f/d/h is the
non-q form, and the Q-prefixed variant is the quad form, which is what
generates vfmaq_f32, vfmaq_f64, and vfmaq_f16. All three feed the same
BI__builtin_neon_vfmaq_v builtin, which is why one CIR case is enough to
cover all three wrappers.
LLVM’s fma intrinsic is multiply-operands-first:
llvm.fma(b, c, a) ; b * c + a
So the lowering cannot simply forward the operands in source order. It must reorder them.
CIR Lowering
Before this commit, both vfma_v and vfmaq_v shared the same fall-through in
emitCommonNeonBuiltinExpr and ended up on a generic unimplemented path,
which is why building any user code that called these wrappers with
-fclangir failed. The relevant pre-patch fragment looked like this:
case NEON::BI__builtin_neon_vfma_v:
case NEON::BI__builtin_neon_vfmaq_v:
// ... fell through with other unimplemented cases ...
The patch splits these two cases apart. vfma_v becomes an explicit
errorNYI, which keeps the diagnostic clean for the form that is still
unimplemented, while vfmaq_v gets a dedicated implementation:
case NEON::BI__builtin_neon_vfma_v:
cgf.cgm.errorNYI(expr->getSourceRange(),
std::string("unimplemented AArch64 builtin call: ") +
ctx.BuiltinInfo.getName(builtinID));
return mlir::Value{};
case NEON::BI__builtin_neon_vfmaq_v: {
// ... new implementation, shown below ...
}
The new vfmaq_v implementation bitcasts all three operands to the expected
result vector type and then emits the LLVM fma intrinsic with reordered
operands:
mlir::Value op0 = cgf.getBuilder().createBitcast(ops[0], ty);
mlir::Value op1 = cgf.getBuilder().createBitcast(ops[1], ty);
mlir::Value op2 = cgf.getBuilder().createBitcast(ops[2], ty);
llvm::SmallVector<mlir::Value> fmaOps = {op1, op2, op0};
return emitCallMaybeConstrainedBuiltin(cgf.getBuilder(), loc, "fma", ty,
fmaOps);
Here:
ops[0]is the accumulatoraops[1]is the first multiplicandbops[2]is the second multiplicandc- the emitted intrinsic receives
{b, c, a}
That preserves the source-level NEON semantics while using LLVM’s canonical
fma operation.
Why the Bitcasts Are There
The tests show several bitcasts before the final llvm.fma.* call. This is
normal for NEON CodeGen. NEON vector values often pass through canonical
integer or byte-vector forms while Clang normalizes builtin operands. Before
calling the floating-point LLVM intrinsic, CodeGen must recover the expected
floating-point vector type.
For example, vfmaq_f32 ultimately emits an LLVM intrinsic of this shape:
call <4 x float> @llvm.fma.v4f32(...)
Similarly:
call <8 x half> @llvm.fma.v8f16(...)
call <2 x double> @llvm.fma.v2f64(...)
The important semantic check is that the final fma call receives the
multiplicands first and the accumulator last.
How the Tests Prove the Change
The moved tests prove three things at once, one per RUN line:
- the existing direct LLVM CodeGen behavior remains unchanged — checked by the
first
RUNline, without-fclangir, against theLLVMprefix - CIR-to-LLVM produces matching LLVM IR — checked by the second
RUNline, with-fclangir -emit-llvm, against the sameLLVMprefix (so the same check lines must hold for both pipelines) - the new CIR implementation emits the expected CIR operation — checked by the
third
RUNline, with-fclangir -emit-cir, against theCIRprefix
For direct LLVM CodeGen and CIR-to-LLVM, the tests check the final LLVM intrinsic call. For raw CIR output, they check the CIR intrinsic operation. A representative CIR check looks like this:
// CIR: cir.call_llvm_intrinsic "fma" %, %, % :
A representative LLVM check looks for the typed LLVM intrinsic:
// LLVM-NEXT: [[FMA:%.*]] = call <4 x float> @llvm.fma.v4f32(...)
That combination catches both sides of the implementation: whether CIR can represent the builtin, and whether CIR-to-LLVM produces the same kind of LLVM IR as the traditional CodeGen path.
From LLVM IR to AArch64 Assembly
The commit stops at LLVM IR tests, but that IR is not the end of the compiler pipeline. LLVM IR is still target-independent enough that it does not name a specific AArch64 instruction. The AArch64 backend is responsible for turning it into real ARM64 assembly.
For the vfmaq_f32 case moved into
clang/test/CodeGen/AArch64/neon/fused-multiply.c, the important LLVM check
from the commit is:
// LLVM-NEXT: [[FMA:%.*]] = call <4 x float> @llvm.fma.v4f32(
// LLVM-SAME: <4 x float> [[B_CAST]],
// LLVM-SAME: <4 x float> [[C_CAST]],
// LLVM-SAME: <4 x float> [[A_CAST]])
The exact FileCheck formatting in the test is more compact, but the important semantic point is this:
call <4 x float> @llvm.fma.v4f32(b, c, a)
This LLVM intrinsic means:
b * c + a
That is the same operation as:
vfmaq_f32(a, b, c)
because the NEON wrapper is accumulator-first while LLVM fma is
multiply-operands-first.
Backend Lowering
After Clang emits LLVM IR, the LLVM backend takes over. In broad strokes, the remaining path is:
LLVM IR
-> LLVM optimization passes
-> AArch64 instruction selection
-> register allocation
-> assembly printing
-> assembler/object code
Instruction selection is the stage that recognizes an operation such as:
%f = call <4 x float> @llvm.fma.v4f32(<4 x float> %b,
<4 x float> %c,
<4 x float> %a)
and chooses an AArch64 instruction that implements it. For four single-precision
floating-point lanes, the natural AArch64 NEON instruction is fmla on a
.4s vector:
fmla v0.4s, v2.4s, v1.4s
An “explain it like I’m five” version is:
LLVM IR says: "do a four-lane fused multiply-add."
The AArch64 backend says: "on this machine, that is an fmla instruction."
Why the Assembly Looks Slightly Different
At first glance, the assembly can look like it changed the operand order again. For a small standalone LLVM IR function like this:
define <4 x float> @test(<4 x float> %a,
<4 x float> %b,
<4 x float> %c) {
entry:
%f = call <4 x float> @llvm.fma.v4f32(<4 x float> %b,
<4 x float> %c,
<4 x float> %a)
ret <4 x float> %f
}
llc can produce:
test:
fmla v0.4s, v2.4s, v1.4s
ret
The reason this is still correct is the AArch64 calling convention and the
shape of the fmla instruction.
For this function, the incoming vector arguments are naturally assigned to vector registers:
%a -> v0
%b -> v1
%c -> v2
The AArch64 fmla vector instruction updates its destination register:
Vd = Vd + Vn * Vm
So:
fmla v0.4s, v2.4s, v1.4s
means:
v0 = v0 + v2 * v1
The backend has placed the two multiplicand operands in the two source slots of
the fmla instruction. The important correctness requirement is that the
accumulator/addend is still v0, which is the original %a, and the result is
also returned in v0.
That gives the same source-level meaning:
a + b * c
This is the connection between the commit and the final machine instruction:
the CIR lowering added in CIRGenBuiltinAArch64.cpp makes sure the LLVM IR has
the canonical llvm.fma.v4f32(b, c, a) form. Once that IR exists, the AArch64
backend can select the actual NEON fmla SIMD instruction.
Code References
The main implementation reference is the new
BI__builtin_neon_vfmaq_v case in
clang/lib/CIR/CodeGen/CIRGenBuiltinAArch64.cpp, already shown in full under
“CIR Lowering” above. It bitcasts the three operands to the result vector
type, builds fmaOps = {op1, op2, op0}, and emits the shared "fma"
intrinsic via emitCallMaybeConstrainedBuiltin.
The test reference is
clang/test/CodeGen/AArch64/neon/fused-multiply.c. The vfmaq_f32 test calls
the source-level wrapper:
return vfmaq_f32(a, b, c);
and checks that LLVM IR receives the operands as:
@llvm.fma.v4f32([[B_CAST]], [[C_CAST]], [[A_CAST]])
The classic CodeGen reference is
clang/lib/CodeGen/TargetBuiltins/ARM.cpp, which already had the same operand
rule for the direct LLVM CodeGen path:
// NEON intrinsic puts accumulator first, unlike the LLVM fma.
return emitCallMaybeConstrainedFPBuiltin(
*this, Intrinsic::fma, Intrinsic::experimental_constrained_fma, Ty,
{Ops[1], Ops[2], Ops[0]});
Summary
The commit adds CIR support for the canonical AArch64 NEON
BI__builtin_neon_vfmaq_v builtin. That enables the ClangIR path for the
vfmaq_f16, vfmaq_f32, and vfmaq_f64 vector quad fused
multiply-accumulate wrappers.
The central point is operand mapping: NEON spells the operation as
accumulator-first, while LLVM’s fma intrinsic expects the two multiplicands
first and the accumulator last. The lowering performs that reordering and emits
the shared fma intrinsic, while the moved tests verify the result through
both direct LLVM CodeGen and CIR-based CodeGen.