[RFC] Re-vectorisation to wider vectors in LoopVectorizer

Proposal

Hi all,

We would like to discuss upstreaming support for re-vectorisation in the LoopVectorizer. AArch64 NEON-to-SVE is the initial motivating target, but this could also apply to other targets that have legacy vector ISAs.

The short version is that many loops in real code already operate on fixed-length vectors, often because they were written using target intrinsics. Today the LoopVectorizer ignores such loops as already-vectorised and does not try to widen them further.

For Arm, this misses an important migration path: there is a lot of existing code written for NEON, and being able to turn fixed-length NEON-style vector loops into scalable-length SVE loops is an investment in future-proofing that code without requiring source rewrites.

The prototype in our downstream branch [1] adds this support under an opt-in flag: -vectorize-vector-loops. The normal LoopVectorizer behaviour remains unchanged unless this new mode is explicitly enabled. The intent is similar to SLPVectorizer’s -slp-revec which offers fixed-length to fixed-length re-vectorisation while fixed-length to scalable-length re-vectorisation is the primary goal here.

What we mean by re-vectorisation

As stated above, the goal is to widen fixed-length vectors to wider vectors. For the purpose of our use-case and the remainder of this proposal, the wider vectors are scalable vectors, although the concepts equally apply to wider fixed-length vectors. The way it works is to create a “hybrid” between vector-length-agnostic and vector-length-specific code: a fixed-length vector from the original loop becomes a “segment” inside a wider vector.

The LoopVectorizer can then widen the loop across multiple such segments using a scalable VF. For example, a “scalar” iteration that loads <8 x i16> may become a scalable operation over <vscale x <8 x i16>>, where each SVE vector contains one or more fixed-length NEON-sized segments. In many cases, operations on e.g. <vscale x <8 x i16>> are equivalent to operations on <vscale x 8 x i16>, but this isn’t always the case as will be explained in more detail later.

For an input “NEON” loop like:

for.body:
  %iv = phi i64 [ 0, %entry ], [ %iv.next, %for.body ]
  %p = getelementptr inbounds <8 x i16>, ptr %in, i64 %iv
  %v = load <8 x i16>, ptr %p, align 16
  %r = call <8 x i16> @llvm.aarch64.neon.uabd.v8i16(
      <8 x i16> %v, <8 x i16> splat (i16 1))
  %q = getelementptr inbounds <8 x i16>, ptr %out, i64 %iv
  store <8 x i16> %r, ptr %q, align 16
  %iv.next = add nuw nsw i64 %iv, 1
  ...

the re-vectorised loop for VF = vscale x 1 uses scalable SVE operations:

%wide.load = load <vscale x 8 x i16>, ptr %wide.ptr, align 16
%wide.op = call <vscale x 8 x i16> @llvm.aarch64.sve.uabd.nxv8i16(
    <vscale x 8 x i1> splat (i1 true),
    <vscale x 8 x i16> %wide.load,
    <vscale x 8 x i16> splat (i16 1))
store <vscale x 8 x i16> %wide.op, ptr %wide.out, align 16

Why this belongs in the LoopVectorizer

The re-vectorisation transformation is still loop vectorisation: it needs the same legality checks, trip-count handling, runtime checks, VPlan construction, costing, and target queries as ordinary scalar-to-vector LV. The main difference is that the “scalar” value for the purpose of widening may itself be a fixed-length vector.

The prototype builds on the LoopVectorizer without requiring any major redesign. Most changes are generalisations of existing code paths from “element type” to “initial value type”, plus target hooks where legality or cost is target-specific:

  • Legality detects fixed-length vector instructions only when -vectorize-vector-loops is enabled. Scalable vector inputs are still rejected so we do not try to re-vectorise code that is already scalable.
  • Most ordinary arithmetic, casts, selects, loads, stores, reductions, and first-order recurrences are handled by reusing the existing VPlan recipes after teaching type inference and widening helpers that the per-lane value may be a vector.

The places that need explicit re-vectorisation handling are mostly the places where LLVM IR currently has no way to express an operation over fixed-length vector segments. For example:

  • Non-contiguous vector loads/stores use the same gather/scatter shape as ordinary non-contiguous scalar memory, but lower through segment-aware intrinsics because the data “lane” is a fixed vector segment.
  • Interleaved groups reuse the existing interleaved-access representation, but use segment-level interleave/deinterleave intrinsics when the member type is a vector.
  • shufflevector needed a dedicated VPlan recipe because its semantics are about positions within the fixed-length vector segment.

The current prototype deliberately keeps unsupported cases conservative. For example, some segment operations are restricted to VF = vscale x 1 until there is agreement on the IR and lowering story.

Segments and endianness

It can be tempting to model <vscale x 4 x i32> as <vscale x 1 x i128> and reuse ordinary vector operations over a wider element type, as if the <4 x i32> segment were just a bag of bits. That is not always semantically equivalent because the original value is still four i32 lanes in vector lane order, while the container representation is one endian-sensitive integer element.

