Skip to content

Static batched decode (LLaMA + Qwen3): B independent sequences per step, up to 41x aggregate throughput#129

Open
mikepapadim wants to merge 10 commits into
beehive-lab:feat/mma_cudafrom
mikepapadim:feat/static-batched-decode
Open

Static batched decode (LLaMA + Qwen3): B independent sequences per step, up to 41x aggregate throughput#129
mikepapadim wants to merge 10 commits into
beehive-lab:feat/mma_cudafrom
mikepapadim:feat/static-batched-decode

Conversation

@mikepapadim

@mikepapadim mikepapadim commented Jul 11, 2026

Copy link
Copy Markdown
Member

Static batched decode — B independent LLaMA sequences per step

Decode B independent sequences at once, one token per step, each attending its
own KV cache. Turns the bandwidth-bound single-token matvecs of decode into
compute-bound tensor-core GEMMs (one weight read amortized across B tokens), so
aggregate throughput scales ~linearly with B until the 128-row MMA tile fills.

41× aggregate throughput vs single-stream decode on an RTX 4090 (Llama-3.2-1B
FP16), output verified coherent + bit-exact against the single-stream greedy reference.

Full write-up in BATCHED_DECODE.md.

Why it wins

Single-token decode is memory-bound: each projection reads the whole weight matrix to
make one output vector (intensity ~1). Batch B tokens sharing the weights → each weight
row is read once and applied to all B → intensity ~B, a GEMM. Past B≈16 the
projections go compute-bound and the tensor cores engage. Attention doesn't batch this
way (per-sequence KV) but is a small fraction of decode FLOPs.

             single-token decode              static batched decode (B)
  weight W    read once  ─┐                     read once ─┐
  token x0    ───────────┼─> y0                 ──────────┼─> y0
  token x1    (read W again next step)          ──────────┼─> y1   one GEMM,
  token x2    (read W again) ...                ──────────┼─> y2   W read once
  intensity ~1 (HBM-bound)                      intensity ~B (compute-bound)

Design

One step routine serves both phases. Prompt tokens are fed with logits discarded —
fills every slot's KV region with the same RoPE the decode step uses (no prefill/decode
mismatch). Then generated tokens feed back.

 embXBatch(FP16 B×dim)
      │  [0] prefillActivation      FP16 → FP32  wrapXBatch
      ▼
 ┌──────────  [1..N] batchDecodeLayer_i  (12 tasks, all GEMMs on MMA) ──────────┐
 │ batch_attn_rms → rms_apply → qkvProj(MMA) → batch_rope_kv* → batch_attention*│
 │ → woProj(MMA) → batch_ffn_rms → rms_apply → gateUpProj(MMA) → swiglu         │
 │ → w2Proj(MMA) → w2Resid            (*) only decode-specific kernels           │
 └─────────────────────────────────────────────────────────────────────────────┘
      │  wrapXBatch (B×dim, persists to next layer)
      ▼
 [N+1] batchLogits    final RMS → gemmMMA over vocab(128256) → logitsBatch (B×vocab)

The 12-task layer is identical to the existing batched-prefill MMA layer
(LlamaFP16LayersBatchPrefillMMA). Only two kernels change, and only in KV addressing:

kernel prefill (one sequence) decode (B sequences)
RoPE + KV write batchedRopeWithKVCachePackedpos=start+b, shared cache batchedDecodeRopeWithKVCachePackedpos=seqPositions[b], per-slot base
flash attention batchedFlashAttentionFP16Out — shared causal KV batchedDecodeAttentionFP16Out — own KV region per slot

RoPE math, online-softmax flash attention, register-partitioned P·V — all untouched; the
forks only change the KV index.

