Core AI model zoo

LFM3-1.7B — port knowledge

LiquidAI/LFM3-1.7B (LFM Open License v1.0, revision ac2586a34f1a1c03329dbdcdefabd8b6478ddaa7) is the first LFM3 checkpoint in the zoo. It keeps LFM2.5’s idea — short-conv mixers interleaved with attention, no recurrent scan — and changes almost everything around it: the conv window, a sliding window on five of the eleven attention layers, the rotary, an output gate, the norms, and the prompt surface (thinking on by default, XML tool calls). The decoder module written for it is shared by the three LFM3 multimodal ports in progress (VL, Omni, Audio; separate pull requests), so this note is also where their language model comes from. Card: models/lfm3-1.7b.

The engine here is the zoo’s fork of apple/coreai-models (john-rocky/coreai-models): llm-runner and the zoo’s provider were built from its branch zoo-0.2 at 661a5d4 (the checkout that ran the gates carried one more local commit, an unrelated Python module, so its Swift engine is 661a5d4’s), and CoreAIKit pins its tag 0.2.8-zoo. Below, “the fork” is that branch at 661a5d4 and “the kit’s pin” is the tag.

Six differences from LFM2 / LFM2.5

  LFM2.5-1.2B / 2.6B LFM3-1.7B
1. conv mixer depthwise causal conv, kernel 3; state [n, 1, 2048, 2] kernel 7; state [13, 1, 2048, 6] (13 conv layers, the last 6 columns of B·x)
2. attention layers full attention only (6 / 8 layers), 32 q / 8 kv heads, head_dim 64 11 = 6 full + 5 sliding (window 256), interleaved with the conv layers; 16 q / 4 kv heads, head_dim 128
3. rotary full head_dim, one θ (1e6 / 1e7) full layers: θ 1e6 on half the head_dim (partial_rotary_factor 0.5, zero-tail, below); sliding layers: θ 1e4 on the full head_dim — two tables per step
4. attention output o_proj of the heads output gate: sigmoid(gate_proj(x)) (2048 → 16, one scalar per head, x = the mixer’s normed input) times each head before o_proj
5. norms pre-norm per block (operator_norm → mixer → + residual → ffn_norm → MLP → + residual), final embedding_norm sandwich norm: operator_norm → mixer → post_operator_norm → + residual → ffn_norm → SwiGLU → post_ffn_norm → + residual; the final norm keeps LFM2’s name embedding_norm
6. prompt surface pythonic calls between <\|tool_call_start\|> / <\|tool_call_end\|>; no reasoning block (1.2B) or <think> opened by the template (2.6B) XML calls (<tool_call> / <function=NAME> / <parameter=KEY>); the template opens <think> by default; the vocab still carries LFM2.5’s <\|tool_call_start\|> pair

Unchanged: hidden 2048, vocab 128 000 with a tied head, a SwiGLU MLP (7680 here), RMSNorm with eps 1e-5, per-head q/k RMSNorm. The config is transformers-v5 shaped: tokenizer_class: TokenizersBackend (the tokenizer trap of lfm2.5-2.6b-port.md applies as it is), and rope_parameters one level deeper than the 2.6B’s — a dict per layer type (full_attention, sliding_attention).

The module is new (coreai_models/models/macos/lfm3.py in the authoring overlay), not a flag on lfm2.py: it imports LFM2’s conv mixer and _rotate_half, uses the shared primitives (MLP, RMSNorm, SDPA, KVCache), and writes the attention, the decoder layer and the stack. It was gated against transformers 5.20.2’s fp32 eager on 17 sequences (8 short questions with thinking off and on, and one 1,648-token document) along three paths — one call over the whole sequence, S=1 with a 4,096-slot KV, and the engine’s own walk (S=1, KV grown from 256 by doubling): worst max|Δlogit| 1.22e-3 / 2.61e-3 / 2.61e-3, top-1 2682/2682 on every path (one position whose HF fp32 top-2 gap is under 1e-3 left out). Three negative controls turn it red: dropping the output gate (23.4 on the long sequence, top-1 889/1647), rotating every dimension (16.8, 1450/1647), and removing the window (24.3, 1081/1647; the 31-token sequence stays green: under 256 tokens the window never bites).

