Watching one Qwen3.8 run on my GPU

I had Qwen3.8-27B running in Unsloth Studio on my RTX 5070 Ti. It worked surprisingly well, but while it was generating I could only see a chat spinner. I wanted to know what my GPU was actually doing.

I used a small problem, Solve xx=10x^x = 10, and recorded the whole conversation. Codex helped me set up the capture, analyze it and build the diagrams. I chose and ran everything locally and reviewed the result.

My first diagram was still too magical. Then I replaced the magic with names such as residual stream, RMS norm and Gated DeltaNet. That was not much better. If you do not already know the architecture, jargon inside boxes is still a black box.

I like the aim of Brendan Bycroft’s LLM visualizer: follow the numbers and show the operations. I find its 3D presentation difficult to navigate, so I stayed in two dimensions and went down to arithmetic instead.

The examples below use rows with two numbers so I can work them out on screen. Next to them I show the real Qwen sizes. The CUDA tab connects the same calculations to measured kernel time and small code fragments. It is the bridge I was missing between a source file, a profiler and the boxes in a model diagram.

I also ran the model again with a small patch to llama.cpp. That run saved the exact token IDs, tensor shapes and numerical summaries at a few checkpoints. I keep it separate from the performance trace because copying tensors to the CPU changes timing.

one Qwen pass, reduced to arithmeticQwen3.8-27B-UD-IQ2_S.gguf
exact route · tiny worked examples

The small numbers below are made up so the calculation fits on screen. The operations and the large sizes beside them come from the exact llama.cpp source and GGUF used for the rerun.

textSolve xx = 10numbered pieces373 IDstable lookup5,120 numbers per IDchange the numbers64 roundsscore endings248,320 scoresnext piece“We”
what enters the modelThe visible question is 9 pieces, not 6 boxes
SID 50olveID 3815·xID 830^ID 61xID 87·=ID 283·ID 2201ID 160ID 15

Qwen never receives words. It receives these integers. ID 830 means ·x; the dot shows that the leading space is part of the piece.

reasoning + tool setup: 354visible user turn: 14assistant opening: 5

Only 14 IDs belong to the user turn. The other 359 describe the tool, the chat format and the start of Qwen's answer.

show all 373 IDs
<|im_start|>248045system8678198Reason24342ing286·effort4879·is369·set716·to310·x830high11553.13·Please5044·think1683·carefully15060·through1472·the279·task3274,11·validate9307·key1328·assumptions30800,11·consider2814·plausible47675·alternatives25605,11·and321·prioritize60445·correctness55404,11·consistency27224,11·and321·clarity30246·in303·the279·final1534·answer4087.13↵↵271#2·Tools13455↵↵271You2523·have599·access2528·to310·the279·following2614·functions5568:25↵↵271<27tools15449>29198{"4754type1267":763·"328function1628",487·"328function1628":763·{"5046name591":763·"328python12305",487·"328description4532":763·"328Run6516·a264·short2716·Python12654·220318.13116217·program1957·in303·a264·disposable42340·container5303·with440·networking27123·disabled8127.13·Print7956·the279·values2663·that421·should1220·be381·returned5743·to310·the279·model1558.",10152·"328parameters13390":763·{"5046type1267":763·"328object1640",487·"328properties12811":763·{"5046code1787":763·{"5046type1267":763·"328string889",487·"328description4532":763·"328Complete12188·Python12654·220318.13116217·source2450·code1970."1149}},36892·"328required6081":763·["4241code1787"],7664·"328additional34325Properties7654":763·false867}}}72964198</510tools15449>29↵↵271If2592·you488·choose4992·to310·call1562·a264·function709·ONLY25835·reply9559·in303·the279·following2614·format3443·with440·NO5486·suffix19900:25↵↵271<tool_call>248058198<27function1628=28example8422_function8901_name1224>29198<27parameter15704=28example8422_parameter24109_62116>29198value927_62116198</510parameter15704>29198<27parameter15704=28example8422_parameter24109_62217>29198This1919·is369·the279·value869·for364·the279·second2018·parameter5555198that8761·can628·span9111198multiple34493·lines4965198</510parameter15704>29198</510function1628>29198</tool_call>248059↵↵271<27IMPORTANT95328>29198Reminder92065:25198-12·Function5534·calls6526·MUST26834·follow1732·the279·specified5024·format3443:25·an449·inner8906·<361function1628=28...1076></1419function1628>29·block2424·must1902·be381·nested23283·within2785·220<tool_call>248058</tool_call>248059·XML11535·tags9212198-12·Required12296·parameters4868·MUST26834·be381·specified5024198-12·You1394·may1189·provide3300·optional9801·reasoning31626·for364·your678·function709·call1562·in303·natural5629·language3992·BEFORE54588·the279·function709·call1562,11·but694·NOT4045·after1238198-12·If1368·there1017·is369·no874·function709·call1562·available2420,11·answer4087·the279·question3296·like1040·normal4472·with440·your678·current1428·knowledge6337·and321·do635·not524·tell3184·the279·user1156·about883·function709·calls6526198</510IMPORTANT95328>29↵↵271You2523·have599·a264·Python12654·tool5224.13·Use5272·it424·for364·numeric23311·computation33303·instead4370·of314·computing23470·by539·hand1375.13<|im_end|>248046198<|im_start|>248045user846198S50olve3815·x830^61x87·=283·220116015<|im_end|>248046198<|im_start|>248045assistant74455198<think>248068198
The worked numbers are teaching examples; the operations are the ones executed by the captured llama.cpp graph. The token IDs, model sizes and final scores are from the rerun. Raw capture, source patch and commands.

