Examples

Example Parsing

This sample uses the Add operator as an example to demonstrate how to integrate a custom Ascend C operator into the GE graph via TorchAir. The GE deliverable implements graph execution through EagerExecuteOp, while Python supports both eager-mode direct calls and torch.compile graph-mode compilation. For the complete example code, see ascendc_add_custom. For more examples, see the source code repositories: Offline OM offloading and Data-dependent shape. The following table lists the specifications of the Add operator.

Item

Content

Operator type

AddCustom

Input

x, y

Output

z

Input shape

8 x 2048

Output shape

8 x 2048

Data type

float16

Format

ND

Kernel name

add_custom

The directory structure is as follows:

ascendc_add_custom
├── CMakeLists.txt
├── README.md
├── add_custom_kernel
│   ├── add_custom.asc        // Ascend C kernel implementation and PyTorch registration code
│   ├── add_custom_kernel.h   // Kernel declaration
│   └── custom_op.cpp         // GE deliverable, implementing the graph execution logic
└── script
    └── add_custom_test.py    // Python test script, covering the eager and graph modes
  • add_custom_kernel/add_custom.asc: implementation file of the Ascend C Add kernel, which also contains the PyTorch custom operator registration logic (TORCH_LIBRARY_FRAGMENT + TORCH_LIBRARY_IMPL).
  • add_custom_kernel/custom_op.cpp: GE deliverable of the custom operator, which implements the Execute path, REG_OP operator prototype registration, InferShape inference, and GE graph integration.
  • script/add_custom_test.py: Python test script, which covers eager execution and TorchAir graph-mode compilation and execution and includes converter registration.
  • CMakeLists.txt: Compile libcust_opapi.so and configure dependencies for PyTorch, TorchAir, and CANN in this file.
  1. Phase 1: Operator definition
    In the operator definition phase, operator metadata, such as the inputs, outputs, and attributes, is declared. In this example, the REG_OP definition on the GE and the TORCH_LIBRARY registration on the PyTorch are completed in two separate C++ files.
    1. REG_OP (custom_op.cpp) on the GE:
      // add_custom_kernel/custom_op.cpp
      // Register the GE operator prototype, declaring the inputs, outputs, and supported data types of the AddCustom operator.
      REG_OP(AddCustom)
          .INPUT (x, "T")    // Input x.
          .INPUT (y, "T")    // Input y.
          .OUTPUT (z, "T")   // Output z.
          .DATATYPE(T, TensorType({DT_BOOL, DT_FLOAT, DT_INT32, DT_INT64, DT_FLOAT16, DT_BF16,
                                   DT_INT16, DT_INT8, DT_UINT8, DT_DOUBLE, DT_COMPLEX128,
                                   DT_COMPLEX64, DT_STRING, DT_COMPLEX32}))
          .OP_END_FACTORY_REG(AddCustom);
      Implement and register the InferShape function in the file:
      // add_custom_kernel/custom_op.cpp
      // Shape inference function of the AddCustom operator, which implements the PyTorch-style broadcast semantics. GE calls this function during graph compilation to infer the output shape based on the input shape.
      graphStatus InferShapeForAdd(gert::InferShapeContext *context) {
        // Obtain the shapes of the two inputs.
        auto input_shape_0 = *context->GetInputShape(0);
        auto input_shape_1 = *context->GetInputShape(1);
        // Obtain the writable reference of the output shape, which is used to set the inference result.
        auto output_shape = context->GetOutputShape(0);
        // Case 1: The two inputs have different dimensions. Handle the broadcast semantics (broadcasting a 1D tensor to a multi-dimensional tensor).
        if (input_shape_0.GetDimNum() != input_shape_1.GetDimNum()) {
          auto min_num = std::min(input_shape_0.GetDimNum(), input_shape_1.GetDimNum());
          auto max_num = std::max(input_shape_0.GetDimNum(), input_shape_1.GetDimNum());
          if (min_num != 1) {
            std::cerr << "Add param invalid" << std::endl;
          } else {
            if (input_shape_1.GetDimNum() > 1) {
              *output_shape = input_shape_1;
            } else {
              *output_shape = input_shape_0;
            }
            return GRAPH_SUCCESS;
          }
        }
        // Case 2: If one input is a scalar (dimension count = 0), the output shape is the shape of the other input.
        if (input_shape_0.GetDimNum() == 0) {
          *output_shape = input_shape_1;
          return GRAPH_SUCCESS;
        }
        // Case 3: If the two inputs have the same number of dimensions, the maximum value is taken dimension-wise (standard broadcasting rule).
        if (input_shape_1.GetDimNum() == 0) {
          *output_shape = input_shape_0;
          return GRAPH_SUCCESS;
        }
        output_shape->SetDimNum(input_shape_0.GetDimNum());
        for (size_t i = 0; i < input_shape_0.GetDimNum(); ++i) {
          output_shape->SetDim(i, std::max(input_shape_0.GetDim(i), input_shape_1.GetDim(i)));
        }
        return GRAPH_SUCCESS;
      }
      
      // Register the InferShape function with the AddCustom operator. When encountering an AddCustom node during GE compilation, the InferShape function is called to infer the output shape.
      IMPL_OP(AddCustom).InferShape(InferShapeForAdd);
    2. TORCH_LIBRARY registration (add_custom.asc) on the PyTorch:
      In the .asc file, define the operator schema using TORCH_LIBRARY_FRAGMENT and bind the implementation of each DispatchKey using TORCH_LIBRARY_IMPL.
      // add_custom_kernel/add_custom.asc
      // Operator implementation on the PyTorch: Execute the Ascend C kernel on the NPU.
      namespace ascendc_ops {
      // ascendc_add function: called by PyTorch in eager mode.
      // Prepare parameters and start the Ascend C kernel function.
      at::Tensor ascendc_add(const at::Tensor& x, const at::Tensor& y) {
        // Obtain the current NPU execution stream. The kernel is executed asynchronously on this stream.
        auto aclStream = c10_npu::getCurrentNPUStream().stream(false);
        // Allocate the output tensor, with the shape the same as that of the input x.
        at::Tensor z = at::empty_like(x);
        // Set the number of blocks to be started by the kernel function.
        uint32_t numBlocks = 8;
        // Calculate the total number of input elements (by multiplying the number of all dimensions).
        uint32_t totalLength = 1;
        for (uint32_t size : x.sizes()) {
          totalLength *= size;
        }
        // Call the encapsulation function to start the kernel function.
        launch_add_custom(
          static_cast<uint8_t*>(x.data_ptr()),  // Device address of input x
          static_cast<uint8_t*>(y.data_ptr()),  // Device address of input y
          static_cast<uint8_t*>(z.data_ptr()),  // Device address of output z
          totalLength,                          // Total number of elements
          numBlocks,                            // Number of blocks of the kernel function
          aclStream                             // Execution stream
        );
        return z;
      }
      } // namespace ascendc_ops
      
      // ===== PyTorch operator registration =====
      // Define the operator schema: Declare the function signature for PyTorch to identify.
      // ascendc_ops is the namespace, and ascendc_add is the function name.
      TORCH_LIBRARY_FRAGMENT(ascendc_ops, m) {
        m.def("ascendc_add(Tensor x, Tensor y) -> Tensor");
      }
      
      // Register the implementation with PrivateUse1: eager execution path on the NPU device.
      // Triggered when a user directly calls torch.ops.ascendc_ops.ascendc_add(x, y) on the NPU.
      TORCH_LIBRARY_IMPL(ascendc_ops, PrivateUse1, m) {
        m.impl("ascendc_add", TORCH_FN(ascendc_ops::ascendc_add));
      }
      
      // Register the implementation with XLA: graph compilation path.
      // When the operator enters the graph mode through torch.compile, the XLA path triggers GE graph compilation.
      TORCH_LIBRARY_IMPL(ascendc_ops, XLA, m) {
        m.impl("ascendc_add", TORCH_FN(ascendc_ops::ascendc_add));
      }
      
      // Register the implementation for Meta: shape inference during compile.
      // The Meta path does not perform actual computation and only returns an empty tensor with the same shape as the input.
      // TorchAir calls this implementation for shape inference during graph compilation.
      TORCH_LIBRARY_IMPL(ascendc_ops, Meta, m) {
        m.impl("ascendc_add", [](const torch::Tensor& x, const torch::Tensor& y) {
          return at::empty_like(x);
        });
      }

      Note: In this example, the PyTorch registration logic is placed in the .asc file (C++ source code) and compiled in the same library as the kernel. This is a common practice for integrating Ascend C operators into graphs. The kernel and registration logic are implemented in the same compilation unit.

  2. Phase 2: Frontend integration

    In the frontend integration phase, use the TorchAir converter to map PyTorch FX nodes to custom GE operators. In this example, the converter is registered in the test script:

    # script/add_custom_test.py
    import torchair
    from torchair import register_fx_node_ge_converter
    from torchair.ge import Tensor, TensorSpec
    
    # Register the GE converter to map the custom operator node in the PyTorch FX graph to the operator node in the GE graph.
    # The decorator specifies the PyTorch operator reference (torch.ops.ascendc_ops.ascendc_add.default) to be converted.
    # When encountering this operator during graph compilation, TorchAir calls this converter function.
    @torchair.register_fx_node_ge_converter(torch.ops.ascendc_ops.ascendc_add.default)
    def convert_ascendc_ascendc_add(
        x: Tensor, y: Tensor, z: Tensor = None, meta_outputs: TensorSpec = None
    ):
        # Construct a GE custom operator node and return it.
        return torchair.ge.custom_op(
            "AddCustom",            # GE operator type name, which must be the same as that in REG_OP and REG_AUTO_MAPPING_OP.
            inputs={
                "x": x,             # Map the PyTorch input x to the input port x of the GE operator.
                "y": y,             # Map the PyTorch input y to the input port y of the GE operator.
            },
            outputs=["z"],          # Declare the name of the GE operator output port, which corresponds to .OUTPUT(z, ...) in REG_OP.
        )

    The AddCustom in the converter must be the same as the op type in REG_OP and REG_AUTO_MAPPING_OP.

  3. Phases 4 and 5: GE deliverable implementation

    The GE deliverable implements EagerExecuteOp::Execute in custom_op.cpp to start the kernel:

    // add_custom_kernel/custom_op.cpp
    // GE deliverable implementation class of the AddCustom operator, which inherits EagerExecuteOp and implements the Execute method to start the kernel.
    class AddCustom : public EagerExecuteOp {
     public:
      // Execute callback: GE calls this method in the graph execution phase to start the Ascend C kernel function.
      graphStatus Execute(gert::EagerOpExecutionContext *ctx) {
        // Obtain the input tensor of the operator (including metadata such as the device address, shape, dtype, and format).
        const gert::Tensor *input_x = ctx->GetInputTensor(0);
        const gert::Tensor *input_y = ctx->GetInputTensor(1);
        if (input_x == nullptr || input_y == nullptr) {
          std::cerr << "Input tensor is null!" << std::endl;
          return GRAPH_FAILED;
        }
        // 2. Extract metadata from the input tensor for allocating the output tensor.
        const gert::StorageShape &output_shape = input_x->GetShape();
        uint32_t tensor_size = input_x->GetSize();
        DataType data_type = input_x->GetDataType();
        const gert::StorageFormat &format = input_x->GetFormat();
    
        // 3. Allocate device memory for the output tensor. (The lifecycle is managed by the GE context, and manual release is not required.)
        gert::Tensor *output_z = ctx->MallocOutputTensor(0, output_shape, format, data_type);
        if (output_z == nullptr) {
          std::cerr << "Failed to malloc output tensor memory!" << std::endl;
          return GRAPH_FAILED;
        }
        // 4. Compute the kernel function startup parameters: grid dimension and number of blocks.
        int64_t n_elements = input_x->GetShapeSize();
        const int32_t BLOCK_SIZE_VALUE = 1024;
        int32_t grid_x = std::ceil(static_cast<double>(n_elements) / (BLOCK_SIZE_VALUE));
        int32_t grid_y = 1;
        int32_t grid_z = 1;
        uint32_t num_block = grid_x * grid_y * grid_z;
    
        // 5. Obtain the device addresses of the input and output.
        void *z_addr = output_z->GetAddr();
        void *x_addr = const_cast<void *>(input_x->GetAddr());
        void *y_addr = const_cast<void *>(input_y->GetAddr());
        // 6. Obtain the execution stream and start the kernel function (asynchronous execution and immediate return).
        aclrtStream stream = ctx->GetStream();
        // Call the encapsulation function to start the Ascend C kernel function add_custom.
        launch_add_custom(static_cast<uint8_t *>(x_addr), static_cast<uint8_t *>(y_addr),
                          static_cast<uint8_t *>(z_addr), tensor_size, num_block, stream);
        return GRAPH_SUCCESS;
      }
    };
    
    // Register the AddCustom implementation class with GE.
    // When GE encounters an AddCustom node in the graph, it instantiates this class and calls back the Execute method.
    REG_AUTO_MAPPING_OP(AddCustom);

    launch_add_custom is declared in add_custom_kernel.h and implemented in add_custom.asc. It calls the Ascend C kernel function add_custom.

Project Compilation and Deployment

  1. Build a project.
    Run the following command in the ascendc_add_custom directory:
    mkdir -p build
    cd build
    cmake ..
    make -j"$(nproc)"

    After the build is successful, the libcust_opapi.so deliverable is generated in the build/ directory.

  2. Deploy and run the project.
    # Configure environment variables.
    export ASCEND_CUSTOM_OPP_PATH="$(pwd)/output:$ASCEND_CUSTOM_OPP_PATH"
    
    # Run a test.
    python3 ../script/add_custom_test.py

    If "Ran 1 test" and "OK" are displayed, the eager mode and TorchAir graph mode pass the verification.