Blog

August 15, 2026 · 10 min read

Apple Foundation Models: Measuring @Generable Structured Output

First measurements from LanguageModelSession and @Generable on device: 1.89s structured versus 2.16-2.36s plain text, and why an array minimum makes the model invent facts.

  • Apple Foundation Models
  • on-device LLM
  • Swift
  • iOS

I received the same summary in two ways. Markdown plain text took 2.16 to 2.36 seconds and produced 173 to 202 characters. Structured output took 1.89 seconds and produced 115 characters. These are medians from five runs each on an M4 Max with an 8K context window.

Structured output generated 115 characters. Plain text generated 173 to 202. On device, that output difference becomes execution time and heat.

Availability has four branches, each needing a different sentence

SystemLanguageModel has availability. If I create a session without checking it, the call fails later.

It is not enough to ask only whether it is available. There are four reasons it may not be usable, and each gives the user a different next action.

// app/JustSend/Sources/FM/FMAvailability.swift:59-70
switch FoundationModelRuntime.shared.model.availability {
case .available:
  return .available
case .unavailable(.deviceNotEligible):            // 기기가 지원하지 않습니다
  return .unavailableDeviceNotEligible
case .unavailable(.appleIntelligenceNotEnabled):  // 설정에서 꺼져 있습니다
  return .unavailableAppleIntelligenceNotEnabled
case .unavailable(.modelNotReady):                // 다운로드 중입니다
  return .unavailableModelNotReady
@unknown default:
  return .unavailableOther
}

@unknown default is insurance. availability is an enum to which Apple may add a case later. The code then keeps compiling and falls through to unavailableOther.

If I collapse all four into one, the screen can say only “AI features are unavailable.” The user does not know what to do.

State What the user can do
deviceNotEligible Nothing; change the device
appleIntelligenceNotEnabled Turn it on in Settings
modelNotReady Wait
Other Unknown

The easiest one to leave unattended is modelNotReady. The model downloads the first time. If the feature looks absent during that time, the user thinks the app is broken. Saying “The model is getting ready. Try again shortly.” gives the user a reason to wait.

I need to be able to create an unsupported device during development

A path that runs only on an unsupported device is difficult to test in the simulator. I left a way to inject the state.

// FMAvailability.swift:50-57
#if DEBUG
  // 출시 빌드는 이 분기를 컴파일하지 않는다. 개발·UI 테스트에서만
  // `UITEST_FM_UNAVAILABLE=deviceNotEligible`처럼 정본 상태를 주입해
  // 실제 미지원 기기에서만 실행되는 저장·화면 경로를 재현한다.
  if let forced = forcedAvailability(from: ProcessInfo.processInfo.arguments) {
    return forced
  }
#endif

#if DEBUG is the safety guard. This code is not compiled into a release build. An app that lets launch arguments change model state must not go to the store.

The first-check cost does not disappear; it moves to the next person

The first query of availability initializes the model handle. Where that cost is paid is a design decision.

Checking it while launching makes startup slower. Removing it from the launch path is a common choice. But the removed cost does not disappear.

// FMAvailability.swift:39-47
/// 첫 프레임이 선 뒤 **배경에서** 모델 핸들을 깨워 둔다.
///
/// 판정을 런치 경로에서 걷어 내면 그 비용은 사라지지 않고 처음 묻는 사람에게
/// 옮겨 간다 — 설정 화면을 여는 손이나 첫 저장이다. 그 자리가 런치보다 낫지만,
/// 아무도 내지 않는 것이 가장 낫다.
static func warmInBackground() {
  Task.detached(priority: .utility) { _ = check() }
}

I wake it in the background at low priority after the first screen appears. By the time the user opens Settings or saves the first note, it is ready.

FoundationModelRuntime.shared is a static let, so initialization happens only once. A utility thread pays that one cost.

This is not unique to on-device models. Every resource with an expensive first call gives the same three choices.

Where it is paid Who waits Cost
Launch path Everyone who opens the app Cold start grows
First use The person opening Settings or saving first That screen stops
Background after first frame Nobody Battery is used even by people who never use it

I chose the third. The last column is its cost. A model handle wakes once even on devices whose users never use AI. I measured that against making the first user wait and judged the latter worse.

I create a new session for every request

Creating a LanguageModelSession takes two things: the model and instructions.

// FoundationProfileModelExecutor.swift:34-41
let session = LanguageModelSession(
  model: model,
  instructions: request.instructions   // 이번 단계의 지시문
)
let options = GenerationOptions(
  sampling: .greedy,                          // 가장 확률 높은 토큰만 고릅니다
  maximumResponseTokens: request.responseTokens // 답의 길이 상한
)

sampling: .greedy makes the same input produce the same answer each time. It fits extraction rather than creation. If a summary changes on every run, I cannot judge regressions.

Creating the session per request is also a choice. Reusing one leaves the previous conversation in context. If I summarize 30 document chunks and earlier chunks occupy the window, less room remains for later ones. To treat chunks independently, sessions must be independent too.

I ask for the window size

Hard-coding the context window as a constant falls behind when the OS changes.