This matters most for operations that observe memory layout or lane order. A segment gather/scatter transfers one fixed-length vector segment for each active scalable predicate lane. A gather/scatter of a wider integer element would instead describe a different element type, and bitcasting around it does not preserve the same semantics on big-endian targets.

For example, the segment operation we want is:

; One predicate bit controls one address, and each active address transfers
; one fixed-length segment in vector lane order.
%seg = call <vscale x 4 x i32> @llvm.masked.segment.gather.nxv4i32.nxv1p0(
    <vscale x 1 x ptr> align 16 %ptrs,
    <vscale x 1 x i1> %mask,
    <vscale x 4 x i32> %passthru)

The tempting container form is not the same contract:

; This describes one i128 element per active address, then reinterprets the
; result as four i32 lanes. On big-endian targets that container value does
; not have the same lane-order semantics as a segment operation.
%tmp = call <vscale x 1 x i128> @llvm.masked.gather.nxv1i128.nxv1p0(
    <vscale x 1 x ptr> align 16 %ptrs,
    <vscale x 1 x i1> %mask,
    <vscale x 1 x i128> %passthru.i128)
%seg = bitcast <vscale x 1 x i128> %tmp to <vscale x 4 x i32>

Implementation details

This section gives technical details based on the prototype branch. The aim is to help readers understand the typical problems that come up when re-vectorising loops that already operate on fixed-length vectors, and to give a high-level overview of the changes made in the prototype.

We’d prefer to avoid discussing the details of the interfaces or intrinsics as shown in this RFC, as those can be discussed individually at a later point. Our primary aim is to get some initial feedback on the concept as a whole.

Click to show implementation details

TargetTransformInfo hooks

The prototype adds several TTI hooks. The default implementation either returns false or an invalid cost, so targets opt in explicitly.

Legality hooks

  • isLegalMaskedSegmentGather(Type *DataType, Align Alignment)
  • isLegalMaskedSegmentScatter(Type *DataType, Align Alignment)

These answer whether a target can support masked gather/scatter where each active scalable lane transfers one fixed-length segment.

The AArch64 hooks allow segment memory operations only for little-endian targets. Segment gather/scatter and segment interleaved accesses map directly to quad-element or doubleword-element operations in ISel. That direct mapping is only appropriate for little-endian AArch64.

Costing hooks

  • getMemIntrinsicInstrCost(...) extended to support .segment gather/scatter intrinsics
  • getSegmentInterleavedMemoryOpCost(...)
  • getSegmentedShuffleCost(...)

These separate segment operations from ordinary element operations. Treating a <vscale x <N x Ty>> operation as a normal <vscale x N x Ty> vector of elements would not necessarily yield the right costs.

Target-intrinsic hooks

  • isTargetIntrinsicVectorizable(Intrinsic::ID ID)
  • vectorizeTargetIntrinsic(...)
  • isSupportedTargetRecurrence(Intrinsic::ID ID, RecurKind RK)

The first two let the target declare that a target-specific fixed-length intrinsic can be widened and then materialise the widened target-specific operation. In the AArch64 prototype this is used to map llvm.aarch64.neon.* intrinsics to llvm.aarch64.sve.* intrinsics. Our goal is to allow both the LoopVectorizer and the SLPVectorizer to rely on these hooks. Currently, there is poor support for preserving flags and metadata, but we’ll make sure to account for that when productising.

isSupportedTargetRecurrence is used for target-specific reductions such as NEON dot-product accumulations (udot, sdot, usdot) where the first operand is the accumulator.

Segment intrinsics

Several generic intrinsics were introduced in the prototype to represent operations over fixed-length segments inside scalable vectors.

Masked segment gather/scatter

declare <vscale x 8 x i16> @llvm.masked.segment.gather.nxv8i16.nxv1p0(
    <vscale x 1 x ptr> align 16 %ptrs,
    <vscale x 1 x i1> %mask,
    <vscale x 8 x i16> %passthru)

declare void @llvm.masked.segment.scatter.nxv8i16.nxv1p0(
    <vscale x 8 x i16> %data,
    <vscale x 1 x ptr> align 16 %ptrs,
    <vscale x 1 x i1> %mask)

The important semantic point is that the predicate is <vscale x 1 x i1>: each predicate lane guards one fixed-length segment, not one scalar element.

We may also want masked segment load/store forms, even for contiguous memory, to make the intended predicate type explicit and avoid relying on later combines.

Segment interleave/deinterleave

The prototype adds segment variants of the existing vector interleave intrinsics:

declare <vscale x 8 x i16> @llvm.vector.interleave.segments2.nxv8i16(
    <vscale x 4 x i16> %a,
    <vscale x 4 x i16> %b)

