From 498677bc883e0050c2b684237518688ae3e3a278 Mon Sep 17 00:00:00 2001 From: Joey Carter Date: Fri, 24 Apr 2026 16:50:42 -0400 Subject: [PATCH 01/75] [WIP] Add conversion pattern for qecl.qec ops --- .../transforms/qecp/convert_qecl_to_qecp.py | 192 +++++++++++++++++- .../transforms/qecp/tanner_graph_lib.py | 47 +++++ .../transforms/qecp/test_tanner_graph_lib.py | 62 ++++++ .../qecp/test_xdsl_convert_qecl_to_qecp.py | 13 ++ 4 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py create mode 100644 frontend/test/pytest/python_interface/transforms/qecp/test_tanner_graph_lib.py diff --git a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py index eeac0655cc..aa9e1678f8 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py +++ b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py @@ -21,12 +21,14 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass, field from enum import StrEnum +from typing import cast import numpy as np from xdsl import builder from xdsl.context import Context -from xdsl.dialects import builtin, func -from xdsl.ir import Block, Region +from xdsl.dialects import arith, builtin, func +from xdsl.dialects.builtin import IndexType, TensorType, i32 +from xdsl.ir import Block, BlockArgument, OpResult, Region from xdsl.passes import ModulePass from xdsl.pattern_rewriter import ( GreedyRewritePatternApplier, @@ -40,6 +42,7 @@ from catalyst.python_interface.dialects import qecl, qecp from catalyst.python_interface.pass_api.compiler_transform import compiler_transform +from catalyst.python_interface.transforms.qecp.tanner_graph_lib import dense_tanner_graph_to_csc from catalyst.utils.exceptions import CompileError from .convert_qecl_noise_to_qec_noise import ConvertQECLNoiseOpToQECPNoisePass @@ -112,20 +115,56 @@ def match_and_rewrite(self, op: qecl.EncodeOp, rewriter: PatternRewriter): "for init_state 'zero'" ) - if (k := op.in_codeblock.type.k.value.data) != self.qec_code.k: + in_codeblock = cast( + qecl.LogicalCodeBlockSSAValue | qecp.PhysicalCodeBlockSSAValue, op.in_codeblock + ) + + if (k := in_codeblock.type.k.value.data) != self.qec_code.k: raise CompileError( f"Circuit expressed in the qecl dialect with k={k} is not compatible with " f"lowering to a code with k={self.qec_code.k}" ) callee = builtin.SymbolRefAttr(self.encode_subroutine.sym_name) - arguments = (op.in_codeblock,) + arguments = (in_codeblock,) return_types = self.encode_subroutine.function_type.outputs.data callOp = func.CallOp(callee, arguments, return_types) rewriter.replace_op(op, callOp) +# MARK: QEC Cycle Op Pattern + + +@dataclass +class QecCycleOpConversion(RewritePattern): + """Converts qecl.encode [zero] to the equivalent subroutine of qecp gates""" + + qec_code: QecCode + qec_cycle_subroutine: func.FuncOp + + @op_type_rewrite_pattern + def match_and_rewrite(self, op: qecl.QecCycleOp, rewriter: PatternRewriter): + """Rewrite pattern for `qecl.encode [zero]` op""" + + in_codeblock = cast( + qecl.LogicalCodeBlockSSAValue | qecp.PhysicalCodeBlockSSAValue, op.in_codeblock + ) + + if (k := in_codeblock.type.k.value.data) != self.qec_code.k: + raise CompileError( + f"Circuit expressed in the qecl dialect with k={k} is not compatible with " + f"lowering to a code with k={self.qec_code.k}" + ) + + callee = builtin.SymbolRefAttr(self.qec_cycle_subroutine.sym_name) + arguments = (in_codeblock,) + return_types = self.qec_cycle_subroutine.function_type.outputs.data + callOp = func.CallOp(callee, arguments, return_types) + + rewriter.replace_op(op, callOp) + + # MARK: Conversion Pass @@ -169,9 +208,86 @@ def apply(self, ctx: Context, op: builtin.ModuleOp) -> None: n=self.qec_code.n, number_errors=self.number_errors ).apply(ctx, op) + module_block = op.regions[0].blocks.first + assert module_block is not None, "Module has no block" + + # Insert Tanner graph ops + x_tanner_row_idx_array, x_tanner_col_ptr_array = dense_tanner_graph_to_csc( + self.qec_code.x_tanner + ) + x_tanner_row_idx_const_op = arith.ConstantOp( + builtin.DenseIntOrFPElementsAttr.from_list( + type=builtin.TensorType(i32, shape=x_tanner_row_idx_array.shape), + data=x_tanner_row_idx_array.tolist(), + ) + ) + x_tanner_col_ptr_const_op = arith.ConstantOp( + builtin.DenseIntOrFPElementsAttr.from_list( + type=builtin.TensorType(i32, shape=x_tanner_col_ptr_array.shape), + data=x_tanner_col_ptr_array.tolist(), + ) + ) + assemble_x_tanner_op = qecp.AssembleTannerGraphOp( + row_idx=x_tanner_row_idx_const_op, + col_ptr=x_tanner_col_ptr_const_op, + tanner_graph_type=qecp.TannerGraphType( + x_tanner_row_idx_array.shape[0], x_tanner_col_ptr_array.shape[0], i32 + ), + ) + + z_tanner_row_idx_array, z_tanner_col_ptr_array = dense_tanner_graph_to_csc( + self.qec_code.z_tanner + ) + z_tanner_row_idx_const_op = arith.ConstantOp( + builtin.DenseIntOrFPElementsAttr.from_list( + type=builtin.TensorType(i32, shape=z_tanner_row_idx_array.shape), + data=z_tanner_row_idx_array.tolist(), + ) + ) + z_tanner_col_ptr_const_op = arith.ConstantOp( + builtin.DenseIntOrFPElementsAttr.from_list( + type=builtin.TensorType(i32, shape=z_tanner_col_ptr_array.shape), + data=z_tanner_col_ptr_array.tolist(), + ) + ) + assemble_z_tanner_op = qecp.AssembleTannerGraphOp( + row_idx=z_tanner_row_idx_const_op, + col_ptr=z_tanner_col_ptr_const_op, + tanner_graph_type=qecp.TannerGraphType( + z_tanner_row_idx_array.shape[0], z_tanner_col_ptr_array.shape[0], i32 + ), + ) + + ops_to_insert = ( + x_tanner_row_idx_const_op, + x_tanner_col_ptr_const_op, + assemble_x_tanner_op, + z_tanner_row_idx_const_op, + z_tanner_col_ptr_const_op, + assemble_z_tanner_op, + ) + + if module_block.first_op is None: + module_block.add_ops(ops_to_insert) + else: + module_block.insert_ops_before( + ( + x_tanner_row_idx_const_op, + x_tanner_col_ptr_const_op, + assemble_x_tanner_op, + z_tanner_row_idx_const_op, + z_tanner_col_ptr_const_op, + assemble_z_tanner_op, + ), + module_block.first_op, + ) + + # Insert subroutines that implement the QEC protocols encode_funcop = self.create_encode_subroutine() - assert op.regions[0].blocks.first is not None - op.regions[0].blocks.first.add_op(encode_funcop) + module_block.add_op(encode_funcop) + + qec_cycle_funcop = self.create_qec_cycle_subroutine() + module_block.add_op(qec_cycle_funcop) PatternRewriteWalker( GreedyRewritePatternApplier( @@ -179,6 +295,9 @@ def apply(self, ctx: Context, op: builtin.ModuleOp) -> None: CodeblockTypeConversion(qec_code=self.qec_code), HyperRegisterTypeConversion(qec_code=self.qec_code), EncodeOpConversion(qec_code=self.qec_code, encode_subroutine=encode_funcop), + QecCycleOpConversion( + qec_code=self.qec_code, qec_cycle_subroutine=qec_cycle_funcop + ), ] ) ).rewrite_module(op) @@ -231,6 +350,67 @@ def create_encode_subroutine(self) -> func.FuncOp: return funcOp + def create_qec_cycle_subroutine(self) -> func.FuncOp: + """FIXME + + Create a subroutine that takes in a codeblock, encodes it in the zero state for + the QEC code (based on the tanner graph), and returns the encoded codeblock. This + encoding procedure follows the example shown in arXiv: 0905.2794, Section VIII.A. + It does not include Z-corrections; this is because the encode op is followed directly + by a full cycle of error correction when lowering to the qecl dialect. + + The subroutine allocates auxiliary qubits for use in encoding based on the number of + rows in the X tanner graph, and deallocates them once encoding is complete. + + Note that this method does not insert the subroutine into the module op. Instead it returns + the built func.FuncOp object that can then be subsequently inserted where desired. + """ + + codeblock_type = qecp.PhysicalCodeblockType(self.qec_code.k, self.qec_code.n) + input_types = (codeblock_type,) + output_types = (codeblock_type,) + + block = Block(arg_types=input_types) + + with builder.ImplicitBuilder(block): + in_codeblock = cast(BlockArgument[qecp.PhysicalCodeblockType], block.args[0]) + + # allocate X-check auxiliary qubits + x_aux_allocate_ops = (qecp.AllocAuxQubitOp() for row in self.qec_code.x_tanner) + x_aux_qubits = [ + cast(OpResult[qecp.QecPhysicalQubitType], op.results[0]) + for op in x_aux_allocate_ops + ] + + # apply X-check gate+measurement pattern + x_measure_ops, x_out_codeblock = self.check_pattern( + x_aux_qubits, in_codeblock, check_type=CheckType.X + ) + + # deallocate the X-check auxiliary qubits + for x_meas_op in x_measure_ops: + qecp.DeallocAuxQubitOp(x_meas_op.out_qubit) + + # Decode X-check ESM + # TODO! + qecp.DecodeEsmCssOp( + tanner_graph=self.qec_code.x_tanner, + esm=..., + err_idx_type=TensorType(IndexType(), shape=(1,)), + ) + + # return the corrected codeblock + func.ReturnOp(x_out_codeblock) + + funcOp = func.FuncOp( + name=f"encode_zero_{self.qec_code.name}", + function_type=(input_types, output_types), + visibility="private", + region=Region([block]), + ) + + return funcOp + def check_pattern( self, in_aux_qbs: Iterable[qecp.QecPhysicalQubitSSAValue], diff --git a/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py b/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py new file mode 100644 index 0000000000..e8a4ae4f5a --- /dev/null +++ b/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py @@ -0,0 +1,47 @@ +# Copyright 2026 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""This module contains utilities for managing Tanner graphs in the qecl-to-qecp dialect-conversion +pass. +""" + +import numpy as np +import scipy.sparse + + +def dense_tanner_graph_to_csc( + matrix: np.ndarray[tuple[int, int]], +) -> tuple[np.ndarray[tuple[int]], np.ndarray[tuple[int]]]: + """Converts a dense parity-check matrix to CSC index arrays. + + Takes a dense 2D numpy array representing a Tanner graph, expressed as a parity-check matrix, + and returns the row indices and column pointers corresponding to the Compressed Sparse Column + (CSC) format of this matrix. + + Note that this function does not return the 'data' array of the CSC matrix (the array of + non-zero values), as these values are assumed to be all 1s. + + Args: + matrix (numpy.ndarray): A 2D numpy array representing the dense matrix, typically containing + 0s and 1s. + + Returns: + tuple: A tuple containing two elements: + - row_idx (numpy.ndarray): The row indices of the non-zero elements. + - col_ptr (numpy.ndarray): The column pointers, where indptr[i] indicates the start + index in 'indices' for the i-th column. + """ + matrix_as_csc = scipy.sparse.csc_matrix(matrix) + + return matrix_as_csc.indices, matrix_as_csc.indptr diff --git a/frontend/test/pytest/python_interface/transforms/qecp/test_tanner_graph_lib.py b/frontend/test/pytest/python_interface/transforms/qecp/test_tanner_graph_lib.py new file mode 100644 index 0000000000..d2ce0987ef --- /dev/null +++ b/frontend/test/pytest/python_interface/transforms/qecp/test_tanner_graph_lib.py @@ -0,0 +1,62 @@ +# Copyright 2026 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test suite for the catalyst.python_interface.transforms.qecp.tanner_graph_lib module.""" + +import numpy as np +import pytest + +from catalyst.python_interface.transforms.qecp.tanner_graph_lib import dense_tanner_graph_to_csc + + +class TestDenseTannerGraphToCsc: + """Unit tests for the `dense_tanner_graph_to_csc` function. + + We don't do thorough, comprehensive testing of this method because it is a wrapper around a + scipy function, and we assume the scipy devs have already done their due diligence. + """ + + @pytest.mark.parametrize( + "matrix, expected_row_idx, expected_col_ptr", + [ + ( + # A simple, contrived parity-check matrix + np.array( + [ + [0, 0, 0, 1, 0], + [0, 0, 0, 1, 1], + [0, 0, 0, 0, 1], + [1, 1, 0, 0, 0], + [0, 1, 1, 0, 0], + ] + ), + np.array([3, 3, 4, 4, 0, 1, 1, 2]), # expected row_idx + np.array([0, 1, 3, 4, 6, 8]), # expected col_ptr + ), + ( + # The Steane code parity-check matrix + np.array([[1, 1, 1, 1, 0, 0, 0], [0, 1, 1, 0, 1, 1, 0], [0, 0, 1, 1, 0, 1, 1]]), + np.array([0, 0, 1, 0, 1, 2, 0, 2, 1, 1, 2, 2]), # expected row_idx + np.array([0, 1, 3, 6, 8, 9, 11, 12]), # expected col_ptr + ), + ], + ) + def test_dense_tanner_graph_to_csc( + self, matrix: np.ndarray, expected_row_idx: np.ndarray, expected_col_ptr: np.ndarray + ): + """Test the `dense_tanner_graph_to_csc` function with simple parity-check matrix inputs.""" + row_idx, col_ptr = dense_tanner_graph_to_csc(matrix) + + assert np.array_equal(row_idx, expected_row_idx) + assert np.array_equal(col_ptr, expected_col_ptr) diff --git a/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py b/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py index efdc58a6b6..c9caf770da 100644 --- a/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py +++ b/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py @@ -141,6 +141,19 @@ def test_hyperreg_conversion_with_k_mismatch(self, run_filecheck): run_filecheck(program, pipeline) +class TestTannerGraphInsertion: + def test_tanner_graph_insertion_steane(self, run_filecheck): + program = """ + builtin.module @test_module { + func.func @test_program() { + return + } + } + """ + pipeline = (ConvertQecLogicalToQecPhysicalPass(qec_code=QecCode.get("Steane")),) + run_filecheck(program, pipeline) + + class TestLoweringEncode: """Test lowering the qecl.EncodeOp to a subroutine of qecp gates""" From e7ef5c64f03af66960551979cf86ed1c58cfbe49 Mon Sep 17 00:00:00 2001 From: Joey Carter Date: Mon, 27 Apr 2026 11:44:54 -0400 Subject: [PATCH 02/75] [WIP] Continue adding QEC cycle conversion pattern --- .../transforms/qecp/convert_qecl_to_qecp.py | 83 ++++++++++++------- .../qecp/test_xdsl_convert_qecl_to_qecp.py | 70 +++++++++++++--- 2 files changed, 110 insertions(+), 43 deletions(-) diff --git a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py index aa9e1678f8..49ff82ce85 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py +++ b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py @@ -26,8 +26,8 @@ import numpy as np from xdsl import builder from xdsl.context import Context -from xdsl.dialects import arith, builtin, func -from xdsl.dialects.builtin import IndexType, TensorType, i32 +from xdsl.dialects import arith, builtin, func, tensor +from xdsl.dialects.builtin import IndexType, TensorType, i1, i32 from xdsl.ir import Block, BlockArgument, OpResult, Region from xdsl.passes import ModulePass from xdsl.pattern_rewriter import ( @@ -211,7 +211,38 @@ def apply(self, ctx: Context, op: builtin.ModuleOp) -> None: module_block = op.regions[0].blocks.first assert module_block is not None, "Module has no block" - # Insert Tanner graph ops + tanner_x, tanner_z = self.insert_tanner_graph_ops_into_block(module_block) + + # Insert subroutines that implement the QEC protocols + encode_funcop = self.create_encode_subroutine() + module_block.add_op(encode_funcop) + + qec_cycle_funcop = self.create_qec_cycle_subroutine(tanner_x=tanner_x, tanner_z=tanner_z) + module_block.add_op(qec_cycle_funcop) + + PatternRewriteWalker( + GreedyRewritePatternApplier( + [ + CodeblockTypeConversion(qec_code=self.qec_code), + HyperRegisterTypeConversion(qec_code=self.qec_code), + EncodeOpConversion(qec_code=self.qec_code, encode_subroutine=encode_funcop), + QecCycleOpConversion( + qec_code=self.qec_code, qec_cycle_subroutine=qec_cycle_funcop + ), + ] + ) + ).rewrite_module(op) + + def insert_tanner_graph_ops_into_block( + self, block: Block + ) -> tuple[OpResult[qecp.TannerGraphType], OpResult[qecp.TannerGraphType]]: + """Insert Tanner graph operations into the given block. + + The operations are inserted at the beginning of the block. + + Returns the X and Z Tanner graph SSA values from the `qecp.assemble_tanner` ops (we assume + a CSS code here and therefore have separate X and Z Tanner graphs). + """ x_tanner_row_idx_array, x_tanner_col_ptr_array = dense_tanner_graph_to_csc( self.qec_code.x_tanner ) @@ -267,10 +298,10 @@ def apply(self, ctx: Context, op: builtin.ModuleOp) -> None: assemble_z_tanner_op, ) - if module_block.first_op is None: - module_block.add_ops(ops_to_insert) + if block.first_op is None: + block.add_ops(ops_to_insert) else: - module_block.insert_ops_before( + block.insert_ops_before( ( x_tanner_row_idx_const_op, x_tanner_col_ptr_const_op, @@ -279,28 +310,10 @@ def apply(self, ctx: Context, op: builtin.ModuleOp) -> None: z_tanner_col_ptr_const_op, assemble_z_tanner_op, ), - module_block.first_op, + block.first_op, ) - # Insert subroutines that implement the QEC protocols - encode_funcop = self.create_encode_subroutine() - module_block.add_op(encode_funcop) - - qec_cycle_funcop = self.create_qec_cycle_subroutine() - module_block.add_op(qec_cycle_funcop) - - PatternRewriteWalker( - GreedyRewritePatternApplier( - [ - CodeblockTypeConversion(qec_code=self.qec_code), - HyperRegisterTypeConversion(qec_code=self.qec_code), - EncodeOpConversion(qec_code=self.qec_code, encode_subroutine=encode_funcop), - QecCycleOpConversion( - qec_code=self.qec_code, qec_cycle_subroutine=qec_cycle_funcop - ), - ] - ) - ).rewrite_module(op) + return assemble_x_tanner_op.tanner_graph, assemble_z_tanner_op.tanner_graph def create_encode_subroutine(self) -> func.FuncOp: """Create a subroutine that takes in a codeblock, encodes it in the zero state for @@ -350,7 +363,9 @@ def create_encode_subroutine(self) -> func.FuncOp: return funcOp - def create_qec_cycle_subroutine(self) -> func.FuncOp: + def create_qec_cycle_subroutine( + self, tanner_x: qecp.TannerGraphSSAValue, tanner_z: qecp.TannerGraphSSAValue + ) -> func.FuncOp: """FIXME Create a subroutine that takes in a codeblock, encodes it in the zero state for @@ -391,11 +406,15 @@ def create_qec_cycle_subroutine(self) -> func.FuncOp: for x_meas_op in x_measure_ops: qecp.DeallocAuxQubitOp(x_meas_op.out_qubit) + pack_mres_tensor_op = tensor.FromElementsOp.build( + operands=([meas_op.mres for meas_op in x_measure_ops],), + result_types=(TensorType(i1, shape=(len(x_measure_ops),)),), + ) + # Decode X-check ESM - # TODO! qecp.DecodeEsmCssOp( - tanner_graph=self.qec_code.x_tanner, - esm=..., + tanner_graph=tanner_x, + esm=pack_mres_tensor_op.result, err_idx_type=TensorType(IndexType(), shape=(1,)), ) @@ -403,7 +422,7 @@ def create_qec_cycle_subroutine(self) -> func.FuncOp: func.ReturnOp(x_out_codeblock) funcOp = func.FuncOp( - name=f"encode_zero_{self.qec_code.name}", + name=f"qec_cycle_{self.qec_code.name}", function_type=(input_types, output_types), visibility="private", region=Region([block]), @@ -416,7 +435,7 @@ def check_pattern( in_aux_qbs: Iterable[qecp.QecPhysicalQubitSSAValue], in_codeblock: qecp.PhysicalCodeBlockSSAValue, check_type: CheckType, - ) -> tuple[Iterable[qecp.MeasureOp], qecp.PhysicalCodeBlockSSAValue]: + ) -> tuple[list[qecp.MeasureOp], qecp.PhysicalCodeBlockSSAValue]: """Contains the ops to perform a QEC check on the provided auxiliary qubits and codeblock. Intended to be called inside `builder.ImplicitBuilder` to add these operations to a block. diff --git a/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py b/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py index c9caf770da..23cb8265f3 100644 --- a/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py +++ b/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py @@ -141,17 +141,7 @@ def test_hyperreg_conversion_with_k_mismatch(self, run_filecheck): run_filecheck(program, pipeline) -class TestTannerGraphInsertion: - def test_tanner_graph_insertion_steane(self, run_filecheck): - program = """ - builtin.module @test_module { - func.func @test_program() { - return - } - } - """ - pipeline = (ConvertQecLogicalToQecPhysicalPass(qec_code=QecCode.get("Steane")),) - run_filecheck(program, pipeline) +# MARK: TestLoweringEncode class TestLoweringEncode: @@ -288,6 +278,64 @@ def test_multiple_encodes_with_Steane(self, run_filecheck): run_filecheck(program, pipeline) +# MARK: TestTannerGraphInsertion + + +class TestTannerGraphInsertion: + """Unit tests for the insertion of Tanner graph ops.""" + + def test_tanner_graph_insertion_steane(self, run_filecheck): + """Test that Tanner graph ops for the Steane code are correctly inserted at the beginning of + the module. + """ + + program = """ + // CHECK-LABEL: test_module + builtin.module @test_module { + // CHECK: [[row_idx_x:%.+]] = arith.constant dense<[0, 0, 1, 0, 1, 2, 0, 2, 1, 1, 2, 2]> : tensor<12xi32> + // CHECK: [[col_ptr_x:%.+]] = arith.constant dense<[0, 1, 3, 6, 8, 9, 11, 12]> : tensor<8xi32> + // CHECK: [[tanner_x:%.+]] = qecp.assemble_tanner [[row_idx_x]], [[col_ptr_x]] : tensor<12xi32>, tensor<8xi32> -> !qecp.tanner_graph<12, 8, i32> + // CHECK: [[row_idx_z:%.+]] = arith.constant dense<[0, 0, 1, 0, 1, 2, 0, 2, 1, 1, 2, 2]> : tensor<12xi32> + // CHECK: [[col_ptr_z:%.+]] = arith.constant dense<[0, 1, 3, 6, 8, 9, 11, 12]> : tensor<8xi32> + // CHECK: [[tanner_z:%.+]] = qecp.assemble_tanner [[row_idx_z]], [[col_ptr_z]] : tensor<12xi32>, tensor<8xi32> -> !qecp.tanner_graph<12, 8, i32> + // CHECK-LABEL: test_program + func.func @test_program() { + return + } + } + """ + pipeline = (ConvertQecLogicalToQecPhysicalPass(qec_code=QecCode.get("Steane")),) + run_filecheck(program, pipeline) + + +# MARK: TestQecCycleLowering + + +class TestQecCycleLowering: + """Unit tests for the `qecl.qec` conversion pattern of the convert-qecl-to-qecp pass.""" + + def test_single_qec_cycle_Steane(self, run_filecheck): + """TODO""" + program = """ + // CHECK-LABEL: test_module + builtin.module @test_module { + // CHECK: [[tanner_x:%.+]] = qecp.assemble_tanner {{.+}} -> !qecp.tanner_graph + // CHECK: [[tanner_z:%.+]] = qecp.assemble_tanner {{.+}} -> !qecp.tanner_graph + // CHECK-LABEL: test_program + func.func @test_program() { + // CHECK: [[cb0:%.+]] = "test.op"() : () -> !qecp.codeblock<1 x 7> + %0 = "test.op"() : () -> !qecl.codeblock<1> + + // CHECK: [[cb1:%.+]] = func.call @qec_cycle_Steane([[cb0]]) : (!qecp.codeblock<1 x 7>) -> !qecp.codeblock<1 x 7> + %1 = qecl.qec %0 : !qecl.codeblock<1> + return + } + } + """ + pipeline = (ConvertQecLogicalToQecPhysicalPass(qec_code=QecCode.get("Steane")),) + run_filecheck(program, pipeline) + + # We can remove this xfail and warning filter once `convert_qecl_to_qecp_pass` is complete @pytest.mark.xfail(reason="The `convert_qecl_to_qecp_pass` is incomplete") @pytest.mark.filterwarnings("ignore:Unable to remove cast UnrealizedConversionCastOp") From f937de91dd88dfa25775212aaf95a2500e78b634 Mon Sep 17 00:00:00 2001 From: Joey Carter Date: Mon, 27 Apr 2026 11:59:13 -0400 Subject: [PATCH 03/75] Insert Z-correction ops --- .../transforms/qecp/convert_qecl_to_qecp.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py index 9352a3c801..aa5a82e2a5 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py +++ b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py @@ -462,14 +462,25 @@ def create_qec_cycle_subroutine( ) # Decode X-check ESM - qecp.DecodeEsmCssOp( + decode_esm_op = qecp.DecodeEsmCssOp( tanner_graph=tanner_x, esm=pack_mres_tensor_op.result, err_idx_type=TensorType(IndexType(), shape=(1,)), ) + # Apply correction + err_idx = cast(OpResult[IndexType], decode_esm_op.err_idx) + extract_err_qubit_op = qecp.ExtractQubitOp(codeblock=x_out_codeblock, idx=err_idx) + err_qubit = extract_err_qubit_op.qubit + qecp.PauliZOp(in_qubit=err_qubit) + insert_err_qubit_op = qecp.InsertQubitOp( + in_codeblock=x_out_codeblock, idx=err_idx, qubit=err_qubit + ) + + out_codeblock = insert_err_qubit_op.out_codeblock + # return the corrected codeblock - func.ReturnOp(x_out_codeblock) + func.ReturnOp(out_codeblock) funcOp = func.FuncOp( name=f"qec_cycle_{self.qec_code.name}", From 76109b92a231457fd6a98fdd35723eb1ee7f9cf3 Mon Sep 17 00:00:00 2001 From: Joey Carter Date: Mon, 27 Apr 2026 13:15:37 -0400 Subject: [PATCH 04/75] [WIP] Apply both X and Z corrections --- .../transforms/qecp/convert_qecl_to_qecp.py | 100 +++++++++++------- 1 file changed, 61 insertions(+), 39 deletions(-) diff --git a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py index aa5a82e2a5..a24d024377 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py +++ b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py @@ -440,47 +440,14 @@ def create_qec_cycle_subroutine( with builder.ImplicitBuilder(block): in_codeblock = cast(BlockArgument[qecp.PhysicalCodeblockType], block.args[0]) - # allocate X-check auxiliary qubits - x_aux_allocate_ops = (qecp.AllocAuxQubitOp() for row in self.qec_code.x_tanner) - x_aux_qubits = [ - cast(OpResult[qecp.QecPhysicalQubitType], op.results[0]) - for op in x_aux_allocate_ops - ] + # Apply X checks pattern for Z corrections + x_out_codeblock = self._qec_cycle_css_pattern(in_codeblock, CheckType.X, tanner_x) - # apply X-check gate+measurement pattern - x_measure_ops, x_out_codeblock = self.check_pattern( - x_aux_qubits, in_codeblock, check_type=CheckType.X - ) - - # deallocate the X-check auxiliary qubits - for x_meas_op in x_measure_ops: - qecp.DeallocAuxQubitOp(x_meas_op.out_qubit) - - pack_mres_tensor_op = tensor.FromElementsOp.build( - operands=([meas_op.mres for meas_op in x_measure_ops],), - result_types=(TensorType(i1, shape=(len(x_measure_ops),)),), - ) - - # Decode X-check ESM - decode_esm_op = qecp.DecodeEsmCssOp( - tanner_graph=tanner_x, - esm=pack_mres_tensor_op.result, - err_idx_type=TensorType(IndexType(), shape=(1,)), - ) - - # Apply correction - err_idx = cast(OpResult[IndexType], decode_esm_op.err_idx) - extract_err_qubit_op = qecp.ExtractQubitOp(codeblock=x_out_codeblock, idx=err_idx) - err_qubit = extract_err_qubit_op.qubit - qecp.PauliZOp(in_qubit=err_qubit) - insert_err_qubit_op = qecp.InsertQubitOp( - in_codeblock=x_out_codeblock, idx=err_idx, qubit=err_qubit - ) + # Apply Z checks pattern for X corrections + z_out_codeblock = self._qec_cycle_css_pattern(x_out_codeblock, CheckType.Z, tanner_z) - out_codeblock = insert_err_qubit_op.out_codeblock - - # return the corrected codeblock - func.ReturnOp(out_codeblock) + # Return the corrected codeblock + func.ReturnOp(z_out_codeblock) funcOp = func.FuncOp( name=f"qec_cycle_{self.qec_code.name}", @@ -588,5 +555,60 @@ def cnot_fn(aux_qb, data_qb): return tanner_graph, cnot_fn + def _qec_cycle_css_pattern( + self, + in_codeblock: qecp.PhysicalCodeBlockSSAValue, + check_type: CheckType, + tanner_graph: qecp.TannerGraphSSAValue, + ) -> OpResult[qecp.PhysicalCodeblockType]: + """TODO""" + # Allocate auxiliary qubits for ESM checks + aux_allocate_ops = (qecp.AllocAuxQubitOp() for row in self.qec_code.x_tanner) + aux_qubits = [ + cast(OpResult[qecp.QecPhysicalQubitType], op.results[0]) for op in aux_allocate_ops + ] + + # Apply gate+measurement pattern for the check + measure_ops, out_codeblock = self.check_pattern( + aux_qubits, in_codeblock, check_type=check_type + ) + + # Checks are done; deallocate the auxiliary qubits + for x_meas_op in measure_ops: + qecp.DeallocAuxQubitOp(x_meas_op.out_qubit) + + # Pack measurement results into a tensor for decoding + pack_mres_tensor_op = tensor.FromElementsOp.build( + operands=([meas_op.mres for meas_op in measure_ops],), + result_types=(TensorType(i1, shape=(len(measure_ops),)),), + ) + + # Decode ESM syndrome + decode_esm_op = qecp.DecodeEsmCssOp( + tanner_graph=tanner_graph, + esm=pack_mres_tensor_op.result, + err_idx_type=TensorType(IndexType(), shape=(1,)), + ) + + # Apply correction + err_idx = cast(OpResult[IndexType], decode_esm_op.err_idx) + extract_err_qubit_op = qecp.ExtractQubitOp(codeblock=out_codeblock, idx=err_idx) + err_qubit = extract_err_qubit_op.qubit + + match check_type: + case CheckType.X: + qecp.PauliZOp(in_qubit=err_qubit) + case CheckType.Z: + qecp.PauliZOp(in_qubit=err_qubit) + case _: + assert False, f"Unknown CheckType: '{check_type}'" + + insert_err_qubit_op = qecp.InsertQubitOp( + in_codeblock=out_codeblock, idx=err_idx, qubit=err_qubit + ) + + # Return updated codeblock SSA value + return insert_err_qubit_op.out_codeblock + convert_qecl_to_qecp_pass = compiler_transform(ConvertQecLogicalToQecPhysicalPass) From 60b70375d694bc9f525270945604337a28489f5f Mon Sep 17 00:00:00 2001 From: Joey Carter Date: Mon, 27 Apr 2026 17:01:49 -0400 Subject: [PATCH 05/75] Update conversion pattern; it's now complete! --- .../transforms/qecp/convert_qecl_to_qecp.py | 205 ++++++++++++------ .../transforms/qecp/qec_code_lib.py | 19 ++ .../transforms/qecp/test_qec_code_lib.py | 9 + .../qecp/test_xdsl_convert_qecl_to_qecp.py | 87 +++++++- 4 files changed, 252 insertions(+), 68 deletions(-) diff --git a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py index a24d024377..54b5471656 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py +++ b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py @@ -26,8 +26,8 @@ import numpy as np from xdsl import builder from xdsl.context import Context -from xdsl.dialects import arith, builtin, func, tensor -from xdsl.dialects.builtin import IndexType, TensorType, i1, i32 +from xdsl.dialects import arith, builtin, func, scf, tensor +from xdsl.dialects.builtin import IndexType, TensorType, i1, i32, i64 from xdsl.ir import Block, BlockArgument, OpResult, Region from xdsl.passes import ModulePass from xdsl.pattern_rewriter import ( @@ -413,51 +413,6 @@ def create_encode_subroutine(self) -> func.FuncOp: return funcOp - def create_qec_cycle_subroutine( - self, tanner_x: qecp.TannerGraphSSAValue, tanner_z: qecp.TannerGraphSSAValue - ) -> func.FuncOp: - """FIXME - - Create a subroutine that takes in a codeblock, encodes it in the zero state for - the QEC code (based on the tanner graph), and returns the encoded codeblock. This - encoding procedure follows the example shown in arXiv: 0905.2794, Section VIII.A. - It does not include Z-corrections; this is because the encode op is followed directly - by a full cycle of error correction when lowering to the qecl dialect. - - The subroutine allocates auxiliary qubits for use in encoding based on the number of - rows in the X tanner graph, and deallocates them once encoding is complete. - - Note that this method does not insert the subroutine into the module op. Instead it returns - the built func.FuncOp object that can then be subsequently inserted where desired. - """ - - codeblock_type = qecp.PhysicalCodeblockType(self.qec_code.k, self.qec_code.n) - input_types = (codeblock_type,) - output_types = (codeblock_type,) - - block = Block(arg_types=input_types) - - with builder.ImplicitBuilder(block): - in_codeblock = cast(BlockArgument[qecp.PhysicalCodeblockType], block.args[0]) - - # Apply X checks pattern for Z corrections - x_out_codeblock = self._qec_cycle_css_pattern(in_codeblock, CheckType.X, tanner_x) - - # Apply Z checks pattern for X corrections - z_out_codeblock = self._qec_cycle_css_pattern(x_out_codeblock, CheckType.Z, tanner_z) - - # Return the corrected codeblock - func.ReturnOp(z_out_codeblock) - - funcOp = func.FuncOp( - name=f"qec_cycle_{self.qec_code.name}", - function_type=(input_types, output_types), - visibility="private", - region=Region([block]), - ) - - return funcOp - def check_pattern( self, in_aux_qbs: Iterable[qecp.QecPhysicalQubitSSAValue], @@ -555,13 +510,68 @@ def cnot_fn(aux_qb, data_qb): return tanner_graph, cnot_fn + def create_qec_cycle_subroutine( + self, tanner_x: qecp.TannerGraphSSAValue, tanner_z: qecp.TannerGraphSSAValue + ) -> func.FuncOp: + """Create a subroutine that performs a cycle of QEC on an input physical codeblock. + + The generated subroutine assumes a CSS QEC code and performs separate X and Z corrections, + as defined by the input X and Z Tanner graphs, `tanner_x` and `tanner_z`. Recall that + X-Tanner graphs define the X stabilizer components of the code, which are used to perform Z + corrections, and conversely Z-Tanner graphs define the Z stabilizer components of the code, + which are used to perform X corrections. + + For each of the X and Z components of the QEC protocol, the subroutine allocates auxiliary + qubits for error-syndrome measurement (ESM) based on the number of rows in the respective + Tanner graph. After obtaining the ESM, it deallocates the auxiliary qubits and feeds the ESM + into a call to the ESM decoder, which returns the indices in the physical codeblock where + the detected error(s) occurred. It then iterates over these codeblock indices, applies the + respective correction, and finally returns the updated physical codeblock SSA value. + + Note that this method does not insert the subroutine into the module op. Instead it returns + the built func.FuncOp object that can then be subsequently inserted where desired. + """ + + codeblock_type = qecp.PhysicalCodeblockType(self.qec_code.k, self.qec_code.n) + input_types = (codeblock_type,) + output_types = (codeblock_type,) + + block = Block(arg_types=input_types) + + with builder.ImplicitBuilder(block): + in_codeblock = cast(BlockArgument[qecp.PhysicalCodeblockType], block.args[0]) + + # Apply X checks pattern for Z corrections + x_out_codeblock = self._qec_cycle_css_pattern(in_codeblock, CheckType.X, tanner_x) + + # Apply Z checks pattern for X corrections + z_out_codeblock = self._qec_cycle_css_pattern(x_out_codeblock, CheckType.Z, tanner_z) + + # Return the corrected codeblock + func.ReturnOp(z_out_codeblock) + + funcOp = func.FuncOp( + name=f"qec_cycle_{self.qec_code.name}", + function_type=(input_types, output_types), + visibility="private", + region=Region([block]), + ) + + return funcOp + def _qec_cycle_css_pattern( self, in_codeblock: qecp.PhysicalCodeBlockSSAValue, check_type: CheckType, tanner_graph: qecp.TannerGraphSSAValue, ) -> OpResult[qecp.PhysicalCodeblockType]: - """TODO""" + """Build the operations that perform a single X or Z component of a CSS QEC cycle on the + given `in_codeblock`. + + This method is intended to be a helper function to `create_qec_cycle_subroutine()` and to be + called inside `builder.ImplicitBuilder` context to automatically add these operations to a + block. + """ # Allocate auxiliary qubits for ESM checks aux_allocate_ops = (qecp.AllocAuxQubitOp() for row in self.qec_code.x_tanner) aux_qubits = [ @@ -569,7 +579,7 @@ def _qec_cycle_css_pattern( ] # Apply gate+measurement pattern for the check - measure_ops, out_codeblock = self.check_pattern( + measure_ops, post_check_codeblock = self.check_pattern( aux_qubits, in_codeblock, check_type=check_type ) @@ -590,25 +600,90 @@ def _qec_cycle_css_pattern( err_idx_type=TensorType(IndexType(), shape=(1,)), ) - # Apply correction - err_idx = cast(OpResult[IndexType], decode_esm_op.err_idx) - extract_err_qubit_op = qecp.ExtractQubitOp(codeblock=out_codeblock, idx=err_idx) - err_qubit = extract_err_qubit_op.qubit - - match check_type: - case CheckType.X: - qecp.PauliZOp(in_qubit=err_qubit) - case CheckType.Z: - qecp.PauliZOp(in_qubit=err_qubit) - case _: - assert False, f"Unknown CheckType: '{check_type}'" - - insert_err_qubit_op = qecp.InsertQubitOp( - in_codeblock=out_codeblock, idx=err_idx, qubit=err_qubit + # Apply correction(s) + err_indices = cast(OpResult[TensorType[IndexType]], decode_esm_op.err_idx) + num_correctable_errors = self.qec_code.correctable_errors + + assert err_indices.type == ( + expected_type := TensorType(IndexType(), shape=(num_correctable_errors,)) + ), ( + f"Expected result of op '{decode_esm_op}' to have type '{expected_type}', " + f"but got '{err_indices.type}'" + ) + + # Build a for loop that iterates over each error index + lb_op = arith.ConstantOp.from_int_and_width(0, IndexType()) + ub_op = arith.ConstantOp.from_int_and_width(num_correctable_errors, IndexType()) + step_op = arith.ConstantOp.from_int_and_width(1, IndexType()) + + for_body = Block( + [], + arg_types=(builtin.IndexType(), post_check_codeblock.type), + ) + + for_each_err_idx_op = scf.ForOp( + lb=lb_op, + ub=ub_op, + step=step_op, + iter_args=(post_check_codeblock,), + body=for_body, ) + with builder.ImplicitBuilder(for_each_err_idx_op.body): + indvar = cast(BlockArgument[IndexType], for_each_err_idx_op.body.block.args[0]) + codeblock = cast( + BlockArgument[qecp.PhysicalCodeblockType], + for_each_err_idx_op.body.block.args[1], + ) + + extract_err_idx_op = tensor.ExtractOp( + err_indices, indices=indvar, result_type=IndexType() + ) + err_idx = cast(OpResult[IndexType], extract_err_idx_op.result) + + # Now we have the error index for this iteration in the for loop. Next we check if its + # value indicates that an error was detected (idx != -1), or if no error was detected + # (idx == -1). + cast_index_op = arith.IndexCastOp(err_idx, target_type=i64) + minus_1_const_op = arith.ConstantOp.from_int_and_width(-1, 64) + apply_corr_cond_op = arith.CmpiOp(cast_index_op.result, minus_1_const_op.result, "ne") + + if_apply_corr_op = scf.IfOp( + apply_corr_cond_op.result, + return_types=(codeblock.type,), + true_region=Region(Block()), + false_region=Region(Block()), + ) + + with builder.ImplicitBuilder(if_apply_corr_op.true_region): + # This branch is for the case where a correctable error was detected + extract_err_qubit_op = qecp.ExtractQubitOp(codeblock=codeblock, idx=err_idx) + err_qubit = extract_err_qubit_op.qubit + + match check_type: + case CheckType.X: + corr_qubit_op = qecp.PauliZOp(in_qubit=err_qubit) + case CheckType.Z: + corr_qubit_op = qecp.PauliXOp(in_qubit=err_qubit) + case _: + assert False, f"Unknown CheckType: '{check_type}'" + + insert_err_qubit_op = qecp.InsertQubitOp( + in_codeblock=post_check_codeblock, idx=err_idx, qubit=corr_qubit_op.out_qubit + ) + + scf.YieldOp(insert_err_qubit_op.out_codeblock) + + with builder.ImplicitBuilder(if_apply_corr_op.false_region): + # This branch is for the case where no correctable error was detected + scf.YieldOp(codeblock) + + out_codeblock = cast(OpResult[qecp.PhysicalCodeblockType], if_apply_corr_op.results[0]) + + scf.YieldOp(out_codeblock) + # Return updated codeblock SSA value - return insert_err_qubit_op.out_codeblock + return out_codeblock convert_qecl_to_qecp_pass = compiler_transform(ConvertQecLogicalToQecPhysicalPass) diff --git a/frontend/catalyst/python_interface/transforms/qecp/qec_code_lib.py b/frontend/catalyst/python_interface/transforms/qecp/qec_code_lib.py index 05253544f3..3b043ebb66 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/qec_code_lib.py +++ b/frontend/catalyst/python_interface/transforms/qecp/qec_code_lib.py @@ -15,6 +15,7 @@ """This module contains a library of QEC codes.""" from dataclasses import dataclass, fields +from math import floor from typing import Any, Self import numpy as np @@ -88,3 +89,21 @@ def get(cls, name: str) -> Self: raise KeyError(f"QEC code {name} not found") return cls(name, *qec_code_params) + + @property + def correctable_errors(self) -> int: + """Return the number of correctable errors of the QEC code. + + For a code with distance *d*, the number of correctable errors *t* is given by + + .. math:: + + t = \\lfloor (d - 1) / 2 \\rfloor + + Example + ------- + + >>> code = QecCode.get("Steane") + >>> code.correctable_errors + """ + return floor((self.d - 1) / 2) diff --git a/frontend/test/pytest/python_interface/transforms/qecp/test_qec_code_lib.py b/frontend/test/pytest/python_interface/transforms/qecp/test_qec_code_lib.py index 6a9bf7bc4b..6009757d1e 100644 --- a/frontend/test/pytest/python_interface/transforms/qecp/test_qec_code_lib.py +++ b/frontend/test/pytest/python_interface/transforms/qecp/test_qec_code_lib.py @@ -133,6 +133,15 @@ def test_constructor_with_dict_input(self, data: dict): assert np.all(qec_code.x_tanner == data["x_tanner"]) assert np.all(qec_code.z_tanner == data["z_tanner"]) + @pytest.mark.parametrize("d, expected_t", [(1, 0), (2, 0), (3, 1), (4, 1), (5, 2), (6, 2)]) + def test_correctable_errors_property(self, d: int, expected_t: int): + """Test the `correctable_errors` property of `QecCode`, which returns the number of + correctable errors of the code, equal to floor((d - 1) / 2). + """ + qec_code = QecCode("", 1, 1, d, np.array([]), np.array([])) + + assert qec_code.correctable_errors == expected_t + @pytest.mark.parametrize("name", SUPPORTED_CODES) def test_get(self, name: str): """Test the `QecCode.get()` method for all supported QEC codes.""" diff --git a/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py b/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py index cace2509d9..3cbbf5fc67 100644 --- a/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py +++ b/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py @@ -388,8 +388,8 @@ def test_multiple_encodes_with_Steane(self, run_filecheck): func.func @test_func() attributes {quantum.node} { // CHECK: [[codeblock1:%.+]] = "test.op"() : () -> !qecp.codeblock<1 x 7> // CHECK-NEXT: [[codeblock2:%.+]] = "test.op"() : () -> !qecp.codeblock<1 x 7> - // CHECK-NEXT: func.call @encode_zero_Steane - // CHECK-NEXT: func.call @encode_zero_Steane + // CHECK-NEXT: func.call @encode_zero_Steane + // CHECK-NEXT: func.call @encode_zero_Steane %0 = "test.op"() : () -> !qecl.codeblock<1> %1 = "test.op"() : () -> !qecl.codeblock<1> %2 = qecl.encode ["zero"] %0 : !qecl.codeblock<1> @@ -441,7 +441,9 @@ class TestQecCycleLowering: """Unit tests for the `qecl.qec` conversion pattern of the convert-qecl-to-qecp pass.""" def test_single_qec_cycle_Steane(self, run_filecheck): - """TODO""" + """Test that a `qecl.qec` op is lowered to a call to the QEC-cycle subroutine for the Steane + code. + """ program = """ // CHECK-LABEL: test_module builtin.module @test_module { @@ -456,6 +458,85 @@ def test_single_qec_cycle_Steane(self, run_filecheck): %1 = qecl.qec %0 : !qecl.codeblock<1> return } + // CHECK-LABEL: qec_cycle_Steane([[cb0:%.+]]: !qecp.codeblock<1 x 7>) -> !qecp.codeblock<1 x 7> + + // COM: The block below takes results of X checks and performs Z corrections + // CHECK: qecp.alloc_aux : !qecp.qubit + // CHECK: qecp.alloc_aux : !qecp.qubit + // CHECK: qecp.alloc_aux : !qecp.qubit + // CHECK: qecp.hadamard {{.*}} : !qecp.qubit + // CHECK: qecp.hadamard {{.*}} : !qecp.qubit + // CHECK: qecp.hadamard {{.*}} : !qecp.qubit + // CHECK: qecp.extract {{.*}} : !qecp.codeblock<1 x 7> -> !qecp.qubit + // CHECK: qecp.cnot {{.*}} : !qecp.qubit, !qecp.qubit + // CHECK: qecp.insert {{.*}} : !qecp.codeblock<1 x 7>, !qecp.qubit + // CHECK: [[cb0:%.+]] = qecp.insert {{.*}}[6], {{.*}} : !qecp.codeblock<1 x 7>, !qecp.qubit + // CHECK: qecp.hadamard {{.*}} : !qecp.qubit + // CHECK: qecp.hadamard {{.*}} : !qecp.qubit + // CHECK: qecp.hadamard {{.*}} : !qecp.qubit + // CHECK: [[m0:%.+]], {{.*}} = qecp.measure {{.*}} : i1, !qecp.qubit + // CHECK: [[m1:%.+]], {{.*}} = qecp.measure {{.*}} : i1, !qecp.qubit + // CHECK: [[m2:%.+]], {{.*}} = qecp.measure {{.*}} : i1, !qecp.qubit + // CHECK: qecp.dealloc_aux {{.*}} : !qecp.qubit + // CHECK: qecp.dealloc_aux {{.*}} : !qecp.qubit + // CHECK: qecp.dealloc_aux {{.*}} : !qecp.qubit + // CHECK: [[esm:%.+]] = tensor.from_elements [[m0]], [[m1]], [[m2]] : tensor<3xi1> + // CHECK: [[idx_t:%.+]] = qecp.decode_esm_css([[esm]] : tensor<3xi1>) [[tanner_x]] : !qecp.tanner_graph<12, 8, i32> -> tensor<1xindex> + // CHECK: [[lb:%.+]] = arith.constant 0 : index + // CHECK: [[ub:%.+]] = arith.constant 1 : index + // CHECK: [[st:%.+]] = arith.constant 1 : index + // CHECK: [[cb_x_out:%.+]] = scf.for [[i:%.+]] = [[lb]] to [[ub]] step [[st]] iter_args([[cb_arg:%.+]] = {{%.+}}) + // CHECK: [[err_idx:%.+]] = tensor.extract [[idx_t]][[[i]]] : tensor<1xindex> + // CHECK: [[err_i64:%.+]] = arith.index_cast [[err_idx]] : index to i64 + // CHECK: [[minus1:%.+]] = arith.constant -1 : i64 + // CHECK: [[cond:%.+]] = arith.cmpi ne, [[err_i64]], [[minus1]] : i64 + // CHECK: [[cond_out_cb:%.+]] = scf.if [[cond]] + // CHECK: [[q0:%.+]] = qecp.extract [[cb_arg]][[[err_idx]]] : !qecp.codeblock<1 x 7> -> !qecp.qubit + // CHECK: [[q1:%.+]] = qecp.z [[q0]] : !qecp.qubit + // CHECK: [[cb_arg_1:%.+]] = qecp.insert [[cb0]][[[err_idx]]], [[q1]] : !qecp.codeblock<1 x 7>, !qecp.qubit + // CHECK: scf.yield [[cb_arg_1]] : !qecp.codeblock<1 x 7> + // CHECK: } else { + // CHECK: scf.yield [[cb_arg]] : !qecp.codeblock<1 x 7> + // CHECK: } + // CHECK: scf.yield [[cond_out_cb]] : !qecp.codeblock<1 x 7> + // CHECK: } + + // COM: The block below takes results of X checks and performs Z corrections + // CHECK: qecp.alloc_aux : !qecp.qubit + // CHECK: qecp.alloc_aux : !qecp.qubit + // CHECK: qecp.alloc_aux : !qecp.qubit + // CHECK-NOT: qecp.hadamard + // CHECK: qecp.extract {{.*}} : !qecp.codeblock<1 x 7> -> !qecp.qubit + // CHECK: qecp.cnot {{.*}} : !qecp.qubit, !qecp.qubit + // CHECK: qecp.insert {{.*}} : !qecp.codeblock<1 x 7>, !qecp.qubit + // CHECK: [[cb0:%.+]] = qecp.insert {{.*}}[6], {{.*}} : !qecp.codeblock<1 x 7>, !qecp.qubit + // CHECK-NOT: qecp.hadamard + // CHECK: [[m0:%.+]], {{.*}} = qecp.measure {{.*}} : i1, !qecp.qubit + // CHECK: [[m1:%.+]], {{.*}} = qecp.measure {{.*}} : i1, !qecp.qubit + // CHECK: [[m2:%.+]], {{.*}} = qecp.measure {{.*}} : i1, !qecp.qubit + // CHECK: qecp.dealloc_aux {{.*}} : !qecp.qubit + // CHECK: qecp.dealloc_aux {{.*}} : !qecp.qubit + // CHECK: qecp.dealloc_aux {{.*}} : !qecp.qubit + // CHECK: [[esm:%.+]] = tensor.from_elements [[m0]], [[m1]], [[m2]] : tensor<3xi1> + // CHECK: [[idx_t:%.+]] = qecp.decode_esm_css([[esm]] : tensor<3xi1>) [[tanner_z]] : !qecp.tanner_graph<12, 8, i32> -> tensor<1xindex> + // CHECK: [[lb:%.+]] = arith.constant 0 : index + // CHECK: [[ub:%.+]] = arith.constant 1 : index + // CHECK: [[st:%.+]] = arith.constant 1 : index + // CHECK: [[cb_x_out:%.+]] = scf.for [[i:%.+]] = [[lb]] to [[ub]] step [[st]] iter_args([[cb_arg:%.+]] = {{%.+}}) + // CHECK: [[err_idx:%.+]] = tensor.extract [[idx_t]][[[i]]] : tensor<1xindex> + // CHECK: [[err_i64:%.+]] = arith.index_cast [[err_idx]] : index to i64 + // CHECK: [[minus1:%.+]] = arith.constant -1 : i64 + // CHECK: [[cond:%.+]] = arith.cmpi ne, [[err_i64]], [[minus1]] : i64 + // CHECK: [[cond_out_cb:%.+]] = scf.if [[cond]] + // CHECK: [[q0:%.+]] = qecp.extract [[cb_arg]][[[err_idx]]] : !qecp.codeblock<1 x 7> -> !qecp.qubit + // CHECK: [[q1:%.+]] = qecp.x [[q0]] : !qecp.qubit + // CHECK: [[cb_arg_1:%.+]] = qecp.insert [[cb0]][[[err_idx]]], [[q1]] : !qecp.codeblock<1 x 7>, !qecp.qubit + // CHECK: scf.yield [[cb_arg_1]] : !qecp.codeblock<1 x 7> + // CHECK: } else { + // CHECK: scf.yield [[cb_arg]] : !qecp.codeblock<1 x 7> + // CHECK: } + // CHECK: scf.yield [[cond_out_cb]] : !qecp.codeblock<1 x 7> + // CHECK: } } """ pipeline = (ConvertQecLogicalToQecPhysicalPass(qec_code=QecCode.get("Steane")),) From 0370bfb452282593d10d926eda9db04f84b0454b Mon Sep 17 00:00:00 2001 From: Joey Carter Date: Mon, 27 Apr 2026 17:06:49 -0400 Subject: [PATCH 06/75] Fix mistake in tensor shape result of qecp.decode_esm_css op --- .../python_interface/transforms/qecp/convert_qecl_to_qecp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py index 54b5471656..b3f8aad1a9 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py +++ b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py @@ -594,15 +594,15 @@ def _qec_cycle_css_pattern( ) # Decode ESM syndrome + num_correctable_errors = self.qec_code.correctable_errors decode_esm_op = qecp.DecodeEsmCssOp( tanner_graph=tanner_graph, esm=pack_mres_tensor_op.result, - err_idx_type=TensorType(IndexType(), shape=(1,)), + err_idx_type=TensorType(IndexType(), shape=(num_correctable_errors,)), ) # Apply correction(s) err_indices = cast(OpResult[TensorType[IndexType]], decode_esm_op.err_idx) - num_correctable_errors = self.qec_code.correctable_errors assert err_indices.type == ( expected_type := TensorType(IndexType(), shape=(num_correctable_errors,)) From 887afd9bb245370a82ae762444315dd203515a45 Mon Sep 17 00:00:00 2001 From: Joey Carter Date: Mon, 27 Apr 2026 17:08:37 -0400 Subject: [PATCH 07/75] Add changelog entry --- doc/releases/changelog-dev.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md index ff941305d0..67e2721f4b 100644 --- a/doc/releases/changelog-dev.md +++ b/doc/releases/changelog-dev.md @@ -1030,6 +1030,7 @@ [(#2716)](https://github.com/PennyLaneAI/catalyst/pull/2716) [(#2737)](https://github.com/PennyLaneAI/catalyst/pull/2737) [(#2731)](https://github.com/PennyLaneAI/catalyst/pull/2731) + [(#2754)](https://github.com/PennyLaneAI/catalyst/pull/2754) * A number of deprecation warnings have been fixed in the compiler python interface. [(#2621)](https://github.com/PennyLaneAI/catalyst/pull/2621) From 2ed1413c3c5c0a42bceb4c2ecdaef55462d7eab0 Mon Sep 17 00:00:00 2001 From: Joey Carter Date: Tue, 28 Apr 2026 09:46:42 -0400 Subject: [PATCH 08/75] Clean up --- .../transforms/qecp/convert_qecl_to_qecp.py | 18 ++++-------------- .../transforms/qecp/qec_code_lib.py | 4 ++-- .../transforms/qecp/test_qec_code_lib.py | 4 ++-- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py index b3f8aad1a9..c106adac55 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py +++ b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py @@ -184,14 +184,14 @@ def match_and_rewrite(self, op: qecl.EncodeOp, rewriter: PatternRewriter): @dataclass class QecCycleOpConversion(RewritePattern): - """Converts qecl.encode [zero] to the equivalent subroutine of qecp gates""" + """Converts qecl.qec to the equivalent subroutine of qecp gates.""" qec_code: QecCode qec_cycle_subroutine: func.FuncOp @op_type_rewrite_pattern def match_and_rewrite(self, op: qecl.QecCycleOp, rewriter: PatternRewriter): - """Rewrite pattern for `qecl.encode [zero]` op""" + """Rewrite pattern for `qecl.qec` ops.""" in_codeblock = cast( qecl.LogicalCodeBlockSSAValue | qecp.PhysicalCodeBlockSSAValue, op.in_codeblock @@ -351,17 +351,7 @@ def insert_tanner_graph_ops_into_block( if block.first_op is None: block.add_ops(ops_to_insert) else: - block.insert_ops_before( - ( - x_tanner_row_idx_const_op, - x_tanner_col_ptr_const_op, - assemble_x_tanner_op, - z_tanner_row_idx_const_op, - z_tanner_col_ptr_const_op, - assemble_z_tanner_op, - ), - block.first_op, - ) + block.insert_ops_before(ops_to_insert, block.first_op) return assemble_x_tanner_op.tanner_graph, assemble_z_tanner_op.tanner_graph @@ -569,7 +559,7 @@ def _qec_cycle_css_pattern( given `in_codeblock`. This method is intended to be a helper function to `create_qec_cycle_subroutine()` and to be - called inside `builder.ImplicitBuilder` context to automatically add these operations to a + called inside a `builder.ImplicitBuilder` context to automatically add these operations to a block. """ # Allocate auxiliary qubits for ESM checks diff --git a/frontend/catalyst/python_interface/transforms/qecp/qec_code_lib.py b/frontend/catalyst/python_interface/transforms/qecp/qec_code_lib.py index 3b043ebb66..eb80abc705 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/qec_code_lib.py +++ b/frontend/catalyst/python_interface/transforms/qecp/qec_code_lib.py @@ -14,8 +14,8 @@ """This module contains a library of QEC codes.""" +import math from dataclasses import dataclass, fields -from math import floor from typing import Any, Self import numpy as np @@ -106,4 +106,4 @@ def correctable_errors(self) -> int: >>> code = QecCode.get("Steane") >>> code.correctable_errors """ - return floor((self.d - 1) / 2) + return math.floor((self.d - 1) / 2) diff --git a/frontend/test/pytest/python_interface/transforms/qecp/test_qec_code_lib.py b/frontend/test/pytest/python_interface/transforms/qecp/test_qec_code_lib.py index 6009757d1e..6e99cf8cac 100644 --- a/frontend/test/pytest/python_interface/transforms/qecp/test_qec_code_lib.py +++ b/frontend/test/pytest/python_interface/transforms/qecp/test_qec_code_lib.py @@ -136,9 +136,9 @@ def test_constructor_with_dict_input(self, data: dict): @pytest.mark.parametrize("d, expected_t", [(1, 0), (2, 0), (3, 1), (4, 1), (5, 2), (6, 2)]) def test_correctable_errors_property(self, d: int, expected_t: int): """Test the `correctable_errors` property of `QecCode`, which returns the number of - correctable errors of the code, equal to floor((d - 1) / 2). + correctable errors of the code, t = floor((d - 1) / 2). """ - qec_code = QecCode("", 1, 1, d, np.array([]), np.array([])) + qec_code = QecCode("", 1, 1, d, np.array([]), np.array([])) # Values of n, k don't matter assert qec_code.correctable_errors == expected_t From f7f01014f28f40cc3fd2428380614f2981297c99 Mon Sep 17 00:00:00 2001 From: Joey Carter Date: Tue, 28 Apr 2026 10:26:08 -0400 Subject: [PATCH 09/75] Fix np.ndarray type hints that caused pylint error --- .../python_interface/transforms/qecp/tanner_graph_lib.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py b/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py index e8a4ae4f5a..3d3599b70a 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py +++ b/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py @@ -20,9 +20,7 @@ import scipy.sparse -def dense_tanner_graph_to_csc( - matrix: np.ndarray[tuple[int, int]], -) -> tuple[np.ndarray[tuple[int]], np.ndarray[tuple[int]]]: +def dense_tanner_graph_to_csc(matrix: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Converts a dense parity-check matrix to CSC index arrays. Takes a dense 2D numpy array representing a Tanner graph, expressed as a parity-check matrix, From b5fefb9e4e987acd8d1bceb5af4d1e3e78758fe7 Mon Sep 17 00:00:00 2001 From: Joey Carter Date: Thu, 30 Apr 2026 11:07:16 -0400 Subject: [PATCH 10/75] Fix the parity_check_matrix_to_tanner_csc function --- .../transforms/qecp/tanner_graph_lib.py | 59 ++++++++++++--- .../transforms/qecp/test_tanner_graph_lib.py | 75 +++++++++++++++---- 2 files changed, 108 insertions(+), 26 deletions(-) diff --git a/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py b/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py index 3d3599b70a..3770f8cb60 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py +++ b/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py @@ -20,26 +20,65 @@ import scipy.sparse -def dense_tanner_graph_to_csc(matrix: np.ndarray) -> tuple[np.ndarray, np.ndarray]: - """Converts a dense parity-check matrix to CSC index arrays. +def parity_check_matrix_to_tanner_csc(H: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Converts a dense parity-check matrix `H` to a Tanner graph adjacency matrix in CSC form. - Takes a dense 2D numpy array representing a Tanner graph, expressed as a parity-check matrix, - and returns the row indices and column pointers corresponding to the Compressed Sparse Column - (CSC) format of this matrix. + Takes a dense 2D numpy array representing a parity-check matrix, converts it to the adjacency + matrix for the equivalent Tanner graph, and returns the row indices and column pointers + corresponding to the Compressed Sparse Column (CSC) format of this adjacency matrix. Note that this function does not return the 'data' array of the CSC matrix (the array of non-zero values), as these values are assumed to be all 1s. Args: - matrix (numpy.ndarray): A 2D numpy array representing the dense matrix, typically containing - 0s and 1s. + matrix (numpy.ndarray): A 2D numpy array representing the dense parity-check matrix, + typically containing 0s and 1s. The parity-check matrix is taken to have dimensions + (n_aux x n_data), where n_aux and n_data are the number of auxiliary and data qubits, + respectively. Returns: - tuple: A tuple containing two elements: + tuple: A tuple containing two elements representing the Tanner graph adjacency matrix: - row_idx (numpy.ndarray): The row indices of the non-zero elements. - col_ptr (numpy.ndarray): The column pointers, where indptr[i] indicates the start index in 'indices' for the i-th column. + + .. details:: + :title: Layout of the (dense) Tanner graph adjacency matrix + + Given an m x n parity-check matrix H, the equivalent Tanner graph adjacency matrix A has the + form + + ┌ ┐ + A = │ 0 H^T │ + │ H 0 │ + └ ┘ + + The adjacency matrix A therefore has shape (m+n, m+n). In this representation, the first n + columns corresponding to the n data qubits of the code, from which the data qubit's + neighbouring aux qubits in the Tanner graph can be read off from the non-zero elements in + the column, and the last m columns correspond to the m aux qubits of the code, from which + their neighbouring data qubits can be read off from the non-zero elements in the column. """ - matrix_as_csc = scipy.sparse.csc_matrix(matrix) + if len(H.shape) != 2: + raise ValueError(f"Expected an m x n matrix, but got an array with shape {H.shape}") + + m, n = H.shape # m = n_aux, n = n_data + + H_csc = scipy.sparse.csc_matrix(H) + + # Get H^T. Note that we can't use the sparse matrix `transpose()` method because that does not + # alter the underlying `indices` and `indptr` arrays. + H_T = H.transpose() + H_T_csc = scipy.sparse.csc_matrix(H_T) + + H_nnz = H_csc.nnz # Number of non-zero elements + + # To get the CSC form of the adjacency matrix, we divide it up into two segments: + # - Columns [0:n-1] (inclusive) + # - Columns [n:n+m-1] (inclusive) + # and get the row_idx and col_ptr values for each, then combine them together as follows: + + A_indices = np.concatenate([H_csc.indices + n, H_T_csc.indices]) + A_indptr = np.concatenate([H_csc.indptr[:-1], H_T_csc.indptr + H_nnz]) - return matrix_as_csc.indices, matrix_as_csc.indptr + return A_indices, A_indptr diff --git a/frontend/test/pytest/python_interface/transforms/qecp/test_tanner_graph_lib.py b/frontend/test/pytest/python_interface/transforms/qecp/test_tanner_graph_lib.py index d2ce0987ef..f5ec40e9ff 100644 --- a/frontend/test/pytest/python_interface/transforms/qecp/test_tanner_graph_lib.py +++ b/frontend/test/pytest/python_interface/transforms/qecp/test_tanner_graph_lib.py @@ -16,22 +16,26 @@ import numpy as np import pytest +import scipy.sparse -from catalyst.python_interface.transforms.qecp.tanner_graph_lib import dense_tanner_graph_to_csc +from catalyst.python_interface.transforms.qecp.tanner_graph_lib import ( + parity_check_matrix_to_tanner_csc, +) -class TestDenseTannerGraphToCsc: - """Unit tests for the `dense_tanner_graph_to_csc` function. +class TestParityCheckMatrixToTannerCsc: + """Unit tests for the `parity_check_matrix_to_tanner_csc` function. - We don't do thorough, comprehensive testing of this method because it is a wrapper around a - scipy function, and we assume the scipy devs have already done their due diligence. + We don't do thorough, comprehensive testing of this method because it is largely a wrapper + around a scipy function, and we assume the scipy devs have already done their due diligence. """ @pytest.mark.parametrize( - "matrix, expected_row_idx, expected_col_ptr", + "H, expected_dense_tanner", [ ( # A simple, contrived parity-check matrix + np.array([[1, 1, 0], [0, 1, 1]]), np.array( [ [0, 0, 0, 1, 0], @@ -41,22 +45,61 @@ class TestDenseTannerGraphToCsc: [0, 1, 1, 0, 0], ] ), - np.array([3, 3, 4, 4, 0, 1, 1, 2]), # expected row_idx - np.array([0, 1, 3, 4, 6, 8]), # expected col_ptr ), ( # The Steane code parity-check matrix np.array([[1, 1, 1, 1, 0, 0, 0], [0, 1, 1, 0, 1, 1, 0], [0, 0, 1, 1, 0, 1, 1]]), - np.array([0, 0, 1, 0, 1, 2, 0, 2, 1, 1, 2, 2]), # expected row_idx - np.array([0, 1, 3, 6, 8, 9, 11, 12]), # expected col_ptr + np.array( + [ + [0, 0, 0, 0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 1, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 1, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 1, 0, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + [1, 1, 1, 1, 0, 0, 0, 0, 0, 0], + [0, 1, 1, 0, 1, 1, 0, 0, 0, 0], + [0, 0, 1, 1, 0, 1, 1, 0, 0, 0], + ] + ), ), ], ) - def test_dense_tanner_graph_to_csc( - self, matrix: np.ndarray, expected_row_idx: np.ndarray, expected_col_ptr: np.ndarray + def test_parity_check_matrix_to_tanner_csc( + self, H: np.ndarray, expected_dense_tanner: np.ndarray ): - """Test the `dense_tanner_graph_to_csc` function with simple parity-check matrix inputs.""" - row_idx, col_ptr = dense_tanner_graph_to_csc(matrix) + """Test the `parity_check_matrix_to_tanner_csc` function with simple parity-check matrix + inputs. + + This is a sort of round-trip test: we convert the input dense parity-check matrix, H, to a + Tanner graph adjacency matrix in CSC form, and check the correctness of the result by + reconstructing the dense adjacency matrix from the output `row_idx` and `col_ptr` arrays + using the scipy sparse matrix library and check against the expected value given as input. + """ + row_idx, col_ptr = parity_check_matrix_to_tanner_csc(H) + + # Reconstruct the dense Tanner graph adjacency matrix from row_idx and col_ptr and compare + # against expected result. + assert len(H.shape) == 2, "Incorrect test input: expected an m x n matrix" + m, n = H.shape + data = np.ones(len(row_idx), dtype=np.int32) + tanner_adj_mat = scipy.sparse.csc_matrix((data, row_idx, col_ptr), shape=(m + n, m + n)) - assert np.array_equal(row_idx, expected_row_idx) - assert np.array_equal(col_ptr, expected_col_ptr) + assert np.array_equal(expected_dense_tanner, tanner_adj_mat.toarray()) + + @pytest.mark.parametrize( + "H", + [ + np.zeros(shape=()), + np.zeros(shape=(1,)), + np.zeros(shape=(2,)), + np.zeros(shape=(2, 2, 2)), + ], + ) + def test_parity_check_matrix_to_tanner_csc_invalid(self, H: np.ndarray): + """Test the `parity_check_matrix_to_tanner_csc` function with invalid input arrays (of the + wrong dimensions). + """ + with pytest.raises(ValueError, match="Expected an m x n matrix"): + row_idx, col_ptr = parity_check_matrix_to_tanner_csc(H) From 2737848e5f68e34abe0abfffdea522c6897e8a81 Mon Sep 17 00:00:00 2001 From: Joey Carter Date: Thu, 30 Apr 2026 11:13:55 -0400 Subject: [PATCH 11/75] Update qecl.qec pattern to use correct Tanner graph --- .../transforms/qecp/convert_qecl_to_qecp.py | 8 ++++--- .../qecp/test_xdsl_convert_qecl_to_qecp.py | 24 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py index 3458a78d6c..19a7790f74 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py +++ b/frontend/catalyst/python_interface/transforms/qecp/convert_qecl_to_qecp.py @@ -49,7 +49,9 @@ from catalyst.python_interface.dialects import qecl, qecp from catalyst.python_interface.pass_api.compiler_transform import compiler_transform -from catalyst.python_interface.transforms.qecp.tanner_graph_lib import dense_tanner_graph_to_csc +from catalyst.python_interface.transforms.qecp.tanner_graph_lib import ( + parity_check_matrix_to_tanner_csc, +) from catalyst.utils.exceptions import CompileError from .convert_qecl_noise_to_qec_noise import ConvertQECLNoiseOpToQECPNoisePass @@ -369,7 +371,7 @@ def insert_tanner_graph_ops_into_block( Returns the X and Z Tanner graph SSA values from the `qecp.assemble_tanner` ops (we assume a CSS code here and therefore have separate X and Z Tanner graphs). """ - x_tanner_row_idx_array, x_tanner_col_ptr_array = dense_tanner_graph_to_csc( + x_tanner_row_idx_array, x_tanner_col_ptr_array = parity_check_matrix_to_tanner_csc( self.qec_code.x_tanner ) x_tanner_row_idx_const_op = arith.ConstantOp( @@ -392,7 +394,7 @@ def insert_tanner_graph_ops_into_block( ), ) - z_tanner_row_idx_array, z_tanner_col_ptr_array = dense_tanner_graph_to_csc( + z_tanner_row_idx_array, z_tanner_col_ptr_array = parity_check_matrix_to_tanner_csc( self.qec_code.z_tanner ) z_tanner_row_idx_const_op = arith.ConstantOp( diff --git a/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py b/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py index dd9689c4be..24545a34f4 100644 --- a/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py +++ b/frontend/test/pytest/python_interface/transforms/qecp/test_xdsl_convert_qecl_to_qecp.py @@ -426,12 +426,16 @@ def test_tanner_graph_insertion_steane(self, run_filecheck): program = """ // CHECK-LABEL: test_module builtin.module @test_module { - // CHECK: [[row_idx_x:%.+]] = arith.constant dense<[0, 0, 1, 0, 1, 2, 0, 2, 1, 1, 2, 2]> : tensor<12xi32> - // CHECK: [[col_ptr_x:%.+]] = arith.constant dense<[0, 1, 3, 6, 8, 9, 11, 12]> : tensor<8xi32> - // CHECK: [[tanner_x:%.+]] = qecp.assemble_tanner [[row_idx_x]], [[col_ptr_x]] : tensor<12xi32>, tensor<8xi32> -> !qecp.tanner_graph<12, 8, i32> - // CHECK: [[row_idx_z:%.+]] = arith.constant dense<[0, 0, 1, 0, 1, 2, 0, 2, 1, 1, 2, 2]> : tensor<12xi32> - // CHECK: [[col_ptr_z:%.+]] = arith.constant dense<[0, 1, 3, 6, 8, 9, 11, 12]> : tensor<8xi32> - // CHECK: [[tanner_z:%.+]] = qecp.assemble_tanner [[row_idx_z]], [[col_ptr_z]] : tensor<12xi32>, tensor<8xi32> -> !qecp.tanner_graph<12, 8, i32> + // CHECK: [[row_idx_x:%.+]] = arith.constant + // CHECK-SAME: dense<[7, 7, 8, 7, 8, 9, 7, 9, 8, 8, 9, 9, 0, 1, 2, 3, 1, 2, 4, 5, 2, 3, 5, 6]> : tensor<24xi32> + // CHECK: [[col_ptr_x:%.+]] = arith.constant dense<[0, 1, 3, 6, 8, 9, 11, 12, 16, 20, 24]> : tensor<11xi32> + // CHECK: [[tanner_x:%.+]] = qecp.assemble_tanner [[row_idx_x]], [[col_ptr_x]] : + // CHECK-SAME: tensor<24xi32>, tensor<11xi32> -> !qecp.tanner_graph<24, 11, i32> + // CHECK: [[row_idx_z:%.+]] = arith.constant + // CHECK-SAME: dense<[7, 7, 8, 7, 8, 9, 7, 9, 8, 8, 9, 9, 0, 1, 2, 3, 1, 2, 4, 5, 2, 3, 5, 6]> : tensor<24xi32> + // CHECK: [[col_ptr_z:%.+]] = arith.constant dense<[0, 1, 3, 6, 8, 9, 11, 12, 16, 20, 24]> : tensor<11xi32> + // CHECK: [[tanner_z:%.+]] = qecp.assemble_tanner [[row_idx_z]], [[col_ptr_z]] : + // CHECK-SAME: tensor<24xi32>, tensor<11xi32> -> !qecp.tanner_graph<24, 11, i32> // CHECK-LABEL: test_program func.func @test_program() { return @@ -455,8 +459,8 @@ def test_single_qec_cycle_Steane(self, run_filecheck): program = """ // CHECK-LABEL: test_module builtin.module @test_module { - // CHECK: [[tanner_x:%.+]] = qecp.assemble_tanner {{.+}} -> !qecp.tanner_graph - // CHECK: [[tanner_z:%.+]] = qecp.assemble_tanner {{.+}} -> !qecp.tanner_graph + // CHECK: [[tanner_x:%.+]] = qecp.assemble_tanner {{.+}} -> !qecp.tanner_graph<24, 11, i32> + // CHECK: [[tanner_z:%.+]] = qecp.assemble_tanner {{.+}} -> !qecp.tanner_graph<24, 11, i32> // CHECK-LABEL: test_program func.func @test_program() { // CHECK: [[cb0:%.+]] = "test.op"() : () -> !qecp.codeblock<1 x 7> @@ -489,7 +493,7 @@ def test_single_qec_cycle_Steane(self, run_filecheck): // CHECK: qecp.dealloc_aux {{.*}} : !qecp.qubit // CHECK: qecp.dealloc_aux {{.*}} : !qecp.qubit // CHECK: [[esm:%.+]] = tensor.from_elements [[m0]], [[m1]], [[m2]] : tensor<3xi1> - // CHECK: [[idx_t:%.+]] = qecp.decode_esm_css([[esm]] : tensor<3xi1>) [[tanner_x]] : !qecp.tanner_graph<12, 8, i32> -> tensor<1xindex> + // CHECK: [[idx_t:%.+]] = qecp.decode_esm_css([[esm]] : tensor<3xi1>) [[tanner_x]] : !qecp.tanner_graph<24, 11, i32> -> tensor<1xindex> // CHECK: [[lb:%.+]] = arith.constant 0 : index // CHECK: [[ub:%.+]] = arith.constant 1 : index // CHECK: [[st:%.+]] = arith.constant 1 : index @@ -526,7 +530,7 @@ def test_single_qec_cycle_Steane(self, run_filecheck): // CHECK: qecp.dealloc_aux {{.*}} : !qecp.qubit // CHECK: qecp.dealloc_aux {{.*}} : !qecp.qubit // CHECK: [[esm:%.+]] = tensor.from_elements [[m0]], [[m1]], [[m2]] : tensor<3xi1> - // CHECK: [[idx_t:%.+]] = qecp.decode_esm_css([[esm]] : tensor<3xi1>) [[tanner_z]] : !qecp.tanner_graph<12, 8, i32> -> tensor<1xindex> + // CHECK: [[idx_t:%.+]] = qecp.decode_esm_css([[esm]] : tensor<3xi1>) [[tanner_z]] : !qecp.tanner_graph<24, 11, i32> -> tensor<1xindex> // CHECK: [[lb:%.+]] = arith.constant 0 : index // CHECK: [[ub:%.+]] = arith.constant 1 : index // CHECK: [[st:%.+]] = arith.constant 1 : index From 2ed3442775d116e9d48b75295e3e074df46f7c23 Mon Sep 17 00:00:00 2001 From: Joey Carter Date: Thu, 30 Apr 2026 11:17:59 -0400 Subject: [PATCH 12/75] Placate CodeFactor --- .../python_interface/transforms/qecp/tanner_graph_lib.py | 2 +- .../python_interface/transforms/qecp/test_tanner_graph_lib.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py b/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py index 3770f8cb60..dd64a04edc 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py +++ b/frontend/catalyst/python_interface/transforms/qecp/tanner_graph_lib.py @@ -62,7 +62,7 @@ def parity_check_matrix_to_tanner_csc(H: np.ndarray) -> tuple[np.ndarray, np.nda if len(H.shape) != 2: raise ValueError(f"Expected an m x n matrix, but got an array with shape {H.shape}") - m, n = H.shape # m = n_aux, n = n_data + _, n = H.shape # m = n_aux, n = n_data H_csc = scipy.sparse.csc_matrix(H) diff --git a/frontend/test/pytest/python_interface/transforms/qecp/test_tanner_graph_lib.py b/frontend/test/pytest/python_interface/transforms/qecp/test_tanner_graph_lib.py index f5ec40e9ff..eacaf9f8e2 100644 --- a/frontend/test/pytest/python_interface/transforms/qecp/test_tanner_graph_lib.py +++ b/frontend/test/pytest/python_interface/transforms/qecp/test_tanner_graph_lib.py @@ -102,4 +102,4 @@ def test_parity_check_matrix_to_tanner_csc_invalid(self, H: np.ndarray): wrong dimensions). """ with pytest.raises(ValueError, match="Expected an m x n matrix"): - row_idx, col_ptr = parity_check_matrix_to_tanner_csc(H) + _, _ = parity_check_matrix_to_tanner_csc(H) From e38d82e229d3eee54580da3c7a0632f118a9b59e Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Mon, 4 May 2026 21:52:28 -0400 Subject: [PATCH 13/75] extend EncodedMemref ABI to include dimension sizes --- frontend/catalyst/utils/libcustom_calls.cpp | 1 + .../Catalyst/Transforms/catalyst_to_llvm.cpp | 20 ++++++++++++++++--- mlir/test/Catalyst/ConversionTest.mlir | 12 ++++++++--- mlir/test/Catalyst/StaticAllocaTest.mlir | 8 ++++++-- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/frontend/catalyst/utils/libcustom_calls.cpp b/frontend/catalyst/utils/libcustom_calls.cpp index a84a4d499b..bcdb60b69a 100644 --- a/frontend/catalyst/utils/libcustom_calls.cpp +++ b/frontend/catalyst/utils/libcustom_calls.cpp @@ -28,6 +28,7 @@ struct EncodedMemref { int64_t rank; void *data_aligned; int8_t dtype; + int64_t *sizes; }; #define DEFINE_LAPACK_FUNC(FUNC_NAME, DATA_SIZE, OUT_SIZE, KERNEL) \ diff --git a/mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp b/mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp index 783a8a1e04..20d2256759 100644 --- a/mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp +++ b/mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp @@ -278,18 +278,19 @@ struct AssertionOpPattern : public OpConversionPattern { // { // i64 rank, // void* data, -// i8 type_encoding +// i8 type_encoding, +// int64_t* sizes // } Value EncodeDataMemRef(Location loc, PatternRewriter &rewriter, MemRefType memrefType, Type llvmMemrefType, Value memrefLlvm) { auto ctx = rewriter.getContext(); - // Encoded memref type: !llvm.struct<(i64, ptr, i8)>. + // Encoded memref type: !llvm.struct<(i64, ptr, i8, ptr)>. Type i8 = rewriter.getI8Type(); Type i64 = rewriter.getI64Type(); Type ptr = LLVM::LLVMPointerType::get(ctx); - auto type = LLVM::LLVMStructType::getLiteral(ctx, {i64, ptr, i8}); + auto type = LLVM::LLVMStructType::getLiteral(ctx, {i64, ptr, i8, ptr}); std::optional elementDtype = encodeNumericType(memrefType.getElementType()); @@ -315,6 +316,19 @@ Value EncodeDataMemRef(Location loc, PatternRewriter &rewriter, MemRefType memre // Dtype memref = LLVM::InsertValueOp::create(rewriter, loc, memref, dtype, SmallVector{2}); + // Sizes + int64_t rankInt = memrefType.getRank(); + Type i64ArrType = LLVM::LLVMArrayType::get(i64, rankInt); + Value sizesAlloca = getStaticAlloca(loc, rewriter, i64ArrType, 1); + for (int64_t i = 0; i < rankInt; ++i) { + Value dimSize = desc.size(rewriter, loc, i); + Value gep = LLVM::GEPOp::create(rewriter, loc, ptr, i64ArrType, sizesAlloca, + SmallVector{0LL, i}); + LLVM::StoreOp::create(rewriter, loc, dimSize, gep); + } + memref = LLVM::InsertValueOp::create(rewriter, loc, memref, sizesAlloca, + SmallVector{3}); + return memref; } diff --git a/mlir/test/Catalyst/ConversionTest.mlir b/mlir/test/Catalyst/ConversionTest.mlir index 6bbc384651..664f311342 100644 --- a/mlir/test/Catalyst/ConversionTest.mlir +++ b/mlir/test/Catalyst/ConversionTest.mlir @@ -103,17 +103,23 @@ func.func @custom_call(%arg0: memref<3x3xf64>, %arg1: memref<3x3xf64>) -> () { // CHECK: [[alloca0:%.+]] = llvm.alloca [[c1]] x !llvm.array<1 x ptr> // CHECK: [[c1:%.+]] = llvm.mlir.constant(1 : i64) - // CHECK: [[alloca1:%.+]] = llvm.alloca [[c1]] x !llvm.struct<(i64, ptr, i8)> + // CHECK: [[alloca1:%.+]] = llvm.alloca [[c1]] x !llvm.struct<(i64, ptr, i8, ptr)> + + // CHECK: [[c1:%.+]] = llvm.mlir.constant(1 : i64) + // CHECK: [[sizes0:%.+]] = llvm.alloca [[c1]] x !llvm.array<2 x i64> // CHECK: [[c1:%.+]] = llvm.mlir.constant(1 : i64) // CHECK: [[alloca2:%.+]] = llvm.alloca [[c1]] x !llvm.array<1 x ptr> // CHECK: [[c1:%.+]] = llvm.mlir.constant(1 : i64) - // CHECK: [[alloca3:%.+]] = llvm.alloca [[c1]] x !llvm.struct<(i64, ptr, i8)> + // CHECK: [[alloca3:%.+]] = llvm.alloca [[c1]] x !llvm.struct<(i64, ptr, i8, ptr)> + + // CHECK: [[c1:%.+]] = llvm.mlir.constant(1 : i64) + // CHECK: [[sizes1:%.+]] = llvm.alloca [[c1]] x !llvm.array<2 x i64> // CHECK: llvm.call @lapack_dgesdd([[alloca2]], [[alloca0]]) catalyst.custom_call fn("lapack_dgesdd") (%arg0, %arg1) {number_original_arg = 1 : i32} : (memref<3x3xf64>, memref<3x3xf64>) -> () - return + return } // ----- diff --git a/mlir/test/Catalyst/StaticAllocaTest.mlir b/mlir/test/Catalyst/StaticAllocaTest.mlir index 5c62250782..d88f252560 100644 --- a/mlir/test/Catalyst/StaticAllocaTest.mlir +++ b/mlir/test/Catalyst/StaticAllocaTest.mlir @@ -41,11 +41,15 @@ module @static_alloca_custom_call { // CHECK: [[one:%.+]] = llvm.mlir.constant(1 : i64) // CHECK: llvm.alloca [[one]] x !llvm.array<1 x ptr> // CHECK: [[one:%.+]] = llvm.mlir.constant(1 : i64) - // CHECK: llvm.alloca [[one]] x !llvm.struct<(i64, ptr, i8)> + // CHECK: llvm.alloca [[one]] x !llvm.struct<(i64, ptr, i8, ptr)> + // CHECK: [[one:%.+]] = llvm.mlir.constant(1 : i64) + // CHECK: llvm.alloca [[one]] x !llvm.array<2 x i64> // CHECK: [[one:%.+]] = llvm.mlir.constant(1 : i64) // CHECK: llvm.alloca [[one]] x !llvm.array<1 x ptr> // CHECK: [[one:%.+]] = llvm.mlir.constant(1 : i64) - // CHECK: llvm.alloca [[one]] x !llvm.struct<(i64, ptr, i8)> + // CHECK: llvm.alloca [[one]] x !llvm.struct<(i64, ptr, i8, ptr)> + // CHECK: [[one:%.+]] = llvm.mlir.constant(1 : i64) + // CHECK: llvm.alloca [[one]] x !llvm.array<2 x i64> // CHECK: ^bb1: cf.br ^bb1 ^bb1: From f8024d78ea023fb5b0f2769bd44bb28fa85db094 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 5 May 2026 09:23:00 -0400 Subject: [PATCH 14/75] Add runtime_call lowering --- frontend/catalyst/jax_primitives.py | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/frontend/catalyst/jax_primitives.py b/frontend/catalyst/jax_primitives.py index 4eaca6339c..5cf462a4de 100644 --- a/frontend/catalyst/jax_primitives.py +++ b/frontend/catalyst/jax_primitives.py @@ -75,6 +75,7 @@ AssertionOp, CallbackCallOp, CallbackOp, + CustomCallOp, PrintOp, ) from mlir_quantum.dialects.gradient import ( @@ -356,6 +357,8 @@ class MeasurementPlane(Enum): measure_in_basis_p.multiple_results = True decomprule_p = core.Primitive("decomposition_rule") decomprule_p.multiple_results = True +runtime_call_p = Primitive("runtime_call") +runtime_call_p.multiple_results = True def decomposition_rule(func=None, *, is_qreg=True, num_params=0, pauli_word=None, op_type=None): @@ -553,6 +556,52 @@ def _python_callback_lowering( return retval +# +# runtime_call +# +@runtime_call_p.def_abstract_eval +def _runtime_call_abstract_eval(*avals, kernel_descriptor): + """Infer output shapes/dtypes from the kernel descriptor.""" + return tuple( + core.ShapedArray(shape, np.dtype(dtype_str)) + for shape, dtype_str in kernel_descriptor.output_spec + ) + + +@runtime_call_p.def_impl +def _runtime_call_impl(*args, **kwargs): # pragma: no cover + raise NotImplementedError() + + +_RUNTIME_ARTIFACTS_ATTR = "catalyst.runtime_artifacts" + + +def _record_runtime_artifact(module_op, artifact_path): + """Append `artifact_path` to the module's `catalyst.runtime_artifacts` attr.""" + attrs = module_op.attributes + existing = ( + [ir.StringAttr(a).value for a in attrs[_RUNTIME_ARTIFACTS_ATTR]] + if _RUNTIME_ARTIFACTS_ATTR in attrs + else [] + ) + if artifact_path in existing: + return + existing.append(artifact_path) + attrs[_RUNTIME_ARTIFACTS_ATTR] = ir.ArrayAttr.get([ir.StringAttr.get(p) for p in existing]) + + +def _runtime_call_lowering(jax_ctx: mlir.LoweringRuleContext, *args, kernel_descriptor): + """Lower runtime_call to catalyst.custom_call and record the artifact on the enclosing module. + The artifact path is written as a catalyst.runtime_artifacts ArrayAttr on calling module. + """ + results_ty = list(convert_shaped_arrays_to_tensors(jax_ctx.avals_out)) + call_op = CustomCallOp(results_ty, list(args), kernel_descriptor.name) + + _record_runtime_artifact(jax_ctx.module_context.module.operation, kernel_descriptor.artifact) + + return call_op.results + + # # print # @@ -3100,6 +3149,7 @@ def subroutine_lowering(*args, **kwargs): (print_p, _print_lowering), (assert_p, _assert_lowering), (python_callback_p, _python_callback_lowering), + (runtime_call_p, _runtime_call_lowering), (value_and_grad_p, _value_and_grad_lowering), (set_state_p, _set_state_lowering), (set_basis_state_p, _set_basis_state_lowering), From 6c2d42c8f030f8f74d3bc103fb393ca13849009b Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Thu, 7 May 2026 17:18:57 -0400 Subject: [PATCH 15/75] add runtime_call frontend --- frontend/catalyst/__init__.py | 3 +- frontend/catalyst/compiler.py | 8 +++ frontend/catalyst/jit.py | 30 +++++++++++ frontend/catalyst/kernel.py | 98 ++++++++++++++++++++++++++++++++++ frontend/catalyst/pipelines.py | 1 + 5 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 frontend/catalyst/kernel.py diff --git a/frontend/catalyst/__init__.py b/frontend/catalyst/__init__.py index 0138e9eb1c..61be3e5d86 100644 --- a/frontend/catalyst/__init__.py +++ b/frontend/catalyst/__init__.py @@ -68,7 +68,7 @@ sys.modules["mlir_quantum.ir"] = __import__("jaxlib.mlir.ir").mlir.ir sys.modules["mlir_quantum._mlir_libs"] = __import__("jaxlib.mlir._mlir_libs").mlir._mlir_libs -from catalyst import debug, logging, passes +from catalyst import debug, kernel, logging, passes from catalyst.api_extensions import * from catalyst.api_extensions import __all__ as _api_extension_list from catalyst.autograph import * @@ -177,6 +177,7 @@ "CompileOptions", "debug", "draw_graph", + "kernel", "passes", "pipeline", *_api_extension_list, diff --git a/frontend/catalyst/compiler.py b/frontend/catalyst/compiler.py index f6c5efeba3..35347ef2d7 100644 --- a/frontend/catalyst/compiler.py +++ b/frontend/catalyst/compiler.py @@ -180,6 +180,14 @@ def get_default_flags(options): if os.path.isfile(os.path.join(rt_lib_path, "librt_OQD_capi" + file_extension)): default_flags.append("-lrt_OQD_capi") + for artifact_path in options.runtime_artifacts: + dir_name = os.path.dirname(artifact_path) + default_flags += [ + f"-Wl,-rpath,{dir_name}", + f"-L{dir_name}", + artifact_path, + ] + return default_flags @staticmethod diff --git a/frontend/catalyst/jit.py b/frontend/catalyst/jit.py index e5955a8e0c..e78db83a24 100644 --- a/frontend/catalyst/jit.py +++ b/frontend/catalyst/jit.py @@ -529,6 +529,34 @@ def entangle_all_qubits(i): ## IMPL ## +def _collect_runtime_artifacts(mlir_module, compile_options): + """Walk all nested modules and collect artifact paths into compile_options. + + Looks for catalyst.runtime_artifacts ArrayAttr on all nested modules and aggregates them so + that the linker receives the full set of artifacts. + """ + from jax._src.lib.mlir import ir as mlir_ir # pylint: disable=import-outside-toplevel + from catalyst.jax_primitives import ( # pylint: disable=import-outside-toplevel + _RUNTIME_ARTIFACTS_ATTR, + ) + + seen = set() + + def _walk(op): + attrs = op.attributes + if _RUNTIME_ARTIFACTS_ATTR in attrs: + for string_attr in attrs[_RUNTIME_ARTIFACTS_ATTR]: + path = mlir_ir.StringAttr(string_attr).value + seen.add(path) + for region in op.regions: + for block in region: + for child_op in block: + _walk(child_op) + + _walk(mlir_module.operation) + compile_options.runtime_artifacts = tuple(seen) + + # pylint: disable=too-many-instance-attributes class QJIT(CatalystCallable): """Class representing a just-in-time compiled hybrid quantum-classical function. @@ -859,6 +887,8 @@ def generate_ir(self): self.jaxpr, self.__name__, get_arg_names(self.jaxpr.in_avals, self.original_function) ) + _collect_runtime_artifacts(mlir_module, self.compile_options) + # Inject Runtime Library-specific functions (e.g. setup/teardown). inject_functions(mlir_module, ctx, self.compile_options.seed) diff --git a/frontend/catalyst/kernel.py b/frontend/catalyst/kernel.py new file mode 100644 index 0000000000..9c9cc5410f --- /dev/null +++ b/frontend/catalyst/kernel.py @@ -0,0 +1,98 @@ +# Copyright 2026 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Declarations and calls for pre-compiled external kernels callable from @qjit programs.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +import jax.numpy as jnp + + +@dataclass(frozen=True) +class KernelDescriptor: + """Describes the ABI of a pre-compiled external kernel. + + Attributes: + name: C symbol name exported by the artifact. + artifact: Absolute path to the shared library (.so / .dylib). + output_spec: Tuple of (shape_tuple, dtype_str) for each output tensor. + """ + + name: str + artifact: str # absolute path to .so / .dylib + output_spec: tuple # ((shape_tuple, dtype_str), ...) + + +def _to_hashable(spec): + """Convert a ShapeDtypeStruct or tuple of them to a hashable spec tuple.""" + specs = (spec,) if hasattr(spec, "shape") else spec + entries = [] + for s in specs: + shape = tuple(s.shape) + if any(d is None for d in shape): + raise ValueError( + f"kernel: dynamic shapes unsupported; got shape {shape}. " + "All dimensions must be static integers." + ) + entries.append((shape, str(s.dtype))) + return tuple(entries) + + +def declare(name: str, artifact: str, outputs) -> KernelDescriptor: + """Declare a pre-compiled external kernel for use with :func:`kernel.runtime_call`. + + Args: + name: C symbol exported by the artifact (e.g. ``"decode"``). + artifact: Path to the shared library. Resolved relative to ``os.getcwd()`` + if not absolute. The file must exist at declare time. + outputs: :class:`jax.ShapeDtypeStruct` or tuple of them describing each + output tensor. JAX needs these shapes at trace time to infer + what the function returns. + + Returns: + A :class:`KernelDescriptor` + """ + artifact = os.path.abspath(artifact) + if not os.path.isfile(artifact): + raise FileNotFoundError(f"kernel.declare: artifact not found: {artifact!r}") + + return KernelDescriptor( + name=name, + artifact=artifact, + output_spec=_to_hashable(outputs), + ) + + +def runtime_call(kernel_descriptor, *args): + """Call a pre-compiled external kernel from inside ``@qjit``. + + Args: + kernel_descriptor: A :class:`KernelDescriptor` returned by :func:`declare`. + *args: Input tensors. JAX infers shapes and dtypes at trace time. + + Returns: + tuple: Output tensors. For a single output, unpack explicitly:: + + (result,) = kernel.runtime_call(decode, syndrome) + + Raises: + NotImplementedError: On use under ``jax.grad`` or ``jax.vmap``. + """ + from catalyst.jax_primitives import runtime_call_p # pylint: disable=import-outside-toplevel + + jax_args = [jnp.asarray(a) for a in args] + return tuple(runtime_call_p.bind(*jax_args, kernel_descriptor=kernel_descriptor)) diff --git a/frontend/catalyst/pipelines.py b/frontend/catalyst/pipelines.py index 241b37a42d..fded1038f9 100644 --- a/frontend/catalyst/pipelines.py +++ b/frontend/catalyst/pipelines.py @@ -170,6 +170,7 @@ class CompileOptions: dialect_plugins: Optional[Set[Path]] = None capture: bool | Literal["global"] = "global" skip_preprocess: bool = False + runtime_artifacts: tuple = () def __post_init__(self): # Convert keep_intermediate to Enum From a118ec289ff29b2c5918e6e377adc37f954a3799 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 8 May 2026 11:43:11 -0400 Subject: [PATCH 16/75] small fix --- mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp b/mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp index 20d2256759..edf13c8136 100644 --- a/mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp +++ b/mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp @@ -323,7 +323,7 @@ Value EncodeDataMemRef(Location loc, PatternRewriter &rewriter, MemRefType memre for (int64_t i = 0; i < rankInt; ++i) { Value dimSize = desc.size(rewriter, loc, i); Value gep = LLVM::GEPOp::create(rewriter, loc, ptr, i64ArrType, sizesAlloca, - SmallVector{0LL, i}); + SmallVector{0, static_cast(i)}); LLVM::StoreOp::create(rewriter, loc, dimSize, gep); } memref = LLVM::InsertValueOp::create(rewriter, loc, memref, sizesAlloca, From f5adc080e763718a8f8a484c0227069d96f90b89 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 8 May 2026 12:05:27 -0400 Subject: [PATCH 17/75] add test --- .../test/pytest/runtime_call/libxor_ref.c | 37 ++++++ .../pytest/runtime_call/test_runtime_call.py | 107 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 frontend/test/pytest/runtime_call/libxor_ref.c create mode 100644 frontend/test/pytest/runtime_call/test_runtime_call.py diff --git a/frontend/test/pytest/runtime_call/libxor_ref.c b/frontend/test/pytest/runtime_call/libxor_ref.c new file mode 100644 index 0000000000..05ab1bab20 --- /dev/null +++ b/frontend/test/pytest/runtime_call/libxor_ref.c @@ -0,0 +1,37 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include + +struct EncodedMemref { + int64_t rank; + void *data_aligned; + int8_t dtype; + int64_t *sizes; +}; + +void xor_reduce(void **args, void **results) +{ + struct EncodedMemref *in = (struct EncodedMemref *)args[0]; + struct EncodedMemref *out = (struct EncodedMemref *)results[0]; + + int8_t *in_data = (int8_t *)in->data_aligned; + int32_t *out_data = (int32_t *)out->data_aligned; + int64_t n = in->sizes[0]; + int32_t acc = 0; + for (int64_t i = 0; i < n; ++i) + acc ^= (int32_t)in_data[i]; + out_data[0] = acc; +} diff --git a/frontend/test/pytest/runtime_call/test_runtime_call.py b/frontend/test/pytest/runtime_call/test_runtime_call.py new file mode 100644 index 0000000000..41acbb01a4 --- /dev/null +++ b/frontend/test/pytest/runtime_call/test_runtime_call.py @@ -0,0 +1,107 @@ +# Copyright 2026 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for kernel.declare and kernel.runtime_call.""" + +import itertools +import os +import platform +import subprocess +import tempfile + +import jax +import jax.numpy as jnp +import pytest +from jax import ShapeDtypeStruct + +from catalyst import kernel, qjit +from catalyst.kernel import KernelDescriptor + +_C_SOURCE = os.path.join(os.path.dirname(__file__), "libxor_ref.c") +_EXT = ".so" if platform.system() != "Darwin" else ".dylib" + + +@pytest.fixture(scope="module") +def libxor_ref(): + """Compile libxor_ref.c to a shared library; return its absolute path.""" + if not os.path.isfile(_C_SOURCE): + pytest.skip(f"Reference C source not found: {_C_SOURCE}") + + with tempfile.TemporaryDirectory() as tmp_dir: + lib_path = os.path.join(tmp_dir, f"libxor_ref{_EXT}") + subprocess.run( + ["cc", "-shared", "-fPIC", "-o", lib_path, _C_SOURCE], + capture_output=True, + text=True, + check=True, + ) + yield lib_path + + +@pytest.fixture(scope="module") +def xor_reduce(libxor_ref): + return kernel.declare( + "xor_reduce", + artifact=libxor_ref, + outputs=ShapeDtypeStruct((1,), jnp.int32), + ) + + +class TestKernelDeclare: + def test_basic(self, xor_reduce, libxor_ref): + assert isinstance(xor_reduce, KernelDescriptor) + assert xor_reduce.name == "xor_reduce" + assert xor_reduce.artifact == libxor_ref + assert xor_reduce.output_spec == (((1,), "int32"),) + + def test_missing_artifact(self): + with pytest.raises(FileNotFoundError, match="artifact not found"): + kernel.declare( + "xor_reduce", + artifact="/blah/libxor.so", + outputs=ShapeDtypeStruct((1,), jnp.int32), + ) + + def test_dynamic_shape_rejected(self, libxor_ref): + with pytest.raises(ValueError, match="dynamic shapes unsupported"): + kernel.declare( + "xor_reduce", + artifact=libxor_ref, + outputs=ShapeDtypeStruct((None,), jnp.int32), + ) + + def test_descriptor_is_hashable(self, xor_reduce): + assert {xor_reduce: 1}[xor_reduce] == 1 + + def test_multiple_outputs(self, libxor_ref): + desc = kernel.declare( + "xor_reduce", + artifact=libxor_ref, + outputs=(ShapeDtypeStruct((1,), jnp.int32), ShapeDtypeStruct((3,), jnp.float32)), + ) + assert len(desc.output_spec) == 2 + + +class TestRuntimeCallIntegration: + def test_xor_truth_table(self, xor_reduce): + @qjit + def circuit(x): + (result,) = kernel.runtime_call(xor_reduce, x) + return result + + import itertools # pylint: disable=import-outside-toplevel + + for bits in itertools.product([0, 1], repeat=3): + x = jnp.array(bits, dtype=jnp.int8) + assert int(circuit(x)[0]) == bits[0] ^ bits[1] ^ bits[2] From 03ea047ce3758f062b1592829ea62afb0a3c5e8e Mon Sep 17 00:00:00 2001 From: Mehrdad Malek <39844030+mehrdad2m@users.noreply.github.com> Date: Fri, 8 May 2026 12:10:11 -0400 Subject: [PATCH 18/75] Apply suggestion from @mehrdad2m --- .../catalyst/python_interface/transforms/qecp/qec_code_lib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/catalyst/python_interface/transforms/qecp/qec_code_lib.py b/frontend/catalyst/python_interface/transforms/qecp/qec_code_lib.py index 0c6fe075d0..142fdc0c70 100644 --- a/frontend/catalyst/python_interface/transforms/qecp/qec_code_lib.py +++ b/frontend/catalyst/python_interface/transforms/qecp/qec_code_lib.py @@ -14,7 +14,6 @@ """This module contains a library of QEC codes.""" -import math from dataclasses import dataclass, fields from typing import Any, Self From 45208193c8f5c4066840e89b3b04dbf87d828393 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 8 May 2026 12:26:44 -0400 Subject: [PATCH 19/75] format --- frontend/catalyst/jit.py | 3 ++- frontend/test/pytest/runtime_call/libxor_ref.c | 14 +++++++------- .../test/pytest/runtime_call/test_runtime_call.py | 2 +- mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp | 4 ++-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/frontend/catalyst/jit.py b/frontend/catalyst/jit.py index e78db83a24..88f5fea5de 100644 --- a/frontend/catalyst/jit.py +++ b/frontend/catalyst/jit.py @@ -532,10 +532,11 @@ def entangle_all_qubits(i): def _collect_runtime_artifacts(mlir_module, compile_options): """Walk all nested modules and collect artifact paths into compile_options. - Looks for catalyst.runtime_artifacts ArrayAttr on all nested modules and aggregates them so + Looks for catalyst.runtime_artifacts ArrayAttr on all nested modules and aggregates them so that the linker receives the full set of artifacts. """ from jax._src.lib.mlir import ir as mlir_ir # pylint: disable=import-outside-toplevel + from catalyst.jax_primitives import ( # pylint: disable=import-outside-toplevel _RUNTIME_ARTIFACTS_ATTR, ) diff --git a/frontend/test/pytest/runtime_call/libxor_ref.c b/frontend/test/pytest/runtime_call/libxor_ref.c index 05ab1bab20..09d420deee 100644 --- a/frontend/test/pytest/runtime_call/libxor_ref.c +++ b/frontend/test/pytest/runtime_call/libxor_ref.c @@ -16,21 +16,21 @@ #include struct EncodedMemref { - int64_t rank; - void *data_aligned; - int8_t dtype; + int64_t rank; + void *data_aligned; + int8_t dtype; int64_t *sizes; }; void xor_reduce(void **args, void **results) { - struct EncodedMemref *in = (struct EncodedMemref *)args[0]; + struct EncodedMemref *in = (struct EncodedMemref *)args[0]; struct EncodedMemref *out = (struct EncodedMemref *)results[0]; - int8_t *in_data = (int8_t *)in->data_aligned; + int8_t *in_data = (int8_t *)in->data_aligned; int32_t *out_data = (int32_t *)out->data_aligned; - int64_t n = in->sizes[0]; - int32_t acc = 0; + int64_t n = in->sizes[0]; + int32_t acc = 0; for (int64_t i = 0; i < n; ++i) acc ^= (int32_t)in_data[i]; out_data[0] = acc; diff --git a/frontend/test/pytest/runtime_call/test_runtime_call.py b/frontend/test/pytest/runtime_call/test_runtime_call.py index 41acbb01a4..5aaaeefc45 100644 --- a/frontend/test/pytest/runtime_call/test_runtime_call.py +++ b/frontend/test/pytest/runtime_call/test_runtime_call.py @@ -44,7 +44,7 @@ def libxor_ref(): ["cc", "-shared", "-fPIC", "-o", lib_path, _C_SOURCE], capture_output=True, text=True, - check=True, + check=True, ) yield lib_path diff --git a/mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp b/mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp index edf13c8136..d23e23cdc5 100644 --- a/mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp +++ b/mlir/lib/Catalyst/Transforms/catalyst_to_llvm.cpp @@ -326,8 +326,8 @@ Value EncodeDataMemRef(Location loc, PatternRewriter &rewriter, MemRefType memre SmallVector{0, static_cast(i)}); LLVM::StoreOp::create(rewriter, loc, dimSize, gep); } - memref = LLVM::InsertValueOp::create(rewriter, loc, memref, sizesAlloca, - SmallVector{3}); + memref = + LLVM::InsertValueOp::create(rewriter, loc, memref, sizesAlloca, SmallVector{3}); return memref; } From e9f80f3f23be3362f272e079b474e3d29f04ffec Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 8 May 2026 13:37:30 -0400 Subject: [PATCH 20/75] codefactor --- .../pytest/runtime_call/test_runtime_call.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/test/pytest/runtime_call/test_runtime_call.py b/frontend/test/pytest/runtime_call/test_runtime_call.py index 5aaaeefc45..0da2ef4b81 100644 --- a/frontend/test/pytest/runtime_call/test_runtime_call.py +++ b/frontend/test/pytest/runtime_call/test_runtime_call.py @@ -14,13 +14,14 @@ """Tests for kernel.declare and kernel.runtime_call.""" +# pylint: disable=redefined-outer-name + import itertools import os import platform import subprocess import tempfile -import jax import jax.numpy as jnp import pytest from jax import ShapeDtypeStruct @@ -51,6 +52,7 @@ def libxor_ref(): @pytest.fixture(scope="module") def xor_reduce(libxor_ref): + """Declare the xor_reduce kernel against the compiled shared library.""" return kernel.declare( "xor_reduce", artifact=libxor_ref, @@ -59,13 +61,17 @@ def xor_reduce(libxor_ref): class TestKernelDeclare: + """Unit tests for kernel.declare error paths and descriptor properties.""" + def test_basic(self, xor_reduce, libxor_ref): + """KernelDescriptor fields are set correctly.""" assert isinstance(xor_reduce, KernelDescriptor) assert xor_reduce.name == "xor_reduce" assert xor_reduce.artifact == libxor_ref assert xor_reduce.output_spec == (((1,), "int32"),) def test_missing_artifact(self): + """declare raises FileNotFoundError for a non-existent artifact.""" with pytest.raises(FileNotFoundError, match="artifact not found"): kernel.declare( "xor_reduce", @@ -74,6 +80,7 @@ def test_missing_artifact(self): ) def test_dynamic_shape_rejected(self, libxor_ref): + """declare raises ValueError when output shapes contain None dimensions.""" with pytest.raises(ValueError, match="dynamic shapes unsupported"): kernel.declare( "xor_reduce", @@ -82,9 +89,11 @@ def test_dynamic_shape_rejected(self, libxor_ref): ) def test_descriptor_is_hashable(self, xor_reduce): + """KernelDescriptor can be used as a dict key.""" assert {xor_reduce: 1}[xor_reduce] == 1 def test_multiple_outputs(self, libxor_ref): + """declare accepts a tuple of ShapeDtypeStructs for multiple outputs.""" desc = kernel.declare( "xor_reduce", artifact=libxor_ref, @@ -94,14 +103,16 @@ def test_multiple_outputs(self, libxor_ref): class TestRuntimeCallIntegration: + """End-to-end tests: compile, link, and execute a kernel via runtime_call.""" + def test_xor_truth_table(self, xor_reduce): + """runtime_call produces correct XOR reduction for all 3-bit inputs.""" + @qjit def circuit(x): (result,) = kernel.runtime_call(xor_reduce, x) return result - import itertools # pylint: disable=import-outside-toplevel - for bits in itertools.product([0, 1], repeat=3): x = jnp.array(bits, dtype=jnp.int8) assert int(circuit(x)[0]) == bits[0] ^ bits[1] ^ bits[2] From eeb38de5f003e27e62c26cd56ff887826dadfa1e Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Mon, 11 May 2026 14:34:53 -0400 Subject: [PATCH 21/75] fix test --- frontend/catalyst/kernel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/catalyst/kernel.py b/frontend/catalyst/kernel.py index 9c9cc5410f..d922b9622c 100644 --- a/frontend/catalyst/kernel.py +++ b/frontend/catalyst/kernel.py @@ -56,7 +56,7 @@ def declare(name: str, artifact: str, outputs) -> KernelDescriptor: """Declare a pre-compiled external kernel for use with :func:`kernel.runtime_call`. Args: - name: C symbol exported by the artifact (e.g. ``"decode"``). + name: C symbol exported by the artifact (e.g. ``"my_func"``). artifact: Path to the shared library. Resolved relative to ``os.getcwd()`` if not absolute. The file must exist at declare time. outputs: :class:`jax.ShapeDtypeStruct` or tuple of them describing each @@ -87,7 +87,7 @@ def runtime_call(kernel_descriptor, *args): Returns: tuple: Output tensors. For a single output, unpack explicitly:: - (result,) = kernel.runtime_call(decode, syndrome) + (result,) = kernel.runtime_call(my_kernel, data) Raises: NotImplementedError: On use under ``jax.grad`` or ``jax.vmap``. From 847b9ca213b759922e958d0d87b06086385b3ea1 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 12 May 2026 23:24:39 -0400 Subject: [PATCH 22/75] add compile_mlir --- frontend/catalyst/debug/__init__.py | 2 + frontend/catalyst/debug/compiler_functions.py | 113 +++++++++++++++- frontend/test/pytest/test_debug.py | 122 +++++++++++++++++- 3 files changed, 230 insertions(+), 7 deletions(-) diff --git a/frontend/catalyst/debug/__init__.py b/frontend/catalyst/debug/__init__.py index e70e96027f..798254cba4 100644 --- a/frontend/catalyst/debug/__init__.py +++ b/frontend/catalyst/debug/__init__.py @@ -17,6 +17,7 @@ from catalyst.debug.callback import callback from catalyst.debug.compiler_functions import ( compile_executable, + compile_mlir, get_cmain, get_compilation_stage, get_compilation_stages_groups, @@ -27,6 +28,7 @@ __all__ = ( "callback", + "compile_mlir", "print", "print_memref", "get_compilation_stage", diff --git a/frontend/catalyst/debug/compiler_functions.py b/frontend/catalyst/debug/compiler_functions.py index f5094532a5..a3de55735a 100644 --- a/frontend/catalyst/debug/compiler_functions.py +++ b/frontend/catalyst/debug/compiler_functions.py @@ -18,16 +18,23 @@ import logging import os +import pathlib import platform import re import shutil import subprocess +from jax._src.lib.mlir import ir + import catalyst -from catalyst.compiler import LinkerDriver +from catalyst.compiled_functions import CompiledFunction +from catalyst.compiler import Compiler, LinkerDriver from catalyst.logging import debug_logger +from catalyst.pipelines import CompileOptions from catalyst.tracing.contexts import EvaluationContext from catalyst.tracing.type_signatures import filter_static_args, promote_arguments +from catalyst.utils.filesystem import WorkspaceManager +from catalyst.utils.types import convert_numpy_dtype_to_mlir logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) @@ -55,16 +62,17 @@ def get_compilation_stage(fn, stage): .. note:: In order to use this function, ``keep_intermediate=True`` must be - set in the :func:`~.qjit` decorator of the input function. + set in the :func:`~.qjit` decorator of the input function, or + passed to :func:`~.compile_mlir`. Args: - fn (QJIT): a qjit-decorated function + fn (QJIT | CompiledMLIR): a qjit-decorated function or a :func:`~.compile_mlir` result stage (str): string corresponding with the name of the stage to be printed Returns: str: output ir from the target compiler stage - .. seealso:: :doc:`/dev/debugging`, :func:`~.replace_ir`. + .. seealso:: :doc:`/dev/debugging`, :func:`~.replace_ir`, :func:`~.compile_mlir`. **Example** @@ -92,8 +100,11 @@ def func(x: float): """ EvaluationContext.check_is_not_tracing("C interface cannot be generated from tracing context.") - if not isinstance(fn, catalyst.QJIT): - raise TypeError(f"First argument needs to be a 'QJIT' object, got a {type(fn)}.") + if not (hasattr(fn, "compiler") and hasattr(fn, "workspace")): + raise TypeError( + f"First argument needs to be a 'QJIT' object or 'CompiledMLIR' instance," + f" got a {type(fn)}." + ) return fn.compiler.get_output_of(stage, fn.workspace) @@ -307,3 +318,93 @@ def f(x): ) return output_file + + +class CompiledMLIR: + """Thin wrapper around a compiled shared object returned by :func:`~.compile_mlir`. + + Call it like a regular Python function to execute the compiled code. + Pass it to :func:`~.get_compilation_stage` to inspect intermediate IR. + """ + + def __init__(self, compiled_function, compiler, workspace): + self.compiled_function = compiled_function + self.compiler = compiler + self.workspace = workspace + + def __call__(self, *args): + return self.compiled_function(*args) + + +@debug_logger +def compile_mlir(mlir_source, *, func_name, result_types=(), **options): + r"""Compile a standalone MLIR module and return a callable. + + Reads an MLIR module from a file path or a raw string, runs it through the full + Catalyst compilation pipeline (``catalyst-cli``), and returns a :class:`CompiledMLIR` + object that can be called like a regular Python function. + + Note that the MLIR module structure should adhere to the Catalyst runtime conventions. + Therefore the module must contain ``func.func @setup()`` and ``func.func @teardown()`` lifecycle + hooks that call ``quantum.init`` / ``quantum.finalize`` (required by the Catalyst runtime + even when no quantum device is used). + + .. warning:: + + Input argument shapes and dtypes are not validated before compilation. Passing arguments + that do not match the compiled function's signature could produce incorrect results + or undefined behavior. + + Args: + mlir_source (str | pathlib.Path): Path to a ``.mlir`` file, or the raw MLIR text. + func_name (str): Name of the entry-point function (without ``@``), e.g. ``"jit_my_func"``. + The function must be annotated with ``{llvm.emit_c_interface}``. + result_types (Sequence[jax.ShapeDtypeStruct]): Output tensor types, in order. + Each element must have ``.shape`` (tuple of ints) and ``.dtype`` (numpy dtype). + Defaults to ``()`` for functions with no return values. + **options: Any :class:`~catalyst.CompileOptions` keyword argument + + Returns: + CompiledMLIR: A callable that accepts the same arguments as the compiled function + + **Example** + + .. code-block:: python + + import numpy as np + import jax + from catalyst.debug import compile_mlir, get_compilation_stage + + fn = compile_mlir( + "my_module.mlir", + func_name="jit_my_func", + result_types=[jax.ShapeDtypeStruct((), np.float64)], + keep_intermediate=True, + ) + + result = fn(np.float64(3.14)) + + print(get_compilation_stage(fn, "QuantumCompilationStage")) + """ + if isinstance(mlir_source, pathlib.Path): + ir_text = mlir_source.read_text(encoding="utf-8") + else: + ir_text = mlir_source + + compile_options = CompileOptions(**options) + keep = compile_options.keep_intermediate + preferred_dir = os.getcwd() if keep else None + workspace = WorkspaceManager.get_or_create_workspace(func_name, preferred_dir) + + compiler = Compiler(options=compile_options) + shared_object, _ = compiler.run_from_ir(ir_text, func_name, workspace) + + ctx = ir.Context() + with ctx, ir.Location.unknown(): + restype = [ + ir.RankedTensorType.get(list(s.shape), convert_numpy_dtype_to_mlir(s.dtype)) + for s in result_types + ] + + compiled_fn = CompiledFunction(shared_object, func_name, restype, None, compile_options) + return CompiledMLIR(compiled_fn, compiler, workspace) diff --git a/frontend/test/pytest/test_debug.py b/frontend/test/pytest/test_debug.py index b7d7f0624c..c4bb0cbb3c 100644 --- a/frontend/test/pytest/test_debug.py +++ b/frontend/test/pytest/test_debug.py @@ -16,6 +16,7 @@ import subprocess import textwrap +import jax import jax.numpy as jnp import numpy as np import pennylane as qp @@ -25,10 +26,34 @@ from catalyst import debug, qjit, value_and_grad from catalyst.compiler import _options_to_cli_flags, to_llvmir, to_mlir_opt -from catalyst.debug import compile_executable, get_cmain, get_compilation_stage, replace_ir +from catalyst.debug import ( + compile_executable, + compile_mlir, + get_cmain, + get_compilation_stage, + replace_ir, +) from catalyst.pipelines import CompileOptions from catalyst.utils.exceptions import CompileError +_IDENTITY_MLIR = """ +module @identity { + func.func public @jit_identity(%arg0: tensor) -> tensor attributes {llvm.emit_c_interface} { + return %arg0 : tensor + } + func.func @setup() { + quantum.init + return + } + func.func @teardown() { + quantum.finalize + return + } +} +""" + +_IDENTITY_RESULT_TYPES = [jax.ShapeDtypeStruct((), np.float64)] + class TestDebugPrint: """Test suite for the runtime print functionality.""" @@ -632,5 +657,100 @@ def test_catalyst_error(self): to_mlir_opt(stdin=mlir) +class TestCompileMLIR: + """Tests for compile_mlir tocompile and run a standalone MLIR module.""" + + def test_compile_from_string(self): + """compile_mlir accepts a raw MLIR string and returns a callable.""" + fn = compile_mlir( + _IDENTITY_MLIR, func_name="jit_identity", result_types=_IDENTITY_RESULT_TYPES + ) + result = fn(np.float64(1.5)) + np.testing.assert_allclose(result[0], 1.5) + + def test_get_compilation_stage(self): + """get_compilation_stage works on a CompiledMLIR object.""" + fn = compile_mlir( + _IDENTITY_MLIR, + func_name="jit_identity", + result_types=_IDENTITY_RESULT_TYPES, + keep_intermediate=True, + ) + ir = get_compilation_stage(fn, "HLOLoweringStage") + assert "@jit_identity" in ir + fn.workspace.cleanup() + + def test_no_outputs(self): + """compile_mlir handles functions with no return values.""" + no_return_mlir = """ + module @noop { + func.func public @jit_noop() attributes {llvm.emit_c_interface} { + return + } + func.func @setup() { quantum.init return } + func.func @teardown() { quantum.finalize return } + } + """ + fn = compile_mlir(no_return_mlir, func_name="jit_noop") + fn() + + def test_ranked_tensor(self): + """compile_mlir handles ranked tensor inputs and outputs.""" + ranked_mlir = """ + module @ranked { + func.func public @jit_ranked(%arg0: tensor<3xf64>) -> tensor<3xf64> + attributes {llvm.emit_c_interface} { + return %arg0 : tensor<3xf64> + } + func.func @setup() { quantum.init return } + func.func @teardown() { quantum.finalize return } + } + """ + fn = compile_mlir( + ranked_mlir, + func_name="jit_ranked", + result_types=[jax.ShapeDtypeStruct((3,), np.float64)], + ) + result = fn(np.array([1.0, 2.0, 3.0])) + np.testing.assert_allclose(result[0], [1.0, 2.0, 3.0]) + + def test_multiple_inputs_outputs(self): + """compile_mlir handles multiple inputs and outputs (returns them swapped).""" + multi_mlir = """ + module @multi { + func.func public @jit_multi(%arg0: tensor, %arg1: tensor) + -> (tensor, tensor) attributes {llvm.emit_c_interface} { + return %arg1, %arg0 : tensor, tensor + } + func.func @setup() { quantum.init return } + func.func @teardown() { quantum.finalize return } + } + """ + fn = compile_mlir( + multi_mlir, + func_name="jit_multi", + result_types=[ + jax.ShapeDtypeStruct((), np.float64), + jax.ShapeDtypeStruct((), np.float64), + ], + ) + result = fn(np.float64(1.0), np.float64(2.0)) + np.testing.assert_allclose(result[0], 2.0) + np.testing.assert_allclose(result[1], 1.0) + + def test_compile_from_pathlib(self, tmp_path): + """compile_mlir accepts a pathlib.Path without needing a pre-existing fixture file.""" + mlir_file = tmp_path / "identity.mlir" + mlir_file.write_text(_IDENTITY_MLIR, encoding="utf-8") + fn = compile_mlir(mlir_file, func_name="jit_identity", result_types=_IDENTITY_RESULT_TYPES) + result = fn(np.float64(4.0)) + np.testing.assert_allclose(result[0], 4.0) + + def test_bad_ir_raises(self): + """compile_mlir raises on invalid MLIR.""" + with pytest.raises(Exception): + compile_mlir("this is not valid mlir", func_name="jit_foo") + + if __name__ == "__main__": pytest.main(["-x", __file__]) From 68aefa584e0b77f9186af4c94129739811fe98cf Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Wed, 13 May 2026 08:58:26 -0400 Subject: [PATCH 23/75] fix condition --- frontend/catalyst/debug/compiler_functions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/catalyst/debug/compiler_functions.py b/frontend/catalyst/debug/compiler_functions.py index a3de55735a..cb4e331342 100644 --- a/frontend/catalyst/debug/compiler_functions.py +++ b/frontend/catalyst/debug/compiler_functions.py @@ -100,7 +100,7 @@ def func(x: float): """ EvaluationContext.check_is_not_tracing("C interface cannot be generated from tracing context.") - if not (hasattr(fn, "compiler") and hasattr(fn, "workspace")): + if not isinstance(fn, (catalyst.QJIT, CompiledMLIR)): raise TypeError( f"First argument needs to be a 'QJIT' object or 'CompiledMLIR' instance," f" got a {type(fn)}." From caa6e17eae785e04685dd6861b725a5f8d4b27b1 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Wed, 13 May 2026 09:16:52 -0400 Subject: [PATCH 24/75] add changelog --- doc/releases/changelog-dev.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md index b2702afae8..ee9a90dbe5 100644 --- a/doc/releases/changelog-dev.md +++ b/doc/releases/changelog-dev.md @@ -8,6 +8,10 @@

Improvements 🛠

+* A new `catalyst.debug.compile_mlir` function has been added, allowing standalone MLIR files to be + compiled through the full Catalyst pipeline and returned as a callable Python object. + [(#2832)](https://github.com/PennyLaneAI/catalyst/pull/2832) +

Breaking changes 💔

Deprecations 👋

@@ -31,4 +35,5 @@ This release contains contributions from (in alphabetical order): Lillian Frederiksen, +Mehrdad Malekmohammadi, Shuli Shu, From cfb7a9849f8a1f9a2e7695fc46264251cf03fde7 Mon Sep 17 00:00:00 2001 From: Mehrdad Malek <39844030+mehrdad2m@users.noreply.github.com> Date: Wed, 13 May 2026 17:13:21 -0400 Subject: [PATCH 25/75] Apply suggestion from @joeycarter Co-authored-by: Joey Carter --- frontend/catalyst/debug/compiler_functions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/catalyst/debug/compiler_functions.py b/frontend/catalyst/debug/compiler_functions.py index cb4e331342..fa5fc676b5 100644 --- a/frontend/catalyst/debug/compiler_functions.py +++ b/frontend/catalyst/debug/compiler_functions.py @@ -337,7 +337,7 @@ def __call__(self, *args): @debug_logger -def compile_mlir(mlir_source, *, func_name, result_types=(), **options): +def compile_mlir(mlir_source, *, func_name, result_types, **options): r"""Compile a standalone MLIR module and return a callable. Reads an MLIR module from a file path or a raw string, runs it through the full From 52e8145e5fe77b441ccf859831817f2120e2d1f7 Mon Sep 17 00:00:00 2001 From: Mehrdad Malek <39844030+mehrdad2m@users.noreply.github.com> Date: Wed, 13 May 2026 17:14:04 -0400 Subject: [PATCH 26/75] Apply suggestion from @mehrdad2m --- frontend/catalyst/debug/compiler_functions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/catalyst/debug/compiler_functions.py b/frontend/catalyst/debug/compiler_functions.py index fa5fc676b5..4b01a9c417 100644 --- a/frontend/catalyst/debug/compiler_functions.py +++ b/frontend/catalyst/debug/compiler_functions.py @@ -361,7 +361,6 @@ def compile_mlir(mlir_source, *, func_name, result_types, **options): The function must be annotated with ``{llvm.emit_c_interface}``. result_types (Sequence[jax.ShapeDtypeStruct]): Output tensor types, in order. Each element must have ``.shape`` (tuple of ints) and ``.dtype`` (numpy dtype). - Defaults to ``()`` for functions with no return values. **options: Any :class:`~catalyst.CompileOptions` keyword argument Returns: From 6fb3e23bb9cc94dfeeb91d13e7f529f3d8581d86 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Wed, 13 May 2026 19:11:02 -0400 Subject: [PATCH 27/75] support skipping inlining nested modules --- .../Transforms/InlineNestedModules.cpp | 64 ++++++++--- mlir/test/Catalyst/NestedModuleTarget.mlir | 108 ++++++++++++++++++ 2 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 mlir/test/Catalyst/NestedModuleTarget.mlir diff --git a/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp b/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp index a3a0986cfc..1a0b4e0419 100644 --- a/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp +++ b/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp @@ -289,8 +289,8 @@ struct InlineNestedModule : public RewritePattern { LogicalResult matchAndRewrite(Operation *op, PatternRewriter &rewriter) const override { bool isSymbolTable = op->hasTrait(); - // TODO: isQnode - bool mustInline = isSymbolTable; + // Modules annotated with catalyst.target are compiled separately; skip inlining. + bool mustInline = isSymbolTable && !op->hasAttr("catalyst.target"); if (!mustInline) { return failure(); } @@ -417,7 +417,8 @@ struct CleanupPattern : public RewritePattern { { bool hasQualifiedName = op->hasAttr(fullyQualifiedNameAttr); bool hasQNodeAttr = op->hasAttr(quantumNodeAttr); - if (!hasQualifiedName && !hasQNodeAttr) { + bool hasBeenRenamed = op->hasAttr(hasBeenRenamedAttrName); + if (!hasQualifiedName && !hasQNodeAttr && !hasBeenRenamed) { return failure(); } @@ -429,6 +430,9 @@ struct CleanupPattern : public RewritePattern { if (hasQualifiedName) { op->removeAttr(fullyQualifiedNameAttr); } + if (hasBeenRenamed) { + op->removeAttr(hasBeenRenamedAttrName); + } }); return success(); @@ -513,21 +517,49 @@ struct InlineNestedSymbolTablePass : PassWrapper old_to_new; - for (auto ®ion : symbolTable->getRegions()) { - for (auto &block : region.getBlocks()) { - for (auto &op : block) { - if (!isa(op)) - continue; - auto hasQualifiedName = op.hasAttr(fullyQualifiedNameAttr); - if (!hasQualifiedName) - continue; + bool emitDecls = _stopAfterStep >= 4 || _stopAfterStep == 0; + SmallVector targetDeclarations; + llvm::SmallSet rootSymbols; + if (emitDecls) { + for (Operation &rootOp : symbolTable->getRegion(0).front()) { + if (auto symbol = dyn_cast(rootOp)) + rootSymbols.insert(symbol.getName()); + } + } - SymbolRefAttr old = op.getAttrOfType(fullyQualifiedNameAttr); - auto symbol = cast(op); - SymbolRefAttr _new = SymbolRefAttr::get(symbol); - old_to_new.insert({old, _new}); - } + symbolTable->walk([&](Operation *nested) { + if (nested == symbolTable) + return WalkResult::advance(); + // Recurse into target modules (not inlined); skip other nested modules (inlined). + if (auto mod = dyn_cast(nested)) + return mod->hasAttr("catalyst.target") ? WalkResult::advance() : WalkResult::skip(); + if (!isa(nested) || !nested->hasAttr(fullyQualifiedNameAttr)) + return WalkResult::advance(); + SymbolRefAttr old = nested->getAttrOfType(fullyQualifiedNameAttr); + SymbolRefAttr _new = SymbolRefAttr::get(cast(nested)); + old_to_new.insert({old, _new}); + // For functions remaining inside a target module, emit an external declaration in + // the root so the flat call inserted by NestedToFlatCallPattern is visible. + if (emitDecls && isa(nested) && nested->getParentOp() != symbolTable) { + auto func = cast(nested); + StringRef name = func.getName(); + if (!rootSymbols.insert(name).second) + return WalkResult::advance(); + + auto decl = cast(func->clone()); + decl.eraseBody(); + decl->removeAttr(fullyQualifiedNameAttr); + decl.setPrivate(); + targetDeclarations.push_back(decl); } + return WalkResult::advance(); + }); + + if (!targetDeclarations.empty()) { + OpBuilder declBuilder(&symbolTable->getRegion(0).front(), + symbolTable->getRegion(0).front().begin()); + for (func::FuncOp decl : targetDeclarations) + declBuilder.insert(decl); } RewritePatternSet nestedToFlat(context); diff --git a/mlir/test/Catalyst/NestedModuleTarget.mlir b/mlir/test/Catalyst/NestedModuleTarget.mlir new file mode 100644 index 0000000000..25777dfc08 --- /dev/null +++ b/mlir/test/Catalyst/NestedModuleTarget.mlir @@ -0,0 +1,108 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Verify that modules annotated with catalyst.target are not inlined. + +// RUN: quantum-opt --inline-nested-module --split-input-file %s | FileCheck %s + + +// CHECK-LABEL: module @host +module @host { + // CHECK: func.func private @ghz_0() + // CHECK: func.func public @jit_main + func.func public @jit_main() attributes {llvm.emit_c_interface} { + // CHECK-NOT: catalyst.launch_kernel + // CHECK: call @ghz_0() + catalyst.launch_kernel @module_accel::@ghz() : () -> () + func.return + } + + // CHECK: module @module_accel attributes {catalyst.target + // CHECK-NOT: catalyst.unique_names + // CHECK: func.func @ghz_0() + module @module_accel attributes {catalyst.target = {backend = "accel"}} { + func.func @ghz() { + func.return + } + } + + func.func @setup() { func.return } + func.func @teardown() { func.return } +} + +// ----- + +// CHECK-LABEL: module @mixed +// CHECK: func.func private @kernel_0() +// CHECK: func.func public @jit_run +// CHECK: call @work_0() +// CHECK: call @kernel_0() +// CHECK-NOT: module @module_cpu +// CHECK: module @module_accel2 attributes {catalyst.target +// CHECK-NOT: catalyst.unique_names +module @mixed { + + func.func public @jit_run() attributes {llvm.emit_c_interface} { + catalyst.launch_kernel @module_cpu::@work() : () -> () + catalyst.launch_kernel @module_accel2::@kernel() : () -> () + func.return + } + + module @module_cpu { + func.func @work() { + func.return + } + } + + module @module_accel2 attributes {catalyst.target = {backend = "accel"}} { + func.func @kernel() { + func.return + } + } + + func.func @setup() { func.return } + func.func @teardown() { func.return } +} + +// ----- + +// CHECK-LABEL: module @duplicate_target_names +// CHECK-DAG: func.func private @kernel_1() attributes {attr = "value"} +// CHECK-DAG: func.func private @kernel_0() +// CHECK: func.func public @jit_run +// CHECK: call @kernel_1() +// CHECK: call @kernel_0() +// CHECK-DAG: module @module_accel_a attributes {catalyst.target +// CHECK-DAG: module @module_accel_b attributes {catalyst.target +// CHECK-NOT: catalyst.unique_names +module @duplicate_target_names { + + func.func public @jit_run() attributes {llvm.emit_c_interface} { + catalyst.launch_kernel @module_accel_a::@kernel() : () -> () + catalyst.launch_kernel @module_accel_b::@kernel() : () -> () + func.return + } + + module @module_accel_a attributes {catalyst.target = {backend = "accel"}} { + func.func @kernel() attributes {attr = "value"} { + func.return + } + } + + module @module_accel_b attributes {catalyst.target = {backend = "accel"}} { + func.func @kernel() { + func.return + } + } +} From cb4b16c8fb929f71958e55677400c5a2d95efe1a Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Wed, 13 May 2026 19:17:07 -0400 Subject: [PATCH 28/75] fix test --- frontend/test/pytest/test_debug.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/test/pytest/test_debug.py b/frontend/test/pytest/test_debug.py index c4bb0cbb3c..6d197b7003 100644 --- a/frontend/test/pytest/test_debug.py +++ b/frontend/test/pytest/test_debug.py @@ -691,7 +691,7 @@ def test_no_outputs(self): func.func @teardown() { quantum.finalize return } } """ - fn = compile_mlir(no_return_mlir, func_name="jit_noop") + fn = compile_mlir(no_return_mlir, func_name="jit_noop", result_types=()) fn() def test_ranked_tensor(self): From c73cf13fea12b4575c0559b222000b130e98b695 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Wed, 13 May 2026 20:32:48 -0400 Subject: [PATCH 29/75] keep the attributes on the decleration --- mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp | 2 ++ mlir/test/Catalyst/NestedModuleTarget.mlir | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp b/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp index 1a0b4e0419..a0817e336b 100644 --- a/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp +++ b/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp @@ -550,6 +550,8 @@ struct InlineNestedSymbolTablePass : PassWrapperremoveAttr(fullyQualifiedNameAttr); decl.setPrivate(); + if (auto targetAttr = nested->getParentOp()->getAttr("catalyst.target")) + decl->setAttr("catalyst.target", targetAttr); targetDeclarations.push_back(decl); } return WalkResult::advance(); diff --git a/mlir/test/Catalyst/NestedModuleTarget.mlir b/mlir/test/Catalyst/NestedModuleTarget.mlir index 25777dfc08..1e2f6589eb 100644 --- a/mlir/test/Catalyst/NestedModuleTarget.mlir +++ b/mlir/test/Catalyst/NestedModuleTarget.mlir @@ -19,7 +19,7 @@ // CHECK-LABEL: module @host module @host { - // CHECK: func.func private @ghz_0() + // CHECK: func.func private @ghz_0() attributes {catalyst.target = {backend = "accel"}} // CHECK: func.func public @jit_main func.func public @jit_main() attributes {llvm.emit_c_interface} { // CHECK-NOT: catalyst.launch_kernel @@ -44,7 +44,7 @@ module @host { // ----- // CHECK-LABEL: module @mixed -// CHECK: func.func private @kernel_0() +// CHECK: func.func private @kernel_0() attributes {catalyst.target = {backend = "accel"}} // CHECK: func.func public @jit_run // CHECK: call @work_0() // CHECK: call @kernel_0() @@ -78,8 +78,8 @@ module @mixed { // ----- // CHECK-LABEL: module @duplicate_target_names -// CHECK-DAG: func.func private @kernel_1() attributes {attr = "value"} -// CHECK-DAG: func.func private @kernel_0() +// CHECK-DAG: func.func private @kernel_1() attributes {attr = "value", catalyst.target = {backend = "accel"}} +// CHECK-DAG: func.func private @kernel_0() attributes {catalyst.target = {backend = "accel"}} // CHECK: func.func public @jit_run // CHECK: call @kernel_1() // CHECK: call @kernel_0() From 0685a827908e2d3c5e789eebacc7df05cdff33a0 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 15 May 2026 16:23:09 -0400 Subject: [PATCH 30/75] refactor --- frontend/catalyst/debug/compiler_functions.py | 5 ++ frontend/catalyst/jax_primitives.py | 20 +---- frontend/catalyst/jit.py | 32 +------- frontend/catalyst/utils/runtime_artifacts.py | 82 +++++++++++++++++++ 4 files changed, 91 insertions(+), 48 deletions(-) create mode 100644 frontend/catalyst/utils/runtime_artifacts.py diff --git a/frontend/catalyst/debug/compiler_functions.py b/frontend/catalyst/debug/compiler_functions.py index 4b01a9c417..c85919b4e9 100644 --- a/frontend/catalyst/debug/compiler_functions.py +++ b/frontend/catalyst/debug/compiler_functions.py @@ -34,6 +34,7 @@ from catalyst.tracing.contexts import EvaluationContext from catalyst.tracing.type_signatures import filter_static_args, promote_arguments from catalyst.utils.filesystem import WorkspaceManager +from catalyst.utils.runtime_artifacts import collect_runtime_artifacts_from_text from catalyst.utils.types import convert_numpy_dtype_to_mlir logger = logging.getLogger(__name__) @@ -391,6 +392,10 @@ def compile_mlir(mlir_source, *, func_name, result_types, **options): ir_text = mlir_source compile_options = CompileOptions(**options) + + if not compile_options.runtime_artifacts: + collect_runtime_artifacts_from_text(ir_text, compile_options) + keep = compile_options.keep_intermediate preferred_dir = os.getcwd() if keep else None workspace = WorkspaceManager.get_or_create_workspace(func_name, preferred_dir) diff --git a/frontend/catalyst/jax_primitives.py b/frontend/catalyst/jax_primitives.py index 5cf462a4de..79d4c6cb27 100644 --- a/frontend/catalyst/jax_primitives.py +++ b/frontend/catalyst/jax_primitives.py @@ -59,6 +59,7 @@ # pylint: disable=ungrouped-imports from catalyst.jax_extras.patches import mock_attributes from catalyst.utils.patching import Patcher +from catalyst.utils.runtime_artifacts import record_runtime_artifact with Patcher( ( @@ -573,23 +574,6 @@ def _runtime_call_impl(*args, **kwargs): # pragma: no cover raise NotImplementedError() -_RUNTIME_ARTIFACTS_ATTR = "catalyst.runtime_artifacts" - - -def _record_runtime_artifact(module_op, artifact_path): - """Append `artifact_path` to the module's `catalyst.runtime_artifacts` attr.""" - attrs = module_op.attributes - existing = ( - [ir.StringAttr(a).value for a in attrs[_RUNTIME_ARTIFACTS_ATTR]] - if _RUNTIME_ARTIFACTS_ATTR in attrs - else [] - ) - if artifact_path in existing: - return - existing.append(artifact_path) - attrs[_RUNTIME_ARTIFACTS_ATTR] = ir.ArrayAttr.get([ir.StringAttr.get(p) for p in existing]) - - def _runtime_call_lowering(jax_ctx: mlir.LoweringRuleContext, *args, kernel_descriptor): """Lower runtime_call to catalyst.custom_call and record the artifact on the enclosing module. The artifact path is written as a catalyst.runtime_artifacts ArrayAttr on calling module. @@ -597,7 +581,7 @@ def _runtime_call_lowering(jax_ctx: mlir.LoweringRuleContext, *args, kernel_desc results_ty = list(convert_shaped_arrays_to_tensors(jax_ctx.avals_out)) call_op = CustomCallOp(results_ty, list(args), kernel_descriptor.name) - _record_runtime_artifact(jax_ctx.module_context.module.operation, kernel_descriptor.artifact) + record_runtime_artifact(jax_ctx.module_context.module.operation, kernel_descriptor.artifact) return call_op.results diff --git a/frontend/catalyst/jit.py b/frontend/catalyst/jit.py index 3ec76d061e..e66e36df37 100644 --- a/frontend/catalyst/jit.py +++ b/frontend/catalyst/jit.py @@ -58,6 +58,7 @@ from catalyst.utils.exceptions import CompileError from catalyst.utils.filesystem import WorkspaceManager from catalyst.utils.gen_mlir import inject_functions +from catalyst.utils.runtime_artifacts import collect_runtime_artifacts from catalyst.utils.patching import Patcher logger = logging.getLogger(__name__) @@ -529,35 +530,6 @@ def entangle_all_qubits(i): ## IMPL ## -def _collect_runtime_artifacts(mlir_module, compile_options): - """Walk all nested modules and collect artifact paths into compile_options. - - Looks for catalyst.runtime_artifacts ArrayAttr on all nested modules and aggregates them so - that the linker receives the full set of artifacts. - """ - from jax._src.lib.mlir import ir as mlir_ir # pylint: disable=import-outside-toplevel - - from catalyst.jax_primitives import ( # pylint: disable=import-outside-toplevel - _RUNTIME_ARTIFACTS_ATTR, - ) - - seen = set() - - def _walk(op): - attrs = op.attributes - if _RUNTIME_ARTIFACTS_ATTR in attrs: - for string_attr in attrs[_RUNTIME_ARTIFACTS_ATTR]: - path = mlir_ir.StringAttr(string_attr).value - seen.add(path) - for region in op.regions: - for block in region: - for child_op in block: - _walk(child_op) - - _walk(mlir_module.operation) - compile_options.runtime_artifacts = tuple(seen) - - # pylint: disable=too-many-instance-attributes class QJIT(CatalystCallable): """Class representing a just-in-time compiled hybrid quantum-classical function. @@ -896,7 +868,7 @@ def generate_ir(self): self.jaxpr, self.__name__, get_arg_names(self.jaxpr.in_avals, self.original_function) ) - _collect_runtime_artifacts(mlir_module, self.compile_options) + collect_runtime_artifacts(mlir_module, self.compile_options) # Inject Runtime Library-specific functions (e.g. setup/teardown). inject_functions(mlir_module, ctx, self.compile_options.seed) diff --git a/frontend/catalyst/utils/runtime_artifacts.py b/frontend/catalyst/utils/runtime_artifacts.py new file mode 100644 index 0000000000..59e324ed3e --- /dev/null +++ b/frontend/catalyst/utils/runtime_artifacts.py @@ -0,0 +1,82 @@ +# Copyright 2026 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Helpers for the ``catalyst.runtime_artifacts`` module attribute. + +The attribute records shared-library paths that compiled programs need at link time. The write +side is used by JAX primitive lowering (when emitting a ``catalyst.custom_call`` that targets an +external symbol); the read side is used by the compiler driver to populate the link command. +""" + +import re + +from jax._src.lib.mlir import ir + +RUNTIME_ARTIFACTS_ATTR = "catalyst.runtime_artifacts" + + +def record_runtime_artifact(module_op, artifact_path): + """Append `artifact_path` to the module's `catalyst.runtime_artifacts` attr.""" + attrs = module_op.attributes + existing = ( + [ir.StringAttr(a).value for a in attrs[RUNTIME_ARTIFACTS_ATTR]] + if RUNTIME_ARTIFACTS_ATTR in attrs + else [] + ) + if artifact_path in existing: + return + existing.append(artifact_path) + attrs[RUNTIME_ARTIFACTS_ATTR] = ir.ArrayAttr.get([ir.StringAttr.get(p) for p in existing]) + + +def collect_runtime_artifacts(mlir_module, compile_options): + """Walk all nested modules and collect artifact paths into compile_options. + + Looks for catalyst.runtime_artifacts ArrayAttr on all nested modules and aggregates them so + that the linker receives the full set of artifacts. + """ + seen = set() + + def _walk(op): + attrs = op.attributes + if RUNTIME_ARTIFACTS_ATTR in attrs: + for string_attr in attrs[RUNTIME_ARTIFACTS_ATTR]: + seen.add(ir.StringAttr(string_attr).value) + for region in op.regions: + for block in region: + for child_op in block: + _walk(child_op) + + _walk(mlir_module.operation) + compile_options.runtime_artifacts = tuple(seen) + + +_RUNTIME_ARTIFACTS_TEXT_RE = re.compile( + rf"{re.escape(RUNTIME_ARTIFACTS_ATTR)}\s*=\s*\[([^\]]*)\]" +) +_RUNTIME_ARTIFACTS_PATH_RE = re.compile(r'"([^"]*)"') + + +def collect_runtime_artifacts_from_text(ir_text, compile_options): + """Text-mode sibling of `collect_runtime_artifacts`. + + Used by entry points that only have raw MLIR text (e.g. `compile_mlir`), where parsing into + an `ir.Module` would require registering every Catalyst/StableHLO/etc. dialect on a fresh + context. Scans the text for all `catalyst.runtime_artifacts = [...]` occurrences (handling + nested modules) and populates `compile_options.runtime_artifacts`. + """ + seen = set() + for match in _RUNTIME_ARTIFACTS_TEXT_RE.finditer(ir_text): + seen.update(_RUNTIME_ARTIFACTS_PATH_RE.findall(match.group(1))) + compile_options.runtime_artifacts = tuple(seen) From 206252eade33d76d68cf67c4e860e7f828acf319 Mon Sep 17 00:00:00 2001 From: Hong-Sheng Zheng Date: Thu, 28 May 2026 15:46:30 -0400 Subject: [PATCH 31/75] Catch exceptions thrown from setup/teardown --- frontend/catalyst/compiled_functions.py | 8 ++--- frontend/catalyst/utils/wrapper.cpp | 41 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/frontend/catalyst/compiled_functions.py b/frontend/catalyst/compiled_functions.py index db9c196e6e..31a8b0253c 100644 --- a/frontend/catalyst/compiled_functions.py +++ b/frontend/catalyst/compiled_functions.py @@ -109,15 +109,11 @@ def load_symbols(self): return function, setup, teardown, mem_transfer def __enter__(self): - params_to_setup = [b"jitted-function"] - argc = len(params_to_setup) - array_of_char_ptrs = (ctypes.c_char_p * len(params_to_setup))() - array_of_char_ptrs[:] = params_to_setup - self.setup(ctypes.c_int(argc), array_of_char_ptrs) + wrapper.invoke_setup(self.setup, ["jitted-function"]) return self def __exit__(self, _type, _value, _traceback): - self.teardown() + wrapper.invoke_teardown(self.teardown) class CompiledFunction: diff --git a/frontend/catalyst/utils/wrapper.cpp b/frontend/catalyst/utils/wrapper.cpp index f9e5c29b8f..2b9164224e 100644 --- a/frontend/catalyst/utils/wrapper.cpp +++ b/frontend/catalyst/utils/wrapper.cpp @@ -13,8 +13,12 @@ // limitations under the License. #include +#include +#include #include "nanobind/nanobind.h" +#include "nanobind/stl/string.h" +#include "nanobind/stl/vector.h" // TODO: Periodically check and increment version. // https://endoflife.date/numpy @@ -230,6 +234,37 @@ nb::list wrap(nb::object func, nb::tuple py_args, nb::object result_desc, nb::ob return returns; } +template +static FnPtr extract_fn_ptr(nb::object fn_obj) +{ + auto ctypes = nb::module_::import_("ctypes"); + return *reinterpret_cast(nb::cast(ctypes.attr("addressof")(fn_obj))); +} + +void invoke_setup(nb::object setup_fn, std::vector argv) +{ + using setup_fn_t = void (*)(int, char **); + setup_fn_t fn = extract_fn_ptr(setup_fn); + + std::vector argv_c; + argv_c.reserve(argv.size()); + for (auto &s : argv) { + argv_c.push_back(s.data()); + } + + nb::gil_scoped_release lock; + fn(static_cast(argv.size()), argv_c.data()); +} + +void invoke_teardown(nb::object teardown_fn) +{ + using teardown_fn_t = void (*)(); + teardown_fn_t fn = extract_fn_ptr(teardown_fn); + + nb::gil_scoped_release lock; + fn(); +} + NB_MODULE(wrapper, m) { m.doc() = "wrapper module"; @@ -237,6 +272,12 @@ NB_MODULE(wrapper, m) // See https://nanobind.readthedocs.io/en/latest/functions.html#none-arguments m.def("wrap", &wrap, "A wrapper function.", nb::arg("func"), nb::arg("py_args"), nb::arg("result_desc").none(), nb::arg("transfer"), nb::arg("numpy_arrays")); + + m.def("invoke_setup", &invoke_setup, "Call the JIT'd setup(argc, argv)", nb::arg("setup_fn"), + nb::arg("argv")); + + m.def("invoke_teardown", &invoke_teardown, "Call the JIT'd teardown()", nb::arg("teardown_fn")); + int retval = _import_array(); bool success = retval >= 0; if (!success) { From 906c83c563782cef602462779923b00ca6b504d9 Mon Sep 17 00:00:00 2001 From: Hong-Sheng Zheng Date: Thu, 28 May 2026 16:44:53 -0400 Subject: [PATCH 32/75] update --- frontend/catalyst/utils/wrapper.cpp | 3 +- .../test/pytest/test_wrapper_exceptions.py | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 frontend/test/pytest/test_wrapper_exceptions.py diff --git a/frontend/catalyst/utils/wrapper.cpp b/frontend/catalyst/utils/wrapper.cpp index 2b9164224e..38548e2e33 100644 --- a/frontend/catalyst/utils/wrapper.cpp +++ b/frontend/catalyst/utils/wrapper.cpp @@ -234,8 +234,7 @@ nb::list wrap(nb::object func, nb::tuple py_args, nb::object result_desc, nb::ob return returns; } -template -static FnPtr extract_fn_ptr(nb::object fn_obj) +template static FnPtr extract_fn_ptr(nb::object fn_obj) { auto ctypes = nb::module_::import_("ctypes"); return *reinterpret_cast(nb::cast(ctypes.attr("addressof")(fn_obj))); diff --git a/frontend/test/pytest/test_wrapper_exceptions.py b/frontend/test/pytest/test_wrapper_exceptions.py new file mode 100644 index 0000000000..67e9a48587 --- /dev/null +++ b/frontend/test/pytest/test_wrapper_exceptions.py @@ -0,0 +1,51 @@ +# Copyright 2026 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest +from jax.interpreters.mlir import ir + +from catalyst import qjit +from catalyst.utils import gen_mlir + + +@pytest.fixture +def gen_setup_with_failing_assert(monkeypatch): + """ + Patch the gen_setup function to return an MLIR module with a setup function that + executes a catalyst.assert false after quantum.init. + """ + + def patched_gen_setup(ctx, seed): + seed_attr = f" {{seed = {seed} : i32}}" if seed is not None else "" + txt = f""" +func.func @setup() -> () {{ + "quantum.init"(){seed_attr} : () -> () + %f = arith.constant false + "catalyst.assert"(%f) <{{error = "triggered from setup"}}> : (i1) -> () + return +}} +""" + return ir.Module.parse(txt, ctx) + + monkeypatch.setattr(gen_mlir, "gen_setup", patched_gen_setup) + + +def test_setup_failure_surfaces_as_runtime_error(gen_setup_with_failing_assert): + """Test that a RuntimeError thrown from the JIT'd setup function be caught by the wrapper.""" + + @qjit + def circuit(): + return 0 + + with pytest.raises(RuntimeError, match="triggered from setup"): + circuit() From 8ff86af3a4c3430c662b2adcb3d1d22d40fdbb87 Mon Sep 17 00:00:00 2001 From: Hong-Sheng Zheng Date: Thu, 28 May 2026 16:48:44 -0400 Subject: [PATCH 33/75] fix codefactor --- frontend/test/pytest/test_wrapper_exceptions.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/test/pytest/test_wrapper_exceptions.py b/frontend/test/pytest/test_wrapper_exceptions.py index 67e9a48587..8c78fafbbe 100644 --- a/frontend/test/pytest/test_wrapper_exceptions.py +++ b/frontend/test/pytest/test_wrapper_exceptions.py @@ -11,6 +11,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +Test that a RuntimeError thrown from the JIT'd setup function be caught by the wrapper. +""" import pytest from jax.interpreters.mlir import ir @@ -40,7 +43,9 @@ def patched_gen_setup(ctx, seed): monkeypatch.setattr(gen_mlir, "gen_setup", patched_gen_setup) -def test_setup_failure_surfaces_as_runtime_error(gen_setup_with_failing_assert): +def test_setup_failure_surfaces_as_runtime_error( + gen_setup_with_failing_assert, +): # pylint: disable=redefined-outer-name,unused-argument """Test that a RuntimeError thrown from the JIT'd setup function be caught by the wrapper.""" @qjit From 433319fa59cf125e8d6b7364b016bf3c68b191e2 Mon Sep 17 00:00:00 2001 From: Hong-Sheng Zheng Date: Thu, 28 May 2026 16:51:00 -0400 Subject: [PATCH 34/75] formatted --- frontend/test/pytest/test_wrapper_exceptions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/test/pytest/test_wrapper_exceptions.py b/frontend/test/pytest/test_wrapper_exceptions.py index 8c78fafbbe..ea9f5549ca 100644 --- a/frontend/test/pytest/test_wrapper_exceptions.py +++ b/frontend/test/pytest/test_wrapper_exceptions.py @@ -14,6 +14,7 @@ """ Test that a RuntimeError thrown from the JIT'd setup function be caught by the wrapper. """ + import pytest from jax.interpreters.mlir import ir From 8bb8e8a5625d0e68db3dddd4fa42ad3d4c0f7510 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Mon, 1 Jun 2026 16:24:00 +0000 Subject: [PATCH 35/75] Add mark-entry-points pass --- mlir/include/Catalyst/Transforms/Passes.td | 10 +++ .../DefaultPipelines/DefaultPipelines.h | 3 + mlir/lib/Catalyst/Transforms/CMakeLists.txt | 1 + .../Catalyst/Transforms/MarkEntryPoints.cpp | 68 +++++++++++++++ mlir/test/Catalyst/MarkEntryPoints.mlir | 84 +++++++++++++++++++ 5 files changed, 166 insertions(+) create mode 100644 mlir/lib/Catalyst/Transforms/MarkEntryPoints.cpp create mode 100644 mlir/test/Catalyst/MarkEntryPoints.mlir diff --git a/mlir/include/Catalyst/Transforms/Passes.td b/mlir/include/Catalyst/Transforms/Passes.td index c5f87f0d7c..72eba452e6 100644 --- a/mlir/include/Catalyst/Transforms/Passes.td +++ b/mlir/include/Catalyst/Transforms/Passes.td @@ -143,6 +143,16 @@ def ApplyTransformSequencePass : Pass<"apply-transform-sequence"> { let summary = "Apply the passes scheduled with the transform dialect."; } +def MarkEntryPointsPass : Pass<"mark-entry-points", "mlir::ModuleOp"> { + let summary = "Mark externally-callable entries on nested modules."; + let description = [{ + Annotate functions inside any nested module whose name is reachable + from outside that module with `catalyst.entry_point`. + }]; + + let dependentDialects = ["mlir::func::FuncDialect"]; +} + def InlineNestedModulePass : Pass<"inline-nested-module"> { let summary = "Inline nested modules with qnode attribute."; diff --git a/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h b/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h index 33aec77e6d..719ff5ba1d 100644 --- a/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h +++ b/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h @@ -39,6 +39,9 @@ const PipelineList pipelineList{ "split-multiple-tapes", // Run the transform sequence defined in the MLIR module "builtin.module(apply-transform-sequence)", + // Stamp catalyst.entry_point on host-called functions inside every + // nested module. + "mark-entry-points", // Nested modules are something that will be used in the future // for making device-specific transformations. // Since at the moment, nothing in the runtime is using them diff --git a/mlir/lib/Catalyst/Transforms/CMakeLists.txt b/mlir/lib/Catalyst/Transforms/CMakeLists.txt index bb794d3b4c..dd33c7b762 100644 --- a/mlir/lib/Catalyst/Transforms/CMakeLists.txt +++ b/mlir/lib/Catalyst/Transforms/CMakeLists.txt @@ -16,6 +16,7 @@ file(GLOB SRC GEPInboundsPass.cpp GEPInboundsPatterns.cpp InlineNestedModules.cpp + MarkEntryPoints.cpp mark_entry_point_args_non_writable.cpp MemrefCopyToLinalgCopyPass.cpp MemrefCopyToLinalgCopyPatterns.cpp diff --git a/mlir/lib/Catalyst/Transforms/MarkEntryPoints.cpp b/mlir/lib/Catalyst/Transforms/MarkEntryPoints.cpp new file mode 100644 index 0000000000..7f8ee1a785 --- /dev/null +++ b/mlir/lib/Catalyst/Transforms/MarkEntryPoints.cpp @@ -0,0 +1,68 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "llvm/ADT/SmallSet.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/IR/SymbolTable.h" +#include "mlir/Pass/Pass.h" + +using namespace mlir; + +namespace catalyst { + +#define GEN_PASS_DEF_MARKENTRYPOINTSPASS +#include "Catalyst/Transforms/Passes.h.inc" + +namespace { + +struct MarkEntryPointsPass : impl::MarkEntryPointsPassBase { + using MarkEntryPointsPassBase::MarkEntryPointsPassBase; + + void runOnOperation() final + { + ModuleOp root = getOperation(); + UnitAttr entryAttr = UnitAttr::get(&getContext()); + + // Collect leaf names referenced from outside any nested module, then + // stamp matching functions inside each nested module. The host's own + // entry is annotated by the frontend, not this pass. + llvm::SmallSet exposed; + for (Operation &topOp : root.getBody()->getOperations()) { + if (isa(&topOp)) { + continue; + } + if (auto uses = SymbolTable::getSymbolUses(&topOp)) { + for (SymbolTable::SymbolUse use : *uses) { + exposed.insert(use.getSymbolRef().getLeafReference().getValue()); + } + } + } + for (Operation &topOp : root.getBody()->getOperations()) { + auto mod = dyn_cast(&topOp); + if (!mod) { + continue; + } + for (auto func : mod.getOps()) { + if (exposed.contains(func.getName())) { + func->setAttr("catalyst.entry_point", entryAttr); + } + } + } + } +}; + +} // namespace + +} // namespace catalyst diff --git a/mlir/test/Catalyst/MarkEntryPoints.mlir b/mlir/test/Catalyst/MarkEntryPoints.mlir new file mode 100644 index 0000000000..33e8713887 --- /dev/null +++ b/mlir/test/Catalyst/MarkEntryPoints.mlir @@ -0,0 +1,84 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// RUN: quantum-opt %s --mark-entry-points --split-input-file | FileCheck %s + +// Externally-called function in a nested target module gets marked as entry-points in +// the contataining module + +// CHECK-LABEL: module @case_simple +module @case_simple { + func.func public @host() { + func.call @entry() : () -> () + return + } + // CHECK: func.func private @entry() + // CHECK-NOT: catalyst.entry_point + func.func private @entry() + + module @target { + // CHECK: func.func public @entry() + // CHECK-SAME: catalyst.entry_point + func.func public @entry() { + return + } + // CHECK: func.func private @helper() + // CHECK-NOT: catalyst.entry_point + func.func private @helper() { + return + } + } +} + +// ----- + +// Target module with no externally-referenced functions + +// CHECK-LABEL: module @case_orphan +module @case_orphan { + func.func public @host() { return } + + module @target { + // CHECK-NOT: catalyst.entry_point + func.func public @unused() { return } + } +} + +// ----- + +// Intra-module references don't count as "external". + +// CHECK-LABEL: module @case_intra_module +module @case_intra_module { + func.func public @host() { + func.call @entry() : () -> () + return + } + // CHECK: func.func private @entry() + // CHECK-NOT: catalyst.entry_point + func.func private @entry() + + module @qnode { + // CHECK: func.func public @entry() + // CHECK-SAME: catalyst.entry_point + func.func public @entry() { + func.call @callee() : () -> () + return + } + // CHECK: func.func private @callee() + // CHECK-NOT: catalyst.entry_point + func.func private @callee() { return } + } +} + From 48f27fe08679963f18fa059ceea4aa18fbd057dd Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Mon, 1 Jun 2026 17:35:59 +0000 Subject: [PATCH 36/75] add entry point to the main function in frontend --- frontend/catalyst/utils/gen_mlir.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/catalyst/utils/gen_mlir.py b/frontend/catalyst/utils/gen_mlir.py index a90b2a762d..049a79e3a3 100644 --- a/frontend/catalyst/utils/gen_mlir.py +++ b/frontend/catalyst/utils/gen_mlir.py @@ -62,8 +62,10 @@ def inject_functions(module, ctx, seed): """ This function appends functions to the input module. """ - # Add C interface for the quantum function. - module.body.operations[0].attributes["llvm.emit_c_interface"] = ir.UnitAttr.get(context=ctx) + # Add C interface for the quantum function and annonate it as the main entry_point. + mlir_qfunc = module.body.operations[0] + mlir_qfunc.attributes["catalyst.entry_point"] = ir.UnitAttr.get(context=ctx) + mlir_qfunc.attributes["llvm.emit_c_interface"] = ir.UnitAttr.get(context=ctx) setup_module = gen_setup(ctx, seed) setup_func = setup_module.body.operations[0] @@ -73,5 +75,4 @@ def inject_functions(module, ctx, seed): teardown_func = teardown_module.body.operations[0] module.body.append(teardown_func) - mlir_qfunc = module.body.operations[0] assert isinstance(mlir_qfunc, FuncOp) From 61cae90153a8eddd62673900b295cfab39f75467 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Mon, 1 Jun 2026 17:43:30 +0000 Subject: [PATCH 37/75] add changelog --- doc/releases/changelog-dev.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md index d1cf60f3cc..dd32e13c08 100644 --- a/doc/releases/changelog-dev.md +++ b/doc/releases/changelog-dev.md @@ -87,6 +87,10 @@

Internal changes ⚙️

+* A new compiler pass `mark-entry-points` has been added, which annotates externally-callable + entry functions with the `catalyst.entry_point` attribute. + [(#2899)](https://github.com/PennyLaneAI/catalyst/pull/2899) + * Removed the internal ``mlir_specs`` function which was the old backend for :func:`qp.specs`. The resource analysis pass replaces its use. [(#2841)](https://github.com/PennyLaneAI/catalyst/pull/2841) From 0508daf1f104a4dc72e2a2ba122195f51b03d2b7 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Mon, 1 Jun 2026 18:12:03 +0000 Subject: [PATCH 38/75] fix test --- frontend/test/lit/test_function_attributes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/test/lit/test_function_attributes.py b/frontend/test/lit/test_function_attributes.py index 27d6e05784..d516db2d55 100644 --- a/frontend/test/lit/test_function_attributes.py +++ b/frontend/test/lit/test_function_attributes.py @@ -31,7 +31,7 @@ def qnode(x): @qjit(target="mlir") # The entry point has no internal linkage. -# CHECK-DAG: func.func public @jit_workload(%arg0: tensor) -> tensor<4xcomplex> attributes {llvm.emit_c_interface} { +# CHECK-DAG: func.func public @jit_workload(%arg0: tensor) -> tensor<4xcomplex> attributes {catalyst.entry_point, llvm.emit_c_interface} { def workload(x: float): y = x * qp.numpy.pi # pylint: disable=no-member return qnode(y) From 423ea962b75eac41b869afc97db88584af806131 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 2 Jun 2026 03:52:41 +0000 Subject: [PATCH 39/75] add catalyst.target frontend --- frontend/catalyst/api_extensions/__init__.py | 2 + frontend/catalyst/api_extensions/target.py | 69 ++++++++++++++++++++ frontend/catalyst/jax_primitives_utils.py | 6 ++ frontend/test/lit/test_target.py | 61 +++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 frontend/catalyst/api_extensions/target.py create mode 100644 frontend/test/lit/test_target.py diff --git a/frontend/catalyst/api_extensions/__init__.py b/frontend/catalyst/api_extensions/__init__.py index 6c020597c8..5791fc95d1 100644 --- a/frontend/catalyst/api_extensions/__init__.py +++ b/frontend/catalyst/api_extensions/__init__.py @@ -40,6 +40,7 @@ measure, pauli_measure, ) +from catalyst.api_extensions.target import target __all__ = ( "accelerate", @@ -60,4 +61,5 @@ "pauli_measure", "adjoint", "ctrl", + "target", ) diff --git a/frontend/catalyst/api_extensions/target.py b/frontend/catalyst/api_extensions/target.py new file mode 100644 index 0000000000..37faca9440 --- /dev/null +++ b/frontend/catalyst/api_extensions/target.py @@ -0,0 +1,69 @@ +# Copyright 2026 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tag a PennyLane device as a separate cross-compilation target. +""" + +from dataclasses import dataclass +from typing import Optional + +import pennylane as qp + +_TARGET_ATTR = "_catalyst_target" + + +@dataclass(frozen=True) +class Target: + """Cross-compilation target spec attached to a device by :func:`target`. + + Args: + backend: Optional backend name, recorded as metadata on the target module. + pipeline: Optional name of a lowering pipeline registered with compiler. + triple: Optional LLVM target triple. Defaults to the host triple. + """ + + backend: Optional[str] = None + pipeline: Optional[str] = None + triple: Optional[str] = None + + +def target( + device, + *, + backend: Optional[str] = None, + pipeline: Optional[str] = None, + triple: Optional[str] = None, +): + """Tag a PennyLane device as a separate cross-compilation target and return it. + + Any QNode wrapping the returned device is kept as a separate compilation unit which carries + ``catalyst.target = {backend, pipeline, triple}`` and is cross-compiled to a standalone + object file rather than being inlined into the host module. + + Args: + device: A PennyLane device. + backend: Optional backend name, recorded as metadata on the target module. + pipeline: Optional lowering-pipeline name registered with the compiler. + triple: Optional LLVM target triple. Defaults to the host triple. + + Returns: + The same device, now tagged with target metadata. + """ + setattr(device, _TARGET_ATTR, Target(backend=backend, pipeline=pipeline, triple=triple)) + return device + + +def get_target(device) -> Optional[Target]: + """Return the :class:`Target` previously attached via :func:`target`, or ``None``.""" + return getattr(device, _TARGET_ATTR, None) diff --git a/frontend/catalyst/jax_primitives_utils.py b/frontend/catalyst/jax_primitives_utils.py index 1365b45371..1e88565818 100644 --- a/frontend/catalyst/jax_primitives_utils.py +++ b/frontend/catalyst/jax_primitives_utils.py @@ -14,6 +14,7 @@ """This module contains some helper functions for translating JAX primitives to MLIR.""" import copy +import dataclasses import functools import pennylane as qp @@ -26,6 +27,7 @@ from mlir_quantum.dialects.catalyst import LaunchKernelOp from pennylane.transforms.core import BoundTransform +from catalyst.api_extensions.target import get_target from catalyst.jax_extras.lowering import get_mlir_attribute_from_pyval from catalyst.passes import PassPlugin @@ -244,8 +246,12 @@ def lower_qnode_to_funcop(ctx, callable_, call_jaxpr, pipelines): assert isinstance(callable_, qp.QNode), "This function expects qnodes" name = "module_" + callable_.__name__ + target = get_target(callable_.device) # pylint: disable-next=no-member with NestedModule(ctx, name) as module, ir.InsertionPoint(module.regions[0].blocks[0]) as ip: + if target is not None: + fields = {k: v for k, v in dataclasses.asdict(target).items() if v is not None} + module.operation.attributes["catalyst.target"] = get_mlir_attribute_from_pyval(fields) transform_module_lowering(ctx, pipelines) ctx.module_context.ip = ip func_op = get_or_create_funcop(ctx, callable_, call_jaxpr, pipelines) diff --git a/frontend/test/lit/test_target.py b/frontend/test/lit/test_target.py new file mode 100644 index 0000000000..66156a8f1f --- /dev/null +++ b/frontend/test/lit/test_target.py @@ -0,0 +1,61 @@ +# Copyright 2026 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# RUN: %PYTHON %s | FileCheck %s +"""catalyst.target attaches catalyst.target to the nested module.""" + +import pennylane as qp + +import catalyst +from catalyst import qjit, target + + +def test_full_target(): + """All target fields should appear in the emitted DictAttr.""" + + dev = target( + qp.device("null.qubit", wires=1), + backend="my-backend", + pipeline="my-pipeline", + triple="my-triple", + ) + + @qjit(target="mlir") + @qp.qnode(dev) + def circuit(): + qp.Hadamard(0) + return qp.state() + + # CHECK: module @module_circuit attributes {catalyst.target = {backend = "my-backend", pipeline = "my-pipeline", triple = "my-triple"}} + print(circuit.mlir) + + +test_full_target() + + +def test_backend_only(): + """Only backend is set; other fields must be absent from the dict.""" + + dev = target(qp.device("null.qubit", wires=1), backend="my-backend") + + @qjit(target="mlir") + @qp.qnode(dev) + def circuit_min(): + return qp.state() + + # CHECK: module @module_circuit_min attributes {catalyst.target = {backend = "my-backend"}} + print(circuit_min.mlir) + + +test_backend_only() From 6b3c02d4fa1bec63f8edcaa621e7901127033d2c Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 2 Jun 2026 04:54:11 +0000 Subject: [PATCH 40/75] Add cross-compile-targets pass --- mlir/include/Catalyst/Transforms/Passes.td | 37 ++ .../DefaultPipelines/DefaultPipelines.h | 12 +- mlir/lib/Catalyst/Transforms/CMakeLists.txt | 12 + .../Transforms/CrossCompileTargets.cpp | 363 ++++++++++++++++++ mlir/lib/Driver/CompilerDriver.cpp | 17 + .../Catalyst/CrossCompileTargetModules.mlir | 50 +++ 6 files changed, 485 insertions(+), 6 deletions(-) create mode 100644 mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp create mode 100644 mlir/test/Catalyst/CrossCompileTargetModules.mlir diff --git a/mlir/include/Catalyst/Transforms/Passes.td b/mlir/include/Catalyst/Transforms/Passes.td index c5f87f0d7c..0d2a6af902 100644 --- a/mlir/include/Catalyst/Transforms/Passes.td +++ b/mlir/include/Catalyst/Transforms/Passes.td @@ -143,6 +143,43 @@ def ApplyTransformSequencePass : Pass<"apply-transform-sequence"> { let summary = "Apply the passes scheduled with the transform dialect."; } +def CrossCompileTargetsPass : Pass<"cross-compile-targets", "mlir::ModuleOp"> { + let summary = "Cross-compile catalyst.target nested modules to standalone `.o` files."; + let description = [{ + For every `builtin.module` carrying a `catalyst.target` attribute, this pass: + + 1. Extracts the module body into a standalone root module. + 2. Runs the default lowering pipeline on it (bufferization + LLVM-dialect lowering). + 3. Translates to LLVM IR and emits an object file. + 4. Records the emitted object path on the module as a `catalyst.object_file` string + attribute and reduces the module to external declarations of its entry functions. + + The target triple is taken from the optional `triple` key on the `catalyst.target` + dict, falling back to the host triple. + }]; + + let options = [ + Option< + /*C++ var name=*/"workspace", + /*CLI arg name=*/"workspace", + /*type=*/"std::string", + /*default=*/"\"\"", + /*description=*/ + "Filesystem directory to write cross-compiled `.o` files into." + >, + Option< + /*C++ var name=*/"dumpIntermediate", + /*CLI arg name=*/"dump-intermediate", + /*type=*/"bool", + /*default=*/"false", + /*description=*/ + "Also write each target module's extracted MLIR and translated LLVM IR " + "(`extracted.mlir`, `.ll`) into its workspace subdirectory. Set by the " + "driver from `keep_intermediate`." + >, + ]; +} + def InlineNestedModulePass : Pass<"inline-nested-module"> { let summary = "Inline nested modules with qnode attribute."; diff --git a/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h b/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h index 33aec77e6d..088af5b3c8 100644 --- a/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h +++ b/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h @@ -163,7 +163,7 @@ const PipelineList pipelineList{ "register-inactive-callback"}}}; // clang-format on -PipelineNames getPipelineNames() +inline PipelineNames getPipelineNames() { static std::vector names = std::accumulate(driver::pipelineList.begin(), driver::pipelineList.end(), @@ -174,7 +174,7 @@ PipelineNames getPipelineNames() return names; } -PassNames getQuantumCompilationStage(bool disableAssertion = true) +inline PassNames getQuantumCompilationStage(bool disableAssertion = true) { PassNames ret; std::copy_if(pipelineList[0].passNames.begin(), pipelineList[0].passNames.end(), @@ -184,11 +184,11 @@ PassNames getQuantumCompilationStage(bool disableAssertion = true) return ret; } -PassNames getHLOLoweringStage() { return pipelineList[1].passNames; } +inline PassNames getHLOLoweringStage() { return pipelineList[1].passNames; } -PassNames getGradientLoweringStage() { return pipelineList[2].passNames; } +inline PassNames getGradientLoweringStage() { return pipelineList[2].passNames; } -PassNames getBufferizationStage(bool asyncQNodes = false) +inline PassNames getBufferizationStage(bool asyncQNodes = false) { const std::string bufferizationOptions = std::string("{bufferize-function-boundaries ") + "allow-return-allocs-from-loops " + @@ -206,7 +206,7 @@ PassNames getBufferizationStage(bool asyncQNodes = false) return ret; } -PassNames getLLVMDialectLoweringStage(bool asyncQNodes = false) +inline PassNames getLLVMDialectLoweringStage(bool asyncQNodes = false) { PassNames ret; std::copy_if(pipelineList[4].passNames.begin(), pipelineList[4].passNames.end(), diff --git a/mlir/lib/Catalyst/Transforms/CMakeLists.txt b/mlir/lib/Catalyst/Transforms/CMakeLists.txt index bb794d3b4c..562a8f959f 100644 --- a/mlir/lib/Catalyst/Transforms/CMakeLists.txt +++ b/mlir/lib/Catalyst/Transforms/CMakeLists.txt @@ -7,6 +7,7 @@ file(GLOB SRC BufferDeallocation.cpp BufferizableOpInterfaceImpl.cpp catalyst_to_llvm.cpp + CrossCompileTargets.cpp DetectQNodes.cpp DetensorizeFunctionBoundaryPass.cpp DetensorizeSCFPass.cpp @@ -29,11 +30,22 @@ file(GLOB SRC TBAATagsPass.cpp ) +# Required by the cross-compile-targets pass: object emission for arbitrary target +# triples (AllTargets*) and MLIR->LLVM-IR translation registration +# (registerLLVMDialectTranslation / registerBuiltinDialectTranslation, +# translateModuleToLLVMIR). +set(LLVM_LINK_COMPONENTS + AllTargetsAsmParsers + AllTargetsCodeGens +) + get_property(dialect_libs GLOBAL PROPERTY MLIR_DIALECT_LIBS) get_property(conversion_libs GLOBAL PROPERTY MLIR_CONVERSION_LIBS) +get_property(translation_libs GLOBAL PROPERTY MLIR_TRANSLATION_LIBS) set(LIBS ${dialect_libs} ${conversion_libs} + ${translation_libs} catalyst-analysis ) diff --git a/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp b/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp new file mode 100644 index 0000000000..ac48fe443f --- /dev/null +++ b/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp @@ -0,0 +1,363 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "llvm/ADT/StringExtras.h" // llvm::join +#include "llvm/IR/LLVMContext.h" +#include "llvm/IR/LegacyPassManager.h" +#include "llvm/IR/Module.h" +#include "llvm/MC/TargetRegistry.h" +#include "llvm/Support/FileSystem.h" +#include "llvm/Support/Path.h" +#include "llvm/Support/TargetSelect.h" +#include "llvm/Support/raw_ostream.h" +#include "llvm/Target/TargetMachine.h" +#include "llvm/Target/TargetOptions.h" +#include "llvm/TargetParser/Host.h" +#include "llvm/TargetParser/Triple.h" +#include "mlir/Dialect/DLTI/DLTI.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/Dialect/LLVMIR/LLVMDialect.h" +#include "mlir/IR/Builders.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/InitAllDialects.h" +#include "mlir/Pass/Pass.h" +#include "mlir/Pass/PassManager.h" +#include "mlir/Pass/PassRegistry.h" +#include "mlir/Target/LLVMIR/Dialect/Builtin/BuiltinToLLVMIRTranslation.h" +#include "mlir/Target/LLVMIR/Dialect/LLVMIR/LLVMToLLVMIRTranslation.h" +#include "mlir/Target/LLVMIR/Export.h" +#include "mlir/Target/LLVMIR/Import.h" // translateDataLayout + +#include "Catalyst/IR/CatalystDialect.h" +#include "Catalyst/IR/CatalystOps.h" +#include "Driver/DefaultPipelines/DefaultPipelines.h" +#include "Gradient/IR/GradientDialect.h" +#include "Ion/IR/IonDialect.h" +#include "MBQC/IR/MBQCDialect.h" +#include "Mitigation/IR/MitigationDialect.h" +#include "PBC/IR/PBCDialect.h" +#include "PauliFrame/IR/PauliFrameDialect.h" +#include "QRef/IR/QRefDialect.h" +#include "QecLogical/IR/QecLogicalDialect.h" +#include "QecPhysical/IR/QecPhysicalDialect.h" +#include "Quantum/IR/QuantumDialect.h" +#include "RTIO/IR/RTIODialect.h" + +using namespace mlir; + +namespace catalyst { + +#define GEN_PASS_DECL_CROSSCOMPILETARGETSPASS +#define GEN_PASS_DEF_CROSSCOMPILETARGETSPASS +#include "Catalyst/Transforms/Passes.h.inc" + +namespace { + +// Make `fn` externally callable through the C ABI +void exposeEntryViaCInterface(func::FuncOp fn) +{ + OpBuilder builder(fn.getContext()); + fn.setVisibility(SymbolTable::Visibility::Public); + fn->setAttr("llvm.emit_c_interface", builder.getUnitAttr()); + fn->removeAttr("llvm.linkage"); +} + +bool isEntryPoint(func::FuncOp fn) +{ + return fn->hasAttr("catalyst.entry_point"); +} + +// The default lowering applied to a catalyst.target module: bufferization + +// LLVM-dialect lowering, reusing the same stage definitions as the host pipeline. +std::vector defaultLoweringPassList() +{ + auto buf = driver::getBufferizationStage(); + auto llvmPasses = driver::getLLVMDialectLoweringStage(); + std::vector passes; + passes.reserve(buf.size() + llvmPasses.size()); + passes.insert(passes.end(), buf.begin(), buf.end()); + passes.insert(passes.end(), llvmPasses.begin(), llvmPasses.end()); + return passes; +} + +// Reduce a compiled target module to external declarations of its entry functions. The definitions +// now live in the emitted object, so the host pipeline does not re-lower them. +void reduceToEntryDeclarations(ModuleOp nested) +{ + SmallVector toErase; + for (Operation &op : *nested.getBody()) { + auto fn = dyn_cast(&op); + if (fn && isEntryPoint(fn)) { + if (!fn.getBody().empty()) { + fn.getBody().getBlocks().clear(); + } + fn.setVisibility(SymbolTable::Visibility::Private); + fn->removeAttr("llvm.emit_c_interface"); + continue; + } + toErase.push_back(&op); + } + for (Operation *op : toErase) { + op->erase(); + } +} + +struct CrossCompileTargetsPass + : impl::CrossCompileTargetsPassBase { + using CrossCompileTargetsPassBase::CrossCompileTargetsPassBase; + + void getDependentDialects(DialectRegistry ®istry) const override + { + mlir::registerLLVMDialectTranslation(registry); + mlir::registerBuiltinDialectTranslation(registry); + mlir::registerAllDialects(registry); + registry.insert(); + } + + void runOnOperation() final + { + ModuleOp host = getOperation(); + + SmallVector targetMods; + for (auto &op : host.getBody()->getOperations()) { + if (auto mod = dyn_cast(&op)) { + if (mod->hasAttr("catalyst.target")) { + targetMods.push_back(mod); + } + } + } + + if (targetMods.empty()) { + return; + } + + if (workspace.empty()) { + host.emitError("Missing `workspace` option for target cross-compilation"); + return signalPassFailure(); + } + + llvm::InitializeAllTargetInfos(); + llvm::InitializeAllTargets(); + llvm::InitializeAllTargetMCs(); + llvm::InitializeAllAsmParsers(); + llvm::InitializeAllAsmPrinters(); + + // Compile each target module, record its object path, and reduce it to + // entry declarations. + for (auto nested : targetMods) { + FailureOr objPath = compileTargetModule(nested); + if (failed(objPath)) { + return signalPassFailure(); + } + nested->setAttr("catalyst.object_file", StringAttr::get(&getContext(), *objPath)); + reduceToEntryDeclarations(nested); + } + } + + // Returns the kernel-specific subdirectory {workspace}/{name}/, creating it if needed. + std::string makeKernelDir(StringRef name) + { + llvm::SmallString<128> dir(workspace); + llvm::sys::path::append(dir, name); + (void)llvm::sys::fs::create_directories(dir); + return std::string(dir.str()); + } + + // Write `op`/`mod` to {dir}/{filename} (used only when dump-intermediate is set). + void dumpMLIR(mlir::Operation *op, StringRef dir, StringRef filename) + { + llvm::SmallString<128> path(dir); + llvm::sys::path::append(path, filename); + std::error_code ec; + llvm::raw_fd_ostream os(path, ec, llvm::sys::fs::OF_None); + if (!ec) { + op->print(os); + } + } + + void dumpLLVMIR(llvm::Module &mod, StringRef dir, StringRef filename) + { + llvm::SmallString<128> path(dir); + llvm::sys::path::append(path, filename); + std::error_code ec; + llvm::raw_fd_ostream os(path, ec, llvm::sys::fs::OF_None); + if (!ec) { + mod.print(os, nullptr); + } + } + + /** + * @brief Build a TargetMachine for the given triple (generic CPU, no extra features). + * + * @return The TargetMachine, or nullptr (with a diagnostic on stderr) if the + * triple is not available in this LLVM build. + */ + std::unique_ptr createTargetMachine(StringRef triple) + { + llvm::Triple parsedTriple{triple}; + std::string err; + const llvm::Target *llvmTarget = llvm::TargetRegistry::lookupTarget(parsedTriple, err); + if (!llvmTarget) { + llvm::errs() << "Target triple '" << triple + << "' not registered in this LLVM build: " << err << "\n"; + return nullptr; + } + llvm::TargetOptions opt; + std::unique_ptr targetMachine(llvmTarget->createTargetMachine( + parsedTriple, /*cpu=*/"generic", /*features=*/"", opt, llvm::Reloc::Model::PIC_)); + if (!targetMachine) { + llvm::errs() << "Could not create TargetMachine for triple '" << triple << "'\n"; + return nullptr; + } + targetMachine->setOptLevel(llvm::CodeGenOptLevel::Aggressive); + return targetMachine; + } + + /** + * @brief Emit a `.o` file from an LLVM module using a prepared TargetMachine. + * + * The module's triple and data layout were stamped on the source MLIR module before + * lowering and carried into `llvmModule` by translateModuleToLLVMIR, so they already + * match `targetMachine` — no need to re-set them here. + * + * @param llvmModule The LLVM module to emit. + * @param name The name used as the object file's basename. + * @param dir The directory to write the object file into. + * @param targetMachine The target machine to emit for. + * @return std::string The path to the emitted object file, or "" on failure. + */ + std::string emitObjectFile(std::unique_ptr &&llvmModule, StringRef name, + StringRef dir, llvm::TargetMachine &targetMachine) + { + llvm::SmallString<128> p(dir); + llvm::sys::path::append(p, name.str() + ".o"); + std::string objPath = std::string(p.str()); + + std::error_code errCode; + llvm::raw_fd_ostream dest(objPath, errCode, llvm::sys::fs::OF_None); + if (errCode) { + llvm::errs() << "Cannot open " << objPath << " for writing: " << errCode.message() + << "\n"; + return ""; + } + llvm::legacy::PassManager codegenPM; + if (targetMachine.addPassesToEmitFile(codegenPM, dest, nullptr, + llvm::CodeGenFileType::ObjectFile)) { + llvm::errs() << "TargetMachine cannot emit an object file\n"; + return ""; + } + codegenPM.run(*llvmModule); + dest.flush(); + return objPath; + } + + // Cross-compile a catalyst.target module to a `.o` for its triple and return the path. + FailureOr compileTargetModule(ModuleOp nested) + { + MLIRContext *ctx = &getContext(); + StringRef name = nested.getName().value_or("unnamed"); + + auto targetAttr = nested->getAttrOfType("catalyst.target"); + if (!targetAttr) { + nested.emitError("catalyst.target module missing catalyst.target dict attr"); + return failure(); + } + + // Triple selection: the optional 'triple' key on the catalyst.target dict + // wins; otherwise the host triple. + std::string moduleTarget; + if (auto tripleAttr = targetAttr.getAs("triple")) { + moduleTarget = tripleAttr.getValue().str(); + } + if (moduleTarget.empty()) { + moduleTarget = llvm::sys::getDefaultTargetTriple(); + } + + std::string kernelDir = makeKernelDir(name); + + std::unique_ptr targetMachine = createTargetMachine(moduleTarget); + if (!targetMachine) { + nested.emitError("failed to create target machine for triple '" + moduleTarget + + "' (target module: " + name.str() + ")"); + return failure(); + } + llvm::DataLayout dataLayout = targetMachine->createDataLayout(); + + // Clone the target module into an unparented root module: leaves `nested` + // intact for reduceToEntryDeclarations and gives the sub-pipeline / + // translateModuleToLLVMIR a top-level module to operate on. + OpBuilder builder(ctx); + mlir::OwningOpRef standalone(cast(nested->clone())); + + Operation *moduleOp = standalone->getOperation(); + moduleOp->setAttr(LLVM::LLVMDialect::getTargetTripleAttrName(), + builder.getStringAttr(moduleTarget)); + moduleOp->setAttr(LLVM::LLVMDialect::getDataLayoutAttrName(), + builder.getStringAttr(dataLayout.getStringRepresentation())); + moduleOp->setAttr(DLTIDialect::kDataLayoutAttrName, + mlir::translateDataLayout(dataLayout, ctx)); + + // Expose only the entry-point functions through the C ABI. + for (auto fn : standalone->getOps()) { + if (isEntryPoint(fn)) { + exposeEntryViaCInterface(fn); + } + } + + if (dumpIntermediate) { + dumpMLIR(*standalone, kernelDir, "extracted.mlir"); + } + + // Lower the extracted module with the default pipeline (bufferization + + // LLVM-dialect lowering). + PassManager subPM(ctx); + if (failed(parsePassPipeline(llvm::join(defaultLoweringPassList(), ","), subPM))) { + nested.emitError("failed to build the default target-lowering pipeline"); + return failure(); + } + if (failed(subPM.run(*standalone))) { + nested.emitError("failed to lower target module to LLVM dialect: " + name.str()); + return failure(); + } + + // Translate to LLVM IR and emit the object file. + llvm::LLVMContext llvmCtx; + std::unique_ptr llvmModule = + translateModuleToLLVMIR(*standalone, llvmCtx, name); + if (!llvmModule) { + nested.emitError("failed to translate target module to LLVM IR: " + name.str()); + return failure(); + } + if (dumpIntermediate) { + dumpLLVMIR(*llvmModule, kernelDir, name.str() + ".ll"); + } + std::string objPath = + emitObjectFile(std::move(llvmModule), name, kernelDir, *targetMachine); + if (objPath.empty()) { + nested.emitError("failed to emit object file for target module: " + name.str()); + return failure(); + } + return objPath; + } +}; + +} // namespace + +} // namespace catalyst diff --git a/mlir/lib/Driver/CompilerDriver.cpp b/mlir/lib/Driver/CompilerDriver.cpp index a2cf2016f8..508971dc01 100644 --- a/mlir/lib/Driver/CompilerDriver.cpp +++ b/mlir/lib/Driver/CompilerDriver.cpp @@ -478,6 +478,23 @@ llvm::LogicalResult catalyst::driver::runPipeline(PassManager &pm, const Compile return failure(); } catalyst::utils::LinesCount::call(moduleOp); + + // Cross-compile catalyst.target nested modules to standalone objects after + // gradient lowering and before host bufferization. + if (pipeline.getName() == "GradientLoweringStage" && !options.workspace.empty()) { + Pipeline cctPipeline; + cctPipeline.setName("CrossCompileTargets"); + std::string dumpIntermediate = options.keepIntermediate ? "true" : "false"; + cctPipeline.setPasses({"cross-compile-targets{workspace=" + options.workspace.str() + + " dump-intermediate=" + dumpIntermediate + "}"}); + if (failed(catalyst::utils::Timer<>::timer( + catalyst::driver::runPipeline, cctPipeline.getName(), + /* add_endl */ false, pm, options, output, cctPipeline, + /* clHasManualPipeline */ true, moduleOp))) { + return failure(); + } + catalyst::utils::LinesCount::call(moduleOp); + } } return success(); } diff --git a/mlir/test/Catalyst/CrossCompileTargetModules.mlir b/mlir/test/Catalyst/CrossCompileTargetModules.mlir new file mode 100644 index 0000000000..22f0f35d7b --- /dev/null +++ b/mlir/test/Catalyst/CrossCompileTargetModules.mlir @@ -0,0 +1,50 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// RUN: mkdir -p %t +// RUN: quantum-opt %s --cross-compile-targets="workspace=%t" | FileCheck %s + +// The catalyst.target module is compiled to an object file recorded as +// catalyst.object_file, and reduced to declarations of its entry functions. +// CHECK: module @target_compute +// CHECK-SAME: catalyst.object_file = "{{.*}}.o" +// CHECK: func.func private @noop() attributes {catalyst.entry_point} +// CHECK-NOT: noop_helper + +// dump-intermediate writes the extracted MLIR and translated LLVM IR to the workspace. +// RUN: rm -rf %t/target_compute +// RUN: quantum-opt %s --cross-compile-targets="workspace=%t dump-intermediate=true" +// RUN: cat %t/target_compute/extracted.mlir | FileCheck %s --check-prefix=EXTRACTED +// RUN: cat %t/target_compute/target_compute.ll | FileCheck %s --check-prefix=LL +// EXTRACTED: func.func {{.*}}@noop +// LL: define {{.*}}@noop + +module @jit_test_cross_compile_target { + + func.func private @noop() attributes {catalyst.target = {backend = "my-backend"}} + + func.func public @jit_main() attributes {llvm.emit_c_interface} { + func.call @noop() : () -> () + return + } + + module @target_compute attributes {catalyst.target = {backend = "my-backend"}} { + func.func public @noop() attributes {catalyst.entry_point} { + return + } + func.func private @noop_helper() { + return + } + } +} From 786577683d116c221a21b3bb1ea2423881812f7a Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 2 Jun 2026 11:58:13 +0000 Subject: [PATCH 41/75] Add remote dispatch pass --- mlir/include/Catalyst/Transforms/Passes.td | 19 ++ mlir/lib/Catalyst/Transforms/CMakeLists.txt | 1 + .../Transforms/DispatchRemoteTargets.cpp | 208 ++++++++++++++++++ .../Catalyst/DispatchRemoteTargetModules.mlir | 68 ++++++ 4 files changed, 296 insertions(+) create mode 100644 mlir/lib/Catalyst/Transforms/DispatchRemoteTargets.cpp create mode 100644 mlir/test/Catalyst/DispatchRemoteTargetModules.mlir diff --git a/mlir/include/Catalyst/Transforms/Passes.td b/mlir/include/Catalyst/Transforms/Passes.td index c5f87f0d7c..6b6c3150de 100644 --- a/mlir/include/Catalyst/Transforms/Passes.td +++ b/mlir/include/Catalyst/Transforms/Passes.td @@ -333,6 +333,25 @@ def RegisterDecompRuleResourcePass : Pass<"register-decomp-rule-resource"> { }]; } +def DispatchRemoteTargetsPass : Pass<"dispatch-remote-targets", "mlir::ModuleOp"> { + let summary = "Ship cross-compiled catalyst.target modules to a remote executor."; + let description = [{ + For every `builtin.module` carrying a `catalyst.dispatch` attribute and the + `catalyst.object_file` path, this pass will perform the following: + + 1. Injects `remote_open` into `setup()` (once per unique address) and `remote_send_binary` + into `setup()`. + 2. Replaces every host-side `func.call` to the module's entry functions with a + `catalyst.custom_call fn("remote_call")` carrying the object path, address, and callee. + 3. Injects `remote_close` into `teardown()` once any session was opened. + 4. Erases the bodyless external declarations and the nested module from the host. + + The pass is a no-op when no `catalyst.dispatch` modules are present. + }]; + + let dependentDialects = ["catalyst::CatalystDialect", "mlir::func::FuncDialect"]; +} + def EmptyPass : Pass<"empty"> { let summary = "Empty pass that does nothing."; diff --git a/mlir/lib/Catalyst/Transforms/CMakeLists.txt b/mlir/lib/Catalyst/Transforms/CMakeLists.txt index bb794d3b4c..4e7b539b79 100644 --- a/mlir/lib/Catalyst/Transforms/CMakeLists.txt +++ b/mlir/lib/Catalyst/Transforms/CMakeLists.txt @@ -12,6 +12,7 @@ file(GLOB SRC DetensorizeSCFPass.cpp disable_assertion.cpp DisableAssertionPatterns.cpp + DispatchRemoteTargets.cpp EmptyPass.cpp GEPInboundsPass.cpp GEPInboundsPatterns.cpp diff --git a/mlir/lib/Catalyst/Transforms/DispatchRemoteTargets.cpp b/mlir/lib/Catalyst/Transforms/DispatchRemoteTargets.cpp new file mode 100644 index 0000000000..ff95bee176 --- /dev/null +++ b/mlir/lib/Catalyst/Transforms/DispatchRemoteTargets.cpp @@ -0,0 +1,208 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "llvm/ADT/SmallSet.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/IR/Builders.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/Pass/Pass.h" + +#include "Catalyst/IR/CatalystDialect.h" +#include "Catalyst/IR/CatalystOps.h" + +using namespace mlir; + +namespace catalyst { + +#define GEN_PASS_DECL_DISPATCHREMOTETARGETSPASS +#define GEN_PASS_DEF_DISPATCHREMOTETARGETSPASS +#include "Catalyst/Transforms/Passes.h.inc" + +namespace { + +// Functions marked as the object's externally-callable entry points. +bool isEntryPoint(func::FuncOp fn) +{ + return fn->hasAttr("catalyst.entry_point"); +} + +// Ships cross-compiled `catalyst.target` modules to a remote executor. +// +// This pass should be run after `cross-compile-targets`, which records each module's object file in +// `catalyst.object_file`. For every nested module carrying a `catalyst.dispatch` attribute this pass: +// 1. Injects `remote_open` into `setup()` (once per unique address) and +// `remote_send_binary` into `setup()` (once per module; the object holds every entry). +// 2. Rewrites every host-side `func.call` to an entry function into a +// `catalyst.custom_call fn("remote_call")` carrying the object path, address, +// and callee. +// 3. Injects `remote_close` into `teardown()` once any session was opened. +// 4. Erases the bodyless external declarations and the nested module from the host. +struct DispatchRemoteTargetsPass + : impl::DispatchRemoteTargetsPassBase { + using DispatchRemoteTargetsPassBase::DispatchRemoteTargetsPassBase; + + void runOnOperation() final + { + ModuleOp host = getOperation(); + + SmallVector targetMods; + for (auto &op : host.getBody()->getOperations()) { + if (auto mod = dyn_cast(&op)) { + if (mod->hasAttr("catalyst.dispatch")) { + targetMods.push_back(mod); + } + } + } + + if (targetMods.empty()) { + return; + } + + // Modules sharing an executor get a single remote_open. + llvm::SmallSet openedAddresses; + + for (auto nested : targetMods) { + if (failed(dispatchViaOrcRemote(host, nested, openedAddresses))) { + return signalPassFailure(); + } + nested.erase(); + } + + // One remote_close covers every open session. + if (!openedAddresses.empty()) { + injectRemoteCloseIntoTeardown(host); + } + } + + // Insert a no-operand CustomCallOp before the terminator of `funcName` in `host`. + // Returns the op so the caller can attach attributes, or nullptr if the function + // is absent or bodyless. + catalyst::CustomCallOp injectCustomCallInto(ModuleOp host, StringRef funcName, + StringRef callName) + { + auto fn = host.lookupSymbol(funcName); + if (!fn || fn.getBody().empty()) { + return nullptr; + } + Operation *terminator = fn.getBody().front().getTerminator(); + if (!terminator) { + return nullptr; + } + OpBuilder b(terminator); + return catalyst::CustomCallOp::create(b, fn.getLoc(), TypeRange{}, ValueRange{}, + callName, nullptr); + } + + void injectRemoteOpenIntoSetup(ModuleOp host, StringRef addr) + { + auto op = injectCustomCallInto(host, "setup", "remote_open"); + if (op) { + op->setAttr("catalyst.remote_address", StringAttr::get(&getContext(), addr)); + } + } + + void injectRemoteCloseIntoTeardown(ModuleOp host) + { + // remote_close closes all open sessions; no address needed. + injectCustomCallInto(host, "teardown", "remote_close"); + } + + void injectRemoteSendBinaryIntoSetup(ModuleOp host, StringAttr addressAttr, StringAttr pathAttr) + { + auto op = injectCustomCallInto(host, "setup", "remote_send_binary"); + if (op) { + op->setAttr("catalyst.remote_address", addressAttr); + op->setAttr("catalyst.remote_kernel_path", pathAttr); + } + } + + // Open a session (once per address), ship the object recorded in + // `catalyst.object_file`, and rewrite each host-side func.call into a `remote_call`. + LogicalResult dispatchViaOrcRemote(ModuleOp host, ModuleOp nested, + llvm::SmallSet &openedAddresses) + { + MLIRContext *ctx = &getContext(); + + // The object path is produced by the cross-compile-targets pass. + auto objPathAttr = nested->getAttrOfType("catalyst.object_file"); + if (!objPathAttr || objPathAttr.getValue().empty()) { + nested.emitError("remote dispatch requires a non-empty 'catalyst.object_file' " + "attribute (run cross-compile-targets first)"); + return failure(); + } + + auto dispatchAttr = nested->getAttrOfType("catalyst.dispatch"); + auto addrAttr = dispatchAttr ? dispatchAttr.getAs("address") : nullptr; + if (!addrAttr || addrAttr.getValue().empty()) { + nested.emitError("remote dispatch requires a non-empty 'address' key in the " + "catalyst.dispatch attribute"); + return failure(); + } + std::string moduleAddress = addrAttr.getValue().str(); + + auto pathAttr = StringAttr::get(ctx, objPathAttr.getValue()); + auto addressAttr = StringAttr::get(ctx, moduleAddress); + + // Inject remote_open (setup) once per unique address. + if (!openedAddresses.count(moduleAddress)) { + openedAddresses.insert(moduleAddress); + injectRemoteOpenIntoSetup(host, moduleAddress); + } + + // Ship the object once per module: a single `catalyst.object_file` holds every + // entry function, and remote_call resolves individual symbols within it. + injectRemoteSendBinaryIntoSetup(host, addressAttr, pathAttr); + + // Per entry-marked function: rewrite host-side calls to remote_call. + for (auto nestedFn : nested.getOps()) { + if (!isEntryPoint(nestedFn)) { + continue; + } + StringRef fnName = nestedFn.getName(); + auto decl = host.lookupSymbol(fnName); + if (!decl || !decl.isExternal()) { + continue; + } + + auto calleeAttr = StringAttr::get(ctx, fnName); + + SmallVector calls; + if (auto uses = SymbolTable::getSymbolUses(decl.getNameAttr(), host)) { + for (const SymbolTable::SymbolUse &use : *uses) { + if (auto call = dyn_cast(use.getUser())) { + calls.push_back(call); + } + } + } + for (func::CallOp call : calls) { + OpBuilder b(call); + auto custom = catalyst::CustomCallOp::create( + b, call.getLoc(), call.getResultTypes(), call.getOperands(), "remote_call", + nullptr); + custom->setAttr("catalyst.remote_kernel_path", pathAttr); + custom->setAttr("catalyst.remote_address", addressAttr); + custom->setAttr("catalyst.remote_kernel_callee", calleeAttr); + call.replaceAllUsesWith(custom.getResults()); + call.erase(); + } + + decl.erase(); + } + return success(); + } +}; + +} // namespace + +} // namespace catalyst diff --git a/mlir/test/Catalyst/DispatchRemoteTargetModules.mlir b/mlir/test/Catalyst/DispatchRemoteTargetModules.mlir new file mode 100644 index 0000000000..3dea425738 --- /dev/null +++ b/mlir/test/Catalyst/DispatchRemoteTargetModules.mlir @@ -0,0 +1,68 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// RUN: quantum-opt %s --dispatch-remote-targets | FileCheck %s + +// This pass consumes the input IR left by cross-compile-targets which carries catalyst.object_file, +// plus a catalyst.dispatch attributes. + +// setup() opens the session once and ships the module's object +// CHECK-LABEL: func.func @setup() +// CHECK: catalyst.custom_call fn("remote_open") +// CHECK-SAME: catalyst.remote_address = "ADDR:PORT" +// CHECK: catalyst.custom_call fn("remote_send_binary") +// CHECK-SAME: catalyst.remote_address = "ADDR:PORT" +// CHECK-SAME: catalyst.remote_kernel_path = "/tmp/target_compute.o" +// CHECK-NOT: catalyst.custom_call fn("remote_send_binary") +// CHECK: return + +// teardown() closes the session. +// CHECK-LABEL: func.func @teardown() +// CHECK: catalyst.custom_call fn("remote_close") + +// Each host-side call is rewritten to its own remote_call. +// CHECK-LABEL: func.func public @jit_main() +// CHECK: catalyst.custom_call fn("remote_call") +// CHECK-SAME: catalyst.remote_kernel_callee = "noop" +// CHECK: catalyst.custom_call fn("remote_call") +// CHECK-SAME: catalyst.remote_kernel_callee = "noop2" + +// The bodyless declarations and the nested module are erased. +// CHECK-NOT: func.call @noop +// CHECK-NOT: module @target_compute + +module @jit_test_dispatch { + + func.func @setup() { + return + } + + func.func @teardown() { + return + } + + func.func private @noop() + func.func private @noop2() + + func.func public @jit_main() attributes {llvm.emit_c_interface} { + func.call @noop() : () -> () + func.call @noop2() : () -> () + return + } + + module @target_compute attributes {catalyst.object_file = "/tmp/target_compute.o", catalyst.dispatch = {address = "ADDR:PORT"}} { + func.func private @noop() attributes {catalyst.entry_point} + func.func private @noop2() attributes {catalyst.entry_point} + } +} From 1404bbbfa61a93b0df5ef96a61a2603ff480a303 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 2 Jun 2026 13:49:45 +0000 Subject: [PATCH 42/75] add bufferization.access --- mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp b/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp index a0817e336b..7efb85c5e9 100644 --- a/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp +++ b/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp @@ -552,6 +552,13 @@ struct InlineNestedSymbolTablePass : PassWrappergetParentOp()->getAttr("catalyst.target")) decl->setAttr("catalyst.target", targetAttr); + // Mark tensor-typed arguments as read-only so one-shot-bufferize does not + // insert defensive copies at call sites of this external decl. + for (unsigned i = 0; i < decl.getNumArguments(); ++i) { + if (isa(decl.getArgumentTypes()[i])) + decl.setArgAttr(i, "bufferization.access", + StringAttr::get(context, "read")); + } targetDeclarations.push_back(decl); } return WalkResult::advance(); From 1334e9bde55e2b98aca7347a74d2f0def27e9069 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 2 Jun 2026 14:24:49 +0000 Subject: [PATCH 43/75] Add remote --- frontend/catalyst/api_extensions/__init__.py | 3 +- frontend/catalyst/api_extensions/target.py | 42 +++++++++++++++++++- frontend/catalyst/jax_primitives_utils.py | 6 ++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/frontend/catalyst/api_extensions/__init__.py b/frontend/catalyst/api_extensions/__init__.py index 5791fc95d1..156735b3da 100644 --- a/frontend/catalyst/api_extensions/__init__.py +++ b/frontend/catalyst/api_extensions/__init__.py @@ -40,7 +40,7 @@ measure, pauli_measure, ) -from catalyst.api_extensions.target import target +from catalyst.api_extensions.target import remote, target __all__ = ( "accelerate", @@ -62,4 +62,5 @@ "adjoint", "ctrl", "target", + "remote", ) diff --git a/frontend/catalyst/api_extensions/target.py b/frontend/catalyst/api_extensions/target.py index 37faca9440..8fc9fccd1a 100644 --- a/frontend/catalyst/api_extensions/target.py +++ b/frontend/catalyst/api_extensions/target.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Tag a PennyLane device as a separate cross-compilation target. +"""Tag a PennyLane device as a separate cross-compilation target, optionally remote. """ from dataclasses import dataclass @@ -21,6 +21,7 @@ import pennylane as qp _TARGET_ATTR = "_catalyst_target" +_DISPATCH_ATTR = "_catalyst_dispatch" @dataclass(frozen=True) @@ -38,6 +39,17 @@ class Target: triple: Optional[str] = None +@dataclass(frozen=True) +class RemoteDispatch: + """Remote dispatch spec attached to a device by :func:`remote`. + + Args: + address: Executor address, e.g. ``"127.0.0.1:1373"``. + """ + + address: str + + def target( device, *, @@ -64,6 +76,32 @@ def target( return device +def remote(device, *, address: str): + """Mark a :func:`target` device for remote dispatch and return it. + + Stamps ``catalyst.dispatch = {address}`` so the ``dispatch-remote-targets`` pass ships the + compiled object to ``address`` and rewrites host-side calls to it into remote calls. Wrap the + device with :func:`target` first to set cross-compilation options; a default target is applied + if it has none. + + Args: + device: A PennyLane device. + address: Executor address the object is shipped to and called on, e.g. ``"127.0.0.1:1373"``. + + Returns: + The same device, now also tagged for remote dispatch. + """ + if get_target(device) is None: + target(device) + setattr(device, _DISPATCH_ATTR, RemoteDispatch(address=address)) + return device + + def get_target(device) -> Optional[Target]: - """Return the :class:`Target` previously attached via :func:`target`, or ``None``.""" + """Return the :class:`Target` previously attached via :func:`target`/:func:`remote`, or ``None``.""" return getattr(device, _TARGET_ATTR, None) + + +def get_dispatch(device) -> Optional[RemoteDispatch]: + """Return the :class:`RemoteDispatch` attached via :func:`remote`, or ``None``.""" + return getattr(device, _DISPATCH_ATTR, None) diff --git a/frontend/catalyst/jax_primitives_utils.py b/frontend/catalyst/jax_primitives_utils.py index 1e88565818..d282c3b1a8 100644 --- a/frontend/catalyst/jax_primitives_utils.py +++ b/frontend/catalyst/jax_primitives_utils.py @@ -27,7 +27,7 @@ from mlir_quantum.dialects.catalyst import LaunchKernelOp from pennylane.transforms.core import BoundTransform -from catalyst.api_extensions.target import get_target +from catalyst.api_extensions.target import get_dispatch, get_target from catalyst.jax_extras.lowering import get_mlir_attribute_from_pyval from catalyst.passes import PassPlugin @@ -247,11 +247,15 @@ def lower_qnode_to_funcop(ctx, callable_, call_jaxpr, pipelines): name = "module_" + callable_.__name__ target = get_target(callable_.device) + dispatch = get_dispatch(callable_.device) # pylint: disable-next=no-member with NestedModule(ctx, name) as module, ir.InsertionPoint(module.regions[0].blocks[0]) as ip: if target is not None: fields = {k: v for k, v in dataclasses.asdict(target).items() if v is not None} module.operation.attributes["catalyst.target"] = get_mlir_attribute_from_pyval(fields) + if dispatch is not None: + fields = {k: v for k, v in dataclasses.asdict(dispatch).items() if v is not None} + module.operation.attributes["catalyst.dispatch"] = get_mlir_attribute_from_pyval(fields) transform_module_lowering(ctx, pipelines) ctx.module_context.ip = ip func_op = get_or_create_funcop(ctx, callable_, call_jaxpr, pipelines) From 75492bbd20164d987c94460ab52d2428a892d10e Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 2 Jun 2026 14:55:05 +0000 Subject: [PATCH 44/75] fix typo --- mlir/include/Catalyst/Transforms/Passes.td | 1 + 1 file changed, 1 insertion(+) diff --git a/mlir/include/Catalyst/Transforms/Passes.td b/mlir/include/Catalyst/Transforms/Passes.td index 95672736c6..c690027b7c 100644 --- a/mlir/include/Catalyst/Transforms/Passes.td +++ b/mlir/include/Catalyst/Transforms/Passes.td @@ -178,6 +178,7 @@ def CrossCompileTargetsPass : Pass<"cross-compile-targets", "mlir::ModuleOp"> { "driver from `keep_intermediate`." >, ]; +} def MarkEntryPointsPass : Pass<"mark-entry-points", "mlir::ModuleOp"> { let summary = "Mark externally-callable entries on nested modules."; From 3ec18f4d0bec3dd23f5ae9eb9f512e82f6192d1e Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 2 Jun 2026 15:10:07 +0000 Subject: [PATCH 45/75] add to the driver --- mlir/lib/Driver/CompilerDriver.cpp | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/mlir/lib/Driver/CompilerDriver.cpp b/mlir/lib/Driver/CompilerDriver.cpp index 508971dc01..dcdad4d911 100644 --- a/mlir/lib/Driver/CompilerDriver.cpp +++ b/mlir/lib/Driver/CompilerDriver.cpp @@ -479,17 +479,18 @@ llvm::LogicalResult catalyst::driver::runPipeline(PassManager &pm, const Compile } catalyst::utils::LinesCount::call(moduleOp); - // Cross-compile catalyst.target nested modules to standalone objects after - // gradient lowering and before host bufferization. + // Cross-compile catalyst.target nested modules and dispatch for execution if (pipeline.getName() == "GradientLoweringStage" && !options.workspace.empty()) { - Pipeline cctPipeline; - cctPipeline.setName("CrossCompileTargets"); + Pipeline targetPipeline; + targetPipeline.setName("CrossCompileTargets"); std::string dumpIntermediate = options.keepIntermediate ? "true" : "false"; - cctPipeline.setPasses({"cross-compile-targets{workspace=" + options.workspace.str() + - " dump-intermediate=" + dumpIntermediate + "}"}); + targetPipeline.setPasses( + {"cross-compile-targets{workspace=" + options.workspace.str() + + " dump-intermediate=" + dumpIntermediate + "}", + "dispatch-remote-targets"}); if (failed(catalyst::utils::Timer<>::timer( - catalyst::driver::runPipeline, cctPipeline.getName(), - /* add_endl */ false, pm, options, output, cctPipeline, + catalyst::driver::runPipeline, targetPipeline.getName(), + /* add_endl */ false, pm, options, output, targetPipeline, /* clHasManualPipeline */ true, moduleOp))) { return failure(); } From d71cacbe5057b2808c70d4ca958b251ca4959fd1 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 2 Jun 2026 15:59:24 +0000 Subject: [PATCH 46/75] Add integration test --- frontend/catalyst/compiler.py | 53 +++++++----------- .../test/pytest/test_cross_compile_targets.py | 56 +++++++++++++++++++ 2 files changed, 75 insertions(+), 34 deletions(-) create mode 100644 frontend/test/pytest/test_cross_compile_targets.py diff --git a/frontend/catalyst/compiler.py b/frontend/catalyst/compiler.py index e6919dd27f..43efc51613 100644 --- a/frontend/catalyst/compiler.py +++ b/frontend/catalyst/compiler.py @@ -703,41 +703,26 @@ def get_output_of(self, pipeline, workspace) -> Optional[str]: Returns (Optional[str]): output IR """ - file_content = None - for dirpath, _, filenames in os.walk(str(workspace)): - filenames = [f for f in filenames if f.endswith(".mlir") or f.endswith(".ll")] - if not filenames: - break - filenames_no_ext = [os.path.splitext(f)[0] for f in filenames] - if pipeline == "mlir": - # Sort files and pick the first one - selected_file = [ - sorted(filenames)[0], - ] - elif pipeline == "last": - # Sort files and pick the last one - selected_file = [ - sorted(filenames)[-1], - ] - else: - selected_file = [ - f - for f, name_no_ext in zip(filenames, filenames_no_ext) - if pipeline in name_no_ext - ] - if len(selected_file) != 1: - msg = f"Attempting to get output for pipeline: {pipeline}," - msg += " but no or more than one file was found.\n" - raise CompileError(msg) - filename = selected_file[0] - - full_path = os.path.join(dirpath, filename) - with open(full_path, "r", encoding="utf-8") as file: - file_content = file.read() - - if file_content is None: + workspace = str(workspace) + filenames = [f for f in os.listdir(workspace) if f.endswith(".mlir") or f.endswith(".ll")] + if not filenames: msg = f"Attempting to get output for pipeline: {pipeline}," msg += " but no file was found.\n" msg += "Are you sure the file exists?" raise CompileError(msg) - return file_content + + if pipeline == "mlir": + # Sort files and pick the first one + selected_file = [sorted(filenames)[0]] + elif pipeline == "last": + # Sort files and pick the last one + selected_file = [sorted(filenames)[-1]] + else: + selected_file = [f for f in filenames if pipeline in os.path.splitext(f)[0]] + if len(selected_file) != 1: + msg = f"Attempting to get output for pipeline: {pipeline}," + msg += " but no or more than one file was found.\n" + raise CompileError(msg) + + with open(os.path.join(workspace, selected_file[0]), "r", encoding="utf-8") as file: + return file.read() diff --git a/frontend/test/pytest/test_cross_compile_targets.py b/frontend/test/pytest/test_cross_compile_targets.py new file mode 100644 index 0000000000..f2d2e290f4 --- /dev/null +++ b/frontend/test/pytest/test_cross_compile_targets.py @@ -0,0 +1,56 @@ +# Copyright 2026 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration test for the target cross-compilation + remote-dispatch chain. +""" + +import os + +import pennylane as qp + +from catalyst import remote, target +from catalyst.compiler import CompileOptions +from catalyst.debug import get_compilation_stage +from catalyst.jit import QJIT + + +def test_remote_dispatch_full_chain(): + """A ``remote``-tagged QNode is cross-compiled to its own object and its host call is + rewritten for remote dispatch.""" + dev = remote( + target(qp.device("null.qubit", wires=2), backend="my-backend"), + address="ADDR:PORT", + ) + + @qp.qnode(dev) + def circuit(): + qp.Hadamard(0) + qp.CNOT([0, 1]) + return qp.state() + + jitted = QJIT(circuit, CompileOptions(keep_intermediate=True, link=False)) + try: + target_object = os.path.join(str(jitted.workspace), "module_circuit", "module_circuit.o") + assert os.path.exists(target_object) + + ir = get_compilation_stage(jitted, "CrossCompileTargets") + assert 'fn("remote_open")' in ir + assert 'fn("remote_send_binary")' in ir + assert 'fn("remote_call")' in ir + assert 'fn("remote_close")' in ir + assert "ADDR:PORT" in ir + assert target_object in ir + assert "module @module_circuit" not in ir + finally: + jitted.workspace.cleanup() From e011bea10151323694f55c790cd6593655ffe33d Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 2 Jun 2026 16:09:47 +0000 Subject: [PATCH 47/75] only add decleration for entry points --- mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp b/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp index 7efb85c5e9..e235a3e00b 100644 --- a/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp +++ b/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp @@ -542,6 +542,9 @@ struct InlineNestedSymbolTablePass : PassWrapper(nested) && nested->getParentOp() != symbolTable) { auto func = cast(nested); + // The host only calls entry points; helpers stay internal to the object. + if (!func->hasAttr("catalyst.entry_point")) + return WalkResult::advance(); StringRef name = func.getName(); if (!rootSymbols.insert(name).second) return WalkResult::advance(); From 4c2d06a9274cfe4274e6437f7fa0fb0a8adc9546 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 2 Jun 2026 21:20:23 +0000 Subject: [PATCH 48/75] add kernel.define --- frontend/catalyst/kernel.py | 24 ++++++++++++ frontend/test/pytest/test_kernel.py | 60 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 frontend/test/pytest/test_kernel.py diff --git a/frontend/catalyst/kernel.py b/frontend/catalyst/kernel.py index d922b9622c..b33155a0ce 100644 --- a/frontend/catalyst/kernel.py +++ b/frontend/catalyst/kernel.py @@ -18,6 +18,7 @@ import os from dataclasses import dataclass +from typing import Optional import jax.numpy as jnp @@ -77,6 +78,29 @@ def declare(name: str, artifact: str, outputs) -> KernelDescriptor: ) +def define(builder, *, name: Optional[str] = None, outputs): + """Build a kernel with ``builder`` and declare it, as a single decorator. + + Args: + builder: A backend-specific object implementing ``build(kernel_fn, *, name) -> path``, + where ``path`` points to a shared library exporting ``name`` with the + :func:`runtime_call` ABI. + name: Symbol the artifact must export. Defaults to ``kernel_fn.__name__``; + passed to both ``builder.build`` and :func:`declare`. + outputs: :class:`jax.ShapeDtypeStruct` or tuple of them, forwarded to :func:`declare`. + + Returns: + KernelDescriptor: the declared kernel. + """ + + def wrap(kernel_fn): + sym = name or getattr(kernel_fn, "__name__", None) + artifact = builder.build(kernel_fn, name=sym) + return declare(sym, artifact=str(artifact), outputs=outputs) + + return wrap + + def runtime_call(kernel_descriptor, *args): """Call a pre-compiled external kernel from inside ``@qjit``. diff --git a/frontend/test/pytest/test_kernel.py b/frontend/test/pytest/test_kernel.py new file mode 100644 index 0000000000..ab31b5567b --- /dev/null +++ b/frontend/test/pytest/test_kernel.py @@ -0,0 +1,60 @@ +# Copyright 2026 Xanadu Quantum Technologies Inc. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the ``catalyst.kernel`` declaration API (declare/define).""" + +import os + +import jax +import jax.numpy as jnp +import pytest + +from catalyst import kernel +from catalyst.kernel import KernelDescriptor + + +def test_declare(tmp_path): + """declare resolves the artifact to an absolute path and records the output spec.""" + artifact = tmp_path / "lib.so" + artifact.write_bytes(b"") + desc = kernel.declare("sym", str(artifact), jax.ShapeDtypeStruct((3,), jnp.int32)) + assert isinstance(desc, KernelDescriptor) + assert desc.name == "sym" + assert desc.artifact == os.path.abspath(str(artifact)) + assert desc.output_spec == (((3,), "int32"),) + + +def test_declare_missing_artifact(): + """A non-existent artifact is rejected at declare time.""" + with pytest.raises(FileNotFoundError): + kernel.declare("sym", "/no/such/lib.so", jax.ShapeDtypeStruct((1,), jnp.float32)) + + +def test_define(tmp_path): + """define builds via the backend builder and declares, inferring the symbol name.""" + artifact = tmp_path / "k.so" + artifact.write_bytes(b"") + + class Builder: + def build(self, kernel_fn, *, name): # pylint: disable=unused-argument + assert name == "my_kernel" + return str(artifact) + + @kernel.define(Builder(), outputs=jax.ShapeDtypeStruct((1,), jnp.int32)) + def my_kernel(): # pragma: no cover + pass + + assert isinstance(my_kernel, KernelDescriptor) + assert my_kernel.name == "my_kernel" + assert my_kernel.artifact == str(artifact) From 716fbc46851b26ca760d3f7714ade134294c89ab Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Wed, 3 Jun 2026 00:09:55 +0000 Subject: [PATCH 49/75] add entry_points to test --- mlir/test/Catalyst/NestedModuleTarget.mlir | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mlir/test/Catalyst/NestedModuleTarget.mlir b/mlir/test/Catalyst/NestedModuleTarget.mlir index 1e2f6589eb..290ab2830d 100644 --- a/mlir/test/Catalyst/NestedModuleTarget.mlir +++ b/mlir/test/Catalyst/NestedModuleTarget.mlir @@ -19,7 +19,7 @@ // CHECK-LABEL: module @host module @host { - // CHECK: func.func private @ghz_0() attributes {catalyst.target = {backend = "accel"}} + // CHECK: func.func private @ghz_0() attributes {catalyst.entry_point, catalyst.target = {backend = "accel"}} // CHECK: func.func public @jit_main func.func public @jit_main() attributes {llvm.emit_c_interface} { // CHECK-NOT: catalyst.launch_kernel @@ -32,7 +32,7 @@ module @host { // CHECK-NOT: catalyst.unique_names // CHECK: func.func @ghz_0() module @module_accel attributes {catalyst.target = {backend = "accel"}} { - func.func @ghz() { + func.func @ghz() attributes {catalyst.entry_point} { func.return } } @@ -44,7 +44,7 @@ module @host { // ----- // CHECK-LABEL: module @mixed -// CHECK: func.func private @kernel_0() attributes {catalyst.target = {backend = "accel"}} +// CHECK: func.func private @kernel_0() attributes {catalyst.entry_point, catalyst.target = {backend = "accel"}} // CHECK: func.func public @jit_run // CHECK: call @work_0() // CHECK: call @kernel_0() @@ -66,7 +66,7 @@ module @mixed { } module @module_accel2 attributes {catalyst.target = {backend = "accel"}} { - func.func @kernel() { + func.func @kernel() attributes {catalyst.entry_point} { func.return } } @@ -78,8 +78,8 @@ module @mixed { // ----- // CHECK-LABEL: module @duplicate_target_names -// CHECK-DAG: func.func private @kernel_1() attributes {attr = "value", catalyst.target = {backend = "accel"}} -// CHECK-DAG: func.func private @kernel_0() attributes {catalyst.target = {backend = "accel"}} +// CHECK-DAG: func.func private @kernel_1() attributes {attr = "value", catalyst.entry_point, catalyst.target = {backend = "accel"}} +// CHECK-DAG: func.func private @kernel_0() attributes {catalyst.entry_point, catalyst.target = {backend = "accel"}} // CHECK: func.func public @jit_run // CHECK: call @kernel_1() // CHECK: call @kernel_0() @@ -95,13 +95,13 @@ module @duplicate_target_names { } module @module_accel_a attributes {catalyst.target = {backend = "accel"}} { - func.func @kernel() attributes {attr = "value"} { + func.func @kernel() attributes {attr = "value", catalyst.entry_point} { func.return } } module @module_accel_b attributes {catalyst.target = {backend = "accel"}} { - func.func @kernel() { + func.func @kernel() attributes {catalyst.entry_point} { func.return } } From 7264f0a80fdaa5de8641a0ac1cc44857c5ba1b0d Mon Sep 17 00:00:00 2001 From: Hong-Sheng Zheng Date: Tue, 2 Jun 2026 22:19:04 -0400 Subject: [PATCH 50/75] Add remote dialect --- mlir/include/CMakeLists.txt | 1 + mlir/include/Remote/CMakeLists.txt | 1 + mlir/include/Remote/IR/CMakeLists.txt | 3 + mlir/include/Remote/IR/RemoteDialect.h | 21 ++++ mlir/include/Remote/IR/RemoteDialect.td | 48 +++++++ mlir/include/Remote/IR/RemoteOps.h | 25 ++++ mlir/include/Remote/IR/RemoteOps.td | 119 ++++++++++++++++++ mlir/lib/CMakeLists.txt | 1 + mlir/lib/Driver/CMakeLists.txt | 1 + mlir/lib/Driver/CompilerDriver.cpp | 3 + mlir/lib/Remote/CMakeLists.txt | 1 + mlir/lib/Remote/IR/CMakeLists.txt | 10 ++ mlir/lib/Remote/IR/RemoteDialect.cpp | 34 +++++ mlir/lib/Remote/IR/RemoteOps.cpp | 30 +++++ mlir/test/CMakeLists.txt | 1 + mlir/test/Remote/RemoteOps.mlir | 53 ++++++++ mlir/tools/catalyst-cli/CMakeLists.txt | 1 + mlir/tools/quantum-lsp-server/CMakeLists.txt | 1 + .../quantum-lsp-server/quantum-lsp-server.cpp | 3 + mlir/tools/quantum-opt/CMakeLists.txt | 1 + mlir/tools/quantum-opt/quantum-opt.cpp | 3 + 21 files changed, 361 insertions(+) create mode 100644 mlir/include/Remote/CMakeLists.txt create mode 100644 mlir/include/Remote/IR/CMakeLists.txt create mode 100644 mlir/include/Remote/IR/RemoteDialect.h create mode 100644 mlir/include/Remote/IR/RemoteDialect.td create mode 100644 mlir/include/Remote/IR/RemoteOps.h create mode 100644 mlir/include/Remote/IR/RemoteOps.td create mode 100644 mlir/lib/Remote/CMakeLists.txt create mode 100644 mlir/lib/Remote/IR/CMakeLists.txt create mode 100644 mlir/lib/Remote/IR/RemoteDialect.cpp create mode 100644 mlir/lib/Remote/IR/RemoteOps.cpp create mode 100644 mlir/test/Remote/RemoteOps.mlir diff --git a/mlir/include/CMakeLists.txt b/mlir/include/CMakeLists.txt index a94e4af0f9..628ccbde97 100644 --- a/mlir/include/CMakeLists.txt +++ b/mlir/include/CMakeLists.txt @@ -10,5 +10,6 @@ add_subdirectory(QecLogical) add_subdirectory(QecPhysical) add_subdirectory(Quantum) add_subdirectory(QRef) +add_subdirectory(Remote) add_subdirectory(RTIO) add_subdirectory(Test) diff --git a/mlir/include/Remote/CMakeLists.txt b/mlir/include/Remote/CMakeLists.txt new file mode 100644 index 0000000000..f33061b2d8 --- /dev/null +++ b/mlir/include/Remote/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(IR) diff --git a/mlir/include/Remote/IR/CMakeLists.txt b/mlir/include/Remote/IR/CMakeLists.txt new file mode 100644 index 0000000000..ffd2338c1a --- /dev/null +++ b/mlir/include/Remote/IR/CMakeLists.txt @@ -0,0 +1,3 @@ +add_mlir_dialect(RemoteOps remote) +add_mlir_doc(RemoteDialect RemoteDialect Remote/ -gen-dialect-doc) +add_mlir_doc(RemoteOps RemoteOps Remote/ -gen-op-doc) diff --git a/mlir/include/Remote/IR/RemoteDialect.h b/mlir/include/Remote/IR/RemoteDialect.h new file mode 100644 index 0000000000..84b1b8953a --- /dev/null +++ b/mlir/include/Remote/IR/RemoteDialect.h @@ -0,0 +1,21 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include "mlir/Bytecode/BytecodeOpInterface.h" +#include "mlir/IR/Dialect.h" +#include "mlir/IR/OpDefinition.h" + +#include "Remote/IR/RemoteOpsDialect.h.inc" diff --git a/mlir/include/Remote/IR/RemoteDialect.td b/mlir/include/Remote/IR/RemoteDialect.td new file mode 100644 index 0000000000..e45dbe206b --- /dev/null +++ b/mlir/include/Remote/IR/RemoteDialect.td @@ -0,0 +1,48 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef REMOTE_DIALECT +#define REMOTE_DIALECT + +include "mlir/IR/OpBase.td" +include "mlir/IR/DialectBase.td" + +//===----------------------------------------------------------------------===// +// Remote Dialect Definition +//===----------------------------------------------------------------------===// + +def Remote_Dialect : Dialect { + let summary = "Operations for dispatching kernels and calling symbols in a remote executor."; + let description = [{ + The `remote` dialect models a small set of operations that describe how a host program + interacts with a remote executor service: + + * Opening a session with a remote endpoint + * Shipping a cross-compiled kernel object file or assets to that endpoint + * Dispatching a previously-shipped qnode kernel + * Invoking an arbitrary symbol in a remote shared library + }]; + + let name = "remote"; + let cppNamespace = "::catalyst::remote"; +} + +//===----------------------------------------------------------------------===// +// Remote Operation Base +//===----------------------------------------------------------------------===// + +class Remote_Op traits = []> : + Op; + +#endif // REMOTE_DIALECT diff --git a/mlir/include/Remote/IR/RemoteOps.h b/mlir/include/Remote/IR/RemoteOps.h new file mode 100644 index 0000000000..69dff11735 --- /dev/null +++ b/mlir/include/Remote/IR/RemoteOps.h @@ -0,0 +1,25 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include "mlir/Bytecode/BytecodeOpInterface.h" +#include "mlir/IR/BuiltinTypes.h" +#include "mlir/IR/OpDefinition.h" +#include "mlir/Interfaces/SideEffectInterfaces.h" + +#include "Remote/IR/RemoteDialect.h" + +#define GET_OP_CLASSES +#include "Remote/IR/RemoteOps.h.inc" diff --git a/mlir/include/Remote/IR/RemoteOps.td b/mlir/include/Remote/IR/RemoteOps.td new file mode 100644 index 0000000000..05b28290cb --- /dev/null +++ b/mlir/include/Remote/IR/RemoteOps.td @@ -0,0 +1,119 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef REMOTE_OPS +#define REMOTE_OPS + +include "mlir/IR/OpBase.td" +include "mlir/IR/BuiltinAttributes.td" +include "Remote/IR/RemoteDialect.td" + +//===----------------------------------------------------------------------===// +// remote.open +//===----------------------------------------------------------------------===// + +def Remote_OpenOp : Remote_Op<"open"> { + let summary = "Open a session with a remote executor."; + let description = [{ + Establish a connection to the remote executor reachable at `address` + Subsequent remote operations targeting the same `address` reuse this session. + }]; + + let arguments = (ins StrAttr:$address); + + let assemblyFormat = [{ + `(` $address `)` attr-dict + }]; +} + +//===----------------------------------------------------------------------===// +// remote.send_binary +//===----------------------------------------------------------------------===// + +def Remote_SendBinaryOp : Remote_Op<"send_binary"> { + let summary = "Ship a compiled kernel object file to the remote executor."; + let description = [{ + Transfer the compiled binary at `binary_path` to the remote executor at `address`. + The remote loads the file and exposes all of its symbols. + `format` selects the wire encoding which is handled by the runtime. + }]; + + let arguments = (ins + StrAttr:$address, + StrAttr:$binary_path, + DefaultValuedAttr:$format + ); + + let assemblyFormat = [{ + `(` $address `,` $binary_path `)` attr-dict + }]; +} + +//===----------------------------------------------------------------------===// +// remote.launch +//===----------------------------------------------------------------------===// + +def Remote_LaunchOp : Remote_Op<"launch"> { + let summary = "Dispatch a remote-compiled kernel and receive its results."; + let description = [{ + Invoke the kernel previously registered via `remote.send_binary`. + The `inputs` operand list mirrors the original host-side `func.call` + to the kernel; `results` mirrors the kernel's return values. + }]; + + let arguments = (ins + Variadic:$inputs, + StrAttr:$address, + StrAttr:$kernel_callee + ); + + let results = (outs Variadic:$results); + + let assemblyFormat = [{ + `(` $kernel_callee `,` $address `)` `(` $inputs `)` attr-dict + `:` functional-type($inputs, $results) + }]; +} + +//===----------------------------------------------------------------------===// +// remote.call +//===----------------------------------------------------------------------===// + +def Remote_CallOp : Remote_Op<"call"> { + let summary = "Invoke a symbol in a previously-loaded shared library."; + let description = [{ + Invoke `symbol` in a shared library already loaded on the remote executor. + + The operand list packs input buffers followed by output buffers. + `num_input_args`, when present, marks the boundary: operands `[0, num_input_args)` + are inputs and the remainder are output buffers to be filled by the callee. + Without `num_input_args` all operands are treated as inputs. + }]; + + let arguments = (ins + Variadic:$inputs, + StrAttr:$address, + StrAttr:$symbol, + OptionalAttr:$num_input_args + ); + + let results = (outs Variadic:$results); + + let assemblyFormat = [{ + `(` $symbol `,` $address `)` `(` $inputs `)` attr-dict + `:` functional-type($inputs, $results) + }]; +} + +#endif // REMOTE_OPS diff --git a/mlir/lib/CMakeLists.txt b/mlir/lib/CMakeLists.txt index b4ff29b1a5..394b772188 100644 --- a/mlir/lib/CMakeLists.txt +++ b/mlir/lib/CMakeLists.txt @@ -12,5 +12,6 @@ add_subdirectory(QecLogical) add_subdirectory(QecPhysical) add_subdirectory(QRef) add_subdirectory(Quantum) +add_subdirectory(Remote) add_subdirectory(RTIO) add_subdirectory(Test) diff --git a/mlir/lib/Driver/CMakeLists.txt b/mlir/lib/Driver/CMakeLists.txt index 4605938ab8..b57d1d8335 100644 --- a/mlir/lib/Driver/CMakeLists.txt +++ b/mlir/lib/Driver/CMakeLists.txt @@ -76,6 +76,7 @@ set(LIBS ion-transforms MLIRRTIO rtio-transforms + MLIRRemote MLIRCatalystTest ${ENZYME_LIB} fmt::fmt diff --git a/mlir/lib/Driver/CompilerDriver.cpp b/mlir/lib/Driver/CompilerDriver.cpp index a2cf2016f8..d5b53ec234 100644 --- a/mlir/lib/Driver/CompilerDriver.cpp +++ b/mlir/lib/Driver/CompilerDriver.cpp @@ -88,6 +88,8 @@ #include "RegisterAllPasses.h" +#include "Remote/IR/RemoteDialect.h" + using namespace mlir; using namespace catalyst; using namespace catalyst::driver; @@ -179,6 +181,7 @@ void registerAllCatalystDialects(DialectRegistry ®istry) registry.insert(); registry.insert(); registry.insert(); + registry.insert(); registry.insert(); registry.insert(); registry.insert(); diff --git a/mlir/lib/Remote/CMakeLists.txt b/mlir/lib/Remote/CMakeLists.txt new file mode 100644 index 0000000000..f33061b2d8 --- /dev/null +++ b/mlir/lib/Remote/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(IR) diff --git a/mlir/lib/Remote/IR/CMakeLists.txt b/mlir/lib/Remote/IR/CMakeLists.txt new file mode 100644 index 0000000000..3456fba154 --- /dev/null +++ b/mlir/lib/Remote/IR/CMakeLists.txt @@ -0,0 +1,10 @@ +add_mlir_library(MLIRRemote + RemoteDialect.cpp + RemoteOps.cpp + + ADDITIONAL_HEADER_DIRS + ${PROJECT_SOURCE_DIR}/include/Remote + + DEPENDS + MLIRRemoteOpsIncGen +) diff --git a/mlir/lib/Remote/IR/RemoteDialect.cpp b/mlir/lib/Remote/IR/RemoteDialect.cpp new file mode 100644 index 0000000000..907aa766c7 --- /dev/null +++ b/mlir/lib/Remote/IR/RemoteDialect.cpp @@ -0,0 +1,34 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Remote/IR/RemoteDialect.h" + +#include "Remote/IR/RemoteOps.h" + +using namespace mlir; +using namespace catalyst::remote; + +//===----------------------------------------------------------------------===// +// Remote Dialect +//===----------------------------------------------------------------------===// + +#include "Remote/IR/RemoteOpsDialect.cpp.inc" + +void catalyst::remote::RemoteDialect::initialize() +{ + addOperations< +#define GET_OP_LIST +#include "Remote/IR/RemoteOps.cpp.inc" + >(); +} diff --git a/mlir/lib/Remote/IR/RemoteOps.cpp b/mlir/lib/Remote/IR/RemoteOps.cpp new file mode 100644 index 0000000000..54d68707d4 --- /dev/null +++ b/mlir/lib/Remote/IR/RemoteOps.cpp @@ -0,0 +1,30 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "Remote/IR/RemoteOps.h" + +#include "mlir/IR/Builders.h" +#include "mlir/IR/ImplicitLocOpBuilder.h" +#include "mlir/IR/OpImplementation.h" +#include "mlir/IR/PatternMatch.h" + +using namespace mlir; +using namespace catalyst::remote; + +//===----------------------------------------------------------------------===// +// Remote Operations +//===----------------------------------------------------------------------===// + +#define GET_OP_CLASSES +#include "Remote/IR/RemoteOps.cpp.inc" diff --git a/mlir/test/CMakeLists.txt b/mlir/test/CMakeLists.txt index bc55aa9245..7d7c4538bd 100644 --- a/mlir/test/CMakeLists.txt +++ b/mlir/test/CMakeLists.txt @@ -31,6 +31,7 @@ set(TEST_SUITES PauliFrame cli PBC + Remote RTIO QecLogical QecPhysical diff --git a/mlir/test/Remote/RemoteOps.mlir b/mlir/test/Remote/RemoteOps.mlir new file mode 100644 index 0000000000..e58a544cb5 --- /dev/null +++ b/mlir/test/Remote/RemoteOps.mlir @@ -0,0 +1,53 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// RUN: quantum-opt %s --split-input-file | FileCheck %s + +// CHECK-LABEL: func.func @open +// CHECK: remote.open("127.0.0.1:9000") +func.func @open() { + remote.open("127.0.0.1:9000") + return +} + +// ----- + +// CHECK-LABEL: func.func @send_binary +// CHECK: remote.send_binary("127.0.0.1:9000", "/tmp/qnode_0.o") +func.func @send_binary() { + remote.send_binary("127.0.0.1:9000", "/tmp/qnode_0.o") + return +} + +// ----- + +// CHECK-LABEL: func.func @launch +// CHECK: remote.launch("qnode_0", "127.0.0.1:9000") (%{{.*}}) : +// CHECK-SAME: (memref) -> memref +func.func @launch(%arg0: memref) -> memref { + %0 = remote.launch("qnode_0", "127.0.0.1:9000") (%arg0) : (memref) -> memref + return %0 : memref +} + +// ----- + +// CHECK-LABEL: func.func @call +// CHECK: remote.call("foo", "127.0.0.1:9000") +// CHECK-SAME: num_input_args = 1 : i32 +// CHECK-SAME: (memref<4xf64>, memref<4xf64>) -> () +func.func @call(%arg0: memref<4xf64>, %arg1: memref<4xf64>) { + remote.call("foo", "127.0.0.1:9000") (%arg0, %arg1) + {num_input_args = 1 : i32} : (memref<4xf64>, memref<4xf64>) -> () + return +} diff --git a/mlir/tools/catalyst-cli/CMakeLists.txt b/mlir/tools/catalyst-cli/CMakeLists.txt index a1de2381d3..57b24fac6f 100644 --- a/mlir/tools/catalyst-cli/CMakeLists.txt +++ b/mlir/tools/catalyst-cli/CMakeLists.txt @@ -47,6 +47,7 @@ set(LIBS ion-transforms MLIRRTIO rtio-transforms + MLIRRemote MLIRQecLogical MLIRQecPhysical MLIRCatalystTest diff --git a/mlir/tools/quantum-lsp-server/CMakeLists.txt b/mlir/tools/quantum-lsp-server/CMakeLists.txt index 5a6bc26be6..cc89753535 100644 --- a/mlir/tools/quantum-lsp-server/CMakeLists.txt +++ b/mlir/tools/quantum-lsp-server/CMakeLists.txt @@ -16,6 +16,7 @@ set(LIBS MLIRPauliFrame MLIRIon MLIRRTIO + MLIRRemote MLIRQecLogical MLIRQecPhysical ) diff --git a/mlir/tools/quantum-lsp-server/quantum-lsp-server.cpp b/mlir/tools/quantum-lsp-server/quantum-lsp-server.cpp index 9cc18cbcc2..4ffedb60d6 100644 --- a/mlir/tools/quantum-lsp-server/quantum-lsp-server.cpp +++ b/mlir/tools/quantum-lsp-server/quantum-lsp-server.cpp @@ -30,6 +30,8 @@ #include "Quantum/IR/QuantumDialect.h" #include "RTIO/IR/RTIODialect.h" +#include "Remote/IR/RemoteDialect.h" + int main(int argc, char **argv) { mlir::DialectRegistry registry; @@ -44,6 +46,7 @@ int main(int argc, char **argv) registry.insert(); registry.insert(); registry.insert(); + registry.insert(); registry.insert(); registry.insert(); diff --git a/mlir/tools/quantum-opt/CMakeLists.txt b/mlir/tools/quantum-opt/CMakeLists.txt index ef16ce934d..4de9caed50 100644 --- a/mlir/tools/quantum-opt/CMakeLists.txt +++ b/mlir/tools/quantum-opt/CMakeLists.txt @@ -33,6 +33,7 @@ set(LIBS ion-transforms MLIRRTIO rtio-transforms + MLIRRemote MLIRQecLogical MLIRQecPhysical MLIRCatalystTest diff --git a/mlir/tools/quantum-opt/quantum-opt.cpp b/mlir/tools/quantum-opt/quantum-opt.cpp index 5b9a279fd8..0d7d625e57 100644 --- a/mlir/tools/quantum-opt/quantum-opt.cpp +++ b/mlir/tools/quantum-opt/quantum-opt.cpp @@ -50,6 +50,8 @@ #include "RegisterAllPasses.h" +#include "Remote/IR/RemoteDialect.h" + namespace test { void registerTestDialect(mlir::DialectRegistry &); } // namespace test @@ -77,6 +79,7 @@ int main(int argc, char **argv) registry.insert(); registry.insert(); registry.insert(); + registry.insert(); registry.insert(); registry.insert(); registry.insert(); From 3a31dcff3ab5188d67f69c775205a5dc8d9561f2 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Wed, 3 Jun 2026 02:52:45 +0000 Subject: [PATCH 51/75] support custom lowering pipeline for the targets --- .../Catalyst/Transforms/CrossCompileTargets.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp b/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp index ac48fe443f..6520fecfe0 100644 --- a/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp +++ b/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp @@ -325,11 +325,20 @@ struct CrossCompileTargetsPass dumpMLIR(*standalone, kernelDir, "extracted.mlir"); } - // Lower the extracted module with the default pipeline (bufferization + - // LLVM-dialect lowering). + // Lower the extracted module. A 'pipeline' key on catalyst.target selects a named + // target-lowering pipeline resolved against the host pipeline registry. + // Without it, fall back to the default bufferization + LLVM-dialect lowering. + std::string pipelineSpec; + if (auto pipelineAttr = targetAttr.getAs("pipeline")) { + pipelineSpec = pipelineAttr.getValue().str(); + } + if (pipelineSpec.empty()) { + pipelineSpec = llvm::join(defaultLoweringPassList(), ","); + } PassManager subPM(ctx); - if (failed(parsePassPipeline(llvm::join(defaultLoweringPassList(), ","), subPM))) { - nested.emitError("failed to build the default target-lowering pipeline"); + if (failed(parsePassPipeline(pipelineSpec, subPM))) { + nested.emitError("failed to build the target-lowering pipeline '" + pipelineSpec + + "'"); return failure(); } if (failed(subPM.run(*standalone))) { From 2f38eb408e52b55e04d78670a4f34385d5fbd462 Mon Sep 17 00:00:00 2001 From: Hong-Sheng Zheng Date: Tue, 2 Jun 2026 22:53:50 -0400 Subject: [PATCH 52/75] Add remote to llvm transform --- .../DefaultPipelines/DefaultPipelines.h | 1 + mlir/include/RegisterAllPasses.h | 2 + mlir/include/Remote/CMakeLists.txt | 1 + mlir/include/Remote/Transforms/CMakeLists.txt | 4 + mlir/include/Remote/Transforms/Passes.h | 29 ++ mlir/include/Remote/Transforms/Passes.td | 38 ++ mlir/lib/Driver/CMakeLists.txt | 1 + mlir/lib/Remote/CMakeLists.txt | 1 + mlir/lib/Remote/Transforms/CMakeLists.txt | 25 + mlir/lib/Remote/Transforms/RemoteToLLVM.cpp | 478 ++++++++++++++++++ mlir/test/Remote/ConvertRemoteToLLVM.mlir | 56 ++ mlir/tools/catalyst-cli/CMakeLists.txt | 1 + mlir/tools/quantum-opt/CMakeLists.txt | 1 + 13 files changed, 638 insertions(+) create mode 100644 mlir/include/Remote/Transforms/CMakeLists.txt create mode 100644 mlir/include/Remote/Transforms/Passes.h create mode 100644 mlir/include/Remote/Transforms/Passes.td create mode 100644 mlir/lib/Remote/Transforms/CMakeLists.txt create mode 100644 mlir/lib/Remote/Transforms/RemoteToLLVM.cpp create mode 100644 mlir/test/Remote/ConvertRemoteToLLVM.mlir diff --git a/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h b/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h index 33aec77e6d..540bc96f6f 100644 --- a/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h +++ b/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h @@ -143,6 +143,7 @@ const PipelineList pipelineList{ "memref-to-llvm-tbaa", "finalize-memref-to-llvm{use-generic-functions}", "convert-index-to-llvm", + "convert-remote-to-llvm", "convert-catalyst-to-llvm", // TODO: remove this once PBC has its own pipeline "convert-pbc-to-llvm", diff --git a/mlir/include/RegisterAllPasses.h b/mlir/include/RegisterAllPasses.h index 791fe4a784..b49ef706fd 100644 --- a/mlir/include/RegisterAllPasses.h +++ b/mlir/include/RegisterAllPasses.h @@ -25,6 +25,7 @@ #include "QecPhysical/Transforms/Passes.h" #include "Quantum/Transforms/Passes.h" #include "RTIO/Transforms/Passes.h" +#include "Remote/Transforms/Passes.h" #include "Test/Transforms/Passes.h" #include "hlo-extensions/Transforms/Passes.h" @@ -43,6 +44,7 @@ inline void registerAllPasses() qecp::registerQecPhysicalPasses(); qref::registerQRefPasses(); quantum::registerQuantumPasses(); + remote::registerRemotePasses(); rtio::registerRTIOPasses(); test::registerTestPasses(); } diff --git a/mlir/include/Remote/CMakeLists.txt b/mlir/include/Remote/CMakeLists.txt index f33061b2d8..9f57627c32 100644 --- a/mlir/include/Remote/CMakeLists.txt +++ b/mlir/include/Remote/CMakeLists.txt @@ -1 +1,2 @@ add_subdirectory(IR) +add_subdirectory(Transforms) diff --git a/mlir/include/Remote/Transforms/CMakeLists.txt b/mlir/include/Remote/Transforms/CMakeLists.txt new file mode 100644 index 0000000000..590e0a582d --- /dev/null +++ b/mlir/include/Remote/Transforms/CMakeLists.txt @@ -0,0 +1,4 @@ +set(LLVM_TARGET_DEFINITIONS Passes.td) +mlir_tablegen(Passes.h.inc -gen-pass-decls -name Remote) +add_public_tablegen_target(MLIRRemotePassIncGen) +add_mlir_doc(Passes RemotePasses ./ -gen-pass-doc) diff --git a/mlir/include/Remote/Transforms/Passes.h b/mlir/include/Remote/Transforms/Passes.h new file mode 100644 index 0000000000..ef7fc110bd --- /dev/null +++ b/mlir/include/Remote/Transforms/Passes.h @@ -0,0 +1,29 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include "mlir/Pass/Pass.h" + +#include "Remote/IR/RemoteDialect.h" + +namespace catalyst { +namespace remote { + +#define GEN_PASS_DECL +#define GEN_PASS_REGISTRATION +#include "Remote/Transforms/Passes.h.inc" + +} // namespace remote +} // namespace catalyst diff --git a/mlir/include/Remote/Transforms/Passes.td b/mlir/include/Remote/Transforms/Passes.td new file mode 100644 index 0000000000..c37a9d6215 --- /dev/null +++ b/mlir/include/Remote/Transforms/Passes.td @@ -0,0 +1,38 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef REMOTE_PASSES +#define REMOTE_PASSES + +include "mlir/Pass/PassBase.td" + +def ConvertRemoteToLLVMPass : Pass<"convert-remote-to-llvm", "mlir::ModuleOp"> { + let summary = "Lower the `remote` dialect to direct calls into the Catalyst remote runtime."; + let description = [{ + Replaces each `remote.*` op with the corresponding `__catalyst__remote__*` + runtime entry point: + + * `remote.open` -> `__catalyst__remote__open(addr)` + * `remote.send_binary` -> `__catalyst__remote__send_binary(addr, path, format)` + * `remote.launch` -> `__catalyst__remote__launch(addr, "_catalyst_pyface_", ...)` + * `remote.call` -> `__catalyst__remote__call_wrapper(addr, sym, ...)` + `__catalyst__remote__free_result` + }]; + + let dependentDialects = [ + "mlir::LLVM::LLVMDialect", + "catalyst::remote::RemoteDialect", + ]; +} + +#endif // REMOTE_PASSES diff --git a/mlir/lib/Driver/CMakeLists.txt b/mlir/lib/Driver/CMakeLists.txt index b57d1d8335..ade7ca988d 100644 --- a/mlir/lib/Driver/CMakeLists.txt +++ b/mlir/lib/Driver/CMakeLists.txt @@ -77,6 +77,7 @@ set(LIBS MLIRRTIO rtio-transforms MLIRRemote + remote-transforms MLIRCatalystTest ${ENZYME_LIB} fmt::fmt diff --git a/mlir/lib/Remote/CMakeLists.txt b/mlir/lib/Remote/CMakeLists.txt index f33061b2d8..9f57627c32 100644 --- a/mlir/lib/Remote/CMakeLists.txt +++ b/mlir/lib/Remote/CMakeLists.txt @@ -1 +1,2 @@ add_subdirectory(IR) +add_subdirectory(Transforms) diff --git a/mlir/lib/Remote/Transforms/CMakeLists.txt b/mlir/lib/Remote/Transforms/CMakeLists.txt new file mode 100644 index 0000000000..3476c40708 --- /dev/null +++ b/mlir/lib/Remote/Transforms/CMakeLists.txt @@ -0,0 +1,25 @@ +set(LIBRARY_NAME remote-transforms) + +file(GLOB SRC + RemoteToLLVM.cpp +) + +get_property(dialect_libs GLOBAL PROPERTY MLIR_DIALECT_LIBS) +get_property(conversion_libs GLOBAL PROPERTY MLIR_CONVERSION_LIBS) +set(LIBS + ${dialect_libs} + ${conversion_libs} + MLIRRemote + MLIRCatalyst +) + +set(DEPENDS + MLIRRemotePassIncGen +) + +add_mlir_library(${LIBRARY_NAME} STATIC ${SRC} LINK_LIBS PRIVATE ${LIBS} DEPENDS ${DEPENDS}) +target_compile_features(${LIBRARY_NAME} PUBLIC cxx_std_20) +target_include_directories(${LIBRARY_NAME} PUBLIC + . + ${PROJECT_SOURCE_DIR}/include + ${CMAKE_BINARY_DIR}/include) diff --git a/mlir/lib/Remote/Transforms/RemoteToLLVM.cpp b/mlir/lib/Remote/Transforms/RemoteToLLVM.cpp new file mode 100644 index 0000000000..b208d8d1e9 --- /dev/null +++ b/mlir/lib/Remote/Transforms/RemoteToLLVM.cpp @@ -0,0 +1,478 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "llvm/Support/Path.h" +#include "mlir/Conversion/LLVMCommon/ConversionTarget.h" +#include "mlir/Conversion/LLVMCommon/MemRefBuilder.h" +#include "mlir/Conversion/LLVMCommon/TypeConverter.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/Dialect/LLVMIR/LLVMDialect.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/Pass/Pass.h" +#include "mlir/Transforms/DialectConversion.h" + +#include "Catalyst/Utils/EnsureFunctionDeclaration.h" +#include "Catalyst/Utils/StaticAllocas.h" + +#include "Remote/IR/RemoteOps.h" +#include "Remote/Transforms/Passes.h" + +using namespace mlir; + +namespace catalyst { +namespace remote { + +#define GEN_PASS_DEF_CONVERTREMOTETOLLVMPASS +#include "Remote/Transforms/Passes.h.inc" + +namespace { + +//===----------------------------------------------------------------------===// +// Local conversion helpers +//===----------------------------------------------------------------------===// + +Value getGlobalString(Location loc, OpBuilder &rewriter, StringRef key, StringRef value, + ModuleOp mod) +{ + auto type = LLVM::LLVMArrayType::get(IntegerType::get(rewriter.getContext(), 8), value.size()); + LLVM::GlobalOp glb = mod.lookupSymbol(key); + if (!glb) { + OpBuilder::InsertionGuard guard(rewriter); + rewriter.setInsertionPointToStart(mod.getBody()); + glb = LLVM::GlobalOp::create(rewriter, loc, type, true, LLVM::Linkage::Internal, key, + rewriter.getStringAttr(value)); + } + return LLVM::GEPOp::create(rewriter, loc, LLVM::LLVMPointerType::get(rewriter.getContext()), + type, LLVM::AddressOfOp::create(rewriter, loc, glb), + ArrayRef{0, 0}, LLVM::GEPNoWrapFlags::inbounds); +} + +// Get or create a `internal constant !llvm.array` global. +// And return a `!llvm.ptr` to its first element. +// +// llvm.mlir.global internal constant @(dense<[v0, v1, ...]> : tensor) +// : !llvm.array +// +// Call site: +// +// %addr = llvm.mlir.addressof @ : !llvm.ptr +// %ptr = llvm.getelementptr inbounds %addr[0, 0] +// : (!llvm.ptr) -> !llvm.ptr, !llvm.array +Value getGlobalI64Array(Location loc, OpBuilder &rewriter, StringRef key, ArrayRef values, + ModuleOp mod) +{ + Type ptrTy = LLVM::LLVMPointerType::get(rewriter.getContext()); + // Skip and return a null pointer if the array is empty. + if (values.empty()) { + return LLVM::ZeroOp::create(rewriter, loc, ptrTy); + } + Type i64Ty = rewriter.getI64Type(); + auto arrTy = LLVM::LLVMArrayType::get(i64Ty, values.size()); + LLVM::GlobalOp glb = mod.lookupSymbol(key); + // Create a new global if it doesn't exist. + if (!glb) { + auto tensorTy = RankedTensorType::get({static_cast(values.size())}, i64Ty); + auto valuesAttr = DenseElementsAttr::get(tensorTy, values); + OpBuilder::InsertionGuard guard(rewriter); + rewriter.setInsertionPointToStart(mod.getBody()); + glb = LLVM::GlobalOp::create(rewriter, loc, arrTy, /*isConstant=*/true, + LLVM::Linkage::Internal, key, valuesAttr); + } + return LLVM::GEPOp::create(rewriter, loc, ptrTy, arrTy, + LLVM::AddressOfOp::create(rewriter, loc, glb), + ArrayRef{0, 0}, LLVM::GEPNoWrapFlags::inbounds); +} + +// Allocate a stack buffer of `!llvm.array`. +// +// Outputs: +// +// %slot = llvm.alloca ... : !llvm.array +// %a0 = llvm.undef : !llvm.array +// %a1 = llvm.insertvalue %ptr0, %a0[0] : !llvm.array +// %a2 = llvm.insertvalue %ptr1, %a1[1] : !llvm.array +// ... +// llvm.store %aN, %slot : !llvm.array, !llvm.ptr +// +Value buildStackPtrArray(Location loc, RewriterBase &rewriter, ArrayRef ptrs) +{ + Type ptrTy = LLVM::LLVMPointerType::get(rewriter.getContext()); + if (ptrs.empty()) { + return LLVM::ZeroOp::create(rewriter, loc, ptrTy); + } + auto arrTy = LLVM::LLVMArrayType::get(ptrTy, ptrs.size()); + Value alloca = getStaticAlloca(loc, rewriter, arrTy, 1); + Value arr = LLVM::UndefOp::create(rewriter, loc, arrTy); + for (auto [i, p] : llvm::enumerate(ptrs)) { + arr = LLVM::InsertValueOp::create(rewriter, loc, arr, p, SmallVector{(int64_t)i}); + } + LLVM::StoreOp::create(rewriter, loc, arr, alloca); + return alloca; +} + +// Calculate the byte size of one memref element. +// For complex, the size is 2 * sizeof(T). 1 for real, 1 for imaginary. +int64_t memrefElemSizeBytes(MemRefType ty) +{ + Type elem = ty.getElementType(); + if (auto cplx = dyn_cast(elem)) { + return 2 * ((cplx.getElementType().getIntOrFloatBitWidth() + 7) / 8); + } + return (elem.getIntOrFloatBitWidth() + 7) / 8; +} + +/// Byte size of a primitive element, used for the byte-buffer marshalling path. +int64_t primitiveByteSize(Type ty) +{ + if (auto i = dyn_cast(ty)) { + return (i.getWidth() + 7) / 8; + } + if (auto f = dyn_cast(ty)) { + return (f.getWidth() + 7) / 8; + } + if (isa(ty)) { + return 8; + } + if (auto c = dyn_cast(ty)) { + int64_t inner = primitiveByteSize(c.getElementType()); + return inner < 0 ? -1 : 2 * inner; + } + return -1; +} + +//===----------------------------------------------------------------------===// +// remote.open -> __catalyst__remote__open(addr) +//===----------------------------------------------------------------------===// + +struct OpenOpLowering : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + + LogicalResult matchAndRewrite(remote::OpenOp op, OpAdaptor /*adaptor*/, + ConversionPatternRewriter &rewriter) const override + { + Location loc = op.getLoc(); + MLIRContext *ctx = rewriter.getContext(); + Type ptrTy = LLVM::LLVMPointerType::get(ctx); + Type i64Ty = rewriter.getI64Type(); + ModuleOp mod = op->getParentOfType(); + + Type openSig = LLVM::LLVMFunctionType::get(i64Ty, {ptrTy}); + LLVM::LLVMFuncOp openFn = catalyst::ensureFunctionDeclaration( + rewriter, op, "__catalyst__remote__open", openSig); + + Value addrPtr = + getGlobalString(loc, rewriter, "remote_setup_addr", op.getAddress().str() + '\0', mod); + + LLVM::CallOp::create(rewriter, loc, openFn, ValueRange{addrPtr}); + rewriter.eraseOp(op); + return success(); + } +}; + +//===----------------------------------------------------------------------===// +// remote.send_binary -> __catalyst__remote__send_binary(addr, path, format) +//===----------------------------------------------------------------------===// + +struct SendBinaryOpLowering : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + + LogicalResult matchAndRewrite(remote::SendBinaryOp op, OpAdaptor /*adaptor*/, + ConversionPatternRewriter &rewriter) const override + { + Location loc = op.getLoc(); + MLIRContext *ctx = rewriter.getContext(); + Type ptrTy = LLVM::LLVMPointerType::get(ctx); + Type i32Ty = rewriter.getI32Type(); + Type i64Ty = rewriter.getI64Type(); + ModuleOp mod = op->getParentOfType(); + + Type sendBinSig = LLVM::LLVMFunctionType::get(i64Ty, {ptrTy, ptrTy, i32Ty}); + LLVM::LLVMFuncOp sendBinFn = catalyst::ensureFunctionDeclaration( + rewriter, op, "__catalyst__remote__send_binary", sendBinSig); + + std::string tag = llvm::sys::path::stem(op.getBinaryPath()).str(); + Value addrPtr = + getGlobalString(loc, rewriter, "remote_addr_" + tag, op.getAddress().str() + '\0', mod); + Value pathPtr = getGlobalString(loc, rewriter, "remote_path_" + tag, + op.getBinaryPath().str() + '\0', mod); + Value formatTag = + LLVM::ConstantOp::create(rewriter, loc, rewriter.getI32IntegerAttr(op.getFormat())); + + LLVM::CallOp::create(rewriter, loc, sendBinFn, ValueRange{addrPtr, pathPtr, formatTag}); + rewriter.eraseOp(op); + return success(); + } +}; + +//===----------------------------------------------------------------------===// +// remote.launch -> __catalyst__remote__launch(addr, sym, +// num_in, in_descs, in_ranks, in_sizes, +// num_out, out_descs, out_ranks, out_sizes) +//===----------------------------------------------------------------------===// + +struct LaunchOpLowering : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + + LogicalResult matchAndRewrite(remote::LaunchOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override + { + Location loc = op.getLoc(); + MLIRContext *ctx = rewriter.getContext(); + Type ptrTy = LLVM::LLVMPointerType::get(ctx); + Type i64Ty = rewriter.getI64Type(); + Type voidTy = LLVM::LLVMVoidType::get(ctx); + ModuleOp mod = op->getParentOfType(); + + // parameters: + // - addr: the address of the remote executor + // - symbol: the symbol to invoke + // - num_inputs: the number of input arguments + // - input_descs: the input descriptor array + // - input_ranks: the input rank array + // - input_sizes: the input size array + // - num_outputs: the number of output arguments + // - output_descs: the output descriptor array + // - output_ranks: the output rank array + Type launchSig = LLVM::LLVMFunctionType::get( + voidTy, {ptrTy, ptrTy, i64Ty, ptrTy, ptrTy, ptrTy, i64Ty, ptrTy, ptrTy, ptrTy}); + LLVM::LLVMFuncOp launchFn = catalyst::ensureFunctionDeclaration( + rewriter, op, "__catalyst__remote__launch", launchSig); + + std::string callee = op.getKernelCallee().str(); + Value addrPtr = getGlobalString(loc, rewriter, "remote_addr_" + callee, + op.getAddress().str() + '\0', mod); + + std::string symbolName = "_catalyst_pyface_" + callee; + Value symbolPtr = + getGlobalString(loc, rewriter, "remote_sym_" + callee, symbolName + '\0', mod); + + SmallVector inputDescPtrs; + SmallVector inputRanks, inputElemSizes; + for (auto [origInput, llvmInput] : llvm::zip(op.getInputs(), adaptor.getInputs())) { + auto memrefTy = cast(origInput.getType()); + inputRanks.push_back(memrefTy.getRank()); + inputElemSizes.push_back(memrefElemSizeBytes(memrefTy)); + Value alloca = getStaticAlloca(loc, rewriter, llvmInput.getType(), 1); + LLVM::StoreOp::create(rewriter, loc, llvmInput, alloca); + inputDescPtrs.push_back(alloca); + } + + SmallVector outputDescPtrs; + SmallVector outputRanks, outputElemSizes; + for (Type resultTy : op.getResultTypes()) { + auto memrefTy = cast(resultTy); + outputRanks.push_back(memrefTy.getRank()); + outputElemSizes.push_back(memrefElemSizeBytes(memrefTy)); + Type llvmDescTy = getTypeConverter()->convertType(resultTy); + Value alloca = getStaticAlloca(loc, rewriter, llvmDescTy, 1); + outputDescPtrs.push_back(alloca); + } + + Value inputDescsArr = buildStackPtrArray(loc, rewriter, inputDescPtrs); + Value outputDescsArr = buildStackPtrArray(loc, rewriter, outputDescPtrs); + + Value inputRanksArr = + getGlobalI64Array(loc, rewriter, "remote_in_ranks_" + callee, inputRanks, mod); + Value inputSizesArr = + getGlobalI64Array(loc, rewriter, "remote_in_sizes_" + callee, inputElemSizes, mod); + Value outputRanksArr = + getGlobalI64Array(loc, rewriter, "remote_out_ranks_" + callee, outputRanks, mod); + Value outputSizesArr = + getGlobalI64Array(loc, rewriter, "remote_out_sizes_" + callee, outputElemSizes, mod); + + Value numInputs = LLVM::ConstantOp::create( + rewriter, loc, rewriter.getI64IntegerAttr(inputDescPtrs.size())); + Value numOutputs = LLVM::ConstantOp::create( + rewriter, loc, rewriter.getI64IntegerAttr(outputDescPtrs.size())); + + LLVM::CallOp::create(rewriter, loc, launchFn, + ValueRange{addrPtr, symbolPtr, numInputs, inputDescsArr, inputRanksArr, + inputSizesArr, numOutputs, outputDescsArr, outputRanksArr, + outputSizesArr}); + + SmallVector results; + for (auto [descPtr, resultTy] : llvm::zip(outputDescPtrs, op.getResultTypes())) { + Type llvmDescTy = getTypeConverter()->convertType(resultTy); + Value loaded = LLVM::LoadOp::create(rewriter, loc, llvmDescTy, descPtr); + results.push_back(loaded); + } + rewriter.replaceOp(op, results); + return success(); + } +}; + +//===----------------------------------------------------------------------===// +// remote.call -> __catalyst__remote__call_wrapper(addr, sym, +// args_buf, args_size, +// &out_buf, &out_size) +//===----------------------------------------------------------------------===// + +struct CallOpLowering : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + + /// Static-shape memref byte count, or -1 on unsupported types. + static int64_t memrefBufferBytes(Type ty, Operation *op) + { + auto memrefTy = dyn_cast(ty); + if (!memrefTy) { + op->emitOpError("remote.call requires memref-typed operands; got ") << ty; + return -1; + } + if (!memrefTy.hasStaticShape()) { + op->emitOpError("remote.call requires static-shape memref args; got ") << memrefTy; + return -1; + } + int64_t elemSz = primitiveByteSize(memrefTy.getElementType()); + if (elemSz < 0) { + op->emitOpError("unsupported memref element type for remote.call: ") + << memrefTy.getElementType(); + return -1; + } + return memrefTy.getNumElements() * elemSz; + } + + LogicalResult matchAndRewrite(remote::CallOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override + { + Location loc = op.getLoc(); + MLIRContext *ctx = rewriter.getContext(); + Type ptrTy = LLVM::LLVMPointerType::get(ctx); + Type i32Ty = rewriter.getI32Type(); + Type i64Ty = rewriter.getI64Type(); + Type voidTy = LLVM::LLVMVoidType::get(ctx); + Type i8Ty = rewriter.getI8Type(); + ModuleOp mod = op->getParentOfType(); + + // parameters: + // - addr: the address of the remote executor + // - symbol: the symbol to invoke + // - args_buf: the input buffer + // - args_size: the size of the input buffer + // - out_buf: the output buffer + // - out_size: the size of the output buffer + Type callSig = + LLVM::LLVMFunctionType::get(i32Ty, {ptrTy, ptrTy, ptrTy, i64Ty, ptrTy, ptrTy}); + LLVM::LLVMFuncOp callFn = catalyst::ensureFunctionDeclaration( + rewriter, op, "__catalyst__remote__call_wrapper", callSig); + Type freeSig = LLVM::LLVMFunctionType::get(voidTy, {ptrTy}); + LLVM::LLVMFuncOp freeFn = catalyst::ensureFunctionDeclaration( + rewriter, op, "__catalyst__remote__free_result", freeSig); + + unsigned numInputs = + op.getNumInputArgs().value_or(static_cast(op.getInputs().size())); + if (numInputs > op.getInputs().size()) { + return op->emitOpError("num_input_args exceeds operand count"); + } + + SmallVector inputOffsets; + int64_t totalInputBytes = 0; + for (unsigned i = 0; i < numInputs; ++i) { + int64_t argSize = memrefBufferBytes(op.getInputs()[i].getType(), op); + if (argSize < 0) { + return failure(); + } + inputOffsets.push_back(totalInputBytes); + totalInputBytes += argSize; + } + for (unsigned i = numInputs; i < op.getInputs().size(); ++i) { + if (memrefBufferBytes(op.getInputs()[i].getType(), op) < 0) { + return failure(); + } + } + + Type bufTy = LLVM::LLVMArrayType::get(i8Ty, totalInputBytes > 0 ? totalInputBytes : 1); + std::string sym = op.getSymbol().str(); + + Value addrPtr = getGlobalString(loc, rewriter, "remote_lib_addr_" + sym, + op.getAddress().str() + '\0', mod); + Value symPtr = getGlobalString(loc, rewriter, "remote_lib_sym_" + sym, sym + '\0', mod); + + Value argsBuf = getStaticAlloca(loc, rewriter, bufTy, 1); + for (unsigned i = 0; i < numInputs; ++i) { + auto memrefTy = cast(op.getInputs()[i].getType()); + int64_t numBytes = + memrefTy.getNumElements() * primitiveByteSize(memrefTy.getElementType()); + Value src = MemRefDescriptor(adaptor.getInputs()[i]).alignedPtr(rewriter, loc); + Value slot = LLVM::GEPOp::create( + rewriter, loc, ptrTy, bufTy, argsBuf, + ArrayRef{0, static_cast(inputOffsets[i])}, + LLVM::GEPNoWrapFlags::inbounds); + Value sizeVal = + LLVM::ConstantOp::create(rewriter, loc, rewriter.getI64IntegerAttr(numBytes)); + LLVM::MemcpyOp::create(rewriter, loc, slot, src, sizeVal, /*isVolatile=*/false); + } + Value argsSize = + LLVM::ConstantOp::create(rewriter, loc, rewriter.getI64IntegerAttr(totalInputBytes)); + + Value outBufSlot = getStaticAlloca(loc, rewriter, ptrTy, 1); + Value outSizeSlot = getStaticAlloca(loc, rewriter, i64Ty, 1); + + LLVM::CallOp::create( + rewriter, loc, callFn, + ValueRange{addrPtr, symPtr, argsBuf, argsSize, outBufSlot, outSizeSlot}); + + Value outBuf = LLVM::LoadOp::create(rewriter, loc, ptrTy, outBufSlot); + int64_t outOffset = 0; + for (unsigned i = numInputs; i < op.getInputs().size(); ++i) { + auto memrefTy = cast(op.getInputs()[i].getType()); + int64_t numBytes = + memrefTy.getNumElements() * primitiveByteSize(memrefTy.getElementType()); + Value destPtr = MemRefDescriptor(adaptor.getInputs()[i]).alignedPtr(rewriter, loc); + Value src = LLVM::GEPOp::create(rewriter, loc, ptrTy, i8Ty, outBuf, + ArrayRef{static_cast(outOffset)}, + LLVM::GEPNoWrapFlags::inbounds); + Value sizeVal = + LLVM::ConstantOp::create(rewriter, loc, rewriter.getI64IntegerAttr(numBytes)); + LLVM::MemcpyOp::create(rewriter, loc, destPtr, src, sizeVal, /*isVolatile=*/false); + outOffset += numBytes; + } + + LLVM::CallOp::create(rewriter, loc, freeFn, ValueRange{outBuf}); + rewriter.eraseOp(op); + return success(); + } +}; + +//===----------------------------------------------------------------------===// +// Pass +//===----------------------------------------------------------------------===// + +struct ConvertRemoteToLLVMPass : impl::ConvertRemoteToLLVMPassBase { + using ConvertRemoteToLLVMPassBase::ConvertRemoteToLLVMPassBase; + + void runOnOperation() final + { + MLIRContext *ctx = &getContext(); + ModuleOp mod = getOperation(); + + LLVMTypeConverter typeConverter(ctx); + + RewritePatternSet patterns(ctx); + patterns.add( + typeConverter, ctx); + + LLVMConversionTarget target(*ctx); + target.addLegalOp(); + target.addIllegalDialect(); + + if (failed(applyPartialConversion(mod, target, std::move(patterns)))) { + signalPassFailure(); + } + } +}; + +} // namespace + +} // namespace remote +} // namespace catalyst diff --git a/mlir/test/Remote/ConvertRemoteToLLVM.mlir b/mlir/test/Remote/ConvertRemoteToLLVM.mlir new file mode 100644 index 0000000000..9643e5495a --- /dev/null +++ b/mlir/test/Remote/ConvertRemoteToLLVM.mlir @@ -0,0 +1,56 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// RUN: quantum-opt %s --convert-remote-to-llvm --split-input-file | FileCheck %s + +// CHECK: llvm.func @__catalyst__remote__open(!llvm.ptr) -> i64 +// CHECK-LABEL: func.func @open +func.func @open() { + // CHECK: llvm.call @__catalyst__remote__open + remote.open("127.0.0.1:9000") + return +} + +// ----- + +// CHECK: llvm.func @__catalyst__remote__send_binary(!llvm.ptr, !llvm.ptr, i32) -> i64 +// CHECK-LABEL: func.func @send_binary +func.func @send_binary() { + // CHECK: llvm.call @__catalyst__remote__send_binary + remote.send_binary("127.0.0.1:9000", "/tmp/qnode_0.o") + return +} + +// ----- + +// CHECK: llvm.func @__catalyst__remote__launch +// CHECK-LABEL: func.func @launch +func.func @launch(%arg0: memref) -> memref { + // CHECK: llvm.call @__catalyst__remote__launch + %0 = remote.launch("qnode_0", "127.0.0.1:9000") (%arg0) : (memref) -> memref + return %0 : memref +} + +// ----- + +// CHECK-DAG: llvm.func @__catalyst__remote__call_wrapper +// CHECK-DAG: llvm.func @__catalyst__remote__free_result +// CHECK-LABEL: func.func @call +func.func @call(%arg0: memref<4xf64>, %arg1: memref<4xf64>) { + // CHECK: llvm.call @__catalyst__remote__call_wrapper + // CHECK: llvm.call @__catalyst__remote__free_result + remote.call("foo", "127.0.0.1:9000") (%arg0, %arg1) + {num_input_args = 1 : i32} : (memref<4xf64>, memref<4xf64>) -> () + return +} diff --git a/mlir/tools/catalyst-cli/CMakeLists.txt b/mlir/tools/catalyst-cli/CMakeLists.txt index 57b24fac6f..ec3fa54340 100644 --- a/mlir/tools/catalyst-cli/CMakeLists.txt +++ b/mlir/tools/catalyst-cli/CMakeLists.txt @@ -48,6 +48,7 @@ set(LIBS MLIRRTIO rtio-transforms MLIRRemote + remote-transforms MLIRQecLogical MLIRQecPhysical MLIRCatalystTest diff --git a/mlir/tools/quantum-opt/CMakeLists.txt b/mlir/tools/quantum-opt/CMakeLists.txt index 4de9caed50..bbfdee58e9 100644 --- a/mlir/tools/quantum-opt/CMakeLists.txt +++ b/mlir/tools/quantum-opt/CMakeLists.txt @@ -34,6 +34,7 @@ set(LIBS MLIRRTIO rtio-transforms MLIRRemote + remote-transforms MLIRQecLogical MLIRQecPhysical MLIRCatalystTest From b90471ec13c7b5925ecd4fcfe72a28d969007042 Mon Sep 17 00:00:00 2001 From: Hong-Sheng Zheng Date: Tue, 2 Jun 2026 22:59:57 -0400 Subject: [PATCH 53/75] formattd --- mlir/include/RegisterAllPasses.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mlir/include/RegisterAllPasses.h b/mlir/include/RegisterAllPasses.h index b49ef706fd..717d776a7a 100644 --- a/mlir/include/RegisterAllPasses.h +++ b/mlir/include/RegisterAllPasses.h @@ -25,10 +25,11 @@ #include "QecPhysical/Transforms/Passes.h" #include "Quantum/Transforms/Passes.h" #include "RTIO/Transforms/Passes.h" -#include "Remote/Transforms/Passes.h" #include "Test/Transforms/Passes.h" #include "hlo-extensions/Transforms/Passes.h" +#include "Remote/Transforms/Passes.h" + namespace catalyst { inline void registerAllPasses() From beacc03dde7ea2a6721b790428e9f262e356869c Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Wed, 3 Jun 2026 03:08:23 +0000 Subject: [PATCH 54/75] skip t gates for cliffotd-only circuits --- .../qecl/convert_quantum_to_qecl.py | 13 +++++++++-- .../qecl/test_xdsl_convert_quantum_to_qecl.py | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/frontend/catalyst/python_interface/transforms/qecl/convert_quantum_to_qecl.py b/frontend/catalyst/python_interface/transforms/qecl/convert_quantum_to_qecl.py index d164d382e8..e32fd6fd97 100644 --- a/frontend/catalyst/python_interface/transforms/qecl/convert_quantum_to_qecl.py +++ b/frontend/catalyst/python_interface/transforms/qecl/convert_quantum_to_qecl.py @@ -623,8 +623,17 @@ def apply(self, ctx: Context, op: builtin.ModuleOp) -> None: module_block = op.regions[0].blocks.first assert module_block is not None, "Module has no block" - t_subroutine = self.create_t_subroutine() - module_block.add_op(t_subroutine) + + # The apply_T subroutine is built from `qecl.fabricate`. + # only emit it when the circuit actually contains a T gate. + has_t_gate = any( + isinstance(inner, quantum.CustomOp) and inner.gate_name.data == "T" + for inner in op.walk() + ) + t_subroutine = None + if has_t_gate: + t_subroutine = self.create_t_subroutine() + module_block.add_op(t_subroutine) PatternRewriteWalker( GreedyRewritePatternApplier( diff --git a/frontend/test/pytest/python_interface/transforms/qecl/test_xdsl_convert_quantum_to_qecl.py b/frontend/test/pytest/python_interface/transforms/qecl/test_xdsl_convert_quantum_to_qecl.py index 364a3a46b3..4ed9552afd 100644 --- a/frontend/test/pytest/python_interface/transforms/qecl/test_xdsl_convert_quantum_to_qecl.py +++ b/frontend/test/pytest/python_interface/transforms/qecl/test_xdsl_convert_quantum_to_qecl.py @@ -670,6 +670,29 @@ def test_gate_t_k_1(self, run_filecheck, quantum_to_qecl_pipeline_k_1): """ run_filecheck(program, quantum_to_qecl_pipeline_k_1) + def test_no_apply_t_subroutine_for_clifford_circuit( + self, run_filecheck, quantum_to_qecl_pipeline_k_1 + ): + """Test that the `apply_T` magic-state subroutine is NOT emitted for a Clifford-only + circuit (one with no T gates). + + """ + program = """ + builtin.module @module_circuit { + func.func @test_func() attributes {quantum.node} { + // CHECK: qecl.hadamard + %0 = "test.op"() : () -> !qecl.codeblock<1> + %1 = builtin.unrealized_conversion_cast %0 : !qecl.codeblock<1> to !quantum.bit + %2 = quantum.custom "Hadamard"() %1 : !quantum.bit + %3 = "test.op"(%2) : (!quantum.bit) -> !quantum.bit // To prevent DCE + return + } + // CHECK-NOT: @apply_T + // CHECK-NOT: qecl.fabricate + } + """ + run_filecheck(program, quantum_to_qecl_pipeline_k_1) + def test_gate_cnot_k_1(self, run_filecheck, quantum_to_qecl_pipeline_k_1): """Test that CNOT gates (`quantum.custom "CNOT"() ops) are converted to their corresponding `qecl.cnot` ops for k = 1. From ddd6ad369b98fdac3220c7e8393bbb144da0ca73 Mon Sep 17 00:00:00 2001 From: Hong-Sheng Zheng Date: Thu, 4 Jun 2026 11:27:36 -0400 Subject: [PATCH 55/75] Add remote runtime --- frontend/catalyst/compiler.py | 4 + runtime/Makefile | 6 + runtime/include/RemoteCAPI.h | 46 ++ runtime/include/RuntimeCAPI.h | 3 + runtime/lib/CMakeLists.txt | 4 + runtime/lib/capi/RuntimeCAPI.cpp | 2 + runtime/lib/remote/CMakeLists.txt | 63 +++ runtime/lib/remote/RemoteRuntime.cpp | 267 ++++++++++ runtime/lib/remote/RemoteSession.cpp | 748 +++++++++++++++++++++++++++ runtime/lib/remote/RemoteSession.hpp | 55 ++ 10 files changed, 1198 insertions(+) create mode 100644 runtime/include/RemoteCAPI.h create mode 100644 runtime/lib/remote/CMakeLists.txt create mode 100644 runtime/lib/remote/RemoteRuntime.cpp create mode 100644 runtime/lib/remote/RemoteSession.cpp create mode 100644 runtime/lib/remote/RemoteSession.hpp diff --git a/frontend/catalyst/compiler.py b/frontend/catalyst/compiler.py index e6919dd27f..b9b9159692 100644 --- a/frontend/catalyst/compiler.py +++ b/frontend/catalyst/compiler.py @@ -176,6 +176,10 @@ def get_default_flags(options): "-lrt_decoder", ] + rt_remote_so = "librt_remote" + file_extension + if os.path.isfile(os.path.join(rt_lib_path, rt_remote_so)): + default_flags.append("-lrt_remote") + # If OQD runtime capi is built, link to it as well # TODO: This is not ideal and should be replaced when the compiler is device aware if os.path.isfile(os.path.join(rt_lib_path, "librt_OQD_capi" + file_extension)): diff --git a/runtime/Makefile b/runtime/Makefile index 84c9ba328e..ec79fc9c6f 100644 --- a/runtime/Makefile +++ b/runtime/Makefile @@ -13,6 +13,7 @@ CODE_COVERAGE ?= OFF BUILD_TYPE ?= RelWithDebInfo ENABLE_OPENQASM ?= ON ENABLE_OQD ?= OFF +ENABLE_REMOTE ?= OFF ENABLE_ASAN ?= OFF STRICT_WARNINGS ?= ON LLVM_DIR ?= $(MK_DIR)/../mlir/llvm-project/ @@ -53,6 +54,10 @@ ifeq ($(ENABLE_OQD), ON) TEST_TARGETS += runner_tests_oqd endif +ifeq ($(ENABLE_REMOTE), ON) + BUILD_TARGETS += rt_remote +endif + .PHONY: help help: @echo "Please use \`make ' where is one of" @@ -78,6 +83,7 @@ configure: -DCMAKE_CXX_COMPILER_LAUNCHER=$(COMPILER_LAUNCHER) \ -DENABLE_OPENQASM=$(ENABLE_OPENQASM) \ -DENABLE_OQD=$(ENABLE_OQD) \ + -DENABLE_REMOTE=$(ENABLE_REMOTE) \ -DENABLE_CODE_COVERAGE=$(CODE_COVERAGE) \ -DPython_EXECUTABLE=$(PYTHON) \ -DENABLE_ADDRESS_SANITIZER=$(ENABLE_ASAN) \ diff --git a/runtime/include/RemoteCAPI.h b/runtime/include/RemoteCAPI.h new file mode 100644 index 0000000000..7cd69254f3 --- /dev/null +++ b/runtime/include/RemoteCAPI.h @@ -0,0 +1,46 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once +#ifndef REMOTECAPI_H +#define REMOTECAPI_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Remote Runtime API. This is expected to be called by the host program to establish a remote +// session. Including open a session, send the binary to the remote, launch the kernel on the remote +// and close the session. +int __catalyst__remote__open(const char *addr); +int __catalyst__remote__send_binary(const char *addr, const char *path, uint32_t format); +void __catalyst__remote__launch(const char *addr, const char *entry_symbol, size_t num_inputs, + void *const *input_descs, const size_t *input_ranks, + const size_t *input_elem_sizes, size_t num_outputs, + void *const *output_descs, const size_t *output_ranks, + const size_t *output_elem_sizes); +int __catalyst__remote__call_wrapper(const char *addr, const char *symbol, const char *args_buf, + size_t args_size, void **out_buf, size_t *out_size); +void __catalyst__remote__free_result(void *buf); + +int __catalyst__remote__close(); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif diff --git a/runtime/include/RuntimeCAPI.h b/runtime/include/RuntimeCAPI.h index cb2e637d5f..61c5016705 100644 --- a/runtime/include/RuntimeCAPI.h +++ b/runtime/include/RuntimeCAPI.h @@ -119,6 +119,9 @@ RESULT *__catalyst__mbqc__measure_in_basis(QUBIT *, uint32_t, double, int32_t); // Async runtime error void __catalyst__host__rt__unrecoverable_error(); +// Allocate a host buffer of `size` bytes and register it with the runtime's memory manager +void *__catalyst__rt__alloc_managed(size_t size); + #ifdef __cplusplus } // extern "C" #endif diff --git a/runtime/lib/CMakeLists.txt b/runtime/lib/CMakeLists.txt index a4cd496c33..d4abd8e0e8 100644 --- a/runtime/lib/CMakeLists.txt +++ b/runtime/lib/CMakeLists.txt @@ -50,3 +50,7 @@ add_subdirectory(QEC) if(ENABLE_OQD) add_subdirectory(OQDcapi) endif() + +if(ENABLE_REMOTE) +add_subdirectory(remote) +endif() diff --git a/runtime/lib/capi/RuntimeCAPI.cpp b/runtime/lib/capi/RuntimeCAPI.cpp index 4c26399516..f0ac427f10 100644 --- a/runtime/lib/capi/RuntimeCAPI.cpp +++ b/runtime/lib/capi/RuntimeCAPI.cpp @@ -169,6 +169,8 @@ void *_mlir_memref_to_llvm_alloc(size_t size) return ptr; } +void *__catalyst__rt__alloc_managed(size_t size) { return _mlir_memref_to_llvm_alloc(size); } + void *_mlir_memref_to_llvm_aligned_alloc(size_t alignment, size_t size) { void *ptr = aligned_alloc(alignment, size); diff --git a/runtime/lib/remote/CMakeLists.txt b/runtime/lib/remote/CMakeLists.txt new file mode 100644 index 0000000000..1030e54f26 --- /dev/null +++ b/runtime/lib/remote/CMakeLists.txt @@ -0,0 +1,63 @@ +############################################### +# library catalyst_remote_session + rt_remote # +############################################### + +find_package(LLVM CONFIG REQUIRED) + +llvm_map_components_to_libnames(_remote_llvm_libs + core support orcjit jitlink object passes + ${LLVM_TARGETS_TO_BUILD} +) + +# catalyst_remote_session +add_library(catalyst_remote_session SHARED RemoteSession.cpp) + +target_include_directories(catalyst_remote_session + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_include_directories(catalyst_remote_session SYSTEM PRIVATE + ${LLVM_INCLUDE_DIRS} +) + +target_link_libraries(catalyst_remote_session PRIVATE + rt_capi + ${_remote_llvm_libs} + pthread + ${CMAKE_DL_LIBS} +) + +target_compile_options(catalyst_remote_session PRIVATE -fno-rtti) + +set_property(TARGET catalyst_remote_session PROPERTY POSITION_INDEPENDENT_CODE ON) + +if(NOT APPLE) + set_property(TARGET catalyst_remote_session APPEND PROPERTY BUILD_RPATH $ORIGIN) +else() + set_property(TARGET catalyst_remote_session APPEND PROPERTY BUILD_RPATH @loader_path) +endif() + +# rt_remote + +add_library(rt_remote SHARED RemoteRuntime.cpp) + +target_include_directories(rt_remote + PRIVATE + ${runtime_includes} + ${CMAKE_CURRENT_SOURCE_DIR} +) + +target_link_libraries(rt_remote PRIVATE + catalyst_remote_session + rt_capi + pthread + ${CMAKE_DL_LIBS} +) + +set_property(TARGET rt_remote PROPERTY POSITION_INDEPENDENT_CODE ON) + +if(NOT APPLE) + set_property(TARGET rt_remote APPEND PROPERTY BUILD_RPATH $ORIGIN) +else() + set_property(TARGET rt_remote APPEND PROPERTY BUILD_RPATH @loader_path) +endif() diff --git a/runtime/lib/remote/RemoteRuntime.cpp b/runtime/lib/remote/RemoteRuntime.cpp new file mode 100644 index 0000000000..55f662b75e --- /dev/null +++ b/runtime/lib/remote/RemoteRuntime.cpp @@ -0,0 +1,267 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Exception.hpp" +#include "RemoteCAPI.h" +#include "RemoteSession.hpp" + +namespace { + +struct RemoteEntry { + catalyst::remote::RemoteSession *session = nullptr; // The remote session handle. + std::set loaded_paths; // The paths of the binaries that are going to be loaded + // into the remote session. + std::mutex mu; // The mutex to protect the loaded paths. + + ~RemoteEntry() + { + if (session) { + catalyst::remote::close(session); + session = nullptr; + } + } +}; + +std::mutex g_map_mu; + +// Each address has its own RemoteEntry, so we can dispatch the object file to different remote +// sessions. +std::map> remote_sessions; + +// DEBUG logs +bool remote_verbose() +{ + static const bool v = []() { + const char *e = std::getenv("CATALYST_REMOTE_VERBOSE"); + return e && *e && *e != '0'; + }(); + return v; +} + +// Look up or create the entry for `addr`. +RemoteEntry *find_or_create_entry(const char *addr, bool create_if_missing) +{ + if (!addr || !*addr) { + return nullptr; + } + std::lock_guard lock(g_map_mu); + auto it = remote_sessions.find(addr); + if (it != remote_sessions.end()) { + return it->second.get(); + } + if (!create_if_missing) { + return nullptr; + } + auto inserted = remote_sessions.emplace(std::string(addr), std::make_unique()); + return inserted.first->second.get(); +} + +} // namespace + +extern "C" { + +int __catalyst__remote__open(const char *addr) +{ + if (!addr || !*addr) { + RT_FAIL("Empty address"); + } + RemoteEntry *entry = find_or_create_entry(addr, /*create_if_missing=*/true); + std::string err_msg; + { + std::lock_guard lock(entry->mu); + if (entry->session) { + return 0; // idempotent per addr + } + if (remote_verbose()) { + std::fprintf(stderr, "[remote] open(addr=%s)\n", addr); + } + entry->session = catalyst::remote::open(addr); + if (entry->session) { + if (remote_verbose()) { + std::fprintf(stderr, "[remote] open(%s) OK\n", addr); + } + return 0; + } + err_msg = "Could not connect to catalyst-executor at "; + err_msg += addr; + err_msg += ": "; + err_msg += catalyst::remote::last_error(); + } + { + std::lock_guard mapLock(g_map_mu); + remote_sessions.erase(addr); + } + RT_FAIL(err_msg.c_str()); +} + +int __catalyst__remote__send_binary(const char *addr, const char *path, uint32_t format) +{ + RemoteEntry *entry = find_or_create_entry(addr, /*create_if_missing=*/false); + if (!entry) { + RT_FAIL("No session found, call __catalyst__remote__open first."); + } + std::lock_guard lock(entry->mu); + if (!entry->session) { + std::string msg = "__catalyst__remote__send_binary("; + msg += addr; + msg += "): session is closed."; + RT_FAIL(msg.c_str()); + } + if (!path || !*path) { + return 0; + } + std::string key(path); + if (!entry->loaded_paths.insert(key).second) { + return 0; + } + if (remote_verbose()) { + std::fprintf(stderr, "[remote] send_binary(addr=%s, path=%s, format=%u)\n", addr, path, + format); + } + + int rc = 0; + switch (format) { + case 0: + rc = catalyst::remote::load_object_path(entry->session, path); + break; + case 1: + rc = catalyst::remote::load_asset_path(entry->session, path); + break; + default: { + std::string msg = "unknown binary format tag "; + msg += std::to_string(format); + entry->loaded_paths.erase(key); + RT_FAIL(msg.c_str()); + } + } + + if (rc != 0) { + std::string msg = catalyst::remote::last_error(); + entry->loaded_paths.erase(key); + RT_FAIL(msg.c_str()); + } + return 0; +} + +/** + * @brief Generic ORC wrapper-function call by symbol name. Returns 0 on success, -1 on error. + * + * @param addr The address of the remote session. + * @param symbol The symbol of the function to call. + * @param args_buf The buffer of the arguments. + * @param args_size The size of the arguments. + * @param out_buf The buffer of the result. + * @param out_size The size of the result. + * @return int 0 on success, -1 on error. + */ +int __catalyst__remote__call_wrapper(const char *addr, const char *symbol, const char *args_buf, + size_t args_size, void **out_buf, size_t *out_size) +{ + if (out_buf) { + *out_buf = nullptr; + } + if (out_size) { + *out_size = 0; + } + RemoteEntry *entry = find_or_create_entry(addr, /*create_if_missing=*/false); + if (!entry) { + RT_FAIL("No session found, call __catalyst__remote__open first."); + } + std::lock_guard lock(entry->mu); + if (!entry->session) { + RT_FAIL("Session is closed"); + } + if (!symbol || !*symbol) { + RT_FAIL("Empty symbol passed to __catalyst__remote__call_wrapper"); + } + if (remote_verbose()) { + std::fprintf(stderr, "[remote] call_wrapper(addr=%s, sym=%s, in_size=%zu)\n", addr, symbol, + args_size); + } + char *buf = nullptr; + size_t n = 0; + int rc = + catalyst::remote::call_wrapper_raw(entry->session, symbol, args_buf, args_size, &buf, &n); + if (rc != 0) { + RT_FAIL(catalyst::remote::last_error()); + } + if (out_buf) { + *out_buf = buf; + } + else { + std::free(buf); // caller didn't want the bytes back + } + if (out_size) { + *out_size = n; + } + return 0; +} + +void __catalyst__remote__free_result(void *buf) { std::free(buf); } + +int __catalyst__remote__close() +{ + std::lock_guard mapLock(g_map_mu); + for (auto &[addr, entry] : remote_sessions) { + std::lock_guard lock(entry->mu); + if (entry->session) { + catalyst::remote::close(entry->session); + entry->session = nullptr; + entry->loaded_paths.clear(); + } + } + remote_sessions.clear(); + return 0; +} + +void __catalyst__remote__launch(const char *addr, const char *entry_symbol, size_t num_inputs, + void *const *input_descs, const size_t *input_ranks, + const size_t *input_elem_sizes, size_t num_outputs, + void *const *output_descs, const size_t *output_ranks, + const size_t *output_elem_sizes) +{ + RemoteEntry *entry = find_or_create_entry(addr, /*create_if_missing=*/false); + if (!entry) { + RT_FAIL("Can't find opened session"); + } + + std::lock_guard lock(entry->mu); + if (remote_verbose()) { + std::fprintf(stderr, "[remote] launch(addr=%s, symbol=%s, n_in=%zu, n_out=%zu)\n", addr, + entry_symbol, num_inputs, num_outputs); + } + if (!entry->session) { + RT_FAIL("Session is closed"); + } + uint64_t entry_addr = catalyst::remote::lookup(entry->session, entry_symbol); + if (!entry_addr) { + RT_FAIL(catalyst::remote::last_error()); + } + if (catalyst::remote::invoke_kernel(entry->session, entry_addr, num_inputs, input_descs, + input_ranks, input_elem_sizes, num_outputs, output_descs, + output_ranks, output_elem_sizes) != 0) { + RT_FAIL(catalyst::remote::last_error()); + } +} + +} // extern "C" diff --git a/runtime/lib/remote/RemoteSession.cpp b/runtime/lib/remote/RemoteSession.cpp new file mode 100644 index 0000000000..def519d5c0 --- /dev/null +++ b/runtime/lib/remote/RemoteSession.cpp @@ -0,0 +1,748 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "RemoteSession.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "llvm/ADT/StringRef.h" +#include "llvm/ExecutionEngine/Orc/Core.h" +#include "llvm/ExecutionEngine/Orc/EPCDynamicLibrarySearchGenerator.h" +#include "llvm/ExecutionEngine/Orc/ExecutorProcessControl.h" +#include "llvm/ExecutionEngine/Orc/JITTargetMachineBuilder.h" +#include "llvm/ExecutionEngine/Orc/Mangling.h" +#include "llvm/ExecutionEngine/Orc/MapperJITLinkMemoryManager.h" +#include "llvm/ExecutionEngine/Orc/MemoryAccess.h" +#include "llvm/ExecutionEngine/Orc/ObjectLinkingLayer.h" +#include "llvm/ExecutionEngine/Orc/Shared/ExecutorAddress.h" +#include "llvm/ExecutionEngine/Orc/Shared/OrcRTBridge.h" +#include "llvm/ExecutionEngine/Orc/Shared/SimpleRemoteEPCUtils.h" +#include "llvm/ExecutionEngine/Orc/Shared/TargetProcessControlTypes.h" +#include "llvm/ExecutionEngine/Orc/SimpleRemoteEPC.h" +#include "llvm/ExecutionEngine/Orc/SimpleRemoteMemoryMapper.h" +#include "llvm/ExecutionEngine/Orc/TaskDispatch.h" +#include "llvm/Support/Error.h" +#include "llvm/Support/MemoryBuffer.h" +#include "llvm/Support/TargetSelect.h" + +#include +#include + +using namespace llvm; +using namespace llvm::orc; + +// Public CAPI from librt_capi (declared in runtime/include/RuntimeCAPI.h). +// Allocates a host buffer and registers it with CTX->getMemoryManager(). +extern "C" void *__catalyst__rt__alloc_managed(size_t size); + +namespace { + +// The connection behaviours below are mirrored from `llvm/tools/llvm-jitlink/llvm-jitlink.cpp`, +// and the Session class is mirrored from the official LLVM JIT tutorial: +// `https://llvm.org/docs/tutorial/BuildingAJIT1.html`. + +// Memref descriptor layout: allocated*, aligned*, offset, sizes[], strides[]. +// Offsets of the fixed-size prefix are independent of rank. +// The information can be obtained from `mlir/ExecutionEngine/CRunnerUtils.h`. +constexpr size_t kAllocatedOff = 0; +constexpr size_t kAlignedOff = sizeof(void *); +constexpr size_t kOffsetOff = sizeof(void *) * 2; +constexpr size_t kShapeOff = sizeof(void *) * 2 + sizeof(size_t); + +void initialize_targets() +{ + static const bool inited = []() { + InitializeAllTargets(); + InitializeAllTargetMCs(); + InitializeAllAsmPrinters(); + return true; + }(); + (void)inited; +} + +// For avoiding the error message being overwritten by subsequent errors in async jobs. +// We use thread_local to store the error message. +thread_local std::string g_last_error; +void set_error(const std::string &msg) { g_last_error = msg; } +void clear_error() { g_last_error.clear(); } + +void check(Error E, const Twine &what) +{ + if (E) { + throw std::runtime_error((what + ": " + toString(std::move(E))).str()); + } +} + +// unwrap LLVM Expected to C++ exception +template T unwrap(Expected v, const Twine &what) +{ + check(v.takeError(), what); + return std::move(*v); +} + +// TCP connect (mirrored from llvm-jitlink) +std::string OutOfProcessExecutorConnect; + +Error createTCPSocketError(Twine Details) +{ + return make_error("Failed to connect TCP socket '" + + Twine(OutOfProcessExecutorConnect) + "': " + Details, + inconvertibleErrorCode()); +} + +Expected connectTCPSocket(std::string Host, std::string PortStr) +{ + addrinfo *AI; + addrinfo Hints{}; + Hints.ai_family = AF_INET; + Hints.ai_socktype = SOCK_STREAM; + Hints.ai_flags = AI_NUMERICSERV; + + if (int EC = getaddrinfo(Host.c_str(), PortStr.c_str(), &Hints, &AI)) { + return createTCPSocketError("Address resolution failed (" + StringRef(gai_strerror(EC)) + + ")"); + } + + // Cycle through the returned addrinfo structures and connect to the first + // reachable endpoint. + int SockFD; + addrinfo *Server; + for (Server = AI; Server != nullptr; Server = Server->ai_next) { + // socket might fail, e.g. if the address family is not supported. Skip to + // the next addrinfo structure in such a case. + if ((SockFD = socket(AI->ai_family, AI->ai_socktype, AI->ai_protocol)) < 0) + continue; + + // If connect returns null, we exit the loop with a working socket. + if (connect(SockFD, Server->ai_addr, Server->ai_addrlen) == 0) + break; + + close(SockFD); + } + freeaddrinfo(AI); + + // If we reached the end of the loop without connecting to a valid endpoint, + // dump the last error that was logged in socket() or connect(). + if (Server == nullptr) { + return createTCPSocketError(std::strerror(errno)); + } + + return SockFD; +} + +// Slab-based JIT-link memory manager: reserve a 1 GB slab on the remote once +Expected> +createSimpleRemoteMemoryManager(SimpleRemoteEPC &SREPC) +{ + SimpleRemoteMemoryMapper::SymbolAddrs SAs; + if (auto Err = SREPC.getBootstrapSymbols( + {{SAs.Instance, rt::SimpleExecutorMemoryManagerInstanceName}, + {SAs.Reserve, rt::SimpleExecutorMemoryManagerReserveWrapperName}, + {SAs.Initialize, rt::SimpleExecutorMemoryManagerInitializeWrapperName}, + {SAs.Deinitialize, rt::SimpleExecutorMemoryManagerDeinitializeWrapperName}, + {SAs.Release, rt::SimpleExecutorMemoryManagerReleaseWrapperName}})) { + return std::move(Err); + } + // 1 GB for object's sections (e.g .text, .rodata, ...) + // It will be released once the Session is destroyed. + // TODO: we might want to make this configurable. + size_t SlabSize = 1024 * 1024 * 1024; + return MapperJITLinkMemoryManager::CreateWithMapper(SlabSize, SREPC, + SAs); +} + +Expected> getFile(const Twine &filename) +{ + auto F = MemoryBuffer::getFile(filename); + if (F) { + return std::move(*F); + } + return createFileError(filename, F.getError()); +} + +} // namespace + +namespace catalyst::remote { + +struct RemoteSession { + std::unique_ptr ES; + + DataLayout DL; + + MangleAndInterner Mangle; + ObjectLinkingLayer ObjectLayer; + + JITDylib &MainJD; + + ExecutorAddr alloc_fn{0}; + ExecutorAddr free_fn{0}; + ExecutorAddr invoke_fn{0}; + ExecutorAddr store_asset_fn{0}; + + RemoteSession(std::unique_ptr es, DataLayout dl) + : ES(std::move(es)), DL(std::move(dl)), Mangle(*this->ES, this->DL), ObjectLayer(*this->ES), + MainJD(this->ES->createBareJITDylib("
")) + { + MainJD.addGenerator( + cantFail(EPCDynamicLibrarySearchGenerator::GetForTargetProcess(*this->ES))); + } + + ~RemoteSession() + { + if (auto Err = ES->endSession()) { + ES->reportError(std::move(Err)); + } + } + + static Expected> Create(StringRef remote_addr) + { + initialize_targets(); + + OutOfProcessExecutorConnect = remote_addr.str(); + auto [Host, PortStr] = remote_addr.split(':'); + if (Host.empty()) { + return createTCPSocketError("Host name for -" + OutOfProcessExecutorConnect + + " can not be empty"); + } + if (PortStr.empty()) { + return createTCPSocketError("Port number in -" + OutOfProcessExecutorConnect + + " can not be empty"); + } + + auto SockFD = connectTCPSocket(Host.str(), PortStr.str()); + if (!SockFD) { + return SockFD.takeError(); + } + + auto setup = SimpleRemoteEPC::Setup(); + setup.CreateMemoryManager = createSimpleRemoteMemoryManager; + auto EPC = SimpleRemoteEPC::Create( + std::make_unique(std::nullopt), std::move(setup), + *SockFD, *SockFD); + if (!EPC) { + return EPC.takeError(); + } + + JITTargetMachineBuilder JTMB((*EPC)->getTargetTriple()); + auto DL = JTMB.getDefaultDataLayoutForTarget(); + if (!DL) { + return DL.takeError(); + } + + auto ES = std::make_unique(std::move(*EPC)); + return std::make_unique(std::move(ES), std::move(*DL)); + } + + Error addObjectFile(std::unique_ptr Buf) + { + return ObjectLayer.add(MainJD, std::move(Buf)); + } + + ExecutorAddr lookupSym(StringRef Name) + { + auto Sym = unwrap(ES->lookup({&MainJD}, Mangle(Name.str())), "lookup(" + Name + ")"); + return Sym.getAddress(); + } + + ExecutorProcessControl &getEPC() { return ES->getExecutorProcessControl(); } +}; + +// --------------------------------------------------------------------------- +// Memref Marshalling Helpers +// --------------------------------------------------------------------------- + +namespace { + +ExecutorAddr remote_alloc(RemoteSession *s, size_t size) +{ + ExecutorAddr ret; + auto &epc = s->getEPC(); + std::string error_prefix = "alloc(" + std::to_string(size) + ")"; + check(epc.callSPSWrapper(s->alloc_fn, ret, + static_cast(size)), + error_prefix); + if (!ret) { + throw std::runtime_error(error_prefix + " out of memory"); + } + return ret; +} + +void remote_free(RemoteSession *s, ExecutorAddr addr) +{ + auto &epc = s->getEPC(); + if (auto err = epc.callSPSWrapper(s->free_fn, addr)) { + consumeError(std::move(err)); + } +} + +void remote_write(RemoteSession *s, ExecutorAddr addr, const void *data, size_t size) +{ + auto &epc = s->getEPC(); + tpctypes::BufferWrite w{addr, ArrayRef(static_cast(data), size)}; + check(epc.getMemoryAccess().writeBuffers({w}), "write"); +} + +void remote_read(RemoteSession *s, ExecutorAddr addr, void *data, size_t size) +{ + ExecutorAddrRange r(addr, addr + size); + auto out = unwrap(s->getEPC().getMemoryAccess().readBuffers({r}), "read"); + if (out.empty()) { + throw std::runtime_error("read: empty read"); + } + if (out[0].size() != size) { + throw std::runtime_error("read: size mismatch"); + } + std::memcpy(data, out[0].data(), size); +} + +void remote_invoke(RemoteSession *s, ExecutorAddr entry, ArrayRef arg_addrs) +{ + auto &epc = s->getEPC(); + check(epc.callSPSWrapper)>(s->invoke_fn, + entry, arg_addrs), + "remote_invoke"); +} + +// Memref descriptors are layout-equivalent to: +// { void *allocated; void *aligned; size_t offset; size_t sizes[rank]; size_t strides[rank]; } +size_t memref_desc_size(size_t rank) +{ + return sizeof(void *) // void *allocated + + sizeof(void *) // void *aligned + + sizeof(size_t) // size_t offset + + sizeof(size_t) * rank // size_t sizes[rank] + + sizeof(size_t) * rank // size_t strides[rank] + ; +} + +size_t memref_data_size(const char *desc, size_t rank, size_t elem_size) +{ + if (rank == 0) { + return elem_size; + } + const size_t *shape = reinterpret_cast(desc + kShapeOff); + return std::accumulate(shape, shape + rank, elem_size, std::multiplies()); +} + +class RemoteAllocator { + private: + RemoteSession *sess; + std::vector addrs; + + public: + explicit RemoteAllocator(RemoteSession *s) : sess(s) {} + ~RemoteAllocator() + { + for (ExecutorAddr a : addrs) { + remote_free(sess, a); + } + } + RemoteAllocator(const RemoteAllocator &) = delete; + RemoteAllocator &operator=(const RemoteAllocator &) = delete; + + ExecutorAddr alloc(size_t size) + { + ExecutorAddr addr = remote_alloc(sess, size); + addrs.push_back(addr); + return addr; + } +}; + +} // namespace + +// --------------------------------------------------------------------------- +// RemoteSession's Exported APIs +// --------------------------------------------------------------------------- + +/** + * @brief Open a session to the remote device. + * + * @param remote_addr the remote address + * @return RemoteSession * the session object + */ +RemoteSession *open(const char *remote_addr) +{ + clear_error(); + try { + auto s = unwrap(RemoteSession::Create(remote_addr), "open(" + Twine(remote_addr) + ")"); + check(s->getEPC().getBootstrapSymbols({{s->alloc_fn, "catalyst_remote_alloc"}, + {s->free_fn, "catalyst_remote_free"}, + {s->invoke_fn, "catalyst_remote_invoke"}, + {s->store_asset_fn, "catalyst_remote_store_asset"}}), + "getBootstrapSymbols"); + return s.release(); + } + catch (const std::exception &e) { + set_error(e.what()); + return nullptr; + } +} + +/** + * @brief Close the session to the remote device. + * The remote session's destructor will handle the cleanup of the session. + * + * @param s the session object + */ +void close(RemoteSession *s) { delete s; } + +/** + * @brief Load an object file (cross-compiled for the remote arch) into the remote JIT. + * + * @param s the session object + * @param path the path to the object file + * @return int 0 on success, non-zero on error + */ +int load_object_path(RemoteSession *s, const char *path) +{ + clear_error(); + try { + auto buf = unwrap(getFile(path), "getFile(" + Twine(path) + ")"); + check(s->addObjectFile(std::move(buf)), "addObjectFile"); + return 0; + } + catch (const std::exception &e) { + set_error(e.what()); + return -1; + } +} + +/** + * @brief Load an asset file into the remote JIT. + * + * @param s the session object + * @param path the path to the asset file + * @return int 0 on success, -1 on error + */ +int load_asset_path(RemoteSession *s, const char *path) +{ + clear_error(); + try { + auto buf = unwrap(getFile(path), "getFile(" + Twine(path) + ")"); + ArrayRef bytes(buf->getBufferStart(), buf->getBufferSize()); + + int32_t rc = 0; + check(s->getEPC().callSPSWrapper, shared::SPSString)>( + s->store_asset_fn, rc, bytes, std::string(path)), + "store_asset"); + if (rc != 0) { + throw std::runtime_error("Got non-zero status from store_asset(" + std::string(path) + + "): " + std::to_string(rc)); + } + return 0; + } + catch (const std::exception &e) { + set_error(e.what()); + return -1; + } +} + +/** + * @brief Generic raw ORC wrapper-function call. Looks `sym` up on the executor, invokes its wrapper + * with `(args_buf, args_size)`, and copies the resulting byte buffer into a `out_buf` with the size + * of `out_size`. The caller is responsible for `free()` the result buffer. + * + * @param s The session object. + * @param sym The symbol of the function to call. + * @param args_buf The buffer of the arguments. + * @param args_size The size of the arguments. + * @param out_buf The buffer of the result. + * @param out_size The size of the result. + * @return int 0 on success, -1 on error. + */ +int call_wrapper_raw(RemoteSession *s, const char *sym, const char *args_buf, size_t args_size, + char **out_buf, size_t *out_size) +{ + clear_error(); + *out_buf = nullptr; + *out_size = 0; + try { + ExecutorAddr fn = s->lookupSym(sym); + if (!fn) { + throw std::runtime_error(std::string("symbol not found: ") + sym); + } + auto result = s->getEPC().callWrapper(fn, ArrayRef(args_buf, args_size)); + size_t n = result.size(); + char *buf = nullptr; + if (n > 0) { + buf = static_cast(std::malloc(n)); + if (!buf) { + throw std::runtime_error("malloc failed for wrapper result"); + } + std::memcpy(buf, result.data(), n); + } + *out_buf = buf; + *out_size = n; + return 0; + } + catch (const std::exception &e) { + set_error(e.what()); + return -1; + } +} + +/** + * @brief Lookup the address of a symbol in the remote device. + * + * @param s the session object + * @param name the name of the symbol + * @return uint64_t the address of the symbol + */ +uint64_t lookup(RemoteSession *s, const char *name) +{ + clear_error(); + try { + return s->lookupSym(name).getValue(); + } + catch (const std::exception &e) { + set_error(e.what()); + return 0; + } +} + +/** + * @brief Run the kernel as a main function (take argv as arguments, argc is the length of argv). + * + * @param s the session object + * @param entry the entry function address + * @param argv the command line arguments + * @return int32_t the exit code + */ +int32_t run_as_main(RemoteSession *s, uint64_t entry_addr, int argc, const char *const *argv) +{ + clear_error(); + try { + std::vector args; + args.reserve(argc); + for (int i = 0; i < argc; ++i) { + args.emplace_back(argv[i]); + } + return unwrap(s->getEPC().runAsMain(ExecutorAddr(entry_addr), args), "run_as_main"); + } + catch (const std::exception &e) { + set_error(e.what()); + return -1; + } +} + +/** + * @brief Push one host memref to the remote: + * 1. allocates the data buffer on the remote + * 2. allocates the descriptor on the remote (which has a pointer to the data buffer) + * 3. returns the descriptor's remote addr. + * 4. If `copy_data` is true, the data will be copied to the remote. + * It's used for input memrefs like arguments. + * In the case of output memrefs, the data will be copied back to the host, + * so we don't need to copy the data to the remote. + * + * @param s the session object + * @param alloc the remote allocator + * @param host_desc the host memref descriptor + * @param rank the rank of the memref + * @param elem_size the element size of the memref + * @param copy_data whether to copy the data to the remote + * @return ExecutorAddr the remote address of the memref descriptor + */ +ExecutorAddr push_memref(RemoteSession *s, RemoteAllocator &alloc, void *host_desc, size_t rank, + size_t elem_size, bool copy_data) +{ + char *desc_host = static_cast(host_desc); + size_t desc_size = memref_desc_size(rank); + size_t data_size = memref_data_size(desc_host, rank, elem_size); + + // memref descriptor layout: + // ┌──────────────────────┐ ┌──────┐ + // │memref .allocated┼┬─►│buffer│ + // │descriptor .aligned ─┼┘ └──────┘ + // │ .offset │ + // │ .shape │ + // │ .strides │ + // └──────────────────────┘ + + std::vector desc(desc_size); + std::memcpy(desc.data(), desc_host, desc_size); + + ExecutorAddr data_remote = ExecutorAddr(0); + if (data_size > 0) { + data_remote = alloc.alloc(data_size); + if (copy_data) { + void *aligned_host = *reinterpret_cast(desc_host + kAlignedOff); + if (aligned_host) { + remote_write(s, data_remote, aligned_host, data_size); + } + } + } + std::memcpy(desc.data() + kAllocatedOff, &data_remote, sizeof(uintptr_t)); + std::memcpy(desc.data() + kAlignedOff, &data_remote, sizeof(uintptr_t)); + std::memset(desc.data() + kOffsetOff, 0, sizeof(int64_t)); + + ExecutorAddr desc_remote = alloc.alloc(desc_size); + remote_write(s, desc_remote, desc.data(), desc.size()); + return desc_remote; +} + +/** + * @brief Pull a remote memref descriptor + its data back into the host descriptor. + * + * @param s the session object + * @param remote_desc the remote address of the memref descriptor + * @param host_desc the host memref descriptor + * @param rank the rank of the memref + * @param elem_size the element size of the memref + */ +void pull_memref(RemoteSession *s, ExecutorAddr remote_desc, void *host_desc, size_t rank, + size_t elem_size) +{ + size_t desc_size = memref_desc_size(rank); + std::vector desc(desc_size); + remote_read(s, remote_desc, desc.data(), desc.size()); + + uintptr_t aligned_remote; + std::memcpy(&aligned_remote, desc.data() + kAlignedOff, sizeof(uintptr_t)); + + size_t data_size = memref_data_size(desc.data(), rank, elem_size); + size_t alloc_size = std::max(data_size, 1); + void *aligned_host = __catalyst__rt__alloc_managed(alloc_size); + if (data_size && aligned_remote) { + remote_read(s, ExecutorAddr(aligned_remote), aligned_host, data_size); + } + uintptr_t aligned_addr = reinterpret_cast(aligned_host); + std::memcpy(desc.data() + kAllocatedOff, &aligned_addr, sizeof(uintptr_t)); + std::memcpy(desc.data() + kAlignedOff, &aligned_addr, sizeof(uintptr_t)); + std::memcpy(host_desc, desc.data(), desc_size); +} + +/** + * @brief Invoke a remote kernel. + * + * @param s the session object + * @param entry_addr the address of the kernel entry function + * @param num_inputs the number of input memrefs + * @param input_descs the input memref descriptors + * @param input_ranks the ranks of the input memrefs + * @param input_elem_sizes the element sizes of the input memrefs + * @param num_outputs the number of output memrefs + * @param output_descs the output memref descriptors + * @param output_ranks the ranks of the output memrefs + * @param output_elem_sizes the element sizes of the output memrefs + * @return int 0 on success, non-zero on error + */ +int invoke_kernel(RemoteSession *s, uint64_t entry_addr, size_t num_inputs, + void *const *input_descs, const size_t *input_ranks, + const size_t *input_elem_sizes, size_t num_outputs, void *const *output_descs, + const size_t *output_ranks, const size_t *output_elem_sizes) +{ + clear_error(); + RemoteAllocator allocator(s); + try { + // The remote executor's catalyst_remote_invoke calls the entry as Catalyst's pyface ABI: + // `void(rv*, av*)`. + + // Layout (av): + // av (argument) is a struct whose Nth field is a pointer to the Nth input memref + // descriptor. So av = [N x uintptr_t] (array of remote descriptor addresses). + // + // av ──►┌───────────┐ ┌──────────────────────┐ ┌──────┐ + // │slot0 (ptr)┼────►│memref .allocated┼┬─►│buffer│ + // │slot1 │ │descriptor .aligned ─┼┘ └──────┘ + // │slot2 │ │ .offset │ + // │ ... │ │ .shape │ + // │ │ │ .strides │ + // └───────────┘ └──────────────────────┘ + + // Layout (rv): + // rv is a struct whose Nth field is the Nth output memref descriptor (not a pointer) + // + // rv ──►┌─────┬─────┬─────┬─────┬─────────┐ + // │desc0│desc1│desc2│desc3│ ... │ + // └─────┴─────┴─────┴─────┴─────────┘ + // Each slot maps to a output memref descriptor + + // Step 1. Push the input memref descriptors (with data) to the remote. + std::vector input_remote_descs(num_inputs); + for (size_t i = 0; i < num_inputs; ++i) { + input_remote_descs[i] = push_memref(s, allocator, input_descs[i], input_ranks[i], + input_elem_sizes[i], /*copy_data=*/true); + } + + // Step 2. Allocate a remote buffer holding the input memref descriptors' pointers. + ExecutorAddr av_remote = ExecutorAddr(0); + if (num_inputs > 0) { + av_remote = allocator.alloc(sizeof(uintptr_t) * num_inputs); + std::vector av_buf(num_inputs); + for (size_t i = 0; i < num_inputs; ++i) { + av_buf[i] = input_remote_descs[i].getValue(); + } + remote_write(s, av_remote, av_buf.data(), sizeof(uintptr_t) * num_inputs); + } + + // Step 3. Allocate a remote buffer for kernel to write the output memref descriptors. + std::vector output_offsets(num_outputs); + size_t rv_total = 0; + for (size_t i = 0; i < num_outputs; ++i) { + output_offsets[i] = rv_total; + rv_total += memref_desc_size(output_ranks[i]); + } + ExecutorAddr rv_remote = ExecutorAddr(0); + if (rv_total > 0) { + rv_remote = allocator.alloc(rv_total); + } + + // Step 4. Invoke the kernel remotely. + std::vector arg_addrs = {rv_remote, av_remote}; + remote_invoke(s, ExecutorAddr(entry_addr), arg_addrs); + + // Step 5. Pull each output descriptor back from rv buffer. + if (rv_total > 0) { + std::vector rv_buf(rv_total); + remote_read(s, rv_remote, rv_buf.data(), rv_total); + for (size_t i = 0; i < num_outputs; ++i) { + size_t desc_size = memref_desc_size(output_ranks[i]); + size_t elem_size = output_elem_sizes[i]; + char *desc = rv_buf.data() + output_offsets[i]; + + uintptr_t aligned_remote; + std::memcpy(&aligned_remote, desc + kAlignedOff, sizeof(uintptr_t)); + + size_t data_size = memref_data_size(desc, output_ranks[i], elem_size); + size_t alloc_size = std::max(data_size, 1); + void *aligned_host = __catalyst__rt__alloc_managed(alloc_size); + if (data_size && aligned_remote) { + remote_read(s, ExecutorAddr(aligned_remote), aligned_host, data_size); + } + uintptr_t aligned_addr = reinterpret_cast(aligned_host); + std::memcpy(desc + kAllocatedOff, &aligned_addr, sizeof(uintptr_t)); + std::memcpy(desc + kAlignedOff, &aligned_addr, sizeof(uintptr_t)); + std::memcpy(output_descs[i], desc, desc_size); + } + } + return 0; + } + catch (const std::exception &e) { + set_error(e.what()); + return -1; + } +} + +const char *last_error() { return g_last_error.c_str(); } + +} // namespace catalyst::remote diff --git a/runtime/lib/remote/RemoteSession.hpp b/runtime/lib/remote/RemoteSession.hpp new file mode 100644 index 0000000000..c8eb18f698 --- /dev/null +++ b/runtime/lib/remote/RemoteSession.hpp @@ -0,0 +1,55 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include + +namespace catalyst::remote { + +// Opaque session handle. Created by open(), released by close(). +struct RemoteSession; + +// Open a TCP session to a `host:port` executor. Returns nullptr on error. +RemoteSession *open(const char *remote_addr); + +void close(RemoteSession *s); + +// Load an object file into the remote JIT. Returns 0 on success, -1 on error. +int load_object_path(RemoteSession *s, const char *path); + +// Load an asset file into the remote JIT. Returns 0 on success, -1 on error. +int load_asset_path(RemoteSession *s, const char *path); + +// Generic ORC wrapper-function call by symbol name. Returns 0 on success, -1 on error. +int call_wrapper_raw(RemoteSession *s, const char *sym, const char *args_buf, size_t args_size, + char **out_buf, size_t *out_size); + +// Look up a symbol address on the remote. Returns 0 on error. +uint64_t lookup(RemoteSession *s, const char *name); + +// Run a remote function as `main(argc, argv)`. +int32_t run_as_main(RemoteSession *s, uint64_t entry_addr, int argc, const char *const *argv); + +// Invoke a remote kernel. Returns 0 on success, -1 on error. +int invoke_kernel(RemoteSession *s, uint64_t entry_addr, size_t num_inputs, + void *const *input_descs, const size_t *input_ranks, + const size_t *input_elem_sizes, size_t num_outputs, void *const *output_descs, + const size_t *output_ranks, const size_t *output_elem_sizes); + +// Last error message. +const char *last_error(); + +} // namespace catalyst::remote From b4db93e393bc1bfced5b2f44a9c7d72b84a6fcbc Mon Sep 17 00:00:00 2001 From: Hong-Sheng Zheng Date: Thu, 4 Jun 2026 13:46:26 -0400 Subject: [PATCH 56/75] Add cross compile pass for qnode --- mlir/include/Remote/Transforms/Passes.td | 64 ++++ mlir/lib/Remote/Transforms/CMakeLists.txt | 8 + .../Transforms/CrossCompileRemoteKernels.cpp | 314 ++++++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 mlir/lib/Remote/Transforms/CrossCompileRemoteKernels.cpp diff --git a/mlir/include/Remote/Transforms/Passes.td b/mlir/include/Remote/Transforms/Passes.td index c37a9d6215..a6fac00f32 100644 --- a/mlir/include/Remote/Transforms/Passes.td +++ b/mlir/include/Remote/Transforms/Passes.td @@ -17,6 +17,70 @@ include "mlir/Pass/PassBase.td" +def CrossCompileRemoteKernelsPass : Pass<"cross-compile-remote-kernels", "mlir::ModuleOp"> { + let summary = "Cross-compile each `qnode` to a `.o` and emit `remote.*` ops at the host call sites."; + let description = [{ + For every `func.func` in the host module carrying the `qnode` attribute, this pass: + 1. Clones the func into a standalone module. + 2. Sanitizes it for lowering to LLVM IR and emits the `.o` to `/.o`. + 3. Marks every `func.call` targeting the qnode with `catalyst.remote_kernel_path` + holding the absolute path of the produced `.o`. + }]; + + let dependentDialects = [ + "mlir::LLVM::LLVMDialect", + "mlir::func::FuncDialect", + "catalyst::CatalystDialect", + "catalyst::remote::RemoteDialect", + ]; + + let options = [ + Option< + /*C++ var name=*/"workspace", + /*CLI arg name=*/"workspace", + /*type=*/"std::string", + /*default=*/"\"\"", + /*description=*/ + "Filesystem directory to write cross-compiled `.o` files into." + >, + Option< + /*C++ var name=*/"target", + /*CLI arg name=*/"target", + /*type=*/"std::string", + /*default=*/"\"x86_64\"", + /*description=*/ + "LLVM target triple used for object emission." + >, + Option< + /*C++ var name=*/"cpu", + /*CLI arg name=*/"cpu", + /*type=*/"std::string", + /*default=*/"\"generic\"", + /*description=*/ + "LLVM CPU model fed to `createTargetMachine`. Defaults to " + "`generic`, which emits baseline code for the triple." + >, + Option< + /*C++ var name=*/"features", + /*CLI arg name=*/"features", + /*type=*/"std::string", + /*default=*/"\"\"", + /*description=*/ + "Comma-separated subtarget features fed to `createTargetMachine` " + "(each token must be `+feat` or `-feat`, e.g. `+crc,+aes,+sha2`). " + "Empty (default) lets the CPU choose its own feature set." + >, + Option< + /*C++ var name=*/"address", + /*CLI arg name=*/"address", + /*type=*/"std::string", + /*default=*/"\"\"", + /*description=*/ + "Executor's TCP `host:port` address for remote dispatch." + > + ]; +} + def ConvertRemoteToLLVMPass : Pass<"convert-remote-to-llvm", "mlir::ModuleOp"> { let summary = "Lower the `remote` dialect to direct calls into the Catalyst remote runtime."; let description = [{ diff --git a/mlir/lib/Remote/Transforms/CMakeLists.txt b/mlir/lib/Remote/Transforms/CMakeLists.txt index 3476c40708..235863da45 100644 --- a/mlir/lib/Remote/Transforms/CMakeLists.txt +++ b/mlir/lib/Remote/Transforms/CMakeLists.txt @@ -1,14 +1,22 @@ set(LIBRARY_NAME remote-transforms) +set(LLVM_LINK_COMPONENTS + AllTargetsAsmParsers + AllTargetsCodeGens +) + file(GLOB SRC + CrossCompileRemoteKernels.cpp RemoteToLLVM.cpp ) get_property(dialect_libs GLOBAL PROPERTY MLIR_DIALECT_LIBS) get_property(conversion_libs GLOBAL PROPERTY MLIR_CONVERSION_LIBS) +get_property(translation_libs GLOBAL PROPERTY MLIR_TRANSLATION_LIBS) set(LIBS ${dialect_libs} ${conversion_libs} + ${translation_libs} MLIRRemote MLIRCatalyst ) diff --git a/mlir/lib/Remote/Transforms/CrossCompileRemoteKernels.cpp b/mlir/lib/Remote/Transforms/CrossCompileRemoteKernels.cpp new file mode 100644 index 0000000000..cd2c8574c2 --- /dev/null +++ b/mlir/lib/Remote/Transforms/CrossCompileRemoteKernels.cpp @@ -0,0 +1,314 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "llvm/ADT/StringExtras.h" +#include "llvm/IR/LLVMContext.h" +#include "llvm/IR/LegacyPassManager.h" +#include "llvm/IR/Module.h" +#include "llvm/MC/TargetRegistry.h" +#include "llvm/Support/FileSystem.h" +#include "llvm/Support/Path.h" +#include "llvm/Support/TargetSelect.h" +#include "llvm/Support/raw_ostream.h" +#include "llvm/Target/TargetMachine.h" +#include "llvm/Target/TargetOptions.h" +#include "llvm/TargetParser/Triple.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/Dialect/LLVMIR/LLVMDialect.h" +#include "mlir/IR/Builders.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/Pass/Pass.h" +#include "mlir/Pass/PassManager.h" +#include "mlir/Target/LLVMIR/Export.h" + +#include "Catalyst/IR/CatalystOps.h" +#include "Driver/DefaultPipelines/DefaultPipelines.h" + +#include "Remote/IR/RemoteOps.h" +#include "Remote/Transforms/Passes.h" + +using namespace mlir; + +namespace catalyst { +namespace remote { + +#define GEN_PASS_DEF_CROSSCOMPILEREMOTEKERNELSPASS +#include "Remote/Transforms/Passes.h.inc" + +namespace { + +/// Sanitize the qnode so it can be compiled into a standalone module: +/// 1. Drop `llvm.linkage` and `qnode`. +/// 2. Promote to public visibility. +/// 3. Add `llvm.emit_c_interface` so it's callable from C as +/// `_mlir_ciface_` (and `_catalyst_pyface_` is provided by +/// downstream wrapping). +void sanitizeQNode(func::FuncOp fn) +{ + OpBuilder builder(fn.getContext()); + fn.setVisibility(SymbolTable::Visibility::Public); + fn->setAttr("llvm.emit_c_interface", builder.getUnitAttr()); + fn->setAttr("catalyst.remote_kernel", builder.getUnitAttr()); + fn->removeAttr("llvm.linkage"); + fn->removeAttr("qnode"); +} + +struct CrossCompileRemoteKernelsPass + : impl::CrossCompileRemoteKernelsPassBase { + using CrossCompileRemoteKernelsPassBase::CrossCompileRemoteKernelsPassBase; + + void runOnOperation() final + { + ModuleOp host = getOperation(); + + SmallVector qnodes = + llvm::filter_to_vector(host.getOps(), [&](func::FuncOp fn) { + return fn->hasAttr("qnode") && !fn->hasAttr("catalyst.remote_kernel"); + }); + + SmallVector calls; + host.walk([&](catalyst::CustomCallOp call) { + if (call.getCallTargetName() == "remote_call") { + calls.push_back(call); + } + }); + + if (qnodes.empty() && calls.empty()) { + return; + } + + if (!qnodes.empty()) { + if (workspace.empty()) { + host.emitError("Missing `workspace` option for remote kernel cross-compilation"); + return signalPassFailure(); + } + + llvm::InitializeAllTargetInfos(); + llvm::InitializeAllTargets(); + llvm::InitializeAllTargetMCs(); + llvm::InitializeAllAsmParsers(); + llvm::InitializeAllAsmPrinters(); + } + + injectRemoteOpenIntoSetup(host); + + for (auto qnode : qnodes) { + if (failed(compileQNode(host, qnode))) { + return signalPassFailure(); + } + } + + rewriteLegacyLibCalls(calls); + } + + /// Rewrite legacy `catalyst.custom_call fn("remote_call")` ops into + /// typed `remote.call` ops carrying the executor address. + void rewriteLegacyLibCalls(ArrayRef libCalls) + { + MLIRContext *ctx = &getContext(); + auto addressAttr = StringAttr::get(ctx, address); + for (catalyst::CustomCallOp call : libCalls) { + auto symAttr = call->getAttrOfType("catalyst.remote_symbol"); + if (!symAttr) { + call->emitOpError("legacy remote_call missing `catalyst.remote_symbol` attribute"); + return signalPassFailure(); + } + OpBuilder b(call); + IntegerAttr numInputAttr = nullptr; + if (auto n = call.getNumberOriginalArg()) { + numInputAttr = b.getI32IntegerAttr(*n); + } + auto remoteCall = + remote::CallOp::create(b, call.getLoc(), call.getResultTypes(), call.getOperands(), + /*address=*/addressAttr, /*symbol=*/symAttr, + /*num_input_args=*/numInputAttr); + call.replaceAllUsesWith(remoteCall.getResults()); + call.erase(); + } + } + + /// Insert `remote.open` into the host's `setup` function exactly once. + void injectRemoteOpenIntoSetup(ModuleOp host) + { + auto setupFn = host.lookupSymbol("setup"); + if (!setupFn || setupFn.getBody().empty()) { + return; + } + Block &setupBody = setupFn.getBody().front(); + Operation *terminator = setupBody.getTerminator(); + if (!terminator) { + return; + } + OpBuilder b(terminator); + remote::OpenOp::create(b, setupFn.getLoc(), + /*address=*/StringAttr::get(&getContext(), address)); + } + + /// Insert `remote.send_binary` into the host's `setup` function for the + /// kernel we just produced. + void injectRemoteSendBinaryIntoSetup(ModuleOp host, StringAttr addressAttr, StringAttr pathAttr) + { + auto setupFn = host.lookupSymbol("setup"); + if (!setupFn || setupFn.getBody().empty()) { + return; + } + Block &setupBody = setupFn.getBody().front(); + Operation *terminator = setupBody.getTerminator(); + if (!terminator) { + return; + } + OpBuilder b(terminator); + remote::SendBinaryOp::create(b, setupFn.getLoc(), + /*address=*/addressAttr, + /*binary_path=*/pathAttr); + } + + /// Clone the qnode into a fresh module, sanitize it, run the LLVM-dialect + /// lowering stage, then translate to LLVM IR. + std::unique_ptr loweringQNode(func::FuncOp qnode, llvm::LLVMContext &llvmCtx) + { + MLIRContext *ctx = &getContext(); + StringRef qnodeName = qnode.getName(); + + OpBuilder builder(ctx); + auto clone = ModuleOp::create(builder.getUnknownLoc(), qnodeName); + auto qnodeClone = cast(qnode->clone()); + clone.getBody()->push_back(qnodeClone); + sanitizeQNode(qnodeClone); + + PassManager nested(ctx); + std::string passList = llvm::join(catalyst::driver::getLLVMDialectLoweringStage(), ","); + if (failed(parsePassPipeline(passList, nested))) { + qnode.emitError("Failed to parse LLVM-dialect lowering pipeline"); + clone->erase(); + return nullptr; + } + if (failed(nested.run(clone))) { + qnode.emitError("Lowering failed on cloned qnode"); + clone->erase(); + return nullptr; + } + + std::unique_ptr llvmModule = + translateModuleToLLVMIR(clone, llvmCtx, /*name=*/qnodeName); + if (!llvmModule) { + qnode.emitError("Failed to translate lowered qnode to LLVM IR"); + clone->erase(); + return nullptr; + } + clone->erase(); + return llvmModule; + } + + /// Emit the LLVM module to `/.o`. + std::string emitObjectFile(std::unique_ptr &&llvmModule, StringRef qnodeName, + StringRef targetTriple, StringRef cpuModel, StringRef featureList) + { + llvm::Triple parsedTriple{targetTriple}; + std::string err; + const llvm::Target *llvmTarget = llvm::TargetRegistry::lookupTarget(parsedTriple, err); + if (!llvmTarget) { + llvm::errs() << "Target triple '" << targetTriple + << "' not registered in this LLVM build: " << err << "\n"; + return ""; + } + llvm::TargetOptions opt; + std::unique_ptr targetMachine(llvmTarget->createTargetMachine( + parsedTriple, cpuModel, featureList, opt, llvm::Reloc::Model::PIC_)); + if (!targetMachine) { + llvm::errs() << "Could not create TargetMachine for triple '" << targetTriple + << "' cpu='" << cpuModel << "' features='" << featureList << "'\n"; + return ""; + } + + targetMachine->setOptLevel(llvm::CodeGenOptLevel::Aggressive); + llvmModule->setDataLayout(targetMachine->createDataLayout()); + llvmModule->setTargetTriple(parsedTriple); + + llvm::SmallString<128> p(workspace); + llvm::sys::path::append(p, qnodeName.str() + ".o"); + std::string objPath = std::string(p.str()); + + std::error_code errCode; + llvm::raw_fd_ostream dest(objPath, errCode, llvm::sys::fs::OF_None); + if (errCode) { + llvm::errs() << "Cannot open " << objPath << " for writing: " << errCode.message() + << "\n"; + return ""; + } + llvm::legacy::PassManager codegenPM; + if (targetMachine->addPassesToEmitFile(codegenPM, dest, nullptr, + llvm::CodeGenFileType::ObjectFile)) { + llvm::errs() << "TargetMachine cannot emit an object file for the requested target" + << "\n"; + return ""; + } + codegenPM.run(*llvmModule); + dest.flush(); + + return objPath; + } + + /// Compile a qnode to a `.o`, then replace every host-side `func.call` to + /// it with a `remote.launch` op and inject the matching `remote.send_binary` + /// into setup. + LogicalResult compileQNode(ModuleOp host, func::FuncOp qnode) + { + MLIRContext *ctx = &getContext(); + StringRef qnodeName = qnode.getName(); + + llvm::LLVMContext llvmCtx; + auto llvmModule = loweringQNode(qnode, llvmCtx); + if (!llvmModule) { + return failure(); + } + + std::string objPath = + emitObjectFile(std::move(llvmModule), qnodeName, target, cpu, features); + if (objPath.empty()) { + return failure(); + } + + SmallVector callsToReplace; + if (auto uses = SymbolTable::getSymbolUses(qnode.getNameAttr(), host)) { + for (const SymbolTable::SymbolUse &use : *uses) { + if (auto call = dyn_cast(use.getUser())) { + callsToReplace.push_back(call); + } + } + } + + auto pathAttr = StringAttr::get(ctx, objPath); + auto addressAttr = StringAttr::get(ctx, address); + auto calleeAttr = StringAttr::get(ctx, qnodeName); + + for (func::CallOp call : callsToReplace) { + OpBuilder builder(call); + auto launch = remote::LaunchOp::create(builder, call.getLoc(), call.getResultTypes(), + call.getOperands(), addressAttr, calleeAttr); + call.replaceAllUsesWith(launch.getResults()); + call.erase(); + } + + injectRemoteSendBinaryIntoSetup(host, addressAttr, pathAttr); + + qnode.erase(); + return success(); + } +}; + +} // namespace + +} // namespace remote +} // namespace catalyst From 1178e1e41ecd26e05350f9e7884f3e48fe61ed87 Mon Sep 17 00:00:00 2001 From: Hong-Sheng Zheng Date: Thu, 4 Jun 2026 13:59:58 -0400 Subject: [PATCH 57/75] codecov --- frontend/test/pytest/test_compiler.py | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/frontend/test/pytest/test_compiler.py b/frontend/test/pytest/test_compiler.py index c624f8ccb1..4e509133a7 100644 --- a/frontend/test/pytest/test_compiler.py +++ b/frontend/test/pytest/test_compiler.py @@ -154,6 +154,41 @@ def test_options_to_cli_flags_keep_intermediate_pass(self): assert "--save-ir-after-each=pass" in flags +class TestDefaultFlags: + """Unit tests for optional libraries discovered in LinkerDriver.get_default_flags.""" + + @staticmethod + def _lib_name(stem): + """Build the platform-specific shared library file name for a library stem.""" + ext = ".so" if platform.system() == "Linux" else ".dylib" + return stem + ext + + def _patch_isfile(self, monkeypatch, overrides): + """Patch isfile to force given library file names present/absent.""" + real_isfile = os.path.isfile + overrides = {os.path.basename(name): value for name, value in overrides.items()} + + def fake_isfile(p): + name = os.path.basename(p) + if name in overrides: + return overrides[name] + return real_isfile(p) + + monkeypatch.setattr("catalyst.compiler.os.path.isfile", fake_isfile) + + def test_rt_remote_linked_when_present(self, monkeypatch): + """-lrt_remote is added when librt_remote exists in the runtime lib dir.""" + self._patch_isfile(monkeypatch, overrides={self._lib_name("librt_remote"): True}) + flags = LinkerDriver.get_default_flags(CompileOptions()) + assert "-lrt_remote" in flags + + def test_rt_remote_not_linked_when_absent(self, monkeypatch): + """-lrt_remote is omitted when librt_remote is missing.""" + self._patch_isfile(monkeypatch, overrides={self._lib_name("librt_remote"): False}) + flags = LinkerDriver.get_default_flags(CompileOptions()) + assert "-lrt_remote" not in flags + + class TestCompilerWarnings: """Test compiler's warning messages.""" From 98cb7d1e616efc2bc149c0673b7d6e7d90305977 Mon Sep 17 00:00:00 2001 From: Hong-Sheng Zheng Date: Thu, 4 Jun 2026 14:08:47 -0400 Subject: [PATCH 58/75] fix --- .../Driver/DefaultPipelines/DefaultPipelines.h | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h b/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h index 540bc96f6f..8e50b9e850 100644 --- a/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h +++ b/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h @@ -164,7 +164,7 @@ const PipelineList pipelineList{ "register-inactive-callback"}}}; // clang-format on -PipelineNames getPipelineNames() +inline PipelineNames getPipelineNames() { static std::vector names = std::accumulate(driver::pipelineList.begin(), driver::pipelineList.end(), @@ -175,7 +175,7 @@ PipelineNames getPipelineNames() return names; } -PassNames getQuantumCompilationStage(bool disableAssertion = true) +inline PassNames getQuantumCompilationStage(bool disableAssertion = true) { PassNames ret; std::copy_if(pipelineList[0].passNames.begin(), pipelineList[0].passNames.end(), @@ -185,11 +185,11 @@ PassNames getQuantumCompilationStage(bool disableAssertion = true) return ret; } -PassNames getHLOLoweringStage() { return pipelineList[1].passNames; } +inline PassNames getHLOLoweringStage() { return pipelineList[1].passNames; } -PassNames getGradientLoweringStage() { return pipelineList[2].passNames; } +inline PassNames getGradientLoweringStage() { return pipelineList[2].passNames; } -PassNames getBufferizationStage(bool asyncQNodes = false) +inline PassNames getBufferizationStage(bool asyncQNodes = false) { const std::string bufferizationOptions = std::string("{bufferize-function-boundaries ") + "allow-return-allocs-from-loops " + @@ -207,7 +207,7 @@ PassNames getBufferizationStage(bool asyncQNodes = false) return ret; } -PassNames getLLVMDialectLoweringStage(bool asyncQNodes = false) +inline PassNames getLLVMDialectLoweringStage(bool asyncQNodes = false) { PassNames ret; std::copy_if(pipelineList[4].passNames.begin(), pipelineList[4].passNames.end(), From 1ccef221e5dcaa37b6831c5ee76cb7d8d405cdea Mon Sep 17 00:00:00 2001 From: Hong-Sheng Zheng Date: Fri, 5 Jun 2026 02:14:44 -0400 Subject: [PATCH 59/75] fix remote call --- .../lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp b/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp index 2a3a6947fc..ba7e0c2fd4 100644 --- a/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp +++ b/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp @@ -181,8 +181,10 @@ struct CustomCallOpInterface IntegerAttr numArgumentsAttr = rewriter.getI32IntegerAttr(numArguments); // Create an updated custom call operation - CustomCallOp::create(rewriter, op->getLoc(), TypeRange{}, bufferArgs, - customCallOp.getCallTargetName(), numArgumentsAttr); + auto newCustomCallOp = + CustomCallOp::create(rewriter, op->getLoc(), TypeRange{}, bufferArgs, + customCallOp.getCallTargetName(), numArgumentsAttr); + newCustomCallOp->setDiscardableAttrs(customCallOp->getDiscardableAttrDictionary()); size_t startIndex = bufferArgs.size() - customCallOp.getNumResults(); SmallVector bufferResults(bufferArgs.begin() + startIndex, bufferArgs.end()); bufferization::replaceOpWithBufferizedValues(rewriter, op, bufferResults); From 520dce9bf412e7dbfcaec3fcda4dadaa559725fd Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 5 Jun 2026 17:11:12 -0400 Subject: [PATCH 60/75] Adapt dispatch pass --- mlir/include/Catalyst/Transforms/Passes.td | 19 --- mlir/include/Remote/Transforms/Passes.td | 21 ++++ mlir/lib/Catalyst/Transforms/CMakeLists.txt | 1 - mlir/lib/Remote/Transforms/CMakeLists.txt | 1 + .../Transforms/DispatchRemoteTargets.cpp | 115 +++++++++--------- .../DispatchRemoteTargetModules.mlir | 38 +++--- 6 files changed, 95 insertions(+), 100 deletions(-) rename mlir/lib/{Catalyst => Remote}/Transforms/DispatchRemoteTargets.cpp (62%) rename mlir/test/{Catalyst => Remote}/DispatchRemoteTargetModules.mlir (57%) diff --git a/mlir/include/Catalyst/Transforms/Passes.td b/mlir/include/Catalyst/Transforms/Passes.td index c690027b7c..8095b57d2a 100644 --- a/mlir/include/Catalyst/Transforms/Passes.td +++ b/mlir/include/Catalyst/Transforms/Passes.td @@ -380,25 +380,6 @@ def RegisterDecompRuleResourcePass : Pass<"register-decomp-rule-resource"> { }]; } -def DispatchRemoteTargetsPass : Pass<"dispatch-remote-targets", "mlir::ModuleOp"> { - let summary = "Ship cross-compiled catalyst.target modules to a remote executor."; - let description = [{ - For every `builtin.module` carrying a `catalyst.dispatch` attribute and the - `catalyst.object_file` path, this pass will perform the following: - - 1. Injects `remote_open` into `setup()` (once per unique address) and `remote_send_binary` - into `setup()`. - 2. Replaces every host-side `func.call` to the module's entry functions with a - `catalyst.custom_call fn("remote_call")` carrying the object path, address, and callee. - 3. Injects `remote_close` into `teardown()` once any session was opened. - 4. Erases the bodyless external declarations and the nested module from the host. - - The pass is a no-op when no `catalyst.dispatch` modules are present. - }]; - - let dependentDialects = ["catalyst::CatalystDialect", "mlir::func::FuncDialect"]; -} - def EmptyPass : Pass<"empty"> { let summary = "Empty pass that does nothing."; diff --git a/mlir/include/Remote/Transforms/Passes.td b/mlir/include/Remote/Transforms/Passes.td index a6fac00f32..a70a08cd48 100644 --- a/mlir/include/Remote/Transforms/Passes.td +++ b/mlir/include/Remote/Transforms/Passes.td @@ -99,4 +99,25 @@ def ConvertRemoteToLLVMPass : Pass<"convert-remote-to-llvm", "mlir::ModuleOp"> { ]; } +def DispatchRemoteTargetsPass : Pass<"dispatch-remote-targets", "mlir::ModuleOp"> { + let summary = "Ship cross-compiled catalyst.target modules to a remote executor."; + let description = [{ + For every `builtin.module` carrying a `catalyst.dispatch` attribute and the + `catalyst.object_file` path, this pass will perform the following: + + 1. Injects `remote.open` into `setup()` (once per unique address) and `remote.send_binary` + into `setup()` (once per module). + 2. Replaces every host-side `func.call` to the module's entry functions with a + `remote.launch` op carrying the executor address and the entry callee. + 3. Erases the bodyless external declarations and the nested module from the host. + + Session teardown is handled by the runtime, which closes every open session when the + process exits, so no explicit close op is emitted. + + The pass is a no-op when no `catalyst.dispatch` modules are present. + }]; + + let dependentDialects = ["catalyst::remote::RemoteDialect", "mlir::func::FuncDialect"]; +} + #endif // REMOTE_PASSES diff --git a/mlir/lib/Catalyst/Transforms/CMakeLists.txt b/mlir/lib/Catalyst/Transforms/CMakeLists.txt index 1c41bbf59b..0e4ee8d087 100644 --- a/mlir/lib/Catalyst/Transforms/CMakeLists.txt +++ b/mlir/lib/Catalyst/Transforms/CMakeLists.txt @@ -13,7 +13,6 @@ file(GLOB SRC DetensorizeSCFPass.cpp disable_assertion.cpp DisableAssertionPatterns.cpp - DispatchRemoteTargets.cpp EmptyPass.cpp GEPInboundsPass.cpp GEPInboundsPatterns.cpp diff --git a/mlir/lib/Remote/Transforms/CMakeLists.txt b/mlir/lib/Remote/Transforms/CMakeLists.txt index 235863da45..c879fbd6e0 100644 --- a/mlir/lib/Remote/Transforms/CMakeLists.txt +++ b/mlir/lib/Remote/Transforms/CMakeLists.txt @@ -7,6 +7,7 @@ set(LLVM_LINK_COMPONENTS file(GLOB SRC CrossCompileRemoteKernels.cpp + DispatchRemoteTargets.cpp RemoteToLLVM.cpp ) diff --git a/mlir/lib/Catalyst/Transforms/DispatchRemoteTargets.cpp b/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp similarity index 62% rename from mlir/lib/Catalyst/Transforms/DispatchRemoteTargets.cpp rename to mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp index ff95bee176..ea20546126 100644 --- a/mlir/lib/Catalyst/Transforms/DispatchRemoteTargets.cpp +++ b/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp @@ -18,16 +18,16 @@ #include "mlir/IR/BuiltinOps.h" #include "mlir/Pass/Pass.h" -#include "Catalyst/IR/CatalystDialect.h" -#include "Catalyst/IR/CatalystOps.h" +#include "Remote/IR/RemoteOps.h" +#include "Remote/Transforms/Passes.h" using namespace mlir; namespace catalyst { +namespace remote { -#define GEN_PASS_DECL_DISPATCHREMOTETARGETSPASS #define GEN_PASS_DEF_DISPATCHREMOTETARGETSPASS -#include "Catalyst/Transforms/Passes.h.inc" +#include "Remote/Transforms/Passes.h.inc" namespace { @@ -37,17 +37,19 @@ bool isEntryPoint(func::FuncOp fn) return fn->hasAttr("catalyst.entry_point"); } -// Ships cross-compiled `catalyst.target` modules to a remote executor. +// Ships cross-compiled `catalyst.target` modules to a remote executor using the `remote` dialect. // // This pass should be run after `cross-compile-targets`, which records each module's object file in // `catalyst.object_file`. For every nested module carrying a `catalyst.dispatch` attribute this pass: -// 1. Injects `remote_open` into `setup()` (once per unique address) and -// `remote_send_binary` into `setup()` (once per module; the object holds every entry). -// 2. Rewrites every host-side `func.call` to an entry function into a -// `catalyst.custom_call fn("remote_call")` carrying the object path, address, -// and callee. -// 3. Injects `remote_close` into `teardown()` once any session was opened. -// 4. Erases the bodyless external declarations and the nested module from the host. +// 1. Injects `remote.open` into `setup()` (once per unique address) and `remote.send_binary` +// into `setup()` (once per module; the object holds every entry). +// 2. Rewrites every host-side `func.call` to an entry function into a `remote.launch` op carrying +// the executor address and the entry callee. Its lowering resolves `_catalyst_pyface_` +// in the shipped object. +// 3. Erases the bodyless external declarations and the nested module from the host. +// +// Session teardown is handled by the runtime, which closes every open session when the process +// exits, so no explicit close op is emitted (matching `cross-compile-remote-kernels`). struct DispatchRemoteTargetsPass : impl::DispatchRemoteTargetsPassBase { using DispatchRemoteTargetsPassBase::DispatchRemoteTargetsPassBase; @@ -69,7 +71,7 @@ struct DispatchRemoteTargetsPass return; } - // Modules sharing an executor get a single remote_open. + // Modules sharing an executor get a single remote.open. llvm::SmallSet openedAddresses; for (auto nested : targetMods) { @@ -78,57 +80,41 @@ struct DispatchRemoteTargetsPass } nested.erase(); } - - // One remote_close covers every open session. - if (!openedAddresses.empty()) { - injectRemoteCloseIntoTeardown(host); - } } - // Insert a no-operand CustomCallOp before the terminator of `funcName` in `host`. - // Returns the op so the caller can attach attributes, or nullptr if the function - // is absent or bodyless. - catalyst::CustomCallOp injectCustomCallInto(ModuleOp host, StringRef funcName, - StringRef callName) + // Insert `remote.open` before the terminator of `setup`. No-op if setup is absent or bodyless. + void injectRemoteOpenIntoSetup(ModuleOp host, StringAttr addressAttr) { - auto fn = host.lookupSymbol(funcName); - if (!fn || fn.getBody().empty()) { - return nullptr; + auto setupFn = host.lookupSymbol("setup"); + if (!setupFn || setupFn.getBody().empty()) { + return; } - Operation *terminator = fn.getBody().front().getTerminator(); + Operation *terminator = setupFn.getBody().front().getTerminator(); if (!terminator) { - return nullptr; + return; } OpBuilder b(terminator); - return catalyst::CustomCallOp::create(b, fn.getLoc(), TypeRange{}, ValueRange{}, - callName, nullptr); - } - - void injectRemoteOpenIntoSetup(ModuleOp host, StringRef addr) - { - auto op = injectCustomCallInto(host, "setup", "remote_open"); - if (op) { - op->setAttr("catalyst.remote_address", StringAttr::get(&getContext(), addr)); - } - } - - void injectRemoteCloseIntoTeardown(ModuleOp host) - { - // remote_close closes all open sessions; no address needed. - injectCustomCallInto(host, "teardown", "remote_close"); + remote::OpenOp::create(b, setupFn.getLoc(), addressAttr); } + // Insert `remote.send_binary` before the terminator of `setup`. No-op if setup is absent or + // bodyless. Always called after `injectRemoteOpenIntoSetup` so the session is opened first. void injectRemoteSendBinaryIntoSetup(ModuleOp host, StringAttr addressAttr, StringAttr pathAttr) { - auto op = injectCustomCallInto(host, "setup", "remote_send_binary"); - if (op) { - op->setAttr("catalyst.remote_address", addressAttr); - op->setAttr("catalyst.remote_kernel_path", pathAttr); + auto setupFn = host.lookupSymbol("setup"); + if (!setupFn || setupFn.getBody().empty()) { + return; } + Operation *terminator = setupFn.getBody().front().getTerminator(); + if (!terminator) { + return; + } + OpBuilder b(terminator); + remote::SendBinaryOp::create(b, setupFn.getLoc(), addressAttr, pathAttr); } // Open a session (once per address), ship the object recorded in - // `catalyst.object_file`, and rewrite each host-side func.call into a `remote_call`. + // `catalyst.object_file`, and rewrite each host-side func.call into a `remote.launch`. LogicalResult dispatchViaOrcRemote(ModuleOp host, ModuleOp nested, llvm::SmallSet &openedAddresses) { @@ -154,17 +140,17 @@ struct DispatchRemoteTargetsPass auto pathAttr = StringAttr::get(ctx, objPathAttr.getValue()); auto addressAttr = StringAttr::get(ctx, moduleAddress); - // Inject remote_open (setup) once per unique address. + // Inject remote.open (setup) once per unique address. if (!openedAddresses.count(moduleAddress)) { openedAddresses.insert(moduleAddress); - injectRemoteOpenIntoSetup(host, moduleAddress); + injectRemoteOpenIntoSetup(host, addressAttr); } // Ship the object once per module: a single `catalyst.object_file` holds every - // entry function, and remote_call resolves individual symbols within it. + // entry function, and remote.launch resolves individual symbols within it. injectRemoteSendBinaryIntoSetup(host, addressAttr, pathAttr); - // Per entry-marked function: rewrite host-side calls to remote_call. + // Per entry-marked function: rewrite host-side calls to remote.launch. for (auto nestedFn : nested.getOps()) { if (!isEntryPoint(nestedFn)) { continue; @@ -186,14 +172,22 @@ struct DispatchRemoteTargetsPass } } for (func::CallOp call : calls) { + // remote.launch marshals memref descriptors, so its lowering only accepts + // memref-typed operands and results. Reject anything else here with a clear + // error rather than crashing later in convert-remote-to-llvm. This runs after + // bufferization, so a well-formed entry call is already memref-typed. + auto isMemref = [](Type ty) { return isa(ty); }; + if (!llvm::all_of(call.getOperandTypes(), isMemref) || + !llvm::all_of(call.getResultTypes(), isMemref)) { + call.emitOpError("remote dispatch of '") + << fnName << "' requires memref-typed operands and results"; + return failure(); + } OpBuilder b(call); - auto custom = catalyst::CustomCallOp::create( - b, call.getLoc(), call.getResultTypes(), call.getOperands(), "remote_call", - nullptr); - custom->setAttr("catalyst.remote_kernel_path", pathAttr); - custom->setAttr("catalyst.remote_address", addressAttr); - custom->setAttr("catalyst.remote_kernel_callee", calleeAttr); - call.replaceAllUsesWith(custom.getResults()); + auto launch = + remote::LaunchOp::create(b, call.getLoc(), call.getResultTypes(), + call.getOperands(), addressAttr, calleeAttr); + call.replaceAllUsesWith(launch.getResults()); call.erase(); } @@ -205,4 +199,5 @@ struct DispatchRemoteTargetsPass } // namespace +} // namespace remote } // namespace catalyst diff --git a/mlir/test/Catalyst/DispatchRemoteTargetModules.mlir b/mlir/test/Remote/DispatchRemoteTargetModules.mlir similarity index 57% rename from mlir/test/Catalyst/DispatchRemoteTargetModules.mlir rename to mlir/test/Remote/DispatchRemoteTargetModules.mlir index 3dea425738..05da1871c8 100644 --- a/mlir/test/Catalyst/DispatchRemoteTargetModules.mlir +++ b/mlir/test/Remote/DispatchRemoteTargetModules.mlir @@ -17,29 +17,27 @@ // This pass consumes the input IR left by cross-compile-targets which carries catalyst.object_file, // plus a catalyst.dispatch attributes. -// setup() opens the session once and ships the module's object +// setup() opens the session once and ships the module's object. // CHECK-LABEL: func.func @setup() -// CHECK: catalyst.custom_call fn("remote_open") -// CHECK-SAME: catalyst.remote_address = "ADDR:PORT" -// CHECK: catalyst.custom_call fn("remote_send_binary") -// CHECK-SAME: catalyst.remote_address = "ADDR:PORT" -// CHECK-SAME: catalyst.remote_kernel_path = "/tmp/target_compute.o" -// CHECK-NOT: catalyst.custom_call fn("remote_send_binary") +// CHECK: remote.open("ADDR:PORT") +// CHECK: remote.send_binary("ADDR:PORT", "/tmp/target_compute.o") +// CHECK-NOT: remote.send_binary // CHECK: return -// teardown() closes the session. +// teardown() is left untouched: the runtime closes sessions at process exit. // CHECK-LABEL: func.func @teardown() -// CHECK: catalyst.custom_call fn("remote_close") +// CHECK-NOT: remote. +// CHECK: return -// Each host-side call is rewritten to its own remote_call. -// CHECK-LABEL: func.func public @jit_main() -// CHECK: catalyst.custom_call fn("remote_call") -// CHECK-SAME: catalyst.remote_kernel_callee = "noop" -// CHECK: catalyst.custom_call fn("remote_call") -// CHECK-SAME: catalyst.remote_kernel_callee = "noop2" +// Each host-side call is rewritten to its own remote.launch, preserving the call's +// operand/result types (a typed call exercises the memref-result path). +// CHECK-LABEL: func.func public @jit_main +// CHECK: remote.launch("noop", "ADDR:PORT") () : () -> () +// CHECK: remote.launch("compute", "ADDR:PORT") (%{{.*}}) : (memref<4xf64>) -> memref<4xf64> // The bodyless declarations and the nested module are erased. // CHECK-NOT: func.call @noop +// CHECK-NOT: func.call @compute // CHECK-NOT: module @target_compute module @jit_test_dispatch { @@ -53,16 +51,16 @@ module @jit_test_dispatch { } func.func private @noop() - func.func private @noop2() + func.func private @compute(memref<4xf64>) -> memref<4xf64> - func.func public @jit_main() attributes {llvm.emit_c_interface} { + func.func public @jit_main(%arg0: memref<4xf64>) -> memref<4xf64> attributes {llvm.emit_c_interface} { func.call @noop() : () -> () - func.call @noop2() : () -> () - return + %0 = func.call @compute(%arg0) : (memref<4xf64>) -> memref<4xf64> + return %0 : memref<4xf64> } module @target_compute attributes {catalyst.object_file = "/tmp/target_compute.o", catalyst.dispatch = {address = "ADDR:PORT"}} { func.func private @noop() attributes {catalyst.entry_point} - func.func private @noop2() attributes {catalyst.entry_point} + func.func private @compute(memref<4xf64>) -> memref<4xf64> attributes {catalyst.entry_point} } } From 438a31698660163dce7b091244747e34234f82ad Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 5 Jun 2026 17:11:25 -0400 Subject: [PATCH 61/75] fix test --- frontend/test/pytest/test_cross_compile_targets.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/test/pytest/test_cross_compile_targets.py b/frontend/test/pytest/test_cross_compile_targets.py index f2d2e290f4..8473604b28 100644 --- a/frontend/test/pytest/test_cross_compile_targets.py +++ b/frontend/test/pytest/test_cross_compile_targets.py @@ -45,12 +45,13 @@ def circuit(): assert os.path.exists(target_object) ir = get_compilation_stage(jitted, "CrossCompileTargets") - assert 'fn("remote_open")' in ir - assert 'fn("remote_send_binary")' in ir - assert 'fn("remote_call")' in ir - assert 'fn("remote_close")' in ir + assert "remote.open" in ir + assert "remote.send_binary" in ir + assert "remote.launch" in ir + # Teardown close is handled by the runtime at process exit, not emitted as an op. + assert "remote.close" not in ir assert "ADDR:PORT" in ir - assert target_object in ir - assert "module @module_circuit" not in ir + assert target_object in ir + assert "module @module_circuit" not in ir finally: jitted.workspace.cleanup() From 81e06df092b71eb0ed1dfc3e48214a9f56fe3a46 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 5 Jun 2026 17:12:14 -0400 Subject: [PATCH 62/75] remote needs llvmdir --- runtime/Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/runtime/Makefile b/runtime/Makefile index ec79fc9c6f..b7595ff6bb 100644 --- a/runtime/Makefile +++ b/runtime/Makefile @@ -17,6 +17,7 @@ ENABLE_REMOTE ?= OFF ENABLE_ASAN ?= OFF STRICT_WARNINGS ?= ON LLVM_DIR ?= $(MK_DIR)/../mlir/llvm-project/ +LLVM_BUILD_DIR ?= $(LLVM_DIR)/build PLATFORM := $(shell uname -s) @@ -78,6 +79,7 @@ configure: -DCMAKE_LIBRARY_OUTPUT_DIRECTORY=$(RT_BUILD_DIR)/lib \ -DCMAKE_C_COMPILER=$(C_COMPILER) \ -DMLIR_INCLUDE_DIRS=$(LLVM_DIR)/mlir/include \ + -DLLVM_DIR=$(LLVM_BUILD_DIR)/lib/cmake/llvm \ -DCMAKE_CXX_COMPILER=$(CXX_COMPILER) \ -DCMAKE_C_COMPILER_LAUNCHER=$(COMPILER_LAUNCHER) \ -DCMAKE_CXX_COMPILER_LAUNCHER=$(COMPILER_LAUNCHER) \ From 4ec1cc2764ed57bb77be1beffd98c3df5151fdb9 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 5 Jun 2026 22:26:40 +0000 Subject: [PATCH 63/75] smal dep fix --- mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp b/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp index 6520fecfe0..e56f5e6f8f 100644 --- a/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp +++ b/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp @@ -87,7 +87,11 @@ std::vector defaultLoweringPassList() std::vector passes; passes.reserve(buf.size() + llvmPasses.size()); passes.insert(passes.end(), buf.begin(), buf.end()); - passes.insert(passes.end(), llvmPasses.begin(), llvmPasses.end()); + for (const auto &passName : llvmPasses) { + if (passName != "convert-remote-to-llvm") { + passes.push_back(passName); + } + } return passes; } From 68545f5c29602b1b6c4ad08b105ddfe0c252b855 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 5 Jun 2026 22:34:14 +0000 Subject: [PATCH 64/75] move target lowering to after bufferization --- mlir/lib/Driver/CompilerDriver.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mlir/lib/Driver/CompilerDriver.cpp b/mlir/lib/Driver/CompilerDriver.cpp index 9e3e784fdf..ab5fd8c291 100644 --- a/mlir/lib/Driver/CompilerDriver.cpp +++ b/mlir/lib/Driver/CompilerDriver.cpp @@ -485,7 +485,7 @@ llvm::LogicalResult catalyst::driver::runPipeline(PassManager &pm, const Compile catalyst::utils::LinesCount::call(moduleOp); // Cross-compile catalyst.target nested modules and dispatch for execution - if (pipeline.getName() == "GradientLoweringStage" && !options.workspace.empty()) { + if (pipeline.getName() == "BufferizationStage" && !options.workspace.empty()) { Pipeline targetPipeline; targetPipeline.setName("CrossCompileTargets"); std::string dumpIntermediate = options.keepIntermediate ? "true" : "false"; From 67feeceda16ebb880bda228c194cbfe589b1f09b Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 9 Jun 2026 10:35:18 -0400 Subject: [PATCH 65/75] add backend_config to customcall op --- mlir/include/Catalyst/IR/CatalystOps.td | 3 ++- .../Transforms/BufferizableOpInterfaceImpl.cpp | 3 ++- .../Transforms/HloCustomCallPatterns.cpp | 3 ++- mlir/test/Catalyst/BufferizationTest.mlir | 11 +++++++++++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/mlir/include/Catalyst/IR/CatalystOps.td b/mlir/include/Catalyst/IR/CatalystOps.td index c3c60fc840..cf411256ad 100644 --- a/mlir/include/Catalyst/IR/CatalystOps.td +++ b/mlir/include/Catalyst/IR/CatalystOps.td @@ -104,7 +104,8 @@ def CustomCallOp: Catalyst_Op<"custom_call", let arguments = (ins Variadic:$inputs, StrAttr:$call_target_name, - OptionalAttr: $number_original_arg + OptionalAttr: $number_original_arg, + OptionalAttr: $backend_config ); let results = (outs Variadic); diff --git a/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp b/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp index 2a3a6947fc..39c7f75c62 100644 --- a/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp +++ b/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp @@ -182,7 +182,8 @@ struct CustomCallOpInterface // Create an updated custom call operation CustomCallOp::create(rewriter, op->getLoc(), TypeRange{}, bufferArgs, - customCallOp.getCallTargetName(), numArgumentsAttr); + customCallOp.getCallTargetName(), numArgumentsAttr, + customCallOp.getBackendConfigAttr()); size_t startIndex = bufferArgs.size() - customCallOp.getNumResults(); SmallVector bufferResults(bufferArgs.begin() + startIndex, bufferArgs.end()); bufferization::replaceOpWithBufferizedValues(rewriter, op, bufferResults); diff --git a/mlir/lib/hlo-extensions/Transforms/HloCustomCallPatterns.cpp b/mlir/lib/hlo-extensions/Transforms/HloCustomCallPatterns.cpp index 237ef0f8f8..df6df702f7 100644 --- a/mlir/lib/hlo-extensions/Transforms/HloCustomCallPatterns.cpp +++ b/mlir/lib/hlo-extensions/Transforms/HloCustomCallPatterns.cpp @@ -132,7 +132,8 @@ struct HloCustomCallOpRewritePattern : public mlir::OpRewritePattern(op, resultsType, newOperands, callTargetAttr, - nullptr); + /*number_original_arg=*/nullptr, + /*backend_config=*/nullptr); return success(); } diff --git a/mlir/test/Catalyst/BufferizationTest.mlir b/mlir/test/Catalyst/BufferizationTest.mlir index 81415f9970..2f055d5f14 100644 --- a/mlir/test/Catalyst/BufferizationTest.mlir +++ b/mlir/test/Catalyst/BufferizationTest.mlir @@ -91,6 +91,17 @@ func.func @custom_call_copy(%arg0: tensor<2x3xf64>) -> tensor<2x2xf64> { // ----- +// `backend_config` attribute survives bufferization. +// CHECK-LABEL: func.func @custom_call_backend_config +// CHECK: catalyst.custom_call fn("lapack_dgesdd") (%{{.*}}, %{{.*}}) {backend_config = {foo = "bar"}, number_original_arg = 1 : i32} : +// CHECK-SAME: (memref<3x3xf64>, memref<3x3xf64>) -> () +func.func @custom_call_backend_config(%arg0: tensor<3x3xf64>) -> tensor<3x3xf64> { + %0 = catalyst.custom_call fn("lapack_dgesdd") (%arg0) {backend_config = {foo = "bar"}} : (tensor<3x3xf64>) -> (tensor<3x3xf64>) + return %0 : tensor<3x3xf64> +} + +// ----- + // CHECK-LABEL: @test0 module @test0 { // CHECK: catalyst.callback @callback_1(memref, memref) From e200dacc05cd32b634be878f974c9ec261ca298b Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 9 Jun 2026 11:17:17 -0400 Subject: [PATCH 66/75] adapt dispatch to work with remote dialect --- mlir/include/Remote/Transforms/Passes.td | 6 +- .../Transforms/DispatchRemoteTargets.cpp | 87 +++++++++++++++++- mlir/test/Remote/DispatchRemoteLibCalls.mlir | 92 +++++++++++++++++++ 3 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 mlir/test/Remote/DispatchRemoteLibCalls.mlir diff --git a/mlir/include/Remote/Transforms/Passes.td b/mlir/include/Remote/Transforms/Passes.td index a70a08cd48..a0b0fb6ad0 100644 --- a/mlir/include/Remote/Transforms/Passes.td +++ b/mlir/include/Remote/Transforms/Passes.td @@ -117,7 +117,11 @@ def DispatchRemoteTargetsPass : Pass<"dispatch-remote-targets", "mlir::ModuleOp" The pass is a no-op when no `catalyst.dispatch` modules are present. }]; - let dependentDialects = ["catalyst::remote::RemoteDialect", "mlir::func::FuncDialect"]; + let dependentDialects = [ + "catalyst::remote::RemoteDialect", + "catalyst::CatalystDialect", + "mlir::func::FuncDialect" + ]; } #endif // REMOTE_PASSES diff --git a/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp b/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp index ea20546126..cf484f1a93 100644 --- a/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp +++ b/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp @@ -18,6 +18,7 @@ #include "mlir/IR/BuiltinOps.h" #include "mlir/Pass/Pass.h" +#include "Catalyst/IR/CatalystOps.h" #include "Remote/IR/RemoteOps.h" #include "Remote/Transforms/Passes.h" @@ -67,19 +68,96 @@ struct DispatchRemoteTargetsPass } } - if (targetMods.empty()) { + // catalyst.custom_call ops whose backend_config carries a `dispatch` entry + // The call-target name is the executor-side symbol. + SmallVector libCalls; + host.walk([&](catalyst::CustomCallOp call) { + if (remoteDispatchOf(call)) { + libCalls.push_back(call); + } + }); + + if (targetMods.empty() && libCalls.empty()) { return; } // Modules sharing an executor get a single remote.open. llvm::SmallSet openedAddresses; + StringAttr executorAddress; for (auto nested : targetMods) { - if (failed(dispatchViaOrcRemote(host, nested, openedAddresses))) { + if (failed(dispatchViaOrcRemote(host, nested, openedAddresses, executorAddress))) { return signalPassFailure(); } nested.erase(); } + + const size_t numQnodeExecutors = openedAddresses.size(); + + if (!libCalls.empty()) { + for (catalyst::CustomCallOp call : libCalls) { + StringAttr dispatch = remoteDispatchOf(call); + if ((!dispatch || dispatch.getValue().empty()) && numQnodeExecutors > 1) { + call.emitOpError("ambiguous remote executor"); + return signalPassFailure(); + } + StringAttr addrAttr = libCallAddress(call, executorAddress); + if (!addrAttr) { + call.emitOpError("remote runtime_call has no executor address"); + return signalPassFailure(); + } + if (openedAddresses.insert(addrAttr.str()).second) { + injectRemoteOpenIntoSetup(host, addrAttr); + } + } + if (failed(rewriteRemoteLibCalls(libCalls, executorAddress))) { + return signalPassFailure(); + } + } + } + + static StringAttr remoteDispatchOf(catalyst::CustomCallOp call) + { + if (auto cfg = call.getBackendConfigAttr()) { + return cfg.getAs("dispatch"); + } + return nullptr; + } + + static StringAttr libCallAddress(catalyst::CustomCallOp call, StringAttr fallbackAddress) + { + if (StringAttr dispatch = remoteDispatchOf(call)) { + if (!dispatch.getValue().empty()) { + return dispatch; + } + } + return fallbackAddress; + } + + LogicalResult rewriteRemoteLibCalls(ArrayRef libCalls, + StringAttr fallbackAddress) + { + MLIRContext *ctx = &getContext(); + for (catalyst::CustomCallOp call : libCalls) { + StringAttr addressAttr = libCallAddress(call, fallbackAddress); + if (!addressAttr) { + call.emitOpError("remote runtime_call has no executor address"); + return failure(); + } + auto symAttr = StringAttr::get(ctx, call.getCallTargetName()); + OpBuilder b(call); + IntegerAttr numInputAttr = nullptr; + if (auto n = call.getNumberOriginalArg()) { + numInputAttr = b.getI32IntegerAttr(*n); + } + auto remoteCall = + remote::CallOp::create(b, call.getLoc(), call.getResultTypes(), call.getOperands(), + /*address=*/addressAttr, /*symbol=*/symAttr, + /*num_input_args=*/numInputAttr); + call.replaceAllUsesWith(remoteCall.getResults()); + call.erase(); + } + return success(); } // Insert `remote.open` before the terminator of `setup`. No-op if setup is absent or bodyless. @@ -116,7 +194,8 @@ struct DispatchRemoteTargetsPass // Open a session (once per address), ship the object recorded in // `catalyst.object_file`, and rewrite each host-side func.call into a `remote.launch`. LogicalResult dispatchViaOrcRemote(ModuleOp host, ModuleOp nested, - llvm::SmallSet &openedAddresses) + llvm::SmallSet &openedAddresses, + StringAttr &executorAddress) { MLIRContext *ctx = &getContext(); @@ -139,6 +218,8 @@ struct DispatchRemoteTargetsPass auto pathAttr = StringAttr::get(ctx, objPathAttr.getValue()); auto addressAttr = StringAttr::get(ctx, moduleAddress); + // Remember the executor address so standalone remote lib calls reuse it. + executorAddress = addressAttr; // Inject remote.open (setup) once per unique address. if (!openedAddresses.count(moduleAddress)) { diff --git a/mlir/test/Remote/DispatchRemoteLibCalls.mlir b/mlir/test/Remote/DispatchRemoteLibCalls.mlir new file mode 100644 index 0000000000..bd062b5206 --- /dev/null +++ b/mlir/test/Remote/DispatchRemoteLibCalls.mlir @@ -0,0 +1,92 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// RUN: quantum-opt %s --split-input-file --dispatch-remote-targets --verify-diagnostics | FileCheck %s + +// dispatch-remote-targets also rewrites standalone remote library calls which are +// catalyst.custom_call ops whose backend_config carries a `dispatch` entry into +// remote.call ops invoking a symbol already loaded on the executor. + +// ----- + +// When backend_config.dispatch names the executor explicitly, it is rewritten to a remote.call on +// that address, and a session is opened once in setup(). +// CHECK-LABEL: func.func @setup() +// CHECK: remote.open("ADDR:PORT") +// CHECK: return +// CHECK-LABEL: func.func @main +// CHECK: remote.call("fpga_trampoline_a_setup", "ADDR:PORT") +// CHECK-SAME: (memref<256xi8>) -> () +// CHECK-NOT: catalyst.custom_call +module @jit_bound { + func.func @setup() { + return + } + func.func @main(%arg0: memref<256xi8>) { + catalyst.custom_call fn("fpga_trampoline_a_setup") (%arg0) {backend_config = {dispatch = "ADDR:PORT"}, number_original_arg = 1 : i32} : (memref<256xi8>) -> () + return + } +} + +// ----- + +// backend_config.dispatch = "" binds to the program's single executor, which +// is supplied by the QNode target module's catalyst.dispatch address. +// CHECK-LABEL: func.func @setup() +// CHECK: remote.open("ADDR:PORT") +// CHECK-LABEL: func.func @main +// CHECK: remote.call("fpga_trampoline_a_teardown", "ADDR:PORT") +// CHECK-NOT: catalyst.custom_call +module @jit_inherit { + func.func @setup() { + return + } + func.func @teardown() { + return + } + func.func private @compute() + func.func @main() { + func.call @compute() : () -> () + catalyst.custom_call fn("fpga_trampoline_a_teardown") () {backend_config = {dispatch = ""}} : () -> () + return + } + module @target attributes {catalyst.object_file = "/tmp/target.o", catalyst.dispatch = {address = "ADDR:PORT"}} { + func.func private @compute() attributes {catalyst.entry_point} + } +} + +// ----- + +// When the program targets more than one executor, the custom_call should explicitly bind the +// dispatch otherwise it would be ambiguous. +module @jit_ambiguous { + func.func @setup() { + return + } + func.func private @c1() + func.func private @c2() + func.func @main() { + func.call @c1() : () -> () + func.call @c2() : () -> () + // expected-error @below {{ambiguous remote executor}} + catalyst.custom_call fn("foo") () {backend_config = {dispatch = ""}} : () -> () + return + } + module @t1 attributes {catalyst.object_file = "/tmp/t1.o", catalyst.dispatch = {address = "host:1"}} { + func.func private @c1() attributes {catalyst.entry_point} + } + module @t2 attributes {catalyst.object_file = "/tmp/t2.o", catalyst.dispatch = {address = "host:2"}} { + func.func private @c2() attributes {catalyst.entry_point} + } +} From 5ab16354af415345c8f3dfc6f22f5cc1ae3098b5 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 9 Jun 2026 11:22:59 -0400 Subject: [PATCH 67/75] add remote --- frontend/catalyst/jax_primitives.py | 11 +++- frontend/catalyst/kernel.py | 94 +++++++++++++++++++++++------ 2 files changed, 83 insertions(+), 22 deletions(-) diff --git a/frontend/catalyst/jax_primitives.py b/frontend/catalyst/jax_primitives.py index 2ffd894f64..0638bc0f56 100644 --- a/frontend/catalyst/jax_primitives.py +++ b/frontend/catalyst/jax_primitives.py @@ -579,10 +579,17 @@ def _runtime_call_lowering(jax_ctx: mlir.LoweringRuleContext, *args, kernel_desc The artifact path is written as a catalyst.runtime_artifacts ArrayAttr on calling module. """ results_ty = list(convert_shaped_arrays_to_tensors(jax_ctx.avals_out)) - call_op = CustomCallOp(results_ty, list(args), kernel_descriptor.name) - record_runtime_artifact(jax_ctx.module_context.module.operation, kernel_descriptor.artifact) + if kernel_descriptor.remote: + call_op = CustomCallOp(results_ty, list(args), kernel_descriptor.name) + address = kernel_descriptor.remote_address or "" + call_op.operation.attributes["backend_config"] = ir.DictAttr.get( + {"dispatch": ir.StringAttr.get(address)} + ) + return call_op.results + call_op = CustomCallOp(results_ty, list(args), kernel_descriptor.name) + record_runtime_artifact(jax_ctx.module_context.module.operation, kernel_descriptor.artifact) return call_op.results diff --git a/frontend/catalyst/kernel.py b/frontend/catalyst/kernel.py index d922b9622c..7a345f3dc0 100644 --- a/frontend/catalyst/kernel.py +++ b/frontend/catalyst/kernel.py @@ -24,17 +24,41 @@ @dataclass(frozen=True) class KernelDescriptor: - """Describes the ABI of a pre-compiled external kernel. + """Describes the ABI of an external kernel callable via :func:`runtime_call`. + + One of two shapes, distinguished by ``artifact``: + * **local kernel** — ``artifact`` is a path to the shared library; ``remote_address`` is None. + * **remote symbol** — ``artifact`` is None (the symbol lives on the executor); + ``remote_address`` is the bound executor (``"host:port"``) or None to inherit the program's + single executor. Attributes: - name: C symbol name exported by the artifact. - artifact: Absolute path to the shared library (.so / .dylib). - output_spec: Tuple of (shape_tuple, dtype_str) for each output tensor. + name: C symbol name. For a local kernel it is exported by ``artifact``; for a + remote kernel it is a symbol already loaded on the executor. + artifact: Absolute path to the shared library (.so / .dylib), or ``None`` for a + remote symbol (which lives on the executor, not in a local artifact). + output_spec: Tuple of (shape_tuple, dtype_str) for each output tensor. + remote_address: For a remote kernel, the executor address it targets, e.g. + ``"ADDR:PORT"``; ``None`` means "inherit the program's single + executor". Must be ``None`` for a local kernel. """ name: str - artifact: str # absolute path to .so / .dylib + artifact: Optional[str] # absolute path to .so / .dylib, or None for a remote symbol output_spec: tuple # ((shape_tuple, dtype_str), ...) + remote_address: Optional[str] = None # bound executor for a remote symbol; None => inherit + + @property + def remote(self) -> bool: + """True iff this is an executor-side symbol (no local artifact).""" + return self.artifact is None + + def __post_init__(self): + # `remote_address` only makes sense for a remote symbol; a local kernel has no executor. + if self.remote_address is not None and self.artifact is not None: + raise ValueError( + "kernel: `remote_address` is only valid for a remote kernel (artifact=None)" + ) def _to_hashable(spec): @@ -52,36 +76,66 @@ def _to_hashable(spec): return tuple(entries) -def declare(name: str, artifact: str, outputs) -> KernelDescriptor: - """Declare a pre-compiled external kernel for use with :func:`kernel.runtime_call`. +def declare(name: str, artifact: Optional[str] = None, outputs=None, *, + remote=False) -> KernelDescriptor: + """Declare an external kernel for use with :func:`kernel.runtime_call`. Args: - name: C symbol exported by the artifact (e.g. ``"my_func"``). - artifact: Path to the shared library. Resolved relative to ``os.getcwd()`` - if not absolute. The file must exist at declare time. - outputs: :class:`jax.ShapeDtypeStruct` or tuple of them describing each - output tensor. JAX needs these shapes at trace time to infer - what the function returns. + name: C symbol name. For a local kernel, exported by ``artifact``; for a remote + kernel, a symbol already loaded on the executor (e.g. + ``"fpga_trampoline_a_setup"``). + artifact: Path to the shared library for a local kernel (resolved relative to + ``os.getcwd()`` if not absolute; must exist at declare time). Omit for a + remote kernel. + outputs: :class:`jax.ShapeDtypeStruct` or tuple of them describing each output tensor. + JAX needs these at trace time to infer what the call returns. + remote: Mark the symbol as executor-side. Pass the **remote device** + (``remote(target(...), address=...)``) to bind this call explicitly to that + executor's address. ``True`` inheriting the program's single remote executor. Returns: A :class:`KernelDescriptor` """ + output_spec = _to_hashable(outputs) if outputs is not None else () + + if remote: + address = None + if remote is not True: + from catalyst.api_extensions.target import get_dispatch + + dispatch = get_dispatch(remote) + if dispatch is None: + raise ValueError( + "kernel.declare(remote=): the device has no remote dispatch; wrap it " + "with remote(target(...), address=...) first, or pass remote=True to inherit " + "the program's single executor." + ) + address = dispatch.address + # Remote symbol: no local artifact; `remote` is derived from artifact=None. + return KernelDescriptor( + name=name, artifact=None, output_spec=output_spec, remote_address=address + ) + + if artifact is None: + raise ValueError( + "kernel.declare: a local kernel requires `artifact`; pass remote=True for an " + "executor-side symbol" + ) artifact = os.path.abspath(artifact) if not os.path.isfile(artifact): raise FileNotFoundError(f"kernel.declare: artifact not found: {artifact!r}") - return KernelDescriptor( - name=name, - artifact=artifact, - output_spec=_to_hashable(outputs), - ) + return KernelDescriptor(name=name, artifact=artifact, output_spec=output_spec) def runtime_call(kernel_descriptor, *args): - """Call a pre-compiled external kernel from inside ``@qjit``. + """Call an external kernel from inside ``@qjit`` — local or remote. + + If ``kernel_descriptor.remote`` is set, the symbol is dispatched on the program's remote + executor (rewritten to a ``remote.call``); otherwise it calls the local artifact. Args: - kernel_descriptor: A :class:`KernelDescriptor` returned by :func:`declare`. + kernel_descriptor: A :class:`KernelDescriptor` returned by :func:`declare` / :func:`define`. *args: Input tensors. JAX infers shapes and dtypes at trace time. Returns: From c1cbf050cad3ff5de282786f13fe3141d0698f05 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 12 Jun 2026 14:20:35 -0400 Subject: [PATCH 68/75] fix shutdown --- runtime/lib/remote/RemoteSession.cpp | 56 +++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/runtime/lib/remote/RemoteSession.cpp b/runtime/lib/remote/RemoteSession.cpp index def519d5c0..206eb681d0 100644 --- a/runtime/lib/remote/RemoteSession.cpp +++ b/runtime/lib/remote/RemoteSession.cpp @@ -78,6 +78,58 @@ void initialize_targets() (void)inited; } +// A SimpleRemoteEPC transport that wraps the stock FDSimpleRemoteEPCTransport and adds a +// shutdown() in disconnect(). +// +// Teardown of a socket-backed session otherwise deadlocks: SimpleRemoteEPC::disconnect() (called +// from ExecutionSession::endSession()) does `T->disconnect()` then waits for the transport's +// listenLoop to observe end-of-stream and call handleDisconnect. FDSimpleRemoteEPCTransport's +// disconnect() only close()s the socket fd — but on Linux close() neither wakes a read() already +// in flight on the listener thread nor sends a FIN while that read holds a reference, so the +// listener never returns and the wait blocks forever (and the peer never sees EOF either). +// +// shutdown(SHUT_RDWR) forces the in-flight read() to return and sends FIN to the peer. Doing it +// here — inside disconnect(), which runs only after endSession() has finished its executor +// messaging (e.g. deallocating JIT'd memory) — avoids tearing the socket down prematurely. +class ShutdownFDTransport : public SimpleRemoteEPCTransport { + public: + static Expected> + Create(SimpleRemoteEPCTransportClient &C, int InFD, int OutFD) + { + auto Inner = FDSimpleRemoteEPCTransport::Create(C, InFD, OutFD); + if (!Inner) { + return Inner.takeError(); + } + return std::make_unique(std::move(*Inner), InFD, OutFD); + } + + ShutdownFDTransport(std::unique_ptr inner, int inFD, int outFD) + : Inner(std::move(inner)), InFD(inFD), OutFD(outFD) + { + } + + Error start() override { return Inner->start(); } + + Error sendMessage(SimpleRemoteEPCOpcode OpC, uint64_t SeqNo, ExecutorAddr TagAddr, + ArrayRef ArgBytes) override + { + return Inner->sendMessage(OpC, SeqNo, TagAddr, ArgBytes); + } + + void disconnect() override + { + ::shutdown(InFD, SHUT_RDWR); + if (OutFD != InFD) { + ::shutdown(OutFD, SHUT_RDWR); + } + Inner->disconnect(); + } + + private: + std::unique_ptr Inner; + int InFD, OutFD; +}; + // For avoiding the error message being overwritten by subsequent errors in async jobs. // We use thread_local to store the error message. thread_local std::string g_last_error; @@ -234,7 +286,9 @@ struct RemoteSession { auto setup = SimpleRemoteEPC::Setup(); setup.CreateMemoryManager = createSimpleRemoteMemoryManager; - auto EPC = SimpleRemoteEPC::Create( + // ShutdownFDTransport instead of FDSimpleRemoteEPCTransport so teardown shutdown()s the + // socket and doesn't deadlock — see the class comment above. + auto EPC = SimpleRemoteEPC::Create( std::make_unique(std::nullopt), std::move(setup), *SockFD, *SockFD); if (!EPC) { From 6570c00075ddbe1e483430ce16c6d7cf433f2478 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Tue, 16 Jun 2026 10:37:07 -0400 Subject: [PATCH 69/75] fix makefile to parse multi target --- mlir/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mlir/Makefile b/mlir/Makefile index d0e643ee6b..4a719ef15a 100644 --- a/mlir/Makefile +++ b/mlir/Makefile @@ -76,7 +76,7 @@ llvm: cmake -G Ninja -S llvm-project/llvm -B $(LLVM_BUILD_DIR) \ -DCMAKE_BUILD_TYPE=$(BUILD_TYPE) \ -DLLVM_BUILD_EXAMPLES=OFF \ - -DLLVM_TARGETS_TO_BUILD=${LLVM_TARGETS_TO_BUILD} \ + -DLLVM_TARGETS_TO_BUILD="${LLVM_TARGETS_TO_BUILD}" \ -DLLVM_ENABLE_PROJECTS="$(LLVM_PROJECTS)" \ -DLLVM_ENABLE_ASSERTIONS=ON \ -DMLIR_ENABLE_BINDINGS_PYTHON=ON \ From 024d36645eb8d3275e17f46b58e7177551669ec5 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Wed, 17 Jun 2026 10:35:53 -0400 Subject: [PATCH 70/75] Apply rtcapi dlopen issue fic by hongsheng --- runtime/lib/capi/ExecutionContext.hpp | 26 +++++++- runtime/tests/CMakeLists.txt | 14 +++++ runtime/tests/Test_SharedLibraryManager.cpp | 69 +++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 runtime/tests/Test_SharedLibraryManager.cpp diff --git a/runtime/lib/capi/ExecutionContext.hpp b/runtime/lib/capi/ExecutionContext.hpp index 5bb1f4d825..1d8f3de9f9 100644 --- a/runtime/lib/capi/ExecutionContext.hpp +++ b/runtime/lib/capi/ExecutionContext.hpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -24,6 +25,7 @@ #include #include +#include "DataView.hpp" #include "Exception.hpp" #include "QuantumDevice.hpp" @@ -80,15 +82,37 @@ class SharedLibraryManager final { { #ifdef __APPLE__ auto rtld_flags = RTLD_LAZY; + constexpr const char *dl_ext = ".dylib"; #else // Closing the dynamic library of Lightning simulators with dlclose() where OpenMP // directives (in Lightning simulators) are in use would raise memory segfaults. // Note that we use RTLD_NODELETE as a workaround to fix the issue. auto rtld_flags = RTLD_LAZY | RTLD_NODELETE; + constexpr const char *dl_ext = ".so"; #endif _handler = dlopen(filename.c_str(), rtld_flags); - RT_FAIL_IF(!_handler, dlerror()); + if (_handler) { + return; + } + + // dlerror() is destructive: it returns the pending error and clears the state. Capture it + // into a local before any further use. + const char *primary_dlerror = dlerror(); + std::string primary_error = primary_dlerror ? primary_dlerror : "unknown dlopen error"; + auto basename = std::filesystem::path(filename).filename().string(); + + // rewrite the extension if it differs from the platform one. + if (!basename.ends_with(dl_ext)) { + auto dot = basename.find_last_of('.'); + if (dot != std::string::npos) { + basename.resize(dot); + } + basename += dl_ext; + } + + _handler = dlopen(basename.c_str(), rtld_flags); + RT_FAIL_IF(!_handler, ("dlopen failed to load " + filename + ": " + primary_error).c_str()); } ~SharedLibraryManager() diff --git a/runtime/tests/CMakeLists.txt b/runtime/tests/CMakeLists.txt index bd9f9ba4d0..40d2b3ad71 100644 --- a/runtime/tests/CMakeLists.txt +++ b/runtime/tests/CMakeLists.txt @@ -57,6 +57,7 @@ target_sources(runner_tests_qir_runtime PRIVATE Test_DataView.cpp Test_NullQubit.cpp Test_ResourceTracker.cpp + Test_SharedLibraryManager.cpp ) # For tests we do require libpython in order to embed a Python interpreter. @@ -67,6 +68,19 @@ target_link_libraries(runner_tests_qir_runtime PRIVATE rtd_null_qubit ) +# Test_SharedLibraryManager dlopens +if(NOT APPLE) + set_property(TARGET runner_tests_qir_runtime APPEND PROPERTY BUILD_RPATH $ORIGIN) + set_property(TARGET runner_tests_qir_runtime APPEND PROPERTY BUILD_RPATH $ORIGIN/../lib) +else() + set_property(TARGET runner_tests_qir_runtime APPEND PROPERTY BUILD_RPATH @loader_path) + set_property(TARGET runner_tests_qir_runtime APPEND PROPERTY BUILD_RPATH @loader_path/../lib) +endif() + +target_compile_definitions(runner_tests_qir_runtime PRIVATE + RTD_NULL_QUBIT_LIB="$" +) + catch_discover_tests(runner_tests_qir_runtime) if(ENABLE_OPENQASM) diff --git a/runtime/tests/Test_SharedLibraryManager.cpp b/runtime/tests/Test_SharedLibraryManager.cpp new file mode 100644 index 0000000000..530cb68294 --- /dev/null +++ b/runtime/tests/Test_SharedLibraryManager.cpp @@ -0,0 +1,69 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include + +#include "catch2/catch_test_macros.hpp" +#include "catch2/matchers/catch_matchers_string.hpp" + +#include "Exception.hpp" +#include "ExecutionContext.hpp" + +using namespace Catalyst::Runtime; +using Catch::Matchers::ContainsSubstring; + +#ifdef __APPLE__ +constexpr const char *kPlatformExt = ".dylib"; +constexpr const char *kWrongExt = ".so"; +#else +constexpr const char *kPlatformExt = ".so"; +constexpr const char *kWrongExt = ".dylib"; +#endif + +static const std::string kNullQubitAbs = RTD_NULL_QUBIT_LIB; +static const std::string kNullQubitBasename = std::string("librtd_null_qubit") + kPlatformExt; + +TEST_CASE("SharedLibraryManager loads from absolute path", "[shared_lib]") +{ + REQUIRE_NOTHROW(SharedLibraryManager{kNullQubitAbs}); +} + +TEST_CASE("SharedLibraryManager rewrites wrong extension", "[shared_lib]") +{ + std::string mangled = kNullQubitAbs; + auto dot = mangled.find_last_of('.'); + REQUIRE(dot != std::string::npos); + mangled.resize(dot); + mangled += kWrongExt; + + REQUIRE_NOTHROW(SharedLibraryManager{mangled}); +} + +TEST_CASE("SharedLibraryManager falls back when directory is stale", "[shared_lib]") +{ + std::string mangled = "/this/path/does/not/exist/" + kNullQubitBasename; + REQUIRE_NOTHROW(SharedLibraryManager{mangled}); +} + +TEST_CASE("SharedLibraryManager throws with original filename on hard failure", "[shared_lib]") +{ + const std::string bogus = "lib_not_real.so"; + REQUIRE_THROWS_WITH(SharedLibraryManager{bogus}, ContainsSubstring(bogus)); +} + +TEST_CASE("SharedLibraryManager resolves a known symbol", "[shared_lib]") +{ + SharedLibraryManager mgr{kNullQubitAbs}; + REQUIRE(mgr.getSymbol("NullQubitFactory") != nullptr); +} From 5314ca11ef0897ebd788a0fcd92d6cd60a91373f Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 26 Jun 2026 00:07:00 -0400 Subject: [PATCH 71/75] add launch_kernel bufferization --- .../BufferizableOpInterfaceImpl.cpp | 90 +++++++++++++++++++ .../Transforms/InlineNestedModules.cpp | 66 +++----------- .../Catalyst/LaunchKernelBufferization.mlir | 60 +++++++++++++ mlir/test/Catalyst/NestedModuleTarget.mlir | 64 +++++++++---- 4 files changed, 213 insertions(+), 67 deletions(-) create mode 100644 mlir/test/Catalyst/LaunchKernelBufferization.mlir diff --git a/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp b/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp index 54e26c9fb1..f5471114f9 100644 --- a/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp +++ b/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp @@ -337,6 +337,95 @@ struct CallbackCallOpInterface } }; +/// Bufferization of catalyst.launch_kernel. The callee reads its operands and returns each result +/// in its own freshly allocated buffer. Bufferization therefore converts the operands to buffers and +/// the tensor results to memref results (return-by-value): no operand is written in place and no +/// result aliases an operand. +struct LaunchKernelOpInterface + : public bufferization::BufferizableOpInterface::ExternalModel { + // Each result is returned in a buffer the op allocates. + bool bufferizesToAllocation(Operation *op, Value value) const { return true; } + + // The callee reads its operands. + bool bufferizesToMemoryRead(Operation *op, OpOperand &opOperand, + const bufferization::AnalysisState &state) const + { + return true; + } + + // Operands are not written in place: every result is returned in a separate allocation. + bool bufferizesToMemoryWrite(Operation *op, OpOperand &opOperand, + const bufferization::AnalysisState &state) const + { + return false; + } + + // Results are fresh allocations, so they alias none of the operands. + bufferization::AliasingValueList + getAliasingValues(Operation *op, OpOperand &opOperand, + const bufferization::AnalysisState &state) const + { + return {}; + } + + LogicalResult bufferize(Operation *op, RewriterBase &rewriter, + const bufferization::BufferizationOptions &options, + bufferization::BufferizationState &state) const + { + auto launchOp = cast(op); + + SmallVector bufferOperands; + for (Value operand : launchOp.getInputs()) { + Value buffer; + if (isa(operand.getType())) { + FailureOr opBuffer = getBuffer(rewriter, operand, options, state); + if (failed(opBuffer)) { + return failure(); + } + buffer = *opBuffer; + } + else { + buffer = operand; + } + + // The callee receives each operand as a contiguous block, addressed through the memref's + // aligned pointer with its strides/offset ignored. Copy any non-identity-layout operand + // into a fresh contiguous buffer first so the callee sees the intended elements. + if (auto memrefTy = dyn_cast(buffer.getType())) { + if (!memrefTy.getLayout().isIdentity()) { + MemRefType contiguousTy = + MemRefType::get(memrefTy.getShape(), memrefTy.getElementType()); + auto alloc = memref::AllocOp::create(rewriter, op->getLoc(), contiguousTy); + memref::CopyOp::create(rewriter, op->getLoc(), buffer, alloc.getResult()); + buffer = alloc.getResult(); + } + } + bufferOperands.push_back(buffer); + } + + SmallVector memrefResultTypes; + for (Type resultType : launchOp.getResultTypes()) { + if (auto tensorType = dyn_cast(resultType)) { + memrefResultTypes.push_back( + bufferization::getMemRefTypeWithStaticIdentityLayout(tensorType)); + } + else { + memrefResultTypes.push_back(resultType); + } + } + + auto newLaunchOp = + LaunchKernelOp::create(rewriter, op->getLoc(), memrefResultTypes, launchOp.getCallee(), + bufferOperands, launchOp.getArgAttrsAttr(), + launchOp.getResAttrsAttr()); + // Carry over any discardable attributes, since create() only sets the declared ones. + newLaunchOp->setDiscardableAttrs(launchOp->getDiscardableAttrDictionary()); + bufferization::replaceOpWithBufferizedValues(rewriter, op, newLaunchOp.getResults()); + return success(); + } +}; + } // namespace void catalyst::registerBufferizableOpInterfaceExternalModels(DialectRegistry ®istry) @@ -346,5 +435,6 @@ void catalyst::registerBufferizableOpInterfaceExternalModels(DialectRegistry &re PrintOp::attachInterface(*ctx); CallbackOp::attachInterface(*ctx); CallbackCallOp::attachInterface(*ctx); + LaunchKernelOp::attachInterface(*ctx); }); } diff --git a/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp b/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp index e235a3e00b..c11bf9e36c 100644 --- a/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp +++ b/mlir/lib/Catalyst/Transforms/InlineNestedModules.cpp @@ -61,6 +61,10 @@ * ``` * 5. Cleanup: remove the catalyst.fully_qualified_name attribute * + * Exception: modules carrying `catalyst.target` are NOT inlined, renamed, or flattened. They are + * compiled to standalone objects elsewhere (cross-compile-targets), so their host launch_kernel is + * left in place for a later consumer. + * */ #include @@ -206,8 +210,7 @@ LogicalResult RenameFunctionsPattern::matchAndRewrite(Operation *child, { bool isSymbolTable = child->hasTrait(); bool hasBeenRenamed = child->hasAttr(hasBeenRenamedAttrName); - // TODO: isQnode - bool mustRename = isSymbolTable && !hasBeenRenamed; + bool mustRename = isSymbolTable && !hasBeenRenamed && !child->hasAttr("catalyst.target"); if (!mustRename) { return failure(); } @@ -395,13 +398,14 @@ struct NestedToFlatCallPattern : public OpRewritePatternfind(op.getCallee()) != _map->end(); - if (!found) { + // Dispatch-module entries are excluded from the map (their modules are left intact for + // dispatch-remote-targets), so their launch_kernel is not matched here and stays as-is. + auto found = _map->find(op.getCallee()); + if (found == _map->end()) { return failure(); } - auto newSymbolRefAttr = _map->find(op.getCallee())->getSecond(); - rewriter.replaceOpWithNewOp(op, newSymbolRefAttr, op.getResultTypes(), + rewriter.replaceOpWithNewOp(op, found->getSecond(), op.getResultTypes(), op.getOperands()); return success(); } @@ -517,63 +521,21 @@ struct InlineNestedSymbolTablePass : PassWrapper old_to_new; - bool emitDecls = _stopAfterStep >= 4 || _stopAfterStep == 0; - SmallVector targetDeclarations; - llvm::SmallSet rootSymbols; - if (emitDecls) { - for (Operation &rootOp : symbolTable->getRegion(0).front()) { - if (auto symbol = dyn_cast(rootOp)) - rootSymbols.insert(symbol.getName()); - } - } - symbolTable->walk([&](Operation *nested) { if (nested == symbolTable) return WalkResult::advance(); - // Recurse into target modules (not inlined); skip other nested modules (inlined). - if (auto mod = dyn_cast(nested)) - return mod->hasAttr("catalyst.target") ? WalkResult::advance() : WalkResult::skip(); + // Any module still nested at this point is a catalyst.target module (ordinary ones were + // inlined above): leave it and its contents entirely untouched. + if (isa(nested)) + return WalkResult::skip(); if (!isa(nested) || !nested->hasAttr(fullyQualifiedNameAttr)) return WalkResult::advance(); SymbolRefAttr old = nested->getAttrOfType(fullyQualifiedNameAttr); SymbolRefAttr _new = SymbolRefAttr::get(cast(nested)); old_to_new.insert({old, _new}); - // For functions remaining inside a target module, emit an external declaration in - // the root so the flat call inserted by NestedToFlatCallPattern is visible. - if (emitDecls && isa(nested) && nested->getParentOp() != symbolTable) { - auto func = cast(nested); - // The host only calls entry points; helpers stay internal to the object. - if (!func->hasAttr("catalyst.entry_point")) - return WalkResult::advance(); - StringRef name = func.getName(); - if (!rootSymbols.insert(name).second) - return WalkResult::advance(); - - auto decl = cast(func->clone()); - decl.eraseBody(); - decl->removeAttr(fullyQualifiedNameAttr); - decl.setPrivate(); - if (auto targetAttr = nested->getParentOp()->getAttr("catalyst.target")) - decl->setAttr("catalyst.target", targetAttr); - // Mark tensor-typed arguments as read-only so one-shot-bufferize does not - // insert defensive copies at call sites of this external decl. - for (unsigned i = 0; i < decl.getNumArguments(); ++i) { - if (isa(decl.getArgumentTypes()[i])) - decl.setArgAttr(i, "bufferization.access", - StringAttr::get(context, "read")); - } - targetDeclarations.push_back(decl); - } return WalkResult::advance(); }); - if (!targetDeclarations.empty()) { - OpBuilder declBuilder(&symbolTable->getRegion(0).front(), - symbolTable->getRegion(0).front().begin()); - for (func::FuncOp decl : targetDeclarations) - declBuilder.insert(decl); - } - RewritePatternSet nestedToFlat(context); nestedToFlat.add( context, &old_to_new); diff --git a/mlir/test/Catalyst/LaunchKernelBufferization.mlir b/mlir/test/Catalyst/LaunchKernelBufferization.mlir new file mode 100644 index 0000000000..1c8f7e4dee --- /dev/null +++ b/mlir/test/Catalyst/LaunchKernelBufferization.mlir @@ -0,0 +1,60 @@ +// Copyright 2026 Xanadu Quantum Technologies Inc. + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at + +// http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// RUN: quantum-opt --split-input-file \ +// RUN: --pass-pipeline="builtin.module( \ +// RUN: one-shot-bufferize{unknown-type-conversion=identity-layout-map} \ +// RUN: )" %s | FileCheck %s + +// launch_kernel must survive bufferization with its results intact: tensor operands become memref +// buffers and tensor results become memref results (no out-param marshalling), matching +// remote.launch's contract. + +// CHECK-LABEL: func.func @host +// CHECK: %[[ARG:.*]] = bufferization.to_buffer %{{.*}} : tensor<4xf64> to memref<4xf64> +// CHECK: %[[RES:.*]] = catalyst.launch_kernel @target::@entry(%[[ARG]]) : (memref<4xf64>) -> memref<4xf64> +// CHECK: bufferization.to_tensor %[[RES]] +func.func @host(%arg0: tensor<4xf64>) -> tensor<4xf64> { + %0 = catalyst.launch_kernel @target::@entry(%arg0) : (tensor<4xf64>) -> tensor<4xf64> + return %0 : tensor<4xf64> +} + +module @target { + func.func public @entry(%arg0: tensor<4xf64>) -> tensor<4xf64> { + return %arg0 : tensor<4xf64> + } +} + +// ----- + +// A non-identity-layout (strided/offset) operand must be copied into a fresh contiguous buffer +// before the call: the remote/kernel ABI sends a contiguous block from the aligned pointer and +// ignores strides/offset, so passing the subview directly would ship the wrong bytes. + +// CHECK-LABEL: func.func @host_strided +// CHECK: %[[SUB:.*]] = memref.subview %{{.*}} : memref<8xf64> to memref<4xf64, strided<[1], offset: 2>> +// CHECK: %[[ALLOC:.*]] = memref.alloc() : memref<4xf64> +// CHECK: memref.copy %[[SUB]], %[[ALLOC]] +// CHECK: catalyst.launch_kernel @target::@entry(%[[ALLOC]]) : (memref<4xf64>) -> memref<4xf64> +func.func @host_strided(%arg0: tensor<8xf64>) -> tensor<4xf64> { + %slice = tensor.extract_slice %arg0[2] [4] [1] : tensor<8xf64> to tensor<4xf64> + %0 = catalyst.launch_kernel @target::@entry(%slice) : (tensor<4xf64>) -> tensor<4xf64> + return %0 : tensor<4xf64> +} + +module @target { + func.func public @entry(%arg0: tensor<4xf64>) -> tensor<4xf64> { + return %arg0 : tensor<4xf64> + } +} diff --git a/mlir/test/Catalyst/NestedModuleTarget.mlir b/mlir/test/Catalyst/NestedModuleTarget.mlir index 290ab2830d..8990c25dff 100644 --- a/mlir/test/Catalyst/NestedModuleTarget.mlir +++ b/mlir/test/Catalyst/NestedModuleTarget.mlir @@ -12,27 +12,28 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Verify that modules annotated with catalyst.target are not inlined. +// Verify that modules annotated with catalyst.target are not inlined, renamed, or flattened: their +// launch_kernel survives intact for a later consumer (e.g. dispatch-remote-targets). // RUN: quantum-opt --inline-nested-module --split-input-file %s | FileCheck %s // CHECK-LABEL: module @host module @host { - // CHECK: func.func private @ghz_0() attributes {catalyst.entry_point, catalyst.target = {backend = "accel"}} + // No root declaration is emitted; the launch_kernel is kept (not flattened to a func.call). + // CHECK-NOT: func.func private @ghz // CHECK: func.func public @jit_main func.func public @jit_main() attributes {llvm.emit_c_interface} { - // CHECK-NOT: catalyst.launch_kernel - // CHECK: call @ghz_0() + // CHECK: catalyst.launch_kernel @module_accel::@ghz() : () -> () catalyst.launch_kernel @module_accel::@ghz() : () -> () func.return } // CHECK: module @module_accel attributes {catalyst.target // CHECK-NOT: catalyst.unique_names - // CHECK: func.func @ghz_0() + // CHECK: func.func @ghz() module @module_accel attributes {catalyst.target = {backend = "accel"}} { - func.func @ghz() attributes {catalyst.entry_point} { + func.func @ghz() { func.return } } @@ -43,14 +44,17 @@ module @host { // ----- +// A mix: the non-target module is inlined+flattened (call @work_0); the catalyst.target module +// keeps its nested launch_kernel. // CHECK-LABEL: module @mixed -// CHECK: func.func private @kernel_0() attributes {catalyst.entry_point, catalyst.target = {backend = "accel"}} // CHECK: func.func public @jit_run // CHECK: call @work_0() -// CHECK: call @kernel_0() +// CHECK: catalyst.launch_kernel @module_accel2::@kernel() +// CHECK: func.func @work_0() // CHECK-NOT: module @module_cpu // CHECK: module @module_accel2 attributes {catalyst.target // CHECK-NOT: catalyst.unique_names +// CHECK: func.func @kernel() module @mixed { func.func public @jit_run() attributes {llvm.emit_c_interface} { @@ -66,7 +70,7 @@ module @mixed { } module @module_accel2 attributes {catalyst.target = {backend = "accel"}} { - func.func @kernel() attributes {catalyst.entry_point} { + func.func @kernel() { func.return } } @@ -77,12 +81,13 @@ module @mixed { // ----- +// Two target modules with the same entry name: since neither is inlined or renamed, both keep the +// name @kernel in their own module (no collision — separate symbol tables), and both launch_kernels +// survive. // CHECK-LABEL: module @duplicate_target_names -// CHECK-DAG: func.func private @kernel_1() attributes {attr = "value", catalyst.entry_point, catalyst.target = {backend = "accel"}} -// CHECK-DAG: func.func private @kernel_0() attributes {catalyst.entry_point, catalyst.target = {backend = "accel"}} // CHECK: func.func public @jit_run -// CHECK: call @kernel_1() -// CHECK: call @kernel_0() +// CHECK: catalyst.launch_kernel @module_accel_a::@kernel() +// CHECK: catalyst.launch_kernel @module_accel_b::@kernel() // CHECK-DAG: module @module_accel_a attributes {catalyst.target // CHECK-DAG: module @module_accel_b attributes {catalyst.target // CHECK-NOT: catalyst.unique_names @@ -95,14 +100,43 @@ module @duplicate_target_names { } module @module_accel_a attributes {catalyst.target = {backend = "accel"}} { - func.func @kernel() attributes {attr = "value", catalyst.entry_point} { + func.func @kernel() attributes {attr = "value"} { func.return } } module @module_accel_b attributes {catalyst.target = {backend = "accel"}} { - func.func @kernel() attributes {catalyst.entry_point} { + func.func @kernel() { func.return } } } + +// ----- + +// Dispatch modules (catalyst.dispatch) are not inlined, not flattened, AND not renamed: +// dispatch-remote-targets consumes the launch_kernel directly and resolves the entry in the +// object's own JITDylib on the executor, so the kernel keeps its original name and no root +// declaration is emitted. +// CHECK-LABEL: module @host_dispatch +// CHECK-NOT: func.func private @ghz +// CHECK: func.func public @jit_main +// CHECK: catalyst.launch_kernel @module_remote::@ghz() : () -> () +// CHECK: module @module_remote attributes {catalyst.dispatch +// CHECK: func.func @ghz() +module @host_dispatch { + + func.func public @jit_main() attributes {llvm.emit_c_interface} { + catalyst.launch_kernel @module_remote::@ghz() : () -> () + func.return + } + + module @module_remote attributes {catalyst.target = {backend = "accel"}, catalyst.dispatch = {address = "h:1"}} { + func.func @ghz() { + func.return + } + } + + func.func @setup() { func.return } + func.func @teardown() { func.return } +} From f0ea8c3404f3956c944a292a5be21551ac7aca9b Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 26 Jun 2026 12:29:21 -0400 Subject: [PATCH 72/75] Cross-compile local targets; derive entry points from launch_kernel; remove mark-entry-points --- frontend/catalyst/compiler.py | 23 +++- .../test/pytest/test_cross_compile_targets.py | 27 ++++- mlir/include/Catalyst/Transforms/Passes.td | 10 -- .../DefaultPipelines/DefaultPipelines.h | 3 - mlir/lib/Catalyst/Transforms/CMakeLists.txt | 1 - .../Transforms/CrossCompileTargets.cpp | 110 +++++++++++++----- .../Catalyst/Transforms/MarkEntryPoints.cpp | 68 ----------- mlir/lib/Driver/CompilerDriver.cpp | 16 +++ .../Catalyst/CrossCompileTargetModules.mlir | 35 +++--- mlir/test/Catalyst/MarkEntryPoints.mlir | 84 ------------- 10 files changed, 159 insertions(+), 218 deletions(-) delete mode 100644 mlir/lib/Catalyst/Transforms/MarkEntryPoints.cpp delete mode 100644 mlir/test/Catalyst/MarkEntryPoints.mlir diff --git a/frontend/catalyst/compiler.py b/frontend/catalyst/compiler.py index ed7db4a8e1..d1a69283f2 100644 --- a/frontend/catalyst/compiler.py +++ b/frontend/catalyst/compiler.py @@ -222,9 +222,9 @@ def _available_compilers(fallback_compilers): yield compiler @staticmethod - def _attempt_link(compiler, flags, infile, outfile, options): + def _attempt_link(compiler, flags, infile, outfile, options, extra_objects=None): try: - command = [compiler] + flags + [infile, "-o", outfile] + command = [compiler] + flags + [infile] + list(extra_objects or []) + ["-o", outfile] run_writing_command(command, options) return True except subprocess.CalledProcessError as e: @@ -251,7 +251,8 @@ def get_output_filename(infile): @staticmethod @debug_logger - def run(infile, outfile=None, flags=None, fallback_compilers=None, options=None): + def run(infile, outfile=None, flags=None, fallback_compilers=None, options=None, + extra_objects=None): """ Link the infile against the necessary libraries and produce the outfile. @@ -261,6 +262,8 @@ def run(infile, outfile=None, flags=None, fallback_compilers=None, options=None) flags (Optional[List[str]]): flags to be passed down to the compiler fallback_compilers (Optional[List[str]]): name of executables to be looked for in PATH compile_options (Optional[CompileOptions]): generic compilation options. + extra_objects (Optional[List[str]]): additional object files to statically link + (e.g. locally cross-compiled catalyst.target kernels). Raises: EnvironmentError: The exception is raised when no compiler succeeded. """ @@ -273,7 +276,8 @@ def run(infile, outfile=None, flags=None, fallback_compilers=None, options=None) if fallback_compilers is None: fallback_compilers = LinkerDriver._default_fallback_compilers for compiler in LinkerDriver._available_compilers(fallback_compilers): - success = LinkerDriver._attempt_link(compiler, flags, infile, outfile, options) + success = LinkerDriver._attempt_link( + compiler, flags, infile, outfile, options, extra_objects) if options.verbose: print("Shared object linking successful", file=options.logfile) if success: @@ -507,7 +511,16 @@ def run_from_ir(self, ir: str, module_name: str, workspace: Directory): out_IR = None if self.options.link: - output = LinkerDriver.run(output_object_name, options=self.options) + # Locally cross-compiled catalyst.target kernels are emitted as separate objects; the + # driver lists them in `{module}.objects` for the linker to statically include. + extra_objects = [] + objects_manifest = os.path.join(str(workspace), f"{module_name}.objects") + if os.path.exists(objects_manifest): + with open(objects_manifest, "r", encoding="utf-8") as f: + extra_objects = [line.strip() for line in f if line.strip()] + output = LinkerDriver.run( + output_object_name, options=self.options, extra_objects=extra_objects + ) output = str(pathlib.Path(output).absolute()) else: output = None diff --git a/frontend/test/pytest/test_cross_compile_targets.py b/frontend/test/pytest/test_cross_compile_targets.py index 8473604b28..7bf32a71e6 100644 --- a/frontend/test/pytest/test_cross_compile_targets.py +++ b/frontend/test/pytest/test_cross_compile_targets.py @@ -17,21 +17,19 @@ import os +import numpy as np import pennylane as qp -from catalyst import remote, target +from catalyst import qjit, target from catalyst.compiler import CompileOptions from catalyst.debug import get_compilation_stage from catalyst.jit import QJIT def test_remote_dispatch_full_chain(): - """A ``remote``-tagged QNode is cross-compiled to its own object and its host call is + """A ``target(..., address=...)`` QNode is cross-compiled to its own object and its host call is rewritten for remote dispatch.""" - dev = remote( - target(qp.device("null.qubit", wires=2), backend="my-backend"), - address="ADDR:PORT", - ) + dev = target(qp.device("null.qubit", wires=2), address="ADDR:PORT") @qp.qnode(dev) def circuit(): @@ -55,3 +53,20 @@ def circuit(): assert "module @module_circuit" not in ir finally: jitted.workspace.cleanup() + + +def test_local_target_compiles_links_and_runs(): + """A ``target``-tagged (non-remote) QNode is cross-compiled to its own object, statically linked, + and executed in-process — its host launch_kernel is flattened to a flat call into the object.""" + # No ``address`` and no explicit triple -> host triple -> local static-link path. + dev = target(qp.device("lightning.qubit", wires=2)) + + @qjit + @qp.qnode(dev) + def circuit(x: float): + qp.RX(x, wires=0) + qp.CNOT(wires=[0, 1]) + return qp.expval(qp.PauliZ(0)) + + assert np.isclose(circuit(0.0), 1.0) + assert np.isclose(circuit(np.pi), -1.0) diff --git a/mlir/include/Catalyst/Transforms/Passes.td b/mlir/include/Catalyst/Transforms/Passes.td index 8095b57d2a..0d2a6af902 100644 --- a/mlir/include/Catalyst/Transforms/Passes.td +++ b/mlir/include/Catalyst/Transforms/Passes.td @@ -180,16 +180,6 @@ def CrossCompileTargetsPass : Pass<"cross-compile-targets", "mlir::ModuleOp"> { ]; } -def MarkEntryPointsPass : Pass<"mark-entry-points", "mlir::ModuleOp"> { - let summary = "Mark externally-callable entries on nested modules."; - let description = [{ - Annotate functions inside any nested module whose name is reachable - from outside that module with `catalyst.entry_point`. - }]; - - let dependentDialects = ["mlir::func::FuncDialect"]; -} - def InlineNestedModulePass : Pass<"inline-nested-module"> { let summary = "Inline nested modules with qnode attribute."; diff --git a/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h b/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h index 1836f652a1..b6d3908f68 100644 --- a/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h +++ b/mlir/include/Driver/DefaultPipelines/DefaultPipelines.h @@ -43,9 +43,6 @@ const PipelineList pipelineList{ "split-multiple-tapes", // Run the transform sequence defined in the MLIR module "builtin.module(apply-transform-sequence)", - // Stamp catalyst.entry_point on host-called functions inside every - // nested module. - "mark-entry-points", // Nested modules are something that will be used in the future // for making device-specific transformations. // Since at the moment, nothing in the runtime is using them diff --git a/mlir/lib/Catalyst/Transforms/CMakeLists.txt b/mlir/lib/Catalyst/Transforms/CMakeLists.txt index 0e4ee8d087..562a8f959f 100644 --- a/mlir/lib/Catalyst/Transforms/CMakeLists.txt +++ b/mlir/lib/Catalyst/Transforms/CMakeLists.txt @@ -17,7 +17,6 @@ file(GLOB SRC GEPInboundsPass.cpp GEPInboundsPatterns.cpp InlineNestedModules.cpp - MarkEntryPoints.cpp mark_entry_point_args_non_writable.cpp MemrefCopyToLinalgCopyPass.cpp MemrefCopyToLinalgCopyPatterns.cpp diff --git a/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp b/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp index e56f5e6f8f..b41deabcb7 100644 --- a/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp +++ b/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +#include "llvm/ADT/SmallSet.h" #include "llvm/ADT/StringExtras.h" // llvm::join #include "llvm/IR/LLVMContext.h" #include "llvm/IR/LegacyPassManager.h" @@ -73,11 +74,6 @@ void exposeEntryViaCInterface(func::FuncOp fn) fn->removeAttr("llvm.linkage"); } -bool isEntryPoint(func::FuncOp fn) -{ - return fn->hasAttr("catalyst.entry_point"); -} - // The default lowering applied to a catalyst.target module: bufferization + // LLVM-dialect lowering, reusing the same stage definitions as the host pipeline. std::vector defaultLoweringPassList() @@ -95,26 +91,59 @@ std::vector defaultLoweringPassList() return passes; } -// Reduce a compiled target module to external declarations of its entry functions. The definitions -// now live in the emitted object, so the host pipeline does not re-lower them. -void reduceToEntryDeclarations(ModuleOp nested) +// Lower a *local* (non-dispatch) target module. Its cross-compiled object is statically linked into +// the final binary, so each host-side launch_kernel into it is rewritten to a flat func.call against +// an external declaration of the entry (resolved at link time by the object's native symbol). The +// object path is recorded on the root module for the linker, and the now-empty module is erased. +LogicalResult lowerLocalTargetCalls(ModuleOp host, ModuleOp nested, + SmallVectorImpl &objectFiles) { - SmallVector toErase; - for (Operation &op : *nested.getBody()) { - auto fn = dyn_cast(&op); - if (fn && isEntryPoint(fn)) { - if (!fn.getBody().empty()) { - fn.getBody().getBlocks().clear(); + MLIRContext *ctx = host.getContext(); + + // A statically-linked object must be built for the host architecture. A different triple can + // only be reached via remote dispatch. + if (auto targetAttr = nested->getAttrOfType("catalyst.target")) { + if (auto triple = targetAttr.getAs("triple")) { + if (!triple.getValue().empty() && + triple.getValue() != llvm::sys::getDefaultTargetTriple()) { + nested.emitError("local (non-remote) target must use the host triple '") + << llvm::sys::getDefaultTargetTriple() + << "'; use remote() to dispatch a cross-compiled target"; + return failure(); } - fn.setVisibility(SymbolTable::Visibility::Private); - fn->removeAttr("llvm.emit_c_interface"); - continue; } - toErase.push_back(&op); } - for (Operation *op : toErase) { - op->erase(); + + StringRef moduleName = nested.getSymName().value_or(""); + SmallVector launches; + host.walk([&](catalyst::LaunchKernelOp launchKernel) { + if (launchKernel.getCalleeModuleName().getValue() == moduleName) { + launches.push_back(launchKernel); + } + }); + for (catalyst::LaunchKernelOp launchKernel : launches) { + StringRef entry = launchKernel.getCalleeName().getValue(); + // One external declaration per entry, resolved against the linked object's native symbol. + auto decl = host.lookupSymbol(entry); + if (!decl) { + OpBuilder rootBuilder(host.getBody(), host.getBody()->begin()); + auto fnTy = FunctionType::get(ctx, launchKernel.getOperandTypes(), + launchKernel.getResultTypes()); + decl = func::FuncOp::create(rootBuilder, launchKernel.getLoc(), entry, fnTy); + decl.setPrivate(); + } + OpBuilder callBuilder(launchKernel); + auto call = func::CallOp::create(callBuilder, launchKernel.getLoc(), decl, + launchKernel.getOperands()); + launchKernel.replaceAllUsesWith(call.getResults()); + launchKernel.erase(); + } + + if (auto objPath = nested->getAttrOfType("catalyst.object_file")) { + objectFiles.push_back(objPath); } + nested.erase(); + return success(); } struct CrossCompileTargetsPass @@ -163,15 +192,26 @@ struct CrossCompileTargetsPass llvm::InitializeAllAsmParsers(); llvm::InitializeAllAsmPrinters(); - // Compile each target module, record its object path, and reduce it to - // entry declarations. + // Compile each target module to an object, then dispose of it per delivery mode: + // - remote (catalyst.dispatch): leave the module intact for dispatch-remote-targets. + // - local: statically link — flatten its host calls and record the object for the linker. + SmallVector localObjectFiles; for (auto nested : targetMods) { FailureOr objPath = compileTargetModule(nested); if (failed(objPath)) { return signalPassFailure(); } nested->setAttr("catalyst.object_file", StringAttr::get(&getContext(), *objPath)); - reduceToEntryDeclarations(nested); + if (!nested->hasAttr("catalyst.dispatch")) { + if (failed(lowerLocalTargetCalls(host, nested, localObjectFiles))) { + return signalPassFailure(); + } + } + } + + // Record the objects to statically link, for the driver to hand to the linker. + if (!localObjectFiles.empty()) { + host->setAttr("catalyst.object_files", ArrayAttr::get(&getContext(), localObjectFiles)); } } @@ -304,9 +344,9 @@ struct CrossCompileTargetsPass } llvm::DataLayout dataLayout = targetMachine->createDataLayout(); - // Clone the target module into an unparented root module: leaves `nested` - // intact for reduceToEntryDeclarations and gives the sub-pipeline / - // translateModuleToLLVMIR a top-level module to operate on. + // Clone the target module into an unparented root module: leaves `nested` intact in the host + // (its host launch_kernel is consumed later by local flattening or dispatch) and gives the + // sub-pipeline / translateModuleToLLVMIR a top-level module to operate on. OpBuilder builder(ctx); mlir::OwningOpRef standalone(cast(nested->clone())); @@ -318,11 +358,25 @@ struct CrossCompileTargetsPass moduleOp->setAttr(DLTIDialect::kDataLayoutAttrName, mlir::translateDataLayout(dataLayout, ctx)); - // Expose only the entry-point functions through the C ABI. + // The entry points are exactly the functions the host calls into this module, named by the + // surviving launch_kernel call edges. Expose those through the C ABI; privatize the rest so + // they can be internalized / DCE'd and don't leak as exported symbols. + StringRef moduleName = nested.getSymName().value_or(""); + llvm::SmallSet entries; + if (auto host = nested->getParentOfType()) { + host.walk([&](catalyst::LaunchKernelOp launchKernel) { + if (launchKernel.getCalleeModuleName().getValue() == moduleName) { + entries.insert(launchKernel.getCalleeName().getValue()); + } + }); + } for (auto fn : standalone->getOps()) { - if (isEntryPoint(fn)) { + if (entries.contains(fn.getName())) { exposeEntryViaCInterface(fn); } + else { + fn.setPrivate(); + } } if (dumpIntermediate) { diff --git a/mlir/lib/Catalyst/Transforms/MarkEntryPoints.cpp b/mlir/lib/Catalyst/Transforms/MarkEntryPoints.cpp deleted file mode 100644 index 7f8ee1a785..0000000000 --- a/mlir/lib/Catalyst/Transforms/MarkEntryPoints.cpp +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright 2026 Xanadu Quantum Technologies Inc. - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at - -// http://www.apache.org/licenses/LICENSE-2.0 - -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#include "llvm/ADT/SmallSet.h" -#include "mlir/Dialect/Func/IR/FuncOps.h" -#include "mlir/IR/BuiltinOps.h" -#include "mlir/IR/SymbolTable.h" -#include "mlir/Pass/Pass.h" - -using namespace mlir; - -namespace catalyst { - -#define GEN_PASS_DEF_MARKENTRYPOINTSPASS -#include "Catalyst/Transforms/Passes.h.inc" - -namespace { - -struct MarkEntryPointsPass : impl::MarkEntryPointsPassBase { - using MarkEntryPointsPassBase::MarkEntryPointsPassBase; - - void runOnOperation() final - { - ModuleOp root = getOperation(); - UnitAttr entryAttr = UnitAttr::get(&getContext()); - - // Collect leaf names referenced from outside any nested module, then - // stamp matching functions inside each nested module. The host's own - // entry is annotated by the frontend, not this pass. - llvm::SmallSet exposed; - for (Operation &topOp : root.getBody()->getOperations()) { - if (isa(&topOp)) { - continue; - } - if (auto uses = SymbolTable::getSymbolUses(&topOp)) { - for (SymbolTable::SymbolUse use : *uses) { - exposed.insert(use.getSymbolRef().getLeafReference().getValue()); - } - } - } - for (Operation &topOp : root.getBody()->getOperations()) { - auto mod = dyn_cast(&topOp); - if (!mod) { - continue; - } - for (auto func : mod.getOps()) { - if (exposed.contains(func.getName())) { - func->setAttr("catalyst.entry_point", entryAttr); - } - } - } - } -}; - -} // namespace - -} // namespace catalyst diff --git a/mlir/lib/Driver/CompilerDriver.cpp b/mlir/lib/Driver/CompilerDriver.cpp index ab5fd8c291..d2fb5a2562 100644 --- a/mlir/lib/Driver/CompilerDriver.cpp +++ b/mlir/lib/Driver/CompilerDriver.cpp @@ -500,6 +500,22 @@ llvm::LogicalResult catalyst::driver::runPipeline(PassManager &pm, const Compile return failure(); } catalyst::utils::LinesCount::call(moduleOp); + + // cross-compile-targets records the objects of local (statically-linked) targets on the + // root module. Write them to a manifest the frontend hands to the linker. + if (auto objFiles = moduleOp->getAttrOfType("catalyst.object_files")) { + std::string manifestPath = + options.workspace.str() + "/" + options.moduleName.str() + ".objects"; + std::error_code ec; + llvm::raw_fd_ostream manifest(manifestPath, ec); + if (!ec) { + for (mlir::Attribute pathAttr : objFiles) { + if (auto s = mlir::dyn_cast(pathAttr)) { + manifest << s.getValue() << "\n"; + } + } + } + } } } return success(); diff --git a/mlir/test/Catalyst/CrossCompileTargetModules.mlir b/mlir/test/Catalyst/CrossCompileTargetModules.mlir index 22f0f35d7b..91193d202c 100644 --- a/mlir/test/Catalyst/CrossCompileTargetModules.mlir +++ b/mlir/test/Catalyst/CrossCompileTargetModules.mlir @@ -15,35 +15,44 @@ // RUN: mkdir -p %t // RUN: quantum-opt %s --cross-compile-targets="workspace=%t" | FileCheck %s -// The catalyst.target module is compiled to an object file recorded as -// catalyst.object_file, and reduced to declarations of its entry functions. -// CHECK: module @target_compute -// CHECK-SAME: catalyst.object_file = "{{.*}}.o" -// CHECK: func.func private @noop() attributes {catalyst.entry_point} +// A local (non-dispatch) catalyst.target module is compiled to an object and statically linked: the +// object path is recorded on the root as catalyst.object_files, the host launch_kernel is flattened +// to a func.call against an external declaration of the entry, and the module is erased. +// CHECK: module @jit_test_cross_compile_target +// CHECK-SAME: catalyst.object_files = ["{{.*}}.o"] +// CHECK: func.func private @noop() +// CHECK: func.func public @jit_main +// CHECK: call @noop() +// CHECK-NOT: catalyst.launch_kernel +// CHECK-NOT: module @target_compute // CHECK-NOT: noop_helper -// dump-intermediate writes the extracted MLIR and translated LLVM IR to the workspace. +// dump-intermediate writes the extracted MLIR and translated LLVM IR to the workspace. The entry +// points are derived from the launch_kernel call edges (no separate visibility pass): @noop is the +// callee, so it is exposed through the C ABI (public + llvm.emit_c_interface); @noop_helper is not +// referenced from the host, so it is privatized. // RUN: rm -rf %t/target_compute // RUN: quantum-opt %s --cross-compile-targets="workspace=%t dump-intermediate=true" // RUN: cat %t/target_compute/extracted.mlir | FileCheck %s --check-prefix=EXTRACTED // RUN: cat %t/target_compute/target_compute.ll | FileCheck %s --check-prefix=LL -// EXTRACTED: func.func {{.*}}@noop +// EXTRACTED: func.func @noop() attributes {llvm.emit_c_interface} +// EXTRACTED: func.func private @noop_helper() // LL: define {{.*}}@noop module @jit_test_cross_compile_target { - func.func private @noop() attributes {catalyst.target = {backend = "my-backend"}} - func.func public @jit_main() attributes {llvm.emit_c_interface} { - func.call @noop() : () -> () + catalyst.launch_kernel @target_compute::@noop() : () -> () return } - module @target_compute attributes {catalyst.target = {backend = "my-backend"}} { - func.func public @noop() attributes {catalyst.entry_point} { + // Both functions start public; cross-compile-targets derives the entry set from the host's + // launch_kernel and privatizes the unreferenced helper. + module @target_compute attributes {catalyst.target = {}} { + func.func public @noop() { return } - func.func private @noop_helper() { + func.func public @noop_helper() { return } } diff --git a/mlir/test/Catalyst/MarkEntryPoints.mlir b/mlir/test/Catalyst/MarkEntryPoints.mlir deleted file mode 100644 index 33e8713887..0000000000 --- a/mlir/test/Catalyst/MarkEntryPoints.mlir +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright 2026 Xanadu Quantum Technologies Inc. - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at - -// http://www.apache.org/licenses/LICENSE-2.0 - -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// RUN: quantum-opt %s --mark-entry-points --split-input-file | FileCheck %s - -// Externally-called function in a nested target module gets marked as entry-points in -// the contataining module - -// CHECK-LABEL: module @case_simple -module @case_simple { - func.func public @host() { - func.call @entry() : () -> () - return - } - // CHECK: func.func private @entry() - // CHECK-NOT: catalyst.entry_point - func.func private @entry() - - module @target { - // CHECK: func.func public @entry() - // CHECK-SAME: catalyst.entry_point - func.func public @entry() { - return - } - // CHECK: func.func private @helper() - // CHECK-NOT: catalyst.entry_point - func.func private @helper() { - return - } - } -} - -// ----- - -// Target module with no externally-referenced functions - -// CHECK-LABEL: module @case_orphan -module @case_orphan { - func.func public @host() { return } - - module @target { - // CHECK-NOT: catalyst.entry_point - func.func public @unused() { return } - } -} - -// ----- - -// Intra-module references don't count as "external". - -// CHECK-LABEL: module @case_intra_module -module @case_intra_module { - func.func public @host() { - func.call @entry() : () -> () - return - } - // CHECK: func.func private @entry() - // CHECK-NOT: catalyst.entry_point - func.func private @entry() - - module @qnode { - // CHECK: func.func public @entry() - // CHECK-SAME: catalyst.entry_point - func.func public @entry() { - func.call @callee() : () -> () - return - } - // CHECK: func.func private @callee() - // CHECK-NOT: catalyst.entry_point - func.func private @callee() { return } - } -} - From 864adb8078d544060c1a87aac33e18f374246ee2 Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 26 Jun 2026 12:35:43 -0400 Subject: [PATCH 73/75] Consolidate target() deploy API and drop entry_point frontend stamp --- frontend/catalyst/api_extensions/__init__.py | 3 +- frontend/catalyst/api_extensions/target.py | 49 ++++++------------- frontend/catalyst/kernel.py | 6 +-- frontend/catalyst/utils/gen_mlir.py | 3 +- frontend/test/lit/test_function_attributes.py | 2 +- frontend/test/lit/test_target.py | 13 +++-- 6 files changed, 26 insertions(+), 50 deletions(-) diff --git a/frontend/catalyst/api_extensions/__init__.py b/frontend/catalyst/api_extensions/__init__.py index 156735b3da..5791fc95d1 100644 --- a/frontend/catalyst/api_extensions/__init__.py +++ b/frontend/catalyst/api_extensions/__init__.py @@ -40,7 +40,7 @@ measure, pauli_measure, ) -from catalyst.api_extensions.target import remote, target +from catalyst.api_extensions.target import target __all__ = ( "accelerate", @@ -62,5 +62,4 @@ "adjoint", "ctrl", "target", - "remote", ) diff --git a/frontend/catalyst/api_extensions/target.py b/frontend/catalyst/api_extensions/target.py index 8fc9fccd1a..d6109555c1 100644 --- a/frontend/catalyst/api_extensions/target.py +++ b/frontend/catalyst/api_extensions/target.py @@ -18,8 +18,6 @@ from dataclasses import dataclass from typing import Optional -import pennylane as qp - _TARGET_ATTR = "_catalyst_target" _DISPATCH_ATTR = "_catalyst_dispatch" @@ -29,19 +27,17 @@ class Target: """Cross-compilation target spec attached to a device by :func:`target`. Args: - backend: Optional backend name, recorded as metadata on the target module. - pipeline: Optional name of a lowering pipeline registered with compiler. + pipeline: Optional name of a lowering pipeline registered with the compiler. triple: Optional LLVM target triple. Defaults to the host triple. """ - backend: Optional[str] = None pipeline: Optional[str] = None triple: Optional[str] = None @dataclass(frozen=True) class RemoteDispatch: - """Remote dispatch spec attached to a device by :func:`remote`. + """Remote dispatch spec attached to a device by :func:`target` with an ``address``. Args: address: Executor address, e.g. ``"127.0.0.1:1373"``. @@ -53,55 +49,38 @@ class RemoteDispatch: def target( device, *, - backend: Optional[str] = None, pipeline: Optional[str] = None, triple: Optional[str] = None, + address: Optional[str] = None, ): """Tag a PennyLane device as a separate cross-compilation target and return it. Any QNode wrapping the returned device is kept as a separate compilation unit which carries - ``catalyst.target = {backend, pipeline, triple}`` and is cross-compiled to a standalone - object file rather than being inlined into the host module. + ``catalyst.target = {pipeline, triple}`` and is cross-compiled to a standalone object rather + than being inlined into the host module. With ``address`` set, the object is additionally + dispatched to a remote executor (``catalyst.dispatch = {address}``); without it, the object is + statically linked and runs in-process. Args: device: A PennyLane device. - backend: Optional backend name, recorded as metadata on the target module. pipeline: Optional lowering-pipeline name registered with the compiler. triple: Optional LLVM target triple. Defaults to the host triple. + address: Optional executor address; when set, the target is dispatched remotely. Returns: - The same device, now tagged with target metadata. - """ - setattr(device, _TARGET_ATTR, Target(backend=backend, pipeline=pipeline, triple=triple)) - return device - - -def remote(device, *, address: str): - """Mark a :func:`target` device for remote dispatch and return it. - - Stamps ``catalyst.dispatch = {address}`` so the ``dispatch-remote-targets`` pass ships the - compiled object to ``address`` and rewrites host-side calls to it into remote calls. Wrap the - device with :func:`target` first to set cross-compilation options; a default target is applied - if it has none. - - Args: - device: A PennyLane device. - address: Executor address the object is shipped to and called on, e.g. ``"127.0.0.1:1373"``. - - Returns: - The same device, now also tagged for remote dispatch. + The same device, now tagged with target (and, if ``address`` is given, dispatch) metadata. """ - if get_target(device) is None: - target(device) - setattr(device, _DISPATCH_ATTR, RemoteDispatch(address=address)) + setattr(device, _TARGET_ATTR, Target(pipeline=pipeline, triple=triple)) + if address is not None: + setattr(device, _DISPATCH_ATTR, RemoteDispatch(address=address)) return device def get_target(device) -> Optional[Target]: - """Return the :class:`Target` previously attached via :func:`target`/:func:`remote`, or ``None``.""" + """Return the :class:`Target` attached via :func:`target`, or ``None``.""" return getattr(device, _TARGET_ATTR, None) def get_dispatch(device) -> Optional[RemoteDispatch]: - """Return the :class:`RemoteDispatch` attached via :func:`remote`, or ``None``.""" + """Return the :class:`RemoteDispatch` attached via :func:`target` (``address=...``), or ``None``.""" return getattr(device, _DISPATCH_ATTR, None) diff --git a/frontend/catalyst/kernel.py b/frontend/catalyst/kernel.py index 71e9760f75..89632c2deb 100644 --- a/frontend/catalyst/kernel.py +++ b/frontend/catalyst/kernel.py @@ -91,7 +91,7 @@ def declare(name: str, artifact: Optional[str] = None, outputs=None, *, outputs: :class:`jax.ShapeDtypeStruct` or tuple of them describing each output tensor. JAX needs these at trace time to infer what the call returns. remote: Mark the symbol as executor-side. Pass the **remote device** - (``remote(target(...), address=...)``) to bind this call explicitly to that + (``target(..., address=...)``) to bind this call explicitly to that executor's address. ``True`` inheriting the program's single remote executor. Returns: @@ -107,8 +107,8 @@ def declare(name: str, artifact: Optional[str] = None, outputs=None, *, dispatch = get_dispatch(remote) if dispatch is None: raise ValueError( - "kernel.declare(remote=): the device has no remote dispatch; wrap it " - "with remote(target(...), address=...) first, or pass remote=True to inherit " + "kernel.declare(remote=): the device has no remote dispatch; create it " + "with target(..., address=...) first, or pass remote=True to inherit " "the program's single executor." ) address = dispatch.address diff --git a/frontend/catalyst/utils/gen_mlir.py b/frontend/catalyst/utils/gen_mlir.py index 049a79e3a3..8888e2d59f 100644 --- a/frontend/catalyst/utils/gen_mlir.py +++ b/frontend/catalyst/utils/gen_mlir.py @@ -62,9 +62,8 @@ def inject_functions(module, ctx, seed): """ This function appends functions to the input module. """ - # Add C interface for the quantum function and annonate it as the main entry_point. + # Add the C interface for the quantum function so it is callable across the C ABI. mlir_qfunc = module.body.operations[0] - mlir_qfunc.attributes["catalyst.entry_point"] = ir.UnitAttr.get(context=ctx) mlir_qfunc.attributes["llvm.emit_c_interface"] = ir.UnitAttr.get(context=ctx) setup_module = gen_setup(ctx, seed) diff --git a/frontend/test/lit/test_function_attributes.py b/frontend/test/lit/test_function_attributes.py index d516db2d55..27d6e05784 100644 --- a/frontend/test/lit/test_function_attributes.py +++ b/frontend/test/lit/test_function_attributes.py @@ -31,7 +31,7 @@ def qnode(x): @qjit(target="mlir") # The entry point has no internal linkage. -# CHECK-DAG: func.func public @jit_workload(%arg0: tensor) -> tensor<4xcomplex> attributes {catalyst.entry_point, llvm.emit_c_interface} { +# CHECK-DAG: func.func public @jit_workload(%arg0: tensor) -> tensor<4xcomplex> attributes {llvm.emit_c_interface} { def workload(x: float): y = x * qp.numpy.pi # pylint: disable=no-member return qnode(y) diff --git a/frontend/test/lit/test_target.py b/frontend/test/lit/test_target.py index 66156a8f1f..e13ecaffe7 100644 --- a/frontend/test/lit/test_target.py +++ b/frontend/test/lit/test_target.py @@ -26,7 +26,6 @@ def test_full_target(): dev = target( qp.device("null.qubit", wires=1), - backend="my-backend", pipeline="my-pipeline", triple="my-triple", ) @@ -37,25 +36,25 @@ def circuit(): qp.Hadamard(0) return qp.state() - # CHECK: module @module_circuit attributes {catalyst.target = {backend = "my-backend", pipeline = "my-pipeline", triple = "my-triple"}} + # CHECK: module @module_circuit attributes {catalyst.target = {pipeline = "my-pipeline", triple = "my-triple"}} print(circuit.mlir) test_full_target() -def test_backend_only(): - """Only backend is set; other fields must be absent from the dict.""" +def test_single_field(): + """Only one field is set; the others must be absent from the dict.""" - dev = target(qp.device("null.qubit", wires=1), backend="my-backend") + dev = target(qp.device("null.qubit", wires=1), triple="my-triple") @qjit(target="mlir") @qp.qnode(dev) def circuit_min(): return qp.state() - # CHECK: module @module_circuit_min attributes {catalyst.target = {backend = "my-backend"}} + # CHECK: module @module_circuit_min attributes {catalyst.target = {triple = "my-triple"}} print(circuit_min.mlir) -test_backend_only() +test_single_field() From 0eff2b94d4c7b8824cc0d4f2881ea1f88d3de3ab Mon Sep 17 00:00:00 2001 From: Mehrdad Malekmohammadi Date: Fri, 26 Jun 2026 12:40:48 -0400 Subject: [PATCH 74/75] Dispatch launch_kernel to remote.launch; per-object executor isolation; connect timeout --- mlir/include/Remote/IR/RemoteOps.td | 5 +- .../Transforms/CrossCompileRemoteKernels.cpp | 5 +- .../Transforms/DispatchRemoteTargets.cpp | 74 ++++++-------- mlir/lib/Remote/Transforms/RemoteToLLVM.cpp | 15 ++- mlir/test/Remote/ConvertRemoteToLLVM.mlir | 2 +- mlir/test/Remote/DispatchRemoteLibCalls.mlir | 21 ++-- .../Remote/DispatchRemoteTargetModules.mlir | 30 +++--- mlir/test/Remote/RemoteOps.mlir | 4 +- runtime/include/RemoteCAPI.h | 10 +- runtime/lib/remote/RemoteRuntime.cpp | 18 ++-- runtime/lib/remote/RemoteSession.cpp | 99 +++++++++++++++++-- runtime/lib/remote/RemoteSession.hpp | 6 +- 12 files changed, 189 insertions(+), 100 deletions(-) diff --git a/mlir/include/Remote/IR/RemoteOps.td b/mlir/include/Remote/IR/RemoteOps.td index 05b28290cb..d3cdf66fb8 100644 --- a/mlir/include/Remote/IR/RemoteOps.td +++ b/mlir/include/Remote/IR/RemoteOps.td @@ -75,13 +75,14 @@ def Remote_LaunchOp : Remote_Op<"launch"> { let arguments = (ins Variadic:$inputs, StrAttr:$address, - StrAttr:$kernel_callee + StrAttr:$kernel_callee, + StrAttr:$object ); let results = (outs Variadic:$results); let assemblyFormat = [{ - `(` $kernel_callee `,` $address `)` `(` $inputs `)` attr-dict + `(` $kernel_callee `,` $address `,` $object `)` `(` $inputs `)` attr-dict `:` functional-type($inputs, $results) }]; } diff --git a/mlir/lib/Remote/Transforms/CrossCompileRemoteKernels.cpp b/mlir/lib/Remote/Transforms/CrossCompileRemoteKernels.cpp index cd2c8574c2..3161cb31ba 100644 --- a/mlir/lib/Remote/Transforms/CrossCompileRemoteKernels.cpp +++ b/mlir/lib/Remote/Transforms/CrossCompileRemoteKernels.cpp @@ -295,8 +295,9 @@ struct CrossCompileRemoteKernelsPass for (func::CallOp call : callsToReplace) { OpBuilder builder(call); - auto launch = remote::LaunchOp::create(builder, call.getLoc(), call.getResultTypes(), - call.getOperands(), addressAttr, calleeAttr); + auto launch = + remote::LaunchOp::create(builder, call.getLoc(), call.getResultTypes(), + call.getOperands(), addressAttr, calleeAttr, pathAttr); call.replaceAllUsesWith(launch.getResults()); call.erase(); } diff --git a/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp b/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp index cf484f1a93..817b2affd5 100644 --- a/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp +++ b/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp @@ -32,12 +32,6 @@ namespace remote { namespace { -// Functions marked as the object's externally-callable entry points. -bool isEntryPoint(func::FuncOp fn) -{ - return fn->hasAttr("catalyst.entry_point"); -} - // Ships cross-compiled `catalyst.target` modules to a remote executor using the `remote` dialect. // // This pass should be run after `cross-compile-targets`, which records each module's object file in @@ -231,48 +225,36 @@ struct DispatchRemoteTargetsPass // entry function, and remote.launch resolves individual symbols within it. injectRemoteSendBinaryIntoSetup(host, addressAttr, pathAttr); - // Per entry-marked function: rewrite host-side calls to remote.launch. - for (auto nestedFn : nested.getOps()) { - if (!isEntryPoint(nestedFn)) { - continue; - } - StringRef fnName = nestedFn.getName(); - auto decl = host.lookupSymbol(fnName); - if (!decl || !decl.isExternal()) { - continue; - } - - auto calleeAttr = StringAttr::get(ctx, fnName); - - SmallVector calls; - if (auto uses = SymbolTable::getSymbolUses(decl.getNameAttr(), host)) { - for (const SymbolTable::SymbolUse &use : *uses) { - if (auto call = dyn_cast(use.getUser())) { - calls.push_back(call); - } - } + // Rewrite each host-side launch_kernel targeting this module into a remote.launch. + StringRef moduleName = nested.getSymName().value_or(""); + SmallVector launches; + host.walk([&](catalyst::LaunchKernelOp launchKernel) { + if (launchKernel.getCalleeModuleName().getValue() == moduleName) { + launches.push_back(launchKernel); } - for (func::CallOp call : calls) { - // remote.launch marshals memref descriptors, so its lowering only accepts - // memref-typed operands and results. Reject anything else here with a clear - // error rather than crashing later in convert-remote-to-llvm. This runs after - // bufferization, so a well-formed entry call is already memref-typed. - auto isMemref = [](Type ty) { return isa(ty); }; - if (!llvm::all_of(call.getOperandTypes(), isMemref) || - !llvm::all_of(call.getResultTypes(), isMemref)) { - call.emitOpError("remote dispatch of '") - << fnName << "' requires memref-typed operands and results"; - return failure(); - } - OpBuilder b(call); - auto launch = - remote::LaunchOp::create(b, call.getLoc(), call.getResultTypes(), - call.getOperands(), addressAttr, calleeAttr); - call.replaceAllUsesWith(launch.getResults()); - call.erase(); + }); + for (catalyst::LaunchKernelOp launchKernel : launches) { + // remote.launch marshals memref descriptors, so its lowering only accepts memref-typed + // operands and results. Reject anything else here with a clear error rather than + // crashing later in convert-remote-to-llvm. This runs after bufferization, so a + // well-formed entry call is already memref-typed. + auto isMemref = [](Type ty) { return isa(ty); }; + if (!llvm::all_of(launchKernel.getOperandTypes(), isMemref) || + !llvm::all_of(launchKernel.getResultTypes(), isMemref)) { + launchKernel.emitOpError("remote dispatch of '") + << launchKernel.getCalleeName().getValue() + << "' requires memref-typed operands and results"; + return failure(); } - - decl.erase(); + auto calleeAttr = StringAttr::get(ctx, launchKernel.getCalleeName().getValue()); + OpBuilder b(launchKernel); + // `pathAttr` (the object-file path, same value shipped by send_binary) keys the + // per-kernel JITDylib on the executor so the entry resolves in its own object. + auto launch = remote::LaunchOp::create( + b, launchKernel.getLoc(), launchKernel.getResultTypes(), + launchKernel.getOperands(), addressAttr, calleeAttr, pathAttr); + launchKernel.replaceAllUsesWith(launch.getResults()); + launchKernel.erase(); } return success(); } diff --git a/mlir/lib/Remote/Transforms/RemoteToLLVM.cpp b/mlir/lib/Remote/Transforms/RemoteToLLVM.cpp index b208d8d1e9..89d2ec5029 100644 --- a/mlir/lib/Remote/Transforms/RemoteToLLVM.cpp +++ b/mlir/lib/Remote/Transforms/RemoteToLLVM.cpp @@ -244,8 +244,12 @@ struct LaunchOpLowering : public OpConversionPattern { // - num_outputs: the number of output arguments // - output_descs: the output descriptor array // - output_ranks: the output rank array + // Signature: (addr, symbol, object, num_in, in_descs, in_ranks, in_sizes, + // num_out, out_descs, out_ranks, out_sizes). `object` keys the per-kernel + // JITDylib on the executor so same-named entries in different objects don't + // collide in one symbol namespace. Type launchSig = LLVM::LLVMFunctionType::get( - voidTy, {ptrTy, ptrTy, i64Ty, ptrTy, ptrTy, ptrTy, i64Ty, ptrTy, ptrTy, ptrTy}); + voidTy, {ptrTy, ptrTy, ptrTy, i64Ty, ptrTy, ptrTy, ptrTy, i64Ty, ptrTy, ptrTy, ptrTy}); LLVM::LLVMFuncOp launchFn = catalyst::ensureFunctionDeclaration( rewriter, op, "__catalyst__remote__launch", launchSig); @@ -257,6 +261,9 @@ struct LaunchOpLowering : public OpConversionPattern { Value symbolPtr = getGlobalString(loc, rewriter, "remote_sym_" + callee, symbolName + '\0', mod); + Value objectPtr = getGlobalString(loc, rewriter, "remote_obj_" + callee, + op.getObject().str() + '\0', mod); + SmallVector inputDescPtrs; SmallVector inputRanks, inputElemSizes; for (auto [origInput, llvmInput] : llvm::zip(op.getInputs(), adaptor.getInputs())) { @@ -297,9 +304,9 @@ struct LaunchOpLowering : public OpConversionPattern { rewriter, loc, rewriter.getI64IntegerAttr(outputDescPtrs.size())); LLVM::CallOp::create(rewriter, loc, launchFn, - ValueRange{addrPtr, symbolPtr, numInputs, inputDescsArr, inputRanksArr, - inputSizesArr, numOutputs, outputDescsArr, outputRanksArr, - outputSizesArr}); + ValueRange{addrPtr, symbolPtr, objectPtr, numInputs, inputDescsArr, + inputRanksArr, inputSizesArr, numOutputs, outputDescsArr, + outputRanksArr, outputSizesArr}); SmallVector results; for (auto [descPtr, resultTy] : llvm::zip(outputDescPtrs, op.getResultTypes())) { diff --git a/mlir/test/Remote/ConvertRemoteToLLVM.mlir b/mlir/test/Remote/ConvertRemoteToLLVM.mlir index 9643e5495a..5a3f2dbfd2 100644 --- a/mlir/test/Remote/ConvertRemoteToLLVM.mlir +++ b/mlir/test/Remote/ConvertRemoteToLLVM.mlir @@ -38,7 +38,7 @@ func.func @send_binary() { // CHECK-LABEL: func.func @launch func.func @launch(%arg0: memref) -> memref { // CHECK: llvm.call @__catalyst__remote__launch - %0 = remote.launch("qnode_0", "127.0.0.1:9000") (%arg0) : (memref) -> memref + %0 = remote.launch("qnode_0", "127.0.0.1:9000", "/tmp/qnode_0.o") (%arg0) : (memref) -> memref return %0 : memref } diff --git a/mlir/test/Remote/DispatchRemoteLibCalls.mlir b/mlir/test/Remote/DispatchRemoteLibCalls.mlir index bd062b5206..e6d645193a 100644 --- a/mlir/test/Remote/DispatchRemoteLibCalls.mlir +++ b/mlir/test/Remote/DispatchRemoteLibCalls.mlir @@ -55,14 +55,15 @@ module @jit_inherit { func.func @teardown() { return } - func.func private @compute() func.func @main() { - func.call @compute() : () -> () + catalyst.launch_kernel @target::@compute() : () -> () catalyst.custom_call fn("fpga_trampoline_a_teardown") () {backend_config = {dispatch = ""}} : () -> () return } module @target attributes {catalyst.object_file = "/tmp/target.o", catalyst.dispatch = {address = "ADDR:PORT"}} { - func.func private @compute() attributes {catalyst.entry_point} + func.func public @compute() { + return + } } } @@ -74,19 +75,21 @@ module @jit_ambiguous { func.func @setup() { return } - func.func private @c1() - func.func private @c2() func.func @main() { - func.call @c1() : () -> () - func.call @c2() : () -> () + catalyst.launch_kernel @t1::@c1() : () -> () + catalyst.launch_kernel @t2::@c2() : () -> () // expected-error @below {{ambiguous remote executor}} catalyst.custom_call fn("foo") () {backend_config = {dispatch = ""}} : () -> () return } module @t1 attributes {catalyst.object_file = "/tmp/t1.o", catalyst.dispatch = {address = "host:1"}} { - func.func private @c1() attributes {catalyst.entry_point} + func.func public @c1() { + return + } } module @t2 attributes {catalyst.object_file = "/tmp/t2.o", catalyst.dispatch = {address = "host:2"}} { - func.func private @c2() attributes {catalyst.entry_point} + func.func public @c2() { + return + } } } diff --git a/mlir/test/Remote/DispatchRemoteTargetModules.mlir b/mlir/test/Remote/DispatchRemoteTargetModules.mlir index 05da1871c8..1b6b6f882a 100644 --- a/mlir/test/Remote/DispatchRemoteTargetModules.mlir +++ b/mlir/test/Remote/DispatchRemoteTargetModules.mlir @@ -29,15 +29,14 @@ // CHECK-NOT: remote. // CHECK: return -// Each host-side call is rewritten to its own remote.launch, preserving the call's +// Each host-side launch_kernel is rewritten to its own remote.launch, preserving the call's // operand/result types (a typed call exercises the memref-result path). // CHECK-LABEL: func.func public @jit_main -// CHECK: remote.launch("noop", "ADDR:PORT") () : () -> () -// CHECK: remote.launch("compute", "ADDR:PORT") (%{{.*}}) : (memref<4xf64>) -> memref<4xf64> +// CHECK: remote.launch("noop", "ADDR:PORT", "/tmp/target_compute.o") () : () -> () +// CHECK: remote.launch("compute", "ADDR:PORT", "/tmp/target_compute.o") (%{{.*}}) : (memref<4xf64>) -> memref<4xf64> -// The bodyless declarations and the nested module are erased. -// CHECK-NOT: func.call @noop -// CHECK-NOT: func.call @compute +// The launch_kernels and the nested module are gone. +// CHECK-NOT: catalyst.launch_kernel // CHECK-NOT: module @target_compute module @jit_test_dispatch { @@ -50,17 +49,22 @@ module @jit_test_dispatch { return } - func.func private @noop() - func.func private @compute(memref<4xf64>) -> memref<4xf64> - + // inline-nested-module keeps dispatch-module calls as launch_kernel (no flattening, no host-side + // declarations), so dispatch matches the launch_kernel callee module directly. func.func public @jit_main(%arg0: memref<4xf64>) -> memref<4xf64> attributes {llvm.emit_c_interface} { - func.call @noop() : () -> () - %0 = func.call @compute(%arg0) : (memref<4xf64>) -> memref<4xf64> + catalyst.launch_kernel @target_compute::@noop() : () -> () + %0 = catalyst.launch_kernel @target_compute::@compute(%arg0) : (memref<4xf64>) -> memref<4xf64> return %0 : memref<4xf64> } + // cross-compile-targets leaves remote-dispatch modules intact (public, with bodies); dispatch + // erases the whole module after rewriting the host launch_kernels. module @target_compute attributes {catalyst.object_file = "/tmp/target_compute.o", catalyst.dispatch = {address = "ADDR:PORT"}} { - func.func private @noop() attributes {catalyst.entry_point} - func.func private @compute(memref<4xf64>) -> memref<4xf64> attributes {catalyst.entry_point} + func.func public @noop() { + return + } + func.func public @compute(%arg0: memref<4xf64>) -> memref<4xf64> { + return %arg0 : memref<4xf64> + } } } diff --git a/mlir/test/Remote/RemoteOps.mlir b/mlir/test/Remote/RemoteOps.mlir index e58a544cb5..141d85e30d 100644 --- a/mlir/test/Remote/RemoteOps.mlir +++ b/mlir/test/Remote/RemoteOps.mlir @@ -33,10 +33,10 @@ func.func @send_binary() { // ----- // CHECK-LABEL: func.func @launch -// CHECK: remote.launch("qnode_0", "127.0.0.1:9000") (%{{.*}}) : +// CHECK: remote.launch("qnode_0", "127.0.0.1:9000", "/tmp/qnode.o") (%{{.*}}) : // CHECK-SAME: (memref) -> memref func.func @launch(%arg0: memref) -> memref { - %0 = remote.launch("qnode_0", "127.0.0.1:9000") (%arg0) : (memref) -> memref + %0 = remote.launch("qnode_0", "127.0.0.1:9000", "/tmp/qnode.o") (%arg0) : (memref) -> memref return %0 : memref } diff --git a/runtime/include/RemoteCAPI.h b/runtime/include/RemoteCAPI.h index 7cd69254f3..562f07fd71 100644 --- a/runtime/include/RemoteCAPI.h +++ b/runtime/include/RemoteCAPI.h @@ -28,11 +28,11 @@ extern "C" { // and close the session. int __catalyst__remote__open(const char *addr); int __catalyst__remote__send_binary(const char *addr, const char *path, uint32_t format); -void __catalyst__remote__launch(const char *addr, const char *entry_symbol, size_t num_inputs, - void *const *input_descs, const size_t *input_ranks, - const size_t *input_elem_sizes, size_t num_outputs, - void *const *output_descs, const size_t *output_ranks, - const size_t *output_elem_sizes); +void __catalyst__remote__launch(const char *addr, const char *entry_symbol, const char *object, + size_t num_inputs, void *const *input_descs, + const size_t *input_ranks, const size_t *input_elem_sizes, + size_t num_outputs, void *const *output_descs, + const size_t *output_ranks, const size_t *output_elem_sizes); int __catalyst__remote__call_wrapper(const char *addr, const char *symbol, const char *args_buf, size_t args_size, void **out_buf, size_t *out_size); void __catalyst__remote__free_result(void *buf); diff --git a/runtime/lib/remote/RemoteRuntime.cpp b/runtime/lib/remote/RemoteRuntime.cpp index 55f662b75e..9ee3954d6e 100644 --- a/runtime/lib/remote/RemoteRuntime.cpp +++ b/runtime/lib/remote/RemoteRuntime.cpp @@ -234,11 +234,11 @@ int __catalyst__remote__close() return 0; } -void __catalyst__remote__launch(const char *addr, const char *entry_symbol, size_t num_inputs, - void *const *input_descs, const size_t *input_ranks, - const size_t *input_elem_sizes, size_t num_outputs, - void *const *output_descs, const size_t *output_ranks, - const size_t *output_elem_sizes) +void __catalyst__remote__launch(const char *addr, const char *entry_symbol, const char *object, + size_t num_inputs, void *const *input_descs, + const size_t *input_ranks, const size_t *input_elem_sizes, + size_t num_outputs, void *const *output_descs, + const size_t *output_ranks, const size_t *output_elem_sizes) { RemoteEntry *entry = find_or_create_entry(addr, /*create_if_missing=*/false); if (!entry) { @@ -247,13 +247,15 @@ void __catalyst__remote__launch(const char *addr, const char *entry_symbol, size std::lock_guard lock(entry->mu); if (remote_verbose()) { - std::fprintf(stderr, "[remote] launch(addr=%s, symbol=%s, n_in=%zu, n_out=%zu)\n", addr, - entry_symbol, num_inputs, num_outputs); + std::fprintf(stderr, "[remote] launch(addr=%s, symbol=%s, object=%s, n_in=%zu, n_out=%zu)\n", + addr, entry_symbol, object ? object : "", num_inputs, num_outputs); } if (!entry->session) { RT_FAIL("Session is closed"); } - uint64_t entry_addr = catalyst::remote::lookup(entry->session, entry_symbol); + // Resolve the entry within the JITDylib of its own object, so same-named entries shipped from + // different kernel objects don't collide in one symbol namespace. + uint64_t entry_addr = catalyst::remote::lookup(entry->session, entry_symbol, object); if (!entry_addr) { RT_FAIL(catalyst::remote::last_error()); } diff --git a/runtime/lib/remote/RemoteSession.cpp b/runtime/lib/remote/RemoteSession.cpp index 206eb681d0..5fec651bd5 100644 --- a/runtime/lib/remote/RemoteSession.cpp +++ b/runtime/lib/remote/RemoteSession.cpp @@ -15,12 +15,18 @@ #include "RemoteSession.hpp" #include +#include +#include +#include #include +#include #include +#include #include #include #include #include +#include #include #include "llvm/ADT/StringRef.h" @@ -160,6 +166,21 @@ Error createTCPSocketError(Twine Details) inconvertibleErrorCode()); } +// Seconds to wait for the catalyst-executor ORC bootstrap handshake before giving up. Overridable +// via CATALYST_REMOTE_CONNECT_TIMEOUT; defaults to 10s. A value of 0 disables the timeout (the +// handshake then blocks indefinitely, matching the upstream llvm-jitlink behaviour). +unsigned remoteConnectTimeoutSeconds() +{ + if (const char *env = std::getenv("CATALYST_REMOTE_CONNECT_TIMEOUT")) { + char *end = nullptr; + unsigned long secs = std::strtoul(env, &end, 10); + if (end != env) { + return static_cast(secs); + } + } + return 10; +} + Expected connectTCPSocket(std::string Host, std::string PortStr) { addrinfo *AI; @@ -242,7 +263,12 @@ struct RemoteSession { MangleAndInterner Mangle; ObjectLinkingLayer ObjectLayer; + // Shared namespace: the process-symbol generator (QIR runtime, libc) plus library-call objects. JITDylib &MainJD; + // One JITDylib per shipped kernel object, keyed by its object-file path. Each links against + // MainJD for its external deps, but keeps its own entry symbol isolated — so two objects can + // export the same `_catalyst_pyface_` without colliding. + std::map KernelJDs; ExecutorAddr alloc_fn{0}; ExecutorAddr free_fn{0}; @@ -286,11 +312,54 @@ struct RemoteSession { auto setup = SimpleRemoteEPC::Setup(); setup.CreateMemoryManager = createSimpleRemoteMemoryManager; + // The ORC bootstrap handshake inside SimpleRemoteEPC::Create is an unbounded blocking read on + // the socket. If the peer is not a live catalyst-executor (e.g. a stale port-forward or a + // dead SSH tunnel accepted the connection), it would hang forever. A watchdog shuts the + // socket down after the timeout, which forces the blocked read to fail and Create to return + // an error instead of hanging. + const int sockFd = *SockFD; + const unsigned timeoutSecs = remoteConnectTimeoutSeconds(); + std::mutex mtx; + std::condition_variable cv; + bool handshakeDone = false; + bool timedOut = false; + std::thread watchdog; + if (timeoutSecs > 0) { + watchdog = std::thread([&] { + std::unique_lock lock(mtx); + if (!cv.wait_for(lock, std::chrono::seconds(timeoutSecs), + [&] { return handshakeDone; })) { + timedOut = true; + ::shutdown(sockFd, SHUT_RDWR); + } + }); + } + // ShutdownFDTransport instead of FDSimpleRemoteEPCTransport so teardown shutdown()s the // socket and doesn't deadlock — see the class comment above. auto EPC = SimpleRemoteEPC::Create( - std::make_unique(std::nullopt), std::move(setup), - *SockFD, *SockFD); + std::make_unique(std::nullopt), std::move(setup), sockFd, + sockFd); + + if (watchdog.joinable()) { + { + std::lock_guard lock(mtx); + handshakeDone = true; + } + cv.notify_all(); + watchdog.join(); + } + + if (timedOut) { + // Consume any error the forced socket shutdown produced before reporting the timeout. + if (!EPC) { + consumeError(EPC.takeError()); + } + return createTCPSocketError("handshake with catalyst-executor timed out after " + + Twine(timeoutSecs) + + "s (is a catalyst-executor actually listening there?). " + "Override with CATALYST_REMOTE_CONNECT_TIMEOUT"); + } if (!EPC) { return EPC.takeError(); } @@ -305,11 +374,26 @@ struct RemoteSession { return std::make_unique(std::move(ES), std::move(*DL)); } - Error addObjectFile(std::unique_ptr Buf) + // Load a kernel object into its own JITDylib (keyed by `path`), linked against MainJD for deps. + Error addObjectFile(StringRef path, std::unique_ptr Buf) { - return ObjectLayer.add(MainJD, std::move(Buf)); + JITDylib &jd = ES->createBareJITDylib(("kernel:" + path).str()); + jd.addToLinkOrder(MainJD); + KernelJDs[path.str()] = &jd; + return ObjectLayer.add(jd, std::move(Buf)); } + // Resolve `Name` in the JITDylib of the object identified by `path` (kernel entry points). Falls + // back to MainJD if the object is unknown. + ExecutorAddr lookupSym(StringRef path, StringRef Name) + { + auto it = KernelJDs.find(path.str()); + JITDylib *jd = (it != KernelJDs.end()) ? it->second : &MainJD; + auto Sym = unwrap(ES->lookup({jd}, Mangle(Name.str())), "lookup(" + Name + ")"); + return Sym.getAddress(); + } + + // Resolve `Name` in the shared process namespace (library-call symbols). ExecutorAddr lookupSym(StringRef Name) { auto Sym = unwrap(ES->lookup({&MainJD}, Mangle(Name.str())), "lookup(" + Name + ")"); @@ -471,7 +555,7 @@ int load_object_path(RemoteSession *s, const char *path) clear_error(); try { auto buf = unwrap(getFile(path), "getFile(" + Twine(path) + ")"); - check(s->addObjectFile(std::move(buf)), "addObjectFile"); + check(s->addObjectFile(path, std::move(buf)), "addObjectFile"); return 0; } catch (const std::exception &e) { @@ -561,10 +645,13 @@ int call_wrapper_raw(RemoteSession *s, const char *sym, const char *args_buf, si * @param name the name of the symbol * @return uint64_t the address of the symbol */ -uint64_t lookup(RemoteSession *s, const char *name) +uint64_t lookup(RemoteSession *s, const char *name, const char *object) { clear_error(); try { + if (object && *object) { + return s->lookupSym(object, name).getValue(); + } return s->lookupSym(name).getValue(); } catch (const std::exception &e) { diff --git a/runtime/lib/remote/RemoteSession.hpp b/runtime/lib/remote/RemoteSession.hpp index c8eb18f698..2afebc487b 100644 --- a/runtime/lib/remote/RemoteSession.hpp +++ b/runtime/lib/remote/RemoteSession.hpp @@ -37,8 +37,10 @@ int load_asset_path(RemoteSession *s, const char *path); int call_wrapper_raw(RemoteSession *s, const char *sym, const char *args_buf, size_t args_size, char **out_buf, size_t *out_size); -// Look up a symbol address on the remote. Returns 0 on error. -uint64_t lookup(RemoteSession *s, const char *name); +// Look up a symbol address on the remote. When `object` is non-null/non-empty, the lookup is +// scoped to that object's JITDylib (kernel entries); otherwise it resolves against the shared +// process namespace (library calls). Returns 0 on error. +uint64_t lookup(RemoteSession *s, const char *name, const char *object = nullptr); // Run a remote function as `main(argc, argv)`. int32_t run_as_main(RemoteSession *s, uint64_t entry_addr, int argc, const char *const *argv); From 6362e9efe792b908cfde5884a2dafd8735fa0301 Mon Sep 17 00:00:00 2001 From: Joseph Lee Date: Mon, 6 Jul 2026 16:54:13 -0400 Subject: [PATCH 75/75] format --- frontend/catalyst/api_extensions/target.py | 3 +-- frontend/catalyst/compiler.py | 8 +++--- frontend/catalyst/jit.py | 2 +- frontend/catalyst/kernel.py | 9 ++++--- frontend/catalyst/utils/runtime_artifacts.py | 4 +-- .../test/pytest/test_cross_compile_targets.py | 3 +-- frontend/test/pytest/test_kernel.py | 2 +- .../BufferizableOpInterfaceImpl.cpp | 27 +++++++++---------- .../Transforms/CrossCompileTargets.cpp | 23 ++++++++-------- mlir/lib/Driver/CompilerDriver.cpp | 7 +++-- .../Transforms/DispatchRemoteTargets.cpp | 11 ++++---- runtime/lib/capi/ExecutionContext.hpp | 2 +- runtime/lib/remote/RemoteRuntime.cpp | 5 ++-- runtime/lib/remote/RemoteSession.cpp | 18 ++++++------- 14 files changed, 61 insertions(+), 63 deletions(-) diff --git a/frontend/catalyst/api_extensions/target.py b/frontend/catalyst/api_extensions/target.py index d6109555c1..4434cdf42d 100644 --- a/frontend/catalyst/api_extensions/target.py +++ b/frontend/catalyst/api_extensions/target.py @@ -12,8 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Tag a PennyLane device as a separate cross-compilation target, optionally remote. -""" +"""Tag a PennyLane device as a separate cross-compilation target, optionally remote.""" from dataclasses import dataclass from typing import Optional diff --git a/frontend/catalyst/compiler.py b/frontend/catalyst/compiler.py index d1a69283f2..6aa29c5a76 100644 --- a/frontend/catalyst/compiler.py +++ b/frontend/catalyst/compiler.py @@ -251,8 +251,9 @@ def get_output_filename(infile): @staticmethod @debug_logger - def run(infile, outfile=None, flags=None, fallback_compilers=None, options=None, - extra_objects=None): + def run( + infile, outfile=None, flags=None, fallback_compilers=None, options=None, extra_objects=None + ): """ Link the infile against the necessary libraries and produce the outfile. @@ -277,7 +278,8 @@ def run(infile, outfile=None, flags=None, fallback_compilers=None, options=None, fallback_compilers = LinkerDriver._default_fallback_compilers for compiler in LinkerDriver._available_compilers(fallback_compilers): success = LinkerDriver._attempt_link( - compiler, flags, infile, outfile, options, extra_objects) + compiler, flags, infile, outfile, options, extra_objects + ) if options.verbose: print("Shared object linking successful", file=options.logfile) if success: diff --git a/frontend/catalyst/jit.py b/frontend/catalyst/jit.py index e66e36df37..18e1e7f6d5 100644 --- a/frontend/catalyst/jit.py +++ b/frontend/catalyst/jit.py @@ -58,8 +58,8 @@ from catalyst.utils.exceptions import CompileError from catalyst.utils.filesystem import WorkspaceManager from catalyst.utils.gen_mlir import inject_functions -from catalyst.utils.runtime_artifacts import collect_runtime_artifacts from catalyst.utils.patching import Patcher +from catalyst.utils.runtime_artifacts import collect_runtime_artifacts logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) diff --git a/frontend/catalyst/kernel.py b/frontend/catalyst/kernel.py index 89632c2deb..1868639bed 100644 --- a/frontend/catalyst/kernel.py +++ b/frontend/catalyst/kernel.py @@ -77,8 +77,9 @@ def _to_hashable(spec): return tuple(entries) -def declare(name: str, artifact: Optional[str] = None, outputs=None, *, - remote=False) -> KernelDescriptor: +def declare( + name: str, artifact: Optional[str] = None, outputs=None, *, remote=False +) -> KernelDescriptor: """Declare an external kernel for use with :func:`kernel.runtime_call`. Args: @@ -133,8 +134,8 @@ def define(builder, *, name: Optional[str] = None, outputs): """Build a kernel with ``builder`` and declare it, as a single decorator. Args: - builder: A backend-specific object implementing ``build(kernel_fn, *, name) -> path``, - where ``path`` points to a shared library exporting ``name`` with the + builder: A backend-specific object implementing ``build(kernel_fn, *, name) -> path``, + where ``path`` points to a shared library exporting ``name`` with the :func:`runtime_call` ABI. name: Symbol the artifact must export. Defaults to ``kernel_fn.__name__``; passed to both ``builder.build`` and :func:`declare`. diff --git a/frontend/catalyst/utils/runtime_artifacts.py b/frontend/catalyst/utils/runtime_artifacts.py index 59e324ed3e..ddd6e5b9db 100644 --- a/frontend/catalyst/utils/runtime_artifacts.py +++ b/frontend/catalyst/utils/runtime_artifacts.py @@ -62,9 +62,7 @@ def _walk(op): compile_options.runtime_artifacts = tuple(seen) -_RUNTIME_ARTIFACTS_TEXT_RE = re.compile( - rf"{re.escape(RUNTIME_ARTIFACTS_ATTR)}\s*=\s*\[([^\]]*)\]" -) +_RUNTIME_ARTIFACTS_TEXT_RE = re.compile(rf"{re.escape(RUNTIME_ARTIFACTS_ATTR)}\s*=\s*\[([^\]]*)\]") _RUNTIME_ARTIFACTS_PATH_RE = re.compile(r'"([^"]*)"') diff --git a/frontend/test/pytest/test_cross_compile_targets.py b/frontend/test/pytest/test_cross_compile_targets.py index 7bf32a71e6..36e1bcbf32 100644 --- a/frontend/test/pytest/test_cross_compile_targets.py +++ b/frontend/test/pytest/test_cross_compile_targets.py @@ -12,8 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Integration test for the target cross-compilation + remote-dispatch chain. -""" +"""Integration test for the target cross-compilation + remote-dispatch chain.""" import os diff --git a/frontend/test/pytest/test_kernel.py b/frontend/test/pytest/test_kernel.py index ab31b5567b..bb70f2d795 100644 --- a/frontend/test/pytest/test_kernel.py +++ b/frontend/test/pytest/test_kernel.py @@ -52,7 +52,7 @@ def build(self, kernel_fn, *, name): # pylint: disable=unused-argument return str(artifact) @kernel.define(Builder(), outputs=jax.ShapeDtypeStruct((1,), jnp.int32)) - def my_kernel(): # pragma: no cover + def my_kernel(): # pragma: no cover pass assert isinstance(my_kernel, KernelDescriptor) diff --git a/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp b/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp index f5471114f9..876a48f61f 100644 --- a/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp +++ b/mlir/lib/Catalyst/Transforms/BufferizableOpInterfaceImpl.cpp @@ -182,10 +182,9 @@ struct CustomCallOpInterface // Create an updated custom call operation. `backend_config` is a declared attribute, so it // must be passed to the builder explicitly; discardable attrs are carried separately below. - auto newCustomCallOp = - CustomCallOp::create(rewriter, op->getLoc(), TypeRange{}, bufferArgs, - customCallOp.getCallTargetName(), numArgumentsAttr, - customCallOp.getBackendConfigAttr()); + auto newCustomCallOp = CustomCallOp::create( + rewriter, op->getLoc(), TypeRange{}, bufferArgs, customCallOp.getCallTargetName(), + numArgumentsAttr, customCallOp.getBackendConfigAttr()); newCustomCallOp->setDiscardableAttrs(customCallOp->getDiscardableAttrDictionary()); size_t startIndex = bufferArgs.size() - customCallOp.getNumResults(); SmallVector bufferResults(bufferArgs.begin() + startIndex, bufferArgs.end()); @@ -338,9 +337,9 @@ struct CallbackCallOpInterface }; /// Bufferization of catalyst.launch_kernel. The callee reads its operands and returns each result -/// in its own freshly allocated buffer. Bufferization therefore converts the operands to buffers and -/// the tensor results to memref results (return-by-value): no operand is written in place and no -/// result aliases an operand. +/// in its own freshly allocated buffer. Bufferization therefore converts the operands to buffers +/// and the tensor results to memref results (return-by-value): no operand is written in place and +/// no result aliases an operand. struct LaunchKernelOpInterface : public bufferization::BufferizableOpInterface::ExternalModel { @@ -389,9 +388,10 @@ struct LaunchKernelOpInterface buffer = operand; } - // The callee receives each operand as a contiguous block, addressed through the memref's - // aligned pointer with its strides/offset ignored. Copy any non-identity-layout operand - // into a fresh contiguous buffer first so the callee sees the intended elements. + // The callee receives each operand as a contiguous block, addressed through the + // memref's aligned pointer with its strides/offset ignored. Copy any + // non-identity-layout operand into a fresh contiguous buffer first so the callee sees + // the intended elements. if (auto memrefTy = dyn_cast(buffer.getType())) { if (!memrefTy.getLayout().isIdentity()) { MemRefType contiguousTy = @@ -415,10 +415,9 @@ struct LaunchKernelOpInterface } } - auto newLaunchOp = - LaunchKernelOp::create(rewriter, op->getLoc(), memrefResultTypes, launchOp.getCallee(), - bufferOperands, launchOp.getArgAttrsAttr(), - launchOp.getResAttrsAttr()); + auto newLaunchOp = LaunchKernelOp::create( + rewriter, op->getLoc(), memrefResultTypes, launchOp.getCallee(), bufferOperands, + launchOp.getArgAttrsAttr(), launchOp.getResAttrsAttr()); // Carry over any discardable attributes, since create() only sets the declared ones. newLaunchOp->setDiscardableAttrs(launchOp->getDiscardableAttrDictionary()); bufferization::replaceOpWithBufferizedValues(rewriter, op, newLaunchOp.getResults()); diff --git a/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp b/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp index b41deabcb7..5fbae87797 100644 --- a/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp +++ b/mlir/lib/Catalyst/Transforms/CrossCompileTargets.cpp @@ -92,9 +92,10 @@ std::vector defaultLoweringPassList() } // Lower a *local* (non-dispatch) target module. Its cross-compiled object is statically linked into -// the final binary, so each host-side launch_kernel into it is rewritten to a flat func.call against -// an external declaration of the entry (resolved at link time by the object's native symbol). The -// object path is recorded on the root module for the linker, and the now-empty module is erased. +// the final binary, so each host-side launch_kernel into it is rewritten to a flat func.call +// against an external declaration of the entry (resolved at link time by the object's native +// symbol). The object path is recorded on the root module for the linker, and the now-empty module +// is erased. LogicalResult lowerLocalTargetCalls(ModuleOp host, ModuleOp nested, SmallVectorImpl &objectFiles) { @@ -146,8 +147,7 @@ LogicalResult lowerLocalTargetCalls(ModuleOp host, ModuleOp nested, return success(); } -struct CrossCompileTargetsPass - : impl::CrossCompileTargetsPassBase { +struct CrossCompileTargetsPass : impl::CrossCompileTargetsPassBase { using CrossCompileTargetsPassBase::CrossCompileTargetsPassBase; void getDependentDialects(DialectRegistry ®istry) const override @@ -344,9 +344,9 @@ struct CrossCompileTargetsPass } llvm::DataLayout dataLayout = targetMachine->createDataLayout(); - // Clone the target module into an unparented root module: leaves `nested` intact in the host - // (its host launch_kernel is consumed later by local flattening or dispatch) and gives the - // sub-pipeline / translateModuleToLLVMIR a top-level module to operate on. + // Clone the target module into an unparented root module: leaves `nested` intact in the + // host (its host launch_kernel is consumed later by local flattening or dispatch) and gives + // the sub-pipeline / translateModuleToLLVMIR a top-level module to operate on. OpBuilder builder(ctx); mlir::OwningOpRef standalone(cast(nested->clone())); @@ -383,8 +383,8 @@ struct CrossCompileTargetsPass dumpMLIR(*standalone, kernelDir, "extracted.mlir"); } - // Lower the extracted module. A 'pipeline' key on catalyst.target selects a named - // target-lowering pipeline resolved against the host pipeline registry. + // Lower the extracted module. A 'pipeline' key on catalyst.target selects a named + // target-lowering pipeline resolved against the host pipeline registry. // Without it, fall back to the default bufferization + LLVM-dialect lowering. std::string pipelineSpec; if (auto pipelineAttr = targetAttr.getAs("pipeline")) { @@ -395,8 +395,7 @@ struct CrossCompileTargetsPass } PassManager subPM(ctx); if (failed(parsePassPipeline(pipelineSpec, subPM))) { - nested.emitError("failed to build the target-lowering pipeline '" + pipelineSpec + - "'"); + nested.emitError("failed to build the target-lowering pipeline '" + pipelineSpec + "'"); return failure(); } if (failed(subPM.run(*standalone))) { diff --git a/mlir/lib/Driver/CompilerDriver.cpp b/mlir/lib/Driver/CompilerDriver.cpp index d2fb5a2562..ab64af3198 100644 --- a/mlir/lib/Driver/CompilerDriver.cpp +++ b/mlir/lib/Driver/CompilerDriver.cpp @@ -489,10 +489,9 @@ llvm::LogicalResult catalyst::driver::runPipeline(PassManager &pm, const Compile Pipeline targetPipeline; targetPipeline.setName("CrossCompileTargets"); std::string dumpIntermediate = options.keepIntermediate ? "true" : "false"; - targetPipeline.setPasses( - {"cross-compile-targets{workspace=" + options.workspace.str() + - " dump-intermediate=" + dumpIntermediate + "}", - "dispatch-remote-targets"}); + targetPipeline.setPasses({"cross-compile-targets{workspace=" + options.workspace.str() + + " dump-intermediate=" + dumpIntermediate + "}", + "dispatch-remote-targets"}); if (failed(catalyst::utils::Timer<>::timer( catalyst::driver::runPipeline, targetPipeline.getName(), /* add_endl */ false, pm, options, output, targetPipeline, diff --git a/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp b/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp index 817b2affd5..52e8c086b5 100644 --- a/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp +++ b/mlir/lib/Remote/Transforms/DispatchRemoteTargets.cpp @@ -19,6 +19,7 @@ #include "mlir/Pass/Pass.h" #include "Catalyst/IR/CatalystOps.h" + #include "Remote/IR/RemoteOps.h" #include "Remote/Transforms/Passes.h" @@ -35,7 +36,8 @@ namespace { // Ships cross-compiled `catalyst.target` modules to a remote executor using the `remote` dialect. // // This pass should be run after `cross-compile-targets`, which records each module's object file in -// `catalyst.object_file`. For every nested module carrying a `catalyst.dispatch` attribute this pass: +// `catalyst.object_file`. For every nested module carrying a `catalyst.dispatch` attribute this +// pass: // 1. Injects `remote.open` into `setup()` (once per unique address) and `remote.send_binary` // into `setup()` (once per module; the object holds every entry). // 2. Rewrites every host-side `func.call` to an entry function into a `remote.launch` op carrying @@ -45,8 +47,7 @@ namespace { // // Session teardown is handled by the runtime, which closes every open session when the process // exits, so no explicit close op is emitted (matching `cross-compile-remote-kernels`). -struct DispatchRemoteTargetsPass - : impl::DispatchRemoteTargetsPassBase { +struct DispatchRemoteTargetsPass : impl::DispatchRemoteTargetsPassBase { using DispatchRemoteTargetsPassBase::DispatchRemoteTargetsPassBase; void runOnOperation() final @@ -251,8 +252,8 @@ struct DispatchRemoteTargetsPass // `pathAttr` (the object-file path, same value shipped by send_binary) keys the // per-kernel JITDylib on the executor so the entry resolves in its own object. auto launch = remote::LaunchOp::create( - b, launchKernel.getLoc(), launchKernel.getResultTypes(), - launchKernel.getOperands(), addressAttr, calleeAttr, pathAttr); + b, launchKernel.getLoc(), launchKernel.getResultTypes(), launchKernel.getOperands(), + addressAttr, calleeAttr, pathAttr); launchKernel.replaceAllUsesWith(launch.getResults()); launchKernel.erase(); } diff --git a/runtime/lib/capi/ExecutionContext.hpp b/runtime/lib/capi/ExecutionContext.hpp index 1d8f3de9f9..960ac558b8 100644 --- a/runtime/lib/capi/ExecutionContext.hpp +++ b/runtime/lib/capi/ExecutionContext.hpp @@ -101,7 +101,7 @@ class SharedLibraryManager final { const char *primary_dlerror = dlerror(); std::string primary_error = primary_dlerror ? primary_dlerror : "unknown dlopen error"; auto basename = std::filesystem::path(filename).filename().string(); - + // rewrite the extension if it differs from the platform one. if (!basename.ends_with(dl_ext)) { auto dot = basename.find_last_of('.'); diff --git a/runtime/lib/remote/RemoteRuntime.cpp b/runtime/lib/remote/RemoteRuntime.cpp index 9ee3954d6e..a4d3b7a307 100644 --- a/runtime/lib/remote/RemoteRuntime.cpp +++ b/runtime/lib/remote/RemoteRuntime.cpp @@ -247,8 +247,9 @@ void __catalyst__remote__launch(const char *addr, const char *entry_symbol, cons std::lock_guard lock(entry->mu); if (remote_verbose()) { - std::fprintf(stderr, "[remote] launch(addr=%s, symbol=%s, object=%s, n_in=%zu, n_out=%zu)\n", - addr, entry_symbol, object ? object : "", num_inputs, num_outputs); + std::fprintf(stderr, + "[remote] launch(addr=%s, symbol=%s, object=%s, n_in=%zu, n_out=%zu)\n", addr, + entry_symbol, object ? object : "", num_inputs, num_outputs); } if (!entry->session) { RT_FAIL("Session is closed"); diff --git a/runtime/lib/remote/RemoteSession.cpp b/runtime/lib/remote/RemoteSession.cpp index 5fec651bd5..c0e5b6e62e 100644 --- a/runtime/lib/remote/RemoteSession.cpp +++ b/runtime/lib/remote/RemoteSession.cpp @@ -99,8 +99,8 @@ void initialize_targets() // messaging (e.g. deallocating JIT'd memory) — avoids tearing the socket down prematurely. class ShutdownFDTransport : public SimpleRemoteEPCTransport { public: - static Expected> - Create(SimpleRemoteEPCTransportClient &C, int InFD, int OutFD) + static Expected> Create(SimpleRemoteEPCTransportClient &C, + int InFD, int OutFD) { auto Inner = FDSimpleRemoteEPCTransport::Create(C, InFD, OutFD); if (!Inner) { @@ -312,9 +312,9 @@ struct RemoteSession { auto setup = SimpleRemoteEPC::Setup(); setup.CreateMemoryManager = createSimpleRemoteMemoryManager; - // The ORC bootstrap handshake inside SimpleRemoteEPC::Create is an unbounded blocking read on - // the socket. If the peer is not a live catalyst-executor (e.g. a stale port-forward or a - // dead SSH tunnel accepted the connection), it would hang forever. A watchdog shuts the + // The ORC bootstrap handshake inside SimpleRemoteEPC::Create is an unbounded blocking read + // on the socket. If the peer is not a live catalyst-executor (e.g. a stale port-forward or + // a dead SSH tunnel accepted the connection), it would hang forever. A watchdog shuts the // socket down after the timeout, which forces the blocked read to fail and Create to return // an error instead of hanging. const int sockFd = *SockFD; @@ -338,8 +338,8 @@ struct RemoteSession { // ShutdownFDTransport instead of FDSimpleRemoteEPCTransport so teardown shutdown()s the // socket and doesn't deadlock — see the class comment above. auto EPC = SimpleRemoteEPC::Create( - std::make_unique(std::nullopt), std::move(setup), sockFd, - sockFd); + std::make_unique(std::nullopt), std::move(setup), + sockFd, sockFd); if (watchdog.joinable()) { { @@ -383,8 +383,8 @@ struct RemoteSession { return ObjectLayer.add(jd, std::move(Buf)); } - // Resolve `Name` in the JITDylib of the object identified by `path` (kernel entry points). Falls - // back to MainJD if the object is unknown. + // Resolve `Name` in the JITDylib of the object identified by `path` (kernel entry points). + // Falls back to MainJD if the object is unknown. ExecutorAddr lookupSym(StringRef path, StringRef Name) { auto it = KernelJDs.find(path.str());