Skip to content

Expose minimal Gemma3 surface for frozen-text-encoder use#387

Open
xocialize wants to merge 6 commits into
ml-explore:mainfrom
xocialize:gemma3-all-hidden-states
Open

Expose minimal Gemma3 surface for frozen-text-encoder use#387
xocialize wants to merge 6 commits into
ml-explore:mainfrom
xocialize:gemma3-all-hidden-states

Conversation

@xocialize

@xocialize xocialize commented Jul 2, 2026

Copy link
Copy Markdown

Motivation

Multimodal generators increasingly use a frozen LLM as a text encoder, conditioning on the hidden states of every layer rather than on generated tokens. A concrete example: Lightricks LTX-2 conditions its video DiT on all 49 hidden states of Gemma-3-12B-it. We've been carrying this as a small fork while building a Swift port of LTX-2 — upstreaming so the next consumer of Gemma-3-as-encoder doesn't have to.

Shape (reworked per review)

Originally this PR shipped an allHiddenStates(_:mask:) extension in MLXLLM. Per review, that tap is pipeline-specific (its uniform mask matches the LTX-2 reference encoder, not generic Gemma3 per-layer sliding/global semantics) — so it now lives in client code, and this PR only widens visibility, scoped to @_spi(GemmaEncoder) so it isn't advertised public API:

  • Gemma3TextConfiguration.hiddenSize, .hiddenLayers
  • Gemma3TransformerBlock + its callAsFunction(_:mask:cache:)
  • Gemma3Model.embedTokens, .layers, .config

Clients opt in with @_spi(GemmaEncoder) import MLXLLM. Zero behavior change — attributes and doc comments only.

Testing

Gemma3EncoderAccessTests locks the contract: it implements the client-side all-hidden-states tap using @_spi(GemmaEncoder) import MLXLLMno @testable — on a tiny randomly-initialized model (no downloads), asserting state count (hiddenLayers + 1), shapes, first-state == scaled embedding, layer transformation, and determinism. 3/3 pass via xcodebuild test. If the exposed surface regresses, this test stops compiling.

Branch is merged up to current main; scripts/verify-docs.sh builds MLXLLM docs clean under --warnings-as-errors.

🤖 Generated with Claude Code

xocialize and others added 2 commits July 2, 2026 07:50
Encoder-style multi-layer hidden-state extraction on Gemma3Model /
Gemma3TextModel: returns the embedding output plus every transformer
layer's output (numHiddenLayers + 1 states), under a single caller-
supplied uniform mask (combined causal+padding), intentionally bypassing
the per-layer sliding/global mask selection of the generation path.

Motivation: multimodal generators increasingly condition on ALL hidden
states of a frozen LLM text encoder rather than on generated tokens —
e.g. Lightricks LTX-2 conditions its video DiT on all 49 hidden states
of Gemma-3-12B. Per-layer materialization keeps command buffers small so
long-sequence encodes on large checkpoints stay under the macOS GPU
watchdog.

