[Runtime] Unify dtype handling in Python bindings (#24587)

Signed-off-by: Florian Walbroel <walbroel@roofline.ai>
diff --git a/runtime/bindings/python/CMakeLists.txt b/runtime/bindings/python/CMakeLists.txt
index 3f4e357..8668aa7 100644
--- a/runtime/bindings/python/CMakeLists.txt
+++ b/runtime/bindings/python/CMakeLists.txt
@@ -132,6 +132,7 @@
     "iree/runtime/_binding.pyi"
     "iree/runtime/array_interop.py"
     "iree/runtime/benchmark.py"
+    "iree/runtime/dtypes.py"
     "iree/runtime/flags.py"
     "iree/runtime/function.py"
     "iree/runtime/io.py"
@@ -319,6 +320,13 @@
 
 iree_py_test(
   NAME
+    dtypes_test
+  SRCS
+    "tests/dtypes_test.py"
+)
+
+iree_py_test(
+  NAME
     flags_test
   SRCS
     "tests/flags_test.py"
diff --git a/runtime/bindings/python/iree/runtime/array_interop.py b/runtime/bindings/python/iree/runtime/array_interop.py
index da2b21c..a7256ba 100644
--- a/runtime/bindings/python/iree/runtime/array_interop.py
+++ b/runtime/bindings/python/iree/runtime/array_interop.py
@@ -7,7 +7,6 @@
 
 from typing import Literal, Optional, Tuple, cast
 import logging
-import ml_dtypes
 import numpy as np
 import numpy.lib.mixins
 
@@ -21,6 +20,7 @@
     MemoryType,
     HalFence,
 )
+from .dtypes import map_dtype_to_hal_element_type
 
 __all__ = [
     "asdevicearray",
@@ -334,37 +334,5 @@
     )
 
 
-# NOTE: Numpy dtypes are not hashable and exist in a hierarchy that should
-# be queried via isinstance checks. This should be done as a fallback but
-# this is a linear list for quick access to the most common. There may also
-# be a better way to do this.
-_DTYPE_TO_HAL_ELEMENT_TYPE = (
-    (np.float16, HalElementType.FLOAT_16),
-    (np.float32, HalElementType.FLOAT_32),
-    (np.float64, HalElementType.FLOAT_64),
-    (np.int32, HalElementType.SINT_32),
-    (np.int64, HalElementType.SINT_64),
-    (np.int16, HalElementType.SINT_16),
-    (np.int8, HalElementType.SINT_8),
-    (np.uint32, HalElementType.UINT_32),
-    (np.uint64, HalElementType.UINT_64),
-    (np.uint16, HalElementType.UINT_16),
-    (np.uint8, HalElementType.UINT_8),
-    (np.bool_, HalElementType.BOOL_8),
-    (np.complex64, HalElementType.COMPLEX_64),
-    (np.complex128, HalElementType.COMPLEX_128),
-    (ml_dtypes.bfloat16, HalElementType.BFLOAT_16),
-    (ml_dtypes.float8_e4m3fn, HalElementType.FLOAT_8_E4M3_FN),
-    (ml_dtypes.float8_e4m3fnuz, HalElementType.FLOAT_8_E4M3_FNUZ),
-    (ml_dtypes.float8_e5m2, HalElementType.FLOAT_8_E5M2),
-    (ml_dtypes.float8_e5m2fnuz, HalElementType.FLOAT_8_E5M2_FNUZ),
-    (ml_dtypes.float8_e8m0fnu, HalElementType.FLOAT_8_E8M0_FNU),
-)
-
-
 def map_dtype_to_element_type(dtype) -> Optional[HalElementType]:
-    for match_dtype, element_type in _DTYPE_TO_HAL_ELEMENT_TYPE:
-        if match_dtype == dtype:
-            return element_type
-    else:
-        return None
+    return map_dtype_to_hal_element_type(dtype)
diff --git a/runtime/bindings/python/iree/runtime/benchmark.py b/runtime/bindings/python/iree/runtime/benchmark.py
index ecccde0..3a2d9da 100644
--- a/runtime/bindings/python/iree/runtime/benchmark.py
+++ b/runtime/bindings/python/iree/runtime/benchmark.py
@@ -23,6 +23,7 @@
 import numpy
 import os
 import subprocess
+from .dtypes import DTYPE_TO_ABI_TYPE
 
 __all__ = [
     "benchmark_exe",
@@ -33,21 +34,6 @@
     "BenchmarkResult", "benchmark_name time cpu_time iterations user_counters"
 )
 
-DTYPE_TO_ABI_TYPE = {
-    numpy.dtype(numpy.float16): "f16",
-    numpy.dtype(numpy.float32): "f32",
-    numpy.dtype(numpy.float64): "f64",
-    numpy.dtype(numpy.int8): "i8",
-    numpy.dtype(numpy.uint8): "i8",
-    numpy.dtype(numpy.int16): "i16",
-    numpy.dtype(numpy.uint16): "i16",
-    numpy.dtype(numpy.int32): "i32",
-    numpy.dtype(numpy.uint32): "i32",
-    numpy.dtype(numpy.int64): "i64",
-    numpy.dtype(numpy.uint64): "i64",
-    numpy.dtype(numpy.bool_): "i1",
-}
-
 
 class BenchmarkToolError(Exception):
     """Benchmark exception that preserves the command line and error output."""
@@ -95,7 +81,7 @@
         args.append(f"--module={module}")
         if entry_function is None:
             raise ValueError(
-                f"When specifying the module as a filepath the entry function must be specified."
+                "When specifying the module as a filepath the entry function must be specified."
             )
 
     args.append(f"--function={entry_function}")
diff --git a/runtime/bindings/python/iree/runtime/dtypes.py b/runtime/bindings/python/iree/runtime/dtypes.py
new file mode 100644
index 0000000..2d37b14
--- /dev/null
+++ b/runtime/bindings/python/iree/runtime/dtypes.py
@@ -0,0 +1,127 @@
+import dataclasses
+import numpy as np
+import ml_dtypes
+from typing import Any
+
+from ._binding import HalElementType
+
+
+@dataclasses.dataclass(frozen=True)
+class DTypeInfo:
+    dtype: np.dtype
+    name: str
+    abi_type: str
+    hal_type: HalElementType
+
+    @property
+    def is_complex(self) -> bool:
+        return np.issubdtype(self.dtype, np.complexfloating)
+
+    @property
+    def is_floating_point(self) -> bool:
+        return np.issubdtype(self.dtype, np.floating)
+
+    @property
+    def is_integer(self) -> bool:
+        return np.issubdtype(self.dtype, np.integer)
+
+    @property
+    def is_signed(self) -> bool:
+        return np.issubdtype(self.dtype, np.signedinteger)
+
+
+_ML_DTYPE_TO_HAL_ELEMENT_TYPE = {
+    np.dtype(ml_dtypes.bfloat16): HalElementType.BFLOAT_16,
+    np.dtype(ml_dtypes.float8_e4m3fn): HalElementType.FLOAT_8_E4M3_FN,
+    np.dtype(ml_dtypes.float8_e4m3fnuz): HalElementType.FLOAT_8_E4M3_FNUZ,
+    np.dtype(ml_dtypes.float8_e5m2): HalElementType.FLOAT_8_E5M2,
+    np.dtype(ml_dtypes.float8_e5m2fnuz): HalElementType.FLOAT_8_E5M2_FNUZ,
+    np.dtype(ml_dtypes.float8_e8m0fnu): HalElementType.FLOAT_8_E8M0_FNU,
+}
+
+
+_DTYPE_TO_INFO = {
+    info.dtype: info
+    for info in [
+        # Float
+        DTypeInfo(np.dtype(np.float16), "float16", "f16", HalElementType.FLOAT_16),
+        DTypeInfo(np.dtype(np.float32), "float32", "f32", HalElementType.FLOAT_32),
+        DTypeInfo(np.dtype(np.float64), "float64", "f64", HalElementType.FLOAT_64),
+        # Integer
+        DTypeInfo(np.dtype(np.int8), "int8", "i8", HalElementType.SINT_8),
+        DTypeInfo(np.dtype(np.uint8), "uint8", "i8", HalElementType.UINT_8),
+        DTypeInfo(np.dtype(np.int16), "int16", "i16", HalElementType.SINT_16),
+        DTypeInfo(np.dtype(np.uint16), "uint16", "i16", HalElementType.UINT_16),
+        DTypeInfo(np.dtype(np.int32), "int32", "i32", HalElementType.SINT_32),
+        DTypeInfo(np.dtype(np.uint32), "uint32", "i32", HalElementType.UINT_32),
+        DTypeInfo(np.dtype(np.int64), "int64", "i64", HalElementType.SINT_64),
+        DTypeInfo(np.dtype(np.uint64), "uint64", "i64", HalElementType.UINT_64),
+        # Boolean
+        DTypeInfo(np.dtype(np.bool_), "bool", "i1", HalElementType.BOOL_8),
+        # Complex
+        DTypeInfo(
+            np.dtype(np.complex64),
+            "complex64",
+            "complex<f32>",
+            HalElementType.COMPLEX_64,
+        ),
+        DTypeInfo(
+            np.dtype(np.complex128),
+            "complex128",
+            "complex<f64>",
+            HalElementType.COMPLEX_128,
+        ),
+    ]
+}
+
+_NAME_TO_INFO: dict[str, DTypeInfo] = {
+    info.name: info for info in _DTYPE_TO_INFO.values()
+}
+
+_ABI_TYPE_TO_INFO: dict[str, DTypeInfo] = {
+    info.abi_type: info
+    for info in _DTYPE_TO_INFO.values()
+    if not info.is_integer
+    or info.is_signed  # Exclude unsigned integers since they share ABI types with signed integers.
+}
+
+
+def map_dtype_to_dtype_info(dtype: np.dtype | type) -> DTypeInfo:
+    dtype = np.dtype(dtype)
+    try:
+        return _DTYPE_TO_INFO[dtype]
+    except KeyError:
+        raise KeyError(f"Numpy dtype {dtype} not found.") from None
+
+
+def map_name_to_dtype_info(name: str) -> DTypeInfo:
+    try:
+        return _NAME_TO_INFO[name]
+    except KeyError:
+        raise KeyError(f"Numpy dtype name {name!r} not found.") from None
+
+
+def map_abi_type_to_dtype_info(abi_type: str) -> DTypeInfo:
+    try:
+        return _ABI_TYPE_TO_INFO[abi_type]
+    except KeyError:
+        raise KeyError(f"ABI type {abi_type!r} not found.") from None
+
+
+def map_dtype_to_hal_element_type(dtype: Any) -> HalElementType | None:
+    try:
+        normalized_dtype = np.dtype(dtype)
+    except TypeError:
+        return None
+    elem_ty = _ML_DTYPE_TO_HAL_ELEMENT_TYPE.get(normalized_dtype)
+    if elem_ty is not None:
+        return elem_ty
+    try:
+        return map_dtype_to_dtype_info(normalized_dtype).hal_type
+    except KeyError:
+        return None
+
+
+DTYPE_TO_ABI_TYPE: dict[np.dtype, str] = {
+    info.dtype: info.abi_type for info in _DTYPE_TO_INFO.values()
+}
diff --git a/runtime/bindings/python/iree/runtime/function.py b/runtime/bindings/python/iree/runtime/function.py
index c104d24..342dd94 100644
--- a/runtime/bindings/python/iree/runtime/function.py
+++ b/runtime/bindings/python/iree/runtime/function.py
@@ -4,7 +4,7 @@
 # See https://llvm.org/LICENSE.txt for license information.
 # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
 
-from typing import Dict, Optional
+from typing import Optional
 
 import json
 import logging
@@ -27,11 +27,11 @@
 )
 
 from .array_interop import (
-    map_dtype_to_element_type,
     DeviceArray,
 )