Qwen3 reuses the pattern (Qwen3FP16LayersBatchDecodeMMA): keeps the per-head Q/K
RMS-norm task, swaps in split-half batchedDecodeRopeWithKVCacheQwen3Packed + the same
per-slot flash kernel (parameterized by qDim/nEmbdHead/gqa). Engine dispatches on model
type; embeddings + batched-logits GEMM are model-agnostic. KV cache is B-sized (batchIdx stride =
numLayers·ctx·kvDim), engine-owned so ctx can be capped for VRAM.

 keyCacheBatch [ slot0 | slot1 | … | slotB-1 ]   size = B·numLayers·ctx·kvDim
                  └ [ layer0 | layer1 | … ] └ [ pos0 | pos1 | … ] written at seqPositions[slot]

Correctness

Greedy + B copies of one prompt → all B streams bit-exact identical AND equal to the
single-stream greedy reference
(each slot reproduces the reference independently):

[verify] mode=greedy: all 128 streams identical (== single-stream greedy ref): true

Temperature + per-slot RNG → B distinct, individually coherent continuations (real
concurrent requests, not replication):

[verify] mode=sample temp=0.70: 128/128 streams distinct (independent per-slot KV)

Performance (RTX 4090, Llama-3.2-1B FP16, ctx=512, 64 steps, CUDA graphs, steady-state)

config aggregate tok/s vs single-stream per-step
single-stream (stock decode) 101 1.0×
batched B=32 1085 10.7× 29.5 ms
batched B=64 2167 21.5× 29.5 ms
batched B=128 4175 41.3× 30.7 ms

Across models (largest B that fits 24 GB; deeper/wider → higher per-step):

model layers·dim·vocab single-stream B aggregate speedup
Llama-3.2-1B 16·2048·128256 101 128 4175 41×
Qwen3-1.7B 28·2048·151936 48 64 1433 30×
Qwen3-4B 36·2560·151936 39 32 405 10×

All bit-exact vs single-stream greedy + coherent.

Per-step is ~flat (29–31 ms) from B=8 to B=128: every step runs at paddedBatch=128
(MMA tile is 128 rows) and the logits GEMM spans the full vocab regardless of B. So
aggregate scales ~linearly to B=128 (no pad waste) — B=128 is the efficient operating
point
. Residual cost is real GEMM compute (full-vocab logits ~33 GFLOP + 16 layer
projections at 128 rows), not launch overhead — hence CUDA graphs help only ~6%.

Reproduce

TornadoVM: needs the CUDA backend + MMA KernelContext
(mmaFragment/mmaLoadA/mmaMultiply, HalfFloatArray) — the same TornadoVM this
feat/mma_cuda branch already requires. Tested against TornadoVM 5.0.1-jdk21-dev,
CUDA backend, JDK 21 (make BACKEND=cuda). All numbers here are from the CUDA backend.

Build: mvn -Pjdk21 -Dtornadovm.base.version=5.0.1 -Djdk.version.suffix=-jdk21-dev clean package -DskipTests

Run (-Dllama.prefillBatchSize MUST equal -Dbatch.decode.B; take
llama-tornado --show-command, swap main class, prepend flags):

-Dllama.prefillBatchSize=128 -Dbatch.decode.B=128 -Dbatch.decode.ctx=512 \
-Dbatch.decode.n=64 -Dbatch.decode.temp=0.0 -Dbatch.decode.cudaGraphs=true \
org.beehive.gpullama3.bench.BatchedDecodeEngine -m <llama-3.2-1b-fp16.gguf> -p "…" --instruct

Two micro-benchmarks isolate the regimes: BatchedProjectionBench (compute-bound
projection crossover, ~20×) and BatchedDecodeAttentionBench (memory-bound per-slot
attention, ~2×, bit-exact vs CPU ref).

Scope

Additive — no existing path touched (new decode layer graph + 2 kernels + engine/benches).
FP16 LLaMA + Qwen3; same-length prompts (static). Qwen3-8B is garbage in the stock decode path on this build (not this feature). Ragged / continuous batching is the
follow-up — per-slot seqPositions + per-slot KV base already allow per-slot positions.


Update: continuous batching + PagedAttention (vLLM-style)

Continuous (iteration-level) batching (-Dbatch.decode.continuous=true): each slot is
independently prefilling or decoding; a slot that hits stop/max is evicted and immediately
refilled from a pending queue — new requests join a partly-decoded batch mid-flight. Zero
kernel changes (the per-step forward already feeds one token per slot at its own
seqPositions[b]). Same 512-request workload (Llama-1B, B=128, max-gen∈[8,64], greedy):

scheduling steps gen tok/s req/s slot utilization
static wave 336 1645 46.9 66.4%
continuous 272 1972 56.2 82.2%

+20% throughput / +24% relative utilization; grows with length variance.

PagedAttention (-Dbatch.decode.paged=true, LLaMA): KV in a shared pool of fixed-size
blocks + per-slot block table; the two decode KV kernels index blockTable[b][pos/blockSize].
Pool sized to actual concurrent demand, not B·ctx:

KV cache pool peak used throughput
contiguous 4096 MB 1972 tok/s
paged 384 MB 372 blk (97%) 1939 tok/s

~10.7× less KV memory at ~1% overhead, still bit-exact. Undersizing the pool throws a
clear KV block pool exhausted (where admission control takes over). Prerequisite for
prefix caching. Details in BATCHED_DECODE.md.


Update: prefix caching + Qwen3 paging

Prefix caching (-Dbatch.decode.prefixCache=true, needs paging): a shared prompt prefix
(system prompt / preamble) is prefilled once into pinned blocks; every request points its
block-table prefix rows at them and starts decoding at pos = sharedPrefixLen, skipping the
prefix's prefill. The attention kernel walks the block table, so it reads shared-then-private
blocks with no kernel change. Same 512-request continuous+paged workload, 48-tok shared
prompt (Llama-1B, B=128):

steps gen tok/s req/s prefill tokens
no prefix cache 419 1307 37.2 28672
prefix cache 211 2422 69.0 4096 (85.7% saved)

~2× fewer steps, +85% throughput; bit-exact.

Qwen3 now runs the full stack (batched decode → continuous → paging → prefix caching) —
adds a split-half paged RoPE kernel; the paged flash kernel is shared (qDim==nHeads·nEmbdHead).
Qwen3-1.7B, 256-request continuous+paged, 48-tok shared prompt:

Qwen3-1.7B steps gen tok/s req/s peak KV blocks
paged, no prefix 405 465 13.6 296
paged + prefix 193 949 27.7 151

+104% throughput; prefix sharing also halves peak KV blocks. All bit-exact.

vLLM-style feature set now on both LLaMA and Qwen3: continuous batching + PagedAttention +
prefix caching. Details + reproduction in BATCHED_DECODE.md.


How to test — exact prompts and flags

Launch. The engine is a main on the standard TornadoVM module path. Easiest: capture the
JVM command from the normal runner, swap the main class to the engine, and prepend the -D
flags:

# 1) get the base JVM command for your model
python3 llama-tornado --gpu --cuda --model <model.gguf> --prompt x --instruct --show-command > cmd.txt

# 2) take the java line, replace the main class + prepend engine flags, set the prompt
BASE=$(grep 'bin/java' cmd.txt)
echo "$BASE" \
  | sed "s#org.beehive.gpullama3.LlamaApp#<ENGINE_FLAGS> org.beehive.gpullama3.bench.BatchedDecodeEngine#" \
  | sed 's#-p x --instruct#-p "<PROMPT>" --instruct#' | bash

-Dllama.prefillBatchSize must equal -Dbatch.decode.B (it sizes the batch buffers).
Larger models need more device memory — add -Dtornado.device.memory=20GB to <ENGINE_FLAGS>.

Prompts used (verbatim):

  • P_SHORT = Tell me a short story about a robot learning to paint.
  • P_LONG (48-token shared prefix) = You are a helpful and knowledgeable assistant. Please write a detailed, vivid and engaging short story about a small curious robot named Zeta who lives in an old cluttered workshop and slowly learns the delicate art of painting with oil colors.
  • P_QA = What is the capital of France?

Exact <ENGINE_FLAGS> per result (model, prompt):

