Published on

TensorFlow Lite LLM 4bit 변환 에러 해결 가이드

Authors

서버에서 돌리던 LLM을 모바일/엣지로 내리려면 결국 TensorFlow Lite(TFLite)로 가야 하는 순간이 옵니다. 문제는 “4bit로 줄이면 되겠지” 하고 양자화(quantization)를 걸었을 때, 변환 단계에서 바로 죽거나(Converter 에러), 변환은 됐는데 실행 단계에서 커널 미지원으로 터지거나(Delegate/Op 에러), 혹은 출력이 망가지는(정확도 붕괴) 케이스가 매우 흔하다는 점입니다.

이 글은 TFLite로 LLM 4bit 변환 시 발생하는 대표 에러 패턴

  1. 어떤 조건에서 재현되는지,
  2. 무엇이 실제 원인인지,
  3. 어떤 우회/해결책이 현실적으로 통하는지 순서로 정리합니다.

특히 아래를 목표로 합니다.

  • “왜 안 되지?”를 로그 한두 줄로 추측하는 대신, 원인-증상 매핑으로 빠르게 좁히기
  • TFLite가 지원하는 4bit의 범위를 정확히 이해하고(LLM에서 가장 중요), 가능한 설계로 바꾸기
  • 변환 파이프라인을 스크립트로 고정해 CI에서 재현 가능하게 만들기

참고로, 장애 원인 추적 관점은 시스템 문제를 파는 글에서도 동일합니다. 변환 실패가 반복될 때는 로그/환경/재현성을 먼저 고정해야 합니다. 비슷한 접근으로는 systemd 서비스가 계속 재시작될 때 원인 추적법 글의 “증상 분리-가설-검증” 흐름이 그대로 적용됩니다.

먼저 짚고 가기: TFLite에서 “LLM 4bit”는 무엇을 의미하나

TFLite에서 흔히 말하는 4bit는 대개 다음 중 하나입니다.

  1. 가중치(Weight) 4bit 양자화: 모델 파라미터를 4bit로 압축. 실행 시에는 커널이 이를 해석해 계산.
  2. 활성값(Activation)까지 4bit: 매우 공격적이며 지원이 제한적. 정확도/커널/성능 난이도 급상승.
  3. 하이브리드: 일부 텐서는 8bit/16bit 유지, 핵심 가중치만 4bit.

중요한 현실:

  • TFLite 기본 내장 커널이 LLM의 모든 연산을 4bit로 완전 지원하는 경우는 드뭅니다.
  • “4bit 변환”을 시도했다가 실패하는 많은 케이스는 양자화 자체가 불가능해서가 아니라, 변환된 그래프에 대해 TFLite 런타임이 지원하는 Op 조합이 안 맞아서입니다.
  • 따라서 목표를 “무조건 4bit”로 잡기보다, 어떤 레이어를 4bit로 만들고 어떤 레이어는 8bit/16bit로 남길지를 먼저 설계해야 합니다.

에러를 3가지 단계로 분류하면 해결이 빨라진다

LLM 4bit 관련 이슈는 대체로 아래 3단계 중 한 곳에서 터집니다.

  1. 모델 내보내기(Export): SavedModel/ConcreteFunction/TF Graph 단계
  2. TFLite 변환(Conversion): tf.lite.TFLiteConverter 단계
  3. TFLite 실행(Runtime): Interpreter/Delegate(XNNPACK, GPU, NNAPI) 단계

각 단계의 에러는 메시지 톤이 다릅니다.

  • Export 실패: Keras/TF trace 관련, tf.function/shape, control flow
  • Conversion 실패: ConverterError, Unsupported Ops, MLIR 패스 실패
  • Runtime 실패: Select TF ops required, Op not supported, delegate 적용 실패

이 분류를 먼저 하고 들어가면, “Representative dataset을 늘리면 되나?” 같은 엉뚱한 처방을 줄일 수 있습니다.

공통 준비: 변환 로그를 ‘최대로’ 뽑는 스캐폴딩

에러를 해결하려면 우선 변환 옵션을 고정하고, 로그를 최대한 확보해야 합니다.

아래 스크립트는 변환 시 가장 자주 쓰는 옵션들을 한 곳에 모아둔 템플릿입니다.

import tensorflow as tf
import pathlib