declare { <vscale x 4 x i16>, <vscale x 4 x i16> }
  @llvm.vector.deinterleave.segments2.nxv8i16(<vscale x 8 x i16> %v)

These operate on fixed-length segments rather than scalar elements. They are used for interleaved memory groups whose members are fixed vectors, and for length-changing shufflevector idioms such as subvector concatenation and subvector extraction.

For example:

%concat = shufflevector <4 x i16> %a, <4 x i16> %b,
                       <8 x i32> <0, 1, 2, 3, 4, 5, 6, 7>

can become:

%wide.concat = call <vscale x 8 x i16>
  @llvm.vector.interleave.segments2.nxv8i16(
      <vscale x 4 x i16> %wide.a,
      <vscale x 4 x i16> %wide.b)

and:

%extract = shufflevector <8 x i16> %v, <8 x i16> poison,
                         <4 x i32> <0, 1, 2, 3>

can become:

%parts = call { <vscale x 4 x i16>, <vscale x 4 x i16> }
  @llvm.vector.deinterleave.segments2.nxv8i16(<vscale x 8 x i16> %wide.v)
%wide.extract = extractvalue { <vscale x 4 x i16>, <vscale x 4 x i16> } %parts, 0

The design question is whether these are sufficiently general to justify generic IR intrinsics. Their advantage is that they avoid introducing wider element types such as i128/i256.

Segmented shuffle

Length-preserving shufflevector instructions are represented with:

declare <vscale x 8 x i16> @llvm.vector.segmented.shuffle.nxv8i16.v8i32(
    <vscale x 8 x i16> %src0,
    <vscale x 8 x i16> %src1,
    <8 x i32> %mask)

The fixed-length mask is applied independently to each segment. This is useful for NEON idioms such as unzip, transpose, reverse within a 128-bit segment, duplicate-element shuffles, and other lane rearrangements that should not cross segment boundaries.

For example:

%r = shufflevector <8 x i16> %v, <8 x i16> poison,
                   <8 x i32> <0, 2, 4, 6, 0, 2, 4, 6>

becomes:

%wide.r = call <vscale x 8 x i16>
  @llvm.vector.segmented.shuffle.nxv8i16.v8i32(
      <vscale x 8 x i16> %wide.v,
      <vscale x 8 x i16> poison,
      <8 x i32> <0, 2, 4, 6, 0, 2, 4, 6>)

Vector stretch

The prototype uses llvm.vector.stretch to duplicate each input element in place until the result type is reached:

%wide.mask = call <vscale x 8 x i1>
  @llvm.vector.stretch.nxv8i1.nxv1i1(<vscale x 1 x i1> %segment.mask)

This is mainly useful when a segment-level predicate has to guard an element-level operation such as a select or a masked contiguous load/store. It also keeps VPlan emission simple by representing “broadcast this predicate over the fixed-length segment” directly in IR.

This is also the mechanism used by the prototype for some if-converted vector loops: a segment predicate can be stretched into an element predicate at the point where an element-wise operation needs it. Tail folding is currently disabled for re-vectorised loops, so the initial upstream behaviour would rely on the usual scalar epilogue path for tails. That is a conservative starting point rather than a fundamental limitation.

Note that the intrinsic is not strictly required because it is semantically equivalent to repeatedly interleaving a vector with itself. For example, stretching <vscale x 1 x i1> to <vscale x 4 x i1> could be written as a vector.interleave4 operation. The problem is that larger fixed segments may need higher factors such as interleave8 or interleave16, at which point the IR gets difficult to read.

Vector broadcast

The prototype introduces llvm.vector.broadcast to broadcast a fixed-length vector value into a wider scalable vector of segments:

%wide.v = call <vscale x 8 x i16>
  @llvm.vector.broadcast.nxv8i16.v8i16(<8 x i16> %v)

This comes up for uniform or live-in fixed-length values that need to be broadcast VF times. It is conceptually the vector-valued analogue of a scalar splat.

Partial reductions

Re-vectorising a fixed-vector reduction must still produce a fixed vector at loop exit. The prototype currently relies on partial reduction intrinsics to reduce the widened vectors back to their original types.

For example, if the original loop accumulates <4 x i32> values, the vector loop carries a <vscale x 4 x i32> accumulator. In the middle block, the scalable accumulator is reduced across the scalable VF back to one <4 x i32> segment, and that segment is then combined with the scalar-loop start value:

vector.body:
  %vec.phi = phi <vscale x 4 x i32> [ %start.splat, %vector.ph ],
                                   [ %vec.next, %vector.body ]
  %wide.load = load <vscale x 4 x i32>, ptr %p
  %vec.next = add <vscale x 4 x i32> %vec.phi, %wide.load
  ...