The first six tabs follow one piece of text through the arithmetic. The seventh starts from the opposite end: it takes the CUDA work Nsight measured and shows the calculation behind each group.

Now the profiler view has a narrower job: show when the GPU worked and what kind of CUDA work it did.

one local conversation · 73.1 seconds wall timeQwen → PythonPython + capturePython + captureQwen answersGPU busy · measured by NVML100%0GPU power · measured by NVML320W00s15s30s45s60s73s
click a phase or drag either plot · replay compresses 73s into 12s
session tracesolid timing is captured · hatched work is reconstructed
agentconversation73.1s
model request prefill inside request tool + capture gap 64-block passes, illustrative timing
0.0sphaseQwen → PythonGPU49%power53WVRAM13,629 MiBillustrationprefill · layer 0
Qwen → PythonAPI timings + Nsight/NVML

Derives Lambert W and writes a double-precision Newton solver.

prompt eval
0.497s
prompt tokens
355
cache reused
0
output tokens
523
decode rate
62.87 tok/s

NVML was sampled every 100 ms and the CUDA kernels came from Nsight Systems. The moving 64-layer strip is my reconstruction, not profiler output.

The blue spans are Qwen running. The gray spans are Python plus my slow manual capture steps around it. The GPU graphs and kernel counts are measured. I grouped the kernels with a small string-matching script.

The Execution tab is a synchronized summary of one pass. Nsight did not give me per-layer timing, so the moving layer highlight remains a teaching reconstruction.

I ran it again

I kept the September 3 performance capture unchanged and made a second capture on September 4. It uses the same 8.37 GB Qwen3.8-27B-UD-IQ2_S.gguf and the same Unsloth llama.cpp commit. Its SHA-256 starts with 7897d2c5a5ce; the complete digest is in the capture manifest.

The new capture is much less mysterious. Before inference I asked llama.cpp to render the complete chat template, then sent that exact string back through its tokenizer with special-token parsing enabled. The first request was 373 tokens:

part of the prompttokens
reasoning instructions, Python schema and tool-call rules354
user turn, including chat markers14
opening of the assistant turn5

The words Solve x^x = 10 are nine tokens inside the 14-token user turn. The other five are role markers and newlines. This is why token counts are part of the input, not a cosmetic detail.

The rerun made one Python call and then answered. Its first model turn used 373 prompt tokens and generated 622 tokens at 63.72 tok/s. The second prompt grew to 1,066 tokens; llama.cpp reused 994 and generated 1,059 at 63.43 tok/s. These are API timings from the rerun, not replacements for the Nsight timings below.

For the teaching pass I disabled CUDA graphs, selected named tensors and copied only those tensors to the CPU. I captured the embedding, several internals of recurrent block 0, Q/K/V and attention output from block 3, the output of block 63, final normalization and all 248,320 logits. That run is useful for shapes and values. It is useless as a speed measurement.

The first conversation

The initial request contained this message and a JSON schema for a function called python:

system: You have a Python tool.
        Use it for numeric computation instead of computing by hand.
user:   Solve x^x = 10

Qwen did not answer immediately. It took logarithms, mentioned Lambert W and asked to run this Python code:

import math

x = 2.0
for _ in range(20):
    fx = x*math.log(x) - math.log(10)
    dfx = math.log(x) + 1
    x -= fx/dfx
print(x, x**x)

Codex ran it in a disposable Python 3.12 container without network access. It printed:

2.5061841455887692 10.0

Qwen then requested a 50-digit Decimal calculation and finally returned

x=eW(ln10)=ln10W(ln10)x2.50618414559.\begin{aligned} x &= e^{W(\ln 10)} = \frac{\ln 10}{W(\ln 10)} \\ x &\approx 2.50618414559. \end{aligned}

