Skip to content

Commit 8239b1a

Browse files
committed
Fix int8 SVDF activation-state zero-point overflow (BUG=#2721)
In the int8 SVDF reference kernel's feature-matmul step, the activation_state zero-point was added to the requantized dot product AFTER the saturating clamp to the int8 range, instead of before. This lets the zero-point-shifted sum exceed the int8 range and silently wrap around (instead of saturate) when narrowed into the persistent activation_state buffer, corrupting the SVDF's recurrent state whenever the activation_state tensor has a non-zero zero-point. Move the zero-point addition before the clamp, matching the pattern already used in the same file's Rescale step (requantize -> add zero-point -> clamp -> store). The int16 activation-state instantiation is unaffected since its zero-point is conventionally 0 (per the function's own comment). Adds a regression test that exercises a non-zero activation_state zero-point end-to-end (the existing TestIntegerSVDF() test helper always hardcodes a zero-point of 0 for that tensor regardless of the value passed to it, so it never covered this path). ## Disclosure Generative AI (Claude) was used to help investigate this issue and implement this fix. All changes were reviewed by me before submission.
1 parent fddd370 commit 8239b1a

2 files changed

Lines changed: 124 additions & 3 deletions

File tree

tensorflow/lite/micro/kernels/svdf_common.cc

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,15 @@ void EvalIntegerSvdfReference(TfLiteContext* context, TfLiteNode* node,
108108
}
109109
dot_prod = MultiplyByQuantizedMultiplier(
110110
dot_prod, data.effective_scale_1_a, data.effective_scale_1_b);
111-
dot_prod = std::min(std::max(output_min, dot_prod), output_max);
112111
// The int16 version of the op assumes a zero_point of 0. This
113112
// code accounts for the potentially non-zero zero_point for the int8
114-
// version of the op.
115-
*result_in_batch = data.activation_state_zero_point + dot_prod;
113+
// version of the op. The zero-point must be added BEFORE the
114+
// saturating clamp below (not after), otherwise the sum can exceed
115+
// the int8 range and silently wrap around when narrowed into the
116+
// persistent activation_state buffer.
117+
dot_prod = data.activation_state_zero_point + dot_prod;
118+
dot_prod = std::min(std::max(output_min, dot_prod), output_max);
119+
*result_in_batch = dot_prod;
116120
result_in_batch += n_memory;
117121
}
118122
}

tensorflow/lite/micro/kernels/svdf_test.cc

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,116 @@ void SvdfQuantized1x16Input64x1OutputReluShouldMatchGolden() {
825825
sizeof(tflite::testing::golden_output_relu_16x1x1) / sizeof(float));
826826
}
827827