def convert_to_tflite(saved_model_dir: str, out_path: str):
    converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir)

    # 기본 최적화 플래그
    converter.optimizations = [tf.lite.Optimize.DEFAULT]

    # 런타임에서 TF 연산을 fallback로 쓰는 옵션(문제 분리용)
    # 이걸 켰을 때 변환이 되면 "TFLite builtin op 미지원" 가능성이 큼
    converter.target_spec.supported_ops = [
        tf.lite.OpsSet.TFLITE_BUILTINS,
        tf.lite.OpsSet.SELECT_TF_OPS,
    ]

    # 변환 안정성을 위해 실험적으로 켜볼 수 있는 옵션
    converter.experimental_new_converter = True

    tflite_model = converter.convert()
    pathlib.Path(out_path).write_bytes(tflite_model)
    print(f"Wrote: {out_path} ({len(tflite_model)/1024/1024:.2f} MiB)")

if __name__ == "__main__":
    convert_to_tflite("./saved_model", "./model.tflite")

핵심은 SELECT_TF_OPS처음엔 켜서 변환을 통과시키고, 그 다음에 builtin-only로 줄이며 어디서 깨지는지 역추적하는 전략입니다.

케이스 1: “Unsupported Ops” 또는 “Some ops are not supported by the native TFLite runtime”

증상

변환 로그에 다음과 같은 류가 나옵니다.

  • Some of the ops in the model are not supported by the native TFLite runtime
  • Unsupported op: ...
  • TF Select ops required: Flex...

원인

LLM 그래프에는 흔히 다음이 섞입니다.

  • RaggedTensor/동적 shape 기반 전처리
  • tf.einsum/복잡한 MatMul 변형
  • While/control flow
  • GatherND, TensorList*, BroadcastTo

이 중 일부는 TFLite builtin으로 완전 커버가 안 되어 Flex(Select TF Ops)로 떨어지거나, 아예 변환이 실패합니다.

해결 전략

1) 일단 SELECT_TF_OPS로 통과시켜 “어디가 문제인지” 확정

위 템플릿처럼 supported_opsSELECT_TF_OPS를 넣고 변환이 되는지 확인합니다.

  • 변환이 된다면: builtin 커널 미지원이 핵심이므로 그래프 단순화 또는 연산 치환이 필요
  • 변환이 여전히 안 된다면: export/trace 문제거나 MLIR 패스에서 깨지는 문제일 가능성

2) LLM 추론 그래프를 “고정 shape”로 내보내기

TFLite는 동적 shape에 취약합니다. 특히 LLM의 seq_len이 런타임 가변이면 변환/런타임 모두 난이도가 올라갑니다.

가능하면 다음 중 하나를 선택합니다.

  • seq_len을 고정(예: 128, 256)한 프롬프트 길이 버킷 모델을 여러 개 만든다
  • KV cache를 쓰는 구조라면 cache 텐서 shape를 고정하고, step-by-step 디코딩으로 설계를 바꾼다

3) einsummatmul로 치환(가능한 경우)

LLM의 projection에서 einsum이 남아 있으면 TFLite 변환에서 자주 골칫거리가 됩니다. 모델 구현 단계에서 Dense/MatMul 기반으로 변경하는 게 가장 확실합니다.

케이스 2: 4bit 양자화 옵션을 켰더니 Converter가 바로 죽는다

증상

  • ConverterError: ... (MLIR quantize pass 실패)
  • Quantization not yet supported for op ...
  • Failed to quantize tensor ... 같은 메시지

원인

TFLite의 양자화는 “모든 텐서를 마음대로 4bit로” 바꾸는 기능이 아닙니다.

  • 4bit는 특히 지원되는 커널/형식이 제한적이며, LLM에서 흔한 연산 조합을 전부 커버하지 못합니다.
  • 일부 변환 경로는 8bit 정수 양자화를 주로 지원하고, 4bit는 특정 조건(가중치-only, 특정 op)에만 허용됩니다.

해결 전략

1) 목표를 “완전 4bit”에서 “가중치 4bit + 활성값 16bit/8bit”로 낮추기

현실적으로 가장 많이 성공하는 조합은 다음입니다.

  • 가중치: 4bit(또는 최소 8bit)
  • 활성값: float16 또는 int8

즉, weight-only quantization을 우선 목표로 잡고 성공시키는 것이 좋습니다.

2) 변환 파이프라인을 2단으로 나누기

한 번에 다 하려다 실패하는 경우가 많습니다.

  1. float 모델을 먼저 TFLite로 변환(builtin-only 가능한지 확인)
  2. 그 다음 양자화 적용(가능한 범위에서)

이렇게 해야 “양자화 때문에 깨진 건지, 원래 TFLite로도 못 가는 그래프인지”가 분리됩니다.

3) Representative dataset을 ‘정확히’ 준비하기(활성값 양자화 시)