middle.block:
  ; Reduce across the scalable VF, preserving the original <4 x i32> segment.
  %segment.sum = call <4 x i32> @llvm.vector.partial.reduce.add.v4i32.nxv4i32(
      <4 x i32> zeroinitializer,
      <vscale x 4 x i32> %vec.next)

However, we do not want to change the existing semantics for partial.reduce intrinsics and require them to preserve lane-ordering within segments. Therefore, we’ll need a different implementation when productising, e.g. relying on new intrinsics or a new TTI hook to query the target directly about how to reduce values.

AArch64 target intrinsics

The AArch64 implementation uses a TableGen mapping from NEON intrinsics to SVE intrinsics where possible. The table records the target intrinsic name and how to construct the SVE argument list from the widened NEON arguments. The mapping language supports:

  • A:N: use widened argument N.
  • T: synthesise an all-true predicate of the appropriate SVE predicate type.
  • Z: synthesise a zero value.
  • P: synthesise poison.
  • I:N: scalarise a constant splat into an immediate operand.

A mapping entry is intentionally small. For example:

// uabd(a, b) becomes sve.uabd(ptrue, a, b).
def : IntrinsicMapping<"llvm.aarch64.neon.uabd",
                       "llvm.aarch64.sve.uabd",
                       "T,A:0,A:1">;

This covers direct mappings such as dot products, absolute difference, halving/rounding add/sub, saturating add/sub, saturating shifts, widening/narrowing operations, MMLA operations, and many conversion intrinsics.

Some NEON intrinsics are marked as custom because the corresponding SVE sequence needs extra IR.

Current limitations

The prototype is intentionally conservative. Known restrictions include:

  • It only considers loops containing fixed-length vector values; loops already containing scalable vectors are rejected.
  • Tail folding is disabled for re-vectorised loops.
  • Some segment interleave/deinterleave cases are restricted to VF = vscale x 1.
  • Reverse interleaved segment groups are not supported yet.
  • Support for length-changing shufflevector patterns is limited to “aligned” segment concatenations or extracts.

Upstream integration plan

The upstream work would naturally split into a few pieces, some of which could be worked on in parallel:

  • An opt-in LoopVectorizer mode, exposed through -vectorize-vector-loops, with the legality and type-inference changes needed to recognise fixed-length vector loops as re-vectorisation candidates.
  • New IR intrinsics for segment operations that cannot be expressed cleanly with existing vector intrinsics, including LangRef/verifier coverage and target-independent lowering support where needed.
  • TTI hooks with conservative default behaviour, so targets explicitly opt in to the legality and costing decisions that depend on their scalable vector ISA.
  • Target-independent VPlan support for widening fixed-vector values, including segment gathers/scatters, segment interleaves, reductions, and shufflevector.
  • The remapping of AArch64 NEON intrinsics to SVE equivalents.

Everything should remain disabled by default under -vectorize-vector-loops for the initial upstream work. The first upstream target would be AArch64, but the generic pieces are intended to be target-independent. Other targets could opt in by implementing the TTI hooks and lowering the segment intrinsics that make sense for their vector ISA.

Questions for the community

As a first step, we’d like to get some input on whether we can pursue this direction upstream, and specifically hear if there are any objections. Is re-vectorising fixed-length vector loops in the LV initially behind -vectorize-vector-loops a reasonable direction?

In the coming weeks, we’re hoping to get some feedback here in the RFC or via the upcoming Vectorizer community call, and then we’d like to start the conversation about some of the technical design considerations for the implementation.

Cc @fhahn @david-arm

The prototype branch is at [DRAFT] Re-vectorisation in LV by gbossu · Pull Request #203885 · llvm/llvm-project · GitHub


  1. [DRAFT] Re-vectorisation in LV by gbossu · Pull Request #203885 · llvm/llvm-project · GitHub ↩︎

7 Likes

Currently, it violates the TTI contract. TTI is analysis only. Check the header: TTI is the query interface (part 2), and codegen is a separate target-specific implementation (part 3).

include/llvm/Analysis/TargetTransformInfo.h: 8-17

/// \file
/// This pass exposes codegen information to IR-level passes. Every
/// transformation that uses codegen information is broken into three parts:
/// 1. The IR-level analysis pass.
/// 2. The IR-level transformation interface which provides the needed
///    information.
/// 3. Codegen-level implementation which uses target-specific hooks.
///
/// This file defines #2, which is the interface that IR-level transformations
/// use for querying the codegen.
///

vectorizeTargetIntrinsic “materialise[s] the widened target-specific operation,” i.e. it builds the NEON-to-SVE IR inside the analysis layer. That collapses parts 2 and 3 into the query interface. The core transform of the feature would live in TargetTransformInfo.

Yes, instCombineIntrinsic, simplifyDemandedUseBitsIntrinsic, and simplifyDemandedVectorEltsIntrinsic already touch IR through an InstCombiner (header lines 794-808). Those are scoped peephole simplifications returning std::optional, and are a recognised layering compromise, not a template (and still I think this was wrong decision to put them into TTI). A “widen this NEON intrinsic to an SVE call and emit it” hook is not simplification; it is the feature’s codegen. Extending the precedent that far is the wrong move.

The RFC itself shows the symptom: “Currently, there is poor support for preserving flags and metadata” (line 142). That is a direct consequence of emitting IR in TTI instead of in the recipe layer, where FMF/metadata/debugloc propagation already exists.

Proposal: split query from codegen.

  • Keep isTargetIntrinsicVectorizable(ID) → bool (pure query, correct for TTI).
  • Replace vectorizeTargetIntrinsic with a query that returns the mapping as data: target intrinsic ID plus the arg-construction descriptor. The TableGen table stays in the target; TTI surfaces it as data, not IR.
  • The vectorizer (a VPlan recipe, e.g. VPWidenIntrinsicRecipe or a dedicated one) consumes the descriptor and emits the call via its own builder, inheriting existing flag/metadata handling.

This also matters for the stated SLP goal (line 142). An IR-emitting TTI hook bypasses SLP’s tree/scheduling and external-use bookkeeping; SLP must build the call in vectorizeTree. A data descriptor is consumable by both LV and SLP. As specified, the shared codegen hook would not integrate cleanly with SLP.

1 Like

Hi Alexey, thanks for the answer! (and sorry for the delayed response, I was away last week).

I believe you :slight_smile: I agree we should not stretch that contract too much and probably move the codegen hook elsewhere. I’m sure we’ll have time to discuss those details in PRs.

Now, regarding SLP and LV inter-operability: I’ll probably need to do some homework, but I was under the impression that the scheduling part in SLP happens on the tree nodes, before they get generated as IR. I’m not sure what bookkeeping is needed after IR has been generated, but I’ll have a look.

Regarding your proposal for returning a “mapping descriptor” instead of a widened intrinsic directly: This would be a better interface, but in practice I think it won’t work because some intrinsics cannot be easily remapped and need a custom instruction sequence to be generated. Otherwise, I would have favoured a better integration within VPWidenIntrinsic as well. There are examples of such “custom mappings” in AArch64TTIImpl::vectorizeTargetIntrinsic, and you can find tests in e.g. revec-neon.ll or revec-neon-64bit.ll. For flags/metadata, I believe it should be the responsibility of the target to maintain them on its own intrinsics, once again because an intrinsic sometimes needs to get widened using multiple instructions.

I’ll try to look a bit more into SLP and how integration could work. Thanks again for looking into the prototype.

Regarding remapping, we are working on an idea on how to do it differently, I would suggest to discuss it with @sushgokh and find common solution for both LV and SLP

@gbossu From SLP’s perspective, we are exploring the idea of generating a generic,target-independent intrinsic at mid-end that takes following input params:

  1. Mangled intrinsic name

2. Vector factor(VF)

3. Widened/scalar arguments derived from the source intrinsic.

and then lower this appropriately in the backend(addIRPasses/CodeGenPrepare/SelectionDAG)

This should solve the issue of 1-1 or custom mapping.

However, this introduces new issue. What attributes should the generic intrinsic have and why are they important.

What’s the importance of intrinsic attributes?

They are important because many mid-end passes kick in based on these attributes(DCE, LICM, InstCombine etc.). But I am not very sure what attributes really matter from this aspect(maybe memory attributes ?)

What attributes should we have on the generic intrinsic?

Since this is generic and target-independent, we must have minimal set of attributes on it and we may be tempted to have any additional attributes at the callsite. But there are certain problems even with this design:

1. Historically, as far as I understand, there has been inconsistency across passes in handling callsite and function/declaration attributes and not all of them consider callsite attributes.

2. As per langref, attributes like “speculative” can only be present at the declaration. Also, for many attributes, langref says that we must take “intersection” of attributes at callsite/declaration.

3. Having attributes on the generic intrinsic works where there is 1-1 mapping. If source intrinsic lowers to multiple intrinsics, its difficult to derive a common set. Additionally, think of source intrinsic being lowered to (intrinsic + stock IR instructions) and deriving any attributes on the generic intrinsic may be risky because mid-end optimisations that trigger on generic intrinsic are inherently getting triggered on the stock IR instructions as well and these optimisations may well not applicable to these stock IR instructions.

Considering above points, where do we want to lower the intrinsic? Lowering in the backend may be too late and we may miss out on certain mid-end optimisations(and this has happened in the past). Is there any way to alleviate this issue? We thought of preponing the lowering by using EP(Extension Point) hook that runs immediately after the vector passes have been run. For LTO build, there may be lot of passes that run between the EP hook and addIRPasses stage but for non-LTO builds, not many of them run in between. So, maybe its not really worth preponing the lowering to EP hook.

