MLX Data

repository·main·Indexed 19 days ago

https://github.com/ml-explore/mlx-data

A high-performance, framework-agnostic data loading library for Python and C++ designed for efficient processing of large datasets. Compatible with MLX, PyTorch, and JAX, it provides a functional, chainable API for building data pipelines using Buffers (indexable containers for random access) and Streams (iterables for large or remote datasets). Features include lazy evaluation, prefetching, and built-in transformations for image loading and resizing.

Tokens
14.3K
Snippets
49
Records
72
Agent score
66%

What's inside mlx-data

  1. Overview of MLX Data

    main

    MLX Data is a framework-agnostic data loading library developed by Apple Machine Learning Research. It is designed to facilitate high-performance data loading for machine learning training or standalone data pre-processing.

    Key characteristics:

    • Framework Agnostic: Compatible with PyTorch, JAX, or MLX.
    • Multi-threaded: Leverages multiple threads for data processing pipelines, avoiding the complexity of multi-process management or symbolic languages.
    • Python-centric: You can use standard Python to implement processing logic, handle data transformations, or cause side effects directly within the pipeline.
  2. Compare mlx.data with PyTorch and TensorFlow data loaders

    main

    The benchmarks/comparative directory contains performance comparisons between mlx.data, PyTorch DataLoaders, and tf.data. These benchmarks aim to demonstrate the conciseness and speed of mlx.data.

    Note that these are comparative benchmarks and may not reflect real-world performance for your specific use case.

  3. How Stream composition and state work

    main

    A Stream acts like an iterator. When you compose streams (e.g., by creating evens from numbers), the new stream is a pointer to the underlying source. Advancing the base stream will also advance any derived streams.

    If the underlying data source supports it, you can use .reset() to restart the stream from the beginning.

    import mlx.data as dx
    
    numbers = dx.stream_python_iterable(lambda: ({"x": i} for i in range(10)))
    evens = numbers.sample_transform(lambda s: s if s["x"] % 2 == 0 else dict())
    
    # Advancing 'numbers' affects 'evens'
    print(next(numbers)) # {'x': 0}
    print(next(evens))   # {'x': 2} (skips 1 because it was filtered)
    
    # Resetting the stream
    evens.reset()
    print(next(evens))   # {'x': 0}
  4. Use a Buffer to manage indexable samples

    main

    A Buffer in mlx.data is an indexable container of samples. It behaves similarly to a Python list, allowing for random access and transformations that require knowing the total size or order of the data. You can use key_transform to apply a function to a specific key across all samples in the buffer.

    import mlx.data as dx
    
    # Create a buffer from a list of dictionaries
    numbers = dx.buffer_from_vector([{"x": i} for i in range(10)])
    
    # Apply a transformation to the key 'x'
    evens = numbers.key_transform("x", lambda x: 2*x)
    
    print(evens)
    # prints Buffer(size=10, keys={'x'})
    
    print(evens[3])
    # prints {'x': array(6)}
    
    print(len(evens))
    # prints 10
  5. What are Samples in MLX Data

    main

    In MLX Data, a sample is the fundamental unit of data. It is a dictionary that maps string keys to array values.

    • In Python: A dictionary where keys are strings and values are anything implementing the buffer protocol (e.g., NumPy arrays, scalars, or bytes).
    • In C++: An instance of mlx::data::Sample, which is a std::unordered_map<std::string, std::shared_ptr<mlx::data::Array>>.

    Commonly used types for values include:

    • Arrays: e.g., np.array(0).
    • Scalars: These are automatically cast to scalar arrays.
    • Strings: Represented as Unicode.
    • Bytes: Recommended for paths or raw data (e.g., b"path/to/file").
    # Valid samples
    sample = {"hello": np.array(0)}
    sample = {"scalar": 42}
    sample = {"key": "value"}
    
    # Recommended way to handle paths/bytes
    sample = {"key": b"path/to/my/file"}
    sample = {"key": "value".encode("ascii")}
  6. Use feature extraction utilities in MLX data pipelines

    main
    The mlx.data.features submodule provides feature extraction utilities designed to be used as key_transform functions within MLX data pipelines. While these utilities are implemented in NumPy for flexibility, they are intended to be integrated into the pipeline transformation flow to extract meaningful features from raw data.
  7. How Streams work in MLX Data

    main

    A Stream is a potentially infinite iterable of samples. Streams are used when datasets are too large for memory, stored remotely, or have nested structures that prevent random access.

    Key characteristics:

    • Nesting: Unlike buffers, streams allow nesting. For example, a stream of CSV filenames can be transformed into a stream of individual lines.
    • Lazy Execution: Like buffers, stream operations are executed only when the sample is accessed.
    • Creation: Streams can be created from files using stream_csv_reader or stream_line_reader, or by converting a buffer using Buffer.to_stream().
    • Prefetching: Streams support non-deterministic prefetching via .prefetch(num_batches, num_threads) to improve iteration efficiency.
    # Example: Creating a stream from a buffer and applying transformations
    dset = (
        buffer_instance
        .shuffle()
        .to_stream()
        .batch(32)
        .prefetch(8, 4)  # prefetch 8 batches using 4 threads
    )
    
    sample = next(dset)
  8. Transform data using key_transform and sample_transform

    main

    You can manipulate data within a pipeline using two primary transformation methods:

    1. key_transform(key, func): Applies a function to a specific key within the sample dictionary. This is useful for operations like type casting, reshaping, or normalization on specific fields (e.g., an "image" or "audio" key).
    2. sample_transform(func): Applies a function to the entire sample dictionary. If the function returns an empty dictionary {}, the sample is dropped from the stream. This is useful for filtering samples based on certain criteria.

    Note on the GIL: Python functions passed to these transforms run under the Global Interpreter Lock (GIL). To maintain high throughput, use optimized libraries like NumPy within your transforms or keep the Python logic minimal. Some heavy computations (like FFTs in certain feature extractors) may be implemented to run with the GIL released.

    # Transform a specific key (e.g., normalizing images)
    dset = dset.key_transform("image", lambda x: x.astype("float32") / 255)
    
    # Filter samples (drop samples where length <= 10)
    dset = dset.sample_transform(lambda s: s if s["length"] > 10 else dict())
  9. How Buffers work in MLX Data

    main

    A Buffer is an indexable container of samples with a known length. Buffers support random access, shuffling, and iteration.

    Key characteristics:

    • Lazy Evaluation: Operations on buffers (like .load_image()) create new buffers where transformations are only executed when a sample is actually accessed, preventing unnecessary memory usage.
    • Creation: The easiest way to create a buffer is using dx.buffer_from_vector(list_of_samples).
    • Deterministic Prefetching: If you need deterministic prefetching, use Buffer.ordered_prefetch instead of converting to a stream.

    Buffers are designed to be easily portable between Python and C++ as the APIs are mirrored.

    import mlx.data as dx
    from pathlib import Path
    
    # Example: Creating a buffer from a list of dictionaries
    def files_and_classes(root: Path):
        # ... logic to return list of dicts ...
        return [{"image": b"path.jpg", "label": 0}] 
    
    dset = dx.buffer_from_vector(files_and_classes(Path("path/to/dataset")))
  10. Use conditional operations in data pipelines

    main

    To avoid complex redirection logic when configuring pipelines based on command-line arguments or configuration settings, mlx.data provides conditional variants for almost all transformation methods.

    Every transformation method has a corresponding *_if(cond: bool, *args, **kwargs) variant. This allows you to write linear, top-to-bottom pipelines where an operation is only applied if the provided cond evaluates to True.

    # Assuming we have a buffer with image files and labels in dset
    dset = (
        dset
        .load_image("image_file", output_key="image")
        .image_random_crop_if(enable_random_crop, "image", 256, 256)
        .image_random_h_flip_if(flip_prob > 0, "image", flip_prob)
        .key_transform_if(brightness_range > 0, "image",
                          lambda x: ((1 + brightness_range * np.random.rand(x.shape[:2])[..., None]) * x).astype(x.dtype))
    )
  11. Compare Buffers vs Streams

    main

    Choosing between a Buffer and a Stream depends on your data access requirements:

    FeatureBufferStream
    AccessRandom access (indexable)Sequential (iterable)
    LengthKnown/FinitePotentially infinite
    NestingNot supportedSupported (e.g., file $\rightarrow$ lines)
    Use CaseShuffling, random samplingLarge/Remote datasets, streaming files
    Prefetchingordered_prefetch (deterministic)prefetch (non-deterministic)
  12. Apply transformations to Buffer and Stream

    main
    In mlx.data, both Buffer and Stream objects support applying transformations to samples when they are accessed. While Buffer and Stream have different specific methods, they share a common API for transformations. You can chain these operations to build a data pipeline.