The three Qwen turns used 523, 177 and 821 output tokens. Under the profiler they ran at 62.87, 62.54 and 62.52 tokens per second. The numbers are remarkably close, but three turns from one conversation are not a benchmark.

What I found in the CUDA trace

The GGUF says the model has 64 main blocks, 24 query heads, four KV heads and 256 values per KV head. The blocks repeat a simple pattern: three recurrent Gated DeltaNet blocks and one full-attention block. That gives 48 recurrent and 16 attention blocks. The same values are in Qwen’s model configuration and the loader from the exact llama.cpp commit I used.

The CUDA trace contains names such as gated_delta_net_cuda, ssm_conv_f32, k_set_rows and flash_attn_ext. During the longer decode turns I found roughly 48 Gated DeltaNet matches and 32 set_rows matches per output token. Those are suspiciously neat numbers. There are 48 recurrent blocks, and 32 is 16 attention blocks times K and V.

But neat numbers can be a trap. Prefill is mixed into the request window, CUDA graphs make launch counts harder to read, and a kernel name does not say which model layer launched it. llama.cpp does use ggml_set_rows when writing K and V, but it also uses the same operation elsewhere. This is why the diagram says set_rows matches instead of calling every one of them a proven KV-cache write. To prove that I would need to add better annotations to llama.cpp itself.

My simple grouping puts matrix-multiply kernels at roughly 77–79% of CUDA kernel time in every turn. That is what happened in this capture. I would not use it to claim that every short Qwen request has the same mix of work.

How much memory the KV cache needs

The next diagram is only arithmetic. Drag the context length and it calculates conventional K and V storage from the dimensions in the GGUF.

calculated from captured GGUF metadata and llama.cpp's F16 default · not measured allocation
16 layers×4 KV heads×256 values×2 bytes×K + V= 64 KiB/token
fixed recurrent stategrowing K + V

This is calculated, not sampled. It leaves out recurrent state. The all-attention bar is a hypothetical comparison using the same KV dimensions.

The launch command did not override llama.cpp’s F16 cache default. Using that default—two bytes per value—with 16 attention blocks, four KV heads, 256 values per head, and both K and V, the arithmetic is:

16×4×256×2×2=65,536 bytes/token=64 KiB/token.\begin{aligned} 16 \times 4 \times 256 \times 2 \times 2 &= 65{,}536\ \text{bytes/token} \\ &= 64\ \text{KiB/token}. \end{aligned}

At 1,979 tokens the result is 123.7 MiB. At my configured 29,440-token window it is 1.80 GiB. If all 64 blocks used full attention, the same calculation would give 7.19 GiB. That is only a comparison; it is not another configuration of this model. Both numbers also leave out recurrent state and allocator overhead, so they do not predict total VRAM.

The measured VRAM stayed between roughly 13,408 and 13,696 MiB during the conversation. My guess is that llama.cpp allocated most working memory before generation and then reused it. The NVML graph alone cannot prove that.

Getting a clean capture

I started in Unsloth Studio at 127.0.0.1:8888. Its log showed that this session was running through the bundled llama-server. These were the important arguments:

llama-server \
  -m Qwen3.8-27B-UD-IQ2_S.gguf \
  --parallel 4 \
  --flash-attn on \
  -c 29440 \
  -ngl -1 \
  --fit off \
  --metrics \
  --kv-unified \
  --jinja

For the final capture I stopped Studio and ran the same bundled binary directly under Nsight Systems. Its version output says llama.cpp build 10715, commit 92cedc867, compiled by the Unsloth team. Nsight recorded 2,857,912 CUDA kernel launches across the three Qwen turns. NVML produced the GPU graphs above.

The profiler changed the result a little. Decode was about 64.5 tok/s during a lighter observation and about 62.5 tok/s under Nsight. It also used enough memory to reduce the automatically fitted context from 29,952 to 29,440 tokens.

I also tried to collect occupancy, DRAM traffic and L2 hit rate with Nsight Compute, but NVIDIA returned ERR_NVGPUCTRPERM. I do not have those numbers.

Reproducing it

Everything small enough to keep is in the repository. The chart reads the capture JSON. The classifier shows how I grouped kernel names, the manifest records tool versions and SHA-256 hashes, and the capture notes contain the commands and limitations.

The raw Nsight report is 87 MB and its SQLite export is 301 MB, so I did not commit them. This is an important limitation: you can inspect my transformation and the published aggregates, but you cannot regenerate them from this repository alone. The hashes will verify the files if I share them separately, but a hash is not the same as publishing the data.

I still cannot claim that I saw inside Qwen. I saw when the GPU worked, which CUDA kernels llama.cpp launched, what the server reported and how the model says it is built. The layer-by-layer animation between those facts is still a reconstruction.

That is less magical than the view I imagined when I started, but it is much more useful than a spinner.