- Published on
TensorFlow Lite 변환 실패 - 양자화·연산자 누락 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
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 supportedDidn'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가지입니다.
- 입력은 float32로 두고 내부만 양자화(동적 범위 양자화 또는 float 입력 INT8 내부)
- 전처리를 그래프 밖으로 빼고, 입력을 진짜 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_ops 에 TFLITE_BUILTINS 를 함께 넣는 방식이 대표적입니다.
2.5 TF 버전/변환기 버전 불일치
SavedModel을 만든 TF 버전과 변환하는 TF 버전이 다르면, 특히 최신 op가 섞여 있을 때 변환이 깨지는 경우가 있습니다. 재현성 확보를 위해 다음을 권장합니다.
- 학습/내보내기/변환을 같은 TF 메이저/마이너로 맞추기
- Docker로 변환 환경 고정
- 변환 로그를 파일로 남겨 diff 가능하게 만들기
CI에서 이 과정을 반복한다면 캐시/레이어 전략이 중요합니다. 변환 스크립트와 의존성 설치를 분리해 캐시 효율을 올리는 방식은 Docker BuildKit 캐시로 CI 빌드 70% 단축하기 의 패턴을 그대로 적용할 수 있습니다.
2.6 “변환은 성공”했는데 정확도만 크게 떨어짐
이 경우는 실패라기보다 품질 이슈지만, 실무에서는 사실상 롤백 사유가 됩니다. 점검 순서는 다음이 효율적입니다.
- FP32 TFLite로 먼저 변환해 정확도 기준선 확인
- Dynamic range quantization으로 중간 단계 확인
- Float 입력 + 내부 INT8(혼합) 확인
- 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_ops에TFLITE_BUILTINS_INT8만 넣어 완전 INT8을 강제하고 있지 않은가(혼합 허용으로 우회 가능)
5.2 연산자 누락 관련
- 변환 로그에서 문제 op 목록을 확보했는가
- Select TF Ops로 우회 가능한가(
SELECT_TF_OPS) - 전처리/후처리를 그래프 밖으로 뺄 수 있는가
- 입력 shape를 고정하면 해결되는가(
input_signature) - 최후의 수단으로 커스텀 op가 필요한가
6) 마무리: “성공률”을 올리는 권장 접근 순서
TFLite 변환 실패를 줄이는 가장 현실적인 순서는 아래입니다.
- FP32 TFLite 변환 + 스모크 테스트로 기본 동작 확인
- Dynamic range quantization 적용
- 대표 데이터셋 기반 혼합 정밀도(입력 float 유지 가능) 적용
- Full INT8(입출력 INT8)로 최적화
- op 누락이 남으면 모델 수정 또는 Select TF Ops 검토
이 순서를 따르면, 문제를 한 번에 다 해결하려다 “양자화 문제인지 op 문제인지”가 섞여 디버깅이 길어지는 상황을 피할 수 있습니다.
원하시면 변환 로그(에러 전문)와 모델의 입력 스펙(입력 shape/dtype, SavedModel 시그니처)을 기준으로, 어떤 op에서 막히는지와 최적의 supported_ops 조합을 케이스별로 더 구체적으로 잡아드릴 수 있습니다.