result model prompt flags
static B=128, 41× llama-3.2-1b-fp16 P_SHORT -Dllama.prefillBatchSize=128 -Dbatch.decode.B=128 -Dbatch.decode.ctx=512 -Dbatch.decode.n=64
divergent streams llama-3.2-1b-fp16 P_SHORT …B=128 … -Dbatch.decode.temp=0.7
continuous vs static-wave llama-3.2-1b-fp16 P_SHORT …B=128 … -Dbatch.decode.continuous=true -Dbatch.decode.requests=512 -Dbatch.decode.minN=8 -Dbatch.decode.refill=true (baseline: refill=false)
paged, 10.7× less KV llama-3.2-1b-fp16 P_SHORT …continuous=true -Dbatch.decode.paged=true -Dbatch.decode.blocks=384 -Dbatch.decode.requests=512 -Dbatch.decode.minN=8
prefix cache, +85% llama-3.2-1b-fp16 P_LONG …continuous=true -Dbatch.decode.paged=true -Dbatch.decode.blocks=1024 -Dbatch.decode.prefixCache=true -Dbatch.decode.requests=512 -Dbatch.decode.minN=8 (off: prefixCache=false)
Qwen3 paged + prefix, +104% Qwen3-1.7B-f16 P_LONG -Dtornado.device.memory=20GB -Dllama.prefillBatchSize=64 -Dbatch.decode.B=64 -Dbatch.decode.ctx=512 -Dbatch.decode.n=64 -Dbatch.decode.continuous=true -Dbatch.decode.paged=true -Dbatch.decode.blocks=768 -Dbatch.decode.prefixCache=true -Dbatch.decode.requests=256 -Dbatch.decode.minN=8 (off: prefixCache=false)
Qwen3-4B batched Qwen3-4B-f16 P_QA -Dtornado.device.memory=20GB -Dllama.prefillBatchSize=32 -Dbatch.decode.B=32 -Dbatch.decode.ctx=512 -Dbatch.decode.n=48

Single-stream baselines (stock decode, for the speedup ratios): normal runner with CUDA
graphs, e.g. JAVA_TOOL_OPTIONS=-Dllama.cudaGraphs=true python3 llama-tornado --gpu --cuda --model <m.gguf> --prompt "<P_SHORT>" --instruct -n 64 → reads achieved tok/s.

All flags (defaults in parens): batch.decode.B, .ctx (512), .n (64), .temp (0=greedy),
.cudaGraphs (true), .continuous (false), .refill (true), .requests (4·B), .minN (n/2),
.paged (false), .blockSize (16), .blocks (B·ctx/blockSize), .prefixCache (false).
Each run prints [verify] … : true (correctness) and [perf] … (throughput).

Copilot AI review requested due to automatic review settings July 11, 2026 20:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements a new static batched decode path for FP16 LLaMA that advances B independent sequences per step, using MMA/GEMM-heavy projections to amortize weight reads and increase aggregate decode throughput. This adds a new decode-layer task-graph pipeline, decode-specific KV-addressing kernels, and benchmark/doc support to reproduce and validate performance/correctness.

Changes:

  • Add LlamaFP16LayersBatchDecodeMMA to build per-layer TornadoVM TaskGraphs for batched decode with per-slot KV and per-slot positions.
  • Add decode-specific kernels in TransformerBatchPrefillKernels for RoPE+KV write and FP16 flash attention over per-slot KV regions.
  • Add end-to-end and micro-benchmarks plus a detailed write-up (BATCHED_DECODE.md) for validation and reproduction.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/main/java/org/beehive/gpullama3/tornadovm/layers/type/fp16/decode/LlamaFP16LayersBatchDecodeMMA.java New per-layer TaskGraph builder for static batched decode with per-slot KV addressing.
