본문으로 바로가기

모바일에서 AI 모델 돌리기

23.06.01

    ai
    dev

모바일에서 AI 모델 돌리기

2023년, On-device AI 프로젝트를 여러 개 진행하면서 많은 것을 배웠습니다. 스마트폰에서 AI 모델을 직접 돌리는 것이 생각보다 실용적인 수준에 도달했다는 것을 체감한 한 해였습니다. 이 글에서는 그 경험과 기술적인 내용을 정리합니다.

왜 모바일에서 돌리는가

On-device AI를 선택하는 이유는 크게 세 가지입니다.

ON_DEVICE_AI_BENEFITS = {
    "privacy": {
        "description": "데이터를 서버로 전송하지 않음",
        "use_cases": ["음성 인식", "얼굴 인식", "건강 데이터 분석"],
        "compliance": ["GDPR", "HIPAA", "개인정보보호법"]
    },
    "latency": {
        "description": "네트워크 왕복 시간 제거",
        "server_api": "100-500ms (네트워크 포함)",
        "on_device": "10-50ms (추론만)",
        "improvement": "5-10x 빠름"
    },
    "cost": {
        "description": "API 호출 비용 없음",
        "api_cost_per_1k": "$0.001-0.01",
        "on_device_cost": "$0 (초기 개발 비용만)",
        "break_even": "대량 처리 시 유리"
    }
}

실제 프로젝트에서는 프라이버시가 가장 큰 동기였습니다. 음성 데이터나 카메라 피드를 외부 서버로 보내는 것에 대한 사용자 우려가 컸고, 로컬 처리로 이 문제를 해결할 수 있었습니다.

실제로 구현한 기능들

1. 이미지 분류

MobileNet 계열 모델을 활용한 이미지 분류를 구현했습니다. Core ML로 변환하면 iPhone에서 실시간으로 동작합니다.

import coremltools as ct
import torch
from torchvision import models

# PyTorch MobileNetV3 로드
model = models.mobilenet_v3_small(pretrained=True)
model.eval()

# 입력 예시 생성
example_input = torch.rand(1, 3, 224, 224)

# TorchScript로 변환
traced_model = torch.jit.trace(model, example_input)

# Core ML 변환
coreml_model = ct.convert(
    traced_model,
    inputs=[ct.ImageType(
        name="image",
        shape=(1, 3, 224, 224),
        scale=1/255.0,
        bias=[-0.485/0.229, -0.456/0.224, -0.406/0.225]
    )],
    classifier_config=ct.ClassifierConfig("imagenet_labels.txt"),
    minimum_deployment_target=ct.target.iOS15
)

# Neural Engine 최적화 설정
coreml_model = ct.convert(
    traced_model,
    inputs=[ct.ImageType(name="image", shape=(1, 3, 224, 224))],
    compute_units=ct.ComputeUnit.ALL  # CPU, GPU, Neural Engine 모두 활용
)

coreml_model.save("MobileNetV3.mlpackage")

Swift에서 사용하는 코드는 다음과 같습니다.

import CoreML
import Vision

class ImageClassifier {
    private let model: VNCoreMLModel

    init() throws {
        let config = MLModelConfiguration()
        config.computeUnits = .all  // Neural Engine 활용

        let coreMLModel = try MobileNetV3(configuration: config)
        model = try VNCoreMLModel(for: coreMLModel.model)
    }

    func classify(image: CGImage) async throws -> [(label: String, confidence: Float)] {
        return try await withCheckedThrowingContinuation { continuation in
            let request = VNCoreMLRequest(model: model) { request, error in
                if let error = error {
                    continuation.resume(throwing: error)
                    return
                }

                guard let results = request.results as? [VNClassificationObservation] else {
                    continuation.resume(returning: [])
                    return
                }

                let predictions = results.prefix(5).map {
                    (label: $0.identifier, confidence: $0.confidence)
                }
                continuation.resume(returning: predictions)
            }

            let handler = VNImageRequestHandler(cgImage: image)
            try? handler.perform([request])
        }
    }
}

2. 음성 인식 (Whisper)

Whisper 모델을 Core ML로 변환해서 iPhone에서 돌려봤습니다. tiny와 base 모델이 모바일에서 실용적인 수준으로 동작했습니다.

