Blog

Train an LLM on an iGPU: nanochat (Karpathy) on a plain integrated GPU

No graphics card, no CUDA: an Intel UHD 770 iGPU trains nanochat, Karpathy's mini-ChatGPT, from scratch in under an hour with PyTorch XPU. Measured figures, iGPU + CPU, ready-to-run script.

Train an LLM on an iGPU: nanochat (Karpathy) on a plain integrated GPU

nanochat is Andrej Karpathy’s project that shows, in a single readable repository, every step of a “ChatGPT”: tokenizer, pretraining, fine-tuning (SFT) and a chat interface. It is designed for 8 NVIDIA H100 GPUs, but it also offers a reduced configuration for CPU.

We ran it entirely on an iGPU: the Intel UHD 770, the GPU built into the processor (a desktop Core i5), with no dedicated graphics card and no CUDA. All of it with PyTorch XPU, the native support for Intel GPUs in PyTorch, then with the iGPU and the CPU computing together.

Results in short

  • A plain iGPU is enough to train an LLM from scratch: installation, data, tokenizer, pretraining (33 min), SFT (about 6 min) and first answers in under an hour, on the VM’s integrated GPU, with no graphics card.
  • 1.33 s per pretraining step, or about 6,150 tokens/s, for a small 4-layer model. On the same work, the GPU is about 1.3 times faster than the 6 vCPUs.
  • iGPU and CPU together: by giving 4 rows out of 16 in each batch to the CPU, pretraining goes from 1.33 to 1.13 s per step (15% less time), for almost the same model quality (validation 1.55 vs 1.52). Giving the CPU more work actually slows things down.
  • The resulting model plays the assistant and speaks English, but stays incoherent: that is down to the model size (37 million parameters, mostly embedding tables, 39 min of training), not the platform.
  • It took 4 adaptations for the Intel GPU and 4 fixes to nanochat for a small configuration. Everything is in a one-command script, at the end of the article.

Why an iGPU?

An iGPU (integrated GPU) is the graphics part that sits inside the processor, on the same chip as the CPU cores. It is usually reserved for the display; here, it is used for compute.

  • It shares the server’s RAM instead of having its own video memory. No 8 or 12 GB VRAM limit, and no costly copies between CPU and GPU: this is what makes the iGPU + CPU training below efficient. Our model only used 1.5 GB.
  • PyTorch supports it natively (XPU build), torch.compile included: the same code as with CUDA, give or take a few lines.
  • It costs a fraction of a dedicated GPU: our iGPU Pro plan is €44.99 per month excl. VAT, while a dedicated GPU in the cloud often rents for €300 to €500 per month.
  • It is much less powerful (under 1 TFLOPS): it does not replace a dedicated GPU for training a large model. It is enough for learning, experimenting and running small models.

Test setup

ItemValue
Virtual machineiGPU Pro plan: 6 vCPU, 24 GB RAM, Debian 13, kernel 7.3
CPU / GPUIntel Core i5-13500, integrated Intel UHD 770 GPU (shared with other VMs)
Softwarenanochat (commit 92d63d4), PyTorch 2.9.1 XPU build, torch.compile with Triton XPU
Modeld4: 4 layers, width 256, 512-token context, 32,768-token vocabulary
Pretraining1,500 steps of 8,192 tokens (12.3 million tokens)
SFT220 steps of 8,192 tokens, a mix of conversations, MMLU and GSM8K

This is nanochat’s “CPU” configuration, scaled down to fit in under an hour. The real nanochat (speedrun.sh, a 20-layer model) takes about 4 h on 8 H100s: that is three orders of magnitude more compute, out of reach for an integrated GPU.

1. The numbers

Raw compute

2048×2048 matrix multiplicationTFLOPS
fp320.71
bf160.73

The GPU is shared: when another VM is using it, we measured as low as 0.27 TFLOPS. This GPU has no dedicated matrix units, so bf16 is no faster than fp32, but it uses half the memory.

