Token-Gated Language Models

LLMs that can skim their context to selectively read the tokens that matter.

Skimming beats full reads: up to 11x speedup & 3x lower memory for long-context prefill.

Paper · Quick start · How it works · Reproducing results

Current language models read all tokens of their input context, processing each one through all model layers. TokenGated Language Models can skim, selectively processing only context that matters through learned token selection for up to 11.3x faster prefill with 3.4x lower memory at no accuracy degradation. TokenGate extends existing models to enable conditional computing, akin to MoE layers, but for a different scaling dimension: long-context inputs.

TokenGate is a drop-in neural network component that extends existing trained models (e.g., trained transformers or hybrid architectures) to enable learnable token selection with light-weight adaptation. You can adapt your model with TokenGate as a replacement to standard fine-tuning or PEFT. While standard fine-tuning allows models to improve task-specific accuracy, training models with TokenGate allows models to improve both accuracy and prefill efficiency for Pareto-optimal inference performance. Training with TokenGate is as simple and efficient as standard PEFT training.

TokenGate headline results: up to 11.3× faster TTFT and 3.4× lower peak memory at matched accuracy
uv venv && uv pip install -e .     # Python ≥ 3.10, CUDA PyTorch ≥ 2.9; ".[train]" for training

Recommended: python kernels/fa2bias/build.py builds the fast FlashAttention-2 key-bias kernel that the dense-Qwen3 defaults use (needs nvcc). Everything also runs without it via FlexAttention: inference falls back automatically with a warning; training asks for an explicit `attn_backend="flex".

If you're already familiar with 🤗 transformers (from_pretrained / apply_chat_template / generate), getting started with TokenGate will be a breeze. Two things are new: a {state} placeholder marks the prunable document in your context, and generate takes a keep-budget max_kept_prefill_tokens — the number of state tokens that are retained for full processing during prefill.

The example below loads the released needle-in-a-haystack checkpoint and answers a question about one fact planted in ~32,000 tokens of essays. The sample haystack ships with the repo, so it runs as-is:

import json
from transformers import AutoTokenizer
from tokengate import load_model, apply_chat_template

# one object: base model + gate + paired adapter; gate layer and base model
# are read from the checkpoint's own metadata
model = load_model("liana313/tokengate-niah-qwen3-1.7b")
tok = AutoTokenizer.from_pretrained(model.base_model)

# ~32k tokens of essays with one planted fact about San Francisco
essays = json.loads(next(open("examples/data/niah_32k_samples.jsonl")))["body"]

prompt = ("Answer the question using only the document below. "
          "Keep your answer short and direct.\n\n"
          "Question: What is the best thing to do in San Francisco?\n\n"
          "Document:\n{state}\n\nAnswer:")
ids = apply_chat_template(tok, prompt, state=essays)

for k in (None, 2000, 500, 100):          # None = keep every state token
    out = model.generate([ids], tok, max_kept_prefill_tokens=k)
    print(k, f"{out.prefill_s * 1e3:5.0f} ms", out.answers[0])

# K      prefill   answer
# all    1471 ms   eat a sandwich and sit in Dolores Park on a sunny day
# 2000    153 ms   eat a sandwich and sit in Dolores Park on a sunny day
# 500     157 ms   eat a sandwich and sit in Dolores Park on a sunny day
# 100     130 ms   eat a sandwich and sit in Dolores Park on a sunny day

One gate serves every K: the planted needle is still answered verbatim with 144 of 32,191 prompt tokens (0.4%) in the KV cache, at ~11× lower time-to-first-token.

Training looks like a TRL/PEFT recipe: a config object plus a trainer that takes your dataset. The example below trains the same needle-in-a-haystack gate the inference example uses. First generate the synthetic haystacks:

python datasets/niah_kamradt/generate_niah_kamradt.py --output-file niah.jsonl --n-train 12000

Then train:

import json
from tokengate import TokenGateConfig, TokenGateTrainer

niah = [json.loads(l) for l in open("niah.jsonl")]
rows = lambda split: [{"article": f"{r['body']}\n\nQuestion: {r['question']}",
                       "answer": r["answer"]}
                      for r in niah if r["split"] == split]

config = TokenGateConfig(
    base_model="Qwen/Qwen3-1.7B",
    gate_layer=1,                         # layer L whose hidden states the gate scores
    output_dir="e2e_gates/niah-1p7b",
    learning_rate=1e-4,
    epochs=20,
    k_eval=[2000],                        # in-training eval K sweep
    # LoRA co-adaptation (the backbone itself stays frozen)
    lora_rank=16, lora_alpha=32, lora_lr=1e-4,
    lora_targets=["q_proj", "v_proj"],
    wandb_project="token-gate",           # omit to disable logging
)
trainer = TokenGateTrainer(
    config,
    train_dataset=rows("train"),          # [{"article": ..., "answer": ...}, ...]
    eval_dataset=rows("test"),
    task="niah_kamradt",                  # registered prompt + metric (datasets/tasks)
)
trainer.train()                           # → e2e_gates/niah-1p7b/gate_final.pt

Each row is {"article": <the prunable document>, "answer": <target>} ("state" is an alias for "article"; any HF Dataset with those columns works). The trainer tokenizes the rows, carves a deterministic eval split from eval_dataset, and launches a DDP torchrun run on all visible GPUs (num_gpus overrides), blocking until the final gate and its name-paired LoRA land in output_dir. Already-prepared data under datasets/ can be passed by name instead — train_dataset="biodex/biodex_reactions" — and uses the store's own val split.

The gate trains against randomly sampled keep-budgets, so one trained gate serves any K at inference. The Qwen3 defaults train on the key-bias kernel from Installation and fail fast without it; set attn_backend="flex" to train on the no-build kernels (the Qwen3.5 hybrid defaults to flex, and other model families must set it explicitly).

Figure 1: prefill with TokenGate, the gate module, and token-gated attention

A gate module at an early layer L scores every token of the prunable input (the state) and keeps the top-K. The layers above L process only those K tokens and store only their KV-cache entries — pruned tokens never consume compute or memory above the gate, which is where both the time-to-first-token and the peak-memory savings come from. Everything outside the {state} placeholder (instructions, the question, the generated answer) is never pruned.

The top-K selection introduces a challenge for end-to-end training since top-K is non-differentiable. To address this we introduce Token-gated attention, a mechanism which re-weights each kept token's attention logic using its token importance score, providing a gradient path for e2e training. The gate trains end-to-end with standard next-token prediction, jointly with the model backbone.

examples/niah_demo.py downloads the trained gate for Qwen3-1.7B (gate layer 1) from the Hub and runs the classic Kamradt needle-in-a-haystack on committed ~32k-token Paul Graham essay haystacks: for each keep-budget it reports retrieval accuracy, prefill time-to-first-token, and how much of the needle sentence itself survived the pruning:

python examples/niah_demo.py                 # 5 haystacks, K = all/2000/500/100
python examples/niah_demo.py --k 500 --n 2   # one budget, fewer rows

Validated output (5 haystacks, needle depths 11–100%, one GPU):

kept tokens    acc   TTFT(ms)  speedup  needle kept
------------------------------------------------------
         all   1.00     1533.6    1.00x        100%
        2000   1.00      153.4   10.00x        100%
         500   1.00      144.3   10.63x        100%
         100   1.00      137.2   11.18x        100%

Every needle is retrieved at every budget — even keeping 100 of ~32k tokens, the gate's selection lands squarely on the needle sentence.

A second demo on real data follows the same pattern: examples/biodexr_demo.py (BioDEX adverse-drug-reaction extraction; mean exact-match set F1 0.622 at K=2000 over the committed val articles). Both pipelines are covered by python -m tokengate.e2e_gate.tests.test_niah_demo and ...tests.test_biodexr_demo, and --gate-attn flex avoids the custom-kernel build at some speed cost.

For a full accuracy + latency comparison against standard prefill, benchmarks/run_benchmark.py measures, on the same samples and base model, accuracy (the task's own metric) and TTFT (prefill-to-first-token, batch size 1) for three arms: standard full prefill of the base model, TokenGate with nothing pruned (the accuracy reference), and TokenGate at each configured K. Each config in benchmarks/configs/ (niah, repobench, biodexr) pairs a released checkpoint with the prepared dataset's pinned eval split — --split val is the 50-row in-training eval partition, --split test the full reserved held-out partition:

python benchmarks/run_benchmark.py --config benchmarks/configs/niah.yaml --split test

Validated held-out test results (every reserved test row, batch 1, one H100; base = the unmodified model with full prefill):

  • dataset | n | metric | base | TokenGate (no pruning) | TokenGate @K=2000 | TTFT speedup @K=2000
  • NIAH (16k) | 150 | acc | 0.993 | 1.000 | 1.000 | 6.3×
  • RepoBench-P | 450 | ES | 0.285 | 0.587 | 0.550 | 3.8×
  • BioDEX-Reactions | 450 | F1 | 0.056 | 0.284 | 0.292 | 2.1×

Tighter budgets trade little: NIAH stays at 1.000 acc down to K=100 (8.1×), RepoBench holds 0.533 ES at K=500 (5.6×). The no-pruning arm doubles as the fine-tuning reference — its gap to base is what the task-trained gate+adapter add. The TTFT gap widens with context length (the paper's 1.4–11.3× range comes from the longer-context tiers). The 50-row val partitions reproduce the same ordering (e.g. RepoBench 0.576/0.559, BioDEX 0.377/0.368 unpruned/@K=2000).

Released checkpoints: the benchmark set above plus liana313/tokengate-2wikimqa-qwen3-1.7b, liana313/tokengate-multinews-qwen3-1.7b, and the hybrid-attention liana313/tokengate-niah-qwen3.5-2b — each repo holds the exact gate+LoRA checkpoint the paper's benchmarks used.

The released checkpoints train from the lane configs under train-scripts/ (one script per dataset, gate layer 1 — each sets every hyperparameter of its run and calls the shared driver). Install with the train extras (see Installation); the shell drivers read the interpreter from PYTHON — point it at your venv (export PYTHON=$PWD/.venv/bin/python) — and log to W&B (wandb login, or WANDB_MODE=disabled).

The trainer reads pre-prepped tokenized stores. For BioDEX-Reactions (the official splits of BioDEX, ~11.5k train articles):

# one-time download of the official dataset into the HF cache
python -c "import datasets; datasets.load_dataset('BioDEX/BioDEX-Reactions')"

# tokenize into {train,val}.pkl for the base model
python datasets/biodex/biodex_reactions/prepare.py

# train (the packed memory-mapped token store is built automatically at launch)
WANDB_PROJECT=<your-project> bash train-scripts/example/run_e2e_gate_biodexr_example.sh

Common overrides (environment variables; see the script for the full list):

NUM_GPUS=1    bash train-scripts/example/run_e2e_gate_biodexr_example.sh   # single GPU
MAX_STEPS=50  bash train-scripts/example/run_e2e_gate_biodexr_example.sh   # smoke run

Outputs land under e2e_gates/<model>/<run-name>/baseline/L<layer>/: gate_final.pt + lora_final.pt (and SWA variants), periodic resumable checkpoints, params.json, and training-curve CSVs/plots. During training the model is evaluated every EVAL_FREQ steps on the pinned val split and the reserved held-out test split, at each K in K_EVAL_VALUES, alongside no-compression and random-selection reference baselines. The released demo checkpoint was trained with exactly this config. (For a synthetic dataset with no external download, see the NIAH lanes: datasets/niah_kamradt/generate_niah_kamradt.py.)

Each dataset is a directory datasets/<dataset>/<task>/ with a prepare.py that emits rows of {article (the state), answer, id, metadata} and tokenizes them into tokenized/<model>/{train,val}.pkl. A training run pins its dataset through a YAML manifest (tokengate/datasets/manifests/iso_<target>.yaml) and pins its val/test split through an eval-split manifest — the explicit source of eval-set sizes. Scoring lives in tokengate/datasets/tasks/ (one Task class per task: prompt format, metric, judge config).

If you find TokenGate useful, please cite our paper

@article{patel2026tokengate,
  title={TokenGate: Adapting LLMs for Fast and Memory-Efficient Prefill with Learned Token Pruning},
  author={Patel, Liana and Ranjan, Rishabh and Stoica, Ion and Zaharia, Matei and Guestrin, Carlos},
  journal={arXiv preprint arXiv:2610.26754},
  year={2026}
}