ai newsSa., 24. Okt.
EilmeldungToken Beggar: oeffentliches Audit des Claude Code Plugins zur Kostenersparnis

Gemma 4 E2B auf 4-GB-GPU: Mit purem PyTorch schneller als llama.cpp

Eine Schritt-für-Schritt-Anleitung zeigt, wie sich Googles QAT-Variante Gemma 4 E2B auf einer GTX 1650 Ti mit normalem PyTorch und Hugging Face Transformers betreiben lässt. Ein kleiner OpenAI-kompatibler Server macht das Modell als lokalen Server nutzbar.

dev.toSamstag 15:44 · 24.10.DE10 Min. Lesezeit
Bild: dev.to

Dieser Artikel bietet eine Schritt-für-Schritt-Anleitung, um Gemma 4 E2B, Googles quantization-aware trainierten (QAT) Build, auf einer 4-GB-NVIDIA-GTX-1650-Ti-Laptop-GPU mit reinem PyTorch und Hugging Face Transformers zu betreiben. Ein kleiner OpenAI-kompatibler Server wird aufgebaut, um das Ergebnis wie jeden anderen lokalen Modellserver laufen zu lassen.

Kann pures PyTorch Gemma 4 auf einer 4-GB-GPU ausführen? Ja, und schneller als llama.cpp bei Chat-typischer Kontextlänge, solange das Modell 4-Bit von der Festplatte bis zur Arithmetik bleibt:

Jedes Gewicht bleibt auf dem 4-Bit-Raster, auf dem Google trainiert hat, und die Geschwindigkeit kommt daher, dass die 4-Bit-Arithmetik in Kernels ausgeführt wird, die die gepackten Gewichte direkt lesen.

Gemma 4 E2B speichert etwa 4,7 Milliarden Parameter (8,73 GiB in 16 Bit), die meisten davon in zwei Vokabular-Tabellen, und eine 4-GB-Laptop-GPU zeigt für PyTorch 3.732 MiB an. Google veröffentlicht E2B bereits auf 4-Bit-Gewichte trainiert. Die Fragen sind, ob pures PyTorch es innerhalb dieses Speichers ausführen kann, wie schnell es dekodiert, wie viel Kontext daneben passt und ob die Antworten erhalten bleiben, gemessen an llama.cpp auf derselben Karte.

Die Karte ist eine GTX 1650 Ti mit Max-Q-Design (Turing TU117, keine Tensor Cores) in einem Laptop. Die llama.cpp-Ergebnisse derselben Karte finden sich in Do Drafters Work on Small Models?.

Auf 16 Bit auf der CPU entpackt wiegen die Teile des Modells:

{"embed_tokens": 0.75, "layers": 3.579, "embed_tokens_per_layer": 4.375, "total": 8.731} # GiB Die 35 Decoder-Schichten allein beanspruchen 3,58 GiB, gegenüber den 3,65 GiB, die die Karte PyTorch zur Verfügung stellt, noch ohne jeden Arbeitspeicher. Die pro Schicht angelegte Embedding-Tabelle mit 4,38 GiB ist Gemma 4s Eingabetabelle pro Schicht für die E-Größen: eine Zeile mit 35 × 256 Werten für jedes Token des 262.144 Token umfassenden Vokabulars.

Das Checkpoint ist xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text: Googles QAT-E2B, umgepackt ins compressed-tensors-Format W4A16 (4-Bit-Gewichte in Gruppen von 32, 16-Bit-Aktivierungen), nur Text. Jeder 4-Bit-Code darin entspricht Googles QAT-Release:

{"elements": 1876819968, "elements_differing": 187046012, "int4_codes_disagreeing": 0, "worst_diff_in_units_of_group_step": 0.062} Alle 1.876.819.968 Codes stimmen mit google/gemma-4-E2B-it-qat-q4_0-unquantized überein. Die 10 % der Werte, die abweichen, tun dies um höchstens 6,2 % eines Rasterschritts, da der Schritt jeder Gruppe in bf16 gespeichert wird.