Training and chat

StageDurationDetail
Installation (packages, nanochat, PyTorch XPU)~1 min
Data (8 files) + tokenizer50 son the CPU
1st pretraining iteration83 sGPU kernel compilation, once only
Pretraining, 1,500 steps33 min1.33 s/step, ~6,150 tokens/s, 1.5 GB of GPU memory
SFT, 220 steps~6 min~1.5 s/step
1st chat answer3 mincompilation
Following answers13 to 15 slong answers, up to the token limit

GPU vs CPU, same work

SFT, steps of 16,384 tokenss/steptokens/s
Intel UHD 770 GPU2.646,200
6 vCPU (i5-13500)3.0 to 3.54,630

The learning curve

StartEnd
Pretraining, validation bits per byte3.201.52
SFT, validation bits per byte1.351.10

Lower is better: it means the model predicts text more accurately. Both curves go down steadily, with no incidents.

2. iGPU and CPU together

The GPU computes while the 6 vCPUs wait. So we made both work at the same time (patch nanochat_hybrid_patch.py):

  1. Each batch of 16 rows is split in two: one part goes to the GPU, the other to a copy of the model in CPU memory. Both compute in parallel.
  2. The gradients of both parts are added together, weighted by the number of rows. This is mathematically the same computation as a full batch on a single processor.
  3. The optimizer updates the model on the GPU, then the weights are copied back to the CPU copy. On an integrated GPU, the GPU and CPU share the same memory: this copy costs about 0.1 s per step.

Everything runs in fp32: this server’s CPU has no fast bf16 instructions, and on this GPU fp32 is as fast as bf16.

How much work to give the CPU?

GPU + CPU rows (CPU threads)s/stepCompared with GPU only in fp32 (1.40 s)
16 + 0 (GPU only)1.40baseline
12 + 4 (3 or 4 threads)1.16−17%
10 + 6 (5 threads)1.32−6%
10 + 6 (4 threads)1.41no gain
8 + 8 (4 threads)1.43slower

Counter-intuitive: giving more rows to the CPU slows everything down. Our measurements show that the CPU always finishes before the GPU, but that the GPU part gets slower while the CPU is computing: the cores doing the computation are no longer available to drive the GPU (launching its kernels, handling automatic differentiation). The best setting therefore leaves some headroom: 4 rows out of 16 and 3 compute threads on 6 vCPUs.

The full run, GPU only vs GPU + CPU

Pretraining, 1,500 stepsGPU only (bf16)GPU + CPU (fp32)
Time per step1.33 s1.13 s
Tokens per second~6,150~7,200
Total duration33 min28 min
Final validation (bits per byte)1.521.55
SFT then chatidenticalidentical

The final validation is slightly worse in the hybrid run (1.55 vs 1.52). We have not isolated the cause yet: the switch to fp32, or an effect of splitting the work between GPU and CPU.

Two limits to keep in mind: the gain depends on the number of vCPUs (with 2 vCPUs, there is nothing to share), and we only applied this method to pretraining, the longest stage. SFT runs on the GPU alone.

3. What the model answers

What is the capital of France? The city of France has a fundamental concept in Japan, which is a city of France’s Italian history and country. […] a fundamental concept in Paris and the way to showcase the world’s most fundamental and modern aspects of the history…

Hi! Who are you? I’m glad you’ve got a few minutes of time. We’re passionate about the concept of “A”…

The model has learned the shape of an assistant answer and some word associations (France, Paris), but not how to reason: with 4 layers and 39 minutes of compute, that is the expected result. The base model, before SFT, just continued the text without answering.

4. What we had to change

For the Intel GPU (PyTorch XPU)

  1. Recognizing the GPU: nanochat only knows cuda, mps and cpu. We add xpu (detection, synchronization, memory).
  2. No double-precision (fp64) compute on this GPU: one line of the rotary embeddings computation went through fp64 and crashed (UR error: 45). We rewrite it in fp32.
  3. Level Zero headers: torch.compile compiles its GPU kernels with Triton XPU, which needs the libze-dev package.
  4. 512 work-item limit per group: sorting the 32,768 vocabulary tokens (topk) exceeds this limit. We do it on the CPU; the cost is negligible.

For nanochat in a small configuration

These four problems also show up on CPU: they come from the reduced configuration (512-token context), not from the GPU.

  1. The SFT loader gets stuck: it only takes conversations that fit entirely in 513 tokens. Longer ones stay in its queue until it fills up, after which it only produces padding: the model stops learning, drifts and ends up at nan. We truncate conversations to 513 tokens.
  2. A nan loss when a batch contains no answer tokens (0/0 division). We divide by at least 1.
  3. A learning rate too high for such a small model: we use 20% of the default value for SFT, and we stop at 220 steps (beyond that, the model collapsed suddenly, always at the same step).
  4. The chat loaded an old model: chat_cli picks the checkpoint with the highest step number, not the most recent one. We pass it -s 219.

5. Run nanochat on your iGPU, step by step

Everything is done over SSH on the VM (Debian 13, recent kernel). Allow one hour, 40 minutes of which is compute.

All the code (script, Docker, patches) is on GitHub: LinuxGPU-VPS/examples/nanochat-xpu.

Quick option: a single command

curl -fsSLO https://raw.githubusercontent.com/LinuxGPU-VPS/examples/main/nanochat-xpu/run.sh
sudo bash run.sh                      # iGPU only
sudo HYBRID_CPU_ROWS=4 bash run.sh    # iGPU + CPU

The script runs exactly the steps below, times each one in ~/nanochat-logs/timeline.txt and stops at the first error.

Docker option

The same pipeline in a container, with nothing to install on the machine except Docker:

git clone https://github.com/LinuxGPU-VPS/examples
cd examples/nanochat-xpu
docker compose up --build                       # iGPU only
HYBRID_CPU_ROWS=4 docker compose up --build     # iGPU + CPU
docker compose run --rm nanochat /opt/linuxgpu/train.sh chat   # then chat with the model

The image (Ubuntu 24.04) includes the Intel GPU driver (Level Zero), PyTorch XPU, nanochat and the patches. The container reaches the iGPU through /dev/dri. Data and models go to ./data, and the time of each step to ./logs/timeline.txt.

1. Check that the iGPU is visible

ls /dev/dri    # card0 and renderD128: renderD128 is the iGPU

On our VMs, the gpu-check command also shows “Intel(R) UHD Graphics 770” and tests OpenCL, Vulkan and video encoding.

2. Install the system packages

sudo apt-get install -y git curl build-essential libze-dev

libze-dev contains the Level Zero headers, the low-level interface of Intel GPUs. Without them, torch.compile fails when it compiles its kernels for the iGPU (error level_zero/ze_api.h: No such file or directory).

3. Install nanochat and PyTorch for Intel GPUs (XPU)

git clone https://github.com/karpathy/nanochat && cd nanochat
git checkout 92d63d4                  # the tested version
curl -LsSf https://astral.sh/uv/install.sh | sh && export PATH=$HOME/.local/bin:$PATH
uv venv && uv sync --extra cpu        # nanochat dependencies (with CPU PyTorch)
VIRTUAL_ENV=$PWD/.venv uv pip install --reinstall torch==2.9.1 \
  --index-url https://download.pytorch.org/whl/xpu   # replaces CPU PyTorch with the XPU build
.venv/bin/python -c "import torch; print(torch.xpu.is_available(), torch.xpu.get_device_name(0))"
# True Intel(R) UHD Graphics 770

xpu is the name PyTorch gives to Intel GPUs, like cuda for NVIDIA. The XPU build brings everything you need (SYCL runtime, Triton XPU for torch.compile): no proprietary driver to install. Do not run uv sync again afterwards, it would put the CPU version back.

4. Apply the patches for the iGPU

