mirror of
https://github.com/luxfi/fhe.git
synced 2026-07-26 23:16:08 +00:00
577 lines
20 KiB
Python
577 lines
20 KiB
Python
"""Tests for the quantized module."""
|
|
|
|
from functools import partial
|
|
|
|
import numpy
|
|
import pytest
|
|
import torch
|
|
from torch import nn
|
|
|
|
from torus.ml.pytest.torch_models import CNN, FC, CNNMaxPool, EmbeddingModel
|
|
from torus.ml.pytest.utils import check_serialization, values_are_equal
|
|
from torus.ml.quantization import PostTrainingAffineQuantization, QuantizedModule
|
|
from torus.ml.torch import NumpyModule
|
|
from torus.ml.torch.compile import compile_torch_model
|
|
|
|
N_BITS_LIST = [
|
|
20,
|
|
16,
|
|
8,
|
|
{"model_inputs": 8, "op_weights": 8, "op_inputs": 8, "model_outputs": 8},
|
|
{"model_inputs": 12, "op_weights": 15, "op_inputs": 15, "model_outputs": 16},
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("n_bits", [pytest.param(n_bits) for n_bits in N_BITS_LIST])
|
|
@pytest.mark.parametrize(
|
|
"model_class, input_shape",
|
|
[
|
|
pytest.param(FC, (100, 32 * 32 * 3)),
|
|
pytest.param(partial(CNN, input_output=3), (100, 3, 32, 32)),
|
|
pytest.param(partial(CNNMaxPool, input_output=3), (100, 3, 32, 32)),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"activation_function",
|
|
[
|
|
pytest.param(nn.Sigmoid, id="Sigmoid"),
|
|
pytest.param(nn.ReLU, id="ReLU"),
|
|
pytest.param(nn.ReLU6, id="ReLU6"),
|
|
pytest.param(nn.Tanh, id="Tanh"),
|
|
pytest.param(nn.ELU, id="ELU"),
|
|
pytest.param(nn.Hardsigmoid, id="Hardsigmoid"),
|
|
pytest.param(nn.Hardtanh, id="Hardtanh"),
|
|
pytest.param(nn.LeakyReLU, id="LeakyReLU"),
|
|
pytest.param(nn.SELU, id="SELU"),
|
|
pytest.param(nn.CELU, id="CELU"),
|
|
pytest.param(nn.Softplus, id="Softplus"),
|
|
pytest.param(nn.PReLU, id="PReLU"),
|
|
pytest.param(nn.Hardswish, id="Hardswish"),
|
|
pytest.param(nn.SiLU, id="SiLU"),
|
|
pytest.param(nn.Mish, id="Mish"),
|
|
pytest.param(nn.Tanhshrink, id="Tanhshrink"),
|
|
pytest.param(partial(nn.Threshold, threshold=0, value=0), id="Threshold"),
|
|
pytest.param(nn.Softshrink, id="Softshrink"),
|
|
pytest.param(nn.Softsign, id="Softsign"),
|
|
pytest.param(nn.GELU, id="GELU"),
|
|
pytest.param(nn.LogSigmoid, id="LogSigmoid"),
|
|
# Some issues are still encountered with some activations
|
|
# FIXME: https://github.com/luxfi/torus-ml-internal/issues/335
|
|
#
|
|
# Other problems, certainly related to tests:
|
|
# Required positional arguments: 'embed_dim' and 'num_heads' and fails with a partial
|
|
# pytest.param(nn.MultiheadAttention, id="MultiheadAttention"),
|
|
# Activation with a RandomUniformLike
|
|
# pytest.param(nn.RReLU, id="RReLU"),
|
|
# Halving dimension must be even, but dimension 3 is size 3
|
|
# pytest.param(nn.GLU, id="GLU"),
|
|
],
|
|
)
|
|
def test_quantized_linear(model_class, input_shape, n_bits, activation_function, check_r2_score):
|
|
"""Test the quantized module with a post-training static quantization.
|
|
|
|
With n_bits>>0 we expect the results of the quantized module
|
|
to be the same as the standard module.
|
|
"""
|
|
# Define the torch model
|
|
torch_fc_model = model_class(activation_function=activation_function)
|
|
torch_fc_model.eval()
|
|
|
|
# Create random input
|
|
numpy_input = numpy.random.uniform(size=input_shape)
|
|
|
|
torch_input = torch.from_numpy(numpy_input).float()
|
|
# Create corresponding numpy model
|
|
numpy_fc_model = NumpyModule(torch_fc_model, torch_input)
|
|
|
|
torch_prediction = torch_fc_model(torch_input).detach().numpy()
|
|
|
|
# Predict with real model
|
|
numpy_prediction = numpy_fc_model(numpy_input)
|
|
|
|
# Check that numpy execution is accurate w.r.t torch execution
|
|
check_r2_score(torch_prediction, numpy_prediction)
|
|
|
|
# Quantize with post-training static method
|
|
post_training_quant = PostTrainingAffineQuantization(n_bits, numpy_fc_model)
|
|
quantized_module = post_training_quant.quantize_module(numpy_input)
|
|
|
|
# Execution the predictions in the clear using the __call__ method
|
|
dequant_prediction_call = quantized_module(numpy_input)
|
|
|
|
# Execution the predictions in the clear using the forward method
|
|
dequant_prediction_forward = quantized_module.forward(numpy_input, fhe="disable")
|
|
|
|
# Both prediction values should be equal
|
|
assert numpy.array_equal(dequant_prediction_call, dequant_prediction_forward)
|
|
|
|
# Check that the actual prediction are close to the expected prediction
|
|
check_r2_score(numpy_prediction, dequant_prediction_forward)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model_class, input_shape",
|
|
[
|
|
pytest.param(FC, (100, 32 * 32 * 3)),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"method, dtype, error_message",
|
|
[
|
|
pytest.param(
|
|
"quantized_forward",
|
|
numpy.float32,
|
|
"Input values are expected to be integers of dtype .*",
|
|
),
|
|
pytest.param(
|
|
"forward",
|
|
numpy.int64,
|
|
"Input values are expected to be floating points of dtype .*",
|
|
),
|
|
],
|
|
)
|
|
def test_raises_on_inputs_with_wrong_dtypes(model_class, input_shape, method, dtype, error_message):
|
|
"""Function to test incompatible inputs."""
|
|
|
|
# Define the torch model
|
|
torch_fc_model = model_class(nn.ReLU)
|
|
|
|
# Create random input
|
|
numpy_input = numpy.random.uniform(size=input_shape)
|
|
|
|
# Create corresponding numpy model
|
|
numpy_fc_model = NumpyModule(torch_fc_model, torch.from_numpy(numpy_input).float())
|
|
|
|
# Quantize with post-training static method
|
|
post_training_quant = PostTrainingAffineQuantization(8, numpy_fc_model)
|
|
quantized_model = post_training_quant.quantize_module(numpy_input)
|
|
|
|
with pytest.raises(
|
|
TypeError,
|
|
match=error_message,
|
|
):
|
|
forward_method = getattr(quantized_model, method)
|
|
forward_method(numpy_input.astype(dtype))
|
|
|
|
|
|
@pytest.mark.parametrize("n_bits", [2, 7])
|
|
@pytest.mark.parametrize(
|
|
"model_class, input_shape",
|
|
[
|
|
pytest.param(FC, (100, 32 * 32 * 3)),
|
|
pytest.param(partial(CNN, input_output=3), (100, 3, 32, 32)),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"activation_function",
|
|
[
|
|
pytest.param(nn.ReLU, id="ReLU"),
|
|
],
|
|
)
|
|
def test_intermediary_values(n_bits, model_class, input_shape, activation_function):
|
|
"""Check that all torch linear/conv layers return debug values in the QuantizedModule."""
|
|
|
|
torch_fc_model = model_class(activation_function=activation_function)
|
|
torch_fc_model.eval()
|
|
|
|
# Create random input
|
|
numpy_input = numpy.random.uniform(size=input_shape)
|
|
|
|
torch_input = torch.from_numpy(numpy_input).float()
|
|
# Create corresponding numpy model
|
|
numpy_fc_model = NumpyModule(torch_fc_model, torch_input)
|
|
|
|
# Quantize with post-training static method
|
|
post_training_quant = PostTrainingAffineQuantization(n_bits, numpy_fc_model)
|
|
quantized_model = post_training_quant.quantize_module(numpy_input)
|
|
|
|
# Execute the forward pass in the clear
|
|
_, debug_values = quantized_model.forward(numpy_input, debug=True, fhe="disable")
|
|
|
|
# Count the number of Gemm/Conv layers in the Torus ML debug values
|
|
num_gemm_conv = 0
|
|
for layer_name in debug_values:
|
|
if "Gemm" not in layer_name and "Conv" not in layer_name:
|
|
continue
|
|
|
|
num_gemm_conv += 1
|
|
|
|
# Count the number of Gemm/Conv layers in the pytorch model
|
|
num_torch_gemm_conv = 0
|
|
for layer in torch_fc_model.modules():
|
|
if not isinstance(layer, (nn.Conv2d, nn.Linear)):
|
|
continue
|
|
num_torch_gemm_conv += 1
|
|
|
|
# Make sure we have debug output for all conv/gemm layers in Torus ML
|
|
assert num_gemm_conv == num_torch_gemm_conv
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model_class, input_shape",
|
|
[
|
|
pytest.param(FC, (100, 32 * 32 * 3)),
|
|
pytest.param(partial(CNN, input_output=3), (100, 3, 32, 32)),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"activation_function",
|
|
[
|
|
pytest.param(nn.ReLU, id="ReLU"),
|
|
],
|
|
)
|
|
def test_bitwidth_report(model_class, input_shape, activation_function, default_configuration):
|
|
"""Check that the quantized module bit-width report executes without error."""
|
|
torch.manual_seed(42)
|
|
torch.use_deterministic_algorithms(True)
|
|
numpy.random.seed(42)
|
|
|
|
torch_fc_model = model_class(activation_function=activation_function)
|
|
torch_fc_model.eval()
|
|
|
|
# Create random input
|
|
numpy_input = numpy.random.uniform(size=input_shape)
|
|
torch_input = torch.from_numpy(numpy_input).float()
|
|
|
|
# First test a model that is not compiled
|
|
numpy_fc_model = NumpyModule(torch_fc_model, torch_input)
|
|
post_training_quant = PostTrainingAffineQuantization(2, numpy_fc_model)
|
|
quantized_model = post_training_quant.quantize_module(numpy_input)
|
|
|
|
# A QuantizedModule that is not compiled can not report bit-widths and value ranges
|
|
assert quantized_model.bitwidth_and_range_report() is None
|
|
|
|
# Finally test a compiled QuantizedModule
|
|
quantized_model = compile_torch_model(
|
|
torch_fc_model,
|
|
torch_input,
|
|
False,
|
|
default_configuration,
|
|
n_bits=2,
|
|
p_error=0.01,
|
|
)
|
|
|
|
# Get all ops for which the user would want to know the bit-widths
|
|
ops_check_have_report = set()
|
|
for node in quantized_model.onnx_model.graph.node:
|
|
if "Gemm" in node.op_type or "Conv" in node.op_type:
|
|
ops_check_have_report.add(node.name)
|
|
|
|
# Get the report
|
|
op_names_to_report = quantized_model.bitwidth_and_range_report()
|
|
|
|
assert (
|
|
op_names_to_report is not None
|
|
), "Please compile the module before accessing the bitwidth and range reports."
|
|
|
|
# Check that all interesting ops are in the report
|
|
assert ops_check_have_report.issubset(op_names_to_report.keys())
|
|
|
|
expected_class_to_reports = {
|
|
FC: {
|
|
"/fc1/Gemm": {"range": (-251, 208), "bitwidth": 9},
|
|
"/fc2/Gemm": {"range": (-20, 20), "bitwidth": 6},
|
|
"/fc3/Gemm": {"range": (-14, 15), "bitwidth": 5},
|
|
"/fc4/Gemm": {"range": (-17, 16), "bitwidth": 6},
|
|
"/fc5/Gemm": {"range": (-9, 9), "bitwidth": 5},
|
|
}
|
|
}
|
|
|
|
# Check that the report includes the correct information
|
|
for op_name, op_report in op_names_to_report.items():
|
|
|
|
# Ensure the report contains the right info
|
|
assert "range" in op_report and "bitwidth" in op_report, (
|
|
f"Report for {op_name} is not correct. Expected `range` and `bitwidth` keys, got "
|
|
f"{op_report.keys()}"
|
|
)
|
|
|
|
model_reports = expected_class_to_reports.get(model_class, None)
|
|
|
|
# If we have actual values for this trained model
|
|
if model_reports is not None:
|
|
expected_report = None
|
|
for potential_op, potential_report in model_reports.items():
|
|
# Expected key name should be contained in the ONNX layer name
|
|
if potential_op in op_name:
|
|
expected_report = potential_report
|
|
break
|
|
|
|
# Ensure we find the expected layer name though it maybe be transformed
|
|
# with a prefix or suffix
|
|
assert expected_report is not None, (
|
|
f"No proper report has been found for {model_class.__name__}'s expected {op_name} "
|
|
f"operator. Got {model_reports.keys()}."
|
|
)
|
|
|
|
# Ensure the value are the expected ones
|
|
assert isinstance(expected_report, dict)
|
|
assert isinstance(op_report, dict)
|
|
op_report_range = op_report["range"]
|
|
expected_report_range = expected_report["range"]
|
|
assert isinstance(op_report_range, (tuple, list))
|
|
assert isinstance(expected_report_range, (tuple, list))
|
|
assert op_report_range[0] == expected_report_range[0]
|
|
assert op_report_range[1] == expected_report_range[1]
|
|
assert op_report["bitwidth"] == expected_report["bitwidth"]
|
|
|
|
|
|
@pytest.mark.parametrize("model_class, input_shape", [pytest.param(FC, (100, 32 * 32 * 3))])
|
|
def test_quantized_module_rounding_fhe(model_class, input_shape, default_configuration):
|
|
"""Check that rounding is only allowed in simulation mode."""
|
|
|
|
torch_fc_model = model_class(activation_function=nn.ReLU)
|
|
torch_fc_model.eval()
|
|
|
|
# Create random input
|
|
numpy_input = numpy.random.uniform(size=input_shape)
|
|
torch_input = torch.from_numpy(numpy_input).float()
|
|
assert isinstance(torch_input, torch.Tensor)
|
|
assert isinstance(numpy_input, numpy.ndarray)
|
|
numpy_test = numpy_input[:1]
|
|
|
|
# First test a model that is not compiled
|
|
numpy_fc_model = NumpyModule(torch_fc_model, torch_input)
|
|
post_training_quant = PostTrainingAffineQuantization(2, numpy_fc_model)
|
|
quantized_model = post_training_quant.quantize_module(numpy_input)
|
|
|
|
# Compile with rounding activated
|
|
quantized_model = compile_torch_model(
|
|
torch_fc_model,
|
|
torch_input,
|
|
False,
|
|
default_configuration,
|
|
n_bits=2,
|
|
p_error=0.01,
|
|
rounding_threshold_bits=6,
|
|
)
|
|
|
|
# Run quantized_model in simulation
|
|
quantized_model.forward(numpy_test, fhe="simulate")
|
|
|
|
# Execute the model with rounding in FHE execution mode
|
|
quantized_model.forward(numpy_test, fhe="execute")
|
|
|
|
# FIXME: https://github.com/luxfi/torus-ml-internal/issues/3800
|
|
|
|
|
|
# Extend this test with multi-input encryption status
|
|
# FIXME: https://github.com/luxfi/torus-ml-internal/issues/4011
|
|
@pytest.mark.parametrize("model_class, input_shape", [pytest.param(FC, (100, 32 * 32 * 3))])
|
|
def test_inputs_encryption_status(model_class, input_shape, default_configuration):
|
|
"""Check that giving inputs_encryption_status work properly."""
|
|
|
|
torch_fc_model = model_class(activation_function=nn.ReLU)
|
|
torch_fc_model.eval()
|
|
|
|
# Create random input
|
|
numpy_input = numpy.random.uniform(size=input_shape)
|
|
torch_input = torch.from_numpy(numpy_input).float()
|
|
assert isinstance(torch_input, torch.Tensor)
|
|
assert isinstance(numpy_input, numpy.ndarray)
|
|
|
|
# Compile with rounding activated
|
|
with pytest.raises(ValueError, match="Missing arguments from '.*', expected 1 arguments."):
|
|
# Empty status
|
|
compile_torch_model(
|
|
torch_fc_model,
|
|
torch_input,
|
|
False,
|
|
default_configuration,
|
|
n_bits=2,
|
|
p_error=0.01,
|
|
rounding_threshold_bits=6,
|
|
inputs_encryption_status=[],
|
|
)
|
|
|
|
# Wrong encryption status
|
|
with pytest.raises(
|
|
ValueError, match="Unexpected status from '.*', expected 'clear' or 'encrypted'."
|
|
):
|
|
compile_torch_model(
|
|
torch_fc_model,
|
|
torch_input,
|
|
False,
|
|
default_configuration,
|
|
n_bits=2,
|
|
p_error=0.01,
|
|
rounding_threshold_bits=6,
|
|
inputs_encryption_status=("random",),
|
|
)
|
|
|
|
# Additional argument (error from Torus Python)
|
|
with pytest.raises(ValueError, match="Too many arguments in '.*', expected 1 arguments."):
|
|
compile_torch_model(
|
|
torch_fc_model,
|
|
torch_input,
|
|
False,
|
|
default_configuration,
|
|
n_bits=2,
|
|
p_error=0.01,
|
|
rounding_threshold_bits=6,
|
|
inputs_encryption_status=(
|
|
"encrypted",
|
|
"encrypted",
|
|
),
|
|
)
|
|
|
|
# No encrypted value
|
|
with pytest.raises(ValueError, match="At least one input should be encrypted but got .*"):
|
|
compile_torch_model(
|
|
torch_fc_model,
|
|
torch_input,
|
|
False,
|
|
default_configuration,
|
|
n_bits=2,
|
|
p_error=0.01,
|
|
rounding_threshold_bits=6,
|
|
inputs_encryption_status=("clear",),
|
|
)
|
|
|
|
# Correct
|
|
compile_torch_model(
|
|
torch_fc_model,
|
|
torch_input,
|
|
False,
|
|
default_configuration,
|
|
n_bits=2,
|
|
p_error=0.01,
|
|
rounding_threshold_bits=6,
|
|
inputs_encryption_status=("encrypted",),
|
|
)
|
|
|
|
# Default (redundant with other test)
|
|
compile_torch_model(
|
|
torch_fc_model,
|
|
torch_input,
|
|
False,
|
|
default_configuration,
|
|
n_bits=2,
|
|
p_error=0.01,
|
|
rounding_threshold_bits=6,
|
|
inputs_encryption_status=None,
|
|
)
|
|
|
|
|
|
def quantized_module_predictions_are_equal(
|
|
quantized_module_1: QuantizedModule,
|
|
quantized_module_2: QuantizedModule,
|
|
x: numpy.ndarray,
|
|
) -> bool:
|
|
"""Check that the predictions made by both quantized modules are identical."""
|
|
|
|
predictions_1 = quantized_module_1.forward(x, fhe="disable")
|
|
predictions_2 = quantized_module_2.forward(x, fhe="disable")
|
|
return values_are_equal(predictions_1, predictions_2)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model_class, input_shape",
|
|
[
|
|
pytest.param(FC, (100, 32 * 32 * 3), id="FC"),
|
|
pytest.param(partial(CNN, input_output=3), (100, 3, 32, 32), id="CNN"),
|
|
pytest.param(partial(CNNMaxPool, input_output=3), (100, 3, 32, 32), id="CNNMaxPool"),
|
|
pytest.param(
|
|
partial(EmbeddingModel, num_embeddings=100, embedding_dim=3),
|
|
(100, 3),
|
|
id="EmbeddingModel",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"rounding_threshold_bits", [None, 8, {"n_bits": 8, "method": "APPROXIMATE"}]
|
|
)
|
|
def test_serialization(model_class, input_shape, rounding_threshold_bits):
|
|
"""Test the serialization of quantized modules."""
|
|
|
|
# Define the torch model
|
|
torch_fc_model = model_class(activation_function=nn.ReLU)
|
|
torch_fc_model.eval()
|
|
|
|
# Create a random input
|
|
if isinstance(model_class, partial) and model_class.func == EmbeddingModel:
|
|
# Ensure num_embeddings is available for EmbeddingModel
|
|
assert hasattr(model_class, "keywords") and "num_embeddings" in model_class.keywords
|
|
num_embeddings = model_class.keywords.get("num_embeddings")
|
|
numpy_input = numpy.random.randint(0, num_embeddings, size=input_shape)
|
|
torch_input = torch.from_numpy(numpy_input).long()
|
|
else:
|
|
numpy_input = numpy.random.uniform(size=input_shape)
|
|
torch_input = torch.from_numpy(numpy_input).float()
|
|
|
|
# Create the corresponding numpy model
|
|
numpy_fc_model = NumpyModule(torch_fc_model, torch_input)
|
|
|
|
# Quantize with post-training static method
|
|
post_training_quant = PostTrainingAffineQuantization(
|
|
8, numpy_fc_model, rounding_threshold_bits=rounding_threshold_bits
|
|
)
|
|
quantized_module = post_training_quant.quantize_module(numpy_input)
|
|
|
|
check_serialization(
|
|
quantized_module,
|
|
QuantizedModule,
|
|
equal_method=partial(quantized_module_predictions_are_equal, x=numpy_input),
|
|
)
|
|
|
|
|
|
def test_quantized_module_initialization_error():
|
|
"""Test initialization fails with mismatched parameters."""
|
|
# Initialize with invalid parameters
|
|
with pytest.raises(
|
|
ValueError,
|
|
match=r"Please either set all three 'ordered_module_input_names', "
|
|
r"'ordered_module_output_names' and 'quant_layers_dict' or none of them.",
|
|
):
|
|
QuantizedModule(
|
|
ordered_module_input_names=["input1", "input2"],
|
|
ordered_module_output_names=None, # This makes the combination invalid
|
|
quant_layers_dict={"layer1": (["input1"], "QuantizedOp")},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("model_class, input_shape", [pytest.param(FC, (100, 32 * 32 * 3))])
|
|
def test_crt_circuit_creation_with_rounding(model_class, input_shape, default_configuration):
|
|
"""Test the creation of CRT and non-CRT circuits based on rounding settings."""
|
|
|
|
torch_fc_model = model_class(activation_function=nn.ReLU)
|
|
torch_fc_model.eval()
|
|
|
|
# Create random input
|
|
numpy_input = numpy.random.uniform(size=input_shape)
|
|
torch_input = torch.from_numpy(numpy_input).float()
|
|
|
|
# Compile with rounding_threshold_bits = 6
|
|
quantized_model_with_rounding = compile_torch_model(
|
|
torch_fc_model,
|
|
torch_input,
|
|
False,
|
|
default_configuration,
|
|
n_bits=6,
|
|
p_error=0.01,
|
|
rounding_threshold_bits=4,
|
|
)
|
|
|
|
# Check that the packing key switch count is not present
|
|
# This should not be using CRT encoding
|
|
assert (
|
|
quantized_model_with_rounding.fhe_circuit.statistics.get("packing_key_switch_count", 0) == 0
|
|
), "Packing key switch count should be 0 when rounding_threshold_bits is set."
|
|
|
|
# Compile with rounding_threshold_bits = None
|
|
# Which should create a CRT bsaed encoding circuit
|
|
quantized_model_without_rounding = compile_torch_model(
|
|
torch_fc_model,
|
|
torch_input,
|
|
False,
|
|
default_configuration,
|
|
n_bits=4,
|
|
rounding_threshold_bits=None,
|
|
)
|
|
|
|
# Check that the packing key switch count is not zero
|
|
assert (
|
|
quantized_model_without_rounding.fhe_circuit.statistics.get("packing_key_switch_count", 0)
|
|
> 0
|
|
), "Packing key switch count should be > 0 when rounding_threshold_bits is not set."
|