Über Transformers geladen, wobei die Pro-Schicht-Tabelle im CPU-Speicher verbleibt, belegen die gepackten Schichten 1,73 GiB auf der GPU. Der erste Forward-Pass schlägt dann fehl. Transformers 5.16 wendet compressed-tensors mit run_compressed=False an (quantizer_compressed_tensors.py:114), sodass compressed-tensors jede Schicht beim ersten Aufruf auf 16 Bit entpackt, was die oben genannten 3,58 GiB erfordert. Transformers 5.20.2 macht dasselbe, und seine use_optimized_inference deckt nur W8A8 FP8 auf SM89 und neuer ab.

Entfernt man diesen Entpack-Hook und entpackt jede Schicht innerhalb ihres eigenen Forward-Passes, läuft es mit 0,58 tok/s:

{"step": "generate_warm", "prompt_tokens": 19, "new_tokens": 57, "seconds": 97.69, "tok_per_s": 0.58, "peak_cuda_mib": 1948} Schritt 2 — Die Vokabular-Tabellen in 4 Bit halten embed_tokens (das zugleich als Ausgabeschicht lm_head dient) und die Pro-Schicht-Tabelle sind im -text-Checkpoint 16-Bit. Googles QAT hat auch sie auf ein 4-Bit-Raster trainiert: Jede gestichene Gruppe von 32 enthält höchstens 16 verschiedene Werte. xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4 speichert sie als 4-Bit-Codes, wobei der Schritt jeder Gruppe aus dem Raster rekonstruiert und in 16 Bit abgelegt wird:

Der emb4-Checkpoint schreibt embed_tokens und den entkoppelten lm_head mit identischen Stufen und Skalierungen, sodass eine Kopie auf der GPU beiden dient, so wie das Modell trainiert wurde. Mit allen Tabellen in 4 Bit umfasst das gesamte Modell 2.483 MiB auf der GPU, und die Pro-Schicht-Suche findet auf der Karte statt statt im CPU-Speicher.

Die Dekodierung eines Tokens multipliziert einen Vektor mit jeder Gewichtsmatrix, daher ist die Geschwindigkeitsgrenze, wie schnell die Karte die Gewichte liest. Ein Triton-Kernel (w4gemv.py) liest die gepackten 4-Bit-Wörter, entpackt sie in Registern und multipliziert, ohne jemals eine 16-Bit-Kopie zu schreiben.

Zwei Details bringen den Großteil seiner Geschwindigkeit auf dieser Karte. Jeder 4-Bit-Wert wird zu einem Float, indem er per OR in die Bits von 2²³ eingebracht und 2²³ + 8 subtrahiert wird, was exakt ist und Turings langsame Integer-zu-Float-Umwandlung umgeht. Und jede Gewichtsform erhält ihr eigenes festes Tiling, ermittelt durch die Suche nach Tile-Größen, Warps und Pipeline-Stufen auf dieser Karte:

{"summary": {"v4": {"ms_per_token": 9.3, "GBps_effective": 137.8}, "v5": {"ms_per_token": 9.12, "GBps_effective": 140.6}}, "bytes_per_token_MiB": 1222.8} 9,1 ms pro Token für 1.222,8 MiB gepackter Gewichte ergeben 140,6 GB/s. Die Karte liest in einer einfachen Summation über den Speicher 155,9 GB/s, der Kernel läuft also mit 90 % dessen, was die Karte lesen kann.

Ein Dekodierungsschritt besteht aus Hunderten kleiner Kernels, und sie einzeln aus Python zu starten kostet mehr als ihre Ausführung. Der Schritt wird mit torch.compile kompiliert und einmal als CUDA-Graph erfasst, dann für jedes Token wiedergegeben.

