Skip to content

MaterializedArray is a Sendable MLXArray - #418

Open
davidkoski wants to merge 16 commits into
mainfrom
materialized-array
Open

MaterializedArray is a Sendable MLXArray#418
davidkoski wants to merge 16 commits into
mainfrom
materialized-array

Conversation

@davidkoski

Copy link
Copy Markdown
Member

Proposed changes

  • once an array has been evaluated we can use it as Sendable
  • a subtype of MLXArray that is Sendable

Looking for feedback on this. I will add documentation once I test it a bit. Check out the tests to see how it works.

Checklist

Put an x in the boxes that apply.

  • I have read the CONTRIBUTING document
  • I have run pre-commit run --all-files to format my code / installed pre-commit prior to committing changes
  • I have added tests that prove my fix is effective or that my feature works
  • I have updated the necessary documentation (if needed)

import Foundation
import Numerics

public final class MaterializedArray: MLXArray, @unchecked Sendable {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A new subtype of MLXArray that is Sendable


public final class MaterializedArray: MLXArray, @unchecked Sendable {

init(materialized ctx: consuming mlx_array) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Create using materialize(x) or x.materialized()

Comment thread Source/MLX/MLXArray+Init.swift
Comment thread Source/MLX/MLXArray.swift
import Numerics

public final class MLXArray {
public class MLXArray: ExpressibleByArrayLiteral {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

