AI HANDS-ON

Free AI Image Generation with MLX and mflux (No GPU Rental)

Published on 2026-04-16

Running state-of-the-art AI image generation entirely on your Apple Silicon Mac is now a reality. Best of all, you can do it with zero cloud costs and zero GPU rental fees.

In this hands-on walkthrough, we will cover how to run FLUX.1 Schnell, a state-of-the-art model released by Black Forest Labs (the creators of the original Stable Diffusion architecture). Schnell is a distilled variant of the FLUX model that compresses what normally takes 20+ denoising steps down to just 4 steps, without sacrificing image quality.

We will run this pipeline using Apple's own MLX framework paired with mflux—a lightweight, native MLX port of the FLUX diffusion pipeline.


Why MLX on Apple Silicon?

Unlike PyTorch MPS (Metal Performance Shaders), which frequently marshals tensors back and forth across CPU and GPU memory boundaries, MLX is designed from the ground up for Apple Silicon’s Unified Memory Architecture (UMA).

With MLX:

  • Memory operations happen zero-copy. Both CPU and GPU read and write to the same physical memory space.
  • Overhead simply doesn't exist, leading to 1.5x to 2.5x faster inference speeds compared to an equivalent PyTorch MPS setup.

By the end of this guide, you will be generating photorealistic 1024x1024 images, adjusting guidance scales, and rendering vertical 9:16 portrait images (perfect for YouTube Shorts or mobile wallpapers) on a 16GB M4 Mac.


1. Setting Up the Environment

This project uses uv—a modern Python package and project manager written in Rust. It replaces pip, venv, and pip-tools with a single, blazingly fast executable.

Step 1: Sync Dependencies

All required dependencies are declared in pyproject.toml (including mlx, mflux, huggingface_hub, Pillow, and the Jupyter stack).

Open your terminal in the project directory and run:

uv sync

This single command:

  1. Creates an isolated virtual environment at .venv/ inside the project.
  2. Resolves the full dependency tree from your configuration.
  3. Installs all packages without requiring manual virtual environment activation.

(On a cold cache, the first run takes 60–90 seconds due to native MLX extensions; subsequent syncs complete in milliseconds.)

Step 2: Register the Jupyter Kernel

To configure VS Code to use this local virtual environment, register it as a Jupyter kernel using uv run:

uv run python -m ipykernel install --user --name prompt_shot --display-name "Python (prompt_shot)"

Now, open the notebook in VS Code and select Python (prompt_shot) as your kernel in the top-right corner. (If it doesn’t appear, reload your window using Cmd + Shift + P -> Developer: Reload Window).

To clean up later, you can simply delete the environment folder:

rm -rf .venv

2. Dependencies & Hardware Verification

Imports and Warnings

We begin by importing standard helper utilities and configuring warnings to suppress noisy tokenizer updates:

import os
import platform
import subprocess
import time
from pathlib import Path
import warnings

warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", category=UserWarning)

try:
    import mlx.core as mx
    from mflux import Flux1, ModelConfig
except ImportError as e:
    raise ImportError("Dependencies missing. Please make sure you have run `uv sync`.") from e

print(f"MLX version: {mx.__version__}")
print(f"Default Device: {mx.default_device()}")

Sanity Check: On Apple Silicon, mx.default_device() should output device(gpu, 0). If you see cpu, double-check your MLX build.


3. Hugging Face Authentication

Because FLUX.1 Schnell is a gated model, you must accept Black Forest Labs' terms of use on the Hugging Face Hub before you can download its weights.

We use a .env file to safely manage access tokens instead of hardcoding credentials in the code:

from dotenv import load_dotenv
from huggingface_hub import login, HfApi

load_dotenv()
token = os.getenv("HF_TOKEN")

if token:
    # We set add_to_git_credential=False to avoid polluting the host system config
    login(token=token, add_to_git_credential=False)
    api = HfApi()
    user_info = api.whoami()
    print(f"Authenticated successfully as: {user_info['name']}")
