블로그

2026년 8월 15일 · 8분 읽기

온디바이스 추론 발열 관리: thermalState 게이팅과 직렬 큐

온디바이스 LLM 추론에서 기기가 뜨거워지는 문제를 admission queue 직렬화와 thermalState 게이팅으로 다뤘습니다. 한 번에 하나만 돌렸는데도 열이 오른 이유를 찾습니다.

  • 온디바이스 LLM
  • 발열
  • thermalState
  • 성능

2026년 8월 23일 업데이트

실기기에서 발열 신고가 들어왔습니다. iPhone Air였습니다.

원인을 찾으려면 프로파일러를 붙여야 하는데, 그 기기에서는 붙일 수 없었습니다. 코드와 로그만으로 좁혀야 했습니다.

두 가지가 나왔습니다. 하나는 모델 호출이 동시에 여러 개 돌 수 있는 구조였고, 다른 하나는 사용자가 될 리 없는 재시도를 반복하고 있었습니다. 두 번째가 더 나빴습니다. 재시도할 때마다 모델을 통째로 다시 돌렸습니다.

추론 요청이 통과해야 하는 관문

한눈에 보기

문제 실기기에서 발열 신고가 들어왔습니다.
결정 원인을 찾으려면 프로파일러를 붙여야 하는데, 그 기기에서는 붙일 수 없었습니다.
결과 처음 발열 신고를 다시 확인했을 때도 admission은 무죄였습니다.
제약 실기에서 재 봤을 때는 그 구간에 들어가지 않았습니다.

발열은 생성 토큰에서 옵니다

먼저 무엇이 비싼지 정해야 합니다. 온디바이스 추론에서 비용은 입력보다 출력에 있습니다.

같은 요약을 두 방식으로 받아 재 봤습니다. M4 Max에서 8K 창으로 각 다섯 번의 중앙값입니다.

받는 방식 시간 생성 길이
구조화 출력 1.89초 115자
마크다운 평문 2.16~2.36초 173~202자

짧은 쪽이 빠릅니다. 생성 토큰 하나가 곧 연산 한 번이고, 연산이 열입니다.

그래서 발열을 줄이는 첫 번째 방법은 스로틀링이 아닙니다. 답을 짧게 받는 것입니다. 스키마로 형식을 강제하면 모델이 "다음은 요약입니다" 같은 말을 붙이지 않습니다.

실행기 주석에 이렇게 적혀 있습니다.

생성 토큰이 곧 연산이고 발열이므로 산출 억제가 그대로 이득이다.

두 번째 방법이 동시성 제어이고, 세 번째가 열 상태 게이팅입니다. 순서가 이렇습니다. 출력을 줄이고, 겹치지 않게 하고, 뜨거우면 멈춥니다.

모델 호출을 한 줄로 세웠습니다

문서 열 개를 저장하면 요약이 열 개 시작될 수 있습니다. 조각이 각각 열네 개면 호출이 백사십 개입니다.

이걸 동시에 돌리면 기기가 버티지 못합니다. 그래서 모든 모델 응답을 하나의 큐에 넣었습니다.