So, all in all, addIRPasses stage seems to be the right thing to do the lowering unless we have a motivating case that requires doing the lowering earlier or totally shift to a different approach for this transformation.

@nikic Can you shed some light on if this would be right approach or would you suggest something else ?

Can you please provide an example? I do not really understand what this “generic” intrinsic is doing and how it is useful for this RFC.

Nowadays, there should be very little difference between applying an attribute to the call-site and to the function. (Excluding ABI attributes, but this is not relevant for intrinsics.)

Yes, speculatable is the one notable omission, because the semantics of the attribute are unclear when applied to a call-site. Regarding intersection, this is to be understood as taking the stricter of call-site and function attributes. E.g. if you have a call-site memory(read) and a function memory(argmem: readwrite) attribute, the resulting intersection is memory(argmem: read). If in your case the callee doesn’t have any attributes, you are just left with the call-site attributes.

Can you please provide an example? I do not really understand what this “generic” intrinsic is doing and how it is useful for this RFC.

Example:

call <32 x i8> (metadata, i32, ...) @llvm.revec.tgt.intrinsic.v32i8(metadata !"llvm.aarch64.neon.uabd.v16i8", i32 32, <32 x i8> [[TMP1]], <32 x i8> [[TMP2]])

E.g. if you have a call-site memory(read) and a function memory(argmem: readwrite) attribute, the resulting intersection is memory(argmem: read). If in your case the callee doesn’t have any attributes, you are just left with the call-site attributes.

You mean if the cite-site has N attributes and callee has 0 attributes, intersection of them would mean N attributes ? If I understand correctly, the callee has the stricter version and intersection should be no-attributes, right ?

@nikic I also have question wrt following

3. Having attributes on the generic intrinsic works where there is 1-1 mapping. If source intrinsic lowers to multiple intrinsics, its difficult to derive a common set. Additionally, think of source intrinsic being lowered to (intrinsic + stock IR instructions) and deriving any attributes on the generic intrinsic may be risky because mid-end optimisations that trigger on generic intrinsic are inherently getting triggered on the stock IR instructions as well and these optimisations may well not applicable to these stock IR instructions.

Having callsite attributes failrly eases things. But, if we are interested in lowering to combination of (intrinsic + stock IR), having those attributes on generic attributes would also make mid-end optimisations eligible on that stock IR and that may always be not correct, right? What can be done in such cases ?

Hi Sushant, thanks for looking into the prototype.

I’d like to understand a bit better why you think that introducing a new generic intrinsic and delaying its expansion into target IR (or SDAG/GMIR) is a better approach than having LV/SLP directly generate “legal IR”. I believe that compared to the vectorizeTargetIntrinsic hook in the prototype, it just delays the remapping to a wider intrinsic to a later point, but this does not help solving the propagation of existing metadata or FP flags. E.g. for some IR like here: Compiler Explorer

I think it’s also important to have IR with legal target intrinsics as early as possible, in particular for optimisations like InstCombine to kick in, so I’d rather not delay the remapping too much in the pass pipeline.

Also, I think it’s difficult to define the semantics of llvm.revec.tgt.intrinsic, because it’s an intrinsic that might litterally do anything. It might or might not have side effects, and it might or might not be speculatable. I understand we could potentially make it work by intersecting attributes at callsite and declaration, but I’d rather have target intrinsics straight after vectorisation and avoid the confusion altogether.

To be clear, I’m not against changing my approach with the vectorizeTargetIntrinsic hook if it means better support for both vectorisers. I just would like to understand what the advantages of the new llvm.revec.tgt.intrinsic would be, i.e. how it makes the job of LV and SLP easier/safer. I’d be happy to have a short meeting and go through examples together if that helps :slight_smile:

@gbossu

So this is my understanding.

Technically, you can pass scalars, to be vectorized, to the TTI hook and derive FMF/Metadata/OperandBundles and stuff. But again, you would need to extract that data and set it up properly on the resulting vector instructions. This is again some sort of transform. Additionally, this needs to be done probably for every target and this is a redundancy. LV/SLP already have these mechanisms(some recipe in LV/vectorizeTree in SLP) that have been tested over time and for the same reason, it would be great if the call is generated in LV/SLP itself.
Things wont end here. You need to extract and pass context like IR builder(this is different for LV and SLP) etc. from LV/SLP to this TTI hook and this hook would itself become some sort of recipe.

Now coming to bookkeeping required after transform in SLP. Apologies, I may not be able to explain this well. But there are some transforms, like reordering, that happen on the vectorized value. These transforms would expect input to be in certain form. If that form is somehow broken by chance in the TTI hook you mentioned, it would introduce codegen issues. And there is no way, I think, to impose that contract on the TTI hook result.