활성값까지 정수 양자화를 하려면 representative dataset이 사실상 필수입니다. 다만 LLM은 입력이 토큰 ID라서 대표 데이터가 빈약하면 캘리브레이션이 엉망이 되기 쉽습니다.

토큰 입력을 대표하는 예시를 최소 수십~수백 개 준비하고, 길이 버킷(예: 32/64/128)별로 섞어야 합니다.

import numpy as np

# 예시: token ids가 int32라고 가정
samples = [
    np.random.randint(0, 32000, size=(1, 64), dtype=np.int32),
    np.random.randint(0, 32000, size=(1, 128), dtype=np.int32),
]

def rep_data_gen():
    for x in samples:
        yield [x]

converter.representative_dataset = rep_data_gen

주의: 위는 형태 예시입니다. 실제로는 실제 서비스 프롬프트 분포를 반영해야 합니다.

케이스 3: 변환은 됐는데 실행 시 “Delegate 적용 실패” 또는 특정 Op에서 크래시

증상

  • XNNPACK/GPU/NNAPI delegate가 적용되지 않음
  • Node number ... (OP) failed to prepare
  • 특정 디바이스에서만 크래시

원인

  • delegate가 지원하지 않는 op/텐서 타입 조합이 섞여 있음
  • 4bit 텐서가 delegate 경로에서 미지원
  • 메모리 플래너가 예상 못한 큰 텐서를 잡아 OOM 또는 prepare 실패

해결 전략

1) delegate를 끄고 CPU builtin으로 먼저 안정화

성능 이전에 “정상 동작”을 확보해야 합니다.

import tensorflow as tf

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

이 상태에서 정상 동작하면, delegate 경로 문제로 좁혀집니다.

2) op별로 delegate 지원 범위를 확인하고 그래프를 분할

TFLite는 일부 노드만 delegate로 보내고 나머지는 CPU로 처리하는 혼합 실행이 가능합니다. 하지만 4bit 텐서가 섞이면 분할 지점에서 타입 변환이 들어가거나 아예 실패할 수 있습니다.

실무 팁:

  • attention 블록 전체를 delegate에 태우기보다, 큰 MatMul/GEMM만 delegate에 태우는 쪽이 성공률이 높습니다.
  • 변환 결과를 Netron 등으로 열어보고(그래프 확인), 실패하는 op 주변을 집중적으로 본 뒤 해당 op를 피하도록 모델을 수정합니다.

케이스 4: 4bit로 줄였더니 출력이 이상해졌다(정확도 붕괴)

증상

  • 문장이 반복됨
  • 특정 토큰만 과도하게 나옴
  • 긴 문맥에서 급격히 망가짐

원인

LLM은 특히 다음에 민감합니다.

  • LayerNorm/RMSNorm 주변 정밀도
  • Softmax 입력 스케일
  • KV cache 누적 오차

가중치 4bit는 가능해도, activation까지 공격적으로 낮추면 품질이 크게 흔들릴 수 있습니다.

해결 전략

1) 민감 레이어는 float16로 남기기

일반적으로 다음은 양자화에서 제외하거나 더 높은 정밀도로 유지하는 게 안전합니다.

  • Norm 계열
  • Softmax
  • 최종 logits projection 일부

2) Per-channel quantization 우선

가능하다면 per-tensor보다 per-channel이 품질이 낫습니다. 다만 TFLite에서 경로/지원이 제한될 수 있어, 변환 옵션과 커널 지원을 함께 봐야 합니다.

3) “4bit가 목표”가 아니라 “메모리/지연시간 목표”로 재정의

4bit로 품질이 무너지면, 아래 현실적 조합을 고려합니다.

  • float16 (GPU/Metal 가능 시)
  • int8 weight-only
  • int8 hybrid

엣지에서는 모델 크기보다 실제 지연시간과 안정성이 더 중요할 때가 많습니다.

실전 체크리스트: 30분 안에 원인 좁히기

아래 순서대로 하면 삽질이 줄어듭니다.

  1. float 모델이 builtin-only로 TFLite 변환 가능한가
    • 안 되면 4bit 이전에 그래프/연산 호환성 문제
  2. builtin-only가 안 되면 SELECT_TF_OPS로 변환이 되는가
    • 되면: 미지원 op를 찾아 치환/단순화
  3. 변환이 되면 CPU에서 정상 실행되는가
    • 안 되면: shape/type/메모리 문제
  4. CPU OK면 delegate 적용(XNNPACK/GPU/NNAPI)을 단계적으로 켜기
    • 특정 delegate에서만 깨지면: delegate 미지원 op/tensor 타입
  5. 그 다음에야 양자화(8bit부터, 이후 4bit)
    • 한 번에 4bit로 가지 말고, 성공 경로를 고정하고 단계적으로 낮추기

