Published on

TensorFlow Lite 변환 실패 - 양자화·연산자 누락 해결

Authors

TensorFlow Lite(TFLite)로 모델을 내보내는 과정은 생각보다 자주 실패합니다. 특히 양자화(quantization) 를 켠 순간부터는 입력/출력 타입, 대표 데이터셋(representative dataset), 지원 연산자(op) 제약이 동시에 걸리면서 에러 메시지가 난해해집니다. 또 변환은 성공했는데 런타임에서 연산자 누락(지원되지 않는 TF op, Flex/Select TF Ops 필요, 커스텀 op 필요)로 앱에서 터지기도 합니다.

이 글은 아래 두 축을 중심으로, “왜 실패하는지”를 원인별로 분해하고 “어떻게 고치는지”를 코드와 체크리스트로 정리합니다.

  • 양자화 변환 실패(대표 데이터셋, 입력 dtype, 스케일/제로포인트, INT8 제약)
  • 변환/실행 시 연산자 누락(Select TF Ops, built-in op 범위, 커스텀 op)

운영 환경에서 이런 변환 파이프라인은 보통 CI로 자동화합니다. 빌드/런너 환경 이슈가 섞이면 원인 파악이 더 어려워지니, CI 안정화 관점에서는 Docker BuildKit 캐시로 CI 빌드 70% 단축하기 같은 글도 함께 참고하면 좋습니다.

1) 실패를 분류하는 1차 기준: “변환 단계” vs “실행 단계”

문제 해결 속도를 올리려면 에러가 어디에서 발생했는지부터 분리해야 합니다.

A. 변환 단계에서 실패

  • TFLiteConverter 호출 중 예외
  • 대표적으로 아래 유형
    • ValueError: ... representative_dataset ...
    • ConverterError: ... unsupported ops ...
    • Quantization not supported for op ...

B. 변환은 성공하지만 실행 단계에서 실패

  • Android/iOS/Python TFLite Interpreter에서 로딩/추론 시 예외
  • 대표적으로
    • Op builtin code ... not supported
    • Didn't find op for builtin opcode ...
    • Flex delegate 관련 메시지

이 글의 나머지 절에서는 이 두 범주를 각각 공략합니다.

2) 양자화 변환 실패: 대표 원인 6가지와 해결

2.1 대표 데이터셋이 없거나 형식이 틀림

INT8 전량자화(full integer quantization) 를 하려면 대표 데이터셋이 사실상 필수입니다. 대표 데이터셋은 “실제 추론 입력 분포”를 근사해야 하고, yield 로 입력 텐서를 반환해야 합니다.

아래는 Keras 모델을 SavedModel로 저장한 뒤 INT8로 변환하는 최소 예시입니다.

import tensorflow as tf
import numpy as np

saved_model_dir = "./saved_model"

# 1) converter 생성
converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir)

# 2) 최적화 옵션
converter.optimizations = [tf.lite.Optimize.DEFAULT]

# 3) 대표 데이터셋
# 주의: 모델 입력 shape/dtype에 맞춰야 함

def representative_dataset():
    for _ in range(200):
        # 예: (1, 224, 224, 3) float32 입력
        x = np.random.rand(1, 224, 224, 3).astype(np.float32)
        yield [x]

converter.representative_dataset = representative_dataset

# 4) INT8 강제(가능한 경우)
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8

# 5) 변환
tflite_model = converter.convert()
open("model_int8.tflite", "wb").write(tflite_model)

자주 나는 실수는 다음입니다.

  • yield x 로 반환(리스트/튜플로 감싸야 하는데 누락)
  • 모델 입력이 2개인데 하나만 반환
  • 입력 dtype이 float32 인데 대표 데이터셋을 uint8 로 주는 등 불일치

2.2 입력/출력 dtype 강제가 모델 제약과 충돌

converter.inference_input_type = tf.int8 를 설정하면, 변환기는 “입력부터 INT8로 받을 수 있는 모델”을 만들려고 합니다. 하지만 모델이 본질적으로 float 입력을 기대하거나, 전처리/정규화가 그래프에 포함된 경우 제약이 강해집니다.

