Local LLM arena: invert a binary tree
So, can we invert the binary tree using locally running LLMs?
I mean small models, like 4B Qwen or Gemma, and I want the answer reproducible and verifiable, not a feeling. It is a fun problem to pick because it once got the Homebrew author turned down by Google.
I set up a small arena. Two local models, Gemma 4 E4B and Qwen3.5 4B, the same prompt for each, and a deterministic judge that just runs the unit tests. It all runs locally, a few hundred lines of Python you can copy-paste as you go.
Prerequisites
-
An NVIDIA GPU with recent CUDA drivers. Mine is a 5070 Ti, 16 GB. Anything that can hold a 4B Q4 quant works.
-
Python 3.11+ with
huggingface_hub(pip install huggingface_hub). -
llama.cpp built with CUDA. From a fresh checkout:
git clone https://github.com/ggml-org/llama.cpp cd llama.cpp cmake -B build -DGGML_CUDA=ON cmake --build build --config Release -jThe binary you want is
build/bin/llama-server. If you have Unsloth Studio installed it drops its own copy at~/.unsloth/llama.cpp/build/bin/llama-server, which is what I use below.
The models are Unsloth’s GGUFs of Gemma 4 E4B Instruct and Qwen3.5 4B, both at the dynamic UD-Q4_K_XL quant.
huggingface_hub will download them to ~/.cache/huggingface/ on first run, so no need to fetch them by hand.
The prompt
Same prompt for every model, temperature 0, single sample.
Given the root of a binary tree, invert the tree and return its root.
Use this Python class for tree nodes:
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
Implement a function with this exact signature:
def invert_tree(root):
...
The argument is either a `TreeNode` or `None`. Return the root of the inverted tree.
You may mutate the input tree or build a new one — only the returned value is checked.
Output ONLY the function definition in a single Python code block.
Do not include explanations, example usage, tests, or print statements. Calling the model
llama-server is llama.cpp’s HTTP server.
Point it at a GGUF and a port, and it speaks the OpenAI chat-completions protocol:
llama-server -m gemma-4-E4B-it-UD-Q4_K_XL.gguf --port 8123 -c 8192 -ngl 99 -np 1
That separates model loading from orchestration. The Python side stays a thin HTTP client:
"""Send a prompt to a locally running llama-server, return the response.
Start the server first:
llama-server -m <model.gguf> --port 8123 -c 8192 -ngl 99 -np 1
"""
import json
import re
import urllib.request
def call(prompt: str, *, port: int = 8123, temperature: float = 0, top_k: int = 1, seed: int = 42,
enable_thinking: bool | None = None) -> tuple[str, dict]:
"""Returns (response_text, timings). `timings` is llama.cpp's per-call
speed breakdown: predicted_n, predicted_per_second, predicted_ms, etc.
`enable_thinking` forwards to the chat template via `chat_template_kwargs`
so Qwen3-class models can be run with or without their default reasoning trace.
Leave as None for models that don't have a thinking mode (e.g. Gemma)."""
payload = {
"messages": [{"role": "user", "content": prompt}],
"temperature": temperature,
"top_k": top_k,
"seed": seed,
}
if enable_thinking is not None:
payload["chat_template_kwargs"] = {"enable_thinking": enable_thinking}
body = json.dumps(payload).encode()
req = urllib.request.Request(
f"http://127.0.0.1:{port}/v1/chat/completions",
data=body,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=300) as resp:
data = json.loads(resp.read())
return data["choices"][0]["message"]["content"], data.get("timings", {})
def extract_python(raw: str) -> str:
m = re.search(r"```(?:python)?\s*\n(.*?)```", raw, re.DOTALL)
return (m.group(1).rstrip() + "\n") if m else raw temperature=0, top_k=1, seed=42, and -np 1 on the server pin the sampler.
On the same hardware and llama.cpp build, the same prompt produces byte-identical output every run.
The function also returns llama-server’s per-call timings block (tokens generated, tokens per second, total milliseconds), which we use for the performance table later.
Generating samples
llama-server loads one model at a time, so the script cycles it: start pointed at the next GGUF, send the prompt, capture what comes back, shut it down, repeat for each entry in the model list.
A background thread runs alongside each call, polling /proc/<pid>/{status,stat} and nvidia-smi to capture per-process RAM and CPU and per-GPU VRAM and utilization.
Set LLAMA_BIN at the top of the file to your llama-server path before running.
"""Drive llama-server through each model in MODELS, write outputs to samples/.
llama-server's binary needs LD_LIBRARY_PATH pointing at its build/bin/ (libmtmd.so etc live next to it).
Model files are resolved via huggingface_hub, which downloads to ~/.cache/huggingface on first run.
"""
import json
import os
import subprocess
import sys
import threading
import time
import urllib.request
from pathlib import Path
from huggingface_hub import hf_hub_download
sys.path.insert(0, str(Path(__file__).parent))
import call_model
class Sampler:
"""Polls the target process (via /proc) and nvidia-smi while the context is open.
Reports peak RAM (process RSS), CPU% across cores, peak VRAM, and GPU%."""
def __init__(self, pid: int, interval: float = 0.2):
self.pid = pid
self.interval = interval
self.gpu: list[tuple[float, float]] = [] # (util%, vram_mib)
self.ram: list[float] = [] # process RSS in MiB
self.cpu: list[float] = [] # process CPU% (can exceed 100 with multi-core)
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._clk_tck = os.sysconf("SC_CLK_TCK")
self._last_jiffies = 0
self._last_time = 0.0
def _sample_proc(self):
try:
rss_kib = 0
with open(f"/proc/{self.pid}/status") as f:
for line in f:
if line.startswith("VmRSS:"):
rss_kib = int(line.split()[1])
break
with open(f"/proc/{self.pid}/stat") as f:
fields = f.read().split()
# utime + stime + cutime + cstime, in jiffies
jiffies = sum(int(fields[i]) for i in (13, 14, 15, 16))
except (FileNotFoundError, ProcessLookupError):
return
now = time.monotonic()
self.ram.append(rss_kib / 1024)
if self._last_jiffies and now > self._last_time:
self.cpu.append((jiffies - self._last_jiffies) / self._clk_tck / (now - self._last_time) * 100)
self._last_jiffies, self._last_time = jiffies, now
def _sample_gpu(self):
try:
out = subprocess.check_output(
["nvidia-smi", "--query-gpu=utilization.gpu,memory.used",
"--format=csv,noheader,nounits"],
text=True, timeout=1,
)
util, mem = (float(x) for x in out.strip().split(","))
self.gpu.append((util, mem))
except Exception:
pass
def _run(self):
while not self._stop.is_set():
self._sample_proc()
self._sample_gpu()
self._stop.wait(self.interval)
def __enter__(self):
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
return self
def __exit__(self, *exc):
self._stop.set()
if self._thread:
self._thread.join(timeout=2)
def summary(self) -> dict:
avg = lambda xs: round(sum(xs) / len(xs), 1) if xs else None
return {
"ram_peak_mib": int(max(self.ram)) if self.ram else None,
"cpu_util_avg_pct": avg(self.cpu),
"vram_peak_mib": int(max(s[1] for s in self.gpu)) if self.gpu else None,
"gpu_util_avg_pct": avg([s[0] for s in self.gpu]),
"samples": max(len(self.ram), len(self.gpu)),
}
HERE = Path(__file__).parent
POST_DIR = HERE.parent
SAMPLES = POST_DIR / "verify" / "arena" / "samples"
LLAMA_BIN = Path.home() / ".unsloth" / "llama.cpp" / "build" / "bin" / "llama-server"
MODELS = [
# (slug, repo, filename, enable_thinking, ngl).
# enable_thinking=None: don't forward the flag (Gemma has no thinking mode).
# enable_thinking=False on Qwen3 suppresses its default chain-of-thought trace,
# which otherwise produces thousands of reasoning tokens before the answer.
# ngl=99 offloads all layers to the GPU, ngl=0 runs purely on CPU.
("gemma-4-e4b-it-gpu", "unsloth/gemma-4-e4b-it-gguf", "gemma-4-E4B-it-UD-Q4_K_XL.gguf", None, 99),
("gemma-4-e4b-it-cpu", "unsloth/gemma-4-e4b-it-gguf", "gemma-4-E4B-it-UD-Q4_K_XL.gguf", None, 0),
("qwen3.5-4b-gpu", "unsloth/Qwen3.5-4B-GGUF", "Qwen3.5-4B-UD-Q4_K_XL.gguf", False, 99),
("qwen3.5-4b-cpu", "unsloth/Qwen3.5-4B-GGUF", "Qwen3.5-4B-UD-Q4_K_XL.gguf", False, 0),
]
def start_server(model_file, ngl: int):
env = {**os.environ, "LD_LIBRARY_PATH": str(LLAMA_BIN.parent)}
proc = subprocess.Popen(
[str(LLAMA_BIN), "-m", str(model_file), "--port", "8123", "-c", "8192",
"-ngl", str(ngl), "-np", "1", "--no-context-shift"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env,
)
for _ in range(240):
try:
urllib.request.urlopen("http://127.0.0.1:8123/health", timeout=1).read()
return proc
except Exception:
time.sleep(0.5)
proc.terminate()
raise RuntimeError("llama-server did not become ready")
def stop_server(proc):
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
def main():
prompt = (HERE / "prompt.md").read_text()
for slug, repo, filename, enable_thinking, ngl in MODELS:
print(f"--- {slug} ---", flush=True)
model_file = hf_hub_download(repo_id=repo, filename=filename)
proc = start_server(Path(model_file), ngl=ngl)
try:
with Sampler(pid=proc.pid) as s:
raw, timings = call_model.call(prompt, enable_thinking=enable_thinking)
finally:
stop_server(proc)
metrics = {
"tokens_generated": timings.get("predicted_n"),
"tokens_per_second": round(timings["predicted_per_second"], 1)
if timings.get("predicted_per_second") else None,
"generation_seconds": round(timings["predicted_ms"] / 1000, 2)
if timings.get("predicted_ms") else None,
**s.summary(),
}
out = SAMPLES / slug
out.mkdir(parents=True, exist_ok=True)
(out / "raw-response.txt").write_text(raw)
(out / "solution.py").write_text(call_model.extract_python(raw))
(out / "metrics.json").write_text(json.dumps(metrics, indent=2) + "\n")
print(f"wrote {out.relative_to(POST_DIR)}/", flush=True)
if __name__ == "__main__":
main() Run it from the same directory as call_model.py:
python3 generate.py
Each entry writes solution.py, raw-response.txt, and metrics.json to its own folder under samples/.
What the models produced
Gemma 4 E4B Instruct
def invert_tree(root):
if root is None:
return None
# Swap the left and right children
root.left, root.right = root.right, root.left
# Recursively invert the subtrees
invert_tree(root.left)
invert_tree(root.right)
return root Qwen3.5 4B
Qwen3 ships with a default chain-of-thought mode that emits thousands of reasoning tokens before the final answer.
For a one-shot codegen task that’s pure overhead, so generate.py passes chat_template_kwargs.enable_thinking=false to suppress it.
def invert_tree(root):
if not root:
return None
root.left, root.right = root.right, root.left
invert_tree(root.left)
invert_tree(root.right)
return root The judge
The judge loads each solution.py, calls invert_tree against a small set of trees, and prints a row of ✓ or ✗ per model.
A - means the file or function was missing entirely.
"""Run each samples/<model>/solution.py against tree-inversion cases, print a pass-fail table."""
import importlib.util
import signal
from pathlib import Path
class T:
def __init__(self, v, l=None, r=None):
self.val, self.left, self.right = v, l, r
def equal(a, b):
if a is None or b is None:
return a is b
return a.val == b.val and equal(a.left, b.left) and equal(a.right, b.right)
def cases():
return [
("empty", None, None),
("single", T(1), T(1)),
("simple-3", T(1, T(2), T(3)), T(1, T(3), T(2))),
("balanced", T(4, T(2, T(1), T(3)), T(7, T(6), T(9))), T(4, T(7, T(9), T(6)), T(2, T(3), T(1)))),
("left-skewed", T(1, T(2, T(3))), T(1, None, T(2, None, T(3)))),
]
def _alarm(*_):
raise TimeoutError()
signal.signal(signal.SIGALRM, _alarm)
def evaluate(model_dir):
spec = importlib.util.spec_from_file_location(model_dir.name, model_dir / "solution.py")
mod = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(mod)
fn = mod.invert_tree
except Exception:
return ["-"] * 5
marks = []
for _, inp, expected in cases():
signal.alarm(2)
try:
marks.append("✓" if equal(fn(inp), expected) else "✗")
except Exception:
marks.append("✗")
finally:
signal.alarm(0)
return marks
def main():
samples = Path(__file__).parent / "samples"
rows = sorted((d.name, evaluate(d)) for d in samples.iterdir() if d.is_dir())
names = [c[0] for c in cases()]
print("| model | " + " | ".join(names) + " | total |")
print("|" + "---|" * (len(names) + 2))
for name, marks in rows:
print(f"| {name} | " + " | ".join(marks) + f" | {sum(m == '✓' for m in marks)}/{len(names)} |")
if __name__ == "__main__":
main() | model | empty | single | simple-3 | balanced | left-skewed | total |
|---|---|---|---|---|---|---|
| gemma-4-e4b-it-cpu | ✓ | ✓ | ✓ | ✓ | ✓ | 5/5 |
| gemma-4-e4b-it-gpu | ✓ | ✓ | ✓ | ✓ | ✓ | 5/5 |
| qwen3.5-4b-cpu | ✓ | ✓ | ✓ | ✓ | ✓ | 5/5 |
| qwen3.5-4b-gpu | ✓ | ✓ | ✓ | ✓ | ✓ | 5/5 |
$ python3 harness.py · captured at build, 2026-06-08Both models pass all five trees. The thing that supposedly cost a Homebrew author a job at Google is a non-event for a 4B model sitting on my desk. That is the whole result, and it is the part I keep turning over.
Performance
| model | device | tokens | tok/s | gen time | memory peak | compute avg |
|---|---|---|---|---|---|---|
| Gemma 4 E4B Instruct | GPU | 877 | 150.6/s | 5.82s | 5.82 GiB | 79.1% |
| Gemma 4 E4B Instruct | CPU | 733 | 12.2/s | 60.04s | 5.75 GiB | 1182.9% |
| Qwen3.5 4B | GPU | 53 | 163/s | 0.33s | 5.38 GiB | 14.7% |
| Qwen3.5 4B | CPU | 53 | 11.8/s | 4.48s | 3.63 GiB | 1135.9% |
nvidia-smi GPU%. CPU rows report process RSS + per-core CPU% (can exceed 100% across cores). Samplers run at 200 ms cadence, so very short runs (sub-second) get only a handful of samples and the percentage is noisy.GPU runs about 12× faster than CPU for both models on this hardware. For each model both raw-response.txt and the extracted solution.py are byte-for-byte identical across CPU and GPU, which is what temperature 0 and a pinned seed are supposed to buy you. The tokens column is the odd one. It comes from llama-server's timings.predicted_n field, which does not always match the text the model returned. Gemma reports 877 tokens on GPU and 733 on CPU for the same 290-byte response, and I haven't dug into why.