import whisper
import coremltools as ct
import torch

# Whisper tiny 모델 로드
model = whisper.load_model("tiny")
model.eval()

# 오디오 인코더 변환
class WhisperEncoder(torch.nn.Module):
    def __init__(self, whisper_model):
        super().__init__()
        self.encoder = whisper_model.encoder

    def forward(self, mel):
        return self.encoder(mel)

encoder = WhisperEncoder(model)
encoder.eval()

# 트레이싱
mel_input = torch.randn(1, 80, 3000)  # 30초 오디오
traced_encoder = torch.jit.trace(encoder, mel_input)

# Core ML 변환
encoder_mlmodel = ct.convert(
    traced_encoder,
    inputs=[ct.TensorType(name="mel", shape=(1, 80, 3000))],
    minimum_deployment_target=ct.target.iOS16
)

encoder_mlmodel.save("WhisperEncoder.mlpackage")

성능 측정 결과입니다.

WHISPER_PERFORMANCE = {
    "tiny": {
        "model_size": "39MB",
        "iphone_14_pro": {
            "inference_time": "0.8초 (30초 오디오)",
            "real_time_factor": "0.027x"  # 실시간보다 37배 빠름
        },
        "iphone_12": {
            "inference_time": "1.5초",
            "real_time_factor": "0.05x"
        }
    },
    "base": {
        "model_size": "74MB",
        "iphone_14_pro": {
            "inference_time": "1.8초",
            "real_time_factor": "0.06x"
        },
        "iphone_12": {
            "inference_time": "3.5초",
            "real_time_factor": "0.12x"
        }
    }
}

3. 텍스트 분류

DistilBERT를 모바일에 최적화해서 감정 분석에 사용했습니다.

from transformers import DistilBertTokenizer, DistilBertForSequenceClassification
import torch
import coremltools as ct

# 모델 로드
tokenizer = DistilBertTokenizer.from_pretrained("distilbert-base-uncased")
model = DistilBertForSequenceClassification.from_pretrained(
    "distilbert-base-uncased-finetuned-sst-2-english"
)
model.eval()

# 정적 입력 크기로 래핑
class DistilBertWrapper(torch.nn.Module):
    def __init__(self, model):
        super().__init__()
        self.model = model

    def forward(self, input_ids, attention_mask):
        outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
        return outputs.logits

wrapper = DistilBertWrapper(model)

# 트레이싱 (고정 시퀀스 길이)
MAX_SEQ_LEN = 128
dummy_input_ids = torch.zeros(1, MAX_SEQ_LEN, dtype=torch.int32)
dummy_attention_mask = torch.ones(1, MAX_SEQ_LEN, dtype=torch.int32)

traced_model = torch.jit.trace(wrapper, (dummy_input_ids, dummy_attention_mask))

# Core ML 변환
mlmodel = ct.convert(
    traced_model,
    inputs=[
        ct.TensorType(name="input_ids", shape=(1, MAX_SEQ_LEN), dtype=ct.int32),
        ct.TensorType(name="attention_mask", shape=(1, MAX_SEQ_LEN), dtype=ct.int32)
    ],
    minimum_deployment_target=ct.target.iOS15
)

mlmodel.save("DistilBertSentiment.mlpackage")

최적화 기법 상세

양자화 (Quantization)

FP32에서 INT8로 양자화하면 모델 크기가 1/4로 줄어듭니다. 정확도 손실은 대부분의 경우 1% 미만이었습니다.

import torch
from torch.quantization import quantize_dynamic, get_default_qconfig
import coremltools as ct

# 방법 1: PyTorch Dynamic Quantization
model_fp32 = load_your_model()
model_int8 = quantize_dynamic(
    model_fp32,
    {torch.nn.Linear, torch.nn.Conv2d},
    dtype=torch.qint8
)

# 방법 2: Post-Training Quantization with calibration
from torch.ao.quantization import prepare, convert, default_qconfig

model = load_your_model()
model.eval()

# 양자화 설정
model.qconfig = get_default_qconfig('fbgemm')

# 캘리브레이션 준비
model_prepared = prepare(model)