Transformers' Static Cache steht dem im Weg. Seine Sliding-Window-Schicht führt die Cache-Länge als Python-Integer: Ein CUDA-Graph erfasst diesen Integer als Konstante, sodass die Sliding-Window-Maske ab dem fünften Token auf die falsche Position zeigt, und torch.compile legt Guards darauf an, sodass er bei jedem Token neu kompiliert. Die Engine führt stattdessen einen eigenen Cache (Schritt 7), mit jeder Position auf der GPU.

PyTorchs Standard-Attention ist auf dieser Karte ein speichereffizienter Kernel, der für viele Query-Tokens gebaut ist. Für ein einzelnes Query-Token brauchte er 13 ms pro Dekodierungsschritt. Gemma 4 E2B hat einen Key/Value-Kopf, der von acht Query-Köpfen geteilt wird, daher liest ein Triton-Kernel (w4attn.py) jeden gespeicherten Key und Value einmal für alle acht Köpfe, über den Cache in Slices verteilt, und kombiniert die Slices mit einer Online-Softmax:

Der Kernel vermeidet tl.dot: Auf einer Karte ohne Tensor Cores lieferte Tritons 16-Bit-Dot-Produkt 24 GB/s, gegenüber 65 GB/s für eine Multiply-and-Sum-Operation über die acht Köpfe.

Die Gate- und Up-Projektionen jedes MLP sowie die Query-, Key- und Value-Projektionen jeder Attention-Schicht, die eigene Keys und Values berechnet, lesen dieselbe Eingabe. Je zu einer Matrix zusammengefügt, werden daraus ein Kernel-Start statt zwei oder drei, 65 weniger pro Token. Auf dieser Karte brachte das etwa 1 tok/s.

Ein Prompt wird in 256-Token-Blöcken gelesen, wobei die Gewichte für jede Multiplikation zu einer vollen Matrix entpackt werden. Für 16-Bit-Matrixmultiplikationen wählt cuBLAS seine Tensor-Core-Kernels (s884gemm), und der TU117 hat keine Tensor Cores:

Das Entpacken auf 32 Bit für das Prompt-Lesen verdoppelt dessen Geschwindigkeit: Ein 4.081-Token-Prompt liest mit 226,5 tok/s gegenüber 97,7 in 16 Bit. Die Dekodierung bleibt 16-Bit.

Gemma 4 E2B hält einen vollständigen Key/Value-Cache in nur 3 seiner 35 Schichten, je ein Kopf der Breite 512: 2 KiB pro Token pro Schicht. Zwölf weitere Schichten achten über ein 512-Token-Fenster, und die letzten 20 wiederverwenden die Keys und Values früherer Schichten. Der gesamte 131.072-Token-Kontext kostet also 774 MiB:

Die Engine besitzt diesen Cache: Volle Schichten erhalten einen Slot pro Position, Sliding-Schichten einen 512-Slot-Ring, der die Position jedes Slots festhält. Dekodiergeschwindigkeit mit gehaltenem Kontext, gegenüber llama.cpp auf derselben Karte:

Sieben Schichten achten bei jedem Token über den vollständigen Cache, mit je 3,74 ms bei 131.072 Tokens in diesem Kernel, weshalb llama.cpp in der Tiefe aufholt. llama.cpp fasst den vollen Kontext ebenfalls auf dieser Karte, mit seiner Pro-Schicht-Tabelle im CPU-Speicher.

Ein Recall-Test füllt den Kontext mit nummerierten Datensätzen, jeder mit einem zufälligen sechsstelligen Code, und fragt nach dem Code des Datensatzes, der ein Viertel des Weges liegt. Dieselben Prompts, an llama-server mit ausgeschaltetem Thinking gesendet, ergeben dieselben Token-Zahlen und dieselben Antworten:

Bei 32.761 Tokens wurde ein zweiter Prompt mit anderen Codes korrekt beantwortet, bei 130.981 Tokens kam die Antwort aus dem falschen Datensatz. Einen unter Tausenden nahezu identischer Datensätze zu finden, liegt an der Grenze dessen, was E2B in dieser Tiefe leistet, in beiden Engines.