해결 전략은 2가지입니다.

  1. 입력은 float32로 두고 내부만 양자화(동적 범위 양자화 또는 float 입력 INT8 내부)
  2. 전처리를 그래프 밖으로 빼고, 입력을 진짜 INT8로 맞추기

예를 들어 입력은 float로 유지하고 싶다면 아래처럼 설정합니다.

converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset

# 입력/출력은 float32 유지
converter.inference_input_type = tf.float32
converter.inference_output_type = tf.float32

# 내부는 INT8을 최대한 사용
converter.target_spec.supported_ops = [
    tf.lite.OpsSet.TFLITE_BUILTINS_INT8,
    tf.lite.OpsSet.TFLITE_BUILTINS,
]

tflite_model = converter.convert()

이 방식은 하드웨어/런타임에 따라 “완전 INT8” 대비 이득이 줄 수 있지만, 변환 성공률과 호환성이 크게 올라갑니다.

2.3 대표 데이터셋 분포가 이상해서 캘리브레이션이 망가짐

대표 데이터셋이 너무 작거나(예: 1~2개), 값의 범위가 실제와 다르면 스케일이 비정상적으로 잡혀서 정확도가 폭락하거나, 일부 연산에서 양자화 파라미터 추정이 불안정해질 수 있습니다.

실전 팁:

  • 최소 수십~수백 샘플을 권장(모델/도메인에 따라 더 필요)
  • 전처리(정규화, 리사이즈, 채널 순서)를 “실제 추론과 동일”하게 적용
  • 랜덤 노이즈는 변환은 되더라도 정확도가 크게 떨어질 가능성이 큼

2.4 일부 연산이 INT8 양자화를 지원하지 않음

에러 메시지에 특정 op가 나오며 “Quantization not supported” 류가 뜨는 경우가 있습니다. 이때 선택지는 다음입니다.

  • 해당 op를 다른 op 조합으로 치환(모델 수정)
  • 해당 구간만 float로 남기는 혼합 정밀도 허용(지원 ops에 TFLITE_BUILTINS 포함)
  • Select TF Ops(Flex)로 우회(성능/바이너리 크기 비용)

혼합 정밀도 허용은 위 코드처럼 supported_opsTFLITE_BUILTINS 를 함께 넣는 방식이 대표적입니다.

2.5 TF 버전/변환기 버전 불일치

SavedModel을 만든 TF 버전과 변환하는 TF 버전이 다르면, 특히 최신 op가 섞여 있을 때 변환이 깨지는 경우가 있습니다. 재현성 확보를 위해 다음을 권장합니다.

  • 학습/내보내기/변환을 같은 TF 메이저/마이너로 맞추기
  • Docker로 변환 환경 고정
  • 변환 로그를 파일로 남겨 diff 가능하게 만들기

CI에서 이 과정을 반복한다면 캐시/레이어 전략이 중요합니다. 변환 스크립트와 의존성 설치를 분리해 캐시 효율을 올리는 방식은 Docker BuildKit 캐시로 CI 빌드 70% 단축하기 의 패턴을 그대로 적용할 수 있습니다.

2.6 “변환은 성공”했는데 정확도만 크게 떨어짐

이 경우는 실패라기보다 품질 이슈지만, 실무에서는 사실상 롤백 사유가 됩니다. 점검 순서는 다음이 효율적입니다.

  1. FP32 TFLite로 먼저 변환해 정확도 기준선 확인
  2. Dynamic range quantization으로 중간 단계 확인
  3. Float 입력 + 내부 INT8(혼합) 확인
  4. Full INT8 확인

각 단계에서 정확도를 측정해 “어느 단계에서 깨지는지”를 찾으면, 대표 데이터셋 문제인지(대개 3~4 단계), 특정 op 양자화 문제인지(대개 4 단계) 빠르게 분리됩니다.