# 대표 데이터로 캘리브레이션
with torch.no_grad():
    for batch in calibration_dataloader:
        model_prepared(batch)

# 양자화 적용
model_quantized = convert(model_prepared)

# 방법 3: Core ML 양자화
mlmodel = ct.models.MLModel("model.mlpackage")

# 16비트 양자화
mlmodel_fp16 = ct.models.neural_network.quantization_utils.quantize_weights(
    mlmodel,
    nbits=16
)

# 8비트 양자화 (더 공격적)
mlmodel_int8 = ct.models.neural_network.quantization_utils.quantize_weights(
    mlmodel,
    nbits=8
)

mlmodel_int8.save("model_quantized.mlpackage")

양자화 결과 비교입니다.

QUANTIZATION_RESULTS = {
    "MobileNetV3": {
        "fp32": {"size": "21.5MB", "accuracy": "75.2%", "latency": "12ms"},
        "fp16": {"size": "10.8MB", "accuracy": "75.1%", "latency": "8ms"},
        "int8": {"size": "5.4MB", "accuracy": "74.5%", "latency": "5ms"}
    },
    "DistilBERT": {
        "fp32": {"size": "268MB", "accuracy": "91.3%", "latency": "45ms"},
        "fp16": {"size": "134MB", "accuracy": "91.2%", "latency": "28ms"},
        "int8": {"size": "67MB", "accuracy": "90.8%", "latency": "18ms"}
    }
}

Pruning (가지치기)

중요하지 않은 가중치를 제거해서 모델을 경량화합니다.

import torch
import torch.nn.utils.prune as prune

def apply_structured_pruning(model, amount=0.3):
    """
    구조적 프루닝: 전체 필터/뉴런 제거
    비구조적보다 실제 속도 향상에 유리
    """
    for name, module in model.named_modules():
        if isinstance(module, torch.nn.Conv2d):
            prune.ln_structured(
                module,
                name='weight',
                amount=amount,
                n=2,  # L2 norm 기준
                dim=0  # 출력 채널 방향
            )
        elif isinstance(module, torch.nn.Linear):
            prune.l1_unstructured(
                module,
                name='weight',
                amount=amount
            )

    return model

def apply_iterative_pruning(model, dataloader, target_sparsity=0.5, iterations=5):
    """
    점진적 프루닝: 여러 단계에 걸쳐 조금씩 제거
    급격한 성능 저하 방지
    """
    sparsity_per_iter = 1 - (1 - target_sparsity) ** (1 / iterations)

    for i in range(iterations):
        # 프루닝 적용
        apply_structured_pruning(model, amount=sparsity_per_iter)

        # 미세 조정
        fine_tune(model, dataloader, epochs=2)

        # 프루닝 마스크 영구화
        for module in model.modules():
            if hasattr(module, 'weight_orig'):
                prune.remove(module, 'weight')

        current_sparsity = calculate_sparsity(model)
        print(f"Iteration {i+1}: Sparsity = {current_sparsity:.2%}")

    return model

# 프루닝 전후 비교
PRUNING_RESULTS = {
    "original": {"params": "3.4M", "latency": "12ms", "accuracy": "75.2%"},
    "30%_pruned": {"params": "2.4M", "latency": "9ms", "accuracy": "74.8%"},
    "50%_pruned": {"params": "1.7M", "latency": "7ms", "accuracy": "74.1%"},
    "70%_pruned": {"params": "1.0M", "latency": "5ms", "accuracy": "72.5%"}
}

Knowledge Distillation

큰 교사 모델의 지식을 작은 학생 모델로 전이합니다.

import torch
import torch.nn.functional as F