Now, coming to why we are exploring(we havent finalised yet) with new llvm.revec.tgt.intrinsic.

  1. First, SLP will definitely run into issues if the TTI hook generates a scalable vector(there are some shuffles that happen on the vectorized value). We dont want any scalable vectors in SLP and thats the underlying assumption SLP operates on at mutiple places. Now, I suppose that can be handled easily.
  2. Secondly, TTI is an analysis place and we didnt want to reinvent a new structure(like TTI) just for this.
  3. Thirdly, there are multiple target-independent intrinsics present in LLVM that are being lowered to target-dependent intrinsics at the pre-codegen/codegen level.

I think it’s also important to have IR with legal target intrinsics as early as possible, in particular for optimisations like InstCombine to kick in, so I’d rather not delay the remapping too much in the pass pipeline.

I think we need to see if running InstCombine is really necessary. If we need that, we can run it at pre-codegen level as well similar to what AMDGPU target is doing. The only thing I am really concerned about is “speculatable” attribute because there are ~500 AArch64 SVE intrinsics with that attribute(simply cant be ignored). There are few options here:

  1. Allow “speculatable” on callsite for this scenario but this needs seperate discussion
  2. Use EP callback ‘invokeVectorizerEndEPCallbacks’ that runs post addVectorPasses and add the required simplification passes there alongwith the lowering pass.
  3. Invent a hook(similar to invokeVectorizerEndEPCallbacks) that runs in between the addVectorPasses when required.

and I agree that using new llvm.revec.tgt.intrinsic(with lowering much later) will not at all help with “speculatable” attribute.

I am ready to have a meeting and discuss anything offline.

Hi Sushant, I’ll try to summarise my understanding so far.

I’d like to re-map target intrinsics at vectorisation time because:

  • There are some middle-end optimisations that could be missed if we delay SVE intrinsics generation. (AArch64 has a couple of SVE instcombines, and we might add more)
  • Delaying the remapping forces us to add a new intrinsic with hard-to-define semantics, or as an alternative, keep the old intrinsics around with wide “illegal” types. I guess this could be acceptable, but only if delaying the remapping brings additional benefits.
  • I do not see how transferring FMF/Metadata is harder as part of a TTI hook compared to having them on llvm.revec.tgt.intrinsic. At some point, the target will anyway need to lower the intrinsic to “real” IR, and transfer flags as part of this process. Only the target really knows how to transfer the flags, because e.g. some NEON intrinsics need to be re-vectorised using multiple SVE intrinsics, and the NEON arguments do not always nicely map to the SVE ones.

You’d like to re-vectorise target intrinsics to an intermediate llvm.revec.tgt.intrinsic because:

  • It’s convenient to keep the re-vectorised value as a single value and prevents SLP from doing illegal optimisations.
  • You believe it’s easier to transfer FMF/Metadata
  • You say a hook like vectorizeTargetIntrinsic will need a lot of context. That’s fair, it needs the widened arguments, an IRBuilder, the FMF/Metadata that’s already prepared by LV and maybe some other bits and bobs. Looking into TargetLowering (where the hook should probably be), I don’t think that’s shocking though.
  • You say the hook will need implementing for every target whereas the llvm.revec.tgt.intrinsic will be more generic. I really do not understand how, given that this intrinsic needs to be lowered by target-specific code at some point as well. If anything, it adds an intermediate step where the new llvm.revec.tgt.intrinsic needs to be generated.
  • You fear the vectorizeTargetIntrinsic will generate scalable vectors. It must not. It must generate whatever return type the vectorizer expects. You can see that in e.g. revec-neon-64bit.ll where the result types are e.g. nxv4i16, not nxv8i16 even though that is the legal type used in the SVE intrinsic. So for SLP, the hook should/will generate a fixed-length vector.

The way I see it, delaying the remapping does not simplify the work (of generating IR and transferring flags). The main advantage for you is that it makes SLPVectorizer safe because it expects every node in the tree to map to a single instruction, even after the nodes have been code-generated. Do I understand correctly that SLP will reschedule IR instructions after the tree has been code-generated, and this will go wrong if it is unaware of some extra instructions generated as part of a vectorizeTargetIntrinsic hook? I was assuming the scheduling happened on the TreeNode themselves, and then they would be generated one by one after the tree “got flattened/scheduled”. I’m guessing it’s not possible to delay the code-generation then?

From my side, I’d really like to generate the re-vectorised IR as early as possible because re-vectorisation might also introduce generic instructions/intrinsics, and those can potentially be optimised. Similarly, there are target intscombines for the newly-generated target intrinsics.

So in the end, I’m still not opposed to llvm.revec.tgt.intrinsic, but I really don’t think it makes generating re-vectorised IR any easier than using the vectorizeTargetIntrinsic hook. I think its advantage is that it makes SLPVectorizer safe, which is important if we are to find a solution that works for both vectorizers :slight_smile: From your experiments, do you think it would be possible to lower llvm.revec.tgt.intrinsic as soon as possible after vectorisers? And do you know how easy it would be to read the FMF/Metadata from the llvm.revec.tgt.intrinsic intrinsic and transfer them to the new SVE intrinsics? If you have some code showing how llvm.revec.tgt.intrinsic gets lowered, it would make discussions easier I think :slight_smile:

