[RFC] Vector scaled contraction

This is a follow-up to Linalg RFC and next step in adding native scaled contraction support to MLIR.

Summary

This RFC proposes a new Vector operation vector.scaled_contract to allow representing contractions in which the inputs A and B are scaled using additional scale values.

The operation primarily targets lower-level hardware intrinsics implementing Open Compute Project (OCP) Microscaling Formats (MX) Specification.

Prior Art

For the general overview of the MX format and scaled contraction definition, I recommend the introduction section of the Linalg RFC.

Relevant hardware intrinsics and lowering targets for vector.scaled_contract:

  • XeGPU dialect scaled mma op [link]

  • NVVM dialect block mma op [link]

  • x86 new ACE intrinsics [spec, LLVM PR] - see spec’s section 10 (p. 51) for overview and section 14 (p. 76) for compute instructions

Op: vector.scaled_contract

A new Vector op that supports general contraction semantics with extra scale inputs together with flexible scale indexing maps which can be used to represent various scaling schemes.

The aim is to create a pair of operations analogous to linalg.contract and vector.contract that bridge the memory (tensor, memref) and register (vector) worlds and serve as counterpart representations of general contractions in their respective abstraction levels.

The proposed vector.scaled_contract extends vector.contract and closely mirrors the scaling semantics of linalg.scaled_contract, serving as its register-level counterpart.

Similarly to linalg.scaled_contract, block scaling can be represented using expanded shapes:

%D = vector.scaled_contract
  {indexing_maps = [
    affine_map<(m, n, ko, kb) -> (m, ko, kb)>,
    affine_map<(m, n, ko, kb) -> (ko)>,
    affine_map<(m, n, ko, kb) -> (n, ko, kb)>,
    affine_map<(m, n, ko, kb) -> (ko)>,
    affine_map<(m, n, ko, kb) -> (m, n)>],
  iterator_types = ["parallel", "parallel", "reduction", "reduction"]}
  %A, %scaleA, %B, %scaleB, %C
  : vector<16x2x32xf8E5M2>, vector<2xf8E8M0FNU>,
    vector<32x2x32xf8E4M3FN>, vector<2xf8E8M0FNU> into vector<16x32xf32>

or semi-affine indexing maps that mirror the linalg.scaled_contract semantics:

%D = vector.scaled_contract
  {indexing_maps = [
    affine_map<(m, n, k) -> (m, k)>,
    affine_map<(m, n, k) -> (k floordiv 32)>,
    affine_map<(m, n, k) -> (n, k)>,
    affine_map<(m, n, k) -> (k floordiv 32)>,
    affine_map<(m, n, k) -> (m, n)>],
  iterator_types = ["parallel", "parallel", "reduction"]}
  %A, %scaleA, %B, %scaleB, %C
  : vector<16x64xf8E5M2>, vector<2xf8E8M0FNU>,
    vector<32x64xf8E4M3FN>, vector<2xf8E8M0FNU> into vector<16x32xf32>

Initially, the op adapts the same data type restrictions as its Linalg counterpart namely:

The output element type must be floating-point. Input element types must be
integer or floating-point, with bitwidth no greater than the output element
type’s bitwidth.

to prevent over-generalization of scaling semantics. This can be relaxed later as needed.

Additionally, unlike vector.contract today, the new op supports different element data types for its operands to align with commonly supported mixed low-precision variants such as: f8E5M2 * f8E4M3FN.

Combination function kind attribute could be skipped for now to start with the default add or kind could be added to more closely align with vector.contract.

Ingress

Direct vectorization from linalg.scaled_contract.

Lowering

The main goal of vector.scaled_contract is to serve as an intermediate stage toward scaled hardware intrinsics. For example, the operation can directly lowered into xegpu.dpas_mx given compatible shapes and types. Alternatively, further layout pre-processing can be performed locally at the time of lowering to align with hardware instruction requirements like, for example, online VNNI packing is applied today for x86 AMX compute ops.

As a fallback, vector.scaled_contract can be rewritten into a sequence of standard vector operations. An example rewrite strategy:

  • partition K by scale block
  • compute low-precision contraction per block
  • multiply by combined scale
  • accumulate final result