  • remove final -- it is still closed outside the package
  • I don't think we were seeing any particular benefit from the final
  • add ExpressibleByArrayLiteral to the main type so that subclasses can override

Comment thread Source/MLX/MLXArray.swift Outdated
Comment thread Source/MLXNN/Linear.swift
Comment thread Source/MLXNN/MaterializedModule.swift Outdated
}
}

extension MaterializedModule where LayerType: UnaryLayer {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We can use this pattern e.g. for LanguageModel in mlx-swift-lm

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added docs to show how this is done -- the some / any dance took a while to work out.

Comment thread Source/MLXNN/Module.swift
func update(
parameters: ModuleParameters, verify: VerifyUpdate, path: [String] = [],
modulePath: [String] = [],
mutate: (Module, String, MLXArray, MLXArray) -> Void

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Normally we update the backing point in an MLXArray. For materialize() I want to replace the MLXArray instance with a MaterializedArray.

Comment thread Tests/MLXTests/MaterializedTests.swift Outdated
let x = MLXArray(10) + 5
let m = materialize(x)
let t = Task {
print(m + 3)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Tada, pass MLXArray (materialized) between isolation contexts! The compiler would give an error if we did this with x.

Comment thread Tests/MLXTests/MaterializedTests.swift Outdated

let t = Task {
let i = MLXRandom.normal([10, 10])
let r = lm(i)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The same thing with a Module.

Comment thread Package.swift Outdated
@davidkoski
davidkoski force-pushed the materialized-array branch 2 times, most recently from 2e0965e to 18106c8 Compare June 8, 2026 05:34
davidkoski added a commit to ml-explore/mlx-swift-lm that referenced this pull request Jun 9, 2026
- 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
open class MaterializedModule<LayerType: Module>: Module, @unchecked Sendable {

/// Usable by extensions to implement `callAsFunction()` -- anyone else, DO NOT USE.
public let _base: LayerType

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Question: would it make sense to avoid exposing this as public or at least rename it to something like _unsafeBase? Since callers can mutate the wrapper module through this reference.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good question! It has to be public because:

  1. the class can't be subclassed as it is final/Sendable (the former being a requirement for the latter)
  2. since it can't be subclassed it has to be extended and the extensions can only access public members

But renaming it _unsafeBase is a good idea!

I have a staged commit where it marks the held model as immutable -- that will give additional runtime protection.

The integration in mlx-swift-lm for Embedders was good but perhaps too simple. I need to do the same for LLM/VLM and see what pops up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is now exposed via _spi so callers can't see it but implementations can.

Comment thread Tests/MLXTests/MaterializedTests.swift Outdated
import MLX
import MLXNN
import Testing

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: It would be nice to add a unit test for MaterializedModule.parameters(), so it verify the wrapper still exposes the materialized parameter tree.

davidkoski added a commit to ml-explore/mlx-swift-lm that referenced this pull request Jun 18, 2026
- 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
@ronaldmannak

Copy link
Copy Markdown
Contributor

@davidkoski Is there any ETA on merging this PR? I'm working on a MLX-Swift-lm PR where this would be extremely useful

@davidkoski

Copy link
Copy Markdown
Member Author

I think I have tested out most of this in my mlx-swift-lm branch (not all of it is pushed), so I can focus on wrapping this up. I will try to get this part in this week.

@ronaldmannak

Copy link
Copy Markdown
Contributor

Great! Thanks David

@davidkoski
davidkoski force-pushed the materialized-array branch from 7030ed3 to f717b7f Compare July 8, 2026 17:36

// MARK: - Expressible by literals

extension MLXArray: ExpressibleByArrayLiteral {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This gets moved inside the class definition -- required for subclasses.

/// because `UnaryLayer` is not a `Module`.
///
/// The class is `open` so you could give it a try.
open class MaterializedModule<LayerType: Module>: IndentedDescription, @unchecked Sendable {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Note: this has been changed and is no longer a Module itself. That keeps the API surface smaller and means there are less confusing things that it exposes (like children() producing mutable Module instances).

In mlx-swift-lm (ml-explore/mlx-swift-lm#335) this worked out with a little surgery on the protocols.

Callers can still get a view of the parameters (now recast as MaterializedArray) and there is a common theme of counting the bytes for various reasons, now restored.

Comment thread Source/MLXNN/Module.swift
Comment on lines -406 to +414
public func update(parameters: ModuleParameters) -> Self {
public func update(parameters: ModuleParameters) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

These are source breaking changes, though I expect they were never used. This was matching the python implementation:

def update(self, parameters: dict, strict: bool = True) -> Module

I guess this would allow a caller to chain calls, maybe layer.update(...).notSure(). It was never used in mlx-swift-lm.

Technically this is no longer required -- it was a side effect of the original implementation of MaterializedModule being is-a Module. I think it is a worthwhile cleanup so I am leaving it in.

@davidkoski
davidkoski requested a review from angeloskath July 8, 2026 18:13
davidkoski added a commit to ml-explore/mlx-swift-lm that referenced this pull request Jul 10, 2026
- 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
davidkoski added a commit to ml-explore/mlx-swift-lm that referenced this pull request Jul 14, 2026
- 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
davidkoski added a commit to ml-explore/mlx-swift-lm that referenced this pull request Jul 20, 2026
- 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
davidkoski added a commit to ml-explore/mlx-swift-lm that referenced this pull request Jul 23, 2026
- 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
@davidkoski

Copy link
Copy Markdown
Member Author

@thechriswebb out-of-band PR feedback is addressed, thanks!

Comment thread Source/MLXNN/MaterializedModule.swift Outdated
///
/// ## Calling the wrapped module
///
/// `MaterializedModule`is not a `Module` subclass and cannot conform

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.

nit: missing space

Comment thread Source/MLXNN/MaterializedModule.swift Outdated
/// class MaterializedUnaryLayer: MaterializedModule<UnaryLayer> { ... }
/// ```
///
/// because `UnaryLayer` is not a `Module`.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I should clarify that. It is wrong as written -- what is true is that it isn't a concrete type and you can't specialize with a protocol.

Comment thread Source/MLXNN/MaterializedModule.swift Outdated
/// The initializer takes its base module as `consuming`:
///
/// ```swift
/// let lm = try MaterializedModule(Linear(10, 10))

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.

Outdated snippet? Init no longer throws

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yep, good catch

Comment on lines +364 to +370
open override var parameterCount: Int {
if biases != nil {
scales.size * groupSize * 2
} else {
scales.size * groupSize
}
}

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.

I may be reading this wrong, but I think scales.size * groupSize is already the unpacked weight count, so the doubling makes the number about twice as large as it should be. A quantized 1024x1024 layer reports 2097152 parameters, when the parameters it actually holds add up to 1082368. That is also more than the plain Linear it replaces, which reports 1049600.

I'm unsure about the condition too: biases is non-nil for every .affine layer, so it does not seem to separate out the layer bias. Did you mean to key on that instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

My thinking was: biases is optional, but if present it is the same size as the scales.

But, I think what you are asking is about what this is trying to represent. This isn't the absolute byte count, it is a representation of what the non-quantized Linear represents.

So I think you are correct that it means to count the bias property instead, not what is physically stored.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Note: this is currently used in some size estimates for memory pressure and printing some stats.

Comment on lines +229 to +231
open override var parameterCount: Int {
scales.size * groupSize
}

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.

Same question here. This one returns scales.size * groupSize while QuantizedLinear returns twice that for the same weights in .affine mode, so whichever way you want it counted, I think these two want to agree.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think this is correct as-is -- Embedding only has a weight and we are trying to represent that.

@davidkoski

Copy link
Copy Markdown
Member Author

@thechriswebb thank you for the feedback -- addressed in latest commit!

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.

4 participants