src/main/java/org/beehive/gpullama3/tornadovm/kernels/TransformerBatchPrefillKernels.java Adds decode variants of RoPE+KV write and flash-attention kernels with per-slot KV regions/positions.
src/main/java/org/beehive/gpullama3/bench/BatchedProjectionBench.java New synthetic benchmark illustrating projection batching benefits.
src/main/java/org/beehive/gpullama3/bench/BatchedDecodeEngine.java New end-to-end batched decode benchmark/engine wiring activation + decode layers + logits.
src/main/java/org/beehive/gpullama3/bench/BatchedDecodeAttentionBench.java New microbench validating/benchmarking decode attention with per-slot KV caches.
BATCHED_DECODE.md Documentation/write-up describing design, correctness checks, and performance results.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

.task("w2Proj", TransformerBatchPrefillKernels::gemmMMA,
context, state.wrapHbFP16Batch,
weights.w2Layered[layerIndex].asHalfFloatArray(),
state.w2Out, batchSize, dim, hidDim)
Comment on lines +73 to +76
int B = args.length > 0 ? Integer.parseInt(args[0]) : 32;
int K = args.length > 1 ? Integer.parseInt(args[1]) : 2048;
int N = args.length > 2 ? Integer.parseInt(args[2]) : 2048;
int iters = 100;
Comment on lines +40 to +42
int B = args.length > 0 ? Integer.parseInt(args[0]) : 32;
int seqLen = args.length > 1 ? Integer.parseInt(args[1]) : 256; // each slot attends 0..seqLen-1
int iterations = 100;
Comment on lines +91 to +93
if (P + nDecode >= decodeCtx) {
throw new IllegalStateException("prompt(" + P + ")+n(" + nDecode + ") >= ctx(" + decodeCtx + ")");
}
Comment on lines +98 to +100
long kvElems = (long) B * numLayers * decodeCtx * kvDim;
FloatArray keyCacheBatch = new FloatArray((int) kvElems);
FloatArray valueCacheBatch = new FloatArray((int) kvElems);
Comment on lines +63 to +64
int nDecode = Integer.getInteger("batch.decode.n", 64);
boolean cudaGraphs = Boolean.parseBoolean(System.getProperty("batch.decode.cudaGraphs", "true"));
// ── Decode: static batch, greedy. Position advances in lock-step.
// First few steps capture the CUDA graphs; exclude them from timing so
// the reported latency is steady-state replay, not capture.
int warmup = cudaGraphs ? Math.min(4, nDecode - 1) : 0;
…RoPE decode kernels); CUDA-backend docs + multi-model perf
Copilot AI review requested due to automatic review settings July 11, 2026 20:37
@mikepapadim mikepapadim changed the title Static batched decode: B independent LLaMA sequences per step (41× aggregate throughput) Static batched decode (LLaMA + Qwen3): B independent sequences per step, up to 41x aggregate throughput Jul 11, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Comment on lines +184 to +187
.task("w2Proj", TransformerBatchPrefillKernels::gemmMMA,
context, state.wrapHbFP16Batch,
weights.w2Layered[layerIndex].asHalfFloatArray(),
state.w2Out, batchSize, dim, hidDim)
Comment on lines +106 to +116
long kvElems = (long) B * numLayers * decodeCtx * kvDim;
FloatArray keyCacheBatch = new FloatArray((int) kvElems);
FloatArray valueCacheBatch = new FloatArray((int) kvElems);
keyCacheBatch.init(0.0f);
valueCacheBatch.init(0.0f);
IntArray seqPositions = new IntArray(B);
FloatArray finalScaleBatch = new FloatArray(B);
HalfFloatArray normedFinalFP16 = new HalfFloatArray(paddedB * dim);
normedFinalFP16.init(new uk.ac.manchester.tornado.api.types.HalfFloat(0.0f));
FloatArray logitsBatch = new FloatArray(paddedB * vocab);

Comment on lines +214 to +218
// First few steps capture the CUDA graphs; exclude them from timing so
// the reported latency is steady-state replay, not capture.
int warmup = cudaGraphs ? Math.min(4, nDecode - 1) : 0;
long decodeNs = 0;
int steps = 0;
Comment on lines +267 to +272
double decodeSec = decodeNs / 1e9;
int genTokens = steps * B;
System.out.printf("[perf] prefill %d pos in %.1f ms | decode %d steps × B=%d = %d tokens in %.1f ms%n",
P, prefillNs / 1e6, steps, B, genTokens, decodeNs / 1e6);
System.out.printf("[perf] per-step latency %.2f ms | aggregate throughput %.0f tok/s | per-stream %.1f tok/s%n",
decodeNs / 1e6 / steps, genTokens / decodeSec, steps / decodeSec);
Comment on lines +73 to +76
int B = args.length > 0 ? Integer.parseInt(args[0]) : 32;
int K = args.length > 1 ? Integer.parseInt(args[1]) : 2048;
int N = args.length > 2 ? Integer.parseInt(args[2]) : 2048;
int iters = 100;
…-queue, +20% throughput / +24% utilization vs static wave
Copilot AI review requested due to automatic review settings July 11, 2026 20:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Comment on lines +73 to +76
int B = args.length > 0 ? Integer.parseInt(args[0]) : 32;
int K = args.length > 1 ? Integer.parseInt(args[1]) : 2048;
int N = args.length > 2 ? Integer.parseInt(args[2]) : 2048;
int iters = 100;
Comment on lines +106 to +108
long kvElems = (long) B * numLayers * decodeCtx * kvDim;
FloatArray keyCacheBatch = new FloatArray((int) kvElems);
FloatArray valueCacheBatch = new FloatArray((int) kvElems);
Comment on lines +184 to +188
.task("w2Proj", TransformerBatchPrefillKernels::gemmMMA,
context, state.wrapHbFP16Batch,
weights.w2Layered[layerIndex].asHalfFloatArray(),
state.w2Out, batchSize, dim, hidDim)
.task("w2Resid", TransformerBatchPrefillKernels::batchedResidualAddFP32,
Copilot AI review requested due to automatic review settings July 11, 2026 21:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…locks, +85% throughput / 85% fewer prefill tokens
Copilot AI review requested due to automatic review settings July 11, 2026 21:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…d paged flash kernel); +104% throughput with prefix cache
Copilot AI review requested due to automatic review settings July 11, 2026 21:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 11, 2026 21:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 11, 2026 21:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@mikepapadim

Copy link
Copy Markdown
Member Author

GPU utilization (nsys) on larger models

Profiled the continuous+paged+prefix engine with Nsight Systems 2024.5
(nsys profile --trace=cuda --cuda-graph-trace=node), CUDA backend, RTX 4090.

model B steps GPU kernels/step wall/step steady GPU util
Llama-3.2-1B 128 87 18.8 ms 37.6 ms ~50%
Qwen3-4B 32 177 59.0 ms 82.8 ms ~71%

(steady GPU util = Σ kernel time / (steps × wall-per-step); nsys adds ~10–20% wall overhead.)

Kernel mix is the same on both — >95% of GPU time is the tensor-core projection GEMMs:

Llama-1B:  gemmMMA 53.8%  gemmMMAGateUp 28.8%  gemmMMAQKV 11.3%  attention 4.5%  rest <2%
Qwen3-4B:  gemmMMA 52.3%  gemmMMAGateUp 29.5%  gemmMMAQKV 13.8%  attention 3.5%  rest <2%

So when the GPU is working, it is compute-bound on the tensor cores (the intended batched-decode
regime). gemmMMA includes the full-vocab logits projection — the single largest kernel
(2.3 ms on Llama, 3.4 ms on Qwen).

Where the idle time goes (the actionable finding). Per step the host must: copy the full
logits tensor D2H (paddedB×vocab = 65 MB Llama / 78 MB Qwen, ~3.6–4.1 ms) and run
CPU argmax over 128×128256 (Llama) / 32×151936 (Qwen) logits. These serialize between GPU
steps → the GPU idles ~half the step on the small model.

  • Bigger model ⇒ higher utilization (50% → 71%): the GEMMs grow with dim/layers while the
    host overhead (D2H + argmax) is roughly fixed, so it amortizes. Larger models use the GPU better.
  • Top lever = on-device sampling. Doing argmax/sampling on the GPU eliminates both the
    65–78 MB/step D2H copy and the CPU scan → should push small-model utilization toward ~90% and
    ~2× throughput. This is a bigger win than the two remaining vLLM items below, so I'd prioritize it.

Re the other vLLM opts: the profile re-prioritizes them. Chunked prefill — the engine
already interleaves token-by-token prefill with decode in one batch (the anti-stall benefit);
multi-token-per-step chunking conflicts with the one-token-per-slot batch layout (needs dynamic
multi-row-per-sequence) → deferred. Speculative decoding needs K-position-per-slot verification
(same structural change) + a draft model → deferred. Both are larger redesigns; on-device sampling
is the data-driven next step. Logits-skip (skip the vocab GEMM on pure-prefill steps) is already
in — 14% of steps on a long-prompt no-cache workload.

@mikepapadim

Copy link
Copy Markdown
Member Author

Exact model output (greedy, verified)

Prompt (P_LONG, 48-token shared prefix, --instruct):

You are a helpful and knowledgeable assistant. Please write a detailed, vivid and engaging short story about a small curious robot named Zeta who lives in an old cluttered workshop and slowly learns the delicate art of painting with oil colors.

Continuous + paged + prefix-cache, greedy sampling. All completed requests are mutually
prefix-consistent ([verify] … : true) — every slot reproduces the single-stream greedy
reference regardless of when it joined the batch. Longest completed request per model:

Llama-3.2-1B (B=128, 128 requests):

In the dusty, dimly lit workshop, a small, curious robot named Zeta whirred and whizzed
about, his bright, shiny metal body …

Qwen3-4B (B=32, 96 requests):

<think>
Okay, the user wants a short story about a robot named Zeta who lives in an old workshop
and learns to paint with oil colors. Let me start by visualizing the setting. An old,
cluttered workshop gives a sense …

Both coherent, on-prompt, not gibberish. (Qwen3 emits its <think> reasoning block, as
expected for that model.) Earlier greedy run with the short prompt on Llama-1B produced the
full Zeta-and-Professor-Thompson story; temperature=0.7 gives 128/128 distinct but individually
coherent continuations (independent per-slot KV).

…ids not the 65MB logits tensor, +30% throughput
Copilot AI review requested due to automatic review settings July 11, 2026 21:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@mikepapadim

Copy link
Copy Markdown
Member Author

On-device sampling — the nsys-identified fix, benchmarked

Per the GPU-utilization profile above, the engine was host-bound: every step copied the full
paddedB×vocab logits tensor D2H (~65 MB) and ran a CPU argmax over it. Implemented
greedy sampling on the GPU — a per-row argmax kernel (batchedArgmaxLogits, one workgroup
per row) writes B token ids; the logits tensor stays on the device and only B integers
cross to the host.

Effect (nsys, Llama-1B B=128, same workload):

D2H per step D2H total (trace) argmax cost
host sampling ~65 MB (3.6 ms) 115 ms CPU, 16 M-elem scan
on-device ~0.5 KB (B ids) 0.03 ms GPU kernel, 0.1% of time

End-to-end (non-nsys, 512-request continuous+paged+prefix, identical greedy output):

sampling wall gen tok/s per-step (logits graph)
host 7.66 s 2344 31.7 ms
on-device 5.88 s 3057 28.4 ms

+30% throughput, −23% wall. GPU kernel work per step is unchanged (18.8 ms) — the entire
gain is removing the host-side stall (D2H + CPU scan) between GPU steps, exactly what the
profile predicted. Output is bit-exact vs the host path (all requests still mutually
prefix-consistent). Enabled by default for greedy (-Dbatch.decode.deviceSample=true, auto-off
for temperature sampling, which still needs logits on the host). Commit fc1c16e.

Copilot AI review requested due to automatic review settings July 11, 2026 21:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants