| #!/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() |