Unit tests run on a tiny randomly-initialized model (no downloads):
state count/shapes, first-state == scaled embedding, layers transform
the state, determinism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move @_spi(Testing) @testable import below the plain imports to satisfy
the swift-format pre-commit hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment on lines +24 to +27
public func allHiddenStates(
_ inputs: MLXArray,
mask: MLXFast.ScaledDotProductAttentionMaskMode
) -> [MLXArray] {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a really cool idea, but I have some questions & concerns about it.

  • should this live in MLXEmbedders? that is where models that do this type of encoding live. I realize this is a bit different than what they do, but would it make more sense to do this on an embedding model (less weights to load, etc)?
  • the embedding model may not be the same as the full gemma3 model so it might not produce the same results, but I wonder if an ability to load the full weights into the encoding model would do the trick? it should be the same structure and the sanitize should discard the unused pieces
  • is this something that would be widely used? It seems like it might be a bit specialized and maybe is better suited as an extension (like this) in the client code. perhaps we just need to make Gemma3Model open (plus expose the bits needed)?

and separately I think there is a correctness issue:

  • the gemma3 mask is a bit complicated with global and local masks for different layers, e.g.
            fullMask = createAttentionMask(h: h, cache: cacheArray[firstFullIdx])

            let slidingWindow = config.slidingWindow > 0 ? config.slidingWindow : 4096
            slidingWindowMask = createAttentionMask(
                h: h, cache: cacheArray[firstSlidingIdx], windowSize: slidingWindow)

It could be that coincidentally, if your text is <= than the prefill window size (512) then it would produce the same result, but it is complicated and I think not quite correct. The mean cosine you report for 1024 might be caused by this.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the careful look @davidkoski — I think you've landed on the right answer, and your mask concern is actually the strongest argument for it.

On the mask: you're right about the semantics. The single uniform mask is deliberate — it matches the reference LTX-2 encoder implementation (which is what the video DiT was trained against), not generic Gemma3 generation semantics. The parity goldens in the PR description come from that same reference, so the 0.999866 at T=1024 is 4-bit quant/accumulation noise rather than mask divergence — but your underlying point stands: as a generic Gemma3Model feature, a mask that silently diverges from the model's own per-layer sliding/global selection for T > window is surprising API. That's pipeline-specific behavior, and it shouldn't live in MLXLLM.

Which is your option 3. I've reworked the PR accordingly: the allHiddenStates extension and its LTX-specific masking are gone; the PR is now just the minimal visibility changes needed so a client module can implement the tap itself, plus a test that proves sufficiency — it implements the same tap using only import MLXLLM (no @testable) against a tiny randomly-initialized model, so the exposed surface can't silently regress. We'll carry the actual encoder tap (uniform mask + the per-layer eval that keeps 12B-class encodes under the GPU watchdog) in our LTX package where it belongs.

On MLXEmbedders (your first two questions): we considered it, but the encoder must reproduce exactly the hidden states the pipeline was trained against — full Gemma-3-12B-it weights, all 49 states, the reference's masking. An embedders-side model would still need the same specialized tap and masking to be exact, so it moves the specialization rather than removing it. If a general "LLM-as-encoder" story lands in MLXEmbedders someday we'd happily migrate; for now the open/expose route keeps upstream clean.

The branch is also merged up to current main (which brings in the #404 CI fix, so mac_build_and_test should actually run now). Let me know if the exposure set looks right or if you'd prefer any of it shaped differently.

@davidkoski davidkoski left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See what you think about my questions / concern. Thanks!

xocialize and others added 2 commits July 16, 2026 08:56
…ates

Per review feedback, the encoder-style allHiddenStates extension was too
specialized for MLXLLM: its single uniform mask bypasses Gemma3's
per-layer sliding-window/global mask selection, which diverges from
generic Gemma3 semantics for texts longer than the sliding window. That
tap now lives in client code; this repo only exposes the minimal public
surface a client module needs to implement it:

- Gemma3TextConfiguration.hiddenSize, .hiddenLayers: internal -> public
- Gemma3TransformerBlock: internal -> public (init stays internal)
- Gemma3TransformerBlock.callAsFunction(_:mask:cache:): internal -> public
- Gemma3Model.embedTokens: internal -> public
- Gemma3Model.layers: internal -> public
- Gemma3Model.config: internal -> public

No behavior changes; access levels and doc comments only. `public` (not
`open`) since clients only call members from an extension; no
subclassing is required.

Gemma3EncoderAccessTests replaces the old test file: it implements the
same allHiddenStates tap inside the test using `import MLXLLM` without
`@testable`, proving the exposed surface is sufficient, and asserts
state count (numHiddenLayers + 1), (B, T, hiddenSize) shapes, first
state == scaled embedding, and determinism on a tiny
randomly-initialized model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@xocialize xocialize changed the title Add Gemma3 allHiddenStates for frozen-text-encoder use Expose minimal Gemma3 surface for frozen-text-encoder use Jul 16, 2026
@davidkoski

Copy link
Copy Markdown
Collaborator

Awesome, i will take a look! What you said about exact repro makes sense.

For exposing the innards, I wonder if using @_spi would make sense:

    /// Usable by extensions to implement `callAsFunction()`
    @_spi(MaterializedModule)
    public let _base: LayerType  

Then in other packages you can explicitly expose access to the symbol like this:

@_spi(MaterializedModule) import MLXNN

extension MaterializedModule: EmbeddingModel where LayerType: EmbeddingModel {

    public func callAsFunction() {
...
        return _base(

Many models already have bits and pieces exposed as public so the change you made may be correct. This @_spi piece is something I learned about recently and it looks like it might be useful in cases like this -- you want to expose something but not fully advertise it.

xocialize and others added 2 commits July 18, 2026 15:44
Per review feedback, gate the newly exposed Gemma 3 encoder surface behind
an SPI group instead of plain `public`, so opted-in clients can implement an
encoder-style all-hidden-states tap without these symbols becoming
advertised public API of MLXLLM.

All seven declarations move from `public` to `@_spi(GemmaEncoder) public`:

- Gemma3TextConfiguration.hiddenSize, .hiddenLayers
- Gemma3TransformerBlock (class) and its callAsFunction(_:mask:cache:)
- Gemma3Model.embedTokens, .layers, .config

`Gemma3Model.layers` is typed on the SPI `Gemma3TransformerBlock`, so it
must carry the same SPI group; the combination compiles and is usable from
a client that writes `@_spi(GemmaEncoder) import MLXLLM`.

Group name: the repo's only existing group is `@_spi(Testing)`, which marks
test-only escape hatches (MTPSpeculativeTokenIterator's processor accessors,
Gemma4Assistant.forwardHidden). This surface is meant for production client
code, so reusing `Testing` would mislabel it; `GemmaEncoder` follows the
purpose-descriptive, one-group-per-feature style of mlx-swift's
`@_spi(MaterializedModule)`.

Also fixes the `Verify documentation` CI failure. The doc comment on
`Gemma3Model.embedTokens` used a relative link, ``callAsFunction(_:mask:cache:)``,
which DocC tried to resolve as a child of the property. It cannot be fixed by
qualifying it either: `Gemma3Model.callAsFunction` is internal, so it is not in
the public symbol graph and `Gemma3Model/callAsFunction(...)` would not resolve
(its signature also differs -- `mask` is optional there). Downgraded to plain
code formatting with prose instead. Audited every doc comment this branch
touches; this was the only symbol link.

No behavior changes: access levels, attributes, and doc comments only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@xocialize

Copy link
Copy Markdown
Author

@_spi is a much better fit than plain public here — thanks for the pointer, I hadn't used it. Pushed: the whole surface is now @_spi(GemmaEncoder) public, and clients opt in with @_spi(GemmaEncoder) import MLXLLM. It keeps these internals out of the advertised API while still making the tap implementable, which seems like exactly the "expose but don't advertise" case you described.

Notes on the choices, in case you'd shape any of them differently:

  • Group name. The repo currently has one group, @_spi(Testing), used for test-only escape hatches — reusing it would mislabel a surface meant for production client code, so I went with a purpose-descriptive GemmaEncoder following the MaterializedModule style. Happy to rename or fold it into a single shared repo-wide group if you'd rather have one.
  • layers must carry the group since its element type is SPI — compiler requirement rather than a choice.
  • config is exposed whole, which hands clients the entire Gemma3TextConfiguration even though only hiddenSize/hiddenLayers are SPI-public. I kept it because the tap wants config.hiddenLayers to size the result and threading the two values separately is clumsier — but if you'd prefer to drop config and expose only those two, that's an easy trim.
  • DocC fix. Verify documentation was failing on a relative callAsFunction(_:mask:cache:) link I'd written on a property (DocC resolved it as a child of embedTokens). Qualifying it wasn't possible — Gemma3Model.callAsFunction is internal, so it isn't in the public symbol graph — so it's plain code formatting now. Verified with scripts/verify-docs.sh: MLXLLM builds clean under --warnings-as-errors. (Worth noting the @_spi change alone would have turned CI green, since SPI symbols drop out of the symbol graph — I fixed the comment anyway so it's correct on its own.)

One limitation worth stating plainly, since it's the flip side of your original mask concern: Gemma3TransformerBlock.callAsFunction takes a non-optional mask, so this surface only enables a uniform-mask tap — a client can't reproduce the per-layer sliding/global selection that Gemma3Model.callAsFunction does. That's exactly what our encoder wants (it must match the LTX-2 reference), but it does mean the surface isn't a general-purpose "run the layer stack correctly" API. If you'd rather it were, exposing the mask construction would be the additional piece — though that's more surface than this PR needs, so I've left it out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants