Core AI model zoo

Running Gemma 4 (with its PLE table) behind FoundationModels

Gemma 4’s small models (E2B / E4B) carry a non-standard extra input: a giant per-layer embedding (PLE) table the decoder gathers from once per token — the text-model analogue of a VL decoder’s image_embeds. A stock text load path declares only input_ids / position_ids (+ KV states), so it can’t feed the PLE table and the engine rejects the bundle. This page shows how CoreAIKit runs Gemma 4 behind Apple’s FoundationModels LanguageModel protocol — “Ask Gemma 4” through a plain LanguageModelSession — by binding the PLE table as a constant static graph input. (Sibling: [vl-executor-fm-stack] for the VL version of the same trick; [pipelined-engine] for the engine APIs.)

The bundle shape that makes it easy: …_decode_int4lin_tbl

The published pipelined Gemma 4 bundle comes in two forms (see [pipelined-engine] “per-token vs static inputs”):

Use the …_tbl bundle. Then the host only binds two buffers that never change for the life of the model — no per-step host work, no encoder, no per-turn rewrite. It is the simplest possible wiring: a plain text bundle plus two constant inputs.

The wiring (≈ the whole runtime)

// 1) S=1 prefill: set BEFORE the engine reads ModelConfig.chunkThreshold.
setenv("COREAI_CHUNK_THRESHOLD", "1", 1)

// 2) Read each PLE table file once into an OWNED storageModeShared MTLBuffer (owned beats a
//    read-only mmap here — a no-copy mapping pays a large per-encode residency tax, and these
//    are bound on every step). ~2.35 GB for E2B.
let buffers: [String: StaticInputBuffer] = [
  "ple_table": StaticInputBuffer(ownedBuffer(tablesDir + "/embed_per_layer.i8")),
  "ple_scale": StaticInputBuffer(ownedBuffer(tablesDir + "/embed_per_layer.scale.f32")),
]

// 3) Build the engine through the factory so the static inputs can be bound (the default
//    CoreAIRunner load path does not expose EngineOptions). "main" avoids CoreAIShared's
//    ComponentKey.
let bundle = try LanguageBundle(at: decoderDir)
let config = ModelConfig(name: bundle.name, tokenizer: bundle.tokenizer,
  vocabSize: bundle.vocabSize, maxContextLength: bundle.maxContextLength,
  serializedModel: [bundle.modelAssetPath], function: "main")
let engine = try await EngineFactory.createEngine(
  config: try JSONEncoder().encode(config),
  modelURL: try bundle.requireModelURL(for: "main"),
  options: EngineOptions(staticInputBuffers: buffers))   // ← the one line that unblocks Gemma

After this, generation is identical to any pipelined text bundle: engine.reset()engine.generate(with: promptTokens, …) → stream tokens, on-GPU argmax, on-device KV. Keep the two buffers alive for the engine’s lifetime. Do not call engine.warmup() (it warms query length 256, which the S=1 graph rejects — a 1-token generate after load is the warmup). A QAT bundle must be paired with the QAT PLE tables.

Gemma 4’s chat format (it is NOT Gemma 3’s)

Gemma 4 has a new tokenizer. Turns are framed by <|turn> (105) … <turn|> (106) — not <start_of_turn>/<end_of_turn>. Reasoning rides a <|channel>thought\n … <channel|> (100 … 101) channel; <eos> = 1, <bos> = 2. The shipped E2B bundle uses the stock google/gemma-4-E2B-it tokenizer, which has no embedded chat template, so render the prompt explicitly:

<bos>
<|turn>system\n{system}<turn|>\n        # only if instructions are present
<|turn>user\n{user}<turn|>\n
<|turn>model\n                          # generation prompt

Two gotchas, both measured on gemma4_e2b_qat_decode_int4lin_tbl:

Memory / device

Net

The only thing standing between Gemma 4 and the standard text path is two extra graph inputs. Bind them as constant static buffers and Gemma 4 runs behind LanguageModelSession like any other local model — so “Ask Gemma 4” from Siri / App Intents is just a model swap. Same EngineOptions hook the VL executor uses for image_embeds; here it carries a per-layer-embedding table instead.