3) 연산자 누락(Unsupported op) 해결: 4가지 루트

연산자 누락은 크게 다음 중 하나입니다.

  • TFLite built-in op로 변환 가능한데, 변환 설정이 막고 있음
  • TFLite에 없는 TF op가 모델에 포함됨
  • TF op를 그대로 쓰려면 Select TF Ops(Flex)가 필요
  • 커스텀 op를 직접 구현해야 함

3.1 변환 로그에서 “어떤 op가 문제인지” 먼저 고정

변환 예외가 길게 나오면 핵심은 보통 Some ops are not supported by the native TFLite runtime 와 함께 나오는 op 목록입니다.

변환 시 디버그 정보를 늘리는 기본 패턴은 다음입니다.

import tensorflow as tf

converter = tf.lite.TFLiteConverter.from_saved_model("./saved_model")
converter.experimental_new_converter = True  # 구버전 TF에서는 플래그가 다를 수 있음

try:
    tflite_model = converter.convert()
except Exception as e:
    print("TFLite convert failed:")
    print(e)
    raise

여기서 op 이름이 특정되면 아래 루트 중 하나로 갑니다.

3.2 Select TF Ops(Flex)로 우회하기

TFLite가 지원하지 않는 TF 연산이 포함되어도, Select TF Ops 를 허용하면 변환이 되는 경우가 많습니다. 대신 런타임에 Flex delegate가 필요하고 바이너리 크기/의존성이 커질 수 있습니다.

import tensorflow as tf

converter = tf.lite.TFLiteConverter.from_saved_model("./saved_model")
converter.optimizations = [tf.lite.Optimize.DEFAULT]

# Select TF Ops 허용
converter.target_spec.supported_ops = [
    tf.lite.OpsSet.TFLITE_BUILTINS,
    tf.lite.OpsSet.SELECT_TF_OPS,
]

tflite_model = converter.convert()
open("model_flex.tflite", "wb").write(tflite_model)

언제 이걸 쓰나:

  • 빠르게 “일단 동작”이 필요할 때
  • 특정 op를 당장 모델에서 제거하기 어렵고, 디바이스 리소스가 허용될 때

언제 피하나:

  • 앱 바이너리 크기 제한이 빡센 경우
  • Edge TPU 등 특정 가속기 타깃(대개 built-in op 제약이 더 강함)

3.3 문제 op를 모델에서 제거/치환하기(권장)

가장 안정적인 해결은 “TFLite가 좋아하는 그래프”로 모델을 바꾸는 것입니다. 예를 들어 다음이 자주 문제를 일으킵니다.

  • tf.image 계열 일부 연산(학습 그래프에 포함 시)
  • 동적 shape를 강하게 요구하는 연산
  • tf.signal/tf.lookup 등 특정 도메인 op

해결 패턴:

  • 전처리(리사이즈/정규화/색공간 변환)는 앱 코드로 이동
  • 가능하면 Keras 레이어로 구성된 표준 CNN/Transformer 블록 사용
  • 내보내기 전에 tf.function 으로 입력 shape를 고정하거나 input_signature 를 명시

예시로, 전처리를 그래프 밖으로 빼는 간단한 구조는 아래처럼 시작합니다.

import tensorflow as tf

class ModelWrapper(tf.Module):
    def __init__(self, model):
        super().__init__()
        self.model = model

    @tf.function(input_signature=[tf.TensorSpec([1, 224, 224, 3], tf.float32)])
    def __call__(self, x):
        # 그래프 안에서 복잡한 전처리를 하지 않고, 모델 본체만 호출
        return self.model(x)

# wrapper를 SavedModel로 저장한 뒤 변환

3.4 커스텀 op가 필요한 경우

TFLite built-in에도 없고 Select TF Ops로도 해결이 안 되거나(혹은 성능/크기 때문에 못 쓰거나), 특정 하드웨어 delegate를 타야 하는 경우 커스텀 op 구현이 필요합니다.

