TensorFlow Lite에 custom op을 넣어 재빌드하기 - MediaPipe attention face mesh 통합기
들어가며
mediapipe_face_mesh 패키지에 attention face mesh 모델을 추가하며, TFLite 런타임 바이너리를 custom op을 넣어 재빌드했다. 이 글은 그 과정의 기록이다.
face_landmark_with_attention.tflite는 눈·입술·홍채를 한 번의 추론으로 refine해서 478개 landmark를 바로 내주는 모델이다. 기존에는 mesh 모델(468) 추론 후 iris 모델을 한 번 더 돌리는 2-pass 구조였는데, 이 모델이면 1-pass로 끝난다.
이 글에서 반복해서 쓰는 용어 두 개를 먼저 짚는다.
- stock: 손대지 않은 순정 상태 — 공식 배포 그대로의 빌드(바이너리)나 소스. custom op을 넣어 재빌드하거나 패치한 것과 대비되는 기준점으로 쓴다.
- kernel(커널): op 하나를 실제로 계산하는 함수. OS 커널이 아니라 GPU·수치연산에서 말하는 그 compute kernel이다.
custom op란
.tflite 파일은 연산 그래프다. 입력 텐서가 CONV_2D → RELU → FULLY_CONNECTED → … 같은 op(operator)들을 거쳐 출력 텐서가 된다. 인터프리터는 그래프를 순회하면서 각 op에 대응하는 커널(kernel) — op 하나를 실제로 계산하는 함수 — 을 찾아 실행한다.
- builtin op: TFLite에 표준으로 내장된 연산(
CONV_2D,ADD,SOFTMAX…). enum 코드로 식별된다. - custom op: 표준에 없는 연산. 모델 안에 문자열 이름으로 박히고, 실행하려면 그 이름에 대응하는 커널을 런타임에 직접 등록해줘야 한다.
실제로 attention 모델을 stock 런타임에 그냥 넣으면 이 에러가 난다.
1
2
Encountered unresolved custom op: Landmarks2TransformMatrix.
Node number 192 (Landmarks2TransformMatrix) failed to prepare.
.tflite는 FlatBuffer 포맷이고, 그래프의 각 op은 operator_codes 테이블을 가리킨다. builtin op은 builtin_code(enum 숫자)로, custom op은 builtin_code = CUSTOM + custom_code 문자열로 식별된다. 그래서 custom op은 이름으로 매칭된다. 인터프리터가 그래프를 훑다가 모르는 custom_code를 만나면 resolver에서 그 이름의 커널을 찾고, 없으면 위의 unresolved custom op 에러다.
실제로 모델 파일에 문자열이 그대로 들어있다.
1
2
3
4
strings face_landmark_with_attention.tflite | grep -i transform
# TransformTensorBilinear
# TransformLandmarks
# Landmarks2TransformMatrix
이 모델이 쓰는 custom op 3종
| op 이름 | 하는 일 |
|---|---|
Landmarks2TransformMatrix | landmark 좌표들로부터 변환 행렬을 계산한다. 거친 mesh의 눈 주변 점들로 그 부위의 crop·회전 변환을 유도하는 식이다. |
TransformTensorBilinear | 그 변환 행렬대로 feature map을 bilinear 리샘플링한다. 눈·입술 부위를 정렬해서 잘라내는 역할. |
TransformLandmarks | 변환 행렬을 landmark 좌표에 적용한다. refine된 부위 좌표를 전체 얼굴 좌표계로 되돌린다. |
이 셋이 모여 “attention” 메커니즘을 이룬다. 거친 mesh를 뽑고 → 부위별 변환을 계산하고(Landmarks2TransformMatrix) → 그 부위 feature를 워프해서(TransformTensorBilinear) → refinement head를 돌리고 → 결과를 원래 좌표계로 매핑한다(TransformLandmarks). 그래서 눈·입술·홍채가 더 정밀해진다.
참고로 이건 TFLite의 Flex(TF Select) op과는 다르다. Flex는 TF 본체의 op을 끌어다 쓰는 것이고, 여기 건 MediaPipe가 자체 소스로 구현한 별개의 커널이다.
커널은 어떻게 생겼나
TFLite에서 op 커널 하나는 TfLiteRegistration이라는 함수 포인터 묶음이다.
1
2
3
4
5
6
7
8
typedef struct {
void* (*init)(TfLiteContext*, const char* buffer, size_t length); // 옵션 파싱
void (*free)(TfLiteContext*, void* buffer);
TfLiteStatus (*prepare)(TfLiteContext*, TfLiteNode*); // 출력 shape 계산, 메모리 준비
TfLiteStatus (*invoke)(TfLiteContext*, TfLiteNode*); // 실제 연산
const char* custom_name; // "TransformTensorBilinear"
...
} TfLiteRegistration;
Register...V2() 같은 함수가 이 구조체를 반환한다. 즉 custom op의 연산 로직 = prepare/invoke 함수 본체이고, 이게 바이너리에 컴파일되어 있어야 한다.
C의 함수 포인터가 낯설다면, 이 구조체를 Dart로 옮겨보면 핵심이 선명해진다. 함수가 1급 객체라
(*prepare)같은 표기 없이 함수 타입 필드로 쓴다. (개념 비유일 뿐, 실제 커널은 C++다.)
1 2 3 4 5 6 typedef NodeFn = TfLiteStatus Function(TfLiteContext ctx, TfLiteNode node); class TfLiteRegistration { NodeFn? prepare; // 로직이 든 게 아니라, 외부 함수를 꽂을 빈 슬롯 NodeFn? invoke; }즉
reg.prepare = myFn처럼 빈 슬롯에 함수를 꽂는 구조다. TFLite가 모르던 연산도 여기에 함수를 꽂으면 실행된다 — 이게 곧 custom op 등록의 원리다.
stock 런타임은 왜 못 도나
이 패키지는 TFLite C API를 쓴다. C API는 인터프리터를 만들 때 내부적으로 이 함수를 호출한다.
1
2
// tensorflow/lite/create_op_resolver.h
std::unique_ptr<MutableOpResolver> CreateOpResolver();
이게 반환하는 OpResolver가 “op 이름/코드 → 커널” 매핑표다. stock 빌드에는 create_op_resolver_with_builtin_ops가 링크되어 있어서, builtin op만 들어간 resolver를 준다.
그래서 배포되는 libtensorflowlite_c.so나 TensorFlowLiteC.framework에는 저 3개 custom op의 구현도, 등록도 없다.
1
strings libtensorflowlite_c.so | grep -c TransformTensorBilinear # → 0
그래서 직접 빌드하는 수밖에 없다.
해결 - resolver만 갈아 끼운다
다행히 C API는 CreateOpResolver() 하나로 op 목록이 결정된다. 그러니 이 함수를 “builtin + custom op”을 등록하는 버전으로 바꿔서 C 바이너리만 재빌드하면, attention face mesh 모델 로드가 가능하다.
MediaPipe는 이미 그 등록 함수를 갖고 있다. mediapipe/util/tflite/cpu_op_resolver.cc를 보면 custom op 7종을 등록한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
namespace mediapipe {
void MediaPipe_RegisterTfLiteOpResolver(tflite::MutableOpResolver* resolver) {
resolver->AddCustom("MaxPoolingWithArgmax2D",
tflite_operations::RegisterMaxPoolingWithArgmax2D());
resolver->AddCustom("MaxUnpooling2D",
tflite_operations::RegisterMaxUnpooling2D());
resolver->AddCustom("Convolution2DTransposeBias",
tflite_operations::RegisterConvolution2DTransposeBias());
resolver->AddCustom("TransformTensorBilinear",
tflite_operations::RegisterTransformTensorBilinearV2(),
/*version=*/2);
resolver->AddCustom("TransformLandmarks",
tflite_operations::RegisterTransformLandmarksV2(),
/*version=*/2);
resolver->AddCustom("Landmarks2TransformMatrix",
tflite_operations::RegisterLandmarksToTransformMatrixV2(),
/*version=*/2);
resolver->AddCustom("Resampler", tflite_operations::RegisterResampler(),
/*version=*/1);
}
} // namespace mediapipe
그래서 우리가 쓸 코드는 이게 전부다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// mediapipe/custom_capi/create_op_resolver.cc
#include "mediapipe/util/tflite/cpu_op_resolver.h"
#include "tensorflow/lite/kernels/register.h"
#include "tensorflow/lite/mutable_op_resolver.h"
namespace tflite {
std::unique_ptr<MutableOpResolver> CreateOpResolver() {
auto resolver = std::make_unique<ops::builtin::BuiltinOpResolver>(); // builtin 전부
mediapipe::MediaPipe_RegisterTfLiteOpResolver(resolver.get()); // + custom op
return resolver;
}
} // namespace tflite
이게 가능한 건 TFLite가 공식 확장 포인트를 열어뒀기 때문이다. tensorflow/lite/c/BUILD에는 c_api_without_op_resolver라는 변형 타깃이 있는데, CreateOpResolver()를 일부러 미정의로 남긴다.
1
tags = ["allow_undefined_symbols"], # For tflite::CreateOpResolver().
“네가 직접 정의해서 링크해라”라는 뜻이다. 여기에 위 소스를 링크하면 끝난다.
등록과 구현은 다른 층이다
위 코드를 보면 “그럼 op의 실제 계산 코드는 어디 있지? 우리가 안 썼는데?” 싶다.
핵심은 op을 쓰는 데 필요한 것이 두 층으로 나뉘어 있다는 점이다.
| 층 | 하는 일 | 우리가 작성? | 바이너리에 필요? |
|---|---|---|---|
| 등록 | 이름 → 커널 매핑 (AddCustom(...)) | O | O |
| 구현 | prepare/invoke 본체(실제 수학) | X (MediaPipe 제공) | O (반드시) |
AddCustom은 이름과 함수 포인터를 연결만 한다. 그 포인터가 가리키는 실제 코드는 어딘가에서 컴파일·링크돼 있어야 하고, 없으면 undefined reference: RegisterTransformTensorBilinearV2로 링크가 터진다.
그 구현은 bazel이 deps 사슬로 자동으로 끌어온다. 먼저 짚어둘 것 — 이 빌드는 MediaPipe 워크스페이스 안에서 돌린다. 그래서 아래 사슬에서 //mediapipe/...는 지금 빌드하는 워크스페이스의 로컬 소스이고, @org_tensorflow//...는 외부에서 당겨오는 TF 의존성이다. (왜 하필 MediaPipe에서 빌드하는지는 다음 섹션에서 다룬다.)
1
2
3
4
5
6
custom_create_op_resolver
└─ //mediapipe/util/tflite:cpu_op_resolver (등록 함수)
└─ //mediapipe/util/tflite/operations:transform_tensor_bilinear
(TFLite 커널: prepare/invoke)
└─ @org_tensorflow//tensorflow/lite/delegates/gpu/common/mediapipe:
transform_tensor_bilinear (속성 구조체·파서·GPU 커널)
만약 MediaPipe 같은 기성 구현이 아니라 완전히 새로운 custom op이었다면, prepare/invoke 로직을 직접 C++로 작성해야 했을 것이다. 여기선 MediaPipe가 이미 구현해뒀으니 등록만 하면 됐다.
왜 stock TF가 아니라 MediaPipe 워크스페이스인가
여기 함정이 하나 있다. 위 사슬의 마지막 줄, tensorflow/lite/delegates/gpu/common/mediapipe/ 경로는 TF master에 존재하지 않는다.
이건 MediaPipe가 빌드할 때 TF에 패치로 심는 파일들이었다. MediaPipe의 WORKSPACE를 보면 @org_tensorflow를 특정 커밋으로 고정하고 패치 4개를 적용한다.
1
2
3
4
5
6
7
8
9
10
11
http_archive(
name = "org_tensorflow",
urls = ["https://github.com/tensorflow/tensorflow/archive/fad6b3cf5a7d51a437bd01ee929853bc8554b618.tar.gz"],
patches = [
"@//third_party:org_tensorflow_c_api_experimental.diff",
"@//third_party:org_tensorflow_custom_ops.diff", # ← 이게 심는다
"@//third_party:org_tensorflow_objc_build_fixes.diff",
"@//third_party:org_tensorflow_verifier_int4.diff",
],
...
)
org_tensorflow_custom_ops.diff가 TF 트리에 gpu/common/mediapipe/{BUILD, custom_parsers.cc, landmarks_to_transform_matrix.cc, transform_landmarks.cc, transform_tensor_bilinear.cc, ...}를 생성한다.
결론은 MediaPipe 워크스페이스 = 패치된 TF + op 소스 + resolver가 모두 갖춰진 곳. 그래서 빌드는 MediaPipe repo 안에서 하고, C API 타깃은 @org_tensorflow//...로 참조한다.
한 가지 덧붙이자면, @org_tensorflow는 TF master의 최신이 아니라 MediaPipe의 WORKSPACE가 고정한 커밋이다. TF 소스는 MediaPipe repo 안에 들어있지 않고 bazel build 때 tar.gz로 내려받아진다.
전체 그림은 이렇게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[소스 준비]
MediaPipe clone ── WORKSPACE가 @org_tensorflow(핀 커밋) fetch + 패치 적용
└ custom_ops.diff → gpu/common/mediapipe/* 생성
+ mediapipe/custom_capi/{create_op_resolver.cc, BUILD} ← 우리가 추가
[빌드 (bazel, MediaPipe 워크스페이스)]
custom_create_op_resolver ─┐
c_api_without_op_resolver ─┼─► 링크 ─► custom op이 들어간 C API 바이너리
┘
Android: cc_binary(linkshared) → libtensorflowlite_c.so (ABI별)
iOS: ios_static_framework → TensorFlowLiteC.framework
[후처리]
strip / 심볼 숨김 → 검증 → 패키지의 prebuilt 바이너리와 교체
[런타임]
모델 로드 → TfLiteInterpreterCreate → CreateOpResolver()(builtin + custom)
→ AllocateTensors() 성공
Android 빌드
TF가 제공하는 tensorflow/lite/tools/tflite-android.Dockerfile이 bazel + Android SDK + NDK 환경을 만들어준다. 이미지는 “환경”만 주고, 소스와 custom op은 우리가 넣는다.
1
2
3
4
5
6
7
8
9
10
11
docker build . -t tflite-builder -f tflite-android.Dockerfile
docker run -d --name tflite-c \
-v ~/attn-build/mediapipe:/mediapipe -v ~/attn-build:/host_dir \
tflite-builder sleep infinity
docker exec tflite-c bash -lc '
cd /mediapipe
export ANDROID_HOME=/android/sdk ANDROID_NDK_HOME=/android/ndk
bazel build -c opt --config=android_arm64 --cxxopt=--std=c++17 \
//mediapipe/custom_capi:libtensorflowlite_c.so
'
여기서 겪은 에러들.
1. WORKSPACE에 NDK 툴체인이 등록돼 있지 않다. MediaPipe WORKSPACE는 android_ndk_repository를 load만 하고 호출하지 않는다(# @unused). 그대로 두면 Android 툴체인이 없어서 빌드가 안 된다. 직접 추가해야 한다.
1
2
android_ndk_repository(name = "androidndk", api_level = 30)
register_toolchains("@androidndk//:all")
2. tflite_cc_shared_object 매크로의 select() 함정. 처음엔 stock과 똑같이 TF의 매크로를 쓰려 했는데, 매크로 내부의 //tensorflow:... 라벨이 MediaPipe repo 컨텍스트에서 @//tensorflow(= /mediapipe/tensorflow)로 오해석되어 “no such package”로 깨진다.
원인은 Bazel의 라벨 해석 규칙이다. repo를 생략한 bare // 라벨은 빌드를 돌리는 메인 repo 기준으로 풀린다. 이 매크로는 “메인 repo가 TF”인 자기 워크스페이스를 가정하고 짜였는데, 우리는 메인 repo가 mediapipe인 상태(cd /mediapipe && bazel build)로 호출하니 //tensorflow:...가 @mediapipe//tensorflow:...(없음)로 튄다. 게다가 매크로가 플랫폼별 linkopts를 고르는 select()의 분기 키 자체가 그 bare 라벨이라, deps 하나가 아니라 분기 전체가 깨진다.
그래서 매크로를 버리고 plain cc_binary(linkshared = 1)로 우회했다. 모든 라벨을 @org_tensorflow//...로 직접 못 박으면 bare //tensorflow도 select()도 사라져 오해석될 여지가 없다. 대신 매크로가 넣어주던 version-script는 linkopts로 직접 복원했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
cc_binary(
name = "libtensorflowlite_c.so",
linkshared = 1,
linkopts = [
"-Wl,--version-script,$(location @org_tensorflow//tensorflow/lite/c:version_script.lds)",
"-Wl,-z,max-page-size=16384",
],
deps = [
":custom_create_op_resolver",
"@org_tensorflow//tensorflow/lite/c:c_api_without_op_resolver",
"@org_tensorflow//tensorflow/lite/c:c_api_experimental_without_op_resolver",
"@org_tensorflow//tensorflow/lite/c:version_script.lds",
],
)
3. x86_64에서 XNNPACK 최신 ISA 커널이 안 된다. NDK r25b의 clang이 -mavx512fp16, -mavxvnniint8 같은 플래그를 모른다. 해당 커널만 끄면 된다.
1
--define=xnn_enable_avx512fp16=false --define=xnn_enable_avxvnniint8=false ...
4. 용량이 stock의 2.5배로 나온다. 첫 빌드 결과가 10MB였다. stock은 4MB인데. “custom op 때문에 커졌나?” 했는데 아니었다. stock이 이미 stripped였을 뿐이다.
1
2
llvm-strip --strip-all libtensorflowlite_c.so
# arm64-v8a: 10MB → 4.1MB, x86_64: 11.5MB → 6.1MB (stock과 동일)
5. 16KB 페이지 정렬 공식 TFLite 릴리스 .so는 -Wl,-z,max-page-size=16384로 링크되어 LOAD 세그먼트가 16KB(0x4000) 정렬인데, 내 bazel 빌드에는 그 linkopt가 없어서 4KB(0x1000)로 나온 것이다.
참고로 Google Play는 2025년 11월 1일부터 Android 15 이상을 타깃하는 새 앱·업데이트에 대해 64비트 기기에서의 16KB 페이지 지원을 요구한다. prebuilt 네이티브 라이브러리를 교체하는 입장에서는 그냥 넘길 수 없는 조건이다.
1
readelf -lW libtensorflowlite_c.so | grep LOAD # Align이 0x4000이어야 한다
검증
빌드한 TFLite 검증은 세 가지를 본다.
1
2
3
readelf -lW $f | grep LOAD # 0x4000 정렬
strings -n6 $f | grep -c TransformTensorBilinear # custom op 존재
nm -D --defined-only $f | grep -c TfLiteXNNPackDelegate # XNNPACK 심볼 유지
strings에 이름이 있다고 op이 동작하는 건 아니다. arm64 .so는 x86 리눅스에서 실행할 수 없으니, host용으로도 같은 custom C API를 빌드해서 작은 C 프로그램으로 모델을 TfLiteInterpreterCreate → AllocateTensors 해봤다. stock이라면 실패하는 그 지점을 통과했고, 출력 7개 텐서가 정확히 나왔다.
| 출력 텐서 | 크기 | 의미 |
|---|---|---|
| mesh | 1404 | 468 landmark × 3 (x, y, z) |
| lips | 160 | 80 × 2 |
| left eye / right eye | 142 각각 | 71 × 2 |
| left iris / right iris | 10 각각 | 5 × 2 |
| face flag | 1 | 얼굴 존재 확률 |
“op이 들어있다”를 넘어 “동작한다”까지 확인한 셈이다.
iOS 빌드
아이디어는 Android와 100% 같다. resolver 소스는 플랫폼 독립이라 그대로 재사용한다. 다른 건 산출물이 .so가 아니라 TensorFlowLiteC.framework이고 macOS + Xcode가 필요하다.
계획: stock 프레임워크 타깃 미러링 → 실패
Android에서 했던 것처럼 “stock //tensorflow/lite/ios:TensorFlowLiteC_framework(= tflite_ios_framework 매크로)를 미러링하고 deps의 resolver만 교체”하려 했다. 두 겹의 벽에 막혔다.
1. BUILD.apple은 bazel 패키지가 아니다. TF의 tensorflow/lite/ios/는 빌드 파일 이름이 BUILD가 아니라 BUILD.apple이다. 그래서 bazel이 이 디렉터리를 패키지로 인식하지 않는다. load(".../tensorflow/lite/ios:ios.bzl")도, 헤더 타깃도, allowlist_TensorFlowLiteC.txt도 전부 “not a package”로 실패한다.
2. visibility + 순환 참조. 그 패키지의 헤더 타깃과 툴(extract_object_files, hide_symbols_with_allowlist)은 //tensorflow/lite:__subpackages__에만 visible이라 @mediapipe에서 쓸 수 없다. 그렇다고 우리 타깃을 @org_tensorflow 안으로 옮길 수도 없다 — 우리 resolver가 //mediapipe/util/tflite:cpu_op_resolver에 의존하니 TF → MediaPipe 역방향 순환이 된다. “TF의 BUILD를 패치하자”는 대안도 이 순환 때문에 무효였다.
해결 방안: 프레임워크 직접 조립
TF의 iOS 매크로를 못 쓰니, @mediapipe 안에서 rules_apple의 public ios_static_framework로 self-contained하게 조립했다. 우리 resolver가 //mediapipe/util/tflite:cpu_op_resolver에 의존하는 이상 프레임워크 타깃도 @mediapipe에 있어야 하는데, 그 안에서 TF의 ios 패키지를 한 줄도 참조하지 않고 필요한 걸 전부 로컬 cc 소스(C API 껍데기 + resolver)에서 링크한다는 뜻이다. Android의 plain cc_binary 우회와 발상이 같다.
실제로 두 플랫폼은 같은 MediaPipe clone 안에, 우리 BUILD 타깃을 두고, 범용 bazel 규칙으로 조립한다는 뼈대가 동일하다. 다른 건 규칙과 산출물뿐이다.
| Android | iOS | |
|---|---|---|
| 빌드 워크스페이스 | @mediapipe (clone) | @mediapipe (같은 clone) |
| 우리 타깃 위치 | mediapipe/custom_capi/BUILD | mediapipe/custom_capi/BUILD |
| 쓰는 규칙 | 내장 cc_binary | rules_apple ios_static_framework |
| TF 매크로 | 안 씀 (우회) | 안 씀 (우회) |
| 산출물 | libtensorflowlite_c.so | TensorFlowLiteC.framework |
여기서 알아둘 게, iOS .framework는 단일 파일이 아니라 바이너리 + Headers/ + Modules/가 든 번들(디렉터리)이다. custom op은 코드(바이너리)만 바꾸지 C API 시그니처를 안 건드리므로, 바꿔야 하는 건 그 안의 Mach-O 바이너리 하나뿐이고 Headers/Modules는 stock 그대로여도 정합이다. 그런데 ios_static_framework 규칙은 hdrs를 반드시 요구한다. 그래서 기존 프레임워크의 Headers/를 복사해 넣어 규칙을 만족시켜 빌드만 통과시키고, 산출된 번들의 헤더·모듈은 버린 뒤 바이너리만 꺼내 기존 번들의 Headers/Modules는 그대로 재사용했다. 실제 교체도 프레임워크 폴더 안의 파일 하나(TensorFlowLiteC 바이너리)만 스왑하는 최소 변경이다.
1
2
3
4
5
6
JAVA_HOME=/opt/homebrew/opt/openjdk USE_BAZEL_VERSION=7.4.1 bazel build \
--config=ios --ios_multi_cpus=arm64,x86_64 \
-c opt --cxxopt=--std=c++17 \
--repo_env=HERMETIC_PYTHON_VERSION=3.12 \
--repo_env=JAVA_HOME=/opt/homebrew/opt/openjdk \
//mediapipe/custom_capi:TensorFlowLiteC_framework
플래그마다 이유가 있다.
--config=ios_fat(공식 문서)를 쓰면 깨진다.ios_fat에는 armv7/i386이 들어있는데, bzlmod로 받은 최신 rules_apple에는ios_armv7매핑이 없어서 트랜지션 에러(key "ios_armv7" not found)가 난다.--config=ios --ios_multi_cpus=arm64,x86_64로 대체했다.HERMETIC_PYTHON_VERSION=3.12— 시스템 python 3.14는 TF의requirements_lock범위(3.9~3.12) 밖이라 실패한다.JAVA_HOME+--repo_env=JAVA_HOME— JDK가 없으면 rules_java의local_jdkrepo가 깨져서(no such package @rules_java~//tools/jdk) apple 번들링 분석 단계가 죽는다.
367MB → 18MB: 심볼 숨김이 필수였다
ios_static_framework가 뱉은 바이너리는 367MB였다. stock이 47MB인데. 이유는 이게 그냥 ar 아카이브이고 모든 심볼이 global이기 때문이다. stock의 tflite_ios_framework 매크로는 내부에서 심볼 병합·숨김 단계를 자동으로 돌리는데, 우리는 그 매크로를 못 썼으니 그 단계가 통째로 빠진 것이다.
그래서 stock의 스크립트(hide_symbols_with_allowlist.sh)를 수동으로 따로 실행했다. 하는 일은 이렇다.
- arch별로
ld -r -exported_symbols_list <allowlist> -x를 걸어 모든 오브젝트를 단일 Mach-O로 병합하고, - allowlist(
_TfLite*,*AcquireFlexDelegate*) 외의 심볼을 전부 localize/strip한다.
결과는 18MB, export 심볼은 _TfLite* 219개뿐, non-TfLite external 0개. 즉 “custom op을 넣었더니 커졌다”가 아니라, 매크로가 대신 해주던 후처리가 빠져서 커 보였던 것이다. Android에서 strip 한 방으로 끝났던 것의 iOS판인데, 훨씬 손이 많이 갔다.
왜 stock(47MB)보다도 작아졌나
교체 전 stock 프레임워크는 47.5MB인데 우리 건 18.1MB다. 아키텍처는 둘 다 arm64+x86_64로 동일하니 슬라이스 수 때문은 아니다. 확인해보면 두 가지가 겹친 결과로 보인다.
- 링크 구성이 stock보다 좁다. 우리 deps는
c_api_without_op_resolver+c_api_experimental_without_op_resolver+ resolver뿐인데, stock의:tensorflow_lite_c는 여기에 profiling/telemetry 등을 더 얹는다. - 심볼·메타데이터가 줄었다. stock은
SUBSECTIONS_VIA_SYMBOLS플래그를 갖고 심볼 문자열이 ~2,520개인데, 우리 건 그 플래그가 없고 ~646개다.ld -r … -x로 전 오브젝트를 단일 Mach-O로 병합하고 local 심볼을 strip하면 심볼·재배치 테이블이 크게 줄어든다. 코드가 줄었다기보다 메타데이터가 줄었다.
검증 - “Runner 바이너리엔 심볼이 없다”
프레임워크를 ios/Frameworks/에 넣고 example을 빌드해서 심볼을 확인하는데, Runner 본체에는 TfLite 심볼이 0개로 보여서 실패한 줄 알았다.
원인은 이 패키지의 로딩 방식이었다. src/tflite_runtime.h가 Apple에서는 dlsym(RTLD_DEFAULT, ...)로 심볼을 찾는 정적 링크 모델이라, TfLite 심볼은 Runner가 아니라 플러그인의 동적 프레임워크(Runner.app/Frameworks/mediapipe_face_mesh.framework) 안에 들어간다. 거기를 보니 TfLiteInterpreterCreate와 custom op이 정상적으로 있었다.
Android vs iOS 한 줄 요약: Android는 “매크로의 select 함정을 피해 plain
cc_binary로 우회”가 전부였는데, iOS는 TF의 iOS 패키지에 아예 접근할 수 없어서(BUILD.apple + visibility/순환) 프레임워크를 직접 조립하고, 매크로가 해주던 심볼 숨김까지 손으로 복원해야 했다.
패키지 쪽은?
- 옵션 하나 추가(
enableAttentionMesh, 기본 off라 기존 동작과 동일). - attention일 때 모델의 7개 출력 텐서를 478 layout으로 조립한다. mesh 468을 그대로 깔고, 눈·입술은 해당 인덱스를 덮어쓰고, iris 10개를 뒤에 붙인다. 조립 규칙은 MediaPipe의
tensors_to_face_landmarks_with_attention.pbtxt설정을 그대로 따랐다. - 그 뒤의 좌표 변환·ROI·blendshape 등 기존 로직은 478을 받는 입장에서 차이를 모른다.
정리
.tflite는 op 그래프이고, custom op은 표준에 없어서 런타임에 커널을 직접 등록해야 하는 연산이다.- attention face mesh는 MediaPipe custom op 3종(변환 행렬 계산 / feature 워프 / landmark 역변환)으로 구현되어 있다.
- C API는
CreateOpResolver()로 op 목록을 정하는데 stock은 builtin뿐이다. → 그 함수만 커스텀 버전으로 바꿔 C 바이너리를 재빌드하면 앱 코드 변경 없이 모델이 로드된다. TFLite가c_api_without_op_resolver로 열어둔 공식 확장 포인트다. - 그 재빌드는 MediaPipe 워크스페이스(패치된 핀 TF) 안에서 해야 한다. custom op의 의존 파일이 TF master에는 없고 MediaPipe의 패치로만 존재하기 때문이다.
- custom op 자체는 순수 TFLite 커널이라 이식은 깔끔했다. 정작 시간을 잡아먹은 건 op이 아니라 빌드 시스템이었다.
- 용량은 오해하기 쉽다. custom op 때문에 커지는 게 아니라, stock이 원래 해주던 후처리(strip / 심볼 숨김)를 우리가 안 해서 커 보였을 뿐이다. 복원하면 Android 4.1MB(= stock), iOS 18MB(< stock 47MB).
참고
- MediaPipe
cpu_op_resolver.cc - MediaPipe
WORKSPACE(핀 TF 커밋 + 패치) - TFLite
tensorflow/lite/c/BUILD(c_api_without_op_resolver) - TFLite
tensorflow/lite/ios/(BUILD.apple,hide_symbols_with_allowlist.sh) - Support 16 KB page sizes - Android Developers
- mediapipe_face_mesh (pub.dev)