Guide · AI engineering

Memory like a 30B, speed like a 3B

A mixture-of-experts model breaks the usual rule that bigger means slower. A 30B MoE with 3B active parameters needs the RAM of a 30B model and generates at roughly the speed of a 3B one. That trade is why a laptop can run something genuinely useful. Here is the memory arithmetic, the quantization tradeoffs, the Ollama settings that actually matter, and an honest account of when the local model is good enough and when it is not.


What mixture-of-experts actually changes

A dense transformer runs every parameter for every token. A 30B dense model at 4-bit quantization has to read roughly 17 GB of weights out of memory to produce one token, and memory bandwidth — not compute — is what limits generation on a laptop. That is why dense 30B models feel slow on Apple Silicon regardless of how fast the chip is.

A mixture-of-experts model replaces the feed-forward block in each layer with a set of parallel "experts" and a small router that picks a handful of them per token. Qwen3-30B-A3B, the model I run daily, has about 30.5B total parameters and activates roughly 3B of them for any given token. Different tokens route to different experts, so all 30B must be resident and available — but only ~3B are read per token.

Dense 30B @ Q4: ~17 GB resident, ~17 GB read per token MoE 30B-A3B @ Q4: ~17 GB resident, ~2 GB read per token RAM footprint: set by TOTAL parameters Generation speed: set by ACTIVE parameters

So the headline: you pay 30B prices in RAM and get something much closer to 3B speeds in generation. That is the entire reason a 30B-class model is usable on a laptop at all, and it is why "how many parameters" stopped being a single useful number.

The routing has a cost the arithmetic hides. Which experts a token needs is only known at generation time, and consecutive tokens often need different ones, so memory access is scattered rather than sequential. Real throughput lands well below the bandwidth ceiling the numbers above suggest — expect the ratio to help enormously and not to be the 8x the raw parameter math implies.

Unified memory: the Apple Silicon quirk

On Apple Silicon, CPU and GPU share one pool of memory. There is no separate VRAM to fill and no PCIe transfer to pay for, which is why a 64 GB MacBook can hold a model that would need a serious discrete GPU elsewhere. The catch is that macOS caps how much of that pool the GPU may wire down — around 75% by default — and everything else on your machine competes for the rest.

# see the current GPU wired-memory limit (0 = system default, ~75%) sysctl iogpu.wired_limit_mb # raise it — 56 GB on a 64 GB machine. Resets on reboot. sudo sysctl iogpu.wired_limit_mb=57344

Raise this only if you have measured a model spilling, and leave real headroom. Push it too high and the system starts swapping under memory pressure, which is dramatically worse than a slightly smaller model — you go from tokens per second to seconds per token.

Quantization: what each level costs you

Quantization stores weights at reduced precision. The size arithmetic is straightforward: total parameters times bits-per-weight divided by 8. For a 30.5B-parameter model:

LevelApprox. bits/weightSize for 30.5B paramsWhat you give up
Q8_0~8.5~32 GBNothing measurable. Also the least practical on a laptop.
Q6_K~6.6~25 GBEffectively nothing for chat and drafting.
Q5_K_M~5.5~21 GBVery little. Slight softening on exact recall and long code.
Q4_K_M~4.5~17 GBThe standard sweet spot. Noticeable only on hard reasoning and precise code.
Q3_K_M~3.5~13 GBReal degradation. Instruction following and formatting break before knowledge does.
Q2_K~2.6~10 GBDo not. It will produce fluent text and reliably get things wrong.

The published Q4_K_M build of Qwen3-30B-A3B lands around 18 GB on disk, close to the arithmetic. Q4_K_M is where almost everyone should be: it fits a 32 GB machine with room for context, and the quality gap to Q8 is small enough that most people cannot pick the outputs apart blind.

The failure mode below Q4 is worth knowing because it is counterintuitive. Aggressive quantization does not primarily erase facts — it erases precision. The model still knows things and gets steadily worse at following formatting instructions, closing brackets, staying in the requested structure, and holding a constraint across a long answer. If a local model suddenly cannot keep to a JSON shape it handled last week, check the quantization level before you rewrite the prompt.

Context length is a second, separate RAM bill

Model weights are the fixed cost. The KV cache is the variable one, and it is the number people forget: every token in the context window stores a key and a value vector in every layer, for the whole session.

bytes/token = 2 × n_layers × n_kv_heads × head_dim × bytes_per_element Qwen3-30B-A3B: 48 layers, 4 KV heads, head_dim 128, fp16 (2 bytes) = 2 × 48 × 4 × 128 × 2 = 98,304 bytes = 96 KiB per token 8,192 tokens × 96 KiB = 0.75 GiB 40,960 tokens × 96 KiB = 3.75 GiB 131,072 tokens × 96 KiB = 12 GiB

Grouped-query attention is doing a lot of work in that formula — only 4 KV heads instead of 32 cuts the cache by 8x versus full multi-head attention. Check your model's own config for these values rather than reusing mine; a model with more KV heads has a dramatically more expensive cache and the difference shows up as an out-of-memory error at exactly the context length you needed.

You can halve the cache by quantizing it. In Ollama that is OLLAMA_KV_CACHE_TYPE=q8_0, which requires flash attention to be enabled. The quality cost of an 8-bit KV cache is very small; q4_0 halves it again and does start to show, particularly on long-context recall.

Ollama settings that actually matter

Most Ollama tuning advice is noise. These five are not:

SettingValueWhy
OLLAMA_FLASH_ATTENTION1Faster attention, less memory. Also a prerequisite for KV cache quantization.
OLLAMA_KV_CACHE_TYPEq8_0Halves KV cache memory at negligible quality cost. Needs flash attention on.
OLLAMA_KEEP_ALIVE30m or -1Stops the model unloading between requests. A cold load of an 18 GB model is tens of seconds you pay on every call.
OLLAMA_MAX_LOADED_MODELS1On a laptop, two resident 18 GB models means neither fits. Force one.
num_ctx (per request)the smallest that fits your taskContext you configure is context you pay for in RAM whether or not you fill it.

Set these where they survive a reboot. On macOS, launchctl setenv does not persist, and neither does exporting them in a shell that Ollama was not launched from — the daemon reads its environment at start. Put them in the EnvironmentVariables block of a LaunchAgent plist that starts the server, and then actually verify with ps eww $(pgrep ollama) | tr ' ' '\n' | grep OLLAMA. I lost a week to tuning flags that were never applied because the service was being started by a GUI app that never saw them.

Realistic tokens per second on Apple Silicon

Generation speed on a laptop is bounded by memory bandwidth, so the chip tier matters more than the core count. Roughly: base M-series chips land near 100–120 GB/s, Pro chips in the 150–275 GB/s range, and Max chips at 400 GB/s and up. Divide bandwidth by bytes-read-per-token for a theoretical ceiling, then expect real numbers well below it.

On my M1 Max (400 GB/s) running Qwen3-30B-A3B at Q4_K_M, generation sits in the tens of tokens per second at short context and degrades as the context fills — by 20k tokens in, it is meaningfully slower, because attention cost grows with sequence length even though the weight reads do not. Do not trust anyone's numbers including mine; measure your own, because quantization, context length, and what else is running all move it substantially.

curl -s http://localhost:11434/api/chat -d '{ "model": "qwen3:30b-a3b", "stream": false, "messages": [{"role":"user","content":"Write 300 words about memory bandwidth."}] }' | python3 -c ' import sys, json d = json.load(sys.stdin) print("prefill tok/s:", round(d["prompt_eval_count"] / (d["prompt_eval_duration"]/1e9), 1)) print("gen tok/s:", round(d["eval_count"] / (d["eval_duration"]/1e9), 1))'

The number that actually decides your architecture: prefill

Generation speed is what people benchmark. Prompt processing — prefill — is what determines whether your application feels usable, and it is a completely different regime: compute-bound rather than bandwidth-bound, and on Apple Silicon it runs at a few hundred tokens per second rather than the thousands a datacenter GPU manages.

Work the arithmetic for a retrieval pipeline. A RAG query with 20,000 tokens of retrieved context, prefilling at 400 tokens/sec, spends 50 seconds before the first word of the answer appears. The generation phase afterwards might take four seconds. Your users experience a 54-second response and the tokens-per-second number you optimized was irrelevant to 90% of it.

That single fact should shape the design. Retrieve fewer, better chunks — which is what choosing an embedding model and a reranker properly buys you. Keep the stable part of your prompt stable so the runtime can reuse its cached KV state rather than reprefilling it, the local analogue of prompt caching on a hosted API. And prefer batch work over interactive work wherever the task allows.

When local is genuinely good enough

The honest split is about latency tolerance and correctness stakes, not about capability in the abstract.

Local winsPay for an API
Batch jobs and overnight runs — nobody is waiting.Anything interactive where a user watches a cursor blink.
High-volume, low-stakes work: classification, tagging, first-draft generation.Long-context reasoning over big documents — prefill alone rules it out.
Anything where the data cannot leave the machine. This argument beats every quality argument.Code generation where correctness matters and a subtle bug costs hours.
Iterating on prompts, where free retries change how you work.Reliable multi-step tool calling. Frontier models are meaningfully better at it.
Fixed monthly cost matters more than best-possible output.Deadlines. Local throughput is not something you can scale up at 2am.

My own split is exactly this. Overnight automation drafts on the local model — free retries, no rate limits, and if a draft is bad the cost of regenerating it is zero. Anything with a deadline or a correctness requirement goes to a hosted API, where the current Claude lineup runs from claude-haiku-4-5-20251001 for cheap high-volume work through claude-sonnet-5 and claude-opus-5 for the hard problems. The two are not competitors; they are different tools that happen to share an interface.

A working setup, start to finish

  1. Check your RAM. 32 GB runs a 30B MoE at Q4_K_M with modest context. 64 GB runs it comfortably with room for a large window. 16 GB should be running a 7B-class model instead — this guide is not for you and you will be happier.
  2. Pull the Q4_K_M build. Start there always; move up only if you can demonstrate a quality problem, and move down only if you cannot fit.
  3. Set the five environment variables above in a LaunchAgent plist so they survive a reboot, then verify with ps eww that the running process actually has them.
  4. Set num_ctx per request to the smallest value your task needs. Do not configure 128k because it is available.
  5. Measure prefill and generation separately with the timing script above, at the context length you actually use.
  6. Design around prefill, not generation. Fewer retrieved chunks, stable prompt prefixes, batch where you can.
  7. Keep an API path for the work that needs it. A local-only architecture is a constraint, not a virtue.

The setup takes an afternoon and then largely maintains itself. Everything I have built on top of it — the automation drafting, the classification passes, the overnight batch work you can see the shape of on the projects page — runs on that one resident model, and the whole thing costs the electricity to keep a laptop awake.

Tools referenced in this guide

  • Local LLM with Ollama — the setup layer beneath this guide — model selection, the HTTP API, and quantisation basics.
  • Prompt caching — why keeping the stable part of a prompt stable matters locally and on a hosted API.
  • Embedding models for RAG — retrieving fewer, better chunks is the main defence against slow prefill.
  • My stack — the actual hardware and tooling behind this.

FAQ

Quick answers

How much RAM do I need to run a 30B MoE model?

Around 32 GB is the practical floor for a 30B-parameter mixture-of-experts model at Q4_K_M quantization, which needs roughly 17 to 18 GB for weights plus a few gigabytes for the KV cache and headroom for the rest of the system. 64 GB runs it comfortably with a large context window. Note that RAM is set by total parameters, not active ones, so the MoE architecture does not reduce the memory requirement at all.

Why is a 30B MoE model faster than a 30B dense model?

Generation speed on a laptop is limited by memory bandwidth, and a mixture-of-experts model only reads the parameters of the few experts its router selects for each token, roughly 3B out of 30B. A dense model reads all 30B for every token. Both models occupy the same RAM, but the MoE reads far less per token, so it generates much faster.

Which quantization level should I use?

Q4_K_M for almost everyone. It is the standard sweet spot: roughly 4.5 bits per weight, small enough to fit a 32 GB machine with context to spare, and close enough to Q8 that most people cannot distinguish the outputs blind. Go to Q5_K_M if you have the memory and do precision-sensitive work; avoid anything below Q4, where instruction-following and formatting degrade before factual knowledge does.

How much memory does the context window use?

Compute it as 2 times layers times KV heads times head dimension times bytes per element, per token. For a model with 48 layers, 4 KV heads and head dimension 128 at fp16, that is 96 KiB per token — so 40,000 tokens of context costs about 3.75 GiB on top of the weights. Setting the KV cache type to q8_0 in Ollama roughly halves it at negligible quality cost.

What Ollama settings actually improve performance?

Five: OLLAMA_FLASH_ATTENTION set to 1, OLLAMA_KV_CACHE_TYPE set to q8_0, OLLAMA_KEEP_ALIVE at 30m or -1 so the model does not unload between calls, OLLAMA_MAX_LOADED_MODELS set to 1 on a laptop, and num_ctx set per request to the smallest value the task needs. Everything else is noise by comparison.

Why do my Ollama environment variables not seem to apply?

The daemon reads its environment when it starts, so variables exported in an unrelated shell never reach it, and on macOS launchctl setenv does not survive a reboot. Bake them into the EnvironmentVariables block of the LaunchAgent plist that starts the server, then verify against the live process with ps eww on the Ollama PID rather than trusting that they took.

What tokens per second should I expect on Apple Silicon?

It scales with memory bandwidth, so roughly 100 to 120 GB/s on base chips, 150 to 275 on Pro, and 400 or more on Max. A 30B MoE at Q4 on a 400 GB/s machine generates in the tens of tokens per second at short context and slows as context fills. Measure your own with the eval_count and eval_duration fields the Ollama API returns rather than trusting any published figure.

When should I pay for an API instead of running locally?

When latency is user-visible, when the prompt is long enough that prefill dominates, when correctness genuinely matters, or when you need reliable multi-step tool calling. Local wins on batch work, high-volume low-stakes tasks, prompt iteration, and anything where the data must not leave your machine — which is an argument that outweighs any quality comparison.