이 루트는 비용이 큽니다.

  • Android NDK/C++ 구현
  • TFLite op registration
  • delegate 호환성/스레딩/메모리 최적화

따라서 “정말로 커스텀 op가 필요한지”를 먼저 검증하세요.

  • 해당 op가 학습 편의용인지(추론에는 불필요한지)
  • 근사 가능한 표준 op 조합이 있는지
  • Flex로 충분한지

4) 변환 후 검증: 파이썬에서 즉시 재현 가능한 스모크 테스트

변환이 성공했다고 끝이 아닙니다. 최소한 다음을 자동화하면, 앱에 올리기 전에 문제를 잡을 수 있습니다.

  • 모델 로드
  • 입력 텐서 dtype/shape 확인
  • 더미 추론 1회
  • (가능하면) FP32 vs INT8 출력 비교(간단 지표)
import numpy as np
import tensorflow as tf

interpreter = tf.lite.Interpreter(model_path="model_int8.tflite")
interpreter.allocate_tensors()

input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()

print("input:", input_details)
print("output:", output_details)

# 입력 스케일/제로포인트 확인 (INT8/UINT8일 때 중요)
scale, zero_point = input_details[0]["quantization"]

# 예: float 입력을 INT8로 양자화해 넣는 경우
x_float = np.random.rand(1, 224, 224, 3).astype(np.float32)

if input_details[0]["dtype"] == np.int8:
    x_q = (x_float / scale + zero_point).round().astype(np.int8)
    interpreter.set_tensor(input_details[0]["index"], x_q)
else:
    interpreter.set_tensor(input_details[0]["index"], x_float)

interpreter.invoke()
out = interpreter.get_tensor(output_details[0]["index"])
print(out.shape, out.dtype)

이 스모크 테스트를 CI에 넣어두면, “변환은 되는데 런타임에서 깨짐”을 상당수 조기에 차단할 수 있습니다. CI가 불안정해 자주 실패한다면, 리소스 부족으로 프로세스가 죽는 경우도 많으니 GitLab Runner Docker executor OOM·Exit 137 해결 같은 관점도 같이 점검하세요.

5) 실전 체크리스트(원인별 빠른 처방)

5.1 양자화 관련

  • 대표 데이터셋이 yield [input] 형태인가
  • 대표 데이터셋 dtype/shape가 모델 입력과 일치하는가
  • 샘플 수가 충분한가(최소 수십~수백)
  • inference_input_type 강제가 불필요하게 엄격하지 않은가
  • supported_opsTFLITE_BUILTINS_INT8 만 넣어 완전 INT8을 강제하고 있지 않은가(혼합 허용으로 우회 가능)

5.2 연산자 누락 관련

  • 변환 로그에서 문제 op 목록을 확보했는가
  • Select TF Ops로 우회 가능한가(SELECT_TF_OPS)
  • 전처리/후처리를 그래프 밖으로 뺄 수 있는가
  • 입력 shape를 고정하면 해결되는가(input_signature)
  • 최후의 수단으로 커스텀 op가 필요한가

6) 마무리: “성공률”을 올리는 권장 접근 순서

TFLite 변환 실패를 줄이는 가장 현실적인 순서는 아래입니다.

  1. FP32 TFLite 변환 + 스모크 테스트로 기본 동작 확인
  2. Dynamic range quantization 적용
  3. 대표 데이터셋 기반 혼합 정밀도(입력 float 유지 가능) 적용
  4. Full INT8(입출력 INT8)로 최적화
  5. op 누락이 남으면 모델 수정 또는 Select TF Ops 검토

이 순서를 따르면, 문제를 한 번에 다 해결하려다 “양자화 문제인지 op 문제인지”가 섞여 디버깅이 길어지는 상황을 피할 수 있습니다.

원하시면 변환 로그(에러 전문)와 모델의 입력 스펙(입력 shape/dtype, SavedModel 시그니처)을 기준으로, 어떤 op에서 막히는지와 최적의 supported_ops 조합을 케이스별로 더 구체적으로 잡아드릴 수 있습니다.