// FoundationModelRuntime.swift:186-191
static func resolveContextTokenLimit(dynamicContextSize: Int?) -> Int {
  guard let dynamicContextSize, dynamicContextSize > 0 else {
    return SummaryContextBudget.sessionLimit   // 못 물어보면 기본값
  }
  return dynamicContextSize                    // 물어봤으면 실측값
}

If an API reports the actual window size, I use it; otherwise I use a conservative default. The default is 4,096. I do not use this value to block calls. I use it only when calculating how many chunks to make.

Structured output was faster and shorter than plain text

The shape in which I receive the answer changes its speed.

A type marked @Generable makes the model answer only in that shape. Apple calls this guided generation.

// FoundationProfileModelExecutor.swift:180-187
@Generable
private struct GeneratedSummaryChunk {
  @Guide(description: "concise one-line title")
  let headline: String
  @Guide(description: "concise concrete sentences ending with punctuation", .maximumCount(3))
  let points: [String]
}

The call passes that type to generating:.

// FoundationProfileModelExecutor.swift:57-61
try await session.respond(
  to: request.prompt,
  generating: GeneratedSummaryChunk.self,   // 이 모양으로만 답하세요
  options: options
)

I measured the same summary three ways.

Receiving method Time Length
Structured output 1.89 seconds 115 characters
Markdown plain text 2.16~2.36 seconds 173~202 characters
Structured + schema omitted from prompt 2.48 seconds 166 characters

These are medians of five runs each on an M4 Max with an 8K window.

Structured output is faster because it is shorter. The schema forces restraint. With plain text, the model adds phrases like “Here is the summary” and Markdown symbols. Those characters also cost generation time.

Removing the schema from the prompt made it slower

The third row was unexpected.

includeSchemaInPrompt defaults to true, meaning the schema is included in the prompt. Since that makes the prompt longer, turning it off should make things faster.

After turning it off, it became slower at 2.48 seconds. Without the schema, the model did not know the format and wrote more. I reduced the input but increased the output, and on device output costs more.

It is better to measure again before changing the default.

I set only an upper bound on arrays

points has .maximumCount(3). There is no minimum. Both choices have a reason.

With no count bound, the model transfers six or seven source sentences almost as-is. That is copying, not summarizing. Then the final value is cut off at the end of the token budget.

The cut-off shape looks like this.

"...증가율을 놓친"

Guided generation closes the value syntactically at the budget boundary, but the content ends in the middle. When I ran 12 large documents on a real iPhone 17 Pro on 2026-08-12, a 200-token budget repeatedly produced points like this.

So I limited structure and budget together. The array has at most three values, and each value must be a complete sentence ending in punctuation; I say this once in the schema and once in the instructions. I give long chunks up to 280 tokens so there is room to close the last point.

A lower bound makes the model invent facts

I also measured what happens when I require a minimum, such as .count(2...4).

When the short original contains only two facts, the model has to fill the rest. Filling them means inventing facts. The gate that checks whether the invented sentence is grounded in the original rejects the result, so everything is discarded.

With a short, anchor-dense body, the lower-bounded schema failed three out of three times.

Constraint Result
No count limit Copies the original; final value is cut off
Maximum 3 Summary-like length
Require at least 2 Invents facts in short source; 0/3 passed

An empty array is better than an invented sentence.

The language of the schema description pulls the output language

I first wrote @Guide(description:) in Korean. When I asked for an English summary of Korean input, a Korean summary came back. The schema description is part of the prompt, so the model was pulled toward its language.

On the same day I measured 16 languages together. I used one Seoul late-night bus article, with a short 164-character input and a long 515-character input, and asked for each language by language code.

Requested language Short input Long input
ko, ja, zh-Hans, zh-Hant, it, pt-BR Requested language Requested language
en Requested language Korean
es English Requested language
fr, de, da, nl, sv English English
nb English Korean
tr, vi Korean Korean

For the short input, 7/16 came back in the requested language; for the long input, 7/16 did. Six were correct in both. A French request returned English, while a Turkish request returned Korean. Since the source was Korean, the model was pulled toward the source or, because the instruction was English, toward English.

Numbers moved as well. In the Spanish request, it produced from 12 to 22000. The source had 22 routes.

I changed every description to English so the schema would not pull the output language. The core's instructions determine language; the schema describes only shape. I left the record in tmp/evidence/fm-locale-compliance-2026-08-11.log.

Be careful when a prompt comes along with an error

When a model fails, I want to leave the error in the log. But Foundation Models' error description includes the prompt. If I keep it as-is, the user's writing accumulates in logs.

// FoundationProfileModelExecutor.swift:104-109
/// 오류의 **케이스 이름만** 남긴다. Foundation Models 오류 설명에는 프롬프트가
/// 딸려 오므로 괄호 뒤 payload는 버린다.
static func signature(of error: Error) -> String {
  let described = String(describing: error)
  let head = described.prefix { $0 != "(" && $0 != ":" && !$0.isNewline }
  return head.trimmingCharacters(in: .whitespaces)
}

I cut at the first opening parenthesis or colon. What remains is a case name such as guardrailViolation.

Diagnostics need to know which failure occurred, not what the user wrote.

I split log levels too. If I keep even the success path at info, every run adds another line. I hide it at debug normally and raise only blocked cases to error. I retrieve it when needed with log show --debug.