The sliding window is the SDPA composite’s window_size, with no mask input

The decode graph keeps the whole KV for all 11 attention layers in one KV pair ([11, 1, 4, L, 128], L dynamic) and lets the composite apply the window: Lfm3Attention passes window_size=256 to the SDPA composite, the same path the stock gemma3_text.py takes. The composite’s window branch is the lower-right causal mask XOR q_idx >= k_idx − (k_len − q_len − W), i.e. “absolute query position − key position < 256”, which is transformers’ kv_idx > q_idx − sliding_window. The host passes position_ids (the full length) and the three states; there is no mask input, and the engine’s contract (one KV pair + one fixed extra state) holds.

Two things were checked before trusting it:

A ring-buffer KV for the five sliding layers (256 slots) was not built: the decode over a ~3.7–4k KV is only 1.9 % slower than over ~1–1.3k for all 11 attention layers, so a ring on 5 of them would win back about 1 % at a 4,096-token context.

The partial rotary is a zero tail, and there are two θ per step

partial_rotary_factor: 0.5 on the full layers does not mean “rotate the first 64 dimensions”. transformers builds inv_freq over the whole head_dim — its first 32 entries are θ^(−2i/128) and the last 32 are 0 — and then concatenates the frequencies with themselves and applies rotate_half. The rotated pairs are (i, i+64) for i < 32; dimensions 32–63 and 96–127 get angle 0 (cos 1, sin 0) and pass through. A port that rotates the first 64 dimensions instead pairs other dimensions at other frequencies; that form was not run here. The control that was run — the full layers rotating every dimension, with no zero tail — moves the long sequence’s logits by up to 16.8 (top-1 1450/1647). The sliding layers rotate every dimension with θ 1e4.

The module builds both tables once per step from position_ids in fp32 and shares each between the layers of its type. inv_freq is a plain fp32 attribute, not a registered buffer, so a .to(torch.float16) of the model does not round it; the sliding θ stays the integer 10000 from the config (the tiny model’s prefill logits match transformers at max|Δ| 0.0).

What stays fp32 in an fp16 or int8 export

The same two GPU-delegate traps as LFM2.5 (models/lfm2.5, its “Two GPU-delegate findings”), and the same fixes: the conv layers return their new columns and the stack writes all 13 slots of the conv state with one slice_update per step, and the attention projections keep fp32 weights. For LFM3 the fp32 path is wider:

The activations themselves fit fp16 with room: over the 17 sequences the largest values were 4.53 (mixer output before its post-norm), 30.2 (MLP output before its post-norm), 55.6 (SwiGLU intermediate), 25.7 (residual stream) — far from 65 504, so the sandwich norm needs no residual scaling.

fp16 activations have a floor on this model

The fp16 bundle fails the strict parity bar on the long sequence: teacher-forced on the GPU against transformers’ fp32 over the same 17 sequences, max|Δlogit| is 0.353 (p50 0.026), and 6 of the 1,647 counted positions of the 1,648-token sequence flip their top-1, at positions where transformers’ own top-2 gap is 0.003–0.061. It is not the conversion: the same module run in fp16 on the CPU in PyTorch shows the same width (0.49, 4 flips), the fp16 logits’ rounding alone accounts for 0.008–0.011, and the same graph exported in fp32 and run on the GPU passes at 2.20e-3 with every top-1 right. Read fp16 numbers for this model, and for the VL / Omni / Audio language models built on the same module, against that floor. The shipped bundle’s int8 weights (int8hu: the MLP and conv-mixer linears int8 per-block-32, the head untied and int8, the attention fp32), teacher-forced S=1 through main, keep 2637/2682 top-1 = 98.32 % (1027/1035 on the short sequences, 1610/1647 on the long one); the same quantized weights computed in fp32 in PyTorch keep 2636/2682, so the fp16 activations add almost nothing on top of the quantization.