GSM8K (1.319 Mathematikaufgaben der Grundschule, 2.048-Token-Limit) und BFCL simple (400 einzelne Tool-Aufrufe) laufen durch denselben Scoring-Code, dieselben Fragesätze und dieselbe Chat-Vorlage wie die llama.cpp-Ergebnisse auf dieser Karte, greedy, und werden Frage für Frage paarweise verglichen:

Beide Engines beantworten 1.186 GSM8K-Probleme korrekt; je 26 gehen in jede Richtung. Bei BFCL trennt sie eine Frage. Die 4-Bit-Arithmetik, die 16-Bit-Dekodierung und die 32-Bit-Prompt-Lesevorgänge lassen die Antworten dort, wo llama.cpp sie hat.

Für die Sliding-Window-Schichten geht ein Prompt-Block an PyTorchs speichereffiziente Attention mit expliziter Maske, und der einzelne Key/Value-Kopf wird von den acht Query-Köpfen geteilt. Als View übertragen (expand, Stride 0), lassen diese Keys und Values den Kernel von torch 2.13 auf dieser Karte eine falsche letzte Zeile zurückgeben, wann immer der Block 33, 65, 97 … Queries enthält.

n=33 Lk=545: bool+expand 1.127 bool contiguous 0.000 float mask 1.205 D=128 1.452 H=1 0.000 Vollbildmodus verlassen Vollbildmodus verlassen Die letzte Zeile eines eingelesenen Prompts ist das Token, das das erste Antworttoken auswählt, sodass dies dazu führt, dass etwa einer von 32 Prompts seine Antwort falsch beginnt. Werden die Keys und Values zuerst kopiert (.contiguous(), höchstens 768 Keys auf diesen Layern), erhält man bei jeder Chunk-Länge die richtige Zeile. Aufrufe mit der eingebauten kausalen Maske und ohne explizite Maske, wie auf den Full-Attention-Layern, liefern mit der Broadcast-View die richtigen Zeilen zurück.

openai_server.py stellt die Engine hinter /v1/chat/completions, jeweils eine Anfrage gleichzeitig, greedy. Tool-Definitionen laufen durch Gemma 4s Chat-Template, und Tool-Aufrufe kommen im Feld tool_calls im OpenAI-Format zurück:

python3 openai_server.py --port 8100 --max-ctx 8192 curl -s localhost:8100/v1/chat/completions -H 'Content-Type: application/json' -d '{ "messages": [{"role": "user", "content": "What is the weather in Paris right now?"}], "tools": [{"type": "function", "function": { "name": "get_weather", "description": "Current weather for a city", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]} }}] }' Vollbildmodus verlassen Vollbildmodus verlassen Die Antwort mit ihrer Message, ihrem Finish-Grund und den Zeitangaben:

{ "message": { "role": "assistant", "content": null, "tool_calls": [{ "id": "call_5f097a4f09c24b0e8581e124", "type": "function", "function": { "name": "get_weather", "arguments": "{ \" city \": \" Paris \" }" }}]}, "finish_reason": "tool_calls", "usage": { "prompt_tokens": 65, "completion_tokens": 15, "total_tokens": 80 }, "timings": { "prompt_ms": 366.8, "decode_ms": 160.3, "decode_tok_per_s": 81.08 }} Vollbildmodus verlassen Vollbildmodus verlassen Eine einfache Chat-Frage, "In one sentence, what is a GPU?", kam in 34 Tokens mit 85,23 tok/s zurück. Die oben genannten BFCL- und GSM8K-Werte wurden über diesen Server gemessen. --max-ctx ist standardmäßig auf 131.072 gesetzt.