else:
    print("HF_TOKEN not found in .env file. Please check your credentials.")

4. Tuning Hardware & Threading Configurations

To maximize inference performance, we must set strict hardware boundaries:

  • Architecture Check: Validate the machine architecture is arm64.
  • RAM Requirement: Ensure at least 16GB of unified memory is available. The 4-bit quantized FLUX weights occupy roughly 4GB, but intermediate tensor buffers and key-value (KV) caches scale total memory usage to 10–12GB.
  • Thread Tuning: Align threading variables with the performance cores of the CPU. Scheduling heavy matrix operations on efficiency cores actually introduces bottlenecks due to lower bandwidth.
import sys

# 1. Hardware Assertions
assert platform.machine() == "arm64", "MLX requires an Apple Silicon (ARM64) processor."

# Read memory via sysctl
mem_bytes = int(subprocess.check_output(["sysctl", "-n", "hw.memsize"]))
mem_gb = mem_bytes / (1024 ** 3)
assert mem_gb >= 16.0, f"FLUX inference requires at least 16GB RAM. Detected: {mem_gb:.1f}GB"

# 2. Performance Thread Configuration
# For chips like the M4, limit calculations to performance cores
P_CORES = 4
os.environ["OMP_NUM_THREADS"] = str(P_CORES)
os.environ["MKL_NUM_THREADS"] = str(P_CORES)
os.environ["VECLIB_MAXIMUM_THREADS"] = str(P_CORES)
os.environ["TOKENIZERS_PARALLELISM"] = "false"

print(f"System memory: {mem_gb:.1f} GB. Threading optimized for {P_CORES} performance cores.")

5. Metal GPU Benchmarking