The chat template: eight rewrites that change no rendered string

The vendor chat_template.jinja (sha256 f45005c5…) uses Python-jinja constructs the Swift renderer does not handle in every version that ships:

swift-jinja vendor template the bundle’s template
2.3.2 (the pin of apple/coreai-models) does not parse: Expected '%}' after for loop.. Got or instead ({% for tool in tools or [] %}) 30/30
2.3.6 (CoreAIKit’s pin) parses; the 10 conversations with tools differ (tojson is a JSONEncoder: sorted keys, no spaces, \uXXXX, \/) 30/30
2.5.2 30/30 30/30

30 conversations = 15 scenes with thinking off and on — the 8 questions, a three-turn chat with reasoning in the history, a turn with a system prompt and no tools, and five tool scenes (a round trip with and without a system prompt, two calls in one turn, a finished round trip in the history, a tool description full of characters that JSON escapes) — compared string for string and id for id with transformers 5.20.2’s apply_chat_template. The bundle carries the vendor template with eight rewrites: parentheses around tools or [], and a _json(v) macro in place of the template’s tojson calls — it uses tojson when that already prints json.dumps’ format and writes the format out otherwise. transformers’ jinja2 renders the rewritten template to the same string as the vendor’s on all 30. The other tokenizer files are the source’s, verbatim.

Watch for one quiet fallback: when the template does not parse, Apple’s CoreAILanguageModel (and the fork’s) catches the error, logs one CLILogger line (off by default) and encodes the messages’ text joined by newlines without a chat template; the caller is not told (read in the code, not run). That is why the rewrite is shipped instead of relying on a newer swift-jinja being resolved.

Two prompt-surface traps

On the real engine, with the shipped int8 weights

Chunked prefill: the bundle’s second function

A padded last chunk (--pad-tail, not in a pinned engine yet)

Turn 2 from a conv-state checkpoint (an engine patch, not in the kit’s pin yet)

Shipping

Toolchain notes

iPhone

The iPhone bundle (ios-h19p/) is the static Neural Engine form (B below), stripped of its source locations and compiled for the iPhone 18 Pro, and it runs on CoreAIKit’s path. Every run here is on iOS 27.2; the speed columns use a 768-token prompt and 256 generated tokens (for A, prompt + answer = 1,024 is the cap that coreai-models’ bd8dcf7 puts on the pipelined engine’s dynamic KV on iOS):

form phone, engine prefill / decode tok/s a 768 + 256 turn thermal answers
A. int8hu, main only, compiled ahead of time for the GPU (h18p) iPhone 17 Pro, PipelinedBench on a coreai-models pipelined engine with bd8dcf7’s iOS KV cap 10.0 [9.2–11.0] / 9.25 [8.0–10.7], the 4 warm trials (the cold trial: 12.1 / 10.5) 104.37 s nominal → fair in the first run, serious from the second (all 4 warm trials at serious) the 8 questions’ greedy tokens = the Mac’s on 8 / 8; the token gate 16 / 16 in all 3 runs; stops where the Mac stops
B. the static Neural Engine form (h18p) iPhone 17 Pro, Apple’s StaticShapeEngine (apple/coreai-models’ main) 339.6 [336.1–340.6] / 32.86 [32.36–33.17], n = 6 10.02 s fair at the start and end of both speed runs (serious in the three correctness runs) top-1 692 / 721 = 95.98 % teacher-forced vs transformers fp32 (bar 93 %); the 8 questions’ verdicts of transformers bf16, 8 / 8; stops on <\|im_end\|>
B, the published bytes (h19p) iPhone 18 Pro, the StaticShapeEngine CoreAIKit pins 590.6 [583.7–592.7] / 47.17 [46.28–47.22], n = 6 6.71 s nominal at the start and end of every run the same tokens as the row above on every prompt (“The kit’s engine against Apple’s”, below)

Mac speed is on the card.