blob: 0fac0dcdb07bf7efd35729af847eb7885497c39f [file]
#!/usr/bin/env python3
# Copyright 2026 The IREE Authors
#
# Licensed under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""Generator for the paged-KV CUDA e2e attention test.
This follows the structure of `generate_e2e_attention_tests.py`. The batch,
page pool and head dimensions are fixed, while the number of pages per request
is dynamic so that the test exercises the same shape behavior a real model has.
"""
import argparse
import math
import struct
BATCH = 4
NUM_PAGES = 4
POOL_PAGES = 8
PAGE_SIZE = 32
HEAD_DIM = 128
# The attention op consumes an f16 scale, so round 1/sqrt(HEAD_DIM) to f16 here
# and reuse the exact same value for the host reference. Keeping both sides on
# one Python value avoids a silently diverging magic constant.
SCALE = struct.unpack("<e", struct.pack("<e", 1.0 / math.sqrt(HEAD_DIM)))[0]
QUERY_TYPE = f"tensor<{BATCH}x1x1x{HEAD_DIM}xf16>"
KV_STORAGE_TYPE = f"tensor<{POOL_PAGES}x1x{PAGE_SIZE}x{HEAD_DIM}xf16>"
PAGE_TABLE_TYPE = f"tensor<{BATCH}x?xi64>"
GATHERED_KV_TYPE = f"tensor<{BATCH}x?x1x{PAGE_SIZE}x{HEAD_DIM}xf16>"
MASK_TYPE = f"tensor<{BATCH}x1x1x?x{PAGE_SIZE}xf32>"
OUTPUT_TYPE = f"tensor<{BATCH}x1x1x{HEAD_DIM}xf32>"
STATISTICS_TYPE = f"tensor<{BATCH}x1x1xf32>"
def gather_kv_pages(result, page_table, output):
"""Emits a gather of one logical KV tensor out of the physical page pool."""
return f"""\
%{result} = iree_linalg_ext.gather dimension_map = [0]
ins(%kv_storage, %{page_table} : {KV_STORAGE_TYPE}, {PAGE_TABLE_TYPE})
outs(%{output} : {GATHERED_KV_TYPE})
-> {GATHERED_KV_TYPE}
"""
def generate_attention_mlir(output_path):
text = f"""\
func.func @paged_attention(
%query: {QUERY_TYPE},
%kv_storage: {KV_STORAGE_TYPE},
%key_page_table: {PAGE_TABLE_TYPE},
%value_page_table: {PAGE_TABLE_TYPE})
-> {OUTPUT_TYPE} {{
%zero_f32 = arith.constant 0.0 : f32
%neg_inf_f32 = arith.constant 0xFF800000 : f32
%scale = arith.constant {SCALE!r} : f16
%c1 = arith.constant 1 : index
%num_pages = tensor.dim %key_page_table, %c1 : {PAGE_TABLE_TYPE}
%key_empty = tensor.empty(%num_pages) : {GATHERED_KV_TYPE}
%value_empty = tensor.empty(%num_pages) : {GATHERED_KV_TYPE}
{gather_kv_pages("key", "key_page_table", "key_empty")}\
{gather_kv_pages("value", "value_page_table", "value_empty")}\
// All-zero additive mask. It leaves real scores untouched and only exists so
// that the compiler is able to pad the dynamic key/value dimension.
%mask_empty = tensor.empty(%num_pages) : {MASK_TYPE}
%mask = linalg.fill ins(%zero_f32 : f32)
outs(%mask_empty : {MASK_TYPE}) -> {MASK_TYPE}
%output_empty = tensor.empty() : {OUTPUT_TYPE}
%max_empty = tensor.empty() : {STATISTICS_TYPE}
%sum_empty = tensor.empty() : {STATISTICS_TYPE}
%output_init = linalg.fill ins(%zero_f32 : f32)
outs(%output_empty : {OUTPUT_TYPE}) -> {OUTPUT_TYPE}
%max_init = linalg.fill ins(%neg_inf_f32 : f32)
outs(%max_empty : {STATISTICS_TYPE}) -> {STATISTICS_TYPE}
%sum_init = linalg.fill ins(%zero_f32 : f32)
outs(%sum_empty : {STATISTICS_TYPE}) -> {STATISTICS_TYPE}
%attention:3 = iree_linalg_ext.online_attention {{
indexing_maps = [
affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2, d4)>,
affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d5, d1, d6, d4)>,
affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d5, d1, d6, d3)>,
affine_map<(d0, d1, d2, d3, d4, d5, d6) -> ()>,
affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2, d5, d6)>,
affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2, d3)>,
affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2)>,
affine_map<(d0, d1, d2, d3, d4, d5, d6) -> (d0, d1, d2)>]
}}
ins(%query, %key, %value, %scale, %mask :
{QUERY_TYPE}, {GATHERED_KV_TYPE}, {GATHERED_KV_TYPE}, f16, {MASK_TYPE})
outs(%output_init, %max_init, %sum_init :
{OUTPUT_TYPE}, {STATISTICS_TYPE}, {STATISTICS_TYPE}) {{
^bb0(%score: f32):
iree_linalg_ext.yield %score : f32
}} -> {OUTPUT_TYPE}, {STATISTICS_TYPE}, {STATISTICS_TYPE}
%normalized_empty = tensor.empty() : {OUTPUT_TYPE}
%normalized = linalg.generic {{
indexing_maps = [
affine_map<(d0, d1, d2, d3) -> (d0, d1, d2, d3)>,
affine_map<(d0, d1, d2, d3) -> (d0, d1, d2)>,
affine_map<(d0, d1, d2, d3) -> (d0, d1, d2, d3)>],
iterator_types = ["parallel", "parallel", "parallel", "parallel"]
}} ins(%attention#0, %attention#2 : {OUTPUT_TYPE}, {STATISTICS_TYPE})
outs(%normalized_empty : {OUTPUT_TYPE}) {{
^bb0(%attn_value: f32, %sum: f32, %out: f32):
%result = arith.divf %attn_value, %sum : f32
linalg.yield %result : f32
}} -> {OUTPUT_TYPE}
return %normalized : {OUTPUT_TYPE}
}}
"""
with open(output_path, "w") as file:
file.write(text)
def page_table_entries(offset, stride):
"""Builds a deterministic, non-contiguous logical-to-physical page mapping.
The strides are coprime with POOL_PAGES so the mapping walks the whole page
pool, both strides are odd so the key and value tables never select the same
physical page for one slot, and the batch index shifts the mapping so no two
requests share a page sequence.
"""
return [
(offset + stride * page + batch) % POOL_PAGES
for batch in range(BATCH)
for page in range(NUM_PAGES)
]
def emit_page_table(name, pages):
"""Emits one host-constant page table and exports it as a buffer view."""
flat_type = f"tensor<{BATCH * NUM_PAGES}xi64>"
table_type = f"tensor<{BATCH}x{NUM_PAGES}xi64>"
values = ", ".join(str(page) for page in pages)
return f"""\
%{name}_flat = arith.constant dense<[{values}]> : {flat_type}
%{name}_table = tensor.expand_shape %{name}_flat [[0, 1]] output_shape [{BATCH}, {NUM_PAGES}] : {flat_type} into {table_type}
%{name}_page_table = hal.tensor.export %{name}_table : {table_type} -> !hal.buffer_view
"""
def generate_calls_mlir(output_path):
key_pages = page_table_entries(3, 5)
value_pages = page_table_entries(6, 7)
text = f"""\
builtin.module @calls {{
func.func private @attention_test.generate_random_4d_tensor(
%device: !hal.device, %dim0: i64, %dim1: i64, %dim2: i64,
%dim3: i64, %element_type: i32, %seed: i32) -> !hal.buffer_view
func.func private @attention_test.check_paged_attention_results(
%device: !hal.device, %batch: i64, %num_pages: i64, %page_size: i64,
%head_dim: i64, %scale: f32, %query: !hal.buffer_view,
%kv_storage: !hal.buffer_view, %key_page_table: !hal.buffer_view,
%value_page_table: !hal.buffer_view, %result: !hal.buffer_view)
func.func private @module.paged_attention(
%query: !hal.buffer_view, %kv_storage: !hal.buffer_view,
%key_page_table: !hal.buffer_view, %value_page_table: !hal.buffer_view)
-> !hal.buffer_view
func.func @paged_attention_test() attributes {{
iree.reflection = {{description = "Paged KV attention B{BATCH} pages{NUM_PAGES} page{PAGE_SIZE} head{HEAD_DIM} f16"}}
}} {{
%device_index = arith.constant 0 : index
%device = hal.devices.get %device_index : !hal.device
%batch = arith.constant {BATCH} : i64
%num_pages = arith.constant {NUM_PAGES} : i64
%page_size = arith.constant {PAGE_SIZE} : i64
%head_dim = arith.constant {HEAD_DIM} : i64
%scale = arith.constant {SCALE!r} : f32
%c1 = arith.constant 1 : i64
%pool_pages = arith.constant {POOL_PAGES} : i64
%query_seed = arith.constant 17 : i32
%kv_seed = arith.constant 23 : i32
%query_type = hal.element_type<f16> : i32
%query = call @attention_test.generate_random_4d_tensor(
%device, %batch, %c1, %c1, %head_dim, %query_type, %query_seed) : (!hal.device, i64, i64, i64, i64, i32, i32) -> !hal.buffer_view
%kv = call @attention_test.generate_random_4d_tensor(
%device, %pool_pages, %c1, %page_size, %head_dim, %query_type, %kv_seed) : (!hal.device, i64, i64, i64, i64, i32, i32) -> !hal.buffer_view
{emit_page_table("key", key_pages)}\
{emit_page_table("value", value_pages)}\
%result = call @module.paged_attention(%query, %kv, %key_page_table, %value_page_table) : (!hal.buffer_view, !hal.buffer_view, !hal.buffer_view, !hal.buffer_view) -> !hal.buffer_view
call @attention_test.check_paged_attention_results(%device, %batch, %num_pages, %page_size, %head_dim, %scale, %query, %kv, %key_page_table, %value_page_table, %result) : (!hal.device, i64, i64, i64, i64, f32, !hal.buffer_view, !hal.buffer_view, !hal.buffer_view, !hal.buffer_view, !hal.buffer_view) -> ()
return
}}
}}
"""
with open(output_path, "w") as file:
file.write(text)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--output_attention_mlir", required=True)
parser.add_argument("--output_calls_mlir", required=True)
args = parser.parse_args()
generate_attention_mlir(args.output_attention_mlir)
generate_calls_mlir(args.output_calls_mlir)
if __name__ == "__main__":
main()