A dark circuit-board illustration reading Hopper Inference Inc.

tps-maxxing on amd gpu

Nvidia is estimated to generate 74% of inference tokens, while AMD is less than 20%. This gap is surprising, looking at the hardware specs both offer:

Specification AMD MI300X NVIDIA H100 SXM
HBM memory 192 GB HBM3 80 GB HBM3
Memory bandwidth 5.3 TB/s 3.35 TB/s
Dense FP8 compute 2.61 PFLOP/s 1.98 PFLOP/s
Sparse FP8 compute 5.22 PFLOP/s 3.96 PFLOP/s
Approx. monthly rental $1,891 $3,219

At Hopper, we are big users of H100s for voice inference. So we started optimizing inference on an AMD MI300X to see how good we could get it. Let's first setup the system and the goals we are targeting.

Baseline

We started with a plain vLLM serving containing full hip-graph (think cuda graphs for AMD) capture with a ROCM_ATTN attention backend. Here is the launch command:

vllm serve "$MODEL_DIR" \
  --served-model-name qwen-3.6-a3b \
  --tensor-parallel-size 1 \
  --max-model-len 262144 \
  --max-num-seqs 1 \
  --max-num-batched-tokens 12288 \
  --gpu-memory-utilization 0.9 \
  --enable-chunked-prefill \
  --default-chat-template-kwargs '{"enable_thinking": false}' \
  --speculative-config null \
  --attention-backend auto \
  --dtype auto \
  --limit-mm-per-prompt '{"image": 0, "video": 0}' \
  --compilation-config '{
    "cudagraph_capture_sizes": [
      1,2,3,4,5,6,7,8,12,16,24,32,40,48,56,64,72,80,88,96,
      104,112,120,128,256,512,768,1024,1280,1536,1792,2048,
      2304,2560,2816,3072,3328,3584,3840,4096,4352,4608,4864,
      5120,5376,5632,5888,6144,6400,6656,6912,7168,7424,7680,
      7936,8192
    ],
    "max_cudagraph_capture_size": 8192,
    "cudagraph_mode": "FULL_AND_PIECEWISE"
  }'

The following graph shows the TTFT sweep on this server covering 91 exact prompt lengths from 1 to 8,192 :

Baseline time to first token across 1–8K prompts Interactive line chart of median time to first token across 91 prompt lengths. Hover or focus the plot for exact values. 0 100 200 300 TTFT (ms) 0 2K 4K 6K 8K prompt length (tokens)

The ridges in the middle are because the input sizes match the graph capture boundaries. This will be important later when we are tuning the kernel selection.

Following are the decode numbers on same server:

Workload Decode speed TPOT Request TTFT
Varied prompts at 4k 77.878 tok/s 12.841 ms 170.540 ms

Now, lets see where the baseline spends its GPU time. The table below shows the costliest kernels at 4k prompt length.

Kernel Role Calls Device work Share
Prefill
fused_moe_kernel.kd Routed experts 160/request 40.839 ms/request 27.7%
_w8a8_triton_block_scaled_mm.kd Dense FP8 320/request 33.807 ms/request 22.9%
_fwd_kernel.kd Full-attention core 20/request 21.923 ms/request 14.9%
Decode
kernel_paged_attention_2d.kd Paged attention 10/token 5.157 ms/token 36.5%
_w8a8_triton_block_scaled_mm.kd Dense FP8 160/token 2.340 ms/token 16.5%
fused_moe_kernel.kd Routed experts 80/token 1.254 ms/token 8.9%
per_token_group_quant_8bit_kernel FP8 activation quantization 200/token 0.952 ms/token 6.7%
topkGating<8,256,…> MoE routing 40/token 0.396 ms/token 2.8%

Few observations :

Optimizing kernels for MI300X

The next step is to optimize performance for each individual kernel, by specializing on shapes needed by our model. We adapted AMD's AITER for this. AITER provides optimized Triton, Composable Kernel, and assembly implementations for attention, GEMM, MoE, quantization, and other common inference operators. A kernel that performs well for M=4096 prefill may be poor for M=1 decode. So, we benchmark and find compatible implementations for each (M,N,K) or MoE shape.

The exact-4K tuning invocation looks like:

MODEL=qwen3.6-35b-a3b-fp8 \
bash profile/run.sh dev tune_mi300x_aiter.py \
  --component all \
  --dense-m-values 4096 \
  --dense-shape all \
  --dense-libtype both \
  --moe-tokens 4096 \
  --warmup 5 \
  --iters 30 \
  --out results/mi300x_aiter_tuning

gfx942 is AMD’s compiler target (think AMD's sm_90) for the CDNA3 architecture used by MI300X. It tells AITER which hardware-specific kernels and tuning configurations are valid for the GPU. AITER maintains tuned dispatch tables where each row effectively says which kernel to choose given a GPU, data_type and (M,N,K) shape.

For example, here is an entry from the block-scaled A8W8 GEMM tuning table used in our MI300X run:


"gfx": "gfx942",
"cu_num": 304,
"M": 2048,
"N": 7168,
"K": 2048,
"libtype": "ck",
"kernelId": 0,
"splitK": 0,
"us": 78.7607

This tells AITER that, on a gfx942 GPU, an A8W8 block-scaled mat mul with shape M=2048, N=7168, and K=2048 should use Composable Kernel configuration 0 without splitting the K dimension and this configuration took 78.76 microseconds in tuning.

During the tuning sweep, a 2,048-token request took 108.95 ms TTFT, while the 2,049-token request fell to 72.64 ms. We found that this was because the 2048-request selected a slower kernel. The dispatch table contained optimized rows for M=1 and M=4096, but not for the M=2048 bucket. The 2,048-token request therefore used fallback kernels, while the next bucket was padded into an already-optimized shape (M=4096). So, this tuning process adds the most perfomant kernels at every shape.

From multiple such passes across 1-8k tokens, the following prefill and decode kernels gave the best results:

Hot path Model shape Baseline path MI300X-specialized path Changed result
Prefill attention Exact 4K; 10 attention layers; 16 Q / 2 KV heads; head dim 256 ROCM_ATTN _fwd_kernel CK-Tile FmhaFwdKernel;128×128 tile 22.02 → 4.80 ms/request, −78.2%
Decode attention M=1, starting from 4K context kernel_paged_attention_2d; 10 calls/token paged_attention_ll4mi_QKV_mfma16 plus reduction; 20 shorter calls/token 5.157 → 0.256 ms/token, −95.0%
Dense FP8 Five (N,K) projections; prefill M=4096, decode M=1 Triton _w8a8_triton_block_scaled_mm CK XDL block-scaled GEMMs selected from the shape table Prefill 35.52 → 19.22 ms/request, −45.9%; decode 2.532 → 1.748 ms/token, −31.0%
Routed MoE Hidden 2048, intermediate 512, 256 experts, top-8, block-FP8 fused_moe_kernel; 160 main prefill calls One-stage persistent ASM, block_m=64; 40 calls. Decode uses two CK MoE stages Prefill 43.03 → 20.49 ms/request, −52.4%; decode 1.650 → 1.133 ms/token, −31.3%
KV/cache geometry Exact-4K prefill 1,056-token physical page 4,096-token page and prefill chunk 3.09 → 1.20 ms/request, −61.1%

At exact 4K, TTFT fell from 166.666 ms to 95.076 ms (43.0% reduction). This improvement persisted across the longer prompts. The following chart shows this improvement compared to baseline :

Baseline versus MI300X-specific time to first token Interactive comparison of the baseline and MI300X-specific stack across 91 prompt lengths from 1 to 8,192 tokens. Lower is better. Hover or focus the plot for exact values.

With decode attention reduced by 95%, throughput nearly doubled from 77.878 tok/s to 152.891 tok/s. Here are the details for a 4K prompt:

Configuration Decode speed TPOT Request TTFT
Baseline vLLM 77.878 tok/s 12.841 ms 170.540 ms
Using specialized AITER 152.891 tok/s 6.541 ms 110.066 ms

Speculative decoding: native MTP vs. DFlash

With the individual kernels optimized, to improve throughput further we tested two speculative decoding branches : Qwen's native MTP vs DFlash.

Native MTP reuses the prediction layer already embedded in the Qwen checkpoint. Because this model has one MTP layer, vLLM invokes it sequentially three times to propose three draft tokens.

DFlash instead uses a separate six-layer BF16 draft model to predict fifteen draft positions in parallel.

Speculator models take the tokens generated so far and propose the next K candidate tokens (K=3 for MTP, K=15 for DFlash). The target model scores those candidates in one verification pass, and the system accepts the valid prefix until the first rejection. The fraction of proposed draft tokens that survive is the acceptance rate.

When acceptance rate is high, one verification pass can commit several tokens. On the controlled workload with high acceptance rate >90%, MTP reached 329–331 tok/s, while DFlash reached 1,063 tok/s.

Here is how the three configurations behaved across lower- and high-acceptance workloads:

Configuration Decode speed at low acceptance rate Decode speed at high acceptance rate
Current best 152.9 tok/s 152.9 tok/s
Native MTP 217.9 tok/s
56.3% acceptance
329–331 tok/s
100.0% acceptance
DFlash 214.8 tok/s
15.3% acceptance
1,063 tok/s
98.9% acceptance

On varied MI300X prompts, MTP's three draft positions were accepted at approximately 77.7%, 53.5%, and 36.8%. DFlash began at 71.7%, then fell to 44.5%, 27.7%, 18.2%, 11.7%, and 7.9% over its first six positions. DFlash still committed slightly more text per verification round, but its wider draft and verification work erased that advantage.

The DFlash's wider proposer also costs more to start. At 4K input tokens, TTFT was 95.1 ms for the tuned target, 102.1 ms with MTP K3, and 114.3 ms with DFlash B16. MTP added approximately 7.4% to MI300X's one-token TTFT, while DFlash added 20.2%.

While MTP K3 performs well on general workloads, DFlash would be very beneficial if we could train the speculator model on predictable, domain-specific outputs. We are seeing >800 tok/s with narrow, domain-specific speculators on healthcare and insurance datasets.

Results and comparison with H100

First, here is the full progression on MI300X. The TTFT chart uses the full 1–8K one-token sweep, while the decode table reports median native throughput after the first token at 4K.

MI300X progression

MI300X time to first token by optimization stage across 1–8K prompts Interactive line chart comparing baseline vLLM, the MI300X-specific target, native MTP K3, and DFlash B16 across 91 prompt lengths.
Configuration Varied outputs High-acceptance control
Baseline vLLM 77.9 tok/s 77.9 tok/s
Tuned AITER target 152.9 tok/s 152.9 tok/s
Native MTP K3 217.9 tok/s
56.3% acceptance
329.9 tok/s
100.0% acceptance
DFlash B16 214.8 tok/s
15.3% acceptance
1,062.7 tok/s
98.9% acceptance

H100 comparison

H100 time to first token by configuration across 1–8K prompts Interactive line chart comparing the tuned CUDA target, native MTP K3, and DFlash B16 across 91 prompt lengths.
Configuration Varied outputs High-acceptance control
Tuned CUDA target 244.3 tok/s 244.2 tok/s
Native MTP K3 345.3 tok/s
58.4% acceptance
514.9 tok/s
100.0% acceptance
DFlash B16 208.8 tok/s
13.3% acceptance
1,105.3 tok/s
99.0% acceptance

MI300X delivered lower TTFT in every matched configuration: 95.1 vs. 99.7 ms without spec-decoding, 102.1 vs. 104.8 ms with native MTP, and 114.3 vs. 124.3 ms with DFlash. That advantage reversed at longer contexts, with H100 achieving roughly 21% lower TTFT at 8K. While H100 remains substantially stronger for general decode and longer prompts, MI300X is highly competitive at shorter prompts (4k) prefill.

Hero image inspired by Litton Industries' 1977 annual report.