이런 단계적 접근은 CI에서도 중요합니다. 파이프라인이 길어질수록 환경 변수나 옵션 스코프가 꼬여서 “어제 되던 게 오늘 안 됨”이 자주 터집니다. Jenkins를 쓴다면 Jenkins Declarative 환경변수 스코프 꼬임 해결법처럼 스코프를 고정하는 습관이 변환 자동화에도 그대로 도움이 됩니다.

변환 자동화 예시: 실패 지점을 남기는 CI 스크립트

아래는 “float 변환”과 “(가능한 범위에서) 양자화 변환”을 분리하고, 실패 시 로그를 남기는 예시입니다.

set -euo pipefail

python -c "import tensorflow as tf; print(tf.__version__)"

# 1) float 변환
python convert.py --saved_model ./saved_model --out ./out/model-fp.tflite

# 2) builtin-only 검증(SELECT_TF_OPS 없이도 되는지)
python convert_builtin_only.py --saved_model ./saved_model --out ./out/model-builtin.tflite || {
  echo "builtin-only conversion failed; keep SELECT_TF_OPS for now" >&2
}

# 3) 양자화(8bit부터)
python quantize_int8.py --saved_model ./saved_model --out ./out/model-int8.tflite || {
  echo "int8 quantization failed" >&2
}

# 4) 4bit는 실험 플래그로만(성공률 낮음)
python quantize_4bit_experimental.py --saved_model ./saved_model --out ./out/model-4bit.tflite || {
  echo "4bit quantization failed; fallback to int8/float16" >&2
}

포인트는 “4bit 실패”를 파이프라인 실패로 보지 않고, 대체 산출물(int8/float16)을 확보하는 것입니다. 운영 관점에서는 이게 훨씬 안전합니다.

자주 묻는 질문(현업 기준)

Q1. SELECT_TF_OPS를 쓰면 안 좋은가?

완전히 나쁜 건 아닙니다. 다만 Flex op는 보통 바이너리 크기 증가, 성능 저하, 플랫폼 제약이 생길 수 있습니다. 그래서 디버깅/과도기용으로 두고, 최종 목표는 builtin-only 또는 delegate 친화적인 그래프로 정리하는 게 좋습니다.

Q2. “LLM 4bit”는 결국 TFLite에서 비현실적인가?

“범용 LLM을 그대로 4bit로”는 아직 제약이 많습니다. 하지만 모델 구조/연산을 TFLite 친화적으로 설계하고, 가중치 중심의 저비트 + 나머지 고정밀로 타협하면 실무적으로 가능한 경우가 있습니다.

Q3. 성능 튜닝은 어디서부터?

정확도와 안정성이 확보된 다음입니다. 추론 품질 자체를 개선하는 기법(예: 샘플링 안정화, verifier)도 함께 고려해야 하는데, 이 주제는 CoT 노출 없이 추론품질↑ - Self-Consistency+Verifier에서 다룬 접근이 모바일 추론에서도 의외로 잘 먹힙니다(모델을 더 줄이기 어려울 때 품질을 보정하는 방향).

결론: 4bit 변환 에러는 “옵션” 문제가 아니라 “호환성 설계” 문제다

TensorFlow Lite에서 LLM 4bit 변환 에러를 마주치면, 대부분은 다음 중 하나로 귀결됩니다.

  • 그래프에 TFLite builtin이 싫어하는 연산/동적 shape가 남아 있음
  • 4bit가 적용 가능한 텐서/연산 범위를 넘어섬
  • delegate가 4bit 텐서 조합을 처리하지 못함

따라서 해결책도 “옵션 한 줄”이 아니라,

  • float TFLite 변환 성공 경로 확보
  • SELECT_TF_OPS로 문제 구간 특정
  • 고정 shape/연산 치환으로 builtin-only에 가깝게 정리
  • 양자화는 8bit부터 단계적으로, 4bit는 weight-only 중심으로 제한

이 순서로 접근하는 게 가장 빠르고 재현 가능하며, 실제 제품에 넣기에도 안전합니다.

원하시면 사용 중인 모델 아키텍처(예: LLaMA 계열, GPT 계열), 입력 shape(배치/시퀀스 길이), 변환 로그 중 Unsupported op 목록을 주시면 “어떤 레이어를 어떻게 바꾸면 builtin-only로 갈 수 있는지”까지 구체적으로 쪼개서 가이드해드릴게요.