class DistillationTrainer:
    def __init__(
        self,
        teacher_model,
        student_model,
        temperature=4.0,
        alpha=0.7  # soft label 가중치
    ):
        self.teacher = teacher_model
        self.student = student_model
        self.temperature = temperature
        self.alpha = alpha

        self.teacher.eval()
        for param in self.teacher.parameters():
            param.requires_grad = False

    def distillation_loss(self, student_logits, teacher_logits, labels):
        """
        Distillation Loss = α * KL(soft_student || soft_teacher)
                          + (1-α) * CE(student, labels)
        """
        # Soft labels from teacher
        soft_teacher = F.softmax(teacher_logits / self.temperature, dim=1)
        soft_student = F.log_softmax(student_logits / self.temperature, dim=1)

        # KL Divergence for soft labels
        soft_loss = F.kl_div(
            soft_student,
            soft_teacher,
            reduction='batchmean'
        ) * (self.temperature ** 2)

        # Hard label loss
        hard_loss = F.cross_entropy(student_logits, labels)

        # Combined loss
        return self.alpha * soft_loss + (1 - self.alpha) * hard_loss

    def train_step(self, batch):
        inputs, labels = batch

        # Teacher prediction (no gradient)
        with torch.no_grad():
            teacher_logits = self.teacher(inputs)

        # Student prediction
        student_logits = self.student(inputs)

        # Calculate loss
        loss = self.distillation_loss(student_logits, teacher_logits, labels)

        return loss

# 사용 예시
teacher = models.resnet50(pretrained=True)  # 25.6M params
student = models.mobilenet_v3_small(pretrained=False)  # 2.5M params

trainer = DistillationTrainer(teacher, student, temperature=4.0)

플랫폼별 구현

iOS (Core ML)

Core ML은 Apple의 ML 프레임워크로, Neural Engine을 활용하면 매우 빠른 추론이 가능합니다.

import CoreML

class CoreMLInference {
    private let model: MLModel
    private let asyncModel: MLModel?

    init(modelName: String) throws {
        let config = MLModelConfiguration()

        // 계산 유닛 설정
        config.computeUnits = .all  // CPU + GPU + Neural Engine

        // 비동기 예측을 위한 설정
        config.allowLowPrecisionAccumulationOnGPU = true

        // 모델 로드
        guard let modelURL = Bundle.main.url(
            forResource: modelName,
            withExtension: "mlpackage"
        ) else {
            throw ModelError.modelNotFound
        }

        model = try MLModel(contentsOf: modelURL, configuration: config)

        // iOS 16+ 비동기 모델
        if #available(iOS 16.0, *) {
            asyncModel = model
        } else {
            asyncModel = nil
        }
    }

    // 동기 추론
    func predict(input: MLFeatureProvider) throws -> MLFeatureProvider {
        return try model.prediction(from: input)
    }

    // 비동기 추론 (iOS 16+)
    @available(iOS 16.0, *)
    func predictAsync(input: MLFeatureProvider) async throws -> MLFeatureProvider {
        return try await asyncModel!.prediction(from: input)
    }

    // 배치 추론
    func predictBatch(inputs: [MLFeatureProvider]) throws -> [MLFeatureProvider] {
        let batchProvider = MLArrayBatchProvider(array: inputs)
        let results = try model.predictions(fromBatch: batchProvider)

        var outputs: [MLFeatureProvider] = []
        for i in 0..<results.count {
            outputs.append(results.features(at: i))
        }
        return outputs
    }
}

// 성능 측정
class PerformanceProfiler {
    static func measureInference(
        model: MLModel,
        input: MLFeatureProvider,
        iterations: Int = 100
    ) -> (mean: Double, std: Double) {
        var times: [Double] = []

        // 워밍업
        for _ in 0..<10 {
            _ = try? model.prediction(from: input)
        }

        // 측정
        for _ in 0..<iterations {
            let start = CFAbsoluteTimeGetCurrent()
            _ = try? model.prediction(from: input)
            let elapsed = CFAbsoluteTimeGetCurrent() - start
            times.append(elapsed * 1000)  // ms로 변환
        }

        let mean = times.reduce(0, +) / Double(times.count)
        let variance = times.map { pow($0 - mean, 2) }.reduce(0, +) / Double(times.count)
        let std = sqrt(variance)

        return (mean, std)
    }
}

Android (TensorFlow Lite)

TensorFlow Lite는 Android에서 가장 널리 사용되는 ML 프레임워크입니다.

import org.tensorflow.lite.Interpreter
import org.tensorflow.lite.gpu.GpuDelegate
import org.tensorflow.lite.nnapi.NnApiDelegate
import java.nio.ByteBuffer
import java.nio.ByteOrder

class TFLiteInference(context: Context, modelPath: String) {
    private val interpreter: Interpreter