Best,
Gaetan

@gbossu
We are not fully on any side(implementing in TTI or using llvm.revec.tgt.intrinsic). But since we dont want to invent something new like TTI, just for transforms**,** we started exploring option of using this generic intrinsic(and yes, even that has flaws wrt attributes and plausibly missing out on mid-end optimisations).

Now coming to implementation. Both SLP/LV require same helpers:

  1. Querying if the intrinsic is revectorizable
  2. Do the actual lowering/vectorize target intrinsics

(lets ignore the SLP and LV specific code). When we did some sort of prototyping, we thought we can reuse the helpers in your patch and thats it.

Now, I agree we need to do the lowering as early as possible. As stated earlier, I can think of following options:

  1. Allow “speculatable” on callsite for this scenario but this needs seperate discussion @nikic Would this be feasible ?
  2. Use EP callback ‘invokeVectorizerEndEPCallbacks’ that runs post addVectorPasses and add the required simplification passes there alongwith the lowering pass.
  3. Invent a hook(similar to invokeVectorizerEndEPCallbacks) that runs in between the addVectorPasses when required.

I am slightly inclined with option 3 because with this, we can take advantage of InstCombine,LICM etc that would run during addVectorPassespost LV/SLP

I will try out options 2 and 3 unless someone points out against these approaches

I don’t have a particular objection to using llvm.revec.tgt.intrinsic as long as it runs very shortly after vectorisers. My only gripe is that it does add an extra indirection whereas the vectorisers could invoke a TTI/TLI hook directly to generate the IR. Regarding speculatable, I’m guessing the llvm.revec.tgt.intrinsic should be conservative and not have that attribute. Then when lowering it, it will become e.g. an SVE intrinsic, which will come with the right attributes due to how it’s defined in Intrinsics.td.

Food for thought: If the problem is about the SLPVectorizer not being able to track the newly-generated instructions, we could consider some extra options:

  • In vectorizeTree, we could generate “illegal” NEON intrinsics like neon.uabd.v8i32 to maintain a 1:1 mapping between instruction and TreeNode. Then in a final post-process step inside SLPVectorizer, we could call vectorizeTargetIntrinsic on those to turn them into “legal” IR.
  • Worst case, we could extend the vectorizeTargetIntrinsic hook to return a list of generated instructions so that SLP can track them.

I’d assume this to also work to re-vectorize from fixed with to different fixed width? Would limiting to fixed-width vectors initially simplify the initial implementation?

In terms of the VPlan changes, what does that mean in terms of the scalar type that VPValues/defining recipes carry? Would it be set to the type of the segment (i.e. vector type)?

How would that be modeled in the vectorizer? How would things look for fixed vectors? I’d assume we would not need an intrinsic in that case? Could we express widening from <4 x i32> to <vscale x 8 x i32>?

Agreed that LV seems a convenient place. I am not familiar with the details of SLP re-vec support, but presumably it would already also need to do similar legality checks, assuming it supports loops?

It could, I do not foresee a particular blocker here, but I’ve only looked at fixed->scalable because we don’t have a wider ISA than NEON. NEON->SVE was the motivating use case.

I actually think the opposite is true: limiting the initial implementation to scalable VFs is simpler, because replication is forbidden and IIRC some other recipes are invalid as well. I’ve created a tiny PoC PR to show the changes required for basic re-vectorisation.

Yes, this is the main change: the recipes need to be aware that the “scalar” type (i.e. the initial type) might be a vector. This is the main thing the PoC changes along the with legality checks.

masked.segment.gather are introduced for non-contiguous memory accesses, just like normal masked.gather intrinsics. For fixed-width REVEC (e.g. VF=2), that would look like @llvm.masked.segment.gather.v8i32.v2p0(<2 x ptr> %ptrs, ...). That represents a gather of two v4i32 segments. You’re right we could represent that with a normal gather of 8 i32 values by converting the <2 x ptr> %ptrs into something like <8 x ptr> {ptrs[0], ptrs[0]+4, ptrs[0]+8, ptrs[0]+12, ptrs[1], ptrs[1]+4, ptrs[1]+8, ptrs[1]+12}. At this point it will be challenging to get good codegen though.

And for <4 x i32> to <vscale x 8 x i32> (i.e. VF=vscale x 2), then we’d get @llvm.masked.segment.gather.nxv8i32.nxv2p0(<vscale x 2 x ptr> %ptrs, ...). Basically the element count of the pointer vector matches the number of segments. From that, one can infer the segment type: v4i32 in those examples.

Hope it helps, and thanks fo looking at the RFC :slight_smile: