Skip to content

Zero-copy CPU import: mx.array(host_buffer, copy=False) on unified memory#3872

Open
HaoXuAI wants to merge 7 commits into
ml-explore:mainfrom
HaoXuAI:cpu-zero-copy-import
Open

Zero-copy CPU import: mx.array(host_buffer, copy=False) on unified memory#3872
HaoXuAI wants to merge 7 commits into
ml-explore:mainfrom
HaoXuAI:cpu-zero-copy-import

Conversation

@HaoXuAI

@HaoXuAI HaoXuAI commented Jul 19, 2026

Copy link
Copy Markdown

Motivation

When I tried loading Parquet/Arrow columns into MLX on Apple Silicon: mx.array(host_buffer) always copies the host bytes into MLX-owned memory. But on unified memory the CPU and GPU share the same DRAM, so that copy is avoidable — the GPU can address the existing bytes. Pure overhead for large resident columns (feature tables, embeddings).

What

A keyword-only copy on the array constructor. copy=False adopts a CPU host buffer (via Metal newBufferWithBytesNoCopy) instead of copying it.

import numpy as np, mlx.core as mx
a = np.random.rand(64_000_000).astype(np.float32)   # 256 MB

mx.array(a)              # copy  -> 4.2 ms
mx.array(a, copy=False)  # adopt -> 0.02 ms   (no data movement; shares the bytes)

(M4 Max. Default is copy=None — unchanged behavior.)

How

In the CPU branch of nd_array_to_mlx, wrap the host pointer with make_buffer and set_data, using a deleter that keeps the source alive and releases the wrapper via allocator::release. It raises (rather than silently copying) when Metal is unavailable, the source element size != dtype size, or the buffer can't be adopted.

Validation

All tests pass on a local Metal build (M4 Max): values, zero-copy sharing, dtype-mismatch raise, source-lifetime, and adopt-in-a-loop. Fork Linux CI green (lint / Fedora / ASAN / UBSAN); the macOS/Metal matrix only runs on the upstream repo.

Review questions

  • CPU adopt is Metal-build-only by design — acceptable, or would you prefer a different fallback than raising?

HaoXuAI added 2 commits July 18, 2026 23:36
Expose a copy kwarg on the array constructor and adopt page-aligned CPU host
buffers on unified memory instead of copying. In the CPU import branch, when
copy=False, wrap the host pointer via the Metal allocator
(newBufferWithBytesNoCopy) rather than throwing; fall back to an explicit error
when Metal is unavailable, the buffer is not page-aligned, or a dtype
conversion is required. A deleter keeps the source buffer alive for the
adopted array's lifetime.

Enables true zero-copy Arrow/NumPy -> MLX on Apple Silicon.
MLX maps some numpy dtypes to a different-width mlx dtype (e.g. float64 ->
float32), so the dst==src check does not catch a reinterpret mismatch. Add an
itemsize == size_of(dst_dtype) guard (mirroring the Metal path) before adopting;
otherwise copy=False raises. Verified: all zero-copy tests pass on a local Metal
build (M4 Max).
@HaoXuAI
HaoXuAI marked this pull request as ready for review July 19, 2026 18:37
HaoXuAI and others added 5 commits July 19, 2026 21:25
Empirically newBufferWithBytesNoCopy adopts non-page-aligned host buffers
correctly on current macOS, so the 'requires page alignment' comment was
overstated and the unaligned->raises test premise does not hold. Keep the
null-buffer check as a portable defensive fallback; generalize its message.
Verified: remaining 4 tests pass on a local Metal build (M4 Max).
The zero-copy CPU import deleter released the adopted buffer with
allocator::free(), which recycles it into the allocator's buffer cache. A
make_buffer() buffer wraps caller-owned external memory, so recycling it hands
that memory to an unrelated array on a later allocation -> corruption/crash
(surfaces after several iterations, e.g. a training loop). Use
allocator::release() as documented for make_buffer() buffers. Adds a
loop regression test that adopts fresh buffers and computes each iteration.

@angeloskath angeloskath left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That's great!

I refactored it a bit, the main point being to remove the copy argument from mx.array following also the discussion in #3531. We can use the no-copy cast via mx.asarray or mx.from_dlpack and mx.array always copies. If there is need we can add the copy in the array constructor later.

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