    init {
        // 모델 로드
        val modelBuffer = loadModelFile(context, modelPath)

        // 인터프리터 옵션 설정
        val options = Interpreter.Options().apply {
            // GPU 가속 (지원되는 기기에서)
            try {
                val gpuDelegate = GpuDelegate(
                    GpuDelegate.Options().apply {
                        setPrecisionLossAllowed(true)  // FP16 허용
                        setInferencePreference(
                            GpuDelegate.Options.INFERENCE_PREFERENCE_SUSTAINED_SPEED
                        )
                    }
                )
                addDelegate(gpuDelegate)
            } catch (e: Exception) {
                // GPU 미지원 기기 - CPU fallback
            }

            // NNAPI 가속 (Android 8.1+)
            try {
                val nnApiDelegate = NnApiDelegate(
                    NnApiDelegate.Options().apply {
                        setAllowFp16(true)
                        setUseNnapiCpu(false)
                    }
                )
                addDelegate(nnApiDelegate)
            } catch (e: Exception) {
                // NNAPI 미지원
            }

            setNumThreads(4)
        }

        interpreter = Interpreter(modelBuffer, options)
    }

    fun predict(input: FloatArray): FloatArray {
        val inputBuffer = ByteBuffer.allocateDirect(input.size * 4).apply {
            order(ByteOrder.nativeOrder())
            input.forEach { putFloat(it) }
            rewind()
        }

        val outputShape = interpreter.getOutputTensor(0).shape()
        val outputSize = outputShape.reduce { acc, i -> acc * i }
        val outputBuffer = ByteBuffer.allocateDirect(outputSize * 4).apply {
            order(ByteOrder.nativeOrder())
        }

        interpreter.run(inputBuffer, outputBuffer)

        outputBuffer.rewind()
        val output = FloatArray(outputSize)
        outputBuffer.asFloatBuffer().get(output)

        return output
    }

    // 이미지 분류용 편의 메서드
    fun classifyImage(bitmap: Bitmap): List<Pair<Int, Float>> {
        val inputArray = preprocessImage(bitmap)
        val output = predict(inputArray)

        return output.mapIndexed { index, confidence ->
            index to confidence
        }.sortedByDescending { it.second }.take(5)
    }

    private fun preprocessImage(bitmap: Bitmap): FloatArray {
        val resized = Bitmap.createScaledBitmap(bitmap, 224, 224, true)
        val pixels = IntArray(224 * 224)
        resized.getPixels(pixels, 0, 224, 0, 0, 224, 224)

        val input = FloatArray(224 * 224 * 3)
        for (i in pixels.indices) {
            val pixel = pixels[i]
            input[i * 3] = ((pixel shr 16 and 0xFF) / 255.0f - 0.485f) / 0.229f
            input[i * 3 + 1] = ((pixel shr 8 and 0xFF) / 255.0f - 0.456f) / 0.224f
            input[i * 3 + 2] = ((pixel and 0xFF) / 255.0f - 0.406f) / 0.225f
        }

        return input
    }

    private fun loadModelFile(context: Context, path: String): ByteBuffer {
        val assetFileDescriptor = context.assets.openFd(path)
        val inputStream = assetFileDescriptor.createInputStream()
        val fileChannel = inputStream.channel
        val startOffset = assetFileDescriptor.startOffset
        val declaredLength = assetFileDescriptor.declaredLength
        return fileChannel.map(
            java.nio.channels.FileChannel.MapMode.READ_ONLY,
            startOffset,
            declaredLength
        )
    }

    fun close() {
        interpreter.close()
    }
}

성능 벤치마크

실제 디바이스에서 측정한 결과입니다.

