← All courses ← Course home

Lesson 03 of 05

Plan for heat: burst vs sustained

A phone can run fast for a short time, then it gets hot and slows down. Read the thermal state on iOS and Android, and switch from burst mode to sustained mode on purpose.

Agent brief (llms.md)

Environment setup

Same setup as lesson 01: Xcode 27 with an iOS 27 device, Android Studio with minSdk 30 or higher on a real phone. Python tools run with uv run inside the llm-survival project (Python 3.11+, uv add torch mlx mlx-lm).

The problem in numbers you can see

Your benchmark runs for 30 seconds on a cool phone. A real user chats for 15 minutes on a warm day with the phone charging. Speed in the second case can be much lower, because the system cuts clock speeds to protect the device. Nothing crashes. The app just feels slow.

Do not guess. Run your own test: 20 minutes of back-to-back answers, and log tokens per second and thermal state every answer. That chart tells you how much slower sustained mode really is on each device.

iOS: ProcessInfo.thermalState

ProcessInfo.processInfo.thermalState has four values: .nominal, .fair, .serious, and .critical. The system posts thermalStateDidChangeNotification when it changes.

import Foundation

struct GenerationPolicy: Equatable {
    var maxOutputTokens: Int
    var allowBackgroundJobs: Bool
    var preferSmallModel: Bool
}

func policy(for state: ProcessInfo.ThermalState) -> GenerationPolicy {
    switch state {
    case .nominal, .fair:
        return GenerationPolicy(maxOutputTokens: 1024, allowBackgroundJobs: true,  preferSmallModel: false)
    case .serious:
        return GenerationPolicy(maxOutputTokens: 384,  allowBackgroundJobs: false, preferSmallModel: true)
    case .critical:
        return GenerationPolicy(maxOutputTokens: 128,  allowBackgroundJobs: false, preferSmallModel: true)
    @unknown default:
        return GenerationPolicy(maxOutputTokens: 384,  allowBackgroundJobs: false, preferSmallModel: true)
    }
}

@MainActor
final class ThermalWatcher: ObservableObject {
    @Published private(set) var current = policy(for: ProcessInfo.processInfo.thermalState)
    private var token: NSObjectProtocol?

    init() {
        token = NotificationCenter.default.addObserver(
            forName: ProcessInfo.thermalStateDidChangeNotification,
            object: nil, queue: .main
        ) { [weak self] _ in
            let state = ProcessInfo.processInfo.thermalState
            print("thermal:", state.rawValue)
            self?.current = policy(for: state)
        }
    }
}

The numbers in the table are examples. Pick yours from your 20 minute test.

Android: thermal headroom

Android's Thermal API (API level 30) gives PowerManager.getThermalHeadroom(forecastSeconds). The value goes from 0.0 (no throttling) to 1.0 (severe throttling). Google's page lists some rules that are easy to miss:

import android.content.Context
import android.os.PowerManager
import android.os.SystemClock

data class GenerationPolicy(val maxOutputTokens: Int, val allowBackgroundJobs: Boolean, val preferSmallModel: Boolean)

class ThermalPolicy(context: Context) {
    private val power = context.getSystemService(Context.POWER_SERVICE) as PowerManager
    private var lastReadMs = 0L
    private var lastHeadroom = 0f

    /** Reads at most once every 10 seconds, as the Thermal API docs ask. */
    private fun headroom(): Float {
        val now = SystemClock.elapsedRealtime()
        if (now - lastReadMs >= 10_000) {
            lastHeadroom = power.getThermalHeadroom(10)   // forecast 10 seconds ahead
            lastReadMs = now
        }
        return lastHeadroom
    }

    fun current(): GenerationPolicy {
        val h = headroom()
        return when {
            h.isNaN() -> GenerationPolicy(512, false, false)          // API not usable, stay careful
            h >= 0.95f -> GenerationPolicy(128, false, true)
            h >= 0.85f -> GenerationPolicy(384, false, true)
            else -> GenerationPolicy(1024, true, false)
        }
    }

    fun listen(onChange: (Int) -> Unit) {
        power.addThermalStatusListener { status -> onChange(status) }
    }
}

The 0.85 and 0.95 levels come from the heuristic example on Google's Thermal API page. Google also has the Android Dynamic Performance Framework (ADPF) performance hint API for games. For LLM work, the thermal headroom is the part you need first.

Burst vs sustained, in the UI

Showing the mode is better than a slow answer with no reason. To measure speed per answer, the Streaming Metrics course shows how to log time to first token and tokens per second.

Sources

Key concepts

  • Heat lowers clock speed over minutes. A short benchmark hides it.
  • iOS gives four thermal states and a change notification.
  • Android gives a 0.0 to 1.0 headroom value. Read it at most once every 10 seconds.
  • A policy maps heat to output length, model size, and background work.

Takeaways

  • Run a 20 minute sustained test and log speed and thermal state per answer.
  • Switch to a sustained mode on purpose and tell the user why answers are shorter.
  • Stop background AI jobs first when the device gets hot.