Given a semi-affine block scaled example:

func.func @vec_block_scaling_maps(
    %A: vector<16x64xf8E5M2>, %scaleA: vector<2xf8E8M0FNU>,
    %B: vector<32x64xf8E5M2>, %scaleB: vector<2xf8E8M0FNU>,
    %C: vector<16x32xf32>) -> vector<16x32xf32> {
  %D = vector.scaled_contract
    {indexing_maps = [
      affine_map<(m, n, k) -> (m, k)>,
      affine_map<(m, n, k) -> (k floordiv 32)>,
      affine_map<(m, n, k) -> (n, k)>,
      affine_map<(m, n, k) -> (k floordiv 32)>,
      affine_map<(m, n, k) -> (m, n)>],
    iterator_types = ["parallel", "parallel", "reduction"]}
    %A, %scaleA, %B, %scaleB, %C
    : vector<16x64xf8E5M2>, vector<2xf8E8M0FNU>,
      vector<32x64xf8E5M2>, vector<2xf8E8M0FNU> into vector<16x32xf32>

  return %D : vector<16x32xf32>
}

can be decomposed as:

func.func @vec_block_scaling_decomposed(
    %A: vector<16x64xf8E5M2>, %scaleA: vector<2xf8E8M0FNU>,
    %B: vector<32x64xf8E5M2>, %scaleB: vector<2xf8E8M0FNU>,
    %C: vector<16x32xf32>) -> vector<16x32xf32> {
  %zero = arith.constant dense<0.0> : vector<16x32xf32>

  // Extract scale factors.
  %sA0 = vector.extract %scaleA[0]
    : f8E8M0FNU from vector<2xf8E8M0FNU> 
  %sA1 = vector.extract %scaleA[1]
    : f8E8M0FNU from vector<2xf8E8M0FNU>
  %sB0 = vector.extract %scaleB[0]
    : f8E8M0FNU from vector<2xf8E8M0FNU>
  %sB1 = vector.extract %scaleB[1]
    : f8E8M0FNU from vector<2xf8E8M0FNU>

  // Convert scales to accumulator type.
  %sA0f = arith.extf %sA0 : f8E8M0FNU to f32
  %sA1f = arith.extf %sA1 : f8E8M0FNU to f32
  %sB0f = arith.extf %sB0 : f8E8M0FNU to f32
  %sB1f = arith.extf %sB1 : f8E8M0FNU to f32

  %scale0 = arith.mulf %sA0f, %sB0f : f32
  %scale1 = arith.mulf %sA1f, %sB1f : f32

  // Contraction per scaled block.
  // K block 0 : [0, 32)
  %A0 = vector.extract_strided_slice %A
    offsets = [0, 0], sizes = [16, 32], strides = [1, 1]
    : vector<16x64xf8E5M2> to vector<16x32xf8E5M2>

  %B0 = vector.extract_strided_slice %B
    offsets = [0, 0], sizes = [32, 32], strides = [1, 1]
    : vector<32x64xf8E5M2> to vector<32x32xf8E5M2>

  %part0 = vector.contract
    {indexing_maps = [
      affine_map<(m,n,k) -> (m,k)>,
      affine_map<(m,n,k) -> (n,k)>,
      affine_map<(m,n,k) -> (m,n)>],
    iterator_types = ["parallel","parallel","reduction"],
    kind = #vector.kind<add>}
    %A0, %B0, %zero
    : vector<16x32xf8E5M2>, vector<32x32xf8E5M2>
      into vector<16x32xf32>

  %scale0vec = vector.broadcast %scale0 : f32 to vector<16x32xf32>
  %part0scaled = arith.mulf %part0, %scale0vec
      : vector<16x32xf32>

  // K block 1 : [32, 64)
  %A1 = vector.extract_strided_slice %A
    offsets = [0, 32], sizes = [16, 32], strides = [1, 1]
    : vector<16x64xf8E5M2> to vector<16x32xf8E5M2>

  %B1 = vector.extract_strided_slice %B
    offsets = [0, 32], sizes = [32, 32], strides = [1, 1]
    : vector<32x64xf8E5M2> to vector<32x32xf8E5M2>

  %part1 = vector.contract
    {indexing_maps = [
      affine_map<(m,n,k) -> (m,k)>,
      affine_map<(m,n,k) -> (n,k)>,
      affine_map<(m,n,k) -> (m,n)>],
    iterator_types = ["parallel","parallel","reduction"],
    kind = #vector.kind<add>}
    %A1, %B1, %zero
    : vector<16x32xf8E5M2>, vector<32x32xf8E5M2>
      into vector<16x32xf32>

  %scale1vec = vector.broadcast %scale1 : f32 to vector<16x32xf32>
  %part1scaled = arith.mulf %part1, %scale1vec
    : vector<16x32xf32>

  // Final accumulation.
  %sum = arith.addf %part0scaled, %part1scaled
    : vector<16x32xf32>
  %res = arith.addf %C, %sum
    : vector<16x32xf32>

  return %res : vector<16x32xf32>
}

Alternatives

- General Intrinsic Op

In the related Linalg RFC, @Groverkss proposed a more general operation vector.inner_intrinsic:

My personal interpretation: The inner_intrinsic operation is a flexible container that can represent many different operations going beyond only scaled contraction. That would allow to limit number of specialized named operations in Vector dialect. Patterns can verify op’s exact semantics by checking attribute and/or body contents.

With clearly defined boundaries to avoid too abstract generic implementation, it could be a valid alternative. However, I have not analyzed this case thoroughly as it goes way beyond my target scope.

Happy to hear more feedback and experience from others for such approach.

- Pattern Matching

Similar alternative option presented also in the Linalg RFC.

Instead of a new operation, scaled contraction could be represented as a sub-graph of operations.

It requires more expensive pattern matching, and it creates an odd representation gap where high-level (Linalg) and low-level (hardware intrinsics) capture scaled contraction in a single operation.

By preserving whole scaled contraction semantics in a single operation, it will be easier to connect Vector dialect with its surrounding abstractions.

- Extending vector.contract

Existing vector.contract op could be extended to represent scaled contraction by optionally accepting extra operands and indexing maps for scales e.g., scale_a = [%scale_val, #scale_map] etc.

This option is the most invasive and would require add extra checks to existing code. However, it eliminates need for a new operation.

Overall, I’d avoid going this route due to possible unforeseen interactions with the existing code.

Implementation Plan

Proposed steps:

  • Land the new operation according to reached consensus.

  • Add Linalg vectorization.

  • Add lowering to XeGPU dialect.

  • Later, add lowering to x86 dialect when ACE is available in LLVM.

I would leave out the decomposition rewrite fallback until it is needed.

=====================================================

CC @banach-space @dcaballe @Groverkss @Jianhui-Li @rengolin @arunt1204

I think this needs to be explored more. Having a specialized intrinsic container operation is better than having a scaled_contraction operation. I am still for having an intrinsic approach rather than a scaled contraction specific approach.

Can you give an example of how you would lower a 64x64 tile to a vector intrinsic that takes a block size of 16? (You can assume any details I’m not specific about, I’m just looking for a case where this op would eventually lower to 4 intrinsic operations). I know it’s easy to do this for inner_intrinsic, because the operation specifies some dimensions are not part of the iteration space and cannot be unrolled as they are defined by the intrinsic attribute, but there is no such restriction for this vector.scaled_contract operation. How do you determine what the block size is without analyzing the floordiv affine maps? Having non permutation affine maps at very level is very hard.

I’m -1 on having this operation and would recommend exploring the intrinsic route instead.

I would leave out the decomposition rewrite fallback until it is needed.

I think it’s important to show case some examples of how the op is expected to lower to see if it is the right design. I ask because non permutation affine maps are harder to handle than they look. What is the class of affine maps you allow? Please add some examples.

You probably could infer it from operand shapes.
However, when block scaling is encoded as semi-affine map, then the block size is explicitly available is scale operands indexing maps. Figuring out block size in such cases is a simple lookup.

Maps for scales can refer only to dimensions defined already by the data operands i.e., scales are always indexed based on the main contraction dimensions.

The following maps are supported:

  • No dimensions: a scalar value used to scale the whole input:
// Single scale used over the whole data input
affine_map<(m, n, k) -> ()>
  • Dimension scaling - dimension d: one scale value per element along d:
// One scale per M dimension e.g.,
// A = <M, K>, scaleA = <M>; one scale per row
affine_map<(m, n, k) -> (m)>
  • Block scaling - binary expression d floordiv B: one scale value for every B elements along d where B corresponds to the block size:
// Block scaling e.g.,
// A = <M, K>, scaleA = <M/32, K/16>; one scale per 32x16 block
affine_map<(m, n, k) -> (m floordiv 32, k floordiv 16)>

The support for semi-affine maps is intentionally restricted to bare minimum with only variant currently supported is used to represent block scaling.

I’ll assume C += matmul(A * scale_A, B^T * scale_b) with M=N=K=64 and block size 16 applied to the reduction K dim over all parallel dims. That gives us initial representation as:

func.func @vec_block_scaled(
    %A: vector<64x64xf8E5M2>, %scaleA: vector<64x4xf8E8M0FNU>,
    %B: vector<64x64xf8E5M2>, %scaleB: vector<64x4xf8E8M0FNU>,
    %C: vector<64x64xf32>) -> vector<64x64xf32> {
  %D = vector.scaled_contract
    {indexing_maps = [
      affine_map<(m, n, k) -> (m, k)>,
      affine_map<(m, n, k) -> (m, k floordiv 16)>, // A - block scale 16
      affine_map<(m, n, k) -> (n, k)>,
      affine_map<(m, n, k) -> (n, k floordiv 16)>, // B - block scale 16
      affine_map<(m, n, k) -> (m, n)>],
    iterator_types = ["parallel", "parallel", "reduction"]}
    %A, %scaleA, %B, %scaleB, %C
    : vector<64x64xf8E5M2>, vector<64x4xf8E8M0FNU>,
      vector<64x64xf8E5M2>, vector<64x4xf8E8M0FNU> into vector<64x64xf32>
  return %D : vector<64x64xf32>
}

In general, unrolling scaled_contract is no different than standard contract unrolling with the caveat that the target unrolled shape should a multiple of the block size.

For this example, we want to apply unrolling to block scaled dimension as removing variable scale from the reduction dimension can be helpful for further lowering.
Then let’s unroll K dim with target unrolled shape 16:


func.func @vec_block_scaled_unrolled_k(
    %A: vector<64x64xf8E5M2>, %scaleA: vector<64x4xf8E8M0FNU>,
    %B: vector<64x64xf8E5M2>, %scaleB: vector<64x4xf8E8M0FNU>,
    %C: vector<64x64xf32>) -> vector<64x64xf32> {
  // ---- K block 0 : [0, 16) ----
  %A0 = vector.extract_strided_slice %A
      offsets = [0, 0], sizes = [64, 16], strides = [1, 1]
      : vector<64x64xf8E5M2> to vector<64x16xf8E5M2>
  %B0 = vector.extract_strided_slice %B
      offsets = [0, 0], sizes = [64, 16], strides = [1, 1]
      : vector<64x64xf8E5M2> to vector<64x16xf8E5M2>
  %scaleA0 = vector.extract_strided_slice %scaleA
      offsets = [0, 0], sizes = [64, 1], strides = [1, 1]
      : vector<64x4xf8E8M0FNU> to vector<64x1xf8E8M0FNU>
  %scaleB0 = vector.extract_strided_slice %scaleB
      offsets = [0, 0], sizes = [64, 1], strides = [1, 1]
      : vector<64x4xf8E8M0FNU> to vector<64x1xf8E8M0FNU>

  %acc0 = vector.scaled_contract
    {indexing_maps = [
      affine_map<(m, n, k) -> (m, k)>,
      affine_map<(m, n, k) -> (m, k floordiv 16)>,
      affine_map<(m, n, k) -> (n, k)>,
      affine_map<(m, n, k) -> (n, k floordiv 16)>,
      affine_map<(m, n, k) -> (m, n)>],
     iterator_types = ["parallel", "parallel", "reduction"]}
    %A0, %scaleA0, %B0, %scaleB0, %C
    : vector<64x16xf8E5M2>, vector<64x1xf8E8M0FNU>,
      vector<64x16xf8E5M2>, vector<64x1xf8E8M0FNU>
      into vector<64x64xf32>

  // ---- K block 1 : [16, 32) ----
  %A1 = vector.extract_strided_slice %A
      offsets = [0, 16], sizes = [64, 16], strides = [1, 1]
      : vector<64x64xf8E5M2> to vector<64x16xf8E5M2>
  %B1 = vector.extract_strided_slice %B
      offsets = [0, 16], sizes = [64, 16], strides = [1, 1]
      : vector<64x64xf8E5M2> to vector<64x16xf8E5M2>
  %scaleA1 = vector.extract_strided_slice %scaleA
      offsets = [0, 1], sizes = [64, 1], strides = [1, 1]
      : vector<64x4xf8E8M0FNU> to vector<64x1xf8E8M0FNU>
  %scaleB1 = vector.extract_strided_slice %scaleB
      offsets = [0, 1], sizes = [64, 1], strides = [1, 1]
      : vector<64x4xf8E8M0FNU> to vector<64x1xf8E8M0FNU>

  %acc1 = vector.scaled_contract
    {indexing_maps = [
      affine_map<(m, n, k) -> (m, k)>,
      affine_map<(m, n, k) -> (m, k floordiv 16)>,
      affine_map<(m, n, k) -> (n, k)>,
      affine_map<(m, n, k) -> (n, k floordiv 16)>,
      affine_map<(m, n, k) -> (m, n)>],
     iterator_types = ["parallel", "parallel", "reduction"]}
    %A1, %scaleA1, %B1, %scaleB1, %acc0
    : vector<64x16xf8E5M2>, vector<64x1xf8E8M0FNU>,
      vector<64x16xf8E5M2>, vector<64x1xf8E8M0FNU>
      into vector<64x64xf32>

  // ---- K block 2 : [32, 48) ----
  %A2 = vector.extract_strided_slice %A
      offsets = [0, 32], sizes = [64, 16], strides = [1, 1]
      : vector<64x64xf8E5M2> to vector<64x16xf8E5M2>
  %B2 = vector.extract_strided_slice %B
      offsets = [0, 32], sizes = [64, 16], strides = [1, 1]
      : vector<64x64xf8E5M2> to vector<64x16xf8E5M2>
  %scaleA2 = vector.extract_strided_slice %scaleA
      offsets = [0, 2], sizes = [64, 1], strides = [1, 1]
      : vector<64x4xf8E8M0FNU> to vector<64x1xf8E8M0FNU>
  %scaleB2 = vector.extract_strided_slice %scaleB
      offsets = [0, 2], sizes = [64, 1], strides = [1, 1]
      : vector<64x4xf8E8M0FNU> to vector<64x1xf8E8M0FNU>
  %acc2 = vector.scaled_contract
    {indexing_maps = [
      affine_map<(m, n, k) -> (m, k)>,
      affine_map<(m, n, k) -> (m, k floordiv 16)>,
      affine_map<(m, n, k) -> (n, k)>,
      affine_map<(m, n, k) -> (n, k floordiv 16)>,
      affine_map<(m, n, k) -> (m, n)>],
     iterator_types = ["parallel", "parallel", "reduction"]}
    %A2, %scaleA2, %B2, %scaleB2, %acc1
    : vector<64x16xf8E5M2>, vector<64x1xf8E8M0FNU>,
      vector<64x16xf8E5M2>, vector<64x1xf8E8M0FNU>
      into vector<64x64xf32>

  // ---- K block 3 : [48, 64) ----
  %A3 = vector.extract_strided_slice %A
      offsets = [0, 48], sizes = [64, 16], strides = [1, 1]
      : vector<64x64xf8E5M2> to vector<64x16xf8E5M2>
  %B3 = vector.extract_strided_slice %B
      offsets = [0, 48], sizes = [64, 16], strides = [1, 1]
      : vector<64x64xf8E5M2> to vector<64x16xf8E5M2>
  %scaleA3 = vector.extract_strided_slice %scaleA
      offsets = [0, 3], sizes = [64, 1], strides = [1, 1]
      : vector<64x4xf8E8M0FNU> to vector<64x1xf8E8M0FNU>
  %scaleB3 = vector.extract_strided_slice %scaleB
      offsets = [0, 3], sizes = [64, 1], strides = [1, 1]
      : vector<64x4xf8E8M0FNU> to vector<64x1xf8E8M0FNU>

  %acc3 = vector.scaled_contract
    {indexing_maps = [
      affine_map<(m, n, k) -> (m, k)>,
      affine_map<(m, n, k) -> (m, k floordiv 16)>,
      affine_map<(m, n, k) -> (n, k)>,
      affine_map<(m, n, k) -> (n, k floordiv 16)>,
      affine_map<(m, n, k) -> (m, n)>],
     iterator_types = ["parallel", "parallel", "reduction"]}
    %A3, %scaleA3, %B3, %scaleB3, %acc2
    : vector<64x16xf8E5M2>, vector<64x1xf8E8M0FNU>,
      vector<64x16xf8E5M2>, vector<64x1xf8E8M0FNU>
      into vector<64x64xf32>

  return %acc3 : vector<64x64xf32>
}

Block scaled K dim is now unrolled into separate partial scaled contractions with each having fixed single scale in the reduction dimension. Currently, the indexing maps still contain the semi-affine encoding to preserve information.

When block size info is not needed anymore, the maps could be simplified to remove the unit dim and semi-affine encoding such as:

%scaleA_cast = vector.shape_cast %scaleA0 : vector<64x1xf8E8M0FNU> to vector<64xf8E8M0FNU>
%scaleB_cast = vector.shape_cast %scaleB0 : vector<64x1xf8E8M0FNU> to vector<64xf8E8M0FNU>
%acc0 = vector.scaled_contract
  {indexing_maps = [
    affine_map<(m, n, k) -> (m, k)>,
    affine_map<(m, n, k) -> (m)>,
    affine_map<(m, n, k) -> (n, k)>,
    affine_map<(m, n, k) -> (n)>,
    affine_map<(m, n, k) -> (m, n)>],
    iterator_types = ["parallel", "parallel", "reduction"]}
  %A0, %scaleA_cast, %B0, %scaleB_cast, %C
  : vector<64x16xf8E5M2>, vector<64xf8E8M0FNU>,
    vector<64x16xf8E5M2>, vector<64xf8E8M0FNU>
    into vector<64x64xf32>

where scaling uses fixed value for each entry of parallel dims.

Another lowering example toward XeGPU.
xegpu.dpas_mx can natively capture block scaling semantic, let’s take an example from xegpu-wg-to-sg.mlir test showing a scaled contraction entry point to XeGPU stack:

%D = xegpu.dpas_mx %A, %B, %C
  scale_a = %scaleA scale_b = %scaleB
  : (vector<128x128xf8E5M2>, vector<128x128xf8E5M2>, vector<128x128xbf16>,
    vector<128x4xf8E8M0FNU>, vector<4x128xf8E8M0FNU>) -> vector<128x128xbf16>

The above op represents scaled contraction with block size 32 over contraction dimension. A lowering path could be linalg.scaled_contract → tiling → vectorization into vector.scaled_contract:

%D = vector.scaled_contract
  {indexing_maps = [
    affine_map<(m, n, k) -> (m, k)>,
    affine_map<(m, n, k) -> (m, k floordiv 32)>, // A - block scale 32
    affine_map<(m, n, k) -> (n, k)>,
    affine_map<(m, n, k) -> (k floordiv 32, n)>, // B - block scale 32
    affine_map<(m, n, k) -> (m, n)>],
  iterator_types = ["parallel", "parallel", "reduction"]}
  %A, %scaleA, %B, %scaleB, %C
  : vector<128x128xf8E5M2>, vector<128x4xf8E8M0FNU>,
    vector<128x128xf8E5M2>, vector<4x128xf8E8M0FNU> into vector<128x128xbf16>

which can directly lowered into xegpu.dpas_mx after basic shape, data type, and layout validation.

For x86 ACE, the exact lowering is still TBD.
However, conceptually it will follow: vector.scaled_contract → ACE register layout packing + BSRMOV* + x86.ace.top4mx*.

While ACE has its own specific layout requirements, this only a last mile adjustment that is target-specific and does not fit the architecture-agnostic design of Vector dialect.
An “ACE-ready” vector.scaled_contract would only ensure correct basic shapes (such as 16x16xf32 accumulator) and other details are left to the further lowering rewrites.

The goal for vector.scaled_contract is to clearly capture scaled contraction semantics while being target-independent.

Would you mind elaborating on desired semantics and some example uses?

My target scope is pretty narrow targeting just the scaled contraction without any other use cases in mind. However, the premise of a more flexible operation sounds interesting.
I wouldn’t want to blindly (and probably incorrectly) guess while I think you have direct experience here.

I will try to get to it next week. I’m however -1 on using semi affine_maps. If you are already restricting them to d floordiv B, i would rather just encoding B in the op. If not a flexible intrinsic op, I’d rather have it encoded in the operation explicitly. Will try to provide a more thorough answer next week (need to study more over the weekend).

I think scale contraction is required as a standalone vector op, so to bridge the lowering from linalg.scale_contract to xegpu.dpas_mx and nvvm.mma.block_scale. The op semantics has been well defined and broadly adopted by existing AI SW and HW, I think it make vector dialect less useful if it doesn’t include this operation.
It maybe interesting to explore a generic operation semantic like “inner_intrisic” to be added to vector op, but I think we should treat that as a separate discussion. It certainly adds additional complexity in the lowering path from linalg.scale_contract to xegpu.dpas_mx.

I like the semi-affine form better as it allows the scale tensor having same rank with A/B tensor, and the same rank simplify xegpu distribution process from workgroup level to lane. The op definition (like in Triton/Tile IR or XeGPU) may drop the mapping and instead implicitly rely on a scale factor defined (hard-coded) by the op semantics. But I think there is value for MLIR dialect to support more flexible representation (affine_map) to allow different type of scales (like 2d) for potential HW design exploration.

Hey @asiemien, thank you for sharing this proposal, and apologies for not responding earlier.

None of the targets that I normally work on are going to benefit from this, so it’s a bit tricky for me to have a well-informed opinion.

However, we did agree to introduce linalg.scaled_contract, and when discussing that Op, vector.scaled_contract was already mentioned, with no strong objections raised. One way or another, we need a reasonable path from linalg.scaled_contract to hardware intrinsics, and this sounds reasonable to me, with some minor caveats.

I suspect that Kunwar is referring to IREE’s iree_codegen.inner_tiled - a very interesting idea that’s relevant to this discussion and could help us create shared infrastructure to simplify lowering from Vector to various hardware intrinsics (AArch64, X86, RISC-V, XeGPU, NVVM).

However, there’s a major challenge with iree_codegen.inner_tiled:

  • it’s part of IREE and would have to be upstreamed first.
  • For that, we’d need somebody to put cycles into it and demonstrate that it would be beneficial upstream.

I don’t think it would be fair to expect Adam to do that when he is interested in a very specific Op/scenario.

My view is that we should accept vector.scaled_contract upstream, but refrain from generalising it. Instead, we should make it as restricted as possible.

+1

Mindful that the list of users is very limited, I would have two requirements before accepting this upstream:

  1. The decomposition fallback should be included in the initial implementation. Thus far, we have avoided “specialised” Ops in Vector that would only benefit a single target, and pretty much everything has a generic fallback. This would ensure that we avoid adding a “glue” Op that only benefits a single hardware target.

  2. Landing vector.scaled_contract should not be treated as a rejection of the alternative presented by Kunwar. Quite the contrary: in the future, we should remain open to supporting multiple paths, provided that there are users.

If that works for you, I think we should proceed with the implementation.

I note that @Groverkss is against introducing this Op, but the only viable alternative (iree_codegen.inner_tiled / vector.inner_intrinsic) would be too costly in the short term and, IMHO, should be left for later.

That said, we need to be mindful of minimising code duplication (e.g. between patterns for vector.contract and vector.scaled_contract) and avoiding Op explosion (we don’t want a repetition of [RFC] Op explosion in Linalg). This is something we can pay attention to as we make progress.

Thank you,
-Andrzej

Sorry, I didn’t get time to get to this. I won’t block this. If you think this is the right design, feel free to land it. I don’t think this is the best way to do it, but it probably gets us the same place. We can rethink later if needed.