BENCHMARK_RESULTS = {
    "MobileNetV3-Small (Image Classification)": {
        "iPhone_14_Pro": {"latency": "3.2ms", "throughput": "312 FPS"},
        "iPhone_12": {"latency": "5.8ms", "throughput": "172 FPS"},
        "Pixel_7": {"latency": "4.5ms", "throughput": "222 FPS"},
        "Galaxy_S23": {"latency": "4.1ms", "throughput": "244 FPS"},
        "Pixel_4a": {"latency": "12.3ms", "throughput": "81 FPS"}
    },
    "Whisper-Tiny (30s Audio)": {
        "iPhone_14_Pro": {"latency": "0.8s", "RTF": "0.027"},
        "iPhone_12": {"latency": "1.5s", "RTF": "0.05"},
        "Pixel_7": {"latency": "1.2s", "RTF": "0.04"},
        "Galaxy_S23": {"latency": "1.1s", "RTF": "0.037"}
    },
    "DistilBERT (128 tokens)": {
        "iPhone_14_Pro": {"latency": "15ms"},
        "iPhone_12": {"latency": "28ms"},
        "Pixel_7": {"latency": "22ms"},
        "Galaxy_S23": {"latency": "19ms"}
    }
}

배터리 및 발열 고려사항

모바일 AI에서 간과하기 쉬운 부분이 배터리 소모와 발열입니다.

POWER_CONSUMPTION = {
    "continuous_inference": {
        "warning": "지속적인 추론은 배터리를 빠르게 소모",
        "image_classification": {
            "30fps_continuous": "시간당 약 15-20% 배터리 소모",
            "recommendation": "필요할 때만 추론, 프레임 스킵 고려"
        },
        "audio_processing": {
            "realtime_transcription": "시간당 약 10-15% 배터리 소모",
            "recommendation": "버퍼링 후 배치 처리 고려"
        }
    },
    "thermal_throttling": {
        "issue": "지속적 추론 시 발열로 인한 성능 저하",
        "mitigation": [
            "추론 간격 두기 (예: 100ms마다)",
            "Neural Engine 우선 사용 (GPU보다 발열 적음)",
            "배치 크기 조절"
        ]
    }
}

# 배터리 효율적인 추론 패턴
class BatteryEfficientInference:
    def __init__(self, model, min_interval_ms=100):
        self.model = model
        self.min_interval = min_interval_ms / 1000
        self.last_inference_time = 0

    def should_run_inference(self):
        """필요할 때만 추론 실행"""
        current_time = time.time()
        if current_time - self.last_inference_time >= self.min_interval:
            self.last_inference_time = current_time
            return True
        return False

    def run_with_throttling(self, input_data):
        if not self.should_run_inference():
            return self.last_result  # 캐시된 결과 반환

        self.last_result = self.model.predict(input_data)
        return self.last_result

한계와 현실

2023년 기준, 아직 대형 모델은 모바일에서 실용적이지 않습니다.

MOBILE_AI_LIMITATIONS = {
    "model_size": {
        "practical_limit": "~500MB",
        "reason": "앱 다운로드 크기, 메모리 제약",
        "llm_7b": "약 4GB (4-bit 양자화 후에도)",
        "feasibility": "아직 어려움"
    },
    "memory": {
        "iphone_14_pro": "6GB RAM",
        "typical_android": "4-8GB RAM",
        "available_for_ml": "1-2GB (다른 앱, OS 고려)",
        "llm_requirement": "4GB+ (7B 모델)"
    },
    "battery": {
        "issue": "지속 사용 시 빠른 배터리 소모",
        "user_experience": "영향 큼"
    }
}

2024년 전망

하드웨어가 계속 발전하면서 더 큰 모델도 모바일에서 돌릴 수 있게 될 것입니다.

FUTURE_OUTLOOK = {
    "hardware_improvements": {
        "neural_engine": "매년 2-3배 성능 향상",
        "memory": "8GB+ RAM 일반화",
        "npu": "전용 AI 칩 탑재 확대"
    },
    "software_optimizations": {
        "quantization": "2-bit, 1-bit 양자화 연구",
        "speculative_decoding": "작은 모델로 큰 모델 가속",
        "continuous_batching": "효율적인 배치 처리"
    },
    "expected_capabilities": {
        "2024": "3B 파라미터 LLM 모바일 실행",
        "2025": "7B+ 모델 실용화 예상"
    }
}

llama.cpp로 7B 모델을 맥북에서 돌리는 것은 이미 가능하고, 일부 사용자는 iPhone에서도 실행하고 있습니다. 아직 실용적인 속도는 아니지만, 방향성은 명확합니다.

참고 자료

Comments


Copyright © 2025. jiunbae. All rights reserved.