self-hosted/ai
§01·recipe · llm

Nanbeige4.2-3B on Apple M2 Max: 128K-context agentic LLM via llama.cpp Metal

llmintermediate16GB+ VRAMAug 9, 2026

This intermediate recipe sets up Nanbeige4.2 3B on the Apple M2 Max, needing about 16 GB of VRAM.

models
tools
prerequisites
  • Apple silicon Mac — 64 GB unified memory (Apple M2 Max) for the 131,072-token lead configuration; 16 GB runs the same install at 32,768 tokens with a quantized cache
  • macOS Sonoma 14 or newer
  • llama.cpp build b10153 or newer — Metal is on by default on macOS
  • ~2.6 GB free disk for the Q4_K_M GGUF (2,574,807,904 bytes)

What You'll Build

A local OpenAI-compatible server running Nanbeige4.2-3B — a compact agentic model whose 22 decoder blocks execute twice per forward pass — on an Apple M2 Max, at a 131,072-token context that no consumer discrete GPU can hold. The install is mainline llama.cpp with the Metal backend and a community GGUF; the recipe's real content is the memory arithmetic, because the looping doubles the KV cache and that, not the weights, is what a 64 GB Mac is actually spending its memory on.

Hardware data: Apple M2 Max (64 GB unified memory, 48 GiB GPU-addressable) · Q4_K_M weights 2.398 GiB + 22.0 GiB KV cache at 131,072 tokens + reserved logits 0.317 GiB = 24.715 GiB derived · See benchmark data

⚠️ The KV cache is 2× what the layer count suggests. config.json sets num_loops: 2, so the 22 physical blocks are executed twice and each pass keeps its own KV entries — 44 cache layers, not 22. llama.cpp's implementation is explicit about it: src/models/nanbeige.cpp expands the logical layer count "before load_tensors() allocates layers / KV" and comments the tensor-sharing loop "Share physical weights across loops; each slot still has its own KV index." Budget 176 KiB per token at F16, not 88 KiB.

Requirements

ComponentMinimumThis recipe
GPUApple silicon, MetalApple M2 Max, 64 GB unified memory — not measured; the budget below is derived from file bytes and the runtime's own allocation rule (/contribute)
Unified memory16 GB — runs the 32,768-token quantized-cache rung at 5.637 GiB (see ladder)64 GB — 48 GiB GPU-addressable at the macOS default (logged on this chip)
Storage2.575 GB / 2.398 GiB (Q4_K_M)2,574,807,904 bytes on disk; 4.13 GiB if you take Q8_0 instead
SoftwaremacOS Sonoma 14+, llama.cpp ≥ b10153Homebrew llama.cpp (currently 10330) or a source build

Runtime choice: llama.cpp-Metal, not MLX

This is the part worth reading before you download anything, because the MLX shelf for this model is both busier and more treacherous than it looks.

Search MLX builds and you get 27 of them (counted 2026-08-09 through the HuggingFace API; all 27 declare "model_type": "nanbeige"): mlx-community/Nanbeige4.2-3B-OptiQ-4bit, a full 2/3/4/5/6/8-bit ladder from MercuriusDream, sahilchachra's mxfp4/mxfp8/int4/int8 set, and a long tail from vote-for-pedro, jishnuvenugopal, OsaurusAI, yugeshkarunamurthy and others. Twenty-three of the twenty-seven cannot be loaded by stock mlx-lm at all. mlx-lm resolves an architecture by importing mlx_lm.models.<model_type>, and there is no nanbeige.py among the 121 files in mlx_lm/models/ on master, nor a MODEL_REMAPPING entry — so _get_classes raises with msg = f"Model type {model_type} not supported." (mlx_lm/utils.py). The custom_code tag does not rescue them: mlx-lm never reads HuggingFace's auto_map and never executes the PyTorch modeling_nanbeige.py.

Four of the twenty-seven do load, and it matters which four. mlx-lm has a second, separate escape hatch: when config.json declares a model_file key, load_model imports that file and takes arch.Model, arch.ModelArgs out of it, gated behind trust_remote_code — which both mlx_lm.generate and mlx_lm.server expose as --trust-remote-code. The three jishnuvenugopal/Nanbeige4.2-3B-mlx-{4,6,8}bit repos each carry the same self-contained MLX port of the looped architecture — nanbeige.py, 14,157 bytes — and declare it. WaveCut's build declares a different and independently written one, nanbeige_mlx.py at 10,284 bytes, which exposes the same Model / ModelArgs / make_cache surface. Two ports, not one file copied around. So a working MLX path exists today:

pip install mlx-lm
mlx_lm.generate --model jishnuvenugopal/Nanbeige4.2-3B-mlx-4bit \
  --trust-remote-code --prompt "Which number is bigger, 9.11 or 9.8?"

Why llama.cpp still leads it. Native support was merged into mainline on 2026-07-27, the architecture is registered as nanbeige in src/llama-arch.cpp, and Metal is on by default on macOS — so the GGUF path is reviewed upstream code you can pin to a version. The MLX path asks you to execute one individual's Python; that file's own header pins it to mlx-lm ≥ 0.31 internals and its README declines to claim bit-exact parity with the reference. That is a provenance call, not a claim that MLX is unsuited to a looped transformer — and mlx-lm has four open, unmerged PRs adding the architecture properly (#1597, #1599, #1603, #1640; PyPI is still on 0.31.3, which predates the model). The Swift port is already there: mlx-swift-lm PR #460 merged 2026-07-29.

Does the MLX route change the memory arithmetic? No — and that is the useful part. That community port's make_cache returns num_loops * num_hidden_layers KVCache slots, and its README puts the full-context cache at ~47 GB, which is the ladder's 44.0 GiB in decimal units. Four independent implementations — the HuggingFace reference, llama.cpp, mlx-swift-lm and this MLX port — allocate the same 44 slots, so the numbers below are an architecture cost, not a llama.cpp quirk; only the flags are runtime-specific. Two of those differ if you take the MLX route: because the model supplies make_cache, mlx-lm's --max-kv-size is inert, and --kv-bits is the equivalent of llama.cpp's -ctk / -ctv.

Two things on the model card do not apply here. Its llama.cpp section clones the vendor's own fork and configures the build with the CUDA backend flag — mainline superseded that fork and there is no CUDA on a Mac, so skip that block entirely. And the org publishes -FP8 and -GPTQ-Int8 variants, neither of which has an Apple execution path; the GGUF K-quants below are the Apple equivalent.

Installation

1. Install llama.cpp with Metal

brew install llama.cpp
llama-cli --version

The Homebrew formula is currently at build 10330; anything at or above b10153 contains the Nanbeige architecture. To build from source instead (Metal needs no flags on macOS — it is the default backend):

git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp
cmake -B build
cmake --build build --config Release -j

2. Fetch the GGUF

llama.cpp downloads and caches HuggingFace repos itself, so there is no separate step:

llama-server -hf owao/Nanbeige4.2-3B-GGUF:Q4_K_M --port 8080

If you would rather hold the file yourself:

hf download owao/Nanbeige4.2-3B-GGUF Nanbeige4.2-3B-Q4_K_M.gguf \
  --local-dir ~/models/nanbeige4.2-3b

There is no first-party GGUF and no first-party MLX build — the Nanbeige org publishes ten repositories and all ten are safetensors-only, so every quant you can run is a community one. owao/Nanbeige4.2-3B-GGUF is the one to use: 13 quants, ~75,000 downloads, and the uploader re-uploaded the whole set on 2026-07-28 to pick up the vendor's chat-template fix (upstream commit fab06df, 2026-07-27). The artifact is current with respect to that fix — worth checking, because a quant is frozen at its upload timestamp and this one was first published on release day, six days before the fix landed.

Running

llama-server \
  -hf owao/Nanbeige4.2-3B-GGUF:Q4_K_M \
  --host 127.0.0.1 --port 8080 \
  -c 131072 \
  -ngl 99 \
  --temp 0.6 --top-p 0.95 --top-k 20

The server comes up on http://127.0.0.1:8080 with an OpenAI-compatible /v1/chat/completions endpoint and a browser UI at the same address. Startup allocates the full KV cache up front, so if the context is going to be too large you find out in the first few seconds, not mid-conversation.

The sampler values are the model card's own defaults for reasoning and chat. For agentic and tool-use work the card recommends --temp 1.0 instead — but see the tool-call entry under Troubleshooting first, because temperature interacts with a live parser bug.

Context choice is the one decision that matters. The card states: "The model supports a context length of up to 262,144 tokens (256K)." The -c 131072 above matches the 131,072 max-new-tokens the card recommends for reasoning and chat — note that llama.cpp's -c is total context, prompt plus generation, so it is the same number serving a different purpose — and it costs 22.0 GiB of KV. The ladder below gives every other rung.

On a smaller Mac

The install is identical; only the last two lines change. This is the configuration the min_vram_gb: 16 floor is set from:

llama-server \
  -hf owao/Nanbeige4.2-3B-GGUF:Q4_K_M \
  --host 127.0.0.1 --port 8080 \
  -c 32768 \
  -ngl 99 \
  --temp 0.6 --top-p 0.95 --top-k 20 \
  -ctk q8_0 -ctv q8_0

2.398 GiB of weights plus 2.922 GiB of quantized cache plus the same 0.317 GiB of reserved logits = 5.637 GiB, against the 10.667 GiB a 16 GB Mac addresses.

16 GB is the floor because 8 GB does not clear that rung. Metal's default share is an exact fraction of capacity rather than a range — two-thirds at 16 and 32 GB, three-quarters from 36 GB up — so the comparison is decidable: two-thirds of 8 GiB is 5.333 GiB, this working set is 5.637 GiB, and an 8 GB Mac is therefore 0.303 GiB short before the compute graph is allocated at all. A 16 GB Mac addresses 10.667 GiB — Metal reports 11,453.25 MB there, which is that two-thirds to the last printed digit — and clears the same rung with 5.030 GiB spare, enough to stretch to -c 65536 at the same cache precision (8.559 GiB): past the comfortable band, but inside the pool.

Memory budget — the loop tax

Token generation on Apple silicon is bound by unified memory, and this model spends it in an unusual ratio: the weights are tiny and the cache is not.

Where the parameters are. config.json describes 22 layers of hidden_size 3072 with 48 query heads over 8 KV heads at head_dim 128, an intermediate size of 10752, an untied 166,144-token vocabulary, and num_loops: 2. Summing that gives 143,136,768 parameters per layer × 22 = 3,149,008,896, plus a final norm, plus 510,394,368 each for the input embedding and the LM head — 4,169,800,704 total, of which 3,149,011,968 are non-embedding. That closes exactly against the parameter census HuggingFace reports for the safetensors (4169800704) and against the GGUF's own general.size_label = 4.2B, which settles the naming: the "3B" counts non-embedding parameters, exactly as the card says. It also proves the loop is free in parameter terms — the census closes with one copy of the stack, and the Q4_K_M file carries 201 tensors (22 blocks × 9, plus embedding, output norm and LM head), not 44 blocks' worth.

Where the memory goes. The loop is not free in cache terms. With 8 KV heads at 128 dimensions, one layer stores 2048 K+V elements per token. load_arch_hparams() sets hparams.n_layer_all = n_layer_phys * n_loops = 44, and the allocator in src/llama-kv-cache.cpp reads exactly that field — const uint32_t n_layer = hparams.n_layer_all; — creating one K and one V tensor per slot. So at F16:

44 × 2048 × 2 bytes = 180,224 bytes = 176.0 KiB per token — against 88.0 KiB for the same stack run once.

There is one more term, and it does not move with context. llama.cpp sizes a worst-case graph once at startup, and src/llama-context.cpp sets how many rows of logits it must hold to n_outputs_pp = std::min(n_tokens, cparams.n_outputs_max), where n_tokens is std::min(cparams.n_ctx, cparams.n_ubatch) and n_outputs_max defaults to n_batch. At stock settings that is 512 rows whatever -c you pass, each a full f32 distribution over this model's 166,144-token vocabulary: 512 × 166,144 × 4 B = 340,262,912 B = 324.5 MiB = 0.317 GiB, in the Metal compute buffer. It is large only because the vocabulary is; it shrinks with -ub, not with -c.

ContextKV cache (F16)KV cache (-ctk q8_0 -ctv q8_0)LogitsAccounted total (Q4_K_M + F16 KV)
8,1921.375 GiB0.730 GiB0.317 GiB4.090 GiB
32,7685.500 GiB2.922 GiB0.317 GiB8.215 GiB
65,53611.000 GiB5.844 GiB0.317 GiB13.715 GiB
131,07222.000 GiB11.688 GiB0.317 GiB24.715 GiB
262,14444.000 GiB23.375 GiB0.317 GiB46.715 GiB

The lead configuration is the bolded row: 2,574,807,904 bytes of weights plus 23,622,320,128 bytes of KV cache plus 340,262,912 bytes of reserved logits = 24.715 GiB.

What this Mac's GPU addresses is 48 GiB, and that figure is logged rather than estimated. macOS does not give the GPU all of unified memory — Metal publishes a ceiling in recommendedMaxWorkingSetSize, and on a 64 GB Apple M2 Max llama.cpp prints it at startup as recommendedMaxWorkingSetSize = 51539.61 MB. That is 51,539,607,552 bytes: exactly three-quarters of 64 GiB, exactly 48.000 GiB. It is worth pinning down rather than approximating, because the two plausible misreadings land a long way apart — three-quarters of 64 GB decimal would be 44.7 GiB, and the two-thirds share that smaller Macs get would be 42.667 GiB. The lead configuration's 24.715 GiB clears all three by a wide margin, so the recommendation on this page does not depend on which ceiling you believed. The top of the ladder does.

The 23.285 GiB surplus is why 128K is comfortable here, and it is the reason to own 64 GB. That row is the showcase, not the floor: this recipe's min_vram_gb is set from the 32,768-token quantized-cache rung (5.637 GiB accounted), which is why a 16 GB Mac still sees this model in the catalogue.

The last row is the interesting one. At F16 the full 262,144-token context needs 46.715 GiB. That is inside the 48.000 GiB pool — but only by 1.285 GiB, and that remainder still has to cover the Metal compute buffers that sit on top of the reserved logits already counted. Arithmetically admissible, practically not a configuration to run. Take the full context with -ctk q8_0 -ctv q8_0 (23.375 GiB of KV, 26.090 GiB accounted) rather than by raising the wired limit.

One term you will find in the NVIDIA recipes for this model is absent here: those pay 4,096 bytes per context token to expand a quantized KV cache back to f16 before FlashAttention reads it, allocated in ggml/src/ggml-cuda/fattn-common.cuh. That is a CUDA/HIP path; Metal has no analogue, so a quantized cache on this machine costs exactly what the table says and nothing more.

This is a design decision, not an implementation artifact. The technical report's architecture section describes testing a KV-sharing variant and rejecting it, and the team said the same thing twice in the model's discussions when users raised it — "We have also investigated KV-cache sharing across loop passes, but the performance gains were notably smaller than with the full looped setup." (discussion #10, Nanbeige team member leran1995) and "we did try sharing the KV cache across loop passes, but it noticeably hurt performance, so we kept the full cache in Nanbeige4.2." (discussion #18, same speaker). Sharing would have halved the cache; they kept the model quality instead. Nothing in a runtime flag can undo that — only KV quantization moves this number.

What the loop costs in bandwidth. Token generation streams weights, and the loop makes the decoder stack stream twice. Reading the Q4_K_M tensor table: the 22 blocks are 1,865,048,064 bytes, the Q6_K LM head is 418,682,880, and the embedding table is a single-row gather per token. So a generated token moves 2 × 1,865,048,064 + 418,682,880 = 4,148,779,008 bytes ≈ 3.864 GiB, against 2.127 GiB for a same-sized model without the loop — about 1.82× the bytes per token. On a bandwidth-bound machine that is close to a throughput ratio: expect this to feel like a model roughly twice its file size, while occupying the memory of its file size.

Results

  • Speed: omitted. No measured Apple-silicon throughput for Nanbeige4.2-3B exists in any source searched — not the model card, not the technical report (neither publishes a single tokens-per-second figure anywhere), not the 27 discussions on the canonical repo, not the three on the GGUF repo, and not two targeted web searches for M-series numbers. If you run it, please post the figure via /contribute so /check/nanbeige4-2-3b/m2-max stops being empty.
  • Unified memory usage: 24.715 GiB derived for the lead configuration — 2.398 GiB weights, 22.0 GiB KV at 131,072 tokens and 0.317 GiB of reserved logits — from measured file bytes and llama.cpp's own 44-layer allocation rule. Not a measured peak: the activation working set on top of the logits reservation is not included, and the attention mask lives in host memory rather than the GPU pool. See /check/nanbeige4-2-3b/m2-max.
  • Quality notes: the card's published tables are quality benchmarks only, and they are the reason to run this at 3B non-embedding parameters — but they were produced in thinking mode with preserve_thinking=true, which means long reasoning traces, which means the KV table above is not a worst case you can ignore. The card recommends preserve_thinking=False for general chat and True for multi-turn tool use, office tasks and code-agent workflows.

For the full benchmark data, see /check/nanbeige4-2-3b/m2-max.

Troubleshooting

error loading model: unknown model architecture: 'nanbeige'

Your runtime predates 2026-07-27. Mainline llama.cpp merged the architecture in PR #25994 and the first release carrying it is b10153; brew upgrade llama.cpp is the fix. The same message is what the GGUF repo's first support thread opened with on release day, when the vendor fork was the only option.

Ollama and LM Studio are the two places this still bites. There is no entry for this model in the official Ollama library — ollama.com/library/nanbeige, /nanbeige4.2, /nanbeige4-2, /nanbeige-4.2 and /nanbeige4.2-3b all return 404, and the nanbeige namespace the model card's ollama run nanbeige/nanbeige4.2:3b-Q4_K_M command assumes does not exist either. Community pushes surface under ollama.com/search?q=nanbeige, but of the fifteen nanbeige-named results there on 2026-08-09, only two were 4.2: ten were Nanbeige4.1, one was Nanbeige2-16B, and two were unversioned nanbeige3b-tools pushes whose generation the name does not reveal at all. Check the generation before you pull. For LM Studio, whether it works depends on the llama.cpp build its backend ships; the model card's instruction to hand-copy a fork's binaries into the backend directory was written before mainline merged and can be ignored if your backend is current.

ValueError: Model type nanbeige not supported. from mlx-lm

Expected on 23 of the 27 MLX builds, for the reason in the runtime-choice section: stock mlx-lm has no nanbeige architecture and four separate PRs adding it are still open. Nothing about the repo you picked is wrong — including the ones under mlx-community, which are in the failing group. Either use the GGUF path, or switch to one of the four repos that ship their own model_file implementation and pass --trust-remote-code.

One of those four is a trap. WaveCut/Nanbeige4.2-3B-heretic-MLX-DWQ-4bit loads — and it is an abliterated derivative rather than a quantization of the base model, so "it worked when the others didn't" is the worst possible reason to settle on it. It is not a like-for-like substitute for anything else in this recipe.

Tool calls come back as plain text in message.content

A live parser bug in llama.cpp, not a model failure. The model emits <tool_call> with a trailing space where the auto-derived marker expects <tool_call>\n, and the call is dropped. The open fix, PR #26324, puts the incidence at ~25% of calls and describes the consequence as "All such tool calls currently fail and are displayed verbatim to the user instead of being executed." A community reporter separately measured roughly one call in six on a small sample and found it temperature-dependent, parsing cleanly at temperature 0 and degrading at the card's recommended agentic temperature of 1.0 (discussion #17); treat that as a community datapoint from four samples per configuration, not a measurement. Until the PR merges, lower the temperature for tool-use runs or build llama.cpp from the PR branch.

Failed to initialize samplers: Unexpected empty grammar stack

Reported when using response_format: json_schema against llama-server on the default jinja chat path; the reporter's workaround is --no-jinja (discussion #6). Both reports in that thread are community, and the second is explicitly flagged by its author as written by an AI agent, so treat the root-cause analysis as unconfirmed. Note the trade: --no-jinja drops the chat template, and the template is what tool calling is parsed from — so this workaround and the tool-call section above are mutually exclusive.

Memory pressure or a failed allocation at startup

Drop -c one rung down the ladder, or halve the cache with -ctk q8_0 -ctv q8_0 before reaching for sudo sysctl iogpu.wired_limit_mb=<MB>. If you do raise the wired limit, leave 8–16 GB for macOS and watch Activity Monitor's memory-pressure gauge; the setting is temporary and resets on reboot, and 0 restores the default. On this model the raise is rarely the right answer: KV quantization buys back more than the raise safely can.

Do not install the Python path just to try it

The card's HuggingFace quickstart needs trust_remote_code=True and a very specific dependency set — a community write-up got it working only after pinning Python 3.11, PyTorch 2.8.0, Transformers 4.42.4 and several more, having first produced gibberish on a mismatched configuration (discussion #15). That path also pulls CUDA-only attention packages that have no Apple equivalent. The GGUF route above has none of these dependencies.

Nothing else widely reported. Problems, or a throughput number for this chip, go to the submission form.

common questions
How much VRAM does Nanbeige4.2 3B need?

About 16 GB — the minimum this recipe targets.

Which GPUs is Nanbeige4.2 3B tested on?

Apple M2 Max (64 GB).

How hard is this setup?

Intermediate — follow the steps above.