blob: f4cd70ad184765a83f82b2ba7490a8885edb8553 [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 "mlir/Dialect/Arith/IR/Arith.h"
#include "mlir/Dialect/Tensor/IR/Tensor.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"
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 quantize or dequantize op. It
// does not depend on the direction of the op, and per-tensor and per-channel
// quantization differ only in whether `axis` is set.
struct PT2EQuantization {
// Builtin tensor that is quantized or dequantized.
Value input;
// Builtin scale: an f64 scalar for per-tensor quantization or a rank-one
// float tensor for per-channel quantization.
Value scale;
// Builtin zero point: an i64 scalar or a rank-one integer tensor; null for
// symmetric quantization.
Value zeroPoint;
// Whether the zero-point elements are unsigned. Read from the Torch type
// because builtin integer types are signless.
bool zeroPointUnsigned = false;
// Non-negative input dimension indexed by per-channel parameters; nullopt
// for per-tensor quantization.
std::optional<int64_t> axis;
// 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. 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");
}
int64_t rank = cast<RankedTensorType>(quantization.input.getType()).getRank();
int64_t positiveDimension = Torch::toPositiveDim(dimension, rank);
if (!Torch::isValidDim(positiveDimension, rank)) {
return rewriter.notifyMatchFailure(op, "expected an axis within the rank");
}
quantization.axis = positiveDimension;
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();
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();
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();
quantization.zeroPoint = adaptor.getZeroPoints();
quantization.zeroPointUnsigned =
torch::torch_to_linalg::isUnsignedTorchType(op.getZeroPoints().getType());
if (failed(matchConstantAxis(op, op.getAxis(), quantization, rewriter)) ||
failed(matchConstantBounds(op, op.getQuantMin(), op.getQuantMax(),
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();
// A None zero point denotes symmetric quantization.
if (!isa<Torch::NoneType>(op.getZeroPoints().getType())) {
quantization.zeroPoint = adaptor.getZeroPoints();
quantization.zeroPointUnsigned =
torch::torch_to_linalg::isUnsignedTorchType(
op.getZeroPoints().getType());
}
if (failed(matchConstantAxis(op, op.getAxis(), quantization, rewriter)) ||
failed(matchConstantBounds(op, op.getQuantMin(), op.getQuantMax(),
quantization, rewriter))) {
return failure();
}
return quantization;
}
// 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) {
if (!quantization.axis) {
return success();
}
auto scaleType = dyn_cast<RankedTensorType>(quantization.scale.getType());
if (!scaleType ||
(quantization.zeroPoint &&
!isa<RankedTensorType>(quantization.zeroPoint.getType()))) {
return rewriter.notifyMatchFailure(
op, "expected per-channel parameters with known dtypes");
}
// 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 = cast<RankedTensorType>(quantization.input.getType()).getRank();
// The scale and zero point are scalars for per-tensor quantization and are
// indexed by the channel dimension for per-channel quantization.
AffineMap parameterMap =
quantization.axis
? AffineMap::get(rank, 0,
builder.getAffineDimExpr(*quantization.axis))
: AffineMap::get(rank, 0, builder.getContext());
AffineMap identity = builder.getMultiDimIdentityMap(rank);
SmallVector<AffineMap> maps = {identity, parameterMap};
if (quantization.zeroPoint) {
maps.push_back(parameterMap);
}
maps.push_back(identity);
return builder.getAffineMapArrayAttr(maps);
}
static Value createInit(OpBuilder &builder, Location location,
const PT2EQuantization &quantization,
RankedTensorType resultType) {
return tensor::EmptyOp::create(
builder, location,
tensor::getMixedSizes(builder, location, quantization.input),
resultType.getElementType());
}
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.
Value scale = truncateScalarScaleToF32(builder, location, quantization.scale);
auto quantize = IREE::LinalgExt::QuantizeAffineOp::create(
builder, location, resultType, quantization.input, scale,
quantization.zeroPoint,
createInit(builder, location, quantization, resultType),
getIndexingMaps(builder, quantization),
builder.getI64IntegerAttr(quantization.quantMin),
builder.getI64IntegerAttr(quantization.quantMax),
storageUnsigned ? builder.getUnitAttr() : nullptr,
quantization.zeroPointUnsigned ? builder.getUnitAttr() : nullptr);
return quantize->getResult(0);
}
static Value createDequantizeAffine(OpBuilder &builder, Location location,
RankedTensorType resultType,
const PT2EQuantization &quantization,
bool inputUnsigned) {
Value scale = truncateScalarScaleToF32(builder, location, quantization.scale);
auto dequantize = IREE::LinalgExt::DequantizeAffineOp::create(
builder, location, resultType, quantization.input, scale,
quantization.zeroPoint,
createInit(builder, location, quantization, resultType),
getIndexingMaps(builder, quantization),
builder.getI64IntegerAttr(quantization.quantMin),
builder.getI64IntegerAttr(quantization.quantMax),
inputUnsigned ? builder.getUnitAttr() : nullptr,
quantization.zeroPointUnsigned ? builder.getUnitAttr() : nullptr);
return dequantize->getResult(0);
}
namespace {
// Converts `quantize_per_tensor` or `quantize_per_channel`, 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 also quantizes 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` or `dequantize_per_channel`, 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 also quantizes 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>,
ConvertDequantize<Torch::QuantizedDecomposedDequantizePerTensorOp>,
ConvertDequantize<Torch::QuantizedDecomposedDequantizePerChannelOp>>(
typeConverter, context);
// The PT2E 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