Before committing to a heavy model download, we verify that Metal-accelerated matrix operations are executing correctly. We measure a 2048 x 2048 matrix multiplication in MLX against the standard NumPy CPU implementation (which uses Apple's highly-optimized Accelerate vecLib BLAS framework).

import numpy as np

# MLX uses lazy evaluation. We use mx.eval() to force shader compilation 
# and actual command execution before starting the timer.
a_mx = mx.random.normal((2048, 2048))
b_mx = mx.random.normal((2048, 2048))
mx.eval(a_mx, b_mx)

# 1. Benchmark MLX (Metal GPU)
t0 = time.perf_counter()
for _ in range(5):
    c_mx = mx.matmul(a_mx, b_mx)
    mx.eval(c_mx)
mlx_time = (time.perf_counter() - t0) / 5

# 2. Benchmark NumPy (vecLib CPU)
a_np = np.random.randn(2048, 2048).astype(np.float32)
b_np = np.random.randn(2048, 2048).astype(np.float32)
t1 = time.perf_counter()
for _ in range(5):
    c_np = np.matmul(a_np, b_np)
numpy_time = (time.perf_counter() - t1) / 5

print(f"MLX (Metal GPU) average time: {mlx_time*1000:.2f} ms")
print(f"NumPy (vecLib CPU) average time: {numpy_time*1000:.2f} ms")
print(f"Metal speedup factor: {numpy_time / mlx_time:.2fx}")

assert numpy_time / mlx_time >= 1.5, "Metal GPU acceleration is underperforming. Re-check MLX configuration."
print("Metal GPU acceleration confirmed.")

6. Initializing FLUX.1 Schnell

Here, we load the distilled model using 4-bit quantization (quantize=4). This parameter scales down memory consumption by nearly 4x, shrinking the model weights from 16GB down to roughly 4GB.

from mflux.models.flux1.variants.txt2img.flux1_schnell import Flux1Schnell

# Initialize distilled 4-step Schnell model
try:
    model_config = ModelConfig.flux1_schnell_4b()
    flux = Flux1Schnell(
        model_config=model_config,
        quantize=4
    )
    print("FLUX.1 Schnell successfully loaded into Unified Memory.")
except Exception as e:
    raise RuntimeError(
        "Failed to load FLUX.1. Ensure you have accepted the model terms on Hugging Face."
    ) from e

(The framework caches download files locally at ~/.cache/huggingface/hub so subsequent initializations will boot instantly.)


7. Square Image Generation & Guidance Tuning

Let's test image generation at 1024 x 1024 pixels. We will generate two images with the exact same seed to observe the impact of the Guidance Scale (Classifier-Free Guidance / CFG).

  • Guidance 1.0 (Default): Yields higher variety and creative style, but might drift slightly from highly detailed prompts.
  • Guidance 3.5 (Recommended): Steers the diffusion process more strictly to match your prompt descriptions closely.
from PIL import Image

PROMPT = "A cinematic shot of a futuristic neon city in the rain, hyperdetailed, photorealistic, 8k resolution"
SEED = 42
STEPS = 4

# Output directory
output_dir = Path("outputs")
output_dir.mkdir(exist_ok=True)

# 1. Low Guidance (1.0)
img_1 = flux.generate_image(
    prompt=PROMPT,
    seed=SEED,
    num_inference_steps=STEPS,
    guidance=1.0
)
img_1.image.save(output_dir / f"output_guidance_1.0_{int(time.time())}.png")

# 2. High Guidance (3.5)
img_2 = flux.generate_image(
    prompt=PROMPT,
    seed=SEED,
    num_inference_steps=STEPS,
    guidance=3.5
)
img_2.image.save(output_dir / f"output_guidance_3.5_{int(time.time())}.png")

8. Vertical Rendering (9:16 Aspect Ratio)

Because generative diffusion architectures assume balanced spatial matrices, scaling up to full 1080 x 1920 portrait dimensions exceeds unified memory boundaries and triggers OS swapping.

Instead, we use 576 x 1024, which is optimized to remain under 14GB peak memory while keeping dimensions as multiples of 16 (required for the Variational Autoencoder / VAE downsampler).

SHORTS_PROMPT = "A close up portrait of an astronaut exploring a lush jungle planet, detailed spacesuit, bioluminescent plants, vertical layout"
SHORTS_HEIGHT = 1024
SHORTS_WIDTH = 576

shorts_img = flux.generate_image(
    prompt=SHORTS_PROMPT,
    seed=SEED,
    num_inference_steps=STEPS,
    guidance=3.5,
    height=SHORTS_HEIGHT,
    width=SHORTS_WIDTH
)
shorts_img.image.save(output_dir / f"shorts_output_{int(time.time())}.png")
print("Vertical 9:16 layout generated successfully.")

9. Batch Production Loop

To render multiple prompts, we run our inference loop sequentially.

While parallel GPU queuing seems faster on paper, generating multiple 1024x1024 frames concurrently will quickly exceed 16GB of RAM, triggering system instability. Sequential loop execution is the safest strategy on consumer hardware.

prompts = [
    "A majestic lion wearing a golden crown, digital art, dark background",
    "A cozy wooden cabin surrounded by pine trees in winter, warm glowing lights inside, oil painting style",
    "A futuristic flying car gliding through clean energy skyscrapers, bright sunny day"
]

batch_outputs = []
for idx, prompt in enumerate(prompts):
    print(f"Generating batch item {idx+1}/{len(prompts)}...")
    result = flux.generate_image(
        prompt=prompt,
        seed=idx, # Distinct starting noise seed
        num_inference_steps=STEPS,
        guidance=3.5
    )
    
    file_path = output_dir / f"batch_item_{idx+1}_{int(time.time())}.png"
    result.image.save(file_path)
    batch_outputs.append(file_path)

print(f"Batch completed. Saved {len(batch_outputs)} images to outputs/")

Conclusion

By executing these steps, you have successfully verified your local hardware constraints, established benchmark thresholds, configured a quantized model representation, and built custom square, portrait, and batch generation routines.

Running AI models locally grants you absolute privacy, eliminates cloud latency, and lets you experiment with state-of-the-art models for free, leveraging the full capability of Apple Silicon and Metal.