blob: 6b12b113d2dbdda62f582fdea6df9207ab9c3b47 [file]
# 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
"""Exercises iree-benchmark-module lifecycle contracts.
Covers execution models, batching, discovery, output ownership, capture and
failures through iree.runtime.benchmark.
"""
import os
from pathlib import Path
import re
import subprocess
import tempfile
import unittest
from unittest import mock
import iree.compiler
from iree._runtime import libs
from iree.runtime.benchmark import (
_build_benchmark_args as build_benchmark_args,
_run_benchmark as run_benchmark_command,
benchmark_module,
BenchmarkToolError,
)
# Two 64x64 matmuls and one 64x32 matmul: two dispatch functions, one of them
# called twice per invocation.
MATMUL = """
func.func @main(%lhs: tensor<64x64xf32>, %rhs: tensor<64x64xf32>,
%narrow: tensor<64x32xf32>) -> tensor<64x32xf32> {
%zero = arith.constant 0.0 : f32
%empty = tensor.empty() : tensor<64x64xf32>
%fill = linalg.fill ins(%zero : f32) outs(%empty : tensor<64x64xf32>) -> tensor<64x64xf32>
%0 = linalg.matmul ins(%lhs, %rhs : tensor<64x64xf32>, tensor<64x64xf32>)
outs(%fill : tensor<64x64xf32>) -> tensor<64x64xf32>
%1 = linalg.matmul ins(%0, %rhs : tensor<64x64xf32>, tensor<64x64xf32>)
outs(%fill : tensor<64x64xf32>) -> tensor<64x64xf32>
%narrow_empty = tensor.empty() : tensor<64x32xf32>
%narrow_fill = linalg.fill ins(%zero : f32) outs(%narrow_empty : tensor<64x32xf32>) -> tensor<64x32xf32>
%2 = linalg.matmul ins(%1, %narrow : tensor<64x64xf32>, tensor<64x32xf32>)
outs(%narrow_fill : tensor<64x32xf32>) -> tensor<64x32xf32>
return %2 : tensor<64x32xf32>
}
"""
# Two exported functions without inputs, for discovery without --function.
NO_INPUTS = """
func.func @one() -> tensor<4xf32> {
%input = util.unfoldable_constant dense<-1.0> : tensor<4xf32>
%result = math.absf %input : tensor<4xf32>
return %result : tensor<4xf32>
}
func.func @two() -> tensor<4xf32> {
%input = util.unfoldable_constant dense<-1.0> : tensor<4xf32>
%result = math.absf %input : tensor<4xf32>
return %result : tensor<4xf32>
}
"""
# Uses no HAL device.
PURE = """
func.func @main() -> i32 {
%result = arith.constant 42 : i32
return %result : i32
}
"""
# Adds the number of invocations so far to the absolute input.
STATEFUL = """
util.global private mutable @count = 0 : i32
func.func @main(%input: tensor<4xf32>) -> tensor<4xf32> {
%old = util.global.load @count : i32
%one = arith.constant 1 : i32
%next = arith.addi %old, %one : i32
util.global.store %next, @count : i32
%count = arith.sitofp %next : i32 to f32
%empty = tensor.empty() : tensor<4xf32>
%counts = linalg.fill ins(%count : f32) outs(%empty : tensor<4xf32>) -> tensor<4xf32>
%absolute = math.absf %input : tensor<4xf32>
%result = arith.addf %absolute, %counts : tensor<4xf32>
return %result : tensor<4xf32>
}
"""
# One dispatch, dumped as a generated dispatch benchmark.
ABSOLUTE = """
func.func @abs(%input: tensor<4xf32>) -> tensor<4xf32> {
%result = math.absf %input : tensor<4xf32>
return %result : tensor<4xf32>
}
"""
LOCAL_TARGET = [
"--iree-hal-target-device=local",
"--iree-hal-local-target-device-backends=llvm-cpu",
]
MATMUL_INPUTS = ["64x64xf32=1", "64x64xf32=1", "64x32xf32=1"]
DEVICE = "local-task"
# Flags of every run: four iterations on a device with two workers.
BENCHMARK_FLAGS = {
"device": DEVICE,
"task_topology_group_count": 2,
"benchmark_min_time": "4x",
}
# The tool under test and the replay tools come from the same package.
TOOLS = Path(libs.library_path)
BENCHMARK_TOOL = TOOLS / "iree-benchmark-module"
TIMEOUT_SECONDS = 60
def setUpModule():
# The checks must be independent of ambient Google Benchmark defaults.
environment = {
key: value
for key, value in os.environ.items()
if not key.startswith("BENCHMARK_")
}
patch = mock.patch.dict(os.environ, environment, clear=True)
patch.start()
unittest.addModuleCleanup(patch.stop)
def compile_module(directory, name, source, *flags):
"""Compiles MLIR source to directory/name.vmfb and returns its path."""
output = directory / f"{name}.vmfb"
iree.compiler.compile_str(
source, extra_args=[*LOCAL_TARGET, *flags], output_file=str(output)
)
return output
def benchmark_results(module, function="main", inputs=MATMUL_INPUTS, **flags):
"""Benchmarks a module with BENCHMARK_FLAGS and returns all result rows.
Keyword flags are passed to the tool. Without a function every exported
function that takes no inputs runs.
"""
return benchmark_module(
module,
entry_function=function,
inputs=inputs,
timeout=TIMEOUT_SECONDS,
executable=BENCHMARK_TOOL,
**{**BENCHMARK_FLAGS, **flags},
)
def run_benchmark(module, function="main", inputs=MATMUL_INPUTS, **flags):
"""Runs a module like benchmark_results and returns stdout and stderr."""
args, _ = build_benchmark_args(
module,
entry_function=function,
inputs=inputs,
executable=BENCHMARK_TOOL,
**{**BENCHMARK_FLAGS, **flags},
)
return run_benchmark_command(args, timeout=TIMEOUT_SECONDS)
def run_replay_tool(name, *args):
"""Runs a replay tool from the package and fails with its output."""
result = subprocess.run(
[TOOLS / name, *map(str, args)],
capture_output=True,
text=True,
timeout=TIMEOUT_SECONDS,
)
if result.returncode != 0:
raise AssertionError(f"{name} failed:\n{result.stderr}\n{result.stdout}")
def compile_dispatch_benchmark(directory):
"""Compiles the generated benchmark of ABSOLUTE's one dispatch.
Returns the module and the name of its benchmark function.
"""
sources = directory / "dispatches"
compile_module(
directory,
"abs",
ABSOLUTE,
f"--iree-hal-dump-executable-benchmarks-to={sources}",
)
(source,) = sources.glob("*_benchmark.mlir")
module = directory / "dispatch.vmfb"
iree.compiler.compile_file(str(source), output_file=str(module))
stdout, _ = run_benchmark(
module, function=None, inputs=[], benchmark_list_tests=True
)
(name,) = re.findall(r"^BM_([^/\s]+)", stdout, re.MULTILINE)
return module, name
class BenchmarkLifecycleTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
modules = tempfile.TemporaryDirectory(prefix="benchmark-lifecycle-test-")
cls.addClassCleanup(modules.cleanup)
directory = Path(modules.name)
cls.sync = compile_module(directory, "sync", MATMUL)
cls.async_module = compile_module(
directory, "async", MATMUL, "--iree-execution-model=async-external"
)
cls.repeated = compile_module(
directory,
"repeated",
MATMUL,
"--iree-hal-benchmark-dispatch-repeat-count=4",
)
# Four iterations of each execution model: four sync invocations, one
# batch of four async invocations, or one invocation that repeats its
# dispatches four times.
cls.batched = [(cls.sync, 1), (cls.async_module, 4), (cls.repeated, 4)]
def setUp(self):
temporary = tempfile.TemporaryDirectory(prefix="benchmark-lifecycle-test-")
self.addCleanup(temporary.cleanup)
self.directory = Path(temporary.name)
def assertBenchmarkFails(self, message, module, **kwargs):
"""Checks that run_benchmark fails with message in its output."""
with self.assertRaisesRegex(BenchmarkToolError, re.escape(message)):
run_benchmark(module, **kwargs)
def test_unregistered_flags_pass_through(self):
# As in the other benchmark tools.
run_benchmark(self.sync, not_a_registered_flag=1)
def test_iterations_for_each_execution_model(self):
for module, batch_size in self.batched:
with self.subTest(module=module.stem):
results = benchmark_results(module, batch_size=batch_size)
self.assertEqual([result.iterations for result in results], ["4"])
def test_output_processing_for_each_execution_model(self):
for module, batch_size in self.batched:
with self.subTest(module=module.stem):
stdout, _ = run_benchmark(
module,
batch_size=batch_size,
enable_output_processing=True,
expected_output="64x32xf32=262144",
)
self.assertIn("[SUCCESS]", stdout)
def test_batch_rounded_up_to_concurrency(self):
# Three timelines run a batch of four as six invocations.
results = benchmark_results(
self.async_module, batch_size=4, batch_concurrency=3
)
self.assertEqual([result.iterations for result in results], ["6"])
def test_discovery_prepares_async_arguments(self):
# Discovery must also prepare empty arguments for async entry points.
for model in ("async-internal", "async-external"):
with self.subTest(model=model):
module = compile_module(
self.directory,
model,
NO_INPUTS,
f"--iree-execution-model={model}",
)
results = benchmark_results(module, function=None, inputs=[])
self.assertEqual(len(results), 2, results)
def test_filtered_discovery_uses_time_unit(self):
for model in ("async-internal", "async-external"):
with self.subTest(model=model):
module = compile_module(
self.directory,
model,
NO_INPUTS,
f"--iree-execution-model={model}",
)
results = benchmark_results(
module,
function=None,
inputs=[],
benchmark_filter="BM_two",
time_unit="ns",
)
self.assertEqual(len(results), 1, results)
self.assertTrue(results[0].time.endswith(" ns"), results)
def test_output_processing_requires_one_function(self):
# Without --function both @one and @two run.
module = compile_module(self.directory, "no-inputs", NO_INPUTS)
self.assertBenchmarkFails(
"output processing requires one function",
module,
function=None,
inputs=[],
enable_output_processing=True,
)
def test_output_processing_without_devices(self):
module = compile_module(self.directory, "pure", PURE)
stdout, _ = run_benchmark(
module, inputs=[], enable_output_processing=True, output="-"
)
self.assertIn("i32=42", stdout)
def test_outputs_of_last_measured_invocation(self):
# The stateful function counts its invocations, and the outputs are
# those of the fourth.
for model in ("async-internal", "async-external"):
with self.subTest(model=model):
module = compile_module(
self.directory,
model,
STATEFUL,
f"--iree-execution-model={model}",
)
stdout, _ = run_benchmark(
module,
inputs=["4xf32=-1"],
enable_output_processing=True,
expected_output="4xf32=5",
)
self.assertIn("[SUCCESS]", stdout)
def test_dispatch_benchmark_discovery_and_selection(self):
# Automatic discovery and explicit selection use the same dispatch ABI:
# two batches of three per four iterations.
module, name = compile_dispatch_benchmark(self.directory)
for function in (None, name):
with self.subTest(function=function):
results = benchmark_results(
module, function=function, inputs=[], batch_size=3
)
self.assertEqual([result.iterations for result in results], ["6"])
def test_dispatch_benchmark_rejects_inputs(self):
module, name = compile_dispatch_benchmark(self.directory)
self.assertBenchmarkFails(
"dispatch wrappers take --batch_size, not --input",
module,
function=name,
inputs=["4xf32=1"],
)
def test_device_statistics(self):
_, stderr = run_benchmark(self.sync, print_device_statistics=True)
self.assertIn("main_dispatch_", stderr)
def test_replay_capture(self):
for module in (self.sync, self.async_module):
with self.subTest(module=module.stem):
capture = self.directory / f"{module.stem}.ireereplay"
run_benchmark(module, device_replay_output=capture)
run_replay_tool("iree-run-replay", f"--device={DEVICE}", capture)
def test_replay_capture_finalized_on_error(self):
capture = self.directory / "failed.ireereplay"
self.assertBenchmarkFails(
"input0 shape rank mismatch",
self.sync,
inputs=["1xf32=1", *MATMUL_INPUTS[1:]],
device_replay_output=capture,
)
run_replay_tool("iree-dump-replay", capture)
def test_batch_size_and_concurrency_must_be_positive(self):
for flags in ({"batch_size": 0}, {"batch_concurrency": 0}):
with self.subTest(**flags):
self.assertBenchmarkFails(
"batch size and concurrency must be positive", self.sync, **flags
)
if __name__ == "__main__":
unittest.main()