828+
// Regression test for a bug where the int8 activation-state path in
829+
// svdf_common.cc added the (potentially non-zero) activation_state
830+
// zero-point AFTER clamping the requantized dot product to the int8 range,
831+
// instead of before. This let the zero-point-shifted sum silently wrap
832+
// around the int8 range when narrowed into the persistent activation_state
833+
// buffer, instead of saturating like every other quantized op does.
834+
// See https://github.com/tensorflow/tflite-micro/issues/2721.
835+
//
836+
// Note: this deliberately builds its own tensors (rather than reusing
837+
// TestIntegerSVDF() above) because that helper always constructs the
838+
// activation_state tensor with a hardcoded zero-point of 0, regardless of
839+
// the activation_state_zero_point argument passed to it, and so never
840+
// actually exercises the non-zero zero-point code path this bug lives in.
841+
//
842+
// All quantization scales below are exactly 1.0 so that
843+
// MultiplyByQuantizedMultiplier() is an exact identity transform for the
844+
// in-range integer values used here. This keeps the expected values exact
845+
// and isolates the zero-point-add-vs-clamp ordering bug from any
846+
// requantization rounding behavior.
847+
void SvdfInt8ActivationStateZeroPointOverflowRegressionTest() {
848+
constexpr int batch_size = 1;
849+
constexpr int num_units = 1;
850+
constexpr int input_size = 1;
851+
constexpr int memory_size = 1;
852+
constexpr int rank = 1;
853+
constexpr int num_filters = num_units * rank;
854+
855+
const float input_scale = 1.0f;
856+
const int input_zero_point = 0;
857+
const float feature_weights_scale = 1.0f;
858+
const float time_weights_scale = 1.0f;
859+
const float activation_state_scale = 1.0f;
860+
const int activation_state_zero_point = 30; // Ordinary nonzero zero-point.
861+
const float output_scale = 1.0f;
862+
const int output_zero_point = 0;
863+
864+
// Raw feature-matmul dot product = feature_weight * input = 120 * 1 = 120,
865+
// unchanged by requantization (identity scale). Correct (saturating)
866+
// result stored into activation_state:
867+
// clamp(zero_point + dot_prod, -128, 127) = clamp(30 + 120, ...) = 127.
868+
// Buggy (add-after-clamp) result: clamp(dot_prod, ...) = 120 (no-op,
869+
// already in range), then int8_t(30 + 120) = int8_t(150) wraps to -106.
870+
const float input_data[input_size * batch_size] = {1.0f};
871+
const float feature_weights_data[num_filters * input_size] = {120.0f};
872+
const float time_weights_data[num_filters * memory_size] = {1.0f};
873+
const float bias_data[num_units] = {0.0f};
874+
const float initial_activation_state_data[batch_size * memory_size *
875+
num_filters] = {0.0f};
876+
// Correct expected output, derived by hand from the corrected math above:
877+
// activation_state (after fix) = 127
878+
// time-step scratch = time_weight * (127 - zero_point) = 1 * 97 = 97
879+
// reduce (no bias) = 97; rescale (identity, scale=1.0) = 97;
880+
// + output_zero_point(0), clamped to int8 range = 97.
881+
const float golden_output[batch_size * num_units] = {97.0f};
882+
883+
int8_t input_quantized[input_size * batch_size];
884+
int8_t feature_weights_quantized[num_filters * input_size];
885+
int8_t time_weights_quantized[num_filters * memory_size];
886+
int32_t bias_quantized[num_units];
887+
int8_t activation_state_quantized[batch_size * memory_size * num_filters];
888+
int8_t output_data[batch_size * num_units];
889+
int8_t golden_output_quantized[batch_size * num_units];
890+
int8_t input_sequences_quantized[input_size * batch_size];
891+
892+
int input_dims_arg[] = {2, batch_size, input_size};
893+
TfLiteIntArray* input_dims = IntArrayFromInts(input_dims_arg);
894+
int feature_weights_dims_args[] = {2, num_filters, input_size};
895+
TfLiteIntArray* feature_weights_dims =
896+
IntArrayFromInts(feature_weights_dims_args);
897+
int time_weights_dims_args[] = {2, num_filters, memory_size};
898+
TfLiteIntArray* time_weights_dims = IntArrayFromInts(time_weights_dims_args);
899+
int bias_dims_data[] = {1, num_units};
900+
TfLiteIntArray* bias_dims = IntArrayFromInts(bias_dims_data);
901+
int activation_state_dims_args[] = {2, batch_size, memory_size * num_filters};
902+
TfLiteIntArray* activation_state_dims =
903+
IntArrayFromInts(activation_state_dims_args);
904+
int output_dims_args[] = {2, batch_size, num_units};
905+
TfLiteIntArray* output_dims = IntArrayFromInts(output_dims_args);
906+
907+
const int tensor_count = 6; // 5 inputs, 1 output.
908+
TfLiteTensor tensors[] = {
909+
CreateQuantizedTensor(input_data, input_quantized, input_dims,
910+
input_scale, input_zero_point),
911+
CreateQuantizedTensor(feature_weights_data, feature_weights_quantized,
912+
feature_weights_dims, feature_weights_scale, 0),
913+
CreateQuantizedTensor(time_weights_data, time_weights_quantized,
914+
time_weights_dims, time_weights_scale, 0),
915+
CreateQuantizedBiasTensor(bias_data, bias_quantized, bias_dims,
916+
time_weights_scale, activation_state_scale),
917+
// Unlike TestIntegerSVDF() above, wire the *actual* (non-zero)
918+
// activation-state zero-point into the tensor here -- this is the
919+
// code path that exercises the bug.
920+
CreateQuantizedTensor(initial_activation_state_data,
921+
activation_state_quantized, activation_state_dims,
922+
activation_state_scale, activation_state_zero_point,
923+
/*is_variable=*/true),
924+
CreateQuantizedTensor(output_data, output_dims, output_scale,
925+
output_zero_point)};
926+
927+
tflite::Quantize(golden_output, golden_output_quantized,
928+
batch_size * num_units, output_scale, output_zero_point);
929+
tflite::Quantize(input_data, input_sequences_quantized,
930+
input_size * batch_size, input_scale, input_zero_point);
931+
932+
ValidateSVDFGoldens(batch_size, num_units, input_size, rank, tensors,
933+
tensor_count, kTfLiteActNone, input_sequences_quantized,
934+
input_size * batch_size, output_data,
935+
golden_output_quantized, /*tolerance=*/1);
936+
}
937+
828938
} // namespace
829939
} // namespace testing
830940
} // namespace tflite
@@ -961,4 +1071,11 @@ TEST(SvdfTest, SvdfQuantized1x16Input64x1OutputReluShouldMatchGoldenInt16) {
9611071
int16_t>();
9621072
}
9631073

1074+
// Only reference kernels support full int8 svdf currently.
1075+
#if !defined(HEXAGON)
1076+
TEST(SvdfTest, SvdfInt8ActivationStateZeroPointOverflowRegressionTest) {
1077+
tflite::testing::SvdfInt8ActivationStateZeroPointOverflowRegressionTest();
1078+
}
1079+
#endif
1080+
9641081
TF_LITE_MICRO_TESTS_MAIN

0 commit comments

Comments
 (0)