-from .flags import (
-    FUNCTION_INPUT_VALIDATION,
+
+from .dtypes import (
+    map_abi_type_to_dtype_info,
 )
 
 __all__ = [
@@ -204,7 +204,7 @@
     buffer_view = vm_list.get_as_object(vm_index, HalBufferView)
     dtype_str = desc[1]
     try:
-        dtype = ABI_TYPE_TO_DTYPE[dtype_str]
+        dtype = map_abi_type_to_dtype_info(dtype_str).dtype
     except KeyError:
         _raise_return_error(inv, f"unrecognized dtype '{dtype_str}'")
     x = DeviceArray(
@@ -279,42 +279,12 @@
     "bf16": _vm_to_scalar(float),
 }
 
-ABI_TYPE_TO_DTYPE = {
-    # TODO: Others.
-    "f32": np.float32,
-    "i32": np.int32,
-    "i64": np.int64,
-    "f64": np.float64,
-    "i16": np.int16,
-    "i8": np.int8,
-    "i1": np.bool_,
-}
-
 # When we get an ndarray as an argument and are implicitly converting it to a
 # buffer view, flags for doing so.
 IMPLICIT_BUFFER_ARG_MEMORY_TYPE = MemoryType.DEVICE_LOCAL
 IMPLICIT_BUFFER_ARG_USAGE = BufferUsage.DEFAULT
 
 
-def _is_ndarray_descriptor(desc):
-    return desc and desc[0] == "ndarray"
-
-
-def _is_0d_ndarray_descriptor(desc):
-    # Example: ["ndarray", "f32", 0]
-    return desc and desc[0] == "ndarray" and desc[2] == 0
-
-
-def _cast_scalar_to_ndarray(inv: Invocation, x, desc):
-    # Example descriptor: ["ndarray", "f32", 0]
-    dtype_str = desc[1]
-    try:
-        dtype = ABI_TYPE_TO_DTYPE[dtype_str]
-    except KeyError:
-        _raise_argument_error(inv, f"unrecognized dtype '{dtype_str}'")
-    return dtype(x)
-
-
 class ArgumentError(ValueError):
     pass
 
diff --git a/runtime/bindings/python/iree/runtime/io.py b/runtime/bindings/python/iree/runtime/io.py
index dcd5ce8..bc430ea 100644
--- a/runtime/bindings/python/iree/runtime/io.py
+++ b/runtime/bindings/python/iree/runtime/io.py
@@ -13,6 +13,7 @@
 from os import PathLike
 
 from ._binding import ParameterIndex, ParameterIndexEntry
+from .dtypes import map_name_to_dtype_info, map_dtype_to_dtype_info
 
 __all__ = [
     "parameter_index_add_numpy_ndarray",
@@ -151,9 +152,11 @@
 
     # Unpack/validate.
     try:
-        dtype = _NAME_TO_DTYPE[dtype_name]
-    except KeyError:
-        raise ValueError(f"Unknown dtype name '{dtype_name}'")
+        dtype = map_name_to_dtype_info(dtype_name).dtype
+    except KeyError as e:
+        raise ValueError(
+            f"Unsupported dtype for parameter entry {index_entry.key}"
+        ) from e
     try:
         shape = [int(d) for d in shape]
     except ValueError as e:
@@ -163,36 +166,6 @@
     return t.view(dtype=dtype).reshape(shape)
 
 
-_DTYPE_TO_NAME = (
-    (np.dtype(np.float16), "float16"),
-    (np.dtype(np.float32), "float32"),
-    (np.dtype(np.float64), "float64"),
-    (np.dtype(np.int32), "int32"),
-    (np.dtype(np.int64), "int64"),
-    (np.dtype(np.int16), "int16"),
-    (np.dtype(np.int8), "int8"),
-    (np.dtype(np.uint32), "uint32"),
-    (np.dtype(np.uint64), "uint64"),
-    (np.dtype(np.uint16), "uint16"),
-    (np.dtype(np.uint8), "uint8"),
-    (np.dtype(np.bool_), "bool"),
-    (np.dtype(np.complex64), "complex64"),
-    (np.dtype(np.complex128), "complex128"),
-)
-
-_NAME_TO_DTYPE: dict[str, np.dtype[Any]] = {
-    name: np_dtype for np_dtype, name in _DTYPE_TO_NAME
-}
-
-
-def _map_dtype_to_name(dtype) -> str:
-    for match_dtype, dtype_name in _DTYPE_TO_NAME:
-        if match_dtype == dtype:
-            return dtype_name
-
-    raise KeyError(f"Numpy dtype {dtype} not found.")
-
-
 _metadata_version = "TENSORv0"
 """Magic number to identify the format version.
 The current version that will be used when adding tensors to a parameter index."""
@@ -209,20 +182,17 @@
 def _make_tensor_metadata(t: np.ndarray) -> str:
     """Makes a tensor metadata blob that can be used to reconstruct the tensor."""
     dtype = t.dtype
-    dtype_name = _map_dtype_to_name(dtype)
-    is_complex = np.issubdtype(dtype, np.complexfloating)
-    is_floating_point = np.issubdtype(dtype, np.floating)
-    is_signed = np.issubdtype(dtype, np.signedinteger)
+    dtype_info = map_dtype_to_dtype_info(dtype)
     dtype_desc = {
         "class_name": type(dtype).__name__,
-        "is_complex": is_complex,
-        "is_floating_point": is_floating_point,
-        "is_signed": is_signed,
+        "is_complex": dtype_info.is_complex,
+        "is_floating_point": dtype_info.is_floating_point,
+        "is_signed": dtype_info.is_signed,
         "itemsize": dtype.itemsize,
     }
     d = {
         "type": "Tensor",
-        "dtype": dtype_name,
+        "dtype": dtype_info.name,
         "shape": list(t.shape),
         "dtype_desc": dtype_desc,
     }
diff --git a/runtime/bindings/python/tests/dtypes_test.py b/runtime/bindings/python/tests/dtypes_test.py
new file mode 100644
index 0000000..baa0400
--- /dev/null
+++ b/runtime/bindings/python/tests/dtypes_test.py
@@ -0,0 +1,108 @@
+# 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
+
+import numpy as np
+import unittest
+
+from iree import runtime as rt
+import iree.runtime.dtypes as dtypes
+import ml_dtypes
+
+
+class DTypesTest(unittest.TestCase):
+    def testDTypeToInfo(self):
+        info = dtypes.map_dtype_to_dtype_info(np.dtype(np.bool_))
+        self.assertEqual(info.dtype, np.dtype(np.bool_))
+        self.assertEqual(info.name, "bool")
+        self.assertEqual(info.abi_type, "i1")
+        self.assertFalse(info.is_integer)
+        self.assertFalse(info.is_signed)
+        self.assertFalse(info.is_floating_point)
+        self.assertFalse(info.is_complex)
+
+        with self.assertRaises(KeyError):
+            dtypes.map_dtype_to_dtype_info(np.dtype("bfloat16"))
+
+    def testNameToInfo(self):
+        info = dtypes.map_name_to_dtype_info("float32")
+        self.assertEqual(info.dtype, np.dtype(np.float32))
+        self.assertEqual(info.name, "float32")
+        self.assertEqual(info.abi_type, "f32")
+        self.assertFalse(info.is_integer)
+        self.assertFalse(info.is_signed)
+        self.assertTrue(info.is_floating_point)
+        self.assertFalse(info.is_complex)
+
+        with self.assertRaises(KeyError):
+            dtypes.map_name_to_dtype_info("invalid")
+
+    def testAbiTypeToInfo(self):
+        info = dtypes.map_abi_type_to_dtype_info("i16")
+        self.assertEqual(info.dtype, np.dtype(np.int16))
+        self.assertEqual(info.name, "int16")
+        self.assertEqual(info.abi_type, "i16")
+        self.assertTrue(info.is_integer)
+        self.assertTrue(info.is_signed)
+        self.assertFalse(info.is_floating_point)
+        self.assertFalse(info.is_complex)
+
+        with self.assertRaises(KeyError):
+            dtypes.map_abi_type_to_dtype_info("invalid")
+
+        self.assertEqual(
+            dtypes.map_abi_type_to_dtype_info("i16").dtype, np.dtype(np.int16)
+        )
+        self.assertEqual(
+            dtypes.map_abi_type_to_dtype_info("i32").dtype, np.dtype(np.int32)
+        )
+        self.assertEqual(
+            dtypes.map_abi_type_to_dtype_info("i64").dtype, np.dtype(np.int64)
+        )
+
+    def testDTypeToAbiType(self) -> None:
+        self.assertEqual(dtypes.DTYPE_TO_ABI_TYPE[np.dtype(np.uint8)], "i8")
+        self.assertEqual(dtypes.DTYPE_TO_ABI_TYPE[np.dtype(np.uint16)], "i16")
+        self.assertEqual(dtypes.DTYPE_TO_ABI_TYPE[np.dtype(np.uint32)], "i32")
+
+    def testMapMlDtypeToHalElementTypeFromNumpyDType(self):
+        self.assertEqual(
+            dtypes.map_dtype_to_hal_element_type(np.dtype("bfloat16")),
+            rt.HalElementType.BFLOAT_16,
+        )
+        self.assertEqual(
+            dtypes.map_dtype_to_hal_element_type(np.dtype("float8_e4m3fn")),
+            rt.HalElementType.FLOAT_8_E4M3_FN,
+        )
+        self.assertEqual(
+            dtypes.map_dtype_to_hal_element_type(np.dtype("float8_e4m3fnuz")),
+            rt.HalElementType.FLOAT_8_E4M3_FNUZ,
+        )
+        self.assertEqual(
+            dtypes.map_dtype_to_hal_element_type(np.dtype("float8_e5m2")),
+            rt.HalElementType.FLOAT_8_E5M2,
+        )
+        self.assertEqual(
+            dtypes.map_dtype_to_hal_element_type(np.dtype("float8_e5m2fnuz")),
+            rt.HalElementType.FLOAT_8_E5M2_FNUZ,
+        )
+        self.assertEqual(
+            dtypes.map_dtype_to_hal_element_type(np.dtype("float8_e8m0fnu")),
+            rt.HalElementType.FLOAT_8_E8M0_FNU,
+        )
+
+    def testMapMlDtypeToHalElementTypeFromScalarType(self):
+        self.assertEqual(
+            dtypes.map_dtype_to_hal_element_type(ml_dtypes.bfloat16),
+            rt.HalElementType.BFLOAT_16,
+        )
+        self.assertEqual(
+            dtypes.map_dtype_to_hal_element_type(ml_dtypes.float8_e4m3fn),
+            rt.HalElementType.FLOAT_8_E4M3_FN,
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/runtime/bindings/python/tests/function_test.py b/runtime/bindings/python/tests/function_test.py
index 004c2dd..6010d51 100644
--- a/runtime/bindings/python/tests/function_test.py
+++ b/runtime/bindings/python/tests/function_test.py
@@ -56,6 +56,18 @@
         self.assertEqual("[<VmVariantList(2): [1, 2]>]", vm_context.mock_arg_reprs)
         self.assertEqual((3, 4), result)
 
+    def testNoReflectionBfloat16Ndarray(self):
+        def invoke(arg_list, ret_list):
+            ret_list.push_int(3)
+
+        vm_context = MockVmContext(invoke)
+        vm_function = MockVmFunction(reflection={})
+        invoker = FunctionInvoker(vm_context, self.device, vm_function)
+        arg = np.arange(4, dtype=np.dtype("bfloat16")).reshape(2, 2)
+        result = invoker(arg)
+        self.assertEqual(3, result)
+        self.assertIn("HalBufferView", vm_context.mock_arg_reprs)
+
     def testKeywordArgs(self):
         def invoke(arg_list, ret_list):
             ret_list.push_int(3)