blob: 12a5a13e95cf4e878f518bd2ec3482c64f2da7bf [file]
// Copyright 2026 The IREE Authors
//
// Licensed under the Apache License v2.0 with LLVM Exceptions.
// See https://llvm.org/LICENSE.txt for license information.
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#include "compiler/plugins/input/Torch/InputConversion/Passes.h"
#include "iree/compiler/Dialect/LinalgExt/IR/LinalgExtDialect.h"
#include "iree/compiler/Dialect/LinalgExt/IR/LinalgExtOps.h"
#include "iree/compiler/Dialect/LinalgExt/Utils/Utils.h"
#include "llvm/ADT/APSInt.h"
#include "llvm/ADT/Sequence.h"
#include "llvm/ADT/bit.h"
#include "mlir/Dialect/Arith/IR/Arith.h"
#include "mlir/Dialect/Tensor/IR/Tensor.h"
#include "mlir/Dialect/Tensor/Utils/Utils.h"
#include "mlir/IR/Matchers.h"
#include "mlir/IR/TypeUtilities.h"
#include "mlir/Transforms/DialectConversion.h"
#include "torch-mlir/Conversion/TorchToLinalg/Utils.h"
#include "torch-mlir/Dialect/Torch/IR/TorchDialect.h"
#include "torch-mlir/Dialect/Torch/IR/TorchOps.h"
#include "torch-mlir/Dialect/Torch/Utils/Utils.h"
#include "torch-mlir/Dialect/TorchConversion/IR/TorchConversionOps.h"
#include "torch-mlir/Dialect/TorchConversion/Transforms/BackendTypeConversion.h"
#include <cmath>
#include <cstring>
#include <optional>
namespace mlir::iree_compiler::TorchInput {
#define GEN_PASS_DEF_CONVERTTORCHQUANTIZATIONTOLINALGEXTPASS
#include "compiler/plugins/input/Torch/InputConversion/Passes.h.inc"
namespace Torch = torch::Torch;
namespace TorchConversion = torch::TorchConversion;
namespace {
// The affine quantization described by a PT2E or torchao quantize or
// dequantize op. It does not depend on the direction of the op, and `blockSize`
// describes its granularity.
struct PT2EQuantization {
// Builtin tensor that is quantized or dequantized.
Value input;
// Builtin scale: an f64 scalar for per-tensor quantization or a float tensor
// with one value per block.
Value scale;
// Builtin zero point: an i64 scalar or an integer tensor shaped like the
// scale; null for symmetric quantization and for an integral float zero
// point.
Value zeroPoint;
// i32 values of an integral float zero point. Once the op is known to
// convert, a constant of them takes the place of `zeroPoint`.
DenseElementsAttr integralZeroPoint;
// Whether the zero-point elements are unsigned. Read from the Torch type
// because builtin integer types are signless.
bool zeroPointUnsigned = false;
// Number of consecutive input elements along each dimension that share
// parameters, as in torchao's `block_size`. A block spanning a dynamic
// dimension is `ShapedType::kDynamic`.
SmallVector<int64_t> blockSize;
// Inclusive lower bound of the quantized value range.
int64_t quantMin = 0;
// Inclusive upper bound of the quantized value range.
int64_t quantMax = 0;
};
} // namespace
// LinalgExt takes the bounds as attributes, so they must be Torch constants.
static LogicalResult matchConstantBounds(Operation *op, Value quantMin,
Value quantMax,
PT2EQuantization &quantization,
ConversionPatternRewriter &rewriter) {
if (!matchPattern(quantMin,
Torch::m_TorchConstantInt(&quantization.quantMin)) ||
!matchPattern(quantMax,
Torch::m_TorchConstantInt(&quantization.quantMax))) {
return rewriter.notifyMatchFailure(op,
"expected constant quantization bounds");
}
return success();
}
// LinalgExt indexes per-channel parameters with an affine map, so the axis
// must be a Torch constant. Sets `blockSize` to one along the axis and to the
// dimension size elsewhere. Requires `quantization.input` to be set.
static LogicalResult matchConstantAxis(Operation *op, Value axis,
PT2EQuantization &quantization,
ConversionPatternRewriter &rewriter) {
int64_t dimension;
if (!matchPattern(axis, Torch::m_TorchConstantInt(&dimension))) {
return rewriter.notifyMatchFailure(op, "expected a constant axis");
}
auto inputType = cast<RankedTensorType>(quantization.input.getType());
int64_t rank = inputType.getRank();
int64_t positiveDimension = Torch::toPositiveDim(dimension, rank);
if (!Torch::isValidDim(positiveDimension, rank)) {
return rewriter.notifyMatchFailure(op, "expected an axis within the rank");
}
quantization.blockSize = llvm::to_vector(inputType.getShape());
quantization.blockSize[positiveDimension] = 1;
return success();
}
// LinalgExt indexes per-channel-group parameters on the input with its last
// dimension split into groups, so the group size must be a Torch constant that
// divides the static last dimension of a rank-2 input. Sets `blockSize` to one
// along the first dimension and to the group size along the last. Requires
// `quantization.input` to be set.
static LogicalResult
matchConstantGroupSize(Operation *op, Value groupSize,
PT2EQuantization &quantization,
ConversionPatternRewriter &rewriter) {
int64_t size;
if (!matchPattern(groupSize, Torch::m_TorchConstantInt(&size)) || size <= 1) {
return rewriter.notifyMatchFailure(
op, "expected a constant group size greater than one");
}
auto inputType = cast<RankedTensorType>(quantization.input.getType());
if (inputType.getRank() != 2 || inputType.isDynamicDim(1)) {
return rewriter.notifyMatchFailure(
op, "expected a rank-2 input with a static last dimension");
}
if (inputType.getDimSize(1) % size != 0) {
return rewriter.notifyMatchFailure(
op, "expected groups that evenly split the last dimension");
}
quantization.blockSize = {1, size};
return success();
}
// Each vector along the last input dimension is a token with its own
// parameters. Sets `blockSize` to the dimension size along the last dimension
// and to one elsewhere. Requires `quantization.input` to be set.
static void setTokenBlockSize(PT2EQuantization &quantization) {
auto inputType = cast<RankedTensorType>(quantization.input.getType());
quantization.blockSize = llvm::to_vector(inputType.getShape());
std::fill_n(quantization.blockSize.begin(), inputType.getRank() - 1, 1);
}
// LinalgExt splits each dimension holding several blocks of several elements,
// which needs a static size. A non-constant block entry, which torch.export
// gives for a per-tensor block of a dynamic dimension, spans its dimension.
// Sets `blockSize` to the torchao block size. Requires `quantization.input` to
// be set.
static LogicalResult matchBlockSize(Operation *op, Value blockSize,
PT2EQuantization &quantization,
ConversionPatternRewriter &rewriter) {
ArrayRef<int64_t> inputShape =
cast<RankedTensorType>(quantization.input.getType()).getShape();
FailureOr<SmallVector<int64_t>> blocks =
Torch::getTorchaoBlockSize(blockSize, inputShape);
if (failed(blocks)) {
return rewriter.notifyMatchFailure(op, "expected a block_size list");
}
for (auto [size, block] : llvm::zip_equal(inputShape, *blocks)) {
if (block != 1 && block != size && ShapedType::isDynamic(size)) {
return rewriter.notifyMatchFailure(
op, "expected blocks that split only static dimensions");
}
}
quantization.blockSize = std::move(*blocks);
return success();
}
// Returns whether the elements of a Torch zero point are unsigned. Bools count
// as unsigned, since LinalgExt would otherwise sign-extend `true` to -1.
static bool isUnsignedZeroPoint(Type zeroPointType) {
return torch::torch_to_linalg::isUnsignedTorchType(zeroPointType) ||
cast<Torch::ValueTensorType>(zeroPointType).getDtype().isInteger(1);
}
// Returns the raw data of a dense or resource constant of f16, bf16, f32 or f64
// values, or nothing for other constants and for unavailable values, such as
// those of an elided resource. The data of a splat holds one element.
static std::optional<ArrayRef<char>> getFloatData(ElementsAttr attr) {
Type type = attr.getElementType();
if (!type.isF16() && !type.isBF16() && !type.isF32() && !type.isF64()) {
return std::nullopt;
}
if (auto dense = dyn_cast<DenseFPElementsAttr>(attr)) {
return dense.getRawData();
}
auto resource = dyn_cast<DenseResourceElementsAttr>(attr);
if (!resource) {
return std::nullopt;
}
ArrayRef<char> data = resource.getData();
if (static_cast<int64_t>(data.size()) * 8 !=
attr.getNumElements() * type.getIntOrFloatBitWidth()) {
return std::nullopt;
}
return data;
}
// Returns element `index` of `data`, which holds f16, bf16, f32 or f64 values
// of `type`. Each of them converts to double exactly.
static double readFloat(FloatType type, ArrayRef<char> data, int64_t index) {
const char *element = data.data() + index * type.getWidth() / 8;
if (type.isF64()) {
double value;
std::memcpy(&value, element, sizeof(value));
return value;
}
if (type.isF32()) {
float value;
std::memcpy(&value, element, sizeof(value));
return value;
}
assert((type.isF16() || type.isBF16()) && "expected a 16-bit float type");
uint16_t bits;
std::memcpy(&bits, element, sizeof(bits));
// A bf16 value is the upper half of an f32 one.
if (type.isBF16()) {
return llvm::bit_cast<float>(static_cast<uint32_t>(bits) << 16U);
}
return APFloat(type.getFloatSemantics(), APInt(16, bits)).convertToDouble();
}
// Returns the i32 values of a constant float zero point that holds integers
// within the bounds, and fails for other float zero points. Requires the scale
// and bounds of `quantization` to be set.
static FailureOr<DenseElementsAttr>
readIntegralFloatZeroPoint(Operation *op, Value torchZeroPoint,
const PT2EQuantization &quantization,
ConversionPatternRewriter &rewriter) {
ElementsAttr zeroPointAttr;
if (!matchPattern(torchZeroPoint, m_Constant(&zeroPointAttr))) {
return rewriter.notifyMatchFailure(
op, "expected integer or constant float zero points");
}
std::optional<ArrayRef<char>> data = getFloatData(zeroPointAttr);
if (!data) {
return rewriter.notifyMatchFailure(
op, "expected float zero points with readable values");
}
// PT2E dequantizes in the promoted type of a float zero point and the scale,
// so the zero point must not widen the scale.
auto floatType = cast<FloatType>(zeroPointAttr.getElementType());
auto scaleType =
dyn_cast<FloatType>(getElementTypeOrSelf(quantization.scale.getType()));
if (isa<Torch::QuantizedDecomposedDequantizePerChannelOp,
Torch::QuantizedDecomposedDequantizePerChannelGroupOp,
Torch::QuantizedDecomposedDequantizePerTokenOp>(op) &&
(!scaleType || (floatType != scaleType &&
floatType.getWidth() >= scaleType.getWidth()))) {
return rewriter.notifyMatchFailure(
op, "expected float zero points that do not widen the scales");
}
// The values become i32 zero points.
int64_t lowerBound = std::max(quantization.quantMin, llvm::minIntN(32));
int64_t upperBound = std::min(quantization.quantMax, llvm::maxIntN(32));
int64_t numValues =
zeroPointAttr.isSplat() ? 1 : zeroPointAttr.getNumElements();
SmallVector<int32_t> values;
values.reserve(numValues);
for (int64_t index : llvm::seq<int64_t>(0, numValues)) {
double value = readFloat(floatType, *data, index);
// NaN fails the first comparison, and infinities the bounds.
if (value != std::trunc(value) || value < lowerBound ||
value > upperBound) {
return rewriter.notifyMatchFailure(
op, "expected float zero points with integer values within the "
"bounds");
}
values.push_back(static_cast<int32_t>(value));
}
return DenseElementsAttr::get(
zeroPointAttr.getShapedType().clone(rewriter.getI32Type()),
ArrayRef(values));
}
// Returns whether a dense integer zero point holds a value outside the bounds
// of `quantization`, reading its values as unsigned if `isUnsigned`.
static bool hasOutOfBoundsZeroPoint(Value torchZeroPoint, bool isUnsigned,
const PT2EQuantization &quantization) {
DenseIntElementsAttr zeroPointAttr;
if (!matchPattern(torchZeroPoint, m_Constant(&zeroPointAttr))) {
return false;
}
APSInt lowerBound = APSInt::get(quantization.quantMin);
APSInt upperBound = APSInt::get(quantization.quantMax);
return llvm::any_of(zeroPointAttr.getValues<APInt>(), [&](const APInt &bits) {
APSInt value(bits, isUnsigned);
return APSInt::compareValues(value, lowerBound) < 0 ||
APSInt::compareValues(value, upperBound) > 0;
});
}
// Sets the zero point of `quantization` from `torchZeroPoint` and its converted
// value `zeroPoint`. A None zero point and a float one known to be zero leave
// it unset, which denotes symmetric quantization. Other float zero points
// convert only if they hold integers, which are kept in `integralZeroPoint`.
// Float zero points and dense integer zero points must lie within the bounds.
// Requires the scale and bounds of `quantization` to be set.
static LogicalResult matchZeroPoint(Operation *op, Value torchZeroPoint,
Value zeroPoint,
PT2EQuantization &quantization,
ConversionPatternRewriter &rewriter) {
if (isa<Torch::NoneType>(torchZeroPoint.getType())) {
return success();
}
if (isa<FloatType>(getElementTypeOrSelf(zeroPoint))) {
if (IREE::LinalgExt::isKnownZero(torchZeroPoint)) {
return success();
}
FailureOr<DenseElementsAttr> integralZeroPoint =
readIntegralFloatZeroPoint(op, torchZeroPoint, quantization, rewriter);
if (failed(integralZeroPoint)) {
return failure();
}
quantization.integralZeroPoint = *integralZeroPoint;
return success();
}
bool zeroPointUnsigned = isUnsignedZeroPoint(torchZeroPoint.getType());
if (hasOutOfBoundsZeroPoint(torchZeroPoint, zeroPointUnsigned,
quantization)) {
return rewriter.notifyMatchFailure(
op, "expected constant zero points within the bounds");
}
quantization.zeroPoint = zeroPoint;
quantization.zeroPointUnsigned = zeroPointUnsigned;
return success();
}
// How each PT2E op class spells its operands is the only thing that differs
// between the ops of one direction, so each class has its own reader.
static FailureOr<PT2EQuantization> readPT2EQuantization(
Torch::QuantizedDecomposedQuantizePerTensorOp op,
Torch::QuantizedDecomposedQuantizePerTensorOp::Adaptor adaptor,
ConversionPatternRewriter &rewriter) {
PT2EQuantization quantization;
quantization.input = adaptor.getInput();
quantization.scale = adaptor.getScale();
quantization.zeroPoint = adaptor.getZeroPoint();
quantization.blockSize = llvm::to_vector(
cast<RankedTensorType>(quantization.input.getType()).getShape());
if (failed(matchConstantBounds(op, op.getQuantMin(), op.getQuantMax(),
quantization, rewriter))) {
return failure();
}
return quantization;
}
static FailureOr<PT2EQuantization> readPT2EQuantization(
Torch::QuantizedDecomposedDequantizePerTensorOp op,
Torch::QuantizedDecomposedDequantizePerTensorOp::Adaptor adaptor,
ConversionPatternRewriter &rewriter) {
PT2EQuantization quantization;
quantization.input = adaptor.getInput();
quantization.scale = adaptor.getScale();
quantization.zeroPoint = adaptor.getZeroPoint();
quantization.blockSize = llvm::to_vector(
cast<RankedTensorType>(quantization.input.getType()).getShape());
if (failed(matchConstantBounds(op, op.getQuantMin(), op.getQuantMax(),
quantization, rewriter))) {
return failure();
}
return quantization;
}
static FailureOr<PT2EQuantization> readPT2EQuantization(
Torch::QuantizedDecomposedQuantizePerChannelOp op,
Torch::QuantizedDecomposedQuantizePerChannelOp::Adaptor adaptor,
ConversionPatternRewriter &rewriter) {
PT2EQuantization quantization;
quantization.input = adaptor.getInput();
quantization.scale = adaptor.getScales();
if (failed(matchConstantAxis(op, op.getAxis(), quantization, rewriter)) ||
failed(matchConstantBounds(op, op.getQuantMin(), op.getQuantMax(),
quantization, rewriter)) ||
failed(matchZeroPoint(op, op.getZeroPoints(), adaptor.getZeroPoints(),
quantization, rewriter))) {
return failure();
}
return quantization;
}
static FailureOr<PT2EQuantization> readPT2EQuantization(
Torch::QuantizedDecomposedDequantizePerChannelOp op,
Torch::QuantizedDecomposedDequantizePerChannelOp::Adaptor adaptor,
ConversionPatternRewriter &rewriter) {
PT2EQuantization quantization;
quantization.input = adaptor.getInput();
quantization.scale = adaptor.getScales();
if (failed(matchConstantAxis(op, op.getAxis(), quantization, rewriter)) ||
failed(matchConstantBounds(op, op.getQuantMin(), op.getQuantMax(),
quantization, rewriter)) ||
failed(matchZeroPoint(op, op.getZeroPoints(), adaptor.getZeroPoints(),
quantization, rewriter))) {
return failure();
}
return quantization;
}
static FailureOr<PT2EQuantization> readPT2EQuantization(
Torch::QuantizedDecomposedQuantizePerChannelGroupOp op,
Torch::QuantizedDecomposedQuantizePerChannelGroupOp::Adaptor adaptor,
ConversionPatternRewriter &rewriter) {
PT2EQuantization quantization;
quantization.input = adaptor.getInput();
quantization.scale = adaptor.getScales();
if (failed(matchConstantGroupSize(op, op.getGroupSize(), quantization,
rewriter)) ||
failed(matchConstantBounds(op, op.getQuantMin(), op.getQuantMax(),
quantization, rewriter)) ||
failed(matchZeroPoint(op, op.getZeroPoints(), adaptor.getZeroPoints(),
quantization, rewriter))) {
return failure();
}
return quantization;
}
static FailureOr<PT2EQuantization> readPT2EQuantization(
Torch::QuantizedDecomposedDequantizePerChannelGroupOp op,
Torch::QuantizedDecomposedDequantizePerChannelGroupOp::Adaptor adaptor,
ConversionPatternRewriter &rewriter) {
PT2EQuantization quantization;
quantization.input = adaptor.getInput();
quantization.scale = adaptor.getScales();
if (failed(matchConstantGroupSize(op, op.getGroupSize(), quantization,
rewriter)) ||
failed(matchConstantBounds(op, op.getQuantMin(), op.getQuantMax(),
quantization, rewriter)) ||
failed(matchZeroPoint(op, op.getZeroPoints(), adaptor.getZeroPoints(),
quantization, rewriter))) {
return failure();
}
return quantization;
}
static FailureOr<PT2EQuantization> readPT2EQuantization(
Torch::QuantizedDecomposedQuantizePerTokenOp op,
Torch::QuantizedDecomposedQuantizePerTokenOp::Adaptor adaptor,
ConversionPatternRewriter &rewriter) {
PT2EQuantization quantization;
quantization.input = adaptor.getInput();
quantization.scale = adaptor.getScales();
setTokenBlockSize(quantization);
if (failed(matchConstantBounds(op, op.getQuantMin(), op.getQuantMax(),
quantization, rewriter)) ||
failed(matchZeroPoint(op, op.getZeroPoints(), adaptor.getZeroPoints(),
quantization, rewriter))) {
return failure();
}
return quantization;
}
static FailureOr<PT2EQuantization> readPT2EQuantization(
Torch::QuantizedDecomposedDequantizePerTokenOp op,
Torch::QuantizedDecomposedDequantizePerTokenOp::Adaptor adaptor,
ConversionPatternRewriter &rewriter) {
PT2EQuantization quantization;
quantization.input = adaptor.getInput();
quantization.scale = adaptor.getScales();
setTokenBlockSize(quantization);
if (failed(matchConstantBounds(op, op.getQuantMin(), op.getQuantMax(),
quantization, rewriter)) ||
failed(matchZeroPoint(op, op.getZeroPoints(), adaptor.getZeroPoints(),
quantization, rewriter))) {
return failure();
}
return quantization;
}
// Both torchao directions spell their operands alike. None bounds are the
// bounds of `storageDtype`.
template <typename OpTy>
static FailureOr<PT2EQuantization>
readTorchaoQuantization(OpTy op, typename OpTy::Adaptor adaptor,
Value storageDtype,
ConversionPatternRewriter &rewriter) {
PT2EQuantization quantization;
quantization.input = adaptor.getInput();
quantization.scale = adaptor.getScale();
if (failed(matchBlockSize(op, op.getBlockSize(), quantization, rewriter))) {
return failure();
}
FailureOr<std::pair<double, double>> bounds = Torch::getTorchaoQuantBounds(
op.getQuantMin(), op.getQuantMax(), storageDtype);
if (failed(bounds)) {
return rewriter.notifyMatchFailure(
op, "expected constant quantization bounds and dtype");
}
// LinalgExt takes integer bounds within the storage dtype. The verifier
// bounds quant_min from below and quant_max from above, so ordered bounds
// lie within it.
auto [quantMin, quantMax] = *bounds;
if (quantMin > quantMax || quantMin != std::trunc(quantMin) ||
quantMax != std::trunc(quantMax)) {
return rewriter.notifyMatchFailure(
op, "expected integral bounds with quant_min <= quant_max");
}
quantization.quantMin = static_cast<int64_t>(quantMin);
quantization.quantMax = static_cast<int64_t>(quantMax);
if (failed(matchZeroPoint(op, op.getZeroPoint(), adaptor.getZeroPoint(),
quantization, rewriter))) {
return failure();
}
return quantization;
}
static FailureOr<PT2EQuantization>
readPT2EQuantization(Torch::TorchaoQuantizeAffineOp op,
Torch::TorchaoQuantizeAffineOp::Adaptor adaptor,
ConversionPatternRewriter &rewriter) {
return readTorchaoQuantization(op, adaptor, op.getOutputDtype(), rewriter);
}
static FailureOr<PT2EQuantization>
readPT2EQuantization(Torch::TorchaoDequantizeAffineOp op,
Torch::TorchaoDequantizeAffineOp::Adaptor adaptor,
ConversionPatternRewriter &rewriter) {
return readTorchaoQuantization(op, adaptor, op.getInputDtype(), rewriter);
}
// Returns, for each input dimension, its dimensions in the blocked type. A
// dimension with several blocks of several elements is split into the block
// and the position within it, so that LinalgExt can index the block.
static SmallVector<ReassociationIndices>
getBlockedReassociation(const PT2EQuantization &quantization) {
ArrayRef<int64_t> inputShape =
cast<RankedTensorType>(quantization.input.getType()).getShape();
SmallVector<ReassociationIndices> reassociation;
int64_t dim = 0;
for (auto [size, block] :
llvm::zip_equal(inputShape, quantization.blockSize)) {
if (block == 1 || block == size) {
reassociation.push_back({dim++});
} else {
reassociation.push_back({dim, dim + 1});
dim += 2;
}
}
return reassociation;
}
// Returns the type the LinalgExt op works on: the input type, split as
// `getBlockedReassociation` describes.
static RankedTensorType getBlockedType(const PT2EQuantization &quantization) {
auto inputType = cast<RankedTensorType>(quantization.input.getType());
SmallVector<int64_t> shape;
for (auto [dims, size, block] :
llvm::zip_equal(getBlockedReassociation(quantization),
inputType.getShape(), quantization.blockSize)) {
if (dims.size() == 1) {
shape.push_back(size);
} else {
shape.append({size / block, block});
}
}
return inputType.clone(shape);
}
// Returns the dimensions of the blocked type that select a block and so index
// the scale and zero point: none for per-tensor quantization, the channel
// dimension for per-channel quantization, the row and group for
// per-channel-group quantization, every dimension but the last for per-token
// quantization, and every dimension with several blocks for torchao
// quantization.
static SmallVector<unsigned>
getParameterDims(const PT2EQuantization &quantization) {
ArrayRef<int64_t> inputShape =
cast<RankedTensorType>(quantization.input.getType()).getShape();
SmallVector<unsigned> parameterDims;
for (auto [dims, size, block] :
llvm::zip_equal(getBlockedReassociation(quantization), inputShape,
quantization.blockSize)) {
if (block != size) {
parameterDims.push_back(dims.front());
}
}
return parameterDims;
}
// Returns the type of the builtin zero point, which an integral float zero
// point has before it is created, or null for symmetric quantization.
static Type getZeroPointType(const PT2EQuantization &quantization) {
if (quantization.integralZeroPoint) {
return quantization.integralZeroPoint.getType();
}
return quantization.zeroPoint ? quantization.zeroPoint.getType() : Type();
}
// Checks whether LinalgExt can use the scale and zero point, reporting why
// unsupported parameters prevent conversion. Per-tensor scalars need no
// additional checks. `realType` is the element type of the floating-point
// input to quantize or output from dequantize.
static LogicalResult
checkScaleAndZeroPointSupport(Operation *op, Type realType,
const PT2EQuantization &quantization,
ConversionPatternRewriter &rewriter) {
// Only per-tensor scales are scalars. The block size alone cannot identify
// per-tensor quantization, since per-channel quantization along a dimension
// of size one has the same block size.
if (isa<FloatType>(quantization.scale.getType())) {
return success();
}
auto scaleType = dyn_cast<RankedTensorType>(quantization.scale.getType());
Type zeroPointType = getZeroPointType(quantization);
if (!scaleType || (zeroPointType && !isa<RankedTensorType>(zeroPointType))) {
return rewriter.notifyMatchFailure(
op, "expected per-channel parameters with known dtypes");
}
// LinalgExt indexes the scale and zero point by the dimensions that select a
// block, so their non-unit sizes must be the block counts along those
// dimensions.
ArrayRef<int64_t> blockedShape = getBlockedType(quantization).getShape();
SmallVector<int64_t> blockCounts =
llvm::map_to_vector(getParameterDims(quantization),
[&](unsigned dim) { return blockedShape[dim]; });
auto isShapedLikeBlocks = [&](Type parameterType) {
auto nonUnitSizes = llvm::make_filter_range(
cast<RankedTensorType>(parameterType).getShape(),
[](int64_t size) { return size != 1; });
return llvm::equal(nonUnitSizes, blockCounts);
};
if (!isShapedLikeBlocks(scaleType) ||
(zeroPointType && !isShapedLikeBlocks(zeroPointType))) {
return rewriter.notifyMatchFailure(
op, "expected parameters shaped like blocks");
}
// LinalgExt cannot mix distinct float types of equal width, e.g. a bf16 real
// type with f16 scales. Per-tensor scales are converted to f32, which no
// PyTorch real type conflicts with.
auto scaleElementType = cast<FloatType>(scaleType.getElementType());
if (scaleElementType != realType &&
scaleElementType.getWidth() == realType.getIntOrFloatBitWidth()) {
return rewriter.notifyMatchFailure(
op, "expected scales and real values of distinct widths or one type");
}
return success();
}
// Truncates a per-tensor scale to f32 and returns tensor scales unchanged. A
// per-tensor scale is a Python float, which converts to an f64 scalar.
// Inductor applies it as an f32 constant and rounds once to the result dtype,
// and LinalgExt computes in the element type of the scale, so an f32 scale
// reproduces that arithmetic. Eager PyTorch computes f64 results in f64, which
// this deliberately does not follow.
// https://github.com/pytorch/pytorch/blob/cf30153c4c131c8164ee7798e5022d810682e2cb/torch/_inductor/lowering.py#L1960-L1965
static Value truncateScalarScaleToF32(OpBuilder &builder, Location location,
Value scale) {
Type f32Type = builder.getF32Type();
if (!isa<FloatType>(scale.getType()) || scale.getType() == f32Type) {
return scale;
}
return arith::TruncFOp::create(builder, location, f32Type, scale);
}
// Returns the LinalgExt indexing maps in operand order: input, scale, zero
// point when present, and output.
static ArrayAttr getIndexingMaps(Builder &builder,
const PT2EQuantization &quantization) {
int64_t rank = getBlockedType(quantization).getRank();
AffineMap parameterMap = AffineMap::getMultiDimMapWithTargets(
rank, getParameterDims(quantization), builder.getContext());
AffineMap identity = builder.getMultiDimIdentityMap(rank);
SmallVector<AffineMap> maps = {identity, parameterMap};
if (getZeroPointType(quantization)) {
maps.push_back(parameterMap);
}
maps.push_back(identity);
return builder.getAffineMapArrayAttr(maps);
}
// Returns an empty tensor shaped like `input`, with the element type of
// `resultType`.
static Value createInit(OpBuilder &builder, Location location, Value input,
RankedTensorType resultType) {
return tensor::EmptyOp::create(
builder, location, tensor::getMixedSizes(builder, location, input),
resultType.getElementType());
}
// Returns `quantization.input`, expanded into the blocked type.
static Value createBlockedInput(OpBuilder &builder, Location location,
const PT2EQuantization &quantization) {
RankedTensorType blockedType = getBlockedType(quantization);
if (blockedType == quantization.input.getType()) {
return quantization.input;
}
return tensor::ExpandShapeOp::create(builder, location, blockedType,
quantization.input,
getBlockedReassociation(quantization));
}
// Collapses a `result` with the blocked shape back into the input shape.
static Value collapseBlockedResult(OpBuilder &builder, Location location,
Value result,
const PT2EQuantization &quantization) {
if (getBlockedType(quantization) == quantization.input.getType()) {
return result;
}
return tensor::CollapseShapeOp::create(builder, location, result,
getBlockedReassociation(quantization));
}
// Drops the static unit dimensions of a tensor `parameter`, since LinalgExt
// does not index them, and returns scalars and an absent zero point unchanged.
static Value collapseParameter(OpBuilder &builder, Location location,
Value parameter) {
auto parameterType =
parameter ? dyn_cast<RankedTensorType>(parameter.getType()) : nullptr;
if (!parameterType) {
return parameter;
}
llvm::SmallBitVector unitDims(parameterType.getRank());
for (auto [dim, size] : llvm::enumerate(parameterType.getShape())) {
unitDims[dim] = size == 1;
}
if (unitDims.none()) {
return parameter;
}
return tensor::dropGivenUnitDims(builder, location, parameter, unitDims);
}
// Returns the builtin zero point, creating the constant of an integral float
// zero point.
static Value createZeroPoint(OpBuilder &builder, Location location,
const PT2EQuantization &quantization) {
if (!quantization.integralZeroPoint) {
return quantization.zeroPoint;
}
return arith::ConstantOp::create(builder, location,
quantization.integralZeroPoint);
}
static Value createQuantizeAffine(OpBuilder &builder, Location location,
RankedTensorType resultType,
const PT2EQuantization &quantization,
bool storageUnsigned) {
// The PT2E reference upcasts f16 and bf16 inputs and computes in f32. It
// multiplies by `1.0 / scale` rounded to f32, whereas LinalgExt divides by
// the scale, so results can differ by one ulp before rounding. The
// per-channel-group and per-token references also add the zero point before
// rounding, so ties with odd zero points differ. Torch-to-Linalg follows only
// the per-token one. torchao also multiplies by `1.0 / scale`, and computes
// in the wider of the scale and real types, whereas LinalgExt computes in the
// scale type.
Value input = createBlockedInput(builder, location, quantization);
Value scale = truncateScalarScaleToF32(builder, location, quantization.scale);
scale = collapseParameter(builder, location, scale);
Value zeroPoint = collapseParameter(
builder, location, createZeroPoint(builder, location, quantization));
Value init = createInit(builder, location, input, resultType);
auto quantize = IREE::LinalgExt::QuantizeAffineOp::create(
builder, location, init.getType(), input, scale, zeroPoint, init,
getIndexingMaps(builder, quantization),
builder.getI64IntegerAttr(quantization.quantMin),
builder.getI64IntegerAttr(quantization.quantMax),
storageUnsigned ? builder.getUnitAttr() : nullptr,
quantization.zeroPointUnsigned ? builder.getUnitAttr() : nullptr);
return collapseBlockedResult(builder, location, quantize->getResult(0),
quantization);
}
static Value createDequantizeAffine(OpBuilder &builder, Location location,
RankedTensorType resultType,
const PT2EQuantization &quantization,
bool inputUnsigned) {
// torchao subtracts the zero point in the real type, whereas LinalgExt
// subtracts integers, so results can differ for 16-bit real values with
// storage wider than 8 bits. torchao also multiplies in the wider of the
// scale and real types, whereas LinalgExt multiplies in the scale type.
Value input = createBlockedInput(builder, location, quantization);
Value scale = truncateScalarScaleToF32(builder, location, quantization.scale);
scale = collapseParameter(builder, location, scale);
Value zeroPoint = collapseParameter(
builder, location, createZeroPoint(builder, location, quantization));
Value init = createInit(builder, location, input, resultType);
auto dequantize = IREE::LinalgExt::DequantizeAffineOp::create(
builder, location, init.getType(), input, scale, zeroPoint, init,
getIndexingMaps(builder, quantization),
builder.getI64IntegerAttr(quantization.quantMin),
builder.getI64IntegerAttr(quantization.quantMax),
inputUnsigned ? builder.getUnitAttr() : nullptr,
quantization.zeroPointUnsigned ? builder.getUnitAttr() : nullptr);
return collapseBlockedResult(builder, location, dequantize->getResult(0),
quantization);
}
namespace {
// Converts `quantize_per_tensor`, `quantize_per_channel`,
// `quantize_per_channel_group`, `quantize_per_token` or torchao
// `quantize_affine`, whose input holds the real values and whose result holds
// the quantized storage.
template <typename OpTy>
struct ConvertQuantize final : OpConversionPattern<OpTy> {
using OpConversionPattern<OpTy>::OpConversionPattern;
using OpAdaptor = typename OpTy::Adaptor;
LogicalResult
matchAndRewrite(OpTy op, OpAdaptor adaptor,
ConversionPatternRewriter &rewriter) const override {
auto inputType = dyn_cast<RankedTensorType>(adaptor.getInput().getType());
auto resultType =
this->getTypeConverter()->template convertType<RankedTensorType>(
op.getType());
if (!inputType || !resultType) {
return rewriter.notifyMatchFailure(op,
"expected tensors with known dtypes");
}
// PT2E and torchao also quantize to fp8, which LinalgExt storage cannot
// express.
if (!isa<IntegerType>(resultType.getElementType())) {
return rewriter.notifyMatchFailure(op, "expected integer storage");
}
FailureOr<PT2EQuantization> quantization =
readPT2EQuantization(op, adaptor, rewriter);
if (failed(quantization)) {
return failure();
}
if (failed(checkScaleAndZeroPointSupport(
op,
/*realType=*/inputType.getElementType(), *quantization,
rewriter))) {
return failure();
}
bool storageUnsigned =
torch::torch_to_linalg::isUnsignedTorchType(op.getType());
rewriter.replaceOp(op,
createQuantizeAffine(rewriter, op.getLoc(), resultType,
*quantization, storageUnsigned));
return success();
}
};
// Converts `dequantize_per_tensor`, `dequantize_per_channel`,
// `dequantize_per_channel_group`, `dequantize_per_token` or torchao
// `dequantize_affine`, whose input holds the quantized storage and whose result
// holds the real values.
template <typename OpTy>
struct ConvertDequantize final : OpConversionPattern<OpTy> {
using OpConversionPattern<OpTy>::OpConversionPattern;
using OpAdaptor = typename OpTy::Adaptor;
LogicalResult
matchAndRewrite(OpTy op, OpAdaptor adaptor,
ConversionPatternRewriter &rewriter) const override {
auto inputType = dyn_cast<RankedTensorType>(adaptor.getInput().getType());
auto resultType =
this->getTypeConverter()->template convertType<RankedTensorType>(
op.getType());
if (!inputType || !resultType) {
return rewriter.notifyMatchFailure(op,
"expected tensors with known dtypes");
}
// PT2E and torchao also quantize to fp8, which LinalgExt storage cannot
// express.
if (!isa<IntegerType>(inputType.getElementType())) {
return rewriter.notifyMatchFailure(op, "expected integer storage");
}
FailureOr<PT2EQuantization> quantization =
readPT2EQuantization(op, adaptor, rewriter);
if (failed(quantization)) {
return failure();
}
if (failed(checkScaleAndZeroPointSupport(
op,
/*realType=*/resultType.getElementType(), *quantization,
rewriter))) {
return failure();
}
bool inputUnsigned =
torch::torch_to_linalg::isUnsignedTorchType(op.getInput().getType());
rewriter.replaceOp(op,
createDequantizeAffine(rewriter, op.getLoc(), resultType,
*quantization, inputUnsigned));
return success();
}
};
class ConvertTorchQuantizationToLinalgExtPass final
: public impl::ConvertTorchQuantizationToLinalgExtPassBase<
ConvertTorchQuantizationToLinalgExtPass> {
void getDependentDialects(DialectRegistry &registry) const override {
registry.insert<IREE::LinalgExt::IREELinalgExtDialect,
TorchConversion::TorchConversionDialect,
tensor::TensorDialect, arith::ArithDialect>();
}
void runOnOperation() override {
MLIRContext *context = &getContext();
ConversionTarget target(*context);
target.addLegalDialect<IREE::LinalgExt::IREELinalgExtDialect,
tensor::TensorDialect, arith::ArithDialect>();
TypeConverter typeConverter;
// Keeps types the backend conversion does not handle, such as the
// `!torch.none` zero points of symmetric quantization, so that they do not
// block operand conversion.
typeConverter.addConversion([](Type type) { return type; });
TorchConversion::setupBackendTypeConversion(target, typeConverter);
RewritePatternSet patterns(context);
patterns.add<
ConvertQuantize<Torch::QuantizedDecomposedQuantizePerTensorOp>,
ConvertQuantize<Torch::QuantizedDecomposedQuantizePerChannelOp>,
ConvertQuantize<Torch::QuantizedDecomposedQuantizePerChannelGroupOp>,
ConvertQuantize<Torch::QuantizedDecomposedQuantizePerTokenOp>,
ConvertQuantize<Torch::TorchaoQuantizeAffineOp>,
ConvertDequantize<Torch::QuantizedDecomposedDequantizePerTensorOp>,
ConvertDequantize<Torch::QuantizedDecomposedDequantizePerChannelOp>,
ConvertDequantize<
Torch::QuantizedDecomposedDequantizePerChannelGroupOp>,
ConvertDequantize<Torch::QuantizedDecomposedDequantizePerTokenOp>,
ConvertDequantize<Torch::TorchaoDequantizeAffineOp>>(typeConverter,
context);
// These ops are deliberately not marked illegal: ops whose metadata
// LinalgExt cannot represent remain for the Torch-to-Linalg lowering.
if (failed(applyPartialConversion(getOperation(), target,
std::move(patterns)))) {
signalPassFailure();
}
}
};
} // namespace
} // namespace mlir::iree_compiler::TorchInput