// FoundationModelRuntime.swift:193-199
/// Serializes every live Foundation Models response. Unsafe device conditions
/// are waited on outside the shared queue, then checked again immediately
/// after queue acquisition before the model operation starts.
static func withAdmission<T>(
  for job: AdmissionJob,
  operation: @Sendable () async throws -> T
) async throws -> T {

모델을 부르는 모든 자리가 이 함수를 통과합니다. 지금 세 종류입니다.

// FoundationModelRuntime.swift:164-168
enum AdmissionJob: String, Sendable {
  case profileSummaryChunk   // 조각 요약
  case profileSummaryTitle   // 제목 만들기
  case summaryMap            // 음성 전사 요약
}

이름을 붙인 이유는 로그입니다. 어느 종류의 호출이 얼마나 걸렸는지 나눠 볼 수 있습니다.

동시성을 1로 두면 전체 시간은 늘어납니다. 백사십 개 호출이 순서대로 돕니다. 대신 기기가 뜨거워져서 시스템이 앱을 죽이는 일이 없습니다. 느린 완료가 죽은 앱보다 낫습니다.

큐를 주기적으로 새로 만듭니다

큐 자체에 상한이 하나 있습니다.

// FoundationModelRuntime.swift:7-44
private final class FoundationModelAdmissionQueue: @unchecked Sendable {
  static let resetThreshold = 256

  private func resetIfIdleLocked() {
    guard pending == 0, completed >= Self.resetThreshold else { return }
    queue = SequentialOrganizerQueue()   // 새 큐로 교체합니다
    completed = 0
  }
}

완료한 작업이 256개를 넘고 대기 중인 작업이 없을 때 큐 객체를 새로 만듭니다.

큐 안쪽을 보면 이유가 보입니다. 큐는 작업마다 enqueued·started·ended 사건을 배열에 쌓고, 작업 이름과 취소 상태를 사전에 넣습니다. 사건 배열을 줄이는 코드는 없습니다. 문서 백 개를 요약하면 호출 천 개의 사건이 그대로 남습니다. 큐를 새로 만들면 그 기록이 함께 사라집니다.

pending == 0 조건이 안전장치입니다. 대기 중인 작업이 있는데 큐를 바꾸면 그 작업들이 어디로 갈지 알 수 없습니다. 아무도 기다리지 않는 순간에만 교체합니다.

뜨거우면 시작하지 않습니다

동시성을 1로 줄여도 오래 돌면 뜨거워집니다. 그래서 시작 전에 기기 상태를 봅니다.

// FoundationModelRuntime.swift:230-237
private struct Conditions: Sendable {
  let thermalState: ProcessInfo.ThermalState
  let lowPowerMode: Bool

  var isUnsafe: Bool {
    thermalState == .serious || thermalState == .critical || lowPowerMode
  }
}

ProcessInfo.ThermalState는 iOS가 알려 주는 기기의 열 상태입니다. 네 단계입니다.

상태 우리 판정
nominal 정상 실행합니다
fair 조금 따뜻함 실행합니다
serious 뜨거움. 시스템이 성능을 낮춥니다 기다립니다
critical 매우 뜨거움 기다립니다

serious부터 멈추는 것이 선택입니다. 이 상태에서 iOS는 이미 CPU 클럭을 낮추고 있습니다. 그때 무거운 추론을 시작하면 느리게 돌면서 열만 더 냅니다.

저전력 모드도 같이 봅니다. 사용자가 배터리를 아끼겠다고 켠 상태에서 몇 분짜리 추론을 시작하는 것은 의도와 반대입니다.

큐를 기다리는 동안 기기가 뜨거워집니다

이 구조에서 미묘한 자리는 큐 대기입니다.

순서대로 생각하면 이렇습니다. 상태를 확인하고, 안전하면 큐에 들어가고, 차례가 되면 실행합니다.

문제는 큐에서 기다리는 시간입니다. 앞에 백 개가 있으면 몇 분을 기다립니다. 그 몇 분 동안 앞의 작업들이 기기를 뜨겁게 만듭니다. 내 차례가 왔을 때는 확인했던 상태가 아닙니다.

그래서 두 번 확인합니다.

// FoundationModelRuntime.swift:200-226
let deadline = ContinuousClock().now.advanced(by: Self.admissionConditionWaitTimeout)
while true {
  try await waitForSafeConditions(for: job, until: deadline)   // 큐 밖에서 대기

  do {
    return try await admissionQueue.run(named: job.rawValue) {
      let conditions = Self.currentConditions()
      guard !conditions.isUnsafe else {                        // 큐 안에서 재확인
        Self.logDeferred(job: job, conditions: conditions)
        throw DeferredAdmission()                              // 큐를 놓아 줍니다
      }
      return try await operation()
    }
  } catch is DeferredAdmission {
    // Queue ownership has been released. Re-check conditions outside the
    // queue before attempting this same request again.
    continue
  }
}

이 반복을 만드는 것이 DeferredAdmission입니다. 큐 안에서 상태가 나쁘면 예외를 던져 큐를 놓습니다. 그리고 큐 밖에서 다시 기다립니다.

큐를 잡은 채로 기다리면 안 됩니다. 그러면 뒤에 있는 작업들이 전부 막힙니다. 기다릴 때는 자리를 비켜 주고, 실행할 수 있을 때만 자리를 잡습니다.

while true가 이 반복입니다. 상태가 좋아질 때까지 큐 밖에서 기다리고, 들어가서 확인하고, 나쁘면 다시 나옵니다.

같은 조건을 세 자리에서 봅니다. 자리마다 하는 일이 다릅니다.

어디서 무엇을 하나 나쁘면
큐 밖 대기 알림과 250ms 폴링으로 기다린다 계속 기다린다, 상한 30초
대기 등록 직후 한 번 즉시 확인한다 기다린다
큐 안 진입 후 연산 시작 전에 다시 확인한다 예외로 큐를 놓는다

같은 판정을 세 번 하는 것이 중복처럼 보입니다. 시점이 다릅니다. 기다림이 끝난 순간과 큐를 잡은 순간 사이에 시간이 있고, 그 사이에 기기가 다시 뜨거워질 수 있습니다. 큐를 기다리는 시간이 길수록 그 사이가 벌어집니다.

알림을 기다리되 30초에는 포기합니다

기기가 식기를 기다리는 방법이 두 가지입니다. 주기적으로 물어보거나, 바뀔 때 알려 달라고 하거나.

후자를 씁니다.

// FoundationModelRuntime.swift:87-98
observers = [
  center.addObserver(
    forName: ProcessInfo.thermalStateDidChangeNotification,   // 열 상태가 바뀌면
    object: nil, queue: nil
  ) { [weak self] _ in self?.signal() },
  center.addObserver(
    forName: .NSProcessInfoPowerStateDidChange,               // 전력 모드가 바뀌면
    object: nil, queue: nil
  ) { [weak self] _ in self?.signal() },
]

폴링보다 알림이 낫습니다. 250밀리초마다 물어보면 그 자체가 배터리를 씁니다.

다만 알림만 믿을 수는 없습니다. 알림이 오지 않는 경우가 있습니다.

// FoundationModelRuntime.swift:171-174
// Conditions are rechecked at this monotonic deadline even when no system
// notification is delivered. The short interval bounds notification latency.
private static let admissionConditionWaitTimeout: Duration = .seconds(30)
private static let admissionConditionPollInterval: Duration = .milliseconds(250)

30초가 지나면 알림 없이도 다시 확인합니다. 알림이 늦거나 유실되면 영원히 기다리게 되는데, 그 상한이 30초입니다.

대기를 시작할 때 한 번 더 봅니다

알림을 등록한 직후에 상태를 다시 확인합니다.

// FoundationModelRuntime.swift:62-69
let process = ProcessInfo.processInfo
let isUnsafe =
  process.thermalState == .serious
  || process.thermalState == .critical
  || process.isLowPowerModeEnabled
if !isUnsafe {
  finish()      // 이미 안전하면 알림을 기다리지 않습니다
}

옵저버를 등록하는 사이에 기기가 식었을 수 있습니다. 그 경우 다음 알림은 오지 않습니다. 이미 좋은 상태로 바뀌었으니까요.

확인하지 않으면 30초를 그냥 기다립니다. 등록 직후 한 번 보는 것으로 그 30초를 없앱니다.

재시도가 발열을 만들고 있었습니다

모델 오류를 문자열로 분류하고 있었습니다.

// FoundationProfileModelExecutor.swift:115-119 (주석)
// **타입으로 가른다.** 예전에는 `String(describing:)`을 영어 키워드로 훑었는데,
// Apple이 guardrail 거부에 주는 문구는 `"May contain sensitive content"`라서
// `guardrail`·`safety`·`refus` 어디에도 걸리지 않았다 — 안전 필터 거부가 조용히
// `.permanent`로 떨어져 "처리 중 문제가 생겼어요 · 다시 시도"로 보였고, 사용자는
// 될 리 없는 재시도를 반복하며 매번 모델을 통째로 다시 돌렸다(실기 발열).

연쇄가 이렇습니다.

단계 무엇이 일어났나
1 안전 필터가 입력을 거부합니다
2 Apple의 문구는 "May contain sensitive content"입니다
3 우리 코드는 guardrail, safety, refus를 찾았습니다. 안 걸립니다
4 분류되지 않아 "다시 시도"로 표시됩니다
5 사용자가 누릅니다. 같은 입력이니 또 거부됩니다
6 매번 모델을 처음부터 돌립니다

거부는 다시 시도해도 결과가 같습니다. 그런데 화면이 "다시 시도"를 권하고 있었습니다.

타입으로 가르니 사라졌습니다

문자열 대신 오류 타입으로 분류했습니다.

// FoundationProfileModelExecutor.swift:126-136
switch generation {
case .guardrailViolation: return .rejected("guardrailViolation")   // 다시 해도 안 됩니다
case .refusal: return .rejected("refusal")
case .rateLimited, .concurrentRequests: return .transient("rateLimited")  // 잠시 후 됩니다
case .assetsUnavailable: return .unavailable
case .exceededContextWindowSize: return .permanent("exceededContextWindowSize")
case .unsupportedGuide: return .permanent("unsupportedGuide")
case .unsupportedLanguageOrLocale: return .permanent("unsupportedLanguageOrLocale")
case .decodingFailure: return .permanent("decodingFailure")
@unknown default: return .permanent("unknownGenerationError")
}

LanguageModelSession.GenerationError의 case를 직접 봅니다. Apple이 문구를 바꿔도 case는 그대로입니다.

rejectedtransient를 나눈 것이 재시도 정책의 근거입니다. rejected는 재시도 버튼을 보여 주지 않습니다. transient는 보여 줍니다.

문구를 매칭하는 코드는 언어와 버전에 묶입니다. Apple이 문구를 한 글자만 바꿔도 조용히 깨집니다. 깨진 것을 알려 주는 것도 없습니다.

직렬화가 연산을 줄이지는 않습니다

withAdmission이 하는 일은 전역 직렬화와 대기 두 가지입니다. 연산량 자체를 줄이는 장치는 없습니다. 성공한 추론 사이에 강제로 쉬는 간격도 없습니다. 한 번에 하나만 돌리지만, 그 하나는 쉬지 않고 이어집니다.

그래서 serious가 계속 유지되면 사용자가 다른 것을 보게 됩니다. 큐를 놓고 다시 기다리고 다시 들어가는 반복이 30초까지 이어지고, 화면에서는 그냥 오래 걸리는 것으로 보입니다. 발열이 지연으로 바뀝니다.

실기에서 재 봤을 때는 그 구간에 들어가지 않았습니다.

실기 측정
연속 실행한 실제 모델 응답 8회 (메모·음성 전사·문서)
전 구간 열 상태 nominal
thermal defer 0회
대기 후 이어간 횟수 0회
배터리 100% 유지

처음 발열 신고를 다시 확인했을 때도 admission은 무죄였습니다. thermal=nominal, lowPower=false이고 .automatic.userInitiated의 결과가 같았습니다. 발열의 원인은 다음 절에 있습니다.

8회는 긴 검증이 아닙니다. 하루 종일 쓰는 사용자의 기기가 어디까지 가는지는 이 숫자로 말할 수 없습니다.