Use MaterializedArray for Sendable conformance - #335
davidkoski wants to merge 4 commits into
Conversation
| private let context: SerialAccessContainer<EmbedderModelContext> | ||
| private let context: EmbedderModelContext |
There was a problem hiding this comment.
We don't need this as the Context is now Sendable
| get async { | ||
| await context.read { $0.configuration } | ||
| } | ||
| context.configuration |
There was a problem hiding this comment.
These become synchronous accessors.
| public func perform<R>( | ||
| _ action: @Sendable (EmbedderModelContext) throws -> R | ||
| ) rethrows -> R { |
There was a problem hiding this comment.
And we can have a synchronous perform()
| @@ -27,52 +29,52 @@ import MLXLMCommon | |||
| /// } | |||
| /// ``` | |||
| public final class EmbedderModelContainer: Sendable { | |||
There was a problem hiding this comment.
Is this type still needed? Maybe. EmbedderModelContext is a struct, so this gives reference semantics -- users share the instance. Potentially EmbedderModelContext could become a class and we remove this? It would need to be immutable to do so. Keeping it for now.
| } | ||
| } | ||
|
|
||
| extension MaterializedModule: EmbeddingModel, BaseLanguageModel where LayerType: EmbeddingModel { |
There was a problem hiding this comment.
This is how we make MaterializedModule usable as a EmbeddingModel
| public var model: any EmbeddingModel | ||
| public var model: any EmbeddingModel & Sendable | ||
| public var tokenizer: any Tokenizer | ||
| public let pooling: Pooling |
There was a problem hiding this comment.
Potentially we want this to be var
| /// `Pooling` takes the sequence of hidden states from a transformer model and collapses them | ||
| /// into a single vector using strategies like mean, max, or token selection. | ||
| open class Pooling: Module { | ||
| public struct Pooling: Sendable { |
There was a problem hiding this comment.
I don't see why this should be Module. It isn't attached to the model itself (so e.g. update() semantics are not needed). I changed it like this and everything built cleanly.
| ], | ||
| dependencies: [ | ||
| .package(url: "https://github.com/ml-explore/mlx-swift", .upToNextMinor(from: "0.31.4")), | ||
| .package(url: "https://github.com/ml-explore/mlx-swift", branch: "materialized-array"), |
There was a problem hiding this comment.
Pick up that branch for now.
| try await context.read { | ||
| try await action($0) | ||
| } | ||
| try await action(context) |
There was a problem hiding this comment.
This seems to drop the serialization guarantee previously provided. The old SerialAccessContainer.read held an async mutex for the full duration of the closure, including suspension points, so concurrent perform calls could not overlap on the same model/tokenizer/pooling context.
With this direct call, two tasks can now enter perform concurrently and use the same underlying context values at the same time, while the type-level documentation still says the container “guarantees single threaded access.” Even if MaterializedModule makes the wrapped model sendable, that does not by itself preserve the previous exclusive-access contract for all work reachable through the context.
This may not be a real issue, except a documentation kind of inconsistency
There was a problem hiding this comment.
I think that is a good point -- it won't matter as much for the Embedders because they don't have state, but for example the KVCache is state. It has to have exclusive access or be some kind of copy-in/copy-out setup.
For example in ChatSession it had to play some games with private classes and knowledge of what was safe:
private func streamMap<R: Sendable>() {
try await cache.update { cache in
let model = await model.perform { context in
SendableBox(context.model)
}.consume()This would get exclusive access to the KVCache and then "borrow" the model/weights -- it treated them as Sendable, but they were not represented that way in the type system. This had to be internal implementation because it required care to make sure it was thread safe.
My hope is that providing Sendable pieces would allow anyone to write this code and do it safely.
But your point about what is serial access vs not is important.
| ) { | ||
| self.configuration = configuration | ||
| self.model = model | ||
| self.model = MaterializedModule(model) |
There was a problem hiding this comment.
context.model is still exposed as any EmbeddingModel & Sendable; EmbeddingModel inherits BaseLanguageModel, which inherits the Module mutation APIs. This now stores a MaterializedModule, whose update/apply/train/freeze overrides trap with fatalError. Existing callers mutating the model through perform { try $0.model.update(...) } can still compile through the existential API but now crash at runtime.
There was a problem hiding this comment.
Yes, I would prefer it to be typed as MaterializedModule and then these functions are unavailable. That would require either the ModelContext to be generic OR MaterializedModule to not be generic (probably subclasses).
I need to play around with that and see if one works better than the other. Just getting this to compile took some effort -- perhaps it can be done better. This change has to be done in the context of integrating it!
The fact that the Materialized variants violate Liskov substitution isn't great, but I think the benefits of documenting and obtaining Sendable in a way that is interoperable is probably worth it, but I would appreciate feedback from people who use it!
There was a problem hiding this comment.
I should note that MaterializedModule has these methods marked as unavailable so the compile time warning would be available if it were typed like that.
There was a problem hiding this comment.
A non-generic MaterializedModule won't work -- it has to be final for the Sendable to work on it. I can't make it a struct because you can't subclass a struct.
I will look at making ModelContext generic, but that will change things all over the place.
We may have to live with some limitations on MaterializedModule, but perhaps if it is encapsulated inside things like ModelContext it won't be an issue.
There was a problem hiding this comment.
OK, MaterializedModule is no longer a Module and I think that removes a lot of API surface that would e confusing.
It does mean if you want to mutate the model after load you have to indicate that when you load it (loadTrainable vs load). There is a TrainableModelContext and a ModelContext to keep these separate.
Everything should work as it did with the exception of LoRA style fine tuning -- the fact that the model is immutable on load breaks that path. I will update the PR description to reflect the breaks.
da4f7d5 to
8b5c7b7
Compare
| // | ||
| // `Gemma4AssistantDraftModel` is deliberately non-Sendable (see the | ||
| // design note at Gemma4Assistant.swift:153–155 — Embedding is a | ||
| // reference type and cross-domain access must go through | ||
| // `MTPDrafterContainer.perform`). `nonisolated(unsafe)` is appropriate | ||
| // here because the suite is serialized — only one test runs at a time, | ||
| // and the cache becomes read-only after first population. |
There was a problem hiding this comment.
This comment is obsolete, though the cache here looks like it probably needs at least a lock -- it isn't thread safe.
| let warmInput = try await context.processor.prepare( | ||
| input: UserInput(prompt: benchmarkPrompt)) | ||
| let warmStream = try generate(input: warmInput, parameters: parameters, context: context) |
There was a problem hiding this comment.
Use ModelContext instead of ModelContainer. The latter still works, but is deprecated.
| /// ``` | ||
| public final class EmbedderModelContainer: Sendable { | ||
| private let context: SerialAccessContainer<EmbedderModelContext> | ||
| @available(*, deprecated, message: "use EmbedderModelContext instead") |
There was a problem hiding this comment.
We can start removing this. Everything should still work but it is no longer needed for Sendable conformance.
| private var _context: EmbedderModelContext | ||
| private let lock = NSLock() | ||
| private var context: EmbedderModelContext { |
There was a problem hiding this comment.
Because the API allows mutation (of a value type) we do it under lock so the mutation itself is thread safe.
| @@ -40,8 +40,8 @@ import MLX | |||
| public struct SpeculativeDecodingConfig: Sendable { | |||
There was a problem hiding this comment.
I will file an issue for this, but I think this needs to be a reference type -- as it is written, if you have multiple ChatSessions with deferred loads on the draft model, each of them will load it into memory. It works but is probably not the intent (since the whole idea around it is being sensitive to memory pressure).
| images: consuming [UserInput.Image], | ||
| videos: consuming [UserInput.Video], | ||
| audios: consuming [UserInput.Audio] | ||
| images: [UserInput.Image], | ||
| videos: [UserInput.Video], | ||
| audios: [UserInput.Audio] |
There was a problem hiding this comment.
These types are all Sendable now so this doesn't have to be consuming.
| // prepare the cache, if needed. note: | ||
| // this is using the LanguageModel (not Sendable) outside | ||
| // the protective lock. Assuming the weights are not | ||
| // being mutated behind the scenes, this will obey the MLXArray | ||
| // contract that they be evaluated if used across threads. | ||
| // This is internal to the implementation and this technique | ||
| // should not be used in calling code. | ||
| // | ||
| // The benefit is that callers can be running multiple | ||
| // ChatSessions in parallel, as long as the instances | ||
| // are distinct. In particular the KVCache cannot | ||
| // be shared and that is the lock that is held here. | ||
|
|
||
| let model = await model.perform { context in | ||
| SendableBox(context.model) | ||
| }.consume() |
There was a problem hiding this comment.
This fancy dance is no longer needed since the model itself is now Sendable. I think this is one of the biggest gains -- you no longer need tricky code and long explanations about why you think it is safe.
| return generate( | ||
| input: input, context: context, iterator: iterator, | ||
| didGenerate: didGenerate) | ||
| fatalError("not implemented") |
There was a problem hiding this comment.
This has been deprecated for a while. I think it could be ported, but ModelContext would have to accept (I think) MaterializedModule also. Easier to mark as unavailable, but if it becomes an issue then we look at bringing it back.
| } | ||
| } | ||
|
|
||
| public typealias TrainableBaseLanguageModel = BaseLanguageModel & Module |
There was a problem hiding this comment.
BaseLanguageModel (LanguageModel) is no longer a Module. That looks fine except in the LoRA case, where it is a breaking change. We want this because we want to wrap the trainable (Module) based models with MaterializedModule to make them Sendable (for inference cases).
| case ciImage(CIImage) | ||
| case url(URL) | ||
| case array(MLXArray) | ||
| case array(MaterializedArray) |
There was a problem hiding this comment.
Potential source break
|
@ronaldmannak @aleroot @manojmahapatra FYI -- I think this is ready to go, along with the equivalent ml-explore/mlx-swift#418 There are a couple of breaking changes -- we can't have both mutable and Sendable (which we did before -- it was fine unless you used it!) |
| /// ) { progres in ... } | ||
| /// ``` | ||
| @freestanding(expression) | ||
| public macro huggingFaceLoadTrainabledModel( |
There was a problem hiding this comment.
Question: huggingFaceLoadTrainabledModel looks like this is a typo?
| ) -> ModelContext = | ||
| #externalMacro(module: "MLXHuggingFaceMacros", type: "LoadContextMacro") | ||
|
|
||
| /// Load a `TrainableModelContext` using default hub client and tokenizer loader with progress. |
There was a problem hiding this comment.
Question: Do we also need to add a no progressHandler overload of huggingFaceLoadTrainableModel?
There was a problem hiding this comment.
For consistency we probably should, but let me see if I can make it work with a default on the progress handler (or maybe nullable). The macro side actually supplies a default if not present.
There was a problem hiding this comment.
The macro side actually supplies a default if not present.
yea, I see that.
|
I think overall the changes look good to me. 👍 |
I was thinking about the breaking change for I will give that a try in the morning. This API break is the only thing that gives me pause. I think the rest of it cleans up a lot of sensibility issues nicely. |
That sounds reasonable to me. one question - would the state machine be explicit here? In short, the container stays mutable until the first inference call, then transitions one way into a materialized state afterward? |
Yes, exactly. Though looking at mlx-swift-examples, I am not sure it will work. It does the In fact there are no calls to Maybe better just to take the API hit and fix this correctly. 😭 |
c311dda to
78dec31
Compare
| /// applies optional quantization, and | ||
| /// updates the model with the weights. | ||
| public func loadWeights( | ||
| modelDirectory: URL, model: BaseLanguageModel, |
There was a problem hiding this comment.
Question: I pulled the PR locally and it fails to build because ModelConversion.convert(...) still takes model as BaseLanguageModel, while the implementation uses Module APIs like parameters(), leafModules(), and update(...). Should this be changed to TrainableBaseLanguageModel / BaseLanguageModel & Module?
There was a problem hiding this comment.
Yes, that sounds right -- it it curious that it was building for me. I will take a look today. Thanks!
There was a problem hiding this comment.
Rebased on main & this issue is fixed.
| @@ -6,6 +6,8 @@ import MLXNN | |||
|
|
|||
| /// Container for models that guarantees single threaded access. | |||
There was a problem hiding this comment.
Suggestion: The “guarantees single threaded access” wording feels misleading now. perform only reads _context under the lock; the closure itself runs after that access is complete, so multiple perform calls can execute concurrently. Should we update this doc comment to describe ModelContainer as a deprecated lightweight wrapper around ModelContext instead?
There was a problem hiding this comment.
Yes, good idea. I can document how it used to work vs how it works now. In practice things like ChatSession actually used it this way (the current way) but had to do so very very carefully.
There was a problem hiding this comment.
Updated the documentation on ModelContainer.
|
78dec31 to
9c04a10
Compare
9c04a10 to
c3ccd0c
Compare
|
OK, I thought about
--- a/Tools/llm-tool/LLMTool.swift
+++ b/Tools/llm-tool/LLMTool.swift
@@ -196,7 +196,7 @@ struct GenerateArguments: ParsableArguments, Sendable {
}
func prepare(
- _ context: inout ModelContext
+ _ context: inout TrainableModelContext
) {
if let extraEosToken {
context.configuration.extraEOSTokens.insert(extraEosToken)
@@ -204,7 +204,7 @@ struct GenerateArguments: ParsableArguments, Sendable {
}
func generate(
- input: LMInput, context: ModelContext
+ input: LMInput, context: ModelContextProviding
) async throws -> (GenerateCompletionInfo, String) {
var output = ""
for await item in try MLXLMCommon.generate(The LoRATrainingExample (app) requires none. |
- adopt changes from ml-explore/mlx-swift#418 - we don't need private box types -- the technique becomes general - it also opens up some potential for synchronous evaluation
2bedcf0 to
ee189e3
Compare
Proposed changes
ModelContextis now Sendable andModelContaineris deprecatedModelContextis now immutable -- suitable for inference, but not training/fine tuningTrainableLanguageModelandTrainableModelContextprovide mutable modelsloadTrainable) to loadBreaking API Changes
BaseLanguageModelis not aModuleany more -- not allLanguageModelsare mutableTrainableLanguageModelif mutation is needed (past load)UserInput.Image/Audio,MLXArray->MaterializedArrayModelContaineris deprecated (ModelContextis now Sendable) and contains aTrainableModelContextrather than aModelContext(now immutable)See ml-explore/mlx-swift-examples#488 for adoption of the new API.
Checklist
Put an
xin the boxes that apply.pre-commit run --all-filesto format my code / installed pre-commit prior to committing changes