Das Notebook local-pytorch-1650ti-2b-q4w4a16/colab/notebooks/01_gemma4_e2b_int4_on_4gb.ipynb führt dieselben Schritte auf einer Colab T4 aus. Es begrenzt PyTorch auf die 3.732 MiB, die eine GTX 1650 Ti meldet ( torch.cuda.set_per_process_memory_fraction ), sodass sich eine 16-GB-T4 hinsichtlich des Speichers wie die 4-GB-Karte verhält, und vergleicht dann die Zeit für 16-Bit- mit 32-Bit-Multiplikationen, um die Prompts mit der schnelleren Variante einzulesen. Dasselbe Notebook, auf beiden Karten ausgeführt:

Durch die Tensor Cores der T4 ist das Einlesen von Prompts in 16 Bit zehnmal schneller als die 32-Bit-Variante des Laptops; beim Decoding, das davon begrenzt ist, wie schnell die Karte die Gewichte liest, ergibt sich ein Gewinn von 1,2 bis 1,4x. Es wird kein Hugging-Face-Token benötigt, und alle Checkpoints sind öffentlich verfügbar.

Um E2B auf dieser Karte zu betreiben, ist llama.cpp heute bereits einsatzbereit, hält längere Kontexte besser und liest Prompts schneller. Pures PyTorch kommt bei Chat-Länge an sie heran, sobald das Modell durchgehend in 4 Bit bleibt, und jeder Bestandteil ist Python und Triton, das man lesen und ändern kann. Die Schritte lassen sich auf jede kleine GPU übertragen: QAT-Gewichte gepackt halten, aus den gepackten Wörtern multiplizieren, den Cache selbst verwalten und das Decoding als Graph abspielen.

Ziel dieses Artikels war es, Gemma 4 E2B in pures PyTorch auf einer 4-GB-GTX 1650 Ti zu bringen. Der Schlüssel zur Lösung lag darin, Googles QAT-4-Bit-Gewichte von der Festplatte bis zur Arithmetik gepackt zu halten, mit Triton-Kernels, die sie direkt lesen, und dem Decode-Schritt, der als ein einzelner CUDA-Graph abgespielt wird. Die Ergebnisse waren:

Umfang: Nur Gemma 4 E2B-it QAT, nur Text, eine GTX 1650 Ti Max-Q (4 GB) in einem Laptop, torch 2.13.0+cu130, Triton 3.7.1, transformers 5.16.1, greedy Decoding, durchgehend jeweils eine Anfrage gleichzeitig. Die Decode-Geschwindigkeiten beziehen sich auf 63 bis 128 generierte Tokens nach dem Prompt; llama.cpp 7182ef7 wurde am selben Tag auf derselben Karte mit llama-bench gemessen, mit 64 bis 128 generierten Tokens an einer angegebenen Tiefe, daher sind die Spalten zum Prompt-Einlesen unterschiedliche Messungen. Die GSM8K- und BFCL-Referenz ist llama.cpp 77c4540ce auf derselben Karte am 14.10.2026 mit 8 Anfragen gleichzeitig. Für den Recall wurden vier Prompts verwendet, zu wenige für eine Quote.

Die Strategie, Gemma 4 auf einer 4-GB-GPU in pures PyTorch zu bringen, wurde mit einem inkrementellen, schrittweisen Ansatz validiert.

Automatisch übersetzt. Das Original auf Englisch ist unten aufklappbar.

Original (Englisch) anzeigen

This article provides a step by step guide to running Gemma 4 E2B, Google's quantization-aware trained (QAT) build, on a 4 GB NVIDIA GTX 1650 Ti laptop GPU with plain PyTorch and Hugging Face Transformers. A small OpenAI-compatible server is built to run the result like any other local model server.

Can plain PyTorch run Gemma 4 on a 4 GB GPU? Yes, and faster than llama.cpp at chat-length context, once the model stays 4-bit from disk to arithmetic:

Every weight stays on the 4-bit grid Google trained, and the speed comes from doing the 4-bit arithmetic in kernels that read the packed weights directly.