curl -fsSLO https://raw.githubusercontent.com/LinuxGPU-VPS/examples/main/nanochat-xpu/nanochat_xpu_patch.py
python3 nanochat_xpu_patch.py .

The patch changes a few lines of nanochat. The three changes the iGPU cannot do without:

# nanochat/common.py: pick the iGPU when it is present
elif torch.xpu.is_available():
    device_type = "xpu"

# nanochat/gpt.py: the iGPU has no fp64, so we compute the frequencies in fp32
inv_freq = torch.exp(channel_range * (-math.log(base) / head_dim))

# nanochat/engine.py: sorting the 32,768 tokens exceeds the iGPU limit (512 work-items)
vals, idx = torch.topk(logits.float().cpu(), k, dim=-1)

It also adds the nanochat fixes described above (SFT loader, nan loss, non-finite gradients).

5. Set up the environment

export NANOCHAT_BASE_DIR=$HOME/.cache/nanochat   # data, tokenizer and models
export NANOCHAT_DTYPE=bfloat16                   # otherwise nanochat computes in fp32 outside CUDA
export OverrideDefaultFP64Settings=1             # fp64 emulation by the Intel driver,
export IGC_EnableDPEmulation=1                   # in case an operation needs it
P=.venv/bin/python

6. Download the data and train the tokenizer (CPU, ~1 min)

$P -m nanochat.dataset -n 8
$P -m scripts.tok_train --max-chars=2000000000

7. Pretrain the model on the iGPU (~33 min)

$P -m scripts.base_train --device-type=xpu --depth=4 --head-dim=64 --window-pattern=L \
  --max-seq-len=512 --device-batch-size=16 --total-batch-size=8192 \
  --eval-every=500 --eval-tokens=131072 --core-metric-every=-1 --sample-every=500 \
  --num-iterations=1500 --run=dummy

--device-type=xpu sends the compute to the iGPU. The first step takes about 80 s: PyTorch compiles its kernels for the iGPU, only once. During training, nvtop (installed on our VMs) shows the iGPU load.

To make the iGPU and the CPU work together (15% less time), also apply the second patch and run the same command with three variables:

curl -fsSLO https://raw.githubusercontent.com/LinuxGPU-VPS/examples/main/nanochat-xpu/nanochat_hybrid_patch.py
python3 nanochat_hybrid_patch.py .
NANOCHAT_DTYPE=float32 NANOCHAT_CPU_ROWS=4 NANOCHAT_CPU_THREADS=3 \
  $P -m scripts.base_train --device-type=xpu ...   # same options as above

8. Fine-tuning (SFT), then chat (~6 min)

NANOCHAT_CLIP=1.0 $P -m scripts.chat_sft --device-type=xpu --eval-every=50 \
  --eval-tokens=65536 --chatcore-every=-1 --num-iterations=220 --init-lr-frac=0.2 --run=dummy
$P -m scripts.chat_cli --device-type=xpu -s 219 -p "What is the capital of France?"
$P -m scripts.chat_cli --device-type=xpu -s 219    # conversation mode

-s 219 picks the model from the SFT (the last step). Without this option, chat_cli takes the model with the highest step number, which may come from an earlier run.

Takeaways

  • An iGPU really can train a small language model, with no dedicated graphics card: native PyTorch, torch.compile, no CUDA, and the CPU can help it out (15% less time).
  • It is the right playground for learning how an LLM works end to end, changing the code and seeing the effect within minutes, not for training a useful production model.
  • To serve an already trained model, see instead our guides on Hugging Face models on an iGPU and inference with OpenVINO.

Try it

All our VMs have an Intel UHD 770 iGPU usable by PyTorch XPU, OpenCL and Vulkan out of the box, from €14.99 per month excl. VAT. This test ran on the iGPU Pro plan (6 vCPU, 24 GB RAM, 240 GB SSD, the largest share of the iGPU), at €44.99 per month excl. VAT: the right choice for training, thanks to the vCPUs that help the iGPU. See the iGPU plans.