선인장 엔진은 클라우드 비용 없이 하이브리드 앱에 온디바이스 AI를 이식한다
2026年5月17日
0
Computing/SoftwareRelated Video
5:29这款新引擎运行本地 AI,内存占用减少至 1/10!(Cactus)
Better Stack
Comments (0)
Log in to leave a comment
No posts yet
5:29Better Stack
Log in to leave a comment
No posts yet
비싼 클라우드 LLM API 비용을 감당하기 힘든 1~3년 차 모바일 앱 개발자에게 온디바이스 AI는 훌륭한 탈출구다. 선인장(Cactus) 엔진이 대표적이다. 하지만 내장된 미세조정 모델을 .CACT 포맷으로 바꾸고, 네이티브 환경과의 충돌을 막으며, 백그라운드 배터리 드레인을 해결하지 못하면 상용 앱 출시는 불가능하다. 오픈소스 가중치를 모바일 기기에 직접 이식해 고정 운영비를 없애는 현실적인 구현 절차를 정리했다.
자체 미세조정한 LoRA 어댑터 가중치를 모바일 런타임에서 동적으로 결합하면 안 된다. 저사양 코어의 메모리 대역폭 한계로 연산이 심각하게 늘어진다. 선인장 컴퓨트 개발 팀의 명세를 보면, .CACT 포맷은 제로카피 메모리 맵(mmap) 규격을 쓴다. 가중치 데이터를 디바이스 힙 공간으로 복사하지 않고 가상 메모리 주소에 직접 매핑하는 방식이다. 이 기술 덕분에 LiquidAI의 LFM2.5-350m 모델 기준으로 타사 추론 엔진보다 RAM 점유율을 최대 10배 줄인다.
배포 전 오프라인 환경에서 베이스 모델에 LoRA 가중치를 사전 합성해야 한다. 로컬 가상 환경을 켜고 선인장 CLI 배포 툴킷을 빌드하는 과정이다.
python3 -m venv .venv
source .venv/bin/activate
git clone git@github.com:cactus-compute/cactus.git
cd cactus
source ./setup.sh
cactus convert Qwen/Qwen3-0.6B ./my-qwen3-0.6b --lora ./my-lora-adapter
마지막 명령어를 실행하면 원본 텐서 레이어에 어댑터 가중치를 미리 더하고, 모바일 NPU 명령 세트에 맞춘 직렬화 데이터 패킷으로 저장한다.
여기서 양자화를 건너뛰고 부동 소수점(FP16/FP32) 가중치를 그대로 올리면 모바일 UFS 스토리지의 대역폭 한계 때문에 버스 데이터 전송 병목이 생긴다. 앱이 멈추는 ANR 현상으로 이어진다. 선인장 엔진은 4비트 양자화 시 각 텐서에 Hadamard 회전을 적용해 정확도 손실을 보정한다. 아래 명령어로 INT4 정밀도 강제 양자화 컴파일을 수행한다.
cactus test --android --model ./my-qwen3-0.6b --precision int4
이 단계를 마치면 클라우드 추론 API 호출 비용을 완전히 없애 고정 운영비를 줄일 수 있다.
React Native나 Flutter 같은 하이브리드 프레임워크에서 단말기 NPU 연산 파이프라인에 접근하려면 저수준 C API FFI 바인딩 브릿지가 필요하다. 선인장 네이티브 엔진 SDK는 앱 바이너리 용량에 약 14MB 정도만 더하므로 무선 네트워크 다운로드 제한에 걸리지 않는다.
안드로이드 배포 시 무서운 함정은 하위 호환을 고려하지 않은 컴파일 플래그 설정이다. 커널 레벨에서 SIGILL 크래시 에러가 터진다. NDK 컴파일러가 고성능 NPU 가속을 하려고 -march=armv8.2-a+dotprod+i8mm 플래그를 기본으로 넣으면, SDOT 확장이 없는 구형 ARMv8.0 코어 단말기에서 앱이 즉시 죽는다. JNI 브릿지를 컴파일하는 app/src/main/cpp/CMakeLists.txt 내부에 호환 명세를 명확히 다시 적어야 한다.
cmake_minimum_required(VERSION 3.10.2)
project("cactus_native_engine" C CXX)
set(ARM_OPTIONS "-march=armv8-a+fp16+simd")
set(ARM_DEFINITIONS
-D__ARM_NEON=1
-D__ARM_FEATURE_FP16_VECTOR_ARITHMETIC=1
)
add_compile_options(${ARM_OPTIONS})
add_definitions(${ARM_DEFINITIONS})
add_library(cactus SHARED IMPORTED)
set_target_properties(cactus PROPERTIES
IMPORTED_LOCATION "${CMAKE_CURRENT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libcactus.so"
)
add_library(cactus_jni_bridge cactus_jni_bridge.cpp)
target_include_directories(cactus_jni_bridge PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(cactus_jni_bridge cactus log android)
상위 애플리케이션 스레드가 NDK 메모리 세그먼트에 접근할 수 있도록 저수준 JNI 바인딩 브릿지와 Kotlin 인터페이스를 연결한다. 초기화, 추론 실행, 메모리 해제 메서드를 각각 매핑하는 코드다.
#include <jni.h>
#include <string>
#include "cactus.h"
extern "C" {
JNIEXPORT jlong JNICALL
Java_com_cactus_bridge_CactusNativeCore_initializeCactusModel(JNIEnv *env, jobject thiz, jstring model_path, jboolean cache_index) {
const char *native_path = env->GetStringUTFChars(model_path, nullptr);
cactus_model_t model = cactus_init(native_path, nullptr, (bool)cache_index);
env->ReleaseStringUTFChars(model_path, native_path);
return reinterpret_cast<jlong>(model);
}
JNIEXPORT jstring JNICALL
Java_com_cactus_bridge_CactusNativeCore_executeCactusInference(JNIEnv *env, jobject thiz, jlong model_handle, jstring messages_json, jstring options_json) {
cactus_model_t model = reinterpret_cast<cactus_model_t>(model_handle);
const char *messages = env->GetStringUTFChars(messages_json, nullptr);
const char *options = options_json ? env->GetStringUTFChars(options_json, nullptr) : nullptr;
char out_buffer[4096] = {0};
int status = cactus_complete(model, messages, out_buffer, sizeof(out_buffer), options, nullptr, nullptr, nullptr, nullptr, 0);
env->ReleaseStringUTFChars(messages_json, messages);
if (options_json) env->ReleaseStringUTFChars(options_json, options);
if (status < 0) {
return env->NewStringUTF("{\"success\": false, \"error\": \"INFERENCE_EXECUTION_FAILURE\"}");
}
return env->NewStringUTF(out_buffer);
}
JNIEXPORT void JNICALL
Java_com_cactus_bridge_CactusNativeCore_releaseCactusModel(JNIEnv *env, jobject thiz, jlong model_handle) {
cactus_model_t model = reinterpret_cast<cactus_model_t>(model_handle);
if (model) {
cactus_destroy(model);
}
}
}
package com.cactus.bridge
object CactusNativeCore {
init {
System.loadLibrary("cactus_jni_bridge")
}
external fun initializeCactusModel(modelPath: String, cacheIndex: Boolean): Long
external fun executeCactusInference(modelHandle: Long, messagesJson: String, optionsJson: String?): String
external fun releaseCactusModel(modelHandle: Long)
}
iOS는 조금 다르다. cactus build --apple 명령을 써서 애플 실리콘 기기용 정적 라이브러리인 libcactus-device.a와 시뮬레이터용 libcactus-simulator.a를 묶어야 한다. 하나의 cactus-ios.xcframework 구조로 Xcode에 넣는다. 기기 환경에 맞게 배포 모델을 골라야 한다. 예를 들어 LiquidAI의 LFM2-2.6B 가중치를 INT4로 양자화하면 파일 크기는 180MB다. 런타임 시 334MB에서 395MB 범위의 RAM을 쓰기 때문에 스냅드래곤이나 애플 A시리즈 칩셋 기준에 잘 맞는다.
화면이 꺼진 대기 상태에서 무거운 온디바이스 AI 연산이 돌면 배터리가 빠르게 녹는다. 시스템 정책이 프로세스를 갑자기 죽이기도 한다. 특히 저사양 단말기에서 가상 메모리 매핑 주소가 끊기면 크래시가 난다. OS 고유의 백그라운드 스케줄러인 안드로이드 WorkManager와 iOS BGProcessingTask에 생명주기 핸들러를 엮어 하드웨어 점유 우선순위를 받아야 하는 이유다.
안드로이드에서는 백그라운드 워커를 포그라운드 서비스로 올려서 돌리는 방법이 확실하다. 비활성 디스크 입출력 대기 구간을 직접 제어할 수 있다.
package com.cactus.worker
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.ForegroundInfo
import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Build
import androidx.core.app.NotificationCompat
import com.cactus.bridge.CactusNativeCore
class CactusInferenceWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
private val channelId = "cactus_bg_inference"
private val notificationId = 1002
override suspend fun doWork(): Result {
setForeground(createForegroundInfo())
val modelPath = inputData.getString("MODEL_PATH") ?: return Result.failure()
val messagesJson = inputData.getString("MESSAGES_JSON") ?: return Result.failure()
var modelHandle: Long = 0L
return try {
modelHandle = CactusNativeCore.initializeCactusModel(modelPath, true)
val response = CactusNativeCore.executeCactusInference(modelHandle, messagesJson, null)
val outputData = androidx.work.workDataOf("RESPONSE" to response)
Result.success(outputData)
} catch (e: Exception) {
Result.retry()
} finally {
if (modelHandle != 0L) {
CactusNativeCore.releaseCactusModel(modelHandle)
}
}
}
private fun createForegroundInfo(): ForegroundInfo {
val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val chan = NotificationChannel(channelId, "Cactus Background Processing", NotificationManager.IMPORTANCE_LOW)
manager.createNotificationChannel(chan)
}
val notification = NotificationCompat.Builder(applicationContext, channelId)
.setContentTitle("배터리 최적화 추론 가동 중")
.setContentText("선인장 백그라운드 NPU 연산 스케줄링이 활성화되어 있습니다.")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setOngoing(true)
.build()
return ForegroundInfo(notificationId, notification)
}
}
iOS는 BGProcessingTaskRequest 구조 안에서 시스템이 리소스를 회수하려는 순간을 잡아야 한다. expirationHandler를 반드시 매핑해야 메모리 세그먼트가 터져서 생기는 크래시를 막는다.
import Foundation
import BackgroundTasks
public class CactusiOSBackgroundScheduler {
public static let shared = CactusiOSBackgroundScheduler()
private let taskId = "com.cactus.bg.inference_task"
private var modelHandle: OpaquePointer?
private init() {}
public func registerBackgroundTask() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskId, using: nil) { task in
guard let processingTask = task as? BGProcessingTask else { return }
self.handleBackgroundTaskExecution(task: processingTask)
}
}
private func handleBackgroundTaskExecution(task: BGProcessingTask) {
task.expirationHandler = {
self.safelyReleaseLocalEngine()
}
DispatchQueue.global(qos: .utility).async {
self.modelHandle = cactus_init("/var/mobile/Containers/Data/Documents/my_model", nil, true)
var responseBuffer = [CChar](repeating: 0, count: 2048)
let query = "[{\"role\": \"user\", \"content\": \"지정 백그라운드 추론 요청\"}]"
let status = cactus_complete(self.modelHandle, query, &responseBuffer, responseBuffer.count, nil, nil, nil, nil, nil, 0)
if status >= 0 {
task.setTaskCompleted(success: true)
} else {
task.setTaskCompleted(success: false)
}
self.safelyReleaseLocalEngine()
}
}
private func safelyReleaseLocalEngine() {
if let handle = self.modelHandle {
cactus_destroy(handle)
self.modelHandle = nil
}
}
}
하드웨어 오작동을 막으려면 기기 표면 온도가 42도 이상으로 튀는 상황에 대응해야 한다. 온도가 42도에 도달하면 엔진 추론 스레드를 즉시 재운다. 들어오는 입력 데이터 유실을 막기 위해 FIFO 기반 메모리 대기 큐에 데이터를 임시로 쌓는다. AP 코어가 안전 대역인 38도 미만으로 식으면 큐에 있던 데이터 패킷을 한 번에 처리한다. 이 구조를 짜두면 배터리 과소모 오작동 신고를 방지해 구글 플레이나 앱스토어 평점을 지킬 수 있다.
하이브리드 라우터는 기기의 네트워크 상태를 계속 본다. 로컬 모델과 클라우드 인프라 중 어디로 추론을 보낼지 정하는 역할이다. 지하철처럼 통신이 끊기는 곳에서 원격 서버 응답을 기다리다 앱이 굳어버리는 문제를 막으려면 명확한 임계값 분기가 필요하다. 핑 테스트 전송 지연이 450ms를 넘기는 순간 클라우드 파이프라인을 막아야 한다. 연산 작업을 단말기에 내장된 로컬 Parakeet CTC 1.1B 모델로 강제 전환한다. 패킷이 완전히 버려지는 시점인 500ms 물리 타임아웃 전에 선제적으로 움직여야 자연스럽다.
소형 로컬 모델은 파라미터가 적어서 특수 용어나 영문 도메인 단어를 엉망으로 바꿀 때가 많다. 선인장 FFI API가 주는 로컬 디코더 변조 파라미터인 custom_vocabulary와 vocabulary_boost를 JSON 배열로 넣어 정확도를 올려야 한다.
import Foundation
import Network
public class CactusOfflineHybridRouter {
private let latencyThresholdMs: Double = 450.0
private let severeTimeoutMs: Double = 500.0
private var localModel: OpaquePointer?
private let monitor = NWPathMonitor()
private let monitorQueue = DispatchQueue(label: "CactusNetworkRouterQueue")
private var currentConnectionStatus: NWPath.Status = .satisfied
public init(modelPath: String) {
localModel = cactus_init(modelPath, nil, true)
startNetworkPathMonitoring()
}
private func startNetworkPathMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
self?.currentConnectionStatus = path.status
}
monitor.start(queue: monitorQueue)
}
public func processInferenceRoute(audioBuffer: Data, cloudUrl: URL) async -> String {
guard currentConnectionStatus == .satisfied else {
return await executeLocalInference(audioBuffer: audioBuffer)
}
let startPingTime = Date()
do {
let isCloudResponsive = try await runPingCheck(targetUrl: cloudUrl, timeout: severeTimeoutMs / 1000.0)
let pingElapsedTime = Date().timeIntervalSince(startPingTime) * 1000.0
if isCloudResponsive && pingElapsedTime < latencyThresholdMs {
return try await sendAudioToCloud(audioBuffer: audioBuffer, endpoint: cloudUrl)
} else {
return await executeLocalInference(audioBuffer: audioBuffer)
}
} catch {
return await executeLocalInference(audioBuffer: audioBuffer)
}
}
private func runPingCheck(targetUrl: URL, timeout: TimeInterval) async throws -> Bool {
var request = URLRequest(url: targetUrl)
request.httpMethod = "HEAD"
request.timeoutInterval = timeout
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode == 200
}
return false
}
private func sendAudioToCloud(audioBuffer: Data, endpoint: URL) async throws -> String {
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.httpBody = audioBuffer
request.timeoutInterval = 15.0
let (data, _) = try await URLSession.shared.data(for: request)
return String(data: data, encoding: .utf8) ?? ""
}
private func executeLocalInference(audioBuffer: Data) async -> String {
guard let handle = localModel else {
return "{\"success\": false, \"error\": \"Local model not initialized\"}"
}
let optionsJson = """
{
"custom_vocabulary": ["cactus", "hybrid", "on-device"],
"vocabulary_boost": 8.0
}
"""
var responseBuffer = [CChar](repeating: 0, count: 4096)
let status = audioBuffer.withUnsafeBytes { (rawBuffer: UnsafeRawBufferPointer) -> Int32 in
guard let baseAddress = rawBuffer.baseAddress else { return -1 }
let audioBytes = baseAddress.assumingMemoryBound(to: UInt8.self)
return cactus_transcribe(
handle,
nil,
nil,
&responseBuffer,
responseBuffer.count,
optionsJson,
nil,
audioBytes,
audioBuffer.count
)
}
if status >= 0 {
return String(cString: responseBuffer)
} else {
return "{\"success\": false, \"error\": \"Local Parakeet execution failed\"}"
}
}
deinit {
if let handle = localModel {
cactus_destroy(handle)
}
monitor.cancel()
}
}
이 라우터 레이턴시 임계값을 450ms로 분기 처리하는 예외 구문을 안드로이드와 iOS 네이티브 프로젝트에 추가한다. 네트워크 연결이 흔들리거나 아예 터져도 로컬 단말에 심어둔 온디바이스 전사 파이프라인으로 매끄럽게 넘어간다. 오프라인 상태에서도 끊김 없는 음성 인식 경험을 주면 사용자가 답답해서 앱을 지우는 이탈률을 낮출 수 있다.