I wanted to make a normal macOS app for Krea 2, with no Python install and no server running in the background. The result is Krealize, but the interesting part for me was getting the model into a native app and making it behave on Macs with very different amounts of unified memory.
I started from MFLUX. MFLUX is a clean Python implementation of image models using Apple's MLX framework. It is intentionally quite direct, so it was a good reference for understanding the whole Krea 2 pipeline without going through a large framework.
Its Krea 2 implementation covers the Qwen text encoder, text fusion, the 28-block image transformer, the scheduler, and the Qwen-Image VAE. More importantly, it gave me a known-good output to compare against while porting.
Porting the MFLUX pipeline
This is a real C++ port, not Python embedded inside an app. I translated the MLX operations to the MLX C++ API and kept the order of operations close to MFLUX at first. That made the early version a little boring, but easier to check.
The native engine has four main parts: a tokenizer, weight loader, model implementation, and a small Objective-C++ bridge used by SwiftUI.
The tokenizer ended up being more work than I expeced. MFLUX can use the Hugging Face tokenizer from Python. I did not want to ship that dependency, so the C++ side reads tokenizer.json itself. It loads the vocabulary, BPE merge ranks, and added tokens, then does Qwen's byte encoding and pre-tokenization. A small difference here means a different prompt embedding, so even code that looks like boring string parsing can change the final image.
For weights, the engine opens the MFLUX-format safetensors shards directly with MLX. Quantized linear layers look for the weight, scale, and bias tensors and call quantized_matmul. Regular layers use a normal matmul. Keeping the MFLUX tensor names was useful because I did not need another conversion format just for the app.
The model code follows the same broad path as the Python version. First the prompt goes through Qwen. Krea 2 takes hidden states from several text layers, fuses them, and combines the result with the image tokens. The image transformer then runs its 28 blocks for each denoising evaluation. Finally the VAE turns the latent back into pixels.
Swift talks to this through an Objective-C++ class that owns one krea2::Engine. Keeping the engine alive matters. It means model weights and cached prompt embeddings can survive between generations instead of being loaded again every time. The bridge also installs the same serialized MLX CPU and GPU streams at each entry point, since later generations may arrive on a different dispatch worker.
At that point it worked, but it was not yet something I would give to somebody else. MLX is lazy, so a short line of C++ can build a very large graph without running it. That is great until the graph holds several blocks of temporary buffers and the Mac runs out of room.
What actually made it faster
The first optimisation was really a memory fix. The early engine used one fixed memory limit. I added a low-memory mode that could unload the text encoder after prompt encoding, unload the transformer before VAE decode, and clear the MLX cache between phases.
That worked, but a checkbox asking the user to understand model residency felt wrong. I replaced it with automatic hardware profiles for 16, 24, 32, 48, 64, and 96+ GB Macs.
On a 16 GB Mac, every transformer block is evaluated before moving to the next one. On 24 GB it stages every two blocks, and on 32 GB every four. The VAE is staged at decoder block boundaries too. Text, transformer, and VAE weights are unloaded when their phase is over.
With 48 GB or more, the engine can keep the components resident and submit the full 28-block transformer graph. The MLX wired-memory limit is still capped at 90 percent of Apple's recommended working set, with a separate reserve left for macOS. Intermediate memory sizes use the next lower profile instead of hoping for the best.
This staging only changes when arrays are evaluated and released. It does not change the seed, precision, dimensions, scheduler, or attention math.
LoRAs had another avoidable cost. The simple version ran two extra matrix multiplications for every LoRA on every affected layer:
y = xW + (xA1^T)B1^T + (xA2^T)B2^T + ...
Adapters with the same dtype can be joined along their rank dimension. After concatenating the A matrices vertically and the scaled B matrices horizontally, the same sum becomes:
y = xW + (x[A1; A2; ...]^T)[s1B1, s2B2, ...]^T
So N LoRAs go from 2N matmul launches to two launches per dtype pair. Five normal adapters now have the same launch count as one. If only a LoRA strength changes, I rescale its slice in the already fused B matrix instead of reopening every file and rebuilding everything. That also avoids briefly keeping two full fused adapter sets in memory.
Attention was another large temporary allocation. The image transformer has 48 query heads and 12 key/value heads. My first C++ version repeated K and V four times to make the head counts match. MLX's scaled dot product attention can handle grouped-query attention directly, so I removed those copies. This avoids materializing four versions of K and V for every image attention layer.
I did not make the same change in the Qwen text encoder. Its grouped-query path produced small numeric differences from the MFLUX reference, and a short distilled generation can make those differences visible. The text path still expands K and V, while the much larger image path uses native grouped-query attention. slightly untidy, but it keeps the useful optimisation without changing the 1024px reference output.
Then I looked for work that was repeated across denoising steps. Prompt fusion does not depend on the timestep. The spatial rotary position table does not either. The first version rebuilt both inside each transformer call. They are now evaluated once per generation and reused for every step.
On Macs with 48 GB or more, I also pass the full transformer through mx::compile. This lets MLX fuse some of the graph overhead. I do not enable it on the smaller profiles because those need explicit evaluation points between blocks to keep peak memory under control. One setup was not best for every Mac.
Krea 2 Turbo is an eight-step distilled model, and the transformer call is by far the expensive part of a step. The current scheduler reuses the previous prediction at two selected mid and late steps. That reduces eight scheduler steps to six real denoiser evaluations. The shortcut is only enabled for the exact combination it was tested with: eight steps, the ER-SDE scheduler, and guidance 1.0. It is not a general "skip some steps" flag.
Fast mode does one more, much simpler thing. It renders at 90 percent of the requested width and height, rounded to a multiple of 16, then resizes the result. Unlike memory staging, this one is a real speed versus detail tradeoff, so it is kept as a separate mode.
I added a command-line benchmark before doing more tuning. It can measure cold, prepared, and hot runs, and records prompt time, every denoiser evaluation, VAE time, MLX peak memory, process footprint, thermal state, and a SHA-256 of the output. Fixed seeds and output hashes make it obvious when an "optimisation" is fast only because it quietly changed the image.
With the current version, a 1024 x 1024 image takes around 24 seconds in fast mode on an M5 Max MacBook Pro with 48 GB. An M5 MacBook Air with 24 GB takes around 2 minutes and 25 seconds with fast mode off. Those are two machines, not a universal benchmark, but the gap is a good example of why the memory profiles exist.
there are still more things I could fuse or compile, but I have been fairly conservative with them. For this kind of model, a change that saves a few seconds is not useful if it adds a memory spike or slowly moves the output away from the reference.
Krealize is available at krealize.app. I would be interested in benchmark results from other Apple silicon Macs, especially 16 and 24 GB models.
Top comments (1)
A native Mac app around Krea 2 is a nice niche — congrats on shipping.
Quick public check of krealize.app (headers + public config only):
Header always set ...lines, ~6 lines total. Start with HSTS (max-age=31536000; includeSubDomains) and X-Frame-Options SAMEORIGIN; a tight CSP is worth the extra care since it can break inline scripts if added blindly.TLS 1.3 with 85 days on the cert, exactly one h1, canonical, robots.txt and sitemap.xml are all in good shape, and TTFB is 151ms. Happy to re-run the scan free once the headers land. Good luck with the app!