Gemma 4 E2B stores about 4.7 billion parameters (8.73 GiB in 16-bit), most of them in two vocabulary tables, and a 4 GB laptop GPU shows PyTorch 3,732 MiB. Google publishes E2B already trained for 4-bit weights. The questions are whether plain PyTorch can run it inside that memory, how fast it decodes, how much context fits beside it, and whether the answers survive, measured against llama.cpp on the same card.

The card is a GTX 1650 Ti with Max-Q design (Turing TU117, no tensor cores) in a laptop. The same card's llama.cpp results are in Do Drafters Work on Small Models?.

Unpacked to 16-bit on the CPU, the model's parts weigh:

{"embed_tokens": 0.75, "layers": 3.579, "embed_tokens_per_layer": 4.375, "total": 8.731} # GiB Enter fullscreen mode Exit fullscreen mode The 35 decoder layers alone are 3.58 GiB against 3.65 GiB the card gives PyTorch, before any working memory. The per-layer embedding table, 4.38 GiB, is Gemma 4's per-layer input table for the E sizes: a row of 35 × 256 values for every token in the 262,144-token vocabulary.

The checkpoint is xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text: Google's QAT E2B repacked into the compressed-tensors W4A16 format (4-bit weights in groups of 32, 16-bit activations), text only. Every 4-bit code in it equals Google's QAT release:

{"elements": 1876819968, "elements_differing": 187046012, "int4_codes_disagreeing": 0, "worst_diff_in_units_of_group_step": 0.062} Enter fullscreen mode Exit fullscreen mode All 1,876,819,968 codes agree with google/gemma-4-E2B-it-qat-q4_0-unquantized. The 10% of values that differ do so by at most 6.2% of one grid step, from storing each group's step in bf16.

Loaded through Transformers with the per-layer table left in CPU memory, the packed layers take 1.73 GiB on the GPU. The first forward pass then fails. Transformers 5.16 applies compressed-tensors with run_compressed=False ( quantizer_compressed_tensors.py:114 ), so compressed-tensors unpacks every layer to 16-bit on the first call, which needs the 3.58 GiB above. Transformers 5.20.2 does the same, and its use_optimized_inference covers W8A8 FP8 on SM89 and newer only.

Removing that unpack hook and unpacking each layer inside its own forward pass runs, at 0.58 tok/s:

{"step": "generate_warm", "prompt_tokens": 19, "new_tokens": 57, "seconds": 97.69, "tok_per_s": 0.58, "peak_cuda_mib": 1948} Enter fullscreen mode Exit fullscreen mode Step 2 — Keep the Vocabulary Tables in 4-Bit embed_tokens (which doubles as the output layer, lm_head ) and the per-layer table are 16-bit in the -text checkpoint. Google's QAT trained them onto a 4-bit grid as well: every sampled group of 32 holds at most 16 distinct values. xbill9/gemma-4-E2B-it-qat-q4_0-w4a16-ct-text-emb4 stores them as 4-bit codes with each group's step recovered from the grid, in 16-bit:

The emb4 checkpoint writes embed_tokens and the untied lm_head as identical levels and scales, so one copy on the GPU serves both, as the model was trained. With every table in 4-bit, the whole model is 2,483 MiB on the GPU, and the per-layer lookup happens on the card instead of in CPU memory.

Decoding one token multiplies one vector by every weight matrix, so the speed limit is how fast the card reads the weights. A Triton kernel ( w4gemv.py ) reads the packed 4-bit words, unpacks them in registers and multiplies, never writing a 16-bit copy.

Two details carry most of its speed on this card. Each 4-bit value becomes a float by OR-ing it into the bits of 2²³ and subtracting 2²³ + 8, which is exact and skips Turing's slow integer-to-float conversion. And each weight shape gets its own fixed tiling, chosen by searching tile sizes, warps and pipeline stages on this card:

{"summary": {"v4": {"ms_per_token": 9.3, "GBps_effective": 137.8}, "v5": {"ms_per_token": 9.12, "GBps_effective": 140.6}}, "bytes_per_token_MiB": 1222.8} Enter fullscreen mode Exit fullscreen mode 9.1 ms per token for 1,222.8 MiB of packed weights is 140.6 GB/s. The card reads 155.9 GB/s in a plain sum over memory, so the kernel runs at 90% of what the card can read.

A decode step is hundreds of small kernels, and launching them one at a time from Python costs more than running them. The step is compiled with torch.compile and captured once as a CUDA graph, then replayed for every token.

Transformers' static cache stands in the way. Its sliding-window layer keeps the cache length as a Python integer: a CUDA graph captures that integer as a constant, so the sliding-window mask points at the wrong position from the fifth token on, and torch.compile guards on it, so it recompiles at every token. The engine keeps its own cache instead (Step 7), with every position on the GPU.

PyTorch's default attention on this card is a memory-efficient kernel built for many query tokens. For one query token it took 13 ms per decode step. Gemma 4 E2B has one key/value head shared by eight query heads, so a Triton kernel ( w4attn.py ) reads each cached key and value once for all eight heads, split across the cache in slices, and combines the slices with an online softmax:

The kernel avoids tl.dot: on a card without tensor cores, Triton's 16-bit dot product ran at 24 GB/s, against 65 GB/s for a multiply-and-sum over the eight heads.

The gate and up projections of each MLP, and the query, key and value projections of each attention layer that computes its own keys and values, read the same input. Concatenated into one matrix each, they become one kernel launch instead of two or three, 65 fewer per token. On this card that was worth about 1 tok/s.

A prompt is read in 256-token chunks, with the weights unpacked to a full matrix for each multiply. For 16-bit matrix multiplies cuBLAS picks its tensor-core kernels ( s884gemm ), and the TU117 has no tensor cores:

Unpacking to 32-bit for prompt reads doubles their speed: a 4,081-token prompt reads at 226.5 tok/s against 97.7 in 16-bit. Decode stays 16-bit.

Gemma 4 E2B keeps a full-length key/value cache in only 3 of its 35 layers, one head of width 512 each: 2 KiB per token per layer. Twelve more layers attend over a 512-token window, and the last 20 reuse earlier layers' keys and values. So the whole 131,072-token context costs 774 MiB:

The engine owns that cache: full layers get one slot per position, sliding layers a 512-slot ring that records each slot's position. Decode speed with the context held, against llama.cpp on the same card:

Seven layers attend over the full-length cache every token, at 3.74 ms each at 131,072 tokens in this kernel, which is where llama.cpp pulls ahead at depth. llama.cpp also fits the full context on this card, with its per-layer table in CPU memory.

A recall test fills the context with numbered records, each with a random six-digit code, and asks for the code of the record a quarter of the way in. The same prompts, sent to llama-server with thinking off, give the same token counts and the same answers:

At 32,761 tokens a second prompt with different codes was answered correctly, and at 130,981 tokens the answer came from the wrong record. Finding one of thousands of near-identical records is at the edge of what E2B does at this depth, in either engine.

GSM8K (1,319 grade-school math problems, 2,048-token limit) and BFCL simple (400 single tool calls) run through the same scoring code, question sets and chat template as the llama.cpp results on this card, greedy, and are paired question by question:

Both engines answer 1,186 GSM8K problems correctly; 26 go each way. On BFCL one question separates them. The 4-bit arithmetic, 16-bit decode and 32-bit prompt reads leave the answers where llama.cpp has them.

For the sliding-window layers a prompt chunk goes to PyTorch's memory-efficient attention with an explicit mask, and the single key/value head is shared by the eight query heads. Broadcast as a view ( expand, stride 0), those keys and values make torch 2.13's kernel return a wrong last row whenever the chunk holds 33, 65, 97 … queries, on this card:

n=33 Lk=545: bool+expand 1.127 bool contiguous 0.000 float mask 1.205 D=128 1.452 H=1 0.000 Enter fullscreen mode Exit fullscreen mode The last row of a prompt read is the token that picks the first reply token, so this shows up as about one prompt in 32 starting its reply wrong. Copying the keys and values first (.contiguous(), at most 768 keys on these layers) gives the right row at every chunk length. Calls with the built-in causal mask and no explicit mask, as on the full-attention layers, return the right rows with the broadcast view.

openai_server.py puts the engine behind /v1/chat/completions, one request at a time, greedy. Tool definitions go through Gemma 4's chat template and tool calls come back in OpenAI's tool_calls field:

python3 openai_server.py --port 8100 --max-ctx 8192 curl -s localhost:8100/v1/chat/completions -H 'Content-Type: application/json' -d '{ "messages": [{"role": "user", "content": "What is the weather in Paris right now?"}], "tools": [{"type": "function", "function": { "name": "get_weather", "description": "Current weather for a city", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]} }}] }' Enter fullscreen mode Exit fullscreen mode The reply, with its message, finish reason and timings:

{ "message": { "role": "assistant", "content": null, "tool_calls": [{ "id": "call_5f097a4f09c24b0e8581e124", "type": "function", "function": { "name": "get_weather", "arguments": "{ \" city \": \" Paris \" }" }}]}, "finish_reason": "tool_calls", "usage": { "prompt_tokens": 65, "completion_tokens": 15, "total_tokens": 80 }, "timings": { "prompt_ms": 366.8, "decode_ms": 160.3, "decode_tok_per_s": 81.08 }} Enter fullscreen mode Exit fullscreen mode A plain chat question, "In one sentence, what is a GPU?", came back in 34 tokens at 85.23 tok/s. The BFCL and GSM8K scores above were measured through this server. --max-ctx defaults to 131,072.

The notebook local-pytorch-1650ti-2b-q4w4a16/colab/notebooks/01_gemma4_e2b_int4_on_4gb.ipynb runs the same steps on a Colab T4. It caps PyTorch at the 3,732 MiB a GTX 1650 Ti shows ( torch.cuda.set_per_process_memory_fraction ), so a 16 GB T4 behaves like the 4 GB card for memory, then times 16-bit against 32-bit multiplies and reads prompts with the faster one. The same notebook, run on both cards:

The T4's tensor cores make 16-bit prompt reads ten times faster than the laptop's 32-bit ones; decode, limited by how fast the card reads the weights, gains 1.2–1.4x. No Hugging Face token is needed, and every checkpoint is public.

For serving E2B on this card, llama.cpp is ready today, holds long context better and reads prompts faster. Plain PyTorch matches it at chat length once the model stays 4-bit end to end, and every piece is Python and Triton you can read and change. The steps carry to any small GPU: keep QAT weights packed, multiply from the packed words, own the cache, and replay decode as a graph.

The goal of this article was to run Gemma 4 E2B in plain PyTorch on a 4 GB GTX 1650 Ti. The key to the solution was keeping Google's QAT 4-bit weights packed from disk to arithmetic, with Triton kernels that read them directly and the decode step replayed as one CUDA graph. The results were:

Scope: Gemma 4 E2B-it QAT only, text only, one GTX 1650 Ti Max-Q (4 GB) in a laptop, torch 2.13.0+cu130, Triton 3.7.1, transformers 5.16.1, greedy decoding, one request at a time throughout. Decode speeds are 63 to 128 generated tokens after the prompt; llama.cpp 7182ef7 was measured the same day on the same card with llama-bench, 64 to 128 generated tokens at a stated depth, so the prompt-read columns are different measurements. The GSM8K and BFCL reference is llama.cpp 77c4540ce on the same card on 2026-10-14, 8 requests at a time. Recall used four prompts, too few for a rate.

The strategy for running Gemma 4 on a 4 GB GPU in plain PyTorch was validated with an incremental step by step approach.

Originaltitel: Gemma 4 on a 4 GB GPU in Plain PyTorch: From Out of Memory to Faster Than llama.cppQuelle: dev.to (dev.to). Original öffnen