[AI] My Fine-Tuned Model Got Faster By Being Rebuilt — Getting A LoRA Onto A 32GB Mac Without Paying For It
Situation
In The Last Post I Fine-Tuned Muse Glimmer 30B To Call Tools Better, Found It Calling Tools Everywhere, Added Negative Examples, And Picked Checkpoint 200 Out Of The Second Run. It Ended With One Honest Line: I Hadn’t Merged Or Shipped Any Of It.
This Post Is The Shipping Part. The Adapter Was Trained With PEFT On A Single 48GB GPU. The Model Actually Runs On A 32GB Mac Studio, Through MLX, As A 4-Bit Model Behind A Small Shim Service. Those Two Worlds Don’t Share A File Format, And The Mac Doesn’t Have Much Room To Spare.
Result First:
| Goal | Run The ckpt-200 Fine-Tune On The Mac At The Same Speed As The Original 4-Bit Model |
| First Try | Convert The PEFT Adapter To MLX And Load It On Top Of The 4-Bit Base — 10/10 Correct, But 8.3 Tok/s Vs 14.8 |
| What Fixed It | Merge The LoRA Into The bf16 Weights, Then Quantize The Merged Model To 4-Bit Again |
| Gotcha | The Linux MLX Converter Wrote All The Weights And Then Crashed Saving The Processor Config |
| Outcome | Merged 4-Bit Model At 15.0 Tok/s (Base 14.8), 9/10 On The Same 10 Questions Through The Real Service |
Getting The Adapter To Load Was An Afternoon. Getting It To Run Without Costing Half The Speed Meant Rebuilding The Whole Model — And Then Fighting launchd To Actually Use It.
How It Went
Try The Cheap Way First: Just Bring The Adapter
The Adapter Is 210MB. The Merged Model Would Be 56GB In bf16. So The Obvious First Move Was To Ship Only The Adapter And Load It On Top Of The 4-Bit Model The Mac Already Had.
PEFT And MLX Store The Same LoRA Differently:
| PEFT | mlx-vlm | |
|---|---|---|
| Key | ...language_model.layers.N.<module>.lora_A.weight |
language_model.model.layers.N.<module>.lora_a |
| A Matrix | [r, in] |
[in, r] |
| B Matrix | [out, r] |
[r, out] |
| Scale | alpha / r In The Config |
scale In adapter_config.json |
So The Converter Is Mostly Renaming And Transposing. I Wrote It With Plain NumPy Reading And Writing Safetensors, Kept The bf16 Bits As They Were, And Listed All 416 Target Modules Explicitly Instead Of Relying On The “Last N Layers” Default. The Test That Mattered: Feed The Same Small Random Input Through The PEFT Math And The MLX Math And Check The LoRA Delta Is Identical.
One Trap: Loading Adapter Weights With strict=False Never Complains. If Half The Names Didn’t Match, It Would Load Silently And Do Nothing. I Compared All 832 Adapter Tensors Against The Model’s LoRA Parameters One By One. Zero Missing, Zero Shape Mismatches.
The Mac Didn’t Have Room To Test It
The Mac Studio Has 32GB. The Running Shim Alone Holds About 19GB, Another Local App And A VM Take About 16GB More, And Swap Was Already Around 25GB. Loading A Second Copy Of The Model To Test Would Have Dragged The Live Service Down With It.
So I Stopped The Shim For About 12 Minutes, Ran The Test In A Separate Process, And Started It Again.
Right, But Slow
10 Questions From The Out-Of-Distribution Set, Two From Each Group:
| Group | Expected | Training Box (ckpt-200) | Mac 4-Bit + Adapter | Mac 4-Bit Base |
|---|---|---|---|---|
irrelevance ×2 |
No Call | ✓✓ | ✓✓ | ✓✓ |
live_irrelevance ×2 |
No Call | ✓✗ | ✓✓ | ✓✓ |
| Parallel ×2 | Call | ✓✓ | ✓✓ | ✗✗ (One Call, Then None) |
| Chinese Decoys ×2 | No Call | ✓✓ | ✓✓ | ✓✗ (Called 3 Tools) |
| Chinese Should-Call ×2 | Call | ✓✓ | ✓✓ | ✓✓ |
| Decision | 9/10 | 10/10 | 8/10 |
Running On A 4-Bit Base Instead Of The Base It Was Trained On Didn’t Visibly Hurt Anything Here. Ten Questions Only Tell You It Isn’t Broken, Not That It’s Better.
The Speed Was The Problem:
| Time To First Token (Median) | Generation (Median) | |
|---|---|---|
| 4-Bit Base | 6.8s | 14.8 Tok/s |
| 4-Bit Base + Unmerged Adapter | 7.1s | 8.3 Tok/s |
An Unmerged LoRA Adds Two Small Matrix Multiplications To Every Targeted Layer On Every Token. On A Machine That’s Already Memory-Bound And Swapping, That Cost About 44% Of Generation Speed.
The Fix
Merge, Then Quantize Again
Merging The LoRA Into The Weights Makes The Extra Math Disappear. But You Can’t Cleanly Merge Into Weights That Are Already 4-Bit, So The Path Was:
- Merge The Adapter Into The bf16 Base With PEFT’s
merge_and_unload(CPU Only, Capped At 8 Cores, About 3 Minutes, About 56GB Out). - Quantize The Merged Model To MLX 4-Bit With The Same Settings As The Community 4-Bit Build (
--q-bits 4 --q-group-size 64). - Get The Result Onto The Mac.
The Converter Crashed At The Very End
I Ran The MLX Conversion On Linux With The CPU Build Of MLX, Because The Mac Had No Room For 56GB Of bf16 Weights. It Wrote All Four Weight Shards, About 18.2GiB, And Then Died Saving The Processor Config:
TypeError: Object of type ModelConfig is not JSON serializable
The Weights Were Fine. What Was Broken: processor_config.json Was Empty And config.json Was Missing The quantization Block. Since The Architecture Hadn’t Changed, I Copied Those Two Files From The Community 4-Bit Build And Kept The Original Merged Config Next To Them For Reference.
One Real Difference From The Community Build: Three Vision-Side Tensors Weren’t Quantized And Stayed bf16. Everything On The Text Side Matches. It Costs A Few Tens Of MB And Doesn’t Touch Tool Calling.
Moving 18GB To A Machine With 23GB Free
The Laptop In Between Had Only 12GB Free, So Nothing Could Land There. I Streamed It Straight Through:
# Source → Laptop → Mac, Nothing Written On The Laptop
<remote-exec> tar cf - -C /path/to/mlx-4bit . \
| ssh mac 'mkdir -p ~/models/new-4bit && tar xf - -C ~/models/new-4bit'
At About 25MB/s That Took Around 13 Minutes. Then The Checksums Didn’t All Match — The Last Two Small Files Had Been Truncated. Resending Those Two Fixed It, And All 10 Files Matched By sha256. A Lazy Load With The Shim’s Own Environment Saw All 2,272 Tensors And A Working Processor.
The Mac’s Data Disk Was Left At 99%, Which Is Its Own Problem For Later.
Switching, And The Restart That Didn’t Restart Anything
The Shim Already Reads The Model Path From An Environment Variable, So Switching Should Have Been One Line In The launchd Plist Plus A Restart. It Wasn’t:
- The Shim’s
restartUseslaunchctl kickstart -k, Which Restarts The Process With The Job Definition launchd Already Has Loaded. The New Variable Was Never Read. - The Shim’s
startRegenerates The Plist From A Template, Which Would Have Overwritten The Variable I Just Added.
What Actually Works Is Editing The Installed Plist And Reloading The Job Definition:
/usr/libexec/PlistBuddy -c "Add :EnvironmentVariables:MODEL_PATH string /path/to/new-4bit" \
~/Library/LaunchAgents/<shim>.plist
launchctl bootout gui/$(id -u)/<shim>
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/<shim>.plist
About 30 Seconds Of Downtime, 21 Seconds Of That Loading The Model. The Health Check Reported The New Path And loaded: true.
Then The Same 10 Questions, This Time Through The Shim’s Real API Instead Of A Test Script:
| Decision | Generation (Median) | |
|---|---|---|
| 4-Bit Base | 8/10 | 14.8 Tok/s |
| 4-Bit Base + Unmerged Adapter | 10/10 | 8.3 Tok/s |
| Merged, Re-Quantized 4-Bit | 9/10 | 15.0 Tok/s |
The Speed Came All The Way Back. The One Miss Was A Parallel Question Where The Model Made One Call Instead Of Two. Scoring Here Only Compares Tool Names And Call Counts From The Shim’s Parsed Output, Not Arguments. The Median Question Took About 14 Seconds End To End, And The First One About 39 Because Of Warm-Up.
9 Versus 10 On Ten Questions Is One Question. I’m Not Reading Anything Into It Yet.
Rolling Back Is Restoring The Backed-Up Plist And Doing The Same bootout / bootstrap.
Notes
- A 210MB Adapter Isn’t Free At Inference Time. It’s Cheap To Move And Expensive To Run Unmerged — On This Machine, About 44% Of Generation Speed.
strict=FalseWill Happily Load Nothing. If You Convert Adapter Names By Hand, Count The Matches Yourself.- You Can’t Merge Into 4-Bit Weights, So You Re-Quantize The Whole Model. The Adapter Is Tiny, The Path To Shipping It Fast Goes Through The Full bf16 Model Anyway.
- “Wrote All The Weights, Then Crashed” Is Still A Broken Model. Check The Configs, Not Just The Shard Sizes.
- Checksum Every Streamed File. The Big Ones Arrived Fine. The Two Small Ones At The End Didn’t.
- A Service’s
restartMay Not Reload Its Config.launchctl kickstart -kRestarts The Process, Not The Job Definition. Environment Changes NeedbootoutAndbootstrap. - 10 Questions Prove It Isn’t Broken, Nothing More. The Real Comparison Is Still The 365-Question Out-Of-Distribution Set From The Last Post, And I Haven’t Re-Run It On The Merged 4-Bit Model.
Which One To Use
| Situation | What To Do |
|---|---|
| You Want To Try A LoRA Quickly On Apple Silicon | Convert The Adapter And Load It Unmerged — Smallest Transfer, Easiest Rollback |
| You’ve Decided To Keep It | Merge Into bf16 And Re-Quantize, So You Don’t Pay The Per-Token Cost Forever |
| The Target Mac Can’t Hold The bf16 Model | Merge And Quantize Somewhere Else, Ship Only The 4-Bit Result |
| You Convert Or Stream Big Model Files | Verify Tensor Names, Configs And sha256 Before Pointing A Service At Them |
| You Change A launchd Service’s Environment | Edit The Installed Plist, Then bootout And bootstrap — Don’t Trust The Wrapper’s restart |
Reference: