Expose minimal Gemma3 surface for frozen-text-encoder use#387
Expose minimal Gemma3 surface for frozen-text-encoder use#387xocialize wants to merge 6 commits into
Conversation
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>
| public func allHiddenStates( | ||
| _ inputs: MLXArray, | ||
| mask: MLXFast.ScaledDotProductAttentionMaskMode | ||
| ) -> [MLXArray] { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
See what you think about my questions / concern. Thanks!
…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>
|
Awesome, i will take a look! What you said about exact repro makes sense. For exposing the innards, I wonder if using /// 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 |
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>
|
Notes on the choices, in case you'd shape any of them differently:
One limitation worth stating plainly, since it's the flip side of your original mask concern: |
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,.hiddenLayersGemma3TransformerBlock+ itscallAsFunction(_:mask:cache:)Gemma3Model.embedTokens,.layers,.configClients opt in with
@_spi(GemmaEncoder) import MLXLLM. Zero behavior change — attributes and doc comments only.Testing
Gemma3EncoderAccessTestslocks the contract: it implements the client-side all-hidden-states tap using@_spi(GemmaEncoder) import MLXLLM— no@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 viaxcodebuild test. If the exposed surface regresses, this test stops compiling.Branch is merged up to current
main;scripts/verify-docs.shbuilds MLXLLM docs clean under--warnings-as-errors.🤖 Generated with Claude Code