diff --git a/backends/vulkan/test/op_tests/cases.py b/backends/vulkan/test/op_tests/cases.py index f2276b0247c..bc659a158e9 100644 --- a/backends/vulkan/test/op_tests/cases.py +++ b/backends/vulkan/test/op_tests/cases.py @@ -8,7 +8,7 @@ from collections import namedtuple from typing import Callable -from executorch.backends.vulkan.test.op_tests.utils.codegen import VkTestSuite +from executorch.backends.vulkan.test.op_tests.utils.test_suite import VkTestSuite # Prime numbers dim sizes for testing diff --git a/backends/vulkan/test/op_tests/generate_op_tests.py b/backends/vulkan/test/op_tests/generate_op_correctness_tests.py similarity index 77% rename from backends/vulkan/test/op_tests/generate_op_tests.py rename to backends/vulkan/test/op_tests/generate_op_correctness_tests.py index 71047ac6f49..ca7d29de1bf 100644 --- a/backends/vulkan/test/op_tests/generate_op_tests.py +++ b/backends/vulkan/test/op_tests/generate_op_correctness_tests.py @@ -10,12 +10,14 @@ from typing import Dict from executorch.backends.vulkan.test.op_tests.cases import test_suites +from executorch.backends.vulkan.test.op_tests.utils.gen_computegraph import ( + ComputeGraphGen, +) -from executorch.backends.vulkan.test.op_tests.utils.codegen import VkCppTestFileGen -from executorch.backends.vulkan.test.op_tests.utils.codegen_base import ( - TestSuite, - TestSuiteGen, +from executorch.backends.vulkan.test.op_tests.utils.gen_correctness_vk import ( + VkCorrectnessTestFileGen, ) +from executorch.backends.vulkan.test.op_tests.utils.test_suite import TestSuite from torchgen import local from torchgen.gen import parse_native_yaml, ParsedYaml @@ -37,7 +39,7 @@ def construct_f_map(parsed_yaml: ParsedYaml) -> Dict[str, NativeFunction]: def process_test_suites( - cpp_generator: VkCppTestFileGen, + cpp_generator: VkCorrectnessTestFileGen, f_map: Dict[str, NativeFunction], test_suites: Dict[str, TestSuite], ) -> None: @@ -53,12 +55,12 @@ def generate_cpp( native_functions_yaml_path: str, tags_path: str, output_dir: str ) -> None: output_file = os.path.join(output_dir, "op_tests.cpp") - cpp_generator = VkCppTestFileGen(output_file) + cpp_generator = VkCorrectnessTestFileGen(output_file) parsed_yaml = parse_native_yaml(native_functions_yaml_path, tags_path) f_map = construct_f_map(parsed_yaml) - TestSuiteGen.backend_key = parsed_yaml.backend_indices[DispatchKey.CPU] + ComputeGraphGen.backend_key = parsed_yaml.backend_indices[DispatchKey.CPU] process_test_suites(cpp_generator, f_map, test_suites) @@ -67,16 +69,14 @@ def generate_cpp( if __name__ == "__main__": - parser = argparse.ArgumentParser( - description="Generate a simple Hello World C++ program." - ) + parser = argparse.ArgumentParser() parser.add_argument( "--aten-yaml-path", help="path to native_functions.yaml file.", ) parser.add_argument( "--tags-path", - help="Path to tags.yaml. Required by yaml parsing in codegen system.", + help="Path to tags.yaml. Required by yaml parsing in gen_correctness_vk system.", ) parser.add_argument("-o", "--output", help="Output directory", required=True) args = parser.parse_args() diff --git a/backends/vulkan/test/op_tests/targets.bzl b/backends/vulkan/test/op_tests/targets.bzl index 0cffb5d80be..8d7d80e6743 100644 --- a/backends/vulkan/test/op_tests/targets.bzl +++ b/backends/vulkan/test/op_tests/targets.bzl @@ -8,9 +8,9 @@ def define_common_targets(is_fbcode = False): return runtime.python_library( - name = "generate_op_tests_lib", + name = "generate_op_correctness_tests_lib", srcs = native.glob(["utils/*.py"]) + [ - "generate_op_tests.py", + "generate_op_correctness_tests.py", "cases.py", ], base_module = "executorch.backends.vulkan.test.op_tests", @@ -21,23 +21,23 @@ def define_common_targets(is_fbcode = False): ) runtime.python_binary( - name = "generate_op_tests", - main_module = "executorch.backends.vulkan.test.op_tests.generate_op_tests", + name = "generate_op_correctness_tests", + main_module = "executorch.backends.vulkan.test.op_tests.generate_op_correctness_tests", deps = [ - ":generate_op_tests_lib", + ":generate_op_correctness_tests_lib", ], ) aten_src_path = runtime.external_dep_location("aten-src-path") genrule_cmd = [ - "$(exe :generate_op_tests)", + "$(exe :generate_op_correctness_tests)", "--tags-path $(location {})/aten/src/ATen/native/tags.yaml".format(aten_src_path), "--aten-yaml-path $(location {})/aten/src/ATen/native/native_functions.yaml".format(aten_src_path), "-o $OUT", ] runtime.genrule( - name = "generated_op_tests_cpp", + name = "generated_op_correctness_tests_cpp", outs = { "op_tests.cpp": ["op_tests.cpp"], }, @@ -66,7 +66,7 @@ def define_common_targets(is_fbcode = False): runtime.cxx_binary( name = "compute_graph_op_tests_bin", srcs = [ - ":generated_op_tests_cpp[op_tests.cpp]", + ":generated_op_correctness_tests_cpp[op_tests.cpp]", ], define_static_target = False, deps = [ @@ -79,7 +79,7 @@ def define_common_targets(is_fbcode = False): runtime.cxx_test( name = "compute_graph_op_tests", srcs = [ - ":generated_op_tests_cpp[op_tests.cpp]", + ":generated_op_correctness_tests_cpp[op_tests.cpp]", ], contacts = ["oncall+ai_infra_mobile_platform@xmail.facebook.com"], fbandroid_additional_loaded_sonames = [ diff --git a/backends/vulkan/test/op_tests/utils/aten_types.py b/backends/vulkan/test/op_tests/utils/aten_types.py new file mode 100644 index 00000000000..186f5afb78b --- /dev/null +++ b/backends/vulkan/test/op_tests/utils/aten_types.py @@ -0,0 +1,30 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +#################### +## ATen C++ Types ## +#################### + +AT_INT_ARRAY_REF = "at::IntArrayRef" +AT_SCALAR = "at::Scalar" +AT_TENSOR = "at::Tensor" +AT_TENSOR_LIST = "at::TensorList" +BOOL = "bool" +DOUBLE = "double" +INT = "int64_t" +OPT_AT_DOUBLE_ARRAY_REF = "::std::optional>" +OPT_AT_INT_ARRAY_REF = "at::OptionalIntArrayRef" +OPT_AT_TENSOR = "::std::optional" +OPT_BOOL = "::std::optional" +OPT_INT64 = "::std::optional" +OPT_DEVICE = "::std::optional" +OPT_LAYOUT = "::std::optional" +OPT_MEMORY_FORMAT = "::std::optional" +OPT_SCALAR_TYPE = "::std::optional" +STRING = "c10::string_view" +TWO_TENSOR_TUPLE = "::std::tuple" +THREE_TENSOR_TUPLE = "::std::tuple" +TENSOR_VECTOR = "::std::vector" diff --git a/backends/vulkan/test/op_tests/utils/codegen.py b/backends/vulkan/test/op_tests/utils/gen_computegraph.py similarity index 81% rename from backends/vulkan/test/op_tests/utils/codegen.py rename to backends/vulkan/test/op_tests/utils/gen_computegraph.py index 0bccf64458c..c0583bbec4d 100644 --- a/backends/vulkan/test/op_tests/utils/codegen.py +++ b/backends/vulkan/test/op_tests/utils/gen_computegraph.py @@ -6,15 +6,14 @@ import re from dataclasses import dataclass -from typing import Any, List, Optional, Union +from typing import List, Optional, Union -from executorch.backends.vulkan.test.op_tests.utils.codegen_base import ( +from executorch.backends.vulkan.test.op_tests.utils.aten_types import ( AT_INT_ARRAY_REF, AT_SCALAR, AT_TENSOR, AT_TENSOR_LIST, BOOL, - CppTestFileGen, DOUBLE, INT, OPT_AT_DOUBLE_ARRAY_REF, @@ -28,37 +27,20 @@ OPT_SCALAR_TYPE, STRING, TENSOR_VECTOR, - TestSuite, - TestSuiteGen, THREE_TENSOR_TUPLE, TWO_TENSOR_TUPLE, ) +from executorch.backends.vulkan.test.op_tests.utils.test_suite import TestSuite from torchgen.api import cpp from torchgen.api.types import CppSignatureGroup - from torchgen.gen import generate_static_dispatch_backend_call, translate_args - from torchgen.gen_aoti_c_shim import gen_static_dispatch_backend_call_signature from torchgen.model import NativeFunction, Variant -################################## -## Custom Test Suite Definition ## -################################## - - -@dataclass -class VkTestSuite(TestSuite): - def __init__(self, input_cases: List[Any]): - super().__init__(input_cases) - self.storage_types: List[str] = ["utils::kTexture3D"] - self.layouts: List[str] = ["utils::kChannelsPacked"] - self.data_gen: str = "make_rand_tensor" - - -########################## -## Code Generator Class ## -########################## +################################### +## Compute Graph Code Generation ## +################################### @dataclass @@ -105,6 +87,8 @@ def vk_out(self): class ComputeGraphGen: + backend_key = None + def __init__(self, op_reg_name: str, f: NativeFunction, suite_def: TestSuite): self.op_reg_name = op_reg_name self.f = f @@ -230,7 +214,7 @@ def gen_decl(self, fn_name: str, ret_type: str = "void") -> str: def create_aten_fn_call(self) -> str: func_call = generate_static_dispatch_backend_call( - self.f_sig, self.f, TestSuiteGen.backend_key + self.f_sig, self.f, ComputeGraphGen.backend_key )[7:].replace("::cpu", "") return func_call @@ -611,147 +595,3 @@ def gen_op_check_fn(self) -> str: op_check_fn += "\n }" return op_check_fn - - -################################## -## Test Fixture Code Generation ## -################################## - -test_fixture_template = """ -class GeneratedOpsTest_{op_name} : public ::testing::TestWithParam< ::std::tuple> {{ - protected: - ComputeGraph* graph; - at::ScalarType test_dtype = at::kFloat; - float rtol = {rtol}; - float atol = {atol}; - - void SetUp() override {{ - GraphConfig config; - utils::StorageType default_storage_type; - utils::GPUMemoryLayout default_memory_layout; - std::tie(test_dtype, default_storage_type, default_memory_layout) = GetParam(); - config.set_storage_type_override(default_storage_type); - config.set_memory_layout_override(default_memory_layout); - graph = new ComputeGraph(config); - - if (test_dtype == at::kHalf) {{ - rtol = 1e-2; - atol = 1e-2; - }} - }} - - void TearDown() override {{ - delete graph; - graph = nullptr; - }} - - {check_fn} -}}; -""" - - -class VkTestSuiteGen(TestSuiteGen): - def __init__(self, op_reg_name: str, f: NativeFunction, inputs: VkTestSuite): - super().__init__(f, inputs) - self.op_reg_name = op_reg_name - self.generator = ComputeGraphGen(self.op_reg_name, self.f, self.suite_def) - - def generate_fixture_cpp(self) -> str: - check_fn = "" - if not self.suite_def.requires_prepack: - check_fn = self.generator.gen_op_check_fn() - - prepacked_check_fn = "" - if self.suite_def.supports_prepack(): - self.generator.should_prepack = True - prepacked_check_fn = self.generator.gen_op_check_fn() - check_fn += "\n\n " - check_fn += prepacked_check_fn - - return test_fixture_template.format( - op_name=self.op_name, - check_fn=check_fn, - rtol=self.suite_def.rtol, - atol=self.suite_def.atol, - ) - - def gen_parameterization(self) -> str: - dtypes = self.suite_def.dtypes - storage_types = self.suite_def.storage_types - layouts = self.suite_def.layouts - - return f""" -INSTANTIATE_TEST_SUITE_P( - Combos_{self.op_name}, - GeneratedOpsTest_{self.op_name}, - ::testing::Combine( - ::testing::Values({', '.join(dtypes)}), - ::testing::Values({', '.join(storage_types)}), - ::testing::Values({', '.join(layouts)}))); - """ - - -############################## -## Test File Code Generation ## -############################### - -preamble_str = """ -#include -#include -#include - -#include - -using namespace vkcompute; -using TensorOptions = at::TensorOptions; - -vkapi::ScalarType from_at_scalartype(c10::ScalarType at_scalartype) { - switch (at_scalartype) { - case c10::kFloat: - return vkapi::kFloat; - case c10::kHalf: - return vkapi::kHalf; - case c10::kInt: - return vkapi::kInt; - case c10::kLong: - return vkapi::kInt; - case c10::kChar: - return vkapi::kChar; - default: - VK_THROW("Unsupported at::ScalarType!"); - } -} - -#ifdef USE_VULKAN_FP16_INFERENCE -bool check_close(at::Tensor& t1, at::Tensor& t2, float rtol=1e-2, float atol=1e-2) { -#else -bool check_close(at::Tensor& t1, at::Tensor& t2, float rtol=1e-5, float atol=1e-5) { -#endif - // Skip checking index tensors - if (t1.scalar_type() == at::kLong || t2.scalar_type() == at::kLong) { - return true; - } - bool is_close = at::allclose(t1, t2, rtol, atol); - if (!is_close && t1.numel() < 500) { - std::cout << "reference: " << std::endl; - print(t1, 150); - std::cout << std::endl; - std::cout << "vulkan: " << std::endl; - print(t2, 150); - std::cout << std::endl; - } - return is_close; -} -""" - - -class VkCppTestFileGen(CppTestFileGen): - def __init__(self, out_path: str): - super().__init__(out_path) - - def generate_preamble(self) -> str: - return preamble_str - - def add_suite(self, op_reg_name: str, f: NativeFunction, all_input_cases) -> None: - suites_gen = VkTestSuiteGen(op_reg_name, f, all_input_cases) - self.suites_gens.append(suites_gen) diff --git a/backends/vulkan/test/op_tests/utils/codegen_base.py b/backends/vulkan/test/op_tests/utils/gen_correctness_base.py similarity index 87% rename from backends/vulkan/test/op_tests/utils/codegen_base.py rename to backends/vulkan/test/op_tests/utils/gen_correctness_base.py index 5b3ca0908cf..f3e818c95a8 100644 --- a/backends/vulkan/test/op_tests/utils/codegen_base.py +++ b/backends/vulkan/test/op_tests/utils/gen_correctness_base.py @@ -7,60 +7,31 @@ import re from typing import Any, List +from executorch.backends.vulkan.test.op_tests.utils.aten_types import ( + AT_INT_ARRAY_REF, + AT_SCALAR, + AT_TENSOR, + AT_TENSOR_LIST, + BOOL, + DOUBLE, + INT, + OPT_AT_DOUBLE_ARRAY_REF, + OPT_AT_INT_ARRAY_REF, + OPT_AT_TENSOR, + OPT_BOOL, + OPT_DEVICE, + OPT_INT64, + OPT_LAYOUT, + OPT_MEMORY_FORMAT, + OPT_SCALAR_TYPE, + STRING, +) +from executorch.backends.vulkan.test.op_tests.utils.test_suite import TestSuite + from torchgen.api import cpp from torchgen.api.types import CppSignatureGroup from torchgen.model import Argument, NativeFunction -######################## -## ATen code patterns ## -######################## - -AT_INT_ARRAY_REF = "at::IntArrayRef" -AT_SCALAR = "at::Scalar" -AT_TENSOR = "at::Tensor" -AT_TENSOR_LIST = "at::TensorList" -BOOL = "bool" -DOUBLE = "double" -INT = "int64_t" -OPT_AT_DOUBLE_ARRAY_REF = "::std::optional>" -OPT_AT_INT_ARRAY_REF = "at::OptionalIntArrayRef" -OPT_AT_TENSOR = "::std::optional" -OPT_BOOL = "::std::optional" -OPT_INT64 = "::std::optional" -OPT_DEVICE = "::std::optional" -OPT_LAYOUT = "::std::optional" -OPT_MEMORY_FORMAT = "::std::optional" -OPT_SCALAR_TYPE = "::std::optional" -STRING = "c10::string_view" -TWO_TENSOR_TUPLE = "::std::tuple" -THREE_TENSOR_TUPLE = "::std::tuple" -TENSOR_VECTOR = "::std::vector" - -########################### -## Test Suite definition ## -########################### - - -class TestSuite: - def __init__(self, input_cases: List[Any]): - self.input_cases: List[Any] = input_cases - self.prepacked_args: List[str] = [] - self.requires_prepack: bool = False - self.dtypes: List[str] = ["at::kFloat", "at::kHalf"] - - self.data_gen: str = "make_rand_tensor" - self.data_range = (0, 1) - - self.arg_dtype = {} - self.arg_data_range = {} - - self.atol: str = "1e-5" - self.rtol: str = "1e-5" - - def supports_prepack(self): - return len(self.prepacked_args) > 0 - - ########################## ## Test Suite Generation ## ########################## @@ -103,9 +74,7 @@ def get_or_return_default(arg: Argument, inputs: List[Any], i: int): return arg.default -class TestSuiteGen: - backend_key = None - +class CorrectnessTestGen: def __init__(self, f: NativeFunction, test_suite: TestSuite): self.f = f self.suite_def = test_suite @@ -377,7 +346,7 @@ def generate_suite_cpp(self) -> str: """ -class CppTestFileGen: +class CorrectnessTestFileGen: def __init__(self, out_path): self.out_path = out_path self.suites_gens = [] @@ -395,5 +364,5 @@ def generate_test_suites_cpp(self) -> str: return "\n".join([h.generate_suite_cpp() for h in self.suites_gens]) def add_suite(self, op_reg_name: str, f: NativeFunction, all_input_cases) -> None: - suites_gen = TestSuiteGen(f, all_input_cases) + suites_gen = CorrectnessTestGen(f, all_input_cases) self.suites_gens.append(suites_gen) diff --git a/backends/vulkan/test/op_tests/utils/gen_correctness_vk.py b/backends/vulkan/test/op_tests/utils/gen_correctness_vk.py new file mode 100644 index 00000000000..6c165a777db --- /dev/null +++ b/backends/vulkan/test/op_tests/utils/gen_correctness_vk.py @@ -0,0 +1,159 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from executorch.backends.vulkan.test.op_tests.utils.gen_computegraph import ( + ComputeGraphGen, +) +from executorch.backends.vulkan.test.op_tests.utils.gen_correctness_base import ( + CorrectnessTestFileGen, + CorrectnessTestGen, +) +from executorch.backends.vulkan.test.op_tests.utils.test_suite import VkTestSuite + +from torchgen.model import NativeFunction + +################################## +## Test Fixture Code Generation ## +################################## + +test_fixture_template = """ +class GeneratedOpsTest_{op_name} : public ::testing::TestWithParam< ::std::tuple> {{ + protected: + ComputeGraph* graph; + at::ScalarType test_dtype = at::kFloat; + float rtol = {rtol}; + float atol = {atol}; + + void SetUp() override {{ + GraphConfig config; + utils::StorageType default_storage_type; + utils::GPUMemoryLayout default_memory_layout; + std::tie(test_dtype, default_storage_type, default_memory_layout) = GetParam(); + config.set_storage_type_override(default_storage_type); + config.set_memory_layout_override(default_memory_layout); + graph = new ComputeGraph(config); + + if (test_dtype == at::kHalf) {{ + rtol = 1e-2; + atol = 1e-2; + }} + }} + + void TearDown() override {{ + delete graph; + graph = nullptr; + }} + + {check_fn} +}}; +""" + + +class VkCorrectnessTestGen(CorrectnessTestGen): + def __init__(self, op_reg_name: str, f: NativeFunction, inputs: VkTestSuite): + super().__init__(f, inputs) + self.op_reg_name = op_reg_name + self.generator = ComputeGraphGen(self.op_reg_name, self.f, self.suite_def) + + def generate_fixture_cpp(self) -> str: + check_fn = "" + if not self.suite_def.requires_prepack: + check_fn = self.generator.gen_op_check_fn() + + prepacked_check_fn = "" + if self.suite_def.supports_prepack(): + self.generator.should_prepack = True + prepacked_check_fn = self.generator.gen_op_check_fn() + check_fn += "\n\n " + check_fn += prepacked_check_fn + + return test_fixture_template.format( + op_name=self.op_name, + check_fn=check_fn, + rtol=self.suite_def.rtol, + atol=self.suite_def.atol, + ) + + def gen_parameterization(self) -> str: + dtypes = self.suite_def.dtypes + storage_types = self.suite_def.storage_types + layouts = self.suite_def.layouts + + return f""" +INSTANTIATE_TEST_SUITE_P( + Combos_{self.op_name}, + GeneratedOpsTest_{self.op_name}, + ::testing::Combine( + ::testing::Values({', '.join(dtypes)}), + ::testing::Values({', '.join(storage_types)}), + ::testing::Values({', '.join(layouts)}))); + """ + + +############################## +## Test File Code Generation ## +############################### + +preamble_str = """ +#include +#include +#include + +#include + +using namespace vkcompute; +using TensorOptions = at::TensorOptions; + +vkapi::ScalarType from_at_scalartype(c10::ScalarType at_scalartype) { + switch (at_scalartype) { + case c10::kFloat: + return vkapi::kFloat; + case c10::kHalf: + return vkapi::kHalf; + case c10::kInt: + return vkapi::kInt; + case c10::kLong: + return vkapi::kInt; + case c10::kChar: + return vkapi::kChar; + default: + VK_THROW("Unsupported at::ScalarType!"); + } +} + +#ifdef USE_VULKAN_FP16_INFERENCE +bool check_close(at::Tensor& t1, at::Tensor& t2, float rtol=1e-2, float atol=1e-2) { +#else +bool check_close(at::Tensor& t1, at::Tensor& t2, float rtol=1e-5, float atol=1e-5) { +#endif + // Skip checking index tensors + if (t1.scalar_type() == at::kLong || t2.scalar_type() == at::kLong) { + return true; + } + bool is_close = at::allclose(t1, t2, rtol, atol); + if (!is_close && t1.numel() < 500) { + std::cout << "reference: " << std::endl; + print(t1, 150); + std::cout << std::endl; + std::cout << "vulkan: " << std::endl; + print(t2, 150); + std::cout << std::endl; + } + return is_close; +} +""" + + +class VkCorrectnessTestFileGen(CorrectnessTestFileGen): + def __init__(self, out_path: str): + super().__init__(out_path) + + def generate_preamble(self) -> str: + return preamble_str + + def add_suite(self, op_reg_name: str, f: NativeFunction, all_input_cases) -> None: + suites_gen = VkCorrectnessTestGen(op_reg_name, f, all_input_cases) + self.suites_gens.append(suites_gen) diff --git a/backends/vulkan/test/op_tests/utils/test_suite.py b/backends/vulkan/test/op_tests/utils/test_suite.py new file mode 100644 index 00000000000..9ac87802623 --- /dev/null +++ b/backends/vulkan/test/op_tests/utils/test_suite.py @@ -0,0 +1,46 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from dataclasses import dataclass +from typing import Any, List + +################################### +## Generic Test Suite definition ## +################################### + + +class TestSuite: + def __init__(self, input_cases: List[Any]): + self.input_cases: List[Any] = input_cases + self.prepacked_args: List[str] = [] + self.requires_prepack: bool = False + self.dtypes: List[str] = ["at::kFloat", "at::kHalf"] + + self.data_gen: str = "make_rand_tensor" + self.data_range = (0, 1) + + self.arg_dtype = {} + self.arg_data_range = {} + + self.atol: str = "1e-5" + self.rtol: str = "1e-5" + + def supports_prepack(self): + return len(self.prepacked_args) > 0 + + +################################## +## Vulkan Test Suite Definition ## +################################## + + +@dataclass +class VkTestSuite(TestSuite): + def __init__(self, input_cases: List[Any]): + super().__init__(input_cases) + self.storage_types: List[str] = ["utils::kTexture3D"] + self.layouts: List[str] = ["utils::kChannelsPacked"] + self.data_gen: str = "make_rand_tensor"