def test_joint_palettize_quantize_weights(
        self, compute_unit, backend, nbits, channel_group_size, quantize_first
    ):
        """
        If quantize_first is True:
            First quantize to get int8 weight, and then palettize to n-bit lut with int8 entries.
        If quantize_first is False:
            First palettize to get fp16 lut, and then quantize the lut to make int8 lut.

        Notice no matter applies which one first, the final output model's op order is guaranteed to be consistent
        by the common::canonicalize_quantized_lut_pattern graph pass.
        """
        model, inputs, torch_input_values, coreml_input_values = get_test_model_and_data_complex()
        torchmodel = torch.jit.trace(model, torch_input_values)
        mlmodel = ct.convert(
            torchmodel,
            inputs=inputs,
            convert_to="mlprogram",
            minimum_deployment_target=backend.opset_version,
            compute_precision=ct.precision.FLOAT16
            if backend.precision == "fp16"
            else ct.precision.FLOAT32,
            compute_units=compute_unit,
        )

        palettize_config = cto.coreml.OptimizationConfig(
            global_config=cto.coreml.OpPalettizerConfig(
                mode="uniform",
                nbits=nbits,
                granularity="per_grouped_channel",
                group_size=channel_group_size,
                weight_threshold=500,
            )
        )
        quant_config = cto.coreml.OptimizationConfig(
            global_config=cto.coreml.OpLinearQuantizerConfig(
                # Quantize the whole lut tensor as the lut usually is not huge.
                mode="linear",
                dtype="int8",
                granularity="per_tensor",
                weight_threshold=500,
            )
        )

        pytest_context_manager = nullcontext()
        if backend.opset_version < ct.target.iOS18:
            pytest_context_manager = pytest.raises(ValueError, match=self._ERR_MSG)

        with pytest_context_manager:
            if quantize_first:
                mlmodel_quantized = cto.coreml.linear_quantize_weights(mlmodel, quant_config)
                mlmodel_joint_palettized_quantized = cto.coreml.palettize_weights(
                    mlmodel_quantized, palettize_config, joint_compression=True
                )
            else:
                mlmodel_palettized = cto.coreml.palettize_weights(mlmodel, palettize_config)
                mlmodel_joint_palettized_quantized = cto.coreml.linear_quantize_weights(
                    mlmodel_palettized, quant_config, joint_compression=True
                )

        if backend.opset_version < ct.target.iOS18:
            return

        expected_ops = (
            ["constexpr_blockwise_shift_scale", "constexpr_lut_to_dense", "conv"] * 2
            + ["reshape"]
            + ["constexpr_blockwise_shift_scale", "constexpr_lut_to_dense", "linear"] * 2
            + ["constexpr_blockwise_shift_scale", "constexpr_lut_to_dense"] * 3
            + ["lstm"]
        )
        prog = mlmodel_joint_palettized_quantized._mil_program
        if channel_group_size == 0:
            # When doing lut first with per-tensor lut, the lut size is too small, so it's stored as ImmediateValue
            # which won't be quantized.
            ops_in_prog = get_op_types_in_program(prog)
            if nbits < 4 and not quantize_first:
                assert ops_in_prog.count("constexpr_blockwise_shift_scale") == 0
            else:
                assert ops_in_prog.count("constexpr_blockwise_shift_scale") >= 6
        else:
            assert get_op_types_in_program(prog) == expected_ops

        for linear_op in prog.find_ops(op_type="linear"):
            assert linear_op.weight.op.op_type == "constexpr_lut_to_dense"
        for conv_op in prog.find_ops(op_type="conv"):
            assert conv_op.weight.op.op_type == "constexpr_lut_to_dense"

        for quantize_op in prog.find_ops(op_type="constexpr_blockwise_shift_scale"):
            assert quantize_op.data.dtype == types.int8
            assert quantize_op.scale.dtype == types.fp16
            assert quantize_op.offset.dtype == types.int8
            assert quantize_op.outputs[0].child_ops[0].op_type == "constexpr_lut_to_dense"

        for palettize_op in prog.find_ops(op_type="constexpr_lut_to_dense"):
            assert palettize_op.lut.dtype == types.fp16
            assert palettize_op.indices.dtype == types.string_to_builtin(f"uint{nbits}")

        if _macos_version() >= (15, 0):
            verify_model_outputs(mlmodel, mlmodel_joint_palettized_quantized, coreml_input_values)

        If quantize_first is True:
            First quantize to get int8 weight, and then palettize to n-bit lut with int8 entries.
        If quantize_first is False:
            First palettize to get fp16 lut, and then quantize the lut to make int8 lut.

        Notice no matter applies which one first, the final output model's op order is guaranteed to be consistent
        by the common::canonicalize_quantized_lut_pattern graph pass.
        
coremltools/test/optimize/coreml/test_post_training_quantization.py