diff --git a/.github/actions/setup-cudaq-devel-venv/action.yml b/.github/actions/setup-cudaq-devel-venv/action.yml new file mode 100644 index 00000000000..41ee0611bc9 --- /dev/null +++ b/.github/actions/setup-cudaq-devel-venv/action.yml @@ -0,0 +1,168 @@ +name: Set up cudaq-devel environment +description: | + Resolve and download the latest cudaq wheel, install the Python + build toolchain, build a cudaq-devel wheel, and expose wheel paths for further installation or validation. + +inputs: + cudaq-wheel-url: + description: | + Optional URL of a cudaq wheel artifact (e.g. .../actions/runs/RUN_ID/artifacts/ARTIFACT_ID). + Defaults to the latest artifact built from main. + required: false + default: '' + output-dir: + description: Directory where the cudaq-devel wheel is written. + required: false + default: dist + +outputs: + cudaq-version: + description: CUDA-Q release version used to build the devel wheel. + value: ${{ steps.version.outputs.cudaq_version }} + python-version: + description: Python version inferred from the runtime wheel (e.g. 3.11). + value: ${{ steps.runtime_wheel.outputs.python_version }} + python-executable: + description: Path to the uv-managed Python interpreter matching the runtime wheel. + value: ${{ steps.toolchain.outputs.python_executable }} + output-dir: + description: Directory containing the downloaded/built wheels. + value: ${{ inputs.output-dir }} + cudaq-wheel-path: + description: Path to the downloaded cudaq*.whl file. + value: ${{ steps.runtime_wheel.outputs.wheel_path }} + cudaq-devel-wheel-path: + description: Path to the built cudaq_devel*.whl file. + value: ${{ steps.devel_wheel.outputs.wheel_path }} + +runs: + using: composite + steps: + - name: Initialize submodules (with retry) + shell: bash + run: | + retry() { for n in 1 2 3; do "$@" && return 0; [ $n -lt 3 ] && sleep $((15*n*n)); done; return 1; } + retry git -c submodule.tpls/llvm.update=none submodule update --init --recursive + + - name: Compute CUDA-Q version + id: version + uses: ./.github/actions/compute-cudaq-version + + - name: Resolve runtime wheel artifact + id: runtime_artifact + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + ARTIFACT_NAME: pycudaq-3-11-manylinux-amd64-cu13-0-gcc12-main + WHEEL_URL: ${{ inputs.cudaq-wheel-url }} + run: | + if [ -n "$WHEEL_URL" ]; then + run_id=$(echo "$WHEEL_URL" | sed -n 's|.*/runs/\([0-9]*\)/.*|\1|p') + artifact_id=$(echo "$WHEEL_URL" | sed -n 's|.*/artifacts/\([0-9]*\)$|\1|p') + if [ -z "$run_id" ] || [ -z "$artifact_id" ]; then + echo "::error::Could not parse run and artifact id from: $WHEEL_URL" + exit 1 + fi + else + artifact=$(gh api "repos/$REPO/actions/artifacts?name=$ARTIFACT_NAME&per_page=100" \ + --jq '[.artifacts[] | select(.expired == false and .workflow_run.head_branch == "main")] + | sort_by(.created_at) | last') + if [ -z "$artifact" ] || [ "$artifact" = "null" ]; then + echo "::error::No unexpired $ARTIFACT_NAME artifact built from main. Wheels are only kept for a day; pass runtime-wheel-url to use a specific artifact instead." + exit 1 + fi + run_id=$(echo "$artifact" | jq -r '.workflow_run.id') + artifact_id=$(echo "$artifact" | jq -r '.id') + fi + echo "Using artifact $artifact_id from run $run_id" + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + echo "artifact_id=$artifact_id" >> "$GITHUB_OUTPUT" + + - name: Download runtime wheel + uses: actions/download-artifact@v8 + with: + run-id: ${{ steps.runtime_artifact.outputs.run_id }} + artifact-ids: ${{ steps.runtime_artifact.outputs.artifact_id }} + path: ${{ inputs.output-dir }} + github-token: ${{ github.token }} + + - name: Resolve runtime wheel Python version + id: runtime_wheel + shell: bash + env: + RUNTIME_DIR: ${{ inputs.output-dir }} + run: | + whl=$(find "$RUNTIME_DIR" -name 'cuda_quantum_cu13*.whl' | head -1) + if [ -z "$whl" ]; then + echo "::error::No cuda_quantum_cu13*.whl found under $RUNTIME_DIR/" + find "$RUNTIME_DIR" -type f || true + exit 1 + fi + py=$(basename "$whl" | sed -nE 's/.*-cp3([0-9]+)-cp3[0-9]+.*/3.\1/p') + if [ -z "$py" ]; then + echo "::error::Could not parse Python version from wheel: $whl" + exit 1 + fi + echo "Using runtime wheel: $whl (Python $py)" + echo "python_version=$py" >> "$GITHUB_OUTPUT" + echo "wheel_path=$whl" >> "$GITHUB_OUTPUT" + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Set up validation toolchain + id: toolchain + shell: bash + env: + PY: ${{ steps.runtime_wheel.outputs.python_version }} + run: | + retry() { for n in 1 2 3; do "$@" && return 0; [ $n -lt 3 ] && sleep $((15*n*n)); done; return 1; } + uv python install "$PY" + uv tool install "cmake==4.0.3" + uv tool install "ninja==1.13.0" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + retry sudo apt-get update + retry sudo apt-get install -y g++-12 + echo "CC=gcc-12" >> "$GITHUB_ENV" + echo "CXX=g++-12" >> "$GITHUB_ENV" + echo "python_executable=$(uv python find "$PY")" >> "$GITHUB_OUTPUT" + + - name: Log in to GitHub CR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Build cudaq-devel wheel + shell: bash + env: + RELEASE_VERSION: ${{ steps.version.outputs.cudaq_version }} + BASE_IMAGE: ghcr.io/nvidia/cuda-quantum-devdeps:manylinux-amd64-cu13.0-gcc12-main + DEVEL_DIR: ${{ inputs.output-dir }} + PYTHON_VERSION: ${{ steps.runtime_wheel.outputs.python_version }} + run: | + mkdir -p "$DEVEL_DIR" + DOCKER_BUILDKIT=1 docker build \ + -f docker/release/cudaq.wheel.Dockerfile \ + --build-arg base_image="${BASE_IMAGE}" \ + --build-arg release_version="${RELEASE_VERSION}" \ + --build-arg python_version="${PYTHON_VERSION}" \ + --build-arg build_devel=1 \ + --output "$DEVEL_DIR" . + + - name: Resolve devel wheel path + id: devel_wheel + shell: bash + env: + DEVEL_DIR: ${{ inputs.output-dir }} + run: | + whl=$(find "$DEVEL_DIR" -name 'cudaq_devel*.whl' | head -1) + if [ -z "$whl" ]; then + echo "::error::No cudaq_devel*.whl found under $DEVEL_DIR/" + find "$DEVEL_DIR" -type f || true + exit 1 + fi + echo "Built devel wheel: $whl" + echo "wheel_path=$whl" >> "$GITHUB_OUTPUT" diff --git a/.github/research-preview-paths.txt b/.github/research-preview-paths.txt index e69de29bb2d..9428f6efd85 100644 --- a/.github/research-preview-paths.txt +++ b/.github/research-preview-paths.txt @@ -0,0 +1,8 @@ +# Ignore-list of repository-relative path prefixes owned by research-preview +# packages. The stable CUDA-Q CI workflows (ci, ci_macos, codeql, repo_checks) +# pass this file as `paths-ignore-file`, so a change that touches only these +# prefixes does not trigger the full stable CI suite. It is also the single +# source of truth for the paths a preview's own scoped workflow watches (see +# .github/workflows/pulse.yml). Only add a preview here once it has dedicated, +# scoped CI coverage. +pulse/ diff --git a/.github/workflows/pulse.yml b/.github/workflows/pulse.yml new file mode 100644 index 00000000000..ab87d69c45f --- /dev/null +++ b/.github/workflows/pulse.yml @@ -0,0 +1,322 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +name: Pulse research preview + +on: + workflow_dispatch: + inputs: + cudaq-wheel-url: + required: false + type: string + description: 'Optional: URL of a cuda-quantum runtime wheel artifact (e.g. .../actions/runs/RUN_ID/artifacts/ARTIFACT_ID). Defaults to the latest CI run on main.' + # These trigger-level filters are a coarse pre-gate only. YAML `paths:` cannot + # be sourced from a file, so they duplicate the prefixes owned by + # .github/research-preview-paths.txt (plus this workflow); the authoritative + # check in the `changes` job below reads that registry directly. + push: + branches: + - main + - "pull-request/[0-9]+" + paths: + - pulse/** + - .github/workflows/pulse.yml + - .github/actions/setup-cudaq-devel-venv/** + pull_request: + paths: + - pulse/** + - .github/workflows/pulse.yml + - .github/actions/setup-cudaq-devel-venv/** + merge_group: + types: + - checks_requested + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changes: + name: Check for pulse changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + pulse: ${{ steps.filter.outputs.matched }} + steps: + - name: Check out changed-path action and path registry + uses: actions/checkout@v6 + with: + sparse-checkout: | + .github/actions/check-changed-paths + .github/research-preview-paths.txt + sparse-checkout-cone-mode: false + + - name: Assemble pulse path filter + id: paths + shell: bash + # research-preview-paths.txt is the single source of truth for the + # pulse-owned prefixes; add the workflow files that also affect pulse + # but are not preview-owned. Pulse builds standalone against the CUDA-Q + # wheels, so the root CMakeLists.txt is deliberately not listed here. + run: | + { + echo 'value<> "$GITHUB_OUTPUT" + + - name: Check changed paths + id: filter + uses: ./.github/actions/check-changed-paths + with: + paths: ${{ steps.paths.outputs.value }} + + quality: + name: Check pulse license headers + needs: changes + if: needs.changes.outputs.pulse == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out CUDA-Q + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.24' + cache: false + + - name: Check pulse license headers + run: | + find pulse -name CMakeLists.txt -exec cp '{}' '{}.tmp' \; + trap 'find pulse -name CMakeLists.txt.tmp -delete' EXIT + go install github.com/apache/skywalking-eyes/cmd/license-eye@latest + "${GOPATH:-$HOME/go}/bin/license-eye" header check pulse + + setup-cudaq-wheels: + name: Build CUDA-Q wheels + needs: changes + # This job builds on NVIDIA's self-hosted runners, which policy forbids for + # untrusted `pull_request` events. It runs on the trusted paths instead: + # the copy-pr-bot's pull-request/* push, the merge queue, main pushes, and + # manual dispatch (same gating as the GPU job below). + if: needs.changes.outputs.pulse == 'true' && github.event_name != 'pull_request' + runs-on: linux-amd64-cpu8 + permissions: + contents: read + packages: read + actions: read + outputs: + python-version: ${{ steps.setup.outputs.python-version }} + steps: + - name: Check out CUDA-Q + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up cudaq-devel environment + id: setup + uses: ./.github/actions/setup-cudaq-devel-venv + with: + cudaq-wheel-url: ${{ inputs.cudaq-wheel-url }} + + - name: Upload CUDA-Q wheels + uses: actions/upload-artifact@v7 + with: + name: cudaq-wheels + path: ${{ steps.setup.outputs.output-dir }} + if-no-files-found: error + + test-and-docs: + name: Build, test, and generate docs + needs: [changes, setup-cudaq-wheels] + # Self-hosted runner: gate off untrusted `pull_request` events, same as the + # wheel-build job it depends on. + if: needs.changes.outputs.pulse == 'true' && github.event_name != 'pull_request' + runs-on: linux-amd64-cpu8 + timeout-minutes: 60 + permissions: + contents: read + + steps: + - name: Check out CUDA-Q + uses: actions/checkout@v6 + + # No submodules and no development container: pulse compiles against the + # LLVM/MLIR toolchain shipped in the cudaq-devel wheel, so nothing here + # needs tpls/llvm, tpls/nanobind, or a prebuilt devcontainer image. The + # compiler matches the one the wheels were built with. + - name: Set up the build toolchain + run: | + retry() { for n in 1 2 3; do "$@" && return 0; [ $n -lt 3 ] && sleep $((15*n*n)); done; return 1; } + retry sudo apt-get update + retry sudo apt-get install -y g++-12 + echo "CC=gcc-12" >> $GITHUB_ENV + echo "CXX=g++-12" >> $GITHUB_ENV + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Install cmake and ninja + run: | + uv tool install "cmake==4.0.3" + uv tool install "ninja==1.13.0" + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Download CUDA-Q wheels + uses: actions/download-artifact@v4 + with: + name: cudaq-wheels + path: cudaq-wheels + + - name: Create the environment and install the wheels + id: cudaq + env: + PY: ${{ needs.setup-cudaq-wheels.outputs.python-version }} + EXTRA_PACKAGES: >- + nanobind>=2.12 + hypothesis + lit + myst-parser>=3.0 + nvidia-sphinx-theme==0.0.8 + numpy + pytest==9.0.3 + sphinx>=8.1,<8.3 + run: | + runtime=$(find cudaq-wheels -name 'cuda_quantum_cu*.whl' | head -1) + devel=$(find cudaq-wheels -name 'cudaq_devel*.whl' | head -1) + for var in runtime devel; do + if [ -z "${!var}" ]; then + echo "::error::No $var wheel found under cudaq-wheels/" + find cudaq-wheels -type f || true + exit 1 + fi + done + uv python install "$PY" + uv venv --python "$PY" .venv-cudaq + uv pip install --python .venv-cudaq/bin/python --no-deps "$runtime" "$devel" + if [ -n "$EXTRA_PACKAGES" ]; then + mapfile -t packages < <(printf '%s' "$EXTRA_PACKAGES" | tr -s '[:space:]' '\n' | grep -v '^$') + uv pip install --python .venv-cudaq/bin/python "${packages[@]}" + fi + echo "venv_path=$(cd .venv-cudaq && pwd)" >> $GITHUB_OUTPUT + + - name: Configure pulse + run: | + cmake -S pulse -B build-pulse -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCUDAQ_BUILD_TESTS=ON \ + -DCUDAQ_PULSE_BUILD_DOCS=ON \ + -DPython3_EXECUTABLE="${{ steps.cudaq.outputs.venv_path }}/bin/python" + + - name: Build, test, and generate docs + run: | + cmake --build build-pulse --parallel "$(nproc)" \ + --target check-pulse pulse-docs + + - name: Upload documentation + uses: actions/upload-artifact@v4 + with: + name: cudaq-pulse-docs + path: build-pulse/docs/html + if-no-files-found: error + + gpu-validation: + name: Run pulse numerical validation on GPU + needs: [changes, setup-cudaq-wheels] + # Pull-request events contain untrusted fork code and cannot use NVIDIA's + # self-hosted runners. The copy-pr-bot's trusted pull-request/* push, merge + # queue, main push, and manual dispatch paths run this trusted GPU job. + if: needs.changes.outputs.pulse == 'true' && github.event_name != 'pull_request' + runs-on: linux-amd64-gpu-a100-latest-1 + timeout-minutes: 30 + permissions: + contents: read + packages: read # read the public cuda-quantum-devcontainer image from ghcr.io + container: + # Unlike the CPU job, this one still needs a container: the CUDA Toolkit + # and the cuQuantum SDK that the pulse GPU runtime links against are not + # part of the CUDA-Q wheels. LLVM/MLIR still come from the devel wheel. + image: ghcr.io/nvidia/cuda-quantum-devcontainer:cu12.6-gcc12-main + credentials: + username: ${{ github.actor }} + password: ${{ github.token }} + + # The devcontainer's default shell is dash (/bin/sh); force bash so the + # `run` steps below can use bash features (`${!var}`, `mapfile`). + defaults: + run: + shell: bash + + steps: + - name: Check out CUDA-Q + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Download CUDA-Q wheels + uses: actions/download-artifact@v4 + with: + name: cudaq-wheels + path: cudaq-wheels + + - name: Create the environment and install the wheels + id: cudaq + env: + PY: ${{ needs.setup-cudaq-wheels.outputs.python-version }} + EXTRA_PACKAGES: >- + nanobind>=2.12 + hypothesis + lit + numpy + pytest==9.0.3 + run: | + runtime=$(find cudaq-wheels -name 'cuda_quantum_cu*.whl' | head -1) + devel=$(find cudaq-wheels -name 'cudaq_devel*.whl' | head -1) + for var in runtime devel; do + if [ -z "${!var}" ]; then + echo "::error::No $var wheel found under cudaq-wheels/" + find cudaq-wheels -type f || true + exit 1 + fi + done + uv python install "$PY" + uv venv --python "$PY" .venv-cudaq + uv pip install --python .venv-cudaq/bin/python --no-deps "$runtime" "$devel" + if [ -n "$EXTRA_PACKAGES" ]; then + mapfile -t packages < <(printf '%s' "$EXTRA_PACKAGES" | tr -s '[:space:]' '\n' | grep -v '^$') + uv pip install --python .venv-cudaq/bin/python "${packages[@]}" + fi + echo "venv_path=$(cd .venv-cudaq && pwd)" >> $GITHUB_OUTPUT + + - name: Locate cuDensityMat + run: | + echo "CUDENSITYMAT_ROOT=$(python3 -c 'import pathlib, cuquantum; print(pathlib.Path(cuquantum.__file__).parent)')" >> "$GITHUB_ENV" + + - name: Configure pulse GPU validation + run: | + cmake -S pulse -B build-pulse-gpu -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCUDAQ_BUILD_TESTS=ON \ + -DCUDENSITYMAT_ROOT="$CUDENSITYMAT_ROOT" \ + -DPython3_EXECUTABLE="${{ steps.cudaq.outputs.venv_path }}/bin/python" + + - name: Build and run numerical GPU tests + run: | + cmake --build build-pulse-gpu --parallel "$(nproc)" \ + --target check-pulse-gpu diff --git a/pulse/CMakeLists.txt b/pulse/CMakeLists.txt new file mode 100644 index 00000000000..f8b65c20b02 --- /dev/null +++ b/pulse/CMakeLists.txt @@ -0,0 +1,105 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +# CUDA-Q pulse is a standalone CMake project. It does not build as part of the +# CUDA-Q tree; instead it compiles against the CUDA-Q toolchain shipped in the +# `cudaq-devel` wheel (headers, CMake packages, MLIR/LLVM archives, mlir-tblgen) +# together with the `cudaq` runtime wheel (which provides libcudaqMLIR). Install +# both into the active Python environment and configure this directory directly: +# +# pip install cudaq cudaq-devel nanobind +# cmake -S pulse -B build-pulse -G Ninja +# +# See pulse/README.md for the full build, test, and GPU-runtime instructions. +cmake_minimum_required(VERSION 3.30 FATAL_ERROR) + +# Set a default build type / install prefix if none was specified. Must be set +# before project(). +set(CMAKE_BUILD_TYPE "Release" CACHE STRING + "Choose the type of build, options are: None Debug Release RelWithDebInfo MinSizeRel") +set(CMAKE_INSTALL_PREFIX "$ENV{HOME}/.cudaq_pulse" CACHE STRING + "Install path prefix, prepended onto install directories") + +# C is enabled because LLVM's HandleLLVMOptions probes the C compiler. +project(cudaq-pulse LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED TRUE) +set(CMAKE_POSITION_INDEPENDENT_CODE TRUE) + +include(GNUInstallDirs) + +# Locate the CUDA-Q CMake packages installed by the `cudaq-devel` wheel. Both +# `cudaq-devel` and `cudaq` unpack into the environment root, so a single prefix +# resolves CUDAQ, MLIR, and LLVM. An explicit -DCMAKE_PREFIX_PATH still wins. +find_package(Python3 3.10 REQUIRED COMPONENTS Interpreter Development.Module) +execute_process( + COMMAND "${Python3_EXECUTABLE}" -c "import site; print(site.getsitepackages()[0])" + OUTPUT_VARIABLE _cudaq_pulse_site_packages + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _cudaq_pulse_site_status) +if(_cudaq_pulse_site_status EQUAL 0 AND + EXISTS "${_cudaq_pulse_site_packages}/lib/cmake/cudaq") + list(APPEND CMAKE_PREFIX_PATH "${_cudaq_pulse_site_packages}") + message(STATUS + "Using CUDA-Q CMake packages from ${_cudaq_pulse_site_packages}/lib/cmake") +endif() + +# Pulse never compiles CUDA-Q quantum kernels, so the nvq++ toolchain is not +# required. This must be set before find_package(CUDAQ). +set(CUDAQ_ENABLE_LANGUAGE OFF) +find_package(CUDAQ REQUIRED) +find_package(MLIR REQUIRED CONFIG) + +list(APPEND CMAKE_MODULE_PATH + "${MLIR_CMAKE_DIR}" "${LLVM_CMAKE_DIR}" + "${CMAKE_CURRENT_SOURCE_DIR}/cmake") + +include(HandleLLVMOptions) +include(TableGen) +include(AddLLVM) +include(AddMLIR) +include(AddCUDAQ) + +include_directories(SYSTEM ${LLVM_INCLUDE_DIRS} ${MLIR_INCLUDE_DIRS}) + +# add_llvm_executable / add_mlir_library place their artifacts here. +set(LLVM_RUNTIME_OUTPUT_INTDIR "${CMAKE_BINARY_DIR}/bin") +set(LLVM_LIBRARY_OUTPUT_INTDIR "${CMAKE_BINARY_DIR}/lib") + +# CUDA-Q installers use a single relocatable library tree. Keep the pulse CMake +# package under that tree on distributions where GNUInstallDirs would otherwise +# default to lib64. +set(CMAKE_INSTALL_LIBDIR lib) + +set(CUDAQ_PULSE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}") +set(CUDAQ_PULSE_BINARY_DIR "${CMAKE_CURRENT_BINARY_DIR}") +set(CUDAQ_PULSE_FRONTEND_DIR + "${CUDAQ_PULSE_SOURCE_DIR}/core/frontend/cudaq_pulse") + +include_directories( + ${CUDAQ_PULSE_SOURCE_DIR}/core/mlir/include + ${CUDAQ_PULSE_BINARY_DIR}/core/mlir/include) + +# Generate the three research-preview dialects. +add_subdirectory(core/mlir/include/cudaq-pulse) + +# Build the dialects, transforms, conversions, C API, and optimizer driver. +add_subdirectory(core/mlir) +add_subdirectory(core/mlir/tools/cudaq-pulse-opt) + +# Resolve the optional GPU runtime plus the Python and nanobind dependencies. +include(CudaqPulseDependencies) +add_subdirectory(core/mlir/bindings) + +# Stage the Python package and register the aggregate build/install target. +include(CudaqPulsePython) + +# Keep test and documentation target definitions out of the package entrypoint. +include(CudaqPulseTesting) +include(CudaqPulseDocumentation) diff --git a/pulse/README.md b/pulse/README.md new file mode 100644 index 00000000000..6a8c66f1c7f --- /dev/null +++ b/pulse/README.md @@ -0,0 +1,241 @@ +# CUDA-Q pulse + +CUDA-Q pulse is a pulse-level quantum programming research package built on +MLIR. It provides a Python kernel DSL, pulse and operator dialects, compiler +passes, and an experimental cuDensityMat execution path. + +> [!WARNING] +> CUDA-Q pulse is **research-preview software**. It is not production software +> and is not a product-supported CUDA-Q feature. Its Python APIs, MLIR +> dialects, runtime interfaces, build options, numerical behavior, and file +> layout may change incompatibly or be removed without notice. No stability, +> compatibility, performance, or production-readiness guarantee is provided. +> Expect this work to evolve rapidly as the research matures. + +Use this package for evaluation, experimentation, and collaboration—not for +production workloads. This preview does not publish binary wheels. Build it +from the CUDA-Q source tree or use the Docker environment below. + +## Quick example + +```python +import cudaq_pulse as pulse + + +@pulse.kernel +def rabi_oscillation(qubit): + drive_line, tone = get_drive_line(qubit) + drive(drive_line, gaussian(64, 0.5, 16.0), tone) + + +compiled_kernel = pulse.compile( + rabi_oscillation, + [pulse.qudit_ref()], + qubit_freq_hz={0: 5.0e9}, +) +print(compiled_kernel.mlir) +``` + +The compiler traces the Python kernel, builds Pulse dialect IR, applies the +selected transformations, and emits scheduled MLIR. The +[user guide](docs/index.rst) covers the kernel model, operations, compilation, +passes, and experimental GPU execution path. + +## Build from Source + +CUDA-Q pulse is a standalone CMake project. It does not build as part of the +CUDA-Q tree; it compiles against the CUDA-Q toolchain distributed as Python +wheels, so there is no LLVM to build and no submodule to initialize: + +- **`cudaq-devel`** provides the headers, CMake packages, MLIR/LLVM archives, + `mlir-tblgen`, `FileCheck`, and the rest of the pinned LLVM toolchain. +- **`cudaq`** (the runtime wheel) provides `libcudaqMLIR`, the single shared + MLIR/LLVM instance that the pulse Python extension resolves its symbols from. + +Both wheels must come from the same CUDA-Q revision. Beyond them, pulse needs +Python 3.10 or newer, CMake, Ninja, nanobind, `pytest`, Hypothesis, and LLVM lit. + +```bash +git clone https://github.com/NVIDIA/cuda-quantum.git +cd cuda-quantum + +python3 -m venv .venv-pulse +source .venv-pulse/bin/activate +python -m pip install cudaq cudaq-devel +python -m pip install "nanobind>=2.12" cmake ninja pytest hypothesis lit numpy + +cmake -S pulse -B build-pulse -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build-pulse --parallel +``` + +Note the `-S pulse`: the configure entry point is `pulse/`, not the repository +root. Nothing outside `pulse/` participates in the build. + +Pulse locates the wheels through `site.getsitepackages()` of the interpreter +CMake picks up, so run CMake from the environment the wheels were installed +into, or pass `-DPython3_EXECUTABLE=/path/to/venv/bin/python`. To build against +a CUDA-Q installation that is not a wheel, point CMake at it directly with +`-DCMAKE_PREFIX_PATH=/path/to/cudaq/prefix`. nanobind is discovered the same +way and can be overridden with `-Dnanobind_DIR="$(python -m nanobind --cmake_dir)"`. + +The `--target pulse` aggregate target is available for scripts that prefer a +named target. + +The complete pulse package is staged in the build tree, so one `PYTHONPATH` +entry exposes both its Python sources and its native extension: + +```bash +export PATH="$PWD/build-pulse/bin:$PATH" +export PYTHONPATH="$PWD/build-pulse/python${PYTHONPATH:+:$PYTHONPATH}" +python3 -c "import cudaq_pulse; print(cudaq_pulse.__version__)" +``` + +A GPU and cuDensityMat are not required to build the compiler, inspect the +generated MLIR, or run the default unit tests. + +### Build the experimental cuDensityMat GPU runtime + +Point `CUDENSITYMAT_ROOT` at a cuQuantum installation containing +`include/cudensitymat.h` and `lib/libcudensitymat.so`. With pulse enabled, +CMake discovers cuDensityMat and automatically adds the experimental GPU +runtime to the `pulse` target; there is no second pulse feature flag: + +```bash +cmake -S pulse -B build-gpu -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCUDAQ_BUILD_TESTS=ON \ + -DCUDENSITYMAT_ROOT=/path/to/cuquantum \ + -DPython3_EXECUTABLE="$PWD/.venv-pulse/bin/python" + +cmake --build build-gpu --parallel --target pulse + +export CUDAQ_PULSE_BUILD_DIR="$PWD/build-gpu" +export PATH="$PWD/build-gpu/bin:$PATH" +export PYTHONPATH="$PWD/build-gpu/python${PYTHONPATH:+:$PYTHONPATH}" +``` + +When `CUDENSITYMAT_ROOT` is set, configuration fails if the CUDA Toolkit or +cuDensityMat cannot be found. Without cuDensityMat, pulse remains usable in +compiler-only mode. The built runtime links the discovered +`cuDensityMat::cuDensityMat` library and records its library directory in +the runtime search path. + +## Test + +The aggregate target builds and runs the MLIR regression tests and the +non-GPU Python unit tests: + +```bash +cmake --build build-pulse --target check-pulse +``` + +Both suites are also registered with `CTest`: + +```bash +ctest --test-dir build-pulse -L pulse --output-on-failure +``` + +GPU tests remain opt-in and require a compatible NVIDIA GPU and cuDensityMat. +With the GPU runtime enabled, validate dependency linkage and GPU descriptor +creation with: + +```bash +cmake --build build-gpu --target check-pulse-gpu +``` + +These checks validate SDK linkage and descriptor construction, then run the +numerical GPU tests for single-qubit drive evolution, T1 decay, +two-qubit XX coupling, and the public compile/JIT path. CMake enables numerical +tests only when `nvidia-smi` reports a GPU and the CUDA runtime can access at +least one device. Otherwise the target reports the tests disabled and, when +cuDensityMat is installed, retains the CPU-safe SDK linkage check. Passing this +suite is useful regression coverage, not a production numerical-accuracy +guarantee. + +## Build the documentation + +Install the documentation dependencies and enable the docs target at configure +time: + +```bash +python3 -m pip install \ + "myst-parser>=3.0" \ + nvidia-sphinx-theme==0.0.8 \ + "sphinx>=8.1,<8.3" + +cmake -S pulse -B build-pulse -G Ninja \ + -DCUDAQ_PULSE_BUILD_DOCS=ON +cmake --build build-pulse --target pulse-docs +``` + +The generated HTML is written to `build-pulse/docs/html` and uses NVIDIA's +Sphinx theme, matching the `QLX` documentation style. + +## Docker + +The package `Dockerfile` provides a turnkey compiler and Python environment. Run +the build from the CUDA-Q repository root so the full source tree is available +as context: + +```bash +docker build \ + --file pulse/docker/Dockerfile \ + --tag cudaq-pulse-preview \ + . +docker run --rm -it cudaq-pulse-preview +``` + +Inside the container, `cudaq-pulse-opt` and `cudaq_pulse` are already on the +search paths. The `pulse-ci` Docker target additionally runs the unit tests and +generates documentation for local validation. GitHub Actions runs the same +CMake targets directly on a plain runner, installing the `cudaq` and +`cudaq-devel` wheels rather than building any part of the CUDA-Q toolchain. + +CUDA-Q registers research preview roots in +`.github/research-preview-paths.txt`. Pull requests that change only registered +preview packages do not run stable CUDA-Q builds, packaging, macOS CI, `CodeQL`, +or spelling. Pulse changes instead run this package's license, test, and +documentation checks and inherit CUDA-Q's existing formatting job. A pull +request that also changes files outside the registered preview roots runs both +the preview-specific and stable suites. Trusted copy-PR branches, merge-queue +revisions, and `main` additionally run the numerical suite on CUDA-Q's GPU +runner whenever CMake detects an accessible NVIDIA GPU and CUDA runtime. + +## Current scope and limitations + +- The compiler, dialects, lowering experiments, and CPU unit tests are the + primary research surface. +- GPU evolution currently models two-level transmons in per-qubit rotating + frames. Target T1/T2 data, XX couplings, residual ZZ terms, and calibrated + drive-amplitude scaling are supported. Transmon anharmonicity remains target + metadata because a faithful leakage model requires three or more levels. +- GPU evolution supports the ``rk1``, ``rk2``, ``rk4``, ``magnus``, and + ``crank_nicolson`` integrators through the pulse frontend. + Readout/acquisition, observable evaluation, neutral-atom and multilevel + models, unspecialized parameters, arbitrary Python waveform callbacks, and + waveform algebra are rejected explicitly by the execution lowering. +- cuDensityMat integration is opt-in and requires a separately installed + compatible CUDA Toolkit, cuQuantum SDK, NVIDIA driver, and GPU. +- The GPU runtime and end-to-end numerical evolution are active research. + Regression tests exercise representative numerical paths, while results + have no production compatibility or accuracy commitment. +- There are no binary wheels, service-level guarantees, long-term API + guarantees, or product support commitments for this package. + +## Repository contents + +- `core/frontend/` — Python DSL, compiler pipeline, passes, targets, and + runtime +- `core/mlir/` — Pulse, `QOp`, and CuDensityMat dialects and transformations +- `core/runtime/` — experimental cuDensityMat runtime shim +- `cmake/` — dependency, Python staging, testing, and documentation modules +- `test/` — lit and `FileCheck` compiler regression tests +- `tests/` — pytest unit and workload tests +- `examples/` — pulse programming examples +- `docs/` — user, API, and architecture documentation +- `benchmarks/` — compiler and simulation benchmarks + +## License + +CUDA-Q pulse is covered by the CUDA-Q repository's +[Apache License 2.0](../LICENSE). diff --git a/pulse/cmake/CudaqPulseDependencies.cmake b/pulse/cmake/CudaqPulseDependencies.cmake new file mode 100644 index 00000000000..52a82c1aa6d --- /dev/null +++ b/pulse/cmake/CudaqPulseDependencies.cmake @@ -0,0 +1,73 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +include_guard(DIRECTORY) + +# Build-time dependency discovery for pulse: the optional GPU runtime, Python, +# and nanobind. MLIR/LLVM and the CUDA-Q CMake packages are resolved in the +# top-level CMakeLists.txt from the installed cudaq/cudaq-devel wheels. + +# Enable the optional GPU runtime whenever cuDensityMat is available. An +# explicitly provided SDK root is a request, so fail instead of silently +# falling back to the compiler-only package when that root is invalid. +set(_cudaq_pulse_cudm_requested FALSE) +if(CUDENSITYMAT_ROOT OR cuDensityMat_ROOT OR + NOT "$ENV{CUDENSITYMAT_ROOT}" STREQUAL "" OR + NOT "$ENV{cuDensityMat_ROOT}" STREQUAL "") + set(_cudaq_pulse_cudm_requested TRUE) +endif() + +if(NOT TARGET cuDensityMat::cuDensityMat) + if(_cudaq_pulse_cudm_requested) + find_package(cuDensityMat REQUIRED) + else() + # Only probe opportunistically when a CUDA toolkit is actually present; + # FindcuDensityMat requires CUDAToolkit to resolve its library suffixes. + find_package(CUDAToolkit QUIET) + if(CUDAToolkit_FOUND) + find_package(cuDensityMat QUIET) + endif() + endif() +endif() + +if(TARGET cuDensityMat::cuDensityMat) + find_package(CUDAToolkit REQUIRED) + message(STATUS + "CUDA-Q pulse GPU runtime enabled with cuDensityMat ${cuDensityMat_VERSION}") + add_subdirectory(core/runtime) +else() + message(STATUS + "CUDA-Q pulse GPU runtime disabled (cuDensityMat was not found)") +endif() + +# nanobind resolves its Python dependency through FindPython, whereas MLIR and +# the top-level project use FindPython3. Seed the former from the latter so both +# modules agree on a single interpreter. +if(NOT Python_EXECUTABLE) + set(Python_EXECUTABLE "${Python3_EXECUTABLE}") +endif() +find_package(Python 3.10 REQUIRED COMPONENTS Interpreter Development.Module) + +# nanobind ships its own CMake package inside the Python wheel. Ask the +# interpreter where it is, while still letting -Dnanobind_DIR override. +if(NOT nanobind_DIR) + execute_process( + COMMAND "${Python3_EXECUTABLE}" -m nanobind --cmake_dir + OUTPUT_VARIABLE _cudaq_pulse_nanobind_dir + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE _cudaq_pulse_nanobind_status + ERROR_QUIET) + if(NOT _cudaq_pulse_nanobind_status EQUAL 0) + message(FATAL_ERROR + "nanobind was not found in the active Python environment. Install it " + "with `pip install \"nanobind>=2.12\"`, or point at an existing CMake " + "package with -Dnanobind_DIR=.") + endif() + set(nanobind_DIR "${_cudaq_pulse_nanobind_dir}") +endif() +find_package(nanobind CONFIG REQUIRED) diff --git a/pulse/cmake/CudaqPulseDocumentation.cmake b/pulse/cmake/CudaqPulseDocumentation.cmake new file mode 100644 index 00000000000..b6407cabf60 --- /dev/null +++ b/pulse/cmake/CudaqPulseDocumentation.cmake @@ -0,0 +1,30 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +include_guard(DIRECTORY) + +option(CUDAQ_PULSE_BUILD_DOCS + "Build the CUDA-Q pulse research-preview docs" OFF) +if(NOT CUDAQ_PULSE_BUILD_DOCS) + return() +endif() + +get_filename_component(_cudaq_pulse_python_bin + "${Python_EXECUTABLE}" DIRECTORY) +find_program(CUDAQ_PULSE_SPHINX_EXECUTABLE NAMES sphinx-build + HINTS "${_cudaq_pulse_python_bin}" + REQUIRED) +add_custom_target(pulse-docs + COMMAND ${CMAKE_COMMAND} -E env + "PYTHONPATH=${CUDAQ_PULSE_PYTHONPATH}" + ${CUDAQ_PULSE_SPHINX_EXECUTABLE} -W --keep-going -b html + ${CUDAQ_PULSE_SOURCE_DIR}/docs + ${CUDAQ_PULSE_BINARY_DIR}/docs/html + DEPENDS _cudaq_pulse_native + COMMENT "Building CUDA-Q pulse documentation" + USES_TERMINAL) diff --git a/pulse/cmake/CudaqPulsePython.cmake b/pulse/cmake/CudaqPulsePython.cmake new file mode 100644 index 00000000000..a7ff586badd --- /dev/null +++ b/pulse/cmake/CudaqPulsePython.cmake @@ -0,0 +1,42 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +include_guard(DIRECTORY) + +include(CudaqPulseStagePython) + +# Match CUDA-Q's build-tree Python layout. One PYTHONPATH entry exposes both +# the staged Python sources and the native extension. +file(GLOB_RECURSE _cudaq_pulse_python_sources + RELATIVE "${CUDAQ_PULSE_FRONTEND_DIR}" + "${CUDAQ_PULSE_FRONTEND_DIR}/*.py" + "${CUDAQ_PULSE_FRONTEND_DIR}/*.pyi") +list(APPEND _cudaq_pulse_python_sources py.typed) + +cudaq_pulse_stage_python_sources(CudaqPulsePythonStaging + ROOT_DIR "${CUDAQ_PULSE_FRONTEND_DIR}" + OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/python/cudaq_pulse" + SOURCES ${_cudaq_pulse_python_sources}) +add_dependencies(_cudaq_pulse_native CudaqPulsePythonStaging) + +set(CUDAQ_PULSE_PYTHONPATH "${CMAKE_BINARY_DIR}/python") + +set(_cudaq_pulse_build_targets + cudaq-pulse-opt + _cudaq_pulse_native + CudaqPulsePythonStaging) +if(TARGET cudm_runtime) + list(APPEND _cudaq_pulse_build_targets cudm_runtime) +endif() +add_custom_target(pulse DEPENDS ${_cudaq_pulse_build_targets}) + +install(DIRECTORY "${CUDAQ_PULSE_FRONTEND_DIR}" + DESTINATION . + COMPONENT CudaqPulse + PATTERN "__pycache__" EXCLUDE + PATTERN "*.pyc" EXCLUDE) diff --git a/pulse/cmake/CudaqPulseStagePython.cmake b/pulse/cmake/CudaqPulseStagePython.cmake new file mode 100644 index 00000000000..96d0f0b362d --- /dev/null +++ b/pulse/cmake/CudaqPulseStagePython.cmake @@ -0,0 +1,40 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +include_guard(GLOBAL) + +# Stage python sources into the build tree, one symlink rule per file. +# +# Modeled on MLIR's add_mlir_python_sources_target minus its install and export +# machinery; these sources are installed through other mechanisms. This mirrors +# `cudaq_stage_python_sources` from the CUDA-Q source tree, which is not shipped +# in the cudaq-devel wheel. +function(cudaq_pulse_stage_python_sources name) + cmake_parse_arguments(ARG "" "ROOT_DIR;OUTPUT_DIRECTORY" "SOURCES" ${ARGN}) + if(ARG_UNPARSED_ARGUMENTS) + message(FATAL_ERROR "Unhandled arguments to cudaq_pulse_stage_python_sources(${name}): ${ARG_UNPARSED_ARGUMENTS}") + endif() + + set(_dest_paths "") + foreach(_rel_path ${ARG_SOURCES}) + set(_src_path "${ARG_ROOT_DIR}/${_rel_path}") + set(_dest_path "${ARG_OUTPUT_DIRECTORY}/${_rel_path}") + get_filename_component(_dest_dir "${_dest_path}" DIRECTORY) + file(MAKE_DIRECTORY "${_dest_dir}") + add_custom_command( + OUTPUT "${_dest_path}" + COMMENT "Staging python source ${_rel_path}" + DEPENDS "${_src_path}" + COMMAND "${CMAKE_COMMAND}" -E create_symlink + "${_src_path}" "${_dest_path}" + ) + list(APPEND _dest_paths "${_dest_path}") + endforeach() + + add_custom_target(${name} DEPENDS ${_dest_paths}) +endfunction() diff --git a/pulse/cmake/CudaqPulseTesting.cmake b/pulse/cmake/CudaqPulseTesting.cmake new file mode 100644 index 00000000000..1f4fdc05d7c --- /dev/null +++ b/pulse/cmake/CudaqPulseTesting.cmake @@ -0,0 +1,161 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +include_guard(DIRECTORY) + +option(CUDAQ_BUILD_TESTS "Build the CUDA-Q pulse test suites" ON) +if(NOT CUDAQ_BUILD_TESTS) + return() +endif() + +enable_testing() + +if(LLVM_RUNTIME_OUTPUT_INTDIR) + set(CUDAQ_PULSE_TOOLS_DIR "${LLVM_RUNTIME_OUTPUT_INTDIR}") +else() + set(CUDAQ_PULSE_TOOLS_DIR + "${CUDAQ_PULSE_BINARY_DIR}/core/mlir/tools/cudaq-pulse-opt") +endif() +configure_file( + ${CUDAQ_PULSE_SOURCE_DIR}/test/lit.site.cfg.py.in + ${CUDAQ_PULSE_BINARY_DIR}/test/lit.site.cfg.py + @ONLY) + +# Locate the lit launcher from the configured interpreter's own environment. +# lit ships only as a console script (there is no runnable `lit.__main__`), and +# the cudaq-devel wheel's bundled `llvm-lit` carries a shebang pointing at its +# build interpreter, which is absent here. Restrict the search to the +# interpreter's bin directory and run the launcher through that interpreter, so +# the launcher and its shebang always match the installed wheels. +get_filename_component(_cudaq_pulse_python_bindir "${Python_EXECUTABLE}" DIRECTORY) +find_program(CUDAQ_PULSE_LIT_EXECUTABLE NAMES lit llvm-lit + HINTS "${_cudaq_pulse_python_bindir}" + NO_DEFAULT_PATH + REQUIRED) +set(CUDAQ_PULSE_LIT_PYTHONPATH "" CACHE PATH + "Optional Python module path for the LLVM lit launcher") + +add_custom_target(check-pulse-mlir + COMMAND ${CMAKE_COMMAND} -E env + "PYTHONPATH=${CUDAQ_PULSE_LIT_PYTHONPATH}" + ${Python_EXECUTABLE} ${CUDAQ_PULSE_LIT_EXECUTABLE} -sv + ${CUDAQ_PULSE_BINARY_DIR}/test + DEPENDS cudaq-pulse-opt + COMMENT "Running CUDA-Q pulse MLIR tests" + USES_TERMINAL) + +add_custom_target(check-pulse-python + COMMAND ${CMAKE_COMMAND} -E env + "PYTHONPATH=${CUDAQ_PULSE_PYTHONPATH}" + ${Python_EXECUTABLE} -m pytest + ${CUDAQ_PULSE_SOURCE_DIR}/tests -m "not gpu" -q + DEPENDS _cudaq_pulse_native + COMMENT "Running CUDA-Q pulse Python unit tests" + USES_TERMINAL) + +add_custom_target(check-pulse + DEPENDS check-pulse-mlir check-pulse-python) + +add_test(NAME PulsePythonUnitTests + COMMAND ${CMAKE_COMMAND} -E env + "PYTHONPATH=${CUDAQ_PULSE_PYTHONPATH}" + ${Python_EXECUTABLE} -m pytest + ${CUDAQ_PULSE_SOURCE_DIR}/tests -m "not gpu" -q) +set_tests_properties(PulsePythonUnitTests PROPERTIES LABELS "pulse;unit") + +add_test(NAME PulseMLIRTests + COMMAND ${CMAKE_COMMAND} -E env + "PYTHONPATH=${CUDAQ_PULSE_LIT_PYTHONPATH}" + ${Python_EXECUTABLE} ${CUDAQ_PULSE_LIT_EXECUTABLE} -sv + ${CUDAQ_PULSE_BINARY_DIR}/test) +set_tests_properties(PulseMLIRTests PROPERTIES LABELS "pulse;mlir") + +if(TARGET cudm_runtime) + # This private CTest driver is not installed. Without --gpu it verifies SDK + # linkage/version discovery; with --gpu it creates and destroys the basic + # cuDensityMat descriptors used by the preview runtime. + add_executable(cudaq-pulse-cudm-smoke + ${CUDAQ_PULSE_SOURCE_DIR}/tests/runtime/cudm_runtime_smoke.cpp) + target_link_libraries(cudaq-pulse-cudm-smoke PRIVATE + cudm_runtime + CUDA::cudart) + set_target_properties(cudaq-pulse-cudm-smoke PROPERTIES + BUILD_RPATH "$") + + add_test(NAME PulseCuDensityMatLinkSmoke + COMMAND cudaq-pulse-cudm-smoke) + set_tests_properties(PulseCuDensityMatLinkSmoke PROPERTIES + LABELS "pulse;gpu-build") + + set(_cudaq_pulse_gpu_available FALSE) + find_program(CUDAQ_PULSE_NVIDIA_SMI_EXECUTABLE NAMES nvidia-smi) + if(CUDAQ_PULSE_NVIDIA_SMI_EXECUTABLE AND TARGET CUDA::cudart AND + NOT CMAKE_CROSSCOMPILING) + execute_process( + COMMAND ${CUDAQ_PULSE_NVIDIA_SMI_EXECUTABLE} -L + RESULT_VARIABLE _cudaq_pulse_nvidia_smi_result + OUTPUT_VARIABLE _cudaq_pulse_nvidia_smi_output + ERROR_QUIET + OUTPUT_STRIP_TRAILING_WHITESPACE) + if(_cudaq_pulse_nvidia_smi_result EQUAL 0 AND + _cudaq_pulse_nvidia_smi_output MATCHES "GPU [0-9]+:") + try_run( + _cudaq_pulse_cuda_probe_result + _cudaq_pulse_cuda_probe_compiled + ${CMAKE_CURRENT_BINARY_DIR}/cuda-device-probe + SOURCES + ${CUDAQ_PULSE_SOURCE_DIR}/tests/runtime/cuda_device_probe.cpp + LINK_LIBRARIES CUDA::cudart) + if(_cudaq_pulse_cuda_probe_compiled AND + _cudaq_pulse_cuda_probe_result EQUAL 0) + set(_cudaq_pulse_gpu_available TRUE) + endif() + endif() + endif() + + if(_cudaq_pulse_gpu_available) + message(STATUS "CUDA-Q pulse numerical GPU tests enabled") + + add_test(NAME PulseCuDensityMatGpuSmoke + COMMAND cudaq-pulse-cudm-smoke --gpu) + set_tests_properties(PulseCuDensityMatGpuSmoke PROPERTIES + LABELS "pulse;gpu" + SKIP_RETURN_CODE 77) + + add_test(NAME PulsePythonGpuTests + COMMAND ${CMAKE_COMMAND} -E env + "PYTHONPATH=${CUDAQ_PULSE_PYTHONPATH}" + "CUDAQ_PULSE_BUILD_DIR=${CMAKE_BINARY_DIR}" + "CUDAQ_PULSE_LLVM_BIN=${LLVM_TOOLS_BINARY_DIR}" + ${Python_EXECUTABLE} -m pytest + ${CUDAQ_PULSE_SOURCE_DIR}/tests/runtime -m gpu -q) + set_tests_properties(PulsePythonGpuTests PROPERTIES LABELS "pulse;gpu") + + add_custom_target(check-pulse-gpu + COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure + -L gpu + DEPENDS cudaq-pulse-cudm-smoke _cudaq_pulse_native + COMMENT "Running CUDA-Q pulse cuDensityMat and numerical GPU tests" + USES_TERMINAL) + else() + message(STATUS + "CUDA-Q pulse numerical GPU tests disabled (no usable NVIDIA GPU/CUDA runtime)") + add_custom_target(check-pulse-gpu + COMMAND ${CMAKE_CTEST_COMMAND} --output-on-failure + -R PulseCuDensityMatLinkSmoke + DEPENDS cudaq-pulse-cudm-smoke + COMMENT "GPU unavailable; running CUDA-Q pulse SDK linkage test only" + USES_TERMINAL) + endif() + add_dependencies(check-pulse check-pulse-gpu) +else() + add_custom_target(check-pulse-gpu + COMMAND ${CMAKE_COMMAND} -E echo + "CUDA-Q pulse GPU tests disabled: CUDA/cuDensityMat not available" + COMMENT "CUDA-Q pulse GPU tests are disabled") +endif() diff --git a/pulse/cmake/FindcuDensityMat.cmake b/pulse/cmake/FindcuDensityMat.cmake new file mode 100644 index 00000000000..27d7744bcdb --- /dev/null +++ b/pulse/cmake/FindcuDensityMat.cmake @@ -0,0 +1,69 @@ +# ============================================================================ # +# Copyright (c) 2022 - 2025 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +set(_cudensitymat_hints + "${cuDensityMat_ROOT}" + "${CUDENSITYMAT_ROOT}" + "$ENV{cuDensityMat_ROOT}" + "$ENV{CUDENSITYMAT_ROOT}" + "$ENV{CUQUANTUM_INSTALL_PREFIX}" + "$ENV{CUDA_PATH}" + "/usr/local" + "/usr/local/cuda" + "/usr" +) + +if(NOT DEFINED CUDAToolkit_VERSION) + find_package(CUDAToolkit REQUIRED) +endif() + +find_path(cuDensityMat_INCLUDE_DIR + NAMES cudensitymat.h + HINTS ${_cudensitymat_hints} + PATH_SUFFIXES include +) + +find_library(cuDensityMat_LIBRARY + NAMES cudensitymat libcudensitymat.so.0 + HINTS ${_cudensitymat_hints} + PATH_SUFFIXES lib lib/${CUDAToolkit_VERSION_MAJOR} lib64 lib64/${CUDAToolkit_VERSION_MAJOR} +) + +if(cuDensityMat_INCLUDE_DIR AND EXISTS "${cuDensityMat_INCLUDE_DIR}/cudensitymat.h") + file(READ "${cuDensityMat_INCLUDE_DIR}/cudensitymat.h" _cm_hdr) + string(REGEX MATCH "CUDENSITYMAT_MAJOR ([0-9]*)" _ ${_cm_hdr}) + set(CUDENSITYMAT_MAJOR ${CMAKE_MATCH_1}) + string(REGEX MATCH "CUDENSITYMAT_MINOR ([0-9]*)" _ ${_cm_hdr}) + set(CUDENSITYMAT_MINOR ${CMAKE_MATCH_1}) + string(REGEX MATCH "CUDENSITYMAT_PATCH ([0-9]*)" _ ${_cm_hdr}) + set(CUDENSITYMAT_PATCH ${CMAKE_MATCH_1}) + set(cuDensityMat_VERSION ${CUDENSITYMAT_MAJOR}.${CUDENSITYMAT_MINOR}.${CUDENSITYMAT_PATCH}) + + set(_cudm_min_cuda "11") + set(_cudm_max_cuda "13") + if(CUDAToolkit_VERSION_MAJOR VERSION_LESS _cudm_min_cuda OR + CUDAToolkit_VERSION_MAJOR VERSION_GREATER _cudm_max_cuda) + message(FATAL_ERROR + "cuDensityMat ${cuDensityMat_VERSION} supports CUDA >= ${_cudm_min_cuda} " + "and <= ${_cudm_max_cuda}, but found CUDA ${CUDAToolkit_VERSION}") + endif() +endif() + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(cuDensityMat + REQUIRED_VARS cuDensityMat_INCLUDE_DIR cuDensityMat_LIBRARY + VERSION_VAR cuDensityMat_VERSION +) + +if(cuDensityMat_FOUND AND NOT TARGET cuDensityMat::cuDensityMat) + add_library(cuDensityMat::cuDensityMat UNKNOWN IMPORTED) + set_target_properties(cuDensityMat::cuDensityMat PROPERTIES + IMPORTED_LOCATION "${cuDensityMat_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${cuDensityMat_INCLUDE_DIR}" + ) +endif() diff --git a/pulse/core/frontend/cudaq_pulse/__init__.py b/pulse/core/frontend/cudaq_pulse/__init__.py new file mode 100644 index 00000000000..ea439a84309 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/__init__.py @@ -0,0 +1,114 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""cudaq-pulse: pulse-level quantum programming on MLIR. + +Usage:: + + import cudaq_pulse as pulse + + @pulse.kernel + def rabi(qubit): + drive_line, tone = get_drive_line(qubit) + drive(drive_line, gaussian(64, 0.5, 16.0), tone) + + compiled_kernel = pulse.compile(rabi, [pulse.qudit_ref()], + qubit_freq_hz={0: 5.0e9}) + +The compiler recognizes the kernel DSL vocabulary (``drive``, ``gaussian``, +``get_drive_line``, etc.) as bare names inside ``@pulse.kernel`` functions. +""" + +from __future__ import annotations + +__version__ = "0.1.0" + +# ── Infrastructure (accessed via pulse.*) ──────────────────────────────── + +from .kernel import kernel as kernel +from .kernel.decorator import qudit_ref as qudit_ref +from .kernel.decorator import qvec_ref as qvec_ref +from .kernel.decorator import QuditRef as QuditRef +from .kernel.decorator import QvecRef as QvecRef +from .compile import compile as compile +from .compile import CompiledKernel as CompiledKernel +from .compile import CompileMetrics as CompileMetrics +from .kernel.ir_builder import Parameter as Parameter +from .kernel.ir_builder import CompilationError as CompilationError +from .runtime.evolve import EvolveResult as EvolveResult +from .runtime.evolve import evolve as evolve + +# ── Kernel DSL ops (injected as bare names into importer's namespace) ──── + +from .ops import get_drive_line as get_drive_line +from .ops import get_readout_line as get_readout_line +from .ops import drive as drive +from .ops import readout as readout +from .ops import wait as wait +from .ops import sync as sync +from .ops import shift_phase as shift_phase +from .ops import set_phase as set_phase +from .ops import shift_frequency as shift_frequency +from .ops import set_frequency as set_frequency +from .ops import gaussian as gaussian +from .ops import square as square +from .ops import drag as drag +from .ops import cosine as cosine +from .ops import tanh_ramp as tanh_ramp +from .ops import gaussian_square as gaussian_square +from .ops import custom as custom +from .ops import custom_samples as custom_samples +from .ops import wf_add as wf_add +from .ops import wf_sub as wf_sub +from .ops import wf_mul as wf_mul +from .ops import wf_scale as wf_scale +from .ops import wf_neg as wf_neg + +# DSL names that get injected into the importing module's globals. +_DSL_EXPORTS: list[str] = [ + "get_drive_line", + "get_readout_line", + "drive", + "readout", + "wait", + "sync", + "shift_phase", + "set_phase", + "shift_frequency", + "set_frequency", + "gaussian", + "square", + "drag", + "cosine", + "tanh_ramp", + "gaussian_square", + "custom", + "custom_samples", + "wf_add", + "wf_sub", + "wf_mul", + "wf_scale", + "wf_neg", +] + +__all__: list[str] = [ + # infrastructure + "kernel", + "compile", + "CompiledKernel", + "CompileMetrics", + "Parameter", + "CompilationError", + "EvolveResult", + "evolve", + "qudit_ref", + "qvec_ref", + "QuditRef", + "QvecRef", + # kernel DSL ops + *_DSL_EXPORTS, +] diff --git a/pulse/core/frontend/cudaq_pulse/__init__.pyi b/pulse/core/frontend/cudaq_pulse/__init__.pyi new file mode 100644 index 00000000000..e87fa5d5cdc --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/__init__.pyi @@ -0,0 +1,344 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Type stub for cudaq_pulse -- provides IDE autocomplete and hover docs.""" + +from __future__ import annotations + +from typing import Any, Callable, Optional, Sequence, Union + +Numeric = Union[int, float, "Parameter"] + +__version__: str + + +class CompilationError(Exception): + ... + + +# ── Opaque DSL types ───────────────────────────────────────────────────── + + +class Waveform: + """Opaque waveform handle returned by envelope constructors.""" + + ... + + +class Line: + """Opaque drive or readout line handle.""" + + ... + + +class Tone: + """Opaque tone handle for phase and frequency operations.""" + + ... + + +class MeasurementResult: + """Opaque measurement result from ``readout()``.""" + + ... + + +class Parameter: + """Sentinel for a symbolic kernel parameter (compile-once, evaluate-many). + + Parameters are automatically created by ``compile()`` for non-qudit + kernel arguments. They can be passed directly to pulse ops like + ``gaussian(64, amplitude, 16.0)`` where ``amplitude`` is a Parameter. + """ + + name: str + index: int + dtype: str + + def __init__(self, name: str, index: int, dtype: str = "f64") -> None: + ... + + +# ── Infrastructure ─────────────────────────────────────────────────────── + + +class QuditRef: + """A single qudit reference representing one quantum degree of freedom.""" + + def __init__(self, index: int | None = None) -> None: + ... + + @property + def index(self) -> int | None: + ... + + +class QvecRef: + """A fixed-size vector of qudit references.""" + + def __init__(self, size: int) -> None: + ... + + def __len__(self) -> int: + ... + + def __getitem__(self, index: int) -> QuditRef: + ... + + +def qudit_ref(index: int | None = None) -> QuditRef: + """Create a qudit reference, optionally bound to a physical index.""" + ... + + +def qvec_ref(size: int) -> QvecRef: + """Create a vector of *size* qudit references.""" + ... + + +def kernel(fn: Callable[..., Any]) -> Callable[..., Any]: + """Decorator that marks a Python function as a pulse kernel. + + The decorated function is traced via bytecode capture when called, + producing an intermediate representation that can be compiled to + MLIR with ``pulse.compile()``. + """ + ... + + +class CompileMetrics: + """Per-stage timing breakdown (all values in milliseconds).""" + + trace_ms: float + ffi_ms: float + passes_ms: float + schedule_ms: float + total_ms: float + op_count: int + + +class CompiledKernel: + """Result of ``pulse.compile()``. + + For parametric kernels, call the instance to evaluate at concrete + values: ``compiled(amplitude=0.5)`` or ``compiled(0.5)``. + """ + + metrics: CompileMetrics + + @property + def mlir(self) -> str: + """Return the MLIR text representation of the compiled module.""" + ... + + @property + def parameters(self) -> list[str]: + """Names of symbolic parameters (empty for concrete kernels).""" + ... + + @property + def is_parametric(self) -> bool: + """True if this kernel has symbolic parameters.""" + ... + + def __call__(self, *args: float | int, + **kwargs: float | int) -> "CompiledKernel": + """Evaluate a parametric kernel at concrete values. + + Returns a new fully-scheduled CompiledKernel. + """ + ... + + def lower_to_llvm(self) -> str: + """Lower the scheduled pulse module to LLVM IR.""" + ... + + def run(self, + *, + entry: str = "main", + n_qubits: int | None = None) -> list[Any]: + """Execute the compiled pulse module with cuDensityMat.""" + ... + + +class EvolveResult: + """Final state and integration-time metadata from ``evolve``.""" + + final_state: Any + times: Any + expectation_values: dict[str, Any] | None + + +def evolve( + program: Any, + *, + target: Any, + t_start: float, + t_end: float, + num_steps: int, + integrator: str = "rk4", + clock_ghz: float = 2.0, + observables: dict[str, Any] | None = None, +) -> EvolveResult: + """Compile and evolve a pulse program on the cuDensityMat GPU path.""" + ... + + +def compile( + kernel_fn: Callable[..., Any], + args: Sequence[Any], + *, + qubit_freq_hz: dict[int, float] | None = None, + passes: Sequence[str] | None = None, + schedule: str = "alap", +) -> CompiledKernel: + """Compile a ``@pulse.kernel`` function into a scheduled MLIR module. + + Args: + kernel_fn: A ``@pulse.kernel``-decorated function. + args: Positional arguments (typically ``pulse.qudit_ref()`` objects). + qubit_freq_hz: Mapping of qubit index to frequency in Hz. + passes: Optimization passes to run (default: verify, virtual_z, fusion). + schedule: Scheduling strategy (``"alap"``, ``"asap"``, ``"rcp"``). + + Returns: + A ``CompiledKernel`` with MLIR text and compile metrics. + """ + ... + + +# ── Kernel DSL: channel access ─────────────────────────────────────────── + + +def get_drive_line(qubit: QuditRef) -> tuple[Line, Tone]: + """Obtain the drive line and tone for a qubit.""" + ... + + +def get_readout_line(qubit: QuditRef) -> tuple[Line, Tone]: + """Obtain the readout line and tone for a qubit.""" + ... + + +# ── Kernel DSL: scheduling ops ────────────────────────────────────────── + + +def drive(line: Line, waveform: Waveform, tone: Tone) -> None: + """Play a waveform on a drive line.""" + ... + + +def readout(line: Line, waveform: Waveform, tone: Tone) -> MeasurementResult: + """Acquire a measurement through a readout line.""" + ... + + +def wait(target: Line, duration: Numeric) -> None: + """Insert an idle delay on a line.""" + ... + + +def sync(*targets: Line) -> None: + """Synchronize multiple lines to a common time point.""" + ... + + +# ── Kernel DSL: phase / frequency ─────────────────────────────────────── + + +def shift_phase(tone: Tone, phase: Numeric) -> Tone: + """Add a relative phase offset to a tone's rotating frame.""" + ... + + +def set_phase(tone: Tone, phase: Numeric) -> Tone: + """Set the absolute phase of a tone's rotating frame.""" + ... + + +def shift_frequency(tone: Tone, frequency: Numeric) -> Tone: + """Add a relative frequency offset to a tone.""" + ... + + +def set_frequency(tone: Tone, frequency: Numeric) -> Tone: + """Set the absolute frequency of a tone.""" + ... + + +# ── Kernel DSL: waveform constructors ─────────────────────────────────── + + +def gaussian(duration: Numeric, amplitude: Numeric, sigma: Numeric) -> Waveform: + """Create a Gaussian envelope waveform.""" + ... + + +def square(duration: Numeric, amplitude: Numeric) -> Waveform: + """Create a flat-top (square) envelope waveform.""" + ... + + +def drag(duration: Numeric, amplitude: Numeric, sigma: Numeric, + beta: Numeric) -> Waveform: + """Create a DRAG waveform.""" + ... + + +def cosine(duration: Numeric, amplitude: Numeric) -> Waveform: + """Create a raised-cosine envelope waveform.""" + ... + + +def tanh_ramp(duration: Numeric, amplitude: Numeric, + sigma: Numeric) -> Waveform: + """Create a hyperbolic-tangent ramp waveform.""" + ... + + +def gaussian_square(duration: Numeric, amplitude: Numeric, sigma: Numeric, + width: Numeric) -> Waveform: + """Create a Gaussian-square (flat-top Gaussian) waveform.""" + ... + + +def custom(duration: int, envelope_fn: Callable[..., complex]) -> Waveform: + """Create a waveform from a callable envelope function.""" + ... + + +def custom_samples(samples: Sequence[float]) -> Waveform: + """Create a waveform from pre-computed sample data.""" + ... + + +# ── Kernel DSL: waveform arithmetic ───────────────────────────────────── + + +def wf_add(left: Waveform, right: Waveform) -> Waveform: + """Add two waveforms element-wise.""" + ... + + +def wf_sub(left: Waveform, right: Waveform) -> Waveform: + """Subtract two waveforms element-wise.""" + ... + + +def wf_mul(left: Waveform, right: Waveform) -> Waveform: + """Multiply two waveforms element-wise.""" + ... + + +def wf_scale(scalar: float, waveform: Waveform) -> Waveform: + """Scale a waveform by a constant factor.""" + ... + + +def wf_neg(waveform: Waveform) -> Waveform: + """Negate a waveform.""" + ... diff --git a/pulse/core/frontend/cudaq_pulse/_native/__init__.py b/pulse/core/frontend/cudaq_pulse/_native/__init__.py new file mode 100644 index 00000000000..9fcdb56c1de --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/_native/__init__.py @@ -0,0 +1,29 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Thin wrapper around the C++ nanobind extension. + +The native extension is required for MLIR dialect bindings. If it is not +built, importing this module raises ``ImportError`` with an actionable +message. Set ``CUDAQ_PULSE_ALLOW_NO_NATIVE=1`` to suppress the error +during pure-Python development/testing only. +""" + +from __future__ import annotations + +import os + +try: + from ._cudaq_pulse_native import * # noqa: F401,F403 +except ImportError: + if os.environ.get("CUDAQ_PULSE_ALLOW_NO_NATIVE", + "0") not in ("1", "true", "yes"): + raise ImportError( + "cudaq-pulse native extension (_cudaq_pulse_native) is not available. " + "Configure and build the pulse project " + "(cmake -S pulse -B build-pulse), or set " + "CUDAQ_PULSE_ALLOW_NO_NATIVE=1 for pure-Python development mode.") diff --git a/pulse/core/frontend/cudaq_pulse/compile.py b/pulse/core/frontend/cudaq_pulse/compile.py new file mode 100644 index 00000000000..4255086e155 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/compile.py @@ -0,0 +1,459 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Public compile entry point for cudaq-pulse. + +``cudaq_pulse.compile()`` is the single public way to turn a +``@cudaq_pulse.kernel`` into a scheduled, MLIR-backed compilation +artifact. Native bindings are **required** -- there is no fallback. +""" + +from __future__ import annotations + +import time +import inspect +from dataclasses import dataclass, field +from typing import Any, Optional, Sequence, Tuple + +try: + from ._native._cudaq_pulse_native import PulseModuleBuilder +except ImportError as _e: + raise RuntimeError( + "cudaq-pulse native bindings required. " + "Configure and build the pulse project (cmake -S pulse -B build-pulse); " + "see pulse/README.md.") from _e + +from .kernel.ir_builder import Parameter +from .passes.scheduling import ScheduleMetrics, MachineModel + +_VALID_SCHEDULES = frozenset({"asap", "alap", "rcp", "alap_rcp"}) + +# C++ pass names for the full pipeline +_PASS_MAP = { + "verify": "pulse-verify", + "canonicalize": "pulse-canonicalize", + "virtual_z": "pulse-virtual-z", + "fusion": "pulse-fusion", + "licm": "loop-invariant-code-motion", +} + +DEFAULT_PASSES: Tuple[str, ...] = ( + "verify", + "virtual_z", + "fusion", +) + +# --------------------------------------------------------------------------- +# CompiledKernel +# --------------------------------------------------------------------------- + + +@dataclass() +class CompileMetrics: + """Per-stage timing breakdown (all values in milliseconds).""" + + trace_ms: float = 0.0 + ffi_ms: float = 0.0 + passes_ms: float = 0.0 + schedule_ms: float = 0.0 + total_ms: float = 0.0 + op_count: int = 0 + # Legacy fields for backward compat with benchmarks + capture_ms: float = 0.0 + lower_ms: float = 0.0 + mlir_emit_ms: float = 0.0 + schedule_metrics: Optional[ScheduleMetrics] = None + + +@dataclass() +class CompiledKernel: + """Result of ``cudaq_pulse.compile()``. + + Holds an in-memory ``PulseModule`` (MLIR ModuleOp) and provides + access to MLIR text, lowering, and GPU execution. + + For parametric kernels, call the instance to evaluate at concrete + values: ``compiled(amplitude=0.5)`` or ``compiled(0.5)``. + """ + + _pulse_module: Any = field(default=None, repr=False) + _mlir_text: Optional[str] = field(default=None, repr=False) + metrics: CompileMetrics = field(default_factory=CompileMetrics) + _param_names: list = field(default_factory=list, repr=False) + _param_types: list = field(default_factory=list, repr=False) + _schedule: str = field(default="alap", repr=False) + _machine: MachineModel = field(default_factory=MachineModel, repr=False) + _n_qubits: int = field(default=1, repr=False) + + @property + def mlir(self) -> str: + """Pulse-dialect MLIR text.""" + if self._mlir_text is None and self._pulse_module is not None: + self._mlir_text = self._pulse_module.print() + return self._mlir_text or "" + + @property + def module(self): + """The in-memory PulseModule.""" + return self._pulse_module + + @property + def parameters(self) -> list[str]: + """Names of symbolic parameters (empty for concrete kernels).""" + return list(self._param_names) + + @property + def is_parametric(self) -> bool: + return bool(self._param_names) + + def __call__(self, *args, **kwargs) -> "CompiledKernel": + """Evaluate a parametric kernel at concrete values. + + Accepts positional or keyword arguments matching the parameter names. + Returns a new fully-scheduled ``CompiledKernel`` with concrete MLIR. + + Raises ``TypeError`` for non-parametric kernels or argument mismatches. + """ + if not self._param_names: + raise TypeError( + "This kernel has no parameters. " + "Only parametric kernels can be evaluated with (...).") + + values = self._resolve_args(args, kwargs) + + f64_vals: list[float] = [] + i64_vals: list[int] = [] + for val, dtype in zip(values, self._param_types): + if dtype == "i64": + i64_vals.append(int(val)) + else: + f64_vals.append(float(val)) + + t0 = time.perf_counter() + new_module = self._pulse_module.specialize( + f64_vals, + i64_vals, + self._schedule, + self._machine.max_concurrent_drives, + self._machine.max_concurrent_readouts, + int(self._machine.readout_latency_vtu), + int(self._machine.line_switch_penalty_vtu), + ) + specialize_ms = (time.perf_counter() - t0) * 1000 + + return CompiledKernel( + _pulse_module=new_module, + metrics=CompileMetrics(schedule_ms=specialize_ms, + total_ms=specialize_ms), + _schedule=self._schedule, + _machine=self._machine, + _n_qubits=self._n_qubits, + ) + + def _resolve_args(self, args: tuple, kwargs: dict) -> list: + """Merge positional and keyword args into an ordered value list.""" + if args and kwargs: + raise TypeError( + "Cannot mix positional and keyword arguments. " + "Use either compiled(0.5, 64) or compiled(amplitude=0.5, duration=64)." + ) + if kwargs: + values = [] + for name in self._param_names: + if name not in kwargs: + raise TypeError( + f"Missing parameter {name!r}. Required: {', '.join(self._param_names)}" + ) + values.append(kwargs[name]) + extra = set(kwargs) - set(self._param_names) + if extra: + raise TypeError( + f"Unknown parameters: {', '.join(sorted(extra))}. " + f"Available: {', '.join(self._param_names)}") + return values + if len(args) != len(self._param_names): + raise TypeError( + f"Expected {len(self._param_names)} arguments " + f"({', '.join(self._param_names)}), got {len(args)}") + return list(args) + + def lower_to_llvm(self) -> str: + """Run full MLIR lowering (pulse -> qop -> cudm -> llvm).""" + if self._pulse_module is not None: + return self._pulse_module.run_full_lowering() + raise RuntimeError("No PulseModule available") + + def run(self, *, entry: str = "main", n_qubits: Optional[int] = None): + """JIT-compile and execute on GPU via cuDensityMat.""" + from .runtime.jit import compile_and_run_pulse + + if n_qubits is not None and n_qubits <= 0: + raise ValueError("n_qubits must be positive") + return compile_and_run_pulse(self.mlir, + entry=entry, + n_qubits=n_qubits or self._n_qubits) + + +# --------------------------------------------------------------------------- +# compile() +# --------------------------------------------------------------------------- + + +def compile( + kernel_fn, + args: Sequence[Any] | None = None, + *, + clock_ghz: float = 2.0, + qubit_freq_hz: dict[int, float] | None = None, + schedule: str = "alap", + passes: Sequence[str] | None = None, + machine: MachineModel | None = None, +) -> CompiledKernel: + """Compile a ``@cudaq_pulse.kernel`` into a scheduled MLIR module. + + This is the only public compilation entry point. It traces the kernel + directly into a packed int64 buffer, sends it to C++ in a single + zero-copy FFI call, and runs all passes on the in-memory MLIR module. + + Parameters + ---------- + kernel_fn: + A ``@cudaq_pulse.kernel``-decorated function. + args: + Arguments (typically ``qudit_ref()`` objects). + clock_ghz: + System clock frequency in GHz. + qubit_freq_hz: + Mapping from qubit index to frequency in Hz. + schedule: + Scheduling policy: ``"asap"``, ``"alap"``, ``"rcp"``, ``"alap_rcp"``. + passes: + Optimization passes to run. Default is ``DEFAULT_PASSES``. + Pass ``()`` to skip. + machine: + Machine model for resource-constrained scheduling. + """ + if schedule not in _VALID_SCHEDULES: + raise ValueError(f"Unknown schedule policy {schedule!r}. " + f"Choose from: {', '.join(sorted(_VALID_SCHEDULES))}") + + if passes is None: + passes = DEFAULT_PASSES + unknown_passes = sorted(set(passes) - set(_PASS_MAP)) + if unknown_passes: + raise ValueError("Unknown pulse passes: " + ", ".join(unknown_passes)) + + freq = qubit_freq_hz or {} + metrics = CompileMetrics() + machine = machine or MachineModel() + if machine.max_concurrent_drives <= 0: + raise ValueError("machine.max_concurrent_drives must be positive") + if machine.max_concurrent_readouts <= 0: + raise ValueError("machine.max_concurrent_readouts must be positive") + if machine.readout_latency_vtu < 0: + raise ValueError("machine.readout_latency_vtu cannot be negative") + if machine.line_switch_penalty_vtu < 0: + raise ValueError("machine.line_switch_penalty_vtu cannot be negative") + + if not callable(kernel_fn): + raise TypeError( + f"Expected a @cudaq_pulse.kernel function; got {type(kernel_fn).__name__}" + ) + + if args is None: + raise TypeError( + "compile() requires args= when passing a kernel function. " + "e.g. compile(my_kernel, [qudit_ref(), qudit_ref()], ...)") + + # ``args`` binds the leading Python parameters positionally. Any omitted + # trailing parameters are compiled symbolically. + fn = getattr(kernel_fn, "__wrapped__", kernel_fn) + signature = inspect.signature(fn) + parameters = list(signature.parameters.values()) + unsupported = [ + p.name + for p in parameters + if p.kind not in (inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + if unsupported: + raise TypeError("Pulse kernels do not support variadic or keyword-only " + "parameters: " + ", ".join(unsupported)) + if len(args) > len(parameters): + raise TypeError( + f"{fn.__name__}() takes {len(parameters)} arguments, but compile() received {len(args)}" + ) + missing_params = parameters[len(args):] + + if missing_params: + return _compile_parametric(fn, list(args), missing_params, clock_ghz, + freq, passes, schedule, machine, metrics) + + # Step 1: Trace kernel directly into packed buffer + t0 = time.perf_counter() + buf, n_qubits, freq_arr, op_count = _trace_to_packed( + kernel_fn, args, clock_ghz, freq) + metrics.trace_ms = (time.perf_counter() - t0) * 1000 + metrics.capture_ms = metrics.trace_ms + metrics.op_count = op_count + + # Step 2: Single FFI call -- build in-memory MLIR module + t0 = time.perf_counter() + builder = PulseModuleBuilder() + pulse_module = builder.build_from_packed(buf, clock_ghz, n_qubits, freq_arr) + metrics.ffi_ms = (time.perf_counter() - t0) * 1000 + metrics.mlir_emit_ms = metrics.ffi_ms + + # Step 3: Run C++ passes on the in-memory module + t0 = time.perf_counter() + cpp_passes = [_PASS_MAP[p] for p in passes if p in _PASS_MAP] + if cpp_passes: + pulse_module.run_passes(cpp_passes) + metrics.passes_ms = (time.perf_counter() - t0) * 1000 + + # Step 4: Schedule via C++ pass + t0 = time.perf_counter() + pulse_module.schedule( + schedule, + machine.max_concurrent_drives, + machine.max_concurrent_readouts, + int(machine.readout_latency_vtu), + int(machine.line_switch_penalty_vtu), + ) + metrics.schedule_ms = (time.perf_counter() - t0) * 1000 + + metrics.total_ms = metrics.trace_ms + metrics.ffi_ms + metrics.passes_ms + metrics.schedule_ms + + return CompiledKernel( + _pulse_module=pulse_module, + metrics=metrics, + _schedule=schedule, + _machine=machine, + _n_qubits=n_qubits, + ) + + +# --------------------------------------------------------------------------- +# Internal +# --------------------------------------------------------------------------- + + +def _compile_parametric( + fn, + bound_args: list, + missing_params: list[inspect.Parameter], + clock_ghz: float, + freq: dict[int, float], + passes: Sequence[str], + schedule: str, + machine: MachineModel, + metrics: CompileMetrics, +) -> CompiledKernel: + """Compile a parametric kernel (compile-once, evaluate-many).""" + from .kernel.packed_ir_builder import PackedIRBuilder + from .kernel.bytecode_bridge import _trace_kernel_with_builder + + # Create Parameter sentinels for each non-qudit argument + param_sentinels = [] + for i, parameter in enumerate(missing_params): + annotation = parameter.annotation + dtype = "unknown" + if annotation is int or annotation == "int": + dtype = "i64" + elif annotation is float or annotation == "float": + dtype = "f64" + param_sentinels.append(Parameter(parameter.name, i, dtype)) + + # Trace with Parameter sentinels in place of concrete values + t0 = time.perf_counter() + name = getattr(fn, "__name__", "kernel") + builder = PackedIRBuilder(name=name, + clock_ghz=clock_ghz, + qubit_freq_hz=freq) + + trace_args = list(bound_args) + param_sentinels + _trace_kernel_with_builder(fn, builder, trace_args) + + param_types = [parameter.dtype for parameter in param_sentinels] + unresolved = [ + parameter.name + for parameter in param_sentinels + if parameter.dtype == "unknown" + ] + if unresolved: + raise TypeError("Cannot infer types for unused symbolic parameters: " + + ", ".join(unresolved) + + ". Add int/float annotations or provide " + "concrete values to compile().") + + buf = builder.get_buffer() + n_qubits = builder.n_qubits + freq_arr = builder.get_freq_array() + metrics.trace_ms = (time.perf_counter() - t0) * 1000 + metrics.capture_ms = metrics.trace_ms + metrics.op_count = builder.op_count + + # Build parametric MLIR module (func.func with block args) + t0 = time.perf_counter() + mlir_builder = PulseModuleBuilder() + param_names = [parameter.name for parameter in missing_params] + pulse_module = mlir_builder.build_from_packed(buf, clock_ghz, n_qubits, + freq_arr, param_names, + param_types) + metrics.ffi_ms = (time.perf_counter() - t0) * 1000 + metrics.mlir_emit_ms = metrics.ffi_ms + + # Run structural passes (no scheduling -- deferred to evaluate) + t0 = time.perf_counter() + cpp_passes = [_PASS_MAP[p] for p in passes if p in _PASS_MAP] + if cpp_passes: + pulse_module.run_passes(cpp_passes) + metrics.passes_ms = (time.perf_counter() - t0) * 1000 + + # Do NOT schedule parametric kernels -- scheduling needs concrete durations + metrics.total_ms = metrics.trace_ms + metrics.ffi_ms + metrics.passes_ms + + return CompiledKernel( + _pulse_module=pulse_module, + metrics=metrics, + _param_names=[parameter.name for parameter in missing_params], + _param_types=param_types, + _schedule=schedule, + _machine=machine, + _n_qubits=n_qubits, + ) + + +def _trace_to_packed(kernel_fn, args, clock_ghz: float, + freq: dict[int, float]) -> tuple[Any, int, Any, int]: + """Trace the kernel into a packed int64 numpy buffer. + + Returns (buffer, n_qubits, freq_array). + """ + from .kernel.packed_ir_builder import PackedIRBuilder + from .kernel.bytecode_bridge import _trace_kernel_with_builder + + if not callable(kernel_fn): + raise TypeError( + f"Expected a @cudaq_pulse.kernel function; got {type(kernel_fn).__name__}" + ) + + if args is None: + raise TypeError( + "compile() requires args= when passing a kernel function. " + "e.g. compile(my_kernel, [qudit_ref(), qudit_ref()], ...)") + + fn = getattr(kernel_fn, "__wrapped__", kernel_fn) + name = getattr(fn, "__name__", "kernel") + builder = PackedIRBuilder(name=name, + clock_ghz=clock_ghz, + qubit_freq_hz=freq) + + _trace_kernel_with_builder(fn, builder, args) + + return (builder.get_buffer(), builder.n_qubits, builder.get_freq_array(), + builder.op_count) diff --git a/pulse/core/frontend/cudaq_pulse/kernel/__init__.py b/pulse/core/frontend/cudaq_pulse/kernel/__init__.py new file mode 100644 index 00000000000..ad7482d7341 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/kernel/__init__.py @@ -0,0 +1,9 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +from .decorator import kernel diff --git a/pulse/core/frontend/cudaq_pulse/kernel/_bytecode_normalize.py b/pulse/core/frontend/cudaq_pulse/kernel/_bytecode_normalize.py new file mode 100644 index 00000000000..cb5817b03d4 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/kernel/_bytecode_normalize.py @@ -0,0 +1,204 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Version-isolated bytecode normalizer. + +This is the ONLY file that contains Python-version-specific bytecode +knowledge. All downstream modules operate on CanonicalInstr sequences +and never see raw dis.Instruction objects. + +Adding support for a new CPython version: add an ``_OPNAME_MAP_3XX`` +dict and a branch in ``_select_map``. +""" + +from __future__ import annotations + +import dis +import operator +import sys +from dataclasses import dataclass +from types import CodeType +from typing import Any + +_PY_MAJOR, _PY_MINOR = sys.version_info[:2] + +# ── Canonical instruction ──────────────────────────────────────────── + + +@dataclass(frozen=True) +class CanonicalInstr: + op: str + arg: Any + offset: int + lineno: int + + +# ── Opcodes to silently skip (CPython internals, no semantic meaning) ─ + +_SKIP_OPS = frozenset({ + "RESUME", + "CACHE", + "NOP", + "PUSH_NULL", + "COPY", + "PRECALL", + "END_FOR", + "TO_BOOL", + "COPY_FREE_VARS", + "EXTENDED_ARG", +}) + +# ── Binary op sub-codes (3.11+ encode op kind in BINARY_OP arg) ───── + +_NB_OP_TO_STR: dict[int, str] = { + 0: "+", + 1: "&", + 2: "//", + 3: "<<", + 5: "*", + 6: "%", + 7: "|", + 8: "**", + 9: ">>", + 10: "-", + 11: "/", + 12: "^", + 13: "+=", + 14: "&=", + 15: "//=", + 16: "<<=", + 17: "@=", + 18: "*=", + 19: "%=", + 20: "|=", + 21: "**=", + 22: ">>=", + 23: "-=", + 24: "/=", + 25: "^=", +} + +# ── Per-version opname canonicalization maps ───────────────────────── + +_OPNAME_MAP_39: dict[str, str] = { + "CALL_FUNCTION": "CALL", + "CALL_METHOD": "CALL", + "LOAD_METHOD": "LOAD_ATTR", + "BINARY_ADD": "BINARY_OP", + "BINARY_SUBTRACT": "BINARY_OP", + "BINARY_MULTIPLY": "BINARY_OP", + "BINARY_TRUE_DIVIDE": "BINARY_OP", + "BINARY_FLOOR_DIVIDE": "BINARY_OP", + "BINARY_MODULO": "BINARY_OP", + "BINARY_POWER": "BINARY_OP", + "UNARY_NEGATIVE": "UNARY_NEGATIVE", + "POP_JUMP_IF_FALSE": "JUMP_IF_FALSE", + "POP_JUMP_IF_TRUE": "JUMP_IF_TRUE", + "JUMP_ABSOLUTE": "JUMP", + "JUMP_FORWARD": "JUMP", + "RETURN_VALUE": "RETURN", + "IMPORT_NAME": "IMPORT_NAME", +} + +_BINARY_NAME_TO_STR_39: dict[str, str] = { + "BINARY_ADD": "+", + "BINARY_SUBTRACT": "-", + "BINARY_MULTIPLY": "*", + "BINARY_TRUE_DIVIDE": "/", + "BINARY_FLOOR_DIVIDE": "//", + "BINARY_MODULO": "%", + "BINARY_POWER": "**", +} + +_OPNAME_MAP_312: dict[str, str] = { + "CALL": "CALL", + "BINARY_OP": "BINARY_OP", + "POP_JUMP_IF_FALSE": "JUMP_IF_FALSE", + "POP_JUMP_IF_TRUE": "JUMP_IF_TRUE", + "POP_JUMP_FORWARD_IF_FALSE": "JUMP_IF_FALSE", + "POP_JUMP_FORWARD_IF_TRUE": "JUMP_IF_TRUE", + "JUMP_FORWARD": "JUMP", + "JUMP_BACKWARD": "JUMP", + "JUMP_BACKWARD_NO_INTERRUPT": "JUMP", + "RETURN_VALUE": "RETURN", + "RETURN_CONST": "RETURN", + "IMPORT_NAME": "IMPORT_NAME", +} + +# 3.11 is 3.12 plus the opcodes 3.12 removed: method loads were folded into +# LOAD_ATTR, and the FORWARD/BACKWARD conditional-jump variants were merged +# back into direction-agnostic POP_JUMP_IF_*. +_OPNAME_MAP_311: dict[str, str] = { + **_OPNAME_MAP_312, + "LOAD_METHOD": "LOAD_ATTR", + "POP_JUMP_BACKWARD_IF_FALSE": "JUMP_IF_FALSE", + "POP_JUMP_BACKWARD_IF_TRUE": "JUMP_IF_TRUE", +} + + +def _select_map(major: int, minor: int) -> dict[str, str]: + if major != 3: + raise NotImplementedError(f"Python {major}.{minor} is not supported") + if minor in (9, 10): + return _OPNAME_MAP_39 + if minor == 11: + return _OPNAME_MAP_311 + if minor in (12, 13, 14): + return _OPNAME_MAP_312 + raise NotImplementedError( + f"Python {major}.{minor} is not yet supported by the bytecode bridge. " + f"Supported: 3.9-3.14 (3.12 primary). Add an opname map to " + f"_bytecode_normalize.py to add support.") + + +# ── Main normalize function ────────────────────────────────────────── + + +def normalize(code: CodeType) -> list[CanonicalInstr]: + """Convert a code object to a version-agnostic canonical instruction stream.""" + raw = list(dis.get_instructions(code)) + opname_map = _select_map(_PY_MAJOR, _PY_MINOR) + is_39 = _PY_MINOR in (9, 10) + + result: list[CanonicalInstr] = [] + prev_line = 0 + + for instr in raw: + if instr.opname in _SKIP_OPS: + continue + + sl = getattr(instr, "line_number", None) or getattr( + instr, "starts_line", None) + lineno = sl if sl is not None else prev_line + if sl is not None: + prev_line = sl + + canonical_op = opname_map.get(instr.opname, instr.opname) + arg = instr.argval + + if canonical_op == "BINARY_OP": + if is_39: + arg = _BINARY_NAME_TO_STR_39.get(instr.opname, instr.opname) + elif isinstance(instr.argval, int): + arg = _NB_OP_TO_STR.get(instr.argval, str(instr.argval)) + # else argval is already a string like "+" on some versions + + if canonical_op == "RETURN" and instr.opname == "RETURN_CONST": + arg = instr.argval + + if canonical_op == "JUMP": + arg = instr.argval # target offset (already resolved by dis) + + result.append( + CanonicalInstr( + op=canonical_op, + arg=arg, + offset=instr.offset, + lineno=lineno, + )) + + return result diff --git a/pulse/core/frontend/cudaq_pulse/kernel/_cfg.py b/pulse/core/frontend/cudaq_pulse/kernel/_cfg.py new file mode 100644 index 00000000000..dc4e66c1f72 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/kernel/_cfg.py @@ -0,0 +1,117 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Control flow graph builder operating on CanonicalInstr streams. + +Entirely version-agnostic -- all version-specific knowledge is in +_bytecode_normalize.py. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional + +from ._bytecode_normalize import CanonicalInstr + +_JUMP_OPS = frozenset({"JUMP", "JUMP_IF_FALSE", "JUMP_IF_TRUE", "FOR_ITER"}) + + +@dataclass +class BasicBlock: + bid: int + instrs: list[CanonicalInstr] = field(default_factory=list) + successors: list[int] = field(default_factory=list) + predecessors: list[int] = field(default_factory=list) + + +def build_cfg(instrs: list[CanonicalInstr]) -> dict[int, BasicBlock]: + """Build a CFG from a canonical instruction stream. + + Returns a dict mapping block-id (= offset of the first instruction + in the block) to BasicBlock. + """ + if not instrs: + return {} + + leaders: set[int] = {instrs[0].offset} + offset_to_idx: dict[int, int] = { + ci.offset: i for i, ci in enumerate(instrs) + } + + for i, ci in enumerate(instrs): + if ci.op in _JUMP_OPS: + target = ci.arg + if isinstance(target, int): + leaders.add(target) + if i + 1 < len(instrs): + leaders.add(instrs[i + 1].offset) + + sorted_leaders = sorted(leaders) + leader_to_bid: dict[int, int] = {off: off for off in sorted_leaders} + + blocks: dict[int, BasicBlock] = {} + for idx, leader_off in enumerate(sorted_leaders): + end_off = sorted_leaders[idx + + 1] if idx + 1 < len(sorted_leaders) else None + start_i = offset_to_idx.get(leader_off) + if start_i is None: + blocks[leader_off] = BasicBlock(bid=leader_off) + continue + + block_instrs: list[CanonicalInstr] = [] + for j in range(start_i, len(instrs)): + if end_off is not None and instrs[j].offset >= end_off: + break + block_instrs.append(instrs[j]) + + blocks[leader_off] = BasicBlock(bid=leader_off, instrs=block_instrs) + + for bid, block in blocks.items(): + if not block.instrs: + continue + last = block.instrs[-1] + + if last.op == "JUMP": + target = last.arg + if isinstance(target, int) and target in blocks: + block.successors.append(target) + elif last.op in ("JUMP_IF_FALSE", "JUMP_IF_TRUE"): + target = last.arg + fall = _fall_through(bid, sorted_leaders) + if isinstance(target, int) and target in blocks: + block.successors.append(target) + if fall is not None and fall in blocks: + block.successors.append(fall) + elif last.op == "FOR_ITER": + exit_target = last.arg + fall = _fall_through(bid, sorted_leaders) + if fall is not None and fall in blocks: + block.successors.append(fall) + if isinstance(exit_target, int) and exit_target in blocks: + block.successors.append(exit_target) + elif last.op == "RETURN": + pass # no successors + else: + fall = _fall_through(bid, sorted_leaders) + if fall is not None and fall in blocks: + block.successors.append(fall) + + for bid, block in blocks.items(): + for succ in block.successors: + if succ in blocks: + blocks[succ].predecessors.append(bid) + + return blocks + + +def _fall_through(bid: int, sorted_leaders: list[int]) -> Optional[int]: + """Return the block-id of the fall-through successor, or None.""" + idx = sorted_leaders.index(bid) + if idx + 1 < len(sorted_leaders): + return sorted_leaders[idx + 1] + return None diff --git a/pulse/core/frontend/cudaq_pulse/kernel/_emitter.py b/pulse/core/frontend/cudaq_pulse/kernel/_emitter.py new file mode 100644 index 00000000000..fbf02da2083 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/kernel/_emitter.py @@ -0,0 +1,651 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""PulseIREmitter -- walks a RegionTree and emits pulse IR. + +Converts from stack-based bytecode semantics to SSA-style pulse IR +using the existing PythonIRBuilder. Resolves Python globals/builtins +at emit time so that expressions like ``int(4 * sigma)`` and +``math.pi / 2`` evaluate to concrete values, while pulse ops +(``drive``, ``gaussian``, etc.) are emitted as IR operations. +""" + +from __future__ import annotations + +import builtins as _builtins_mod +import operator as pyop +import types +from typing import Any, Union + +from .ir_builder import ( + CompilationError, + IRValue, + LINEAR_TYPES, + OP_TABLE, + Parameter, + is_symbolic, + PythonIRBuilder, +) +from ._bytecode_normalize import CanonicalInstr, normalize +from ._structure import Block, ForLoop, IfElse, Region, recover_structure + +_ALLOC_OPS = {"qudit_ref": "pulse.qudit_alloc", "qvec_ref": "pulse.qvec_alloc"} +_PULSE_OP_NAMES = frozenset(OP_TABLE.keys()) | frozenset(_ALLOC_OPS.keys()) + +_BIN_OP_FN: dict[str, Any] = { + "+": pyop.add, + "-": pyop.sub, + "*": pyop.mul, + "/": pyop.truediv, + "//": pyop.floordiv, + "%": pyop.mod, + "**": pyop.pow, +} + +StackVal = Union[IRValue, Any] + + +def _extract_closures(fn: types.FunctionType) -> dict[str, Any]: + """Return the values captured by a Python helper function.""" + closures: dict[str, Any] = {} + if fn.__closure__ and fn.__code__.co_freevars: + for name, cell in zip(fn.__code__.co_freevars, fn.__closure__): + try: + closures[name] = cell.cell_contents + except ValueError: + pass + return closures + + +def _uses_pulse_ops(fn: types.FunctionType, + seen: set[int] | None = None) -> bool: + """Whether *fn* directly or transitively calls the pulse DSL.""" + seen = seen or set() + if id(fn) in seen: + return False + seen.add(id(fn)) + + for name in fn.__code__.co_names: + if name in _PULSE_OP_NAMES: + return True + value = fn.__globals__.get(name) + if isinstance(value, types.FunctionType) and _uses_pulse_ops( + value, seen): + return True + return False + + +def _loop_var_used(name: str, regions: list[Region]) -> bool: + """Check if a loop variable is referenced anywhere in the body regions.""" + for region in regions: + if isinstance(region, Block): + for ci in region.instrs: + if ci.op == "LOAD_FAST" and ci.arg == name: + return True + elif isinstance(region, ForLoop): + if _loop_var_used(name, region.body): + return True + elif isinstance(region, IfElse): + if _loop_var_used(name, region.true_body): + return True + if _loop_var_used(name, region.false_body): + return True + return False + + +class PulseIREmitter: + """Walks a region tree and emits pulse IR via PythonIRBuilder.""" + + def __init__( + self, + builder: PythonIRBuilder, + fn_globals: dict[str, Any] | None = None, + fn_closures: dict[str, Any] | None = None, + ): + self.b = builder + self.stack: list[StackVal] = [] + self.locals: dict[str, StackVal] = {} + self._fn_globals = fn_globals or {} + self._fn_closures = fn_closures or {} + self._builtins = vars(_builtins_mod) + + # ── Public entry point ─────────────────────────────────────────── + + def emit_regions(self, regions: list[Region]) -> None: + for region in regions: + self._emit_region(region) + + # ── Region dispatch ────────────────────────────────────────────── + + def _emit_region(self, region: Region) -> None: + if isinstance(region, Block): + self._emit_block(region) + elif isinstance(region, ForLoop): + self._emit_for_loop(region) + elif isinstance(region, IfElse): + self._emit_if_else(region) + else: + raise CompilationError(f"unknown region type: {type(region)}") + + def _emit_block(self, block: Block) -> None: + for ci in block.instrs: + self._exec_instr(ci) + + def _emit_for_loop(self, loop: ForLoop) -> None: + values = self._extract_range_values(loop) + if values is None: + raise CompilationError( + "range() bounds must be compile-time integers in pulse kernels") + + # The packed compiler has no region encoding. Always unroll here so + # loop bodies cannot be silently emitted only once. + for i in values: + self.locals[loop.loop_var] = i + self.emit_regions(loop.body) + + def _emit_if_else(self, ifelse: IfElse) -> None: + cond = self.stack.pop() if self.stack else None + + if isinstance(cond, IRValue): + raise CompilationError( + "runtime-dependent branches are not supported in the pulse " + "research preview; measurement results cannot control Python " + "if statements") + elif cond: + self.emit_regions(ifelse.true_body) + else: + self.emit_regions(ifelse.false_body) + + def _emit_scf_if(self, cond: IRValue, ifelse: IfElse) -> None: + snap_full = dict(self.locals) + snap_ir = self._ir_snapshot() + + self.b.emit("scf.if", (cond,), ()) + self.emit_regions(ifelse.true_body) + true_delta = self._ir_delta(snap_ir) + + self.locals = dict(snap_full) + if ifelse.false_body: + self.b.emit("scf.else", (), ()) + self.emit_regions(ifelse.false_body) + false_delta = self._ir_delta(snap_ir) + + all_names = sorted(set(true_delta) | set(false_delta)) + if all_names: + vtypes = tuple( + (true_delta.get(n) or false_delta[n]).vtype for n in all_names) + results = self.b.emit("scf.if_end", (), vtypes, + {"result_names": all_names}) + for n, r in zip(all_names, results): + self.locals[n] = r + else: + self.b.emit("scf.if_end", (), ()) + + # ── Instruction execution ──────────────────────────────────────── + + def _exec_instr(self, ci: CanonicalInstr) -> None: + op = ci.op + arg = ci.arg + + if op == "LOAD_FAST": + if arg not in self.locals: + raise CompilationError(f"undefined local: {arg}") + self.stack.append(self.locals[arg]) + + elif op == "STORE_FAST": + val = self.stack.pop() + self.locals[arg] = val + + elif op == "LOAD_CONST": + self.stack.append(arg) + + elif op == "LOAD_GLOBAL": + self.stack.append(self._resolve_global(arg)) + + elif op == "LOAD_ATTR": + obj = self.stack.pop() + if isinstance( + obj, + tuple) and len(obj) == 2 and obj[0] == "__unresolved__": + self.stack.append(("__unresolved__", arg)) + else: + try: + self.stack.append(getattr(obj, arg)) + except (AttributeError, TypeError): + raise CompilationError( + f"cannot resolve attribute {arg!r} on {type(obj).__name__}" + ) + + elif op == "CALL": + nargs = arg if isinstance(arg, int) else 0 + call_args = [] + for _ in range(nargs): + call_args.append(self.stack.pop()) + call_args.reverse() + + _callable = self.stack.pop() + self._dispatch_call(_callable, call_args) + + elif op == "UNPACK_SEQUENCE": + val = self.stack.pop() + if isinstance(val, tuple): + if len(val) != arg: + raise CompilationError( + f"unpack mismatch: expected {arg}, got {len(val)}") + for v in reversed(val): + self.stack.append(v) + elif isinstance(val, list): + if len(val) != arg: + raise CompilationError( + f"unpack mismatch: expected {arg}, got {len(val)}") + for v in reversed(val): + self.stack.append(v) + else: + raise CompilationError(f"cannot unpack {type(val)}") + + elif op == "POP_TOP": + if self.stack: + self.stack.pop() + + elif op == "BINARY_OP": + right = self.stack.pop() + left = self.stack.pop() + fn = _BIN_OP_FN.get(arg) + if fn is None: + raise CompilationError(f"unsupported binary op: {arg}") + self.stack.append(fn(left, right)) + + elif op == "UNARY_NEGATIVE": + val = self.stack.pop() + self.stack.append(-val) + + elif op == "COMPARE_OP": + right = self.stack.pop() + left = self.stack.pop() + cmp_ops = { + "<": pyop.lt, + "<=": pyop.le, + "==": pyop.eq, + "!=": pyop.ne, + ">": pyop.gt, + ">=": pyop.ge, + } + fn = cmp_ops.get(arg) + if fn is None: + raise CompilationError(f"unsupported comparison: {arg}") + self.stack.append(fn(left, right)) + + elif op == "BUILD_TUPLE": + items = [] + for _ in range(arg): + items.append(self.stack.pop()) + items.reverse() + self.stack.append(tuple(items)) + + elif op == "BUILD_LIST": + items = [] + for _ in range(arg): + items.append(self.stack.pop()) + items.reverse() + self.stack.append(items) + + elif op == "LIST_EXTEND": + iterable = self.stack.pop() + lst = self.stack[-1] + if isinstance(lst, list): + lst.extend(iterable) + else: + raise CompilationError(f"LIST_EXTEND on non-list: {type(lst)}") + + elif op == "LIST_APPEND": + val = self.stack.pop() + lst = self.stack[-1] + if isinstance(lst, list): + lst.append(val) + else: + raise CompilationError(f"LIST_APPEND on non-list: {type(lst)}") + + elif op == "BINARY_SUBSCR": + index = self.stack.pop() + obj = self.stack.pop() + try: + self.stack.append(obj[index]) + except (TypeError, IndexError, KeyError) as e: + raise CompilationError( + f"subscript failed: {type(obj).__name__}[{index!r}]: {e}") + + elif op == "GET_ITER": + pass # handled in for-loop structure recovery + + elif op == "FOR_ITER": + pass # handled in structure recovery + + elif op == "JUMP": + pass # unconditional jumps are consumed by structure recovery + + elif op == "RETURN": + pass # function return at end of kernel + + elif op == "IMPORT_NAME": + self.stack.append(self._resolve_global(arg)) + + elif op in ("LOAD_DEREF", "LOAD_CLOSURE"): + if arg in self._fn_closures: + self.stack.append(self._fn_closures[arg]) + else: + self.stack.append(self._resolve_global(arg)) + + elif op == "STORE_DEREF": + val = self.stack.pop() + self.locals[arg] = val + + elif op == "JUMP_IF_FALSE" or op == "JUMP_IF_TRUE": + pass # consumed by structure recovery for if/else + + elif op == "JUMP_BACKWARD": + raise CompilationError( + "unsupported control flow: while loops and break/continue " + "are not supported in @cudaq_pulse.kernel; use for loops") + + elif op == "SWAP": + n = arg if isinstance(arg, int) else 2 + if n == 2 and len(self.stack) >= 2: + self.stack[-1], self.stack[-2] = self.stack[-2], self.stack[-1] + elif n == 3 and len(self.stack) >= 3: + self.stack[-1], self.stack[-3] = self.stack[-3], self.stack[-1] + elif len(self.stack) >= n: + self.stack[-1], self.stack[-n] = self.stack[-n], self.stack[-1] + + elif op in ("ROT_TWO", "ROT_THREE", "DUP_TOP"): + pass + + elif op == "BUILD_CONST_KEY_MAP": + keys = self.stack.pop() + vals = [] + for _ in range(arg): + vals.append(self.stack.pop()) + vals.reverse() + self.stack.append(dict(zip(keys, vals))) + + elif op == "BUILD_MAP": + d: dict = {} + for _ in range(arg): + v = self.stack.pop() + k = self.stack.pop() + d[k] = v + self.stack.append(d) + + elif op == "STORE_SUBSCR": + index = self.stack.pop() + obj = self.stack.pop() + value = self.stack.pop() + obj[index] = value + + else: + raise CompilationError( + f"unsupported bytecode instruction: {op} (arg={arg})") + + # ── Global / closure resolution ────────────────────────────────── + + def _resolve_global(self, name: str) -> Any: + """Resolve a global name to its Python value.""" + if name in self._fn_globals: + return self._fn_globals[name] + if name in self._fn_closures: + return self._fn_closures[name] + if name in self._builtins: + return self._builtins[name] + return ("__unresolved__", name) + + # ── Call dispatch ──────────────────────────────────────────────── + + def _dispatch_call(self, callable_val: Any, + call_args: list[StackVal]) -> None: + """Route a call to pulse IR emission or Python evaluation.""" + + # Unresolved sentinel (fallback from _resolve_global) + if isinstance(callable_val, tuple) and len(callable_val) == 2: + tag, name = callable_val + if tag == "__unresolved__": + if name in OP_TABLE or name in _ALLOC_OPS: + self._push_pulse_results(name, call_args) + elif name == "range": + self.stack.append(("__range__", call_args)) + else: + raise CompilationError(f"unknown pulse op: {name}") + return + + # Resolved Python callable + fname = getattr(callable_val, "__name__", "") + + # Qudit/qvec allocation (check before OP_TABLE so qvec_ref gets expanded) + if fname in _ALLOC_OPS: + self._push_alloc_results(fname, call_args) + return + + # Pulse ops + if fname in OP_TABLE: + self._push_pulse_results(fname, call_args) + return + + # range() -> sentinel for for-loop handling + if callable_val is range or fname == "range": + self.stack.append(("__range__", call_args)) + return + + # Inline user helpers which consume IR values or call the pulse DSL. + # Executing these as ordinary Python would invoke pulse operations + # outside the compiler context and lose their IR effects. + if isinstance(callable_val, types.FunctionType) and ( + any(isinstance(arg, IRValue) for arg in call_args) or + _uses_pulse_ops(callable_val)): + self._inline_helper(callable_val, call_args) + return + + # Regular Python callable (int, float, len, abs, math.sin, etc.) + # int()/float() on a symbolic value create an explicit IR cast. + if callable(callable_val): + if len(call_args) == 1 and is_symbolic(call_args[0]): + if callable_val is int: + self.stack.append(call_args[0].cast("i64")) + return + if callable_val is float: + self.stack.append(call_args[0].cast("f64")) + return + if any(is_symbolic(a) for a in call_args): + raise CompilationError( + f"Cannot call {fname}() with symbolic parameter " + "expressions. Only int(), float(), arithmetic, and pulse " + "numeric arguments are supported.") + try: + result = callable_val(*call_args) + except Exception as e: + raise CompilationError( + f"failed to evaluate {fname}({call_args!r}): {e}") + self.stack.append(result) + return + + raise CompilationError(f"cannot call: {callable_val!r}") + + def _inline_helper(self, fn: types.FunctionType, + call_args: list[StackVal]) -> None: + """Emit a pulse-aware Python helper into the current IR builder.""" + code = fn.__code__ + params = list(code.co_varnames[:code.co_argcount]) + if len(call_args) != len(params): + raise CompilationError( + f"{fn.__name__}: expected {len(params)} args, got {len(call_args)}" + ) + + helper = PulseIREmitter( + self.b, + fn_globals=fn.__globals__, + fn_closures=_extract_closures(fn), + ) + helper.locals.update(zip(params, call_args)) + helper.emit_regions(recover_structure(normalize(code))) + + # Propagate rebindings of directly passed linear values back to the + # caller, then leave the helper's return value on the caller stack. + for name, original in zip(params, call_args): + updated = helper.locals.get(name) + if not isinstance(original, IRValue) or updated is original: + continue + for caller_name, caller_value in self.locals.items(): + if caller_value is original: + self.locals[caller_name] = updated + + self.stack.append(helper.stack[-1] if helper.stack else None) + + def _push_pulse_results( + self, + fname: str, + call_args: list[StackVal], + ) -> None: + results = self._emit_pulse_call(fname, call_args) + if fname == "readout": + self.stack.append(results[-1]) + elif fname in {"drive", "wait", "sync"}: + return + elif len(results) == 1: + self.stack.append(results[0]) + elif len(results) > 1: + self.stack.append(results) + + def _push_alloc_results( + self, + fname: str, + call_args: list[StackVal], + ) -> None: + """Handle qudit_ref() and qvec_ref(n) allocation inside kernels.""" + if fname == "qvec_ref": + if len(call_args) == 1 and isinstance(call_args[0], int): + n = call_args[0] + ir_vals = [] + for i in range(n): + (v,) = self.b.emit("pulse.qudit_alloc", (), ("qref",), + {"index": i}) + ir_vals.append(v) + self.stack.append(ir_vals) + return + # qudit_ref() or fallback qvec_ref + results = self._emit_pulse_call(fname, call_args) + if len(results) == 1: + self.stack.append(results[0]) + elif len(results) > 1: + self.stack.append(results) + + # ── Pulse IR emission ──────────────────────────────────────────── + + def _emit_pulse_call( + self, + fname: str, + call_args: list[StackVal], + ) -> tuple[IRValue, ...]: + entry = OP_TABLE.get(fname) + if entry is None: + raise CompilationError(f"unknown pulse op: {fname}") + + n_val, attr_names, rtypes = entry + + if n_val == -1: + operands = tuple(v for v in call_args if isinstance(v, IRValue)) + attrs: dict[str, Any] = {} + out_types = tuple( + v.vtype for v in operands) if rtypes is None else rtypes + else: + operands = tuple(call_args[:n_val]) + attr_vals = call_args[n_val:] + if len(attr_vals) != len(attr_names): + raise CompilationError( + f"{fname}: expected {n_val + len(attr_names)} args, got {len(call_args)}" + ) + attrs = dict(zip(attr_names, attr_vals)) + out_types = (operands[0].vtype,) if (rtypes is None and + operands) else (rtypes or ()) + + op_name = _ALLOC_OPS.get(fname, f"pulse.{fname}") + results = self.b.emit(op_name, operands, out_types, attrs) + + self._rebind_linear(fname, call_args, results) + + return results + + def _rebind_linear( + self, + fname: str, + call_args: list[StackVal], + results: tuple[IRValue, ...], + ) -> None: + """Rebind local names for linear-typed results (drive_line, tone, etc).""" + arg_info: list[tuple[str | None, IRValue | None]] = [] + for a in call_args: + if isinstance(a, IRValue): + name = self._find_local_name(a) + arg_info.append((name, a)) + else: + arg_info.append((None, None)) + + claimed: set[int] = set() + for res in results: + if res.vtype not in LINEAR_TYPES: + continue + for i, (name, op_val) in enumerate(arg_info): + if i in claimed or op_val is None or name is None: + continue + if op_val.vtype == res.vtype: + self.locals[name] = res + claimed.add(i) + break + + def _find_local_name(self, val: IRValue) -> str | None: + """Find the local variable name bound to a given IRValue.""" + for name, v in self.locals.items(): + if v is val: + return name + return None + + # ── Range extraction ───────────────────────────────────────────── + + def _extract_range_values(self, loop: ForLoop) -> range | None: + """Extract exact compile-time ``range`` values for a loop.""" + if self.stack: + top = self.stack[-1] + if isinstance(top, + tuple) and len(top) == 2 and top[0] == "__range__": + self.stack.pop() + args = top[1] + if 1 <= len(args) <= 3 and all( + isinstance(arg, int) for arg in args): + try: + return range(*args) + except ValueError as exc: + raise CompilationError(str(exc)) from exc + return None + + for ci in loop.range_setup: + if ci.op == "LOAD_CONST": + if isinstance(ci.arg, int): + return range(ci.arg) + return None + for ci in loop.range_setup: + if ci.op == "LOAD_FAST" and ci.arg in self.locals: + value = self.locals[ci.arg] + return range(value) if isinstance(value, int) else None + return None + + # ── Snapshot/delta for structured control flow ─────────────────── + + def _ir_snapshot(self) -> dict[str, IRValue]: + return {k: v for k, v in self.locals.items() if isinstance(v, IRValue)} + + def _ir_delta(self, snap: dict[str, IRValue]) -> dict[str, IRValue]: + return { + k: v + for k, v in self.locals.items() + if isinstance(v, IRValue) and (k not in snap or snap[k] is not v) + } diff --git a/pulse/core/frontend/cudaq_pulse/kernel/_structure.py b/pulse/core/frontend/cudaq_pulse/kernel/_structure.py new file mode 100644 index 00000000000..ee0bd6b785a --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/kernel/_structure.py @@ -0,0 +1,224 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Structured region recovery from a CFG. + +Recovers ForLoop and IfElse regions from the flat CFG produced by +_cfg.py. Operates purely on canonical instructions and block structure -- +no version-specific knowledge. + +CPython's compiler produces reducible control flow for all constructs +we support (for/range, if/else). We use pattern matching rather than +general-purpose loop detection. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, List, Optional, Union + +from ._bytecode_normalize import CanonicalInstr +from ._cfg import BasicBlock + +# ── Region tree node types ─────────────────────────────────────────── + + +@dataclass +class Block: + """A basic block of straight-line instructions.""" + instrs: list[CanonicalInstr] + + +@dataclass +class ForLoop: + """A for-range loop recovered from FOR_ITER..JUMP pattern.""" + loop_var: str + range_setup: list[CanonicalInstr] # LOAD range, LOAD N, CALL, GET_ITER + body: list[Region] + header_offset: int + + +@dataclass +class IfElse: + """An if/else branch recovered from JUMP_IF_FALSE..JUMP pattern.""" + true_body: list[Region] + false_body: list[Region] + + +Region = Union[Block, ForLoop, IfElse] + +# ── Structure recovery ─────────────────────────────────────────────── + + +def recover_structure(instrs: list[CanonicalInstr]) -> list[Region]: + """Recover structured regions from a flat canonical instruction stream. + + Instead of building a full CFG, we do a single linear scan recognizing + the bytecode patterns CPython emits for for-range loops and if/else. + This is simpler and faster than CFG-based structure recovery for the + restricted set of constructs we support. + """ + return _recover(instrs, 0, len(instrs)) + + +def _recover(instrs: list[CanonicalInstr], start: int, + end: int) -> list[Region]: + """Recursively recover regions in instrs[start:end].""" + regions: list[Region] = [] + i = start + + while i < end: + ci = instrs[i] + + if ci.op == "FOR_ITER": + region, i = _recover_for_loop(instrs, i, end) + regions.append(region) + continue + + if ci.op in ("JUMP_IF_FALSE", "JUMP_IF_TRUE"): + region, i = _recover_if_else(instrs, i, end) + regions.append(region) + continue + + straight: list[CanonicalInstr] = [] + while i < end: + ci = instrs[i] + if ci.op in ("FOR_ITER", "JUMP_IF_FALSE", "JUMP_IF_TRUE"): + break + straight.append(ci) + i += 1 + + if straight: + regions.append(Block(instrs=straight)) + + return regions + + +def _recover_for_loop( + instrs: list[CanonicalInstr], + for_iter_idx: int, + end: int, +) -> tuple[ForLoop, int]: + """Recover a ForLoop starting at the FOR_ITER instruction. + + Pattern (3.9): + ... LOAD_GLOBAL(range) LOAD_CONST(N) CALL GET_ITER + FOR_ITER(exit_offset) + STORE_FAST(loop_var) + + JUMP(back to FOR_ITER offset) + : ... + """ + for_iter = instrs[for_iter_idx] + exit_target = for_iter.arg + header_offset = for_iter.offset + loop_var = "" + + body_start = for_iter_idx + 1 + if body_start < end and instrs[body_start].op == "STORE_FAST": + loop_var = instrs[body_start].arg + body_start += 1 + + body_end = for_iter_idx + 1 + for j in range(body_start, end): + if instrs[j].op == "JUMP" and instrs[j].arg == header_offset: + body_end = j + break + else: + body_end = end + + body_instrs = instrs[body_start:body_end] + body_regions = _recover(body_instrs, 0, len(body_instrs)) + + range_setup: list[CanonicalInstr] = [] + setup_start = for_iter_idx + for k in range(for_iter_idx - 1, -1, -1): + if instrs[k].op == "GET_ITER": + setup_start = k + break + if instrs[k].op in ("LOAD_GLOBAL", "LOAD_CONST", "CALL"): + setup_start = k + else: + break + + next_idx = body_end + 1 + if next_idx < end and isinstance(exit_target, int): + for j in range(next_idx, end): + if instrs[j].offset >= exit_target: + next_idx = j + break + + return ForLoop( + loop_var=loop_var, + range_setup=instrs[setup_start:for_iter_idx], + body=body_regions, + header_offset=header_offset, + ), next_idx + + +def _recover_if_else( + instrs: list[CanonicalInstr], + cond_idx: int, + end: int, +) -> tuple[IfElse, int]: + """Recover an IfElse starting at a JUMP_IF_FALSE instruction. + + Pattern: + JUMP_IF_FALSE(else_or_end_offset) + + JUMP(end_offset) + (optional) + : ... + """ + cond = instrs[cond_idx] + false_target = cond.arg + is_negated = cond.op == "JUMP_IF_FALSE" + + true_start = cond_idx + 1 + true_end = true_start + jump_end_idx: int | None = None + + for j in range(true_start, end): + if isinstance(false_target, int) and instrs[j].offset >= false_target: + true_end = j + break + if instrs[j].op == "JUMP" and j > true_start: + true_end = j + jump_end_idx = j + break + else: + true_end = end + + true_instrs = instrs[true_start:true_end] + + if jump_end_idx is not None: + jump_target = instrs[jump_end_idx].arg + false_start = jump_end_idx + 1 + false_end = false_start + for j in range(false_start, end): + if isinstance(jump_target, int) and instrs[j].offset >= jump_target: + false_end = j + break + else: + false_end = end + false_instrs = instrs[false_start:false_end] + next_idx = false_end + else: + false_instrs = [] + next_idx = true_end + + true_regions = _recover(true_instrs, 0, + len(true_instrs)) if true_instrs else [] + false_regions = _recover(false_instrs, 0, + len(false_instrs)) if false_instrs else [] + + if is_negated: + return IfElse(true_body=true_regions, + false_body=false_regions), next_idx + else: + return IfElse(true_body=false_regions, + false_body=true_regions), next_idx diff --git a/pulse/core/frontend/cudaq_pulse/kernel/bytecode_bridge.py b/pulse/core/frontend/cudaq_pulse/kernel/bytecode_bridge.py new file mode 100644 index 00000000000..e648151c3e8 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/kernel/bytecode_bridge.py @@ -0,0 +1,126 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Bytecode-based kernel capture. + +Uses ``fn.__code__`` directly instead of ``inspect.getsource`` + ``ast.parse``. +All version-specific bytecode handling is in ``_bytecode_normalize.py``. +""" + +from __future__ import annotations + +from typing import Any, Callable + +from .ir_builder import CompilationError, IRValue, PythonIRBuilder +from .decorator import QuditRef, QvecRef +from ._bytecode_normalize import normalize +from ._structure import recover_structure +from ._emitter import PulseIREmitter + + +def compile_kernel_bytecode(fn: Callable) -> Callable[..., PythonIRBuilder]: + """Compile a pulse kernel function via bytecode analysis. + + Returns a callable that, given concrete arguments, produces a + populated ``PythonIRBuilder``. + """ + code = fn.__code__ + params = list(code.co_varnames[:code.co_argcount]) + + instrs = normalize(code) + regions = recover_structure(instrs) + + fn_globals = getattr(fn, "__globals__", {}) + fn_closures = _extract_closures(fn) + + def _emit(*args: Any, **kwargs: Any) -> PythonIRBuilder: + if kwargs: + raise CompilationError( + "keyword arguments not supported in pulse kernels") + if len(args) != len(params): + raise CompilationError( + f"{fn.__name__}: expected {len(params)} args, got {len(args)}") + + builder = PythonIRBuilder(name=fn.__name__) + emitter = PulseIREmitter(builder, + fn_globals=fn_globals, + fn_closures=fn_closures) + + for name, val in zip(params, args): + if isinstance(val, QuditRef): + attrs = {"index": val.index} if val.index is not None else {} + (ir_val,) = builder.emit("pulse.qudit_arg", (), ("qref",), + attrs) + emitter.locals[name] = ir_val + elif isinstance(val, QvecRef): + ir_vals = [] + for i in range(len(val)): + (v,) = builder.emit("pulse.qudit_arg", (), ("qref",), + {"index": i}) + ir_vals.append(v) + emitter.locals[name] = ir_vals + else: + emitter.locals[name] = val + + emitter.emit_regions(regions) + return builder + + return _emit + + +def _trace_kernel_with_builder(fn: Callable, builder, args) -> None: + """Re-trace *fn* using *builder* (any object with an ``emit()`` method). + + This allows ``compile()`` to inject an ``MLIRIRBuilder`` that writes + directly to an in-memory MLIR module instead of a ``PythonIRBuilder``. + """ + code = fn.__code__ + params = list(code.co_varnames[:code.co_argcount]) + + instrs = normalize(code) + regions = recover_structure(instrs) + + fn_globals = getattr(fn, "__globals__", {}) + fn_closures = _extract_closures(fn) + + if len(args) != len(params): + raise CompilationError( + f"{fn.__name__}: expected {len(params)} args, got {len(args)}") + + emitter = PulseIREmitter(builder, + fn_globals=fn_globals, + fn_closures=fn_closures) + + for name, val in zip(params, args): + if isinstance(val, QuditRef): + attrs = {"index": val.index} if val.index is not None else {} + (ir_val,) = builder.emit("pulse.qudit_alloc", (), ("qref",), attrs) + emitter.locals[name] = ir_val + elif isinstance(val, QvecRef): + ir_vals = [] + for i in range(len(val)): + (v,) = builder.emit("pulse.qudit_alloc", (), ("qref",), + {"index": i}) + ir_vals.append(v) + emitter.locals[name] = ir_vals + else: + emitter.locals[name] = val + + emitter.emit_regions(regions) + + +def _extract_closures(fn: Callable) -> dict[str, Any]: + """Extract closure variable values from the function.""" + closures: dict[str, Any] = {} + code = fn.__code__ + if fn.__closure__ and code.co_freevars: + for name, cell in zip(code.co_freevars, fn.__closure__): + try: + closures[name] = cell.cell_contents + except ValueError: + pass + return closures diff --git a/pulse/core/frontend/cudaq_pulse/kernel/decorator.py b/pulse/core/frontend/cudaq_pulse/kernel/decorator.py new file mode 100644 index 00000000000..6b9eb8e2ecc --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/kernel/decorator.py @@ -0,0 +1,159 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +from __future__ import annotations + +import functools +from typing import Any, Callable + +_next_vid = 0 + + +def _alloc_vid() -> int: + global _next_vid + vid = _next_vid + _next_vid += 1 + return vid + + +class QuditRef: + """A single qudit reference representing one quantum degree of freedom. + + Created via ``cudaq_pulse.qudit_ref()``. Passed as an argument to + ``@kernel`` functions to bind physical qubit resources. + """ + + __slots__ = ("_vid", "_index") + + def __init__(self, index: int | None = None) -> None: + if index is not None and (not isinstance(index, int) or index < 0): + raise ValueError("qudit index must be a non-negative integer") + self._vid = _alloc_vid() + self._index = index + + @property + def index(self) -> int | None: + """Requested physical index, or ``None`` for positional binding.""" + return self._index + + +class QvecRef: + """A fixed-size vector of qudit references. + + Created via ``cudaq_pulse.qvec_ref(n)``. Supports ``len()`` and + integer indexing to access individual ``QuditRef`` elements. + """ + + def __init__(self, n: int): + if not isinstance(n, int) or n < 0: + raise ValueError("qvec size must be a non-negative integer") + self._n = n + self._qudits = [QuditRef(index) for index in range(n)] + + def __len__(self) -> int: + return self._n + + def __getitem__(self, idx: int) -> QuditRef: + if not 0 <= idx < self._n: + raise IndexError(f"qudit index {idx} out of range [0, {self._n})") + return self._qudits[idx] + + +def qudit_ref(index: int | None = None) -> QuditRef: + """Allocate a single qudit reference. + + Use as an argument when calling or compiling a ``@kernel`` function:: + + q = cudaq_pulse.qudit_ref() + ck = cudaq_pulse.compile(my_kernel, [q], ...) + + Pass an index to bind a kernel argument to a specific physical qubit, + for example ``cudaq_pulse.qudit_ref(4)``. + + Args: + index: Optional non-negative physical qubit index. When omitted, + qubits are assigned by argument position. + + Returns: + A fresh ``QuditRef`` with a unique virtual ID. + """ + return QuditRef(index) + + +def qvec_ref(n: int) -> QvecRef: + """Allocate a vector of *n* qudit references. + + Example:: + + qubits = cudaq_pulse.qvec_ref(4) + ck = cudaq_pulse.compile(my_kernel, [qubits[0], qubits[1]], ...) + + Args: + n: Number of qudits. + + Returns: + A ``QvecRef`` containing *n* ``QuditRef`` elements. + """ + return QvecRef(n) + + +def kernel(fn: Callable) -> Callable: + """Decorator that marks a Python function as a pulse kernel. + + The decorated function is traced via bytecode capture when called, + producing an intermediate representation that can be compiled to + MLIR with ``cudaq_pulse.compile()``. + + Supported control flow inside the kernel: + + - ``for i in range(N)`` (compile-time bound) + - ``if`` / ``else`` with compile-time conditions + + Example:: + + @cudaq_pulse.kernel + def my_kernel(q): + d, t = get_drive_line(q) + drive(d, gaussian(40, 0.3, 10.0), t) + + Args: + fn: A plain Python function using ``cudaq_pulse`` ops. + + Returns: + A wrapped callable that performs bytecode tracing on each call. + """ + import sys + + _major, _minor = sys.version_info[:2] + if _major != 3 or _minor < 10: + raise RuntimeError( + f"@cudaq_pulse.kernel requires Python >= 3.10, got {_major}.{_minor}" + ) + + _cache_key: list[Any] = [None] + + @functools.wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + key = (fn.__code__, fn.__module__) + if _cache_key[0] != key or wrapper.__cudaq_pulse_emitter__ is None: + from .bytecode_bridge import compile_kernel_bytecode + + wrapper.__cudaq_pulse_emitter__ = compile_kernel_bytecode(fn) + _cache_key[0] = key + return wrapper.__cudaq_pulse_emitter__(*args, **kwargs) + + def _trace_with_builder(builder, args): + """Re-trace the kernel using a custom IR builder (e.g. MLIRIRBuilder).""" + from .bytecode_bridge import _trace_kernel_with_builder + + _trace_kernel_with_builder(fn, builder, args) + + wrapper.__wrapped__ = fn + wrapper.__cudaq_pulse_emitter__ = None + wrapper._trace_with_builder = _trace_with_builder + return wrapper diff --git a/pulse/core/frontend/cudaq_pulse/kernel/ir_builder.py b/pulse/core/frontend/cudaq_pulse/kernel/ir_builder.py new file mode 100644 index 00000000000..84e8a6cffdf --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/kernel/ir_builder.py @@ -0,0 +1,231 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Shared IR types and builder for the pulse kernel compiler. + +This module contains the core data structures used by the bytecode +compiler and downstream passes: Op, IRValue, Parameter, PythonIRBuilder, +OP_TABLE, LINEAR_TYPES, and CompilationError. +""" + +from __future__ import annotations + +from collections import namedtuple +from typing import Any + +Op = namedtuple("Op", ["kind", "operands", "results", "attrs"]) + + +class CompilationError(Exception): + pass + + +class _SymbolicNumber: + """Arithmetic shared by symbolic kernel parameters and expressions.""" + + def _binary(self, op: str, other: Any) -> "ParameterExpression": + return ParameterExpression(op, self, other) + + def _rbinary(self, op: str, other: Any) -> "ParameterExpression": + return ParameterExpression(op, other, self) + + def __add__(self, other: Any) -> "ParameterExpression": + return self._binary("add", other) + + def __radd__(self, other: Any) -> "ParameterExpression": + return self._rbinary("add", other) + + def __sub__(self, other: Any) -> "ParameterExpression": + return self._binary("sub", other) + + def __rsub__(self, other: Any) -> "ParameterExpression": + return self._rbinary("sub", other) + + def __mul__(self, other: Any) -> "ParameterExpression": + return self._binary("mul", other) + + def __rmul__(self, other: Any) -> "ParameterExpression": + return self._rbinary("mul", other) + + def __truediv__(self, other: Any) -> "ParameterExpression": + return self._binary("div", other) + + def __rtruediv__(self, other: Any) -> "ParameterExpression": + return self._rbinary("div", other) + + def __floordiv__(self, other: Any) -> "ParameterExpression": + return self._binary("floordiv", other) + + def __rfloordiv__(self, other: Any) -> "ParameterExpression": + return self._rbinary("floordiv", other) + + def __mod__(self, other: Any) -> "ParameterExpression": + return self._binary("mod", other) + + def __rmod__(self, other: Any) -> "ParameterExpression": + return self._rbinary("mod", other) + + def __pow__(self, other: Any) -> "ParameterExpression": + return self._binary("pow", other) + + def __rpow__(self, other: Any) -> "ParameterExpression": + return self._rbinary("pow", other) + + def __neg__(self) -> "ParameterExpression": + return ParameterExpression("neg", self) + + def cast(self, dtype: str) -> "ParameterExpression": + if dtype not in ("i64", "f64"): + raise CompilationError(f"unsupported symbolic cast to {dtype}") + return ParameterExpression("cast", self, dtype=dtype) + + def _not_concrete(self, operation: str) -> None: + raise CompilationError( + f"Cannot evaluate symbolic parameter expression with {operation}. " + "Use it in a supported pulse numeric argument.") + + def __bool__(self) -> bool: + self._not_concrete("bool()") + return False + + def __float__(self) -> float: + self._not_concrete("float()") + return 0.0 + + def __int__(self) -> int: + self._not_concrete("int()") + return 0 + + +class Parameter(_SymbolicNumber): + """Sentinel for a symbolic kernel parameter (compile-once, evaluate-many). + + Instances track an index and type so the packed IR builder can emit + a PARAM opcode instead of a concrete value. + """ + + __slots__ = ("name", "index", "dtype") + + def __init__(self, name: str, index: int, dtype: str = "unknown"): + if dtype not in ("unknown", "i64", "f64"): + raise ValueError(f"unsupported parameter dtype {dtype!r}") + self.name = name + self.index = index + self.dtype = dtype + + def __repr__(self) -> str: + return f"Parameter({self.name!r}, idx={self.index}, {self.dtype})" + + +class ParameterExpression(_SymbolicNumber): + """A symbolic arithmetic expression rooted in kernel parameters.""" + + __slots__ = ("op", "lhs", "rhs", "dtype") + + def __init__(self, + op: str, + lhs: Any, + rhs: Any = None, + *, + dtype: str = "unknown"): + self.op = op + self.lhs = lhs + self.rhs = rhs + self.dtype = dtype + + def __repr__(self) -> str: + if self.op in ("neg", "cast"): + return f"ParameterExpression({self.op}, {self.lhs!r}, {self.dtype})" + return f"ParameterExpression({self.op}, {self.lhs!r}, {self.rhs!r})" + + +def is_symbolic(value: Any) -> bool: + """Return whether *value* is a symbolic parameter or expression.""" + return isinstance(value, _SymbolicNumber) + + +class IRValue: + __slots__ = ("vid", "vtype", "name") + + def __init__(self, vid: int, vtype: str, name: str = ""): + self.vid = vid + self.vtype = vtype + self.name = name + + def __repr__(self) -> str: + return f"{self.name or f'%v{self.vid}'}:{self.vtype}" + + +LINEAR_TYPES = frozenset({"drive_line", "readout_line", "tone"}) + +# (n_value_args, attr_names, result_types) +# n_value_args: leading args that are IR values; -1 = variadic all-values +# result_types: None = mirror operand types +OP_TABLE: dict[str, tuple[int, tuple[str, ...], tuple[str, ...] | None]] = { + "get_drive_line": (1, (), ("drive_line", "tone")), + "get_readout_line": (1, (), ("readout_line", "tone")), + "gaussian": (0, ("duration", "amplitude", "sigma"), ("waveform",)), + "square": (0, ("duration", "amplitude"), ("waveform",)), + "drag": (0, ("duration", "amplitude", "sigma", "beta"), ("waveform",)), + "cosine": (0, ("duration", "amplitude"), ("waveform",)), + "tanh_ramp": (0, ("duration", "amplitude", "sigma"), ("waveform",)), + "gaussian_square": + (0, ("duration", "amplitude", "sigma", "width"), ("waveform",)), + "custom": (0, ("duration", "name"), ("waveform",)), + "custom_samples": (0, ("samples",), ("waveform",)), + "drive": (3, (), ("drive_line", "tone")), + "readout": (3, (), ("readout_line", "tone", "measurement")), + "wait": (1, ("duration",), None), + "sync": (-1, (), None), + "shift_phase": (1, ("phase_rad",), ("tone",)), + "set_phase": (1, ("phase_rad",), ("tone",)), + "shift_frequency": (1, ("freq_hz",), ("tone",)), + "set_frequency": (1, ("freq_hz",), ("tone",)), + "wf_add": (2, (), ("waveform",)), + "wf_sub": (2, (), ("waveform",)), + "wf_mul": (2, (), ("waveform",)), + "wf_scale": (1, ("scale",), ("waveform",)), + "wf_neg": (1, (), ("waveform",)), + "qudit_ref": (0, (), ("qref",)), + "qvec_ref": (0, ("size",), ("qref",)), +} + + +class PythonIRBuilder: + """Lightweight in-memory IR builder (swap for real MLIR bindings).""" + + def __init__(self, name: str = "main"): + self.name = name + self.ops: list[Op] = [] + self._next_id = 0 + + def _mk(self, vtype: str, name: str = "") -> IRValue: + v = IRValue(self._next_id, vtype, name) + self._next_id += 1 + return v + + def emit( + self, + kind: str, + operands: tuple[IRValue, ...] = (), + result_types: tuple[str, ...] = (), + attrs: dict[str, Any] | None = None, + ) -> tuple[IRValue, ...]: + results = tuple(self._mk(rt) for rt in result_types) + self.ops.append(Op(kind, operands, results, attrs or {})) + return results + + def pretty(self) -> str: + lines = [f"func.func @{self.name}() {{"] + for op in self.ops: + res = ", ".join(repr(r) for r in op.results) + ops_s = ", ".join(repr(o) for o in op.operands) + att = ", ".join(f"{k}={v!r}" for k, v in op.attrs.items()) + parts = [s for s in (ops_s, att) if s] + lines.append(f" {res} = {op.kind}({', '.join(parts)})") + lines.append("}") + return "\n".join(lines) diff --git a/pulse/core/frontend/cudaq_pulse/kernel/packed_ir_builder.py b/pulse/core/frontend/cudaq_pulse/kernel/packed_ir_builder.py new file mode 100644 index 00000000000..1eb0fdff155 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/kernel/packed_ir_builder.py @@ -0,0 +1,686 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""PackedIRBuilder -- fused tracer-to-packed-buffer emitter. + +Replaces PythonIRBuilder + lower.py + pack_program on the fast path. +Writes directly into a flat numpy int64 array during kernel tracing, +producing a buffer that ``PulseModuleBuilder.build_from_packed()`` +consumes via zero-copy FFI. +""" + +from __future__ import annotations + +import struct +from typing import Any + +import numpy as np + +from .ir_builder import CompilationError, IRValue, Parameter, ParameterExpression, is_symbolic + +_pack_d = struct.Struct("=d") +_unpack_q = struct.Struct("=q") + +# OpCodes -- must match packed_emit.py and bindings.cpp kOp* constants +_ALLOC_DRIVE = 0 +_ALLOC_READOUT = 1 +_ALLOC_TONE = 2 +_WF_GAUSSIAN = 3 +_WF_SQUARE = 4 +_WF_DRAG = 5 +_WF_COSINE = 6 +_WF_TANH_RAMP = 7 +_WF_GAUSS_SQUARE = 8 +_WF_CUSTOM = 9 +_DRIVE = 10 +_READOUT = 11 +_SYNC = 12 +_WAIT = 13 +_SHIFT_PHASE = 14 +_SET_PHASE = 15 +_SHIFT_FREQ = 16 +_SET_FREQ = 17 +_PARAM = 18 +_NUM_CONST = 19 +_NUM_BINARY = 20 +_NUM_NEG = 21 +_NUM_CAST = 22 +_WF_CUSTOM_SAMPLES = 23 +_WF_ADD = 24 +_WF_SUB = 25 +_WF_MUL = 26 +_WF_SCALE = 27 +_WF_NEG = 28 + +_NUMERIC_TYPE = {"i64": 0, "f64": 1} +_BINARY_OP = { + "add": 0, + "sub": 1, + "mul": 2, + "div": 3, + "floordiv": 4, + "mod": 5, +} + +_WF_MAP = { + "gaussian": _WF_GAUSSIAN, + "square": _WF_SQUARE, + "drag": _WF_DRAG, + "cosine": _WF_COSINE, + "tanh_ramp": _WF_TANH_RAMP, + "gaussian_square": _WF_GAUSS_SQUARE, +} + +_UNSCHEDULED = -1 + + +def _f2i(x: float) -> int: + return _unpack_q.unpack(_pack_d.pack(float(x)))[0] + + +def _header(opcode: int, payload_len: int, param_mask: int = 0) -> int: + return opcode | (payload_len << 8) | (param_mask << 16) + + +def _real(val: Any) -> float: + return val.real if isinstance(val, complex) else float(val) + + +def _complex_parts(val: Any) -> tuple[float, float]: + if isinstance(val, complex): + return (val.real, val.imag) + return (float(val), 0.0) + + +def _as_i64(value: Any, description: str) -> int: + """Convert an integer-valued Python number without silent truncation.""" + if isinstance(value, bool): + raise CompilationError(f"{description} must be an integer, got bool") + if isinstance(value, (int, np.integer)): + return int(value) + if isinstance(value, (float, np.floating)) and float(value).is_integer(): + return int(value) + raise CompilationError( + f"{description} must be an integer number of virtual time units, got {value!r}" + ) + + +class PackedIRBuilder: + """Fused trace-to-buffer builder. + + Implements the same ``emit()`` interface as ``PythonIRBuilder`` so it + can be dropped into the ``PulseIREmitter`` unchanged. Instead of + accumulating Python objects, it writes packed int64 records into a + pre-allocated numpy buffer. + """ + + def __init__( + self, + name: str = "main", + *, + clock_ghz: float = 2.0, + qubit_freq_hz: dict[int, float] | None = None, + ): + self.name = name + self.clock_ghz = clock_ghz + self._freq_hz = qubit_freq_hz or {} + self._buf = np.empty(8192, dtype=np.int64) + self._cur = 0 + self._next_id = 0 + self._next_qubit = 0 + self._op_count = 0 + self._qref_to_qubit: dict[int, int] = {} + self._wf_attrs: dict[int, dict[str, Any]] = {} + self._param_vids: dict[int, int] = {} # Parameter.index → vid + # Key by the expression object itself, not id(expression): temporary + # expressions may be collected and Python may reuse their integer IDs. + self._numeric_vids: dict[tuple[ParameterExpression, str], int] = {} + + def _mk(self, vtype: str, name: str = "") -> IRValue: + v = IRValue(self._next_id, vtype, name) + self._next_id += 1 + return v + + def _ensure(self, n: int) -> None: + while self._cur + n >= len(self._buf): + self._buf = np.resize(self._buf, len(self._buf) * 2) + + def _w(self, val: int) -> None: + self._buf[self._cur] = val + self._cur += 1 + + def emit( + self, + kind: str, + operands: tuple[IRValue, ...] = (), + result_types: tuple[str, ...] = (), + attrs: dict[str, Any] | None = None, + ) -> tuple[IRValue, ...]: + a = attrs or {} + results = tuple(self._mk(rt) for rt in result_types) + self._op_count += 1 + + # --- Qubit alloc (not encoded, just tracked) --- + if kind in ("pulse.qudit_arg", "pulse.qudit_alloc"): + idx = a.get("index", self._next_qubit) + for r in results: + self._qref_to_qubit[r.vid] = idx + self._next_qubit = max(self._next_qubit, idx + 1) + return results + + # --- get_drive_line → ALLOC_DRIVE --- + if kind == "pulse.get_drive_line": + qref_vid = operands[0].vid if operands else None + qubit = self._qref_to_qubit.get(qref_vid, + 0) if qref_vid is not None else 0 + self._ensure(4) + self._w(_header(_ALLOC_DRIVE, 3)) + self._w(qubit) + self._w(results[0].vid) # line_vid + self._w(results[1].vid) # tone_vid + return results + + # --- get_readout_line → ALLOC_READOUT --- + if kind == "pulse.get_readout_line": + qref_vid = operands[0].vid if operands else None + qubit = self._qref_to_qubit.get(qref_vid, + 0) if qref_vid is not None else 0 + self._ensure(4) + self._w(_header(_ALLOC_READOUT, 3)) + self._w(qubit) + self._w(results[0].vid) + self._w(results[1].vid) + return results + + # --- Waveform constructors --- + wf_name = kind.removeprefix("pulse.") + wf_code = _WF_MAP.get(wf_name) + if wf_code is not None or wf_name in ("custom", "custom_samples"): + rv = results[0].vid + dur_raw = a.get("duration", 0) + dur = dur_raw if is_symbolic(dur_raw) else _as_i64( + dur_raw, f"{wf_name} duration") + wf_attrs = {"waveform_type": wf_name} + if not is_symbolic(dur): + wf_attrs["duration_vtu"] = dur + if "amplitude" in a: + wf_attrs["amplitude"] = a["amplitude"] + for k, v in a.items(): + if k not in ("duration", "amplitude"): + wf_attrs[k] = v + self._wf_attrs[rv] = wf_attrs + + if wf_code == _WF_GAUSSIAN: + amp_raw = a.get("amplitude", 0.0) + sig_raw = a.get("sigma", 1.0) + vals = [dur, amp_raw, sig_raw] + self._emit_wf(_WF_GAUSSIAN, rv, vals, ["i64", "f64", "f64"]) + elif wf_code == _WF_SQUARE: + amp_raw = a.get("amplitude", 0.0) + if is_symbolic(amp_raw): + vals = [dur, amp_raw, 0.0] + else: + re, im = _complex_parts(amp_raw) + vals: list = [dur, re, im] + self._emit_wf(_WF_SQUARE, rv, vals, ["i64", "f64", "f64"]) + elif wf_code == _WF_DRAG: + amp_raw = a.get("amplitude", 0.0) + sig_raw = a.get("sigma", 1.0) + beta_raw = a.get("beta", 0.0) + vals = [dur, amp_raw, sig_raw, beta_raw] + self._emit_wf(_WF_DRAG, rv, vals, ["i64", "f64", "f64", "f64"]) + elif wf_code == _WF_COSINE: + amp_raw = a.get("amplitude", 0.0) + vals = [dur, amp_raw] + self._emit_wf(_WF_COSINE, rv, vals, ["i64", "f64"]) + elif wf_code == _WF_TANH_RAMP: + amp_raw = a.get("amplitude", 0.0) + sig_raw = a.get("sigma", 1.0) + vals = [dur, amp_raw, sig_raw] + self._emit_wf(_WF_TANH_RAMP, rv, vals, ["i64", "f64", "f64"]) + elif wf_code == _WF_GAUSS_SQUARE: + amp_raw = a.get("amplitude", 0.0) + sig_raw = a.get("sigma", 1.0) + width_raw = a.get("width", 0) + # The Python API specifies flat-top width while the dialect + # stores the duration of each rise/fall edge. + if not is_symbolic(width_raw): + width_raw = _as_i64(width_raw, + "gaussian_square flat-top width") + if not is_symbolic(dur) and not is_symbolic(width_raw): + if width_raw < 0 or width_raw >= dur: + raise CompilationError( + "gaussian_square width must satisfy " + f"0 <= width < duration, got {width_raw} and {dur}") + if (dur - width_raw) % 2: + raise CompilationError( + "gaussian_square requires duration - width to be " + "even so its two edges have equal integer length") + rf_raw = (dur - width_raw) // 2 + vals = [dur, amp_raw, sig_raw, rf_raw] + self._emit_wf(_WF_GAUSS_SQUARE, rv, vals, + ["i64", "f64", "f64", "i64"]) + elif wf_name == "custom_samples": + self._emit_custom_samples(rv, a.get("samples", ())) + else: + self._emit_custom(rv, dur, a.get("name", "custom")) + return results + + # --- drive --- + if kind == "pulse.drive": + lv = operands[0].vid + wv = operands[1].vid + tv = operands[2].vid + rlv = results[0].vid + rtv = results[1].vid + self._ensure(8) + self._w(_header(_DRIVE, 7)) + self._w(lv) + self._w(wv) + self._w(tv) + self._w(rlv) + self._w(rtv) + self._w(_UNSCHEDULED) # start_vtu (set by C++ scheduler) + self._w(_UNSCHEDULED) # duration_vtu (set by C++ scheduler) + return results + + # --- readout --- + if kind == "pulse.readout": + lv = operands[0].vid + wv = operands[1].vid + tv = operands[2].vid + rlv = results[0].vid + rtv = results[1].vid + mv = results[2].vid + self._ensure(7) + self._w(_header(_READOUT, 6)) + self._w(lv) + self._w(wv) + self._w(tv) + self._w(rlv) + self._w(rtv) + self._w(mv) + return results + + # --- sync --- + if kind == "pulse.sync": + n = len(operands) + payload_len = 1 + 3 * n + self._ensure(1 + payload_len) + self._w(_header(_SYNC, payload_len)) + self._w(n) + _LINE_TYPES = {"drive_line": 0, "readout_line": 1} + for j in range(n): + in_vid = operands[j].vid + out_vid = results[j].vid if j < len(results) else in_vid + vtype = _LINE_TYPES.get( + results[j].vtype if j < len(results) else "drive_line", 0) + self._w(in_vid) + self._w(out_vid) + self._w(vtype) + return results + + # --- wait --- + if kind == "pulse.wait": + lv = operands[0].vid + rlv = results[0].vid + dur_raw = a.get("duration", 0) + if is_symbolic(dur_raw): + pvid = self._get_numeric_vid(dur_raw, "i64") + self._ensure(4) + self._w(_header(_WAIT, 3, 1 << 2)) + self._w(lv) + self._w(rlv) + self._w(pvid) + else: + dur = _as_i64(dur_raw, "wait duration") + self._ensure(4) + self._w(_header(_WAIT, 3)) + self._w(lv) + self._w(rlv) + self._w(dur) + return results + + # --- shift_phase --- + if kind == "pulse.shift_phase": + tv = operands[0].vid + rtv = results[0].vid + val_raw = a.get("phase_rad", 0.0) + if is_symbolic(val_raw): + pvid = self._get_numeric_vid(val_raw, "f64") + self._ensure(4) + self._w(_header(_SHIFT_PHASE, 3, 1 << 2)) + self._w(tv) + self._w(rtv) + self._w(pvid) + else: + delta = float(val_raw) + self._ensure(4) + self._w(_header(_SHIFT_PHASE, 3)) + self._w(tv) + self._w(rtv) + self._w(_f2i(delta)) + return results + + # --- set_phase --- + if kind == "pulse.set_phase": + tv = operands[0].vid + rtv = results[0].vid + val_raw = a.get("phase_rad", 0.0) + if is_symbolic(val_raw): + pvid = self._get_numeric_vid(val_raw, "f64") + self._ensure(4) + self._w(_header(_SET_PHASE, 3, 1 << 2)) + self._w(tv) + self._w(rtv) + self._w(pvid) + else: + phase = float(val_raw) + self._ensure(4) + self._w(_header(_SET_PHASE, 3)) + self._w(tv) + self._w(rtv) + self._w(_f2i(phase)) + return results + + # --- shift_frequency --- + if kind == "pulse.shift_frequency": + tv = operands[0].vid + rtv = results[0].vid + val_raw = a.get("freq_hz", 0.0) + if is_symbolic(val_raw): + pvid = self._get_numeric_vid(val_raw, "f64") + self._ensure(4) + self._w(_header(_SHIFT_FREQ, 3, 1 << 2)) + self._w(tv) + self._w(rtv) + self._w(pvid) + else: + freq = float(val_raw) + self._ensure(4) + self._w(_header(_SHIFT_FREQ, 3)) + self._w(tv) + self._w(rtv) + self._w(_f2i(freq)) + return results + + # --- set_frequency --- + if kind == "pulse.set_frequency": + tv = operands[0].vid + rtv = results[0].vid + val_raw = a.get("freq_hz", 0.0) + if is_symbolic(val_raw): + pvid = self._get_numeric_vid(val_raw, "f64") + self._ensure(4) + self._w(_header(_SET_FREQ, 3, 1 << 2)) + self._w(tv) + self._w(rtv) + self._w(pvid) + else: + freq = float(val_raw) + self._ensure(4) + self._w(_header(_SET_FREQ, 3)) + self._w(tv) + self._w(rtv) + self._w(_f2i(freq)) + return results + + # SCF operations are rejected or unrolled by the emitter. + if kind.startswith("scf."): + raise CompilationError( + f"structured control flow op {kind!r} cannot be packed") + + algebra_opcodes = { + "pulse.wf_add": _WF_ADD, + "pulse.wf_sub": _WF_SUB, + "pulse.wf_mul": _WF_MUL, + "pulse.wf_neg": _WF_NEG, + } + if kind in algebra_opcodes: + rv = results[0].vid + self._ensure(2 + len(operands)) + self._w(_header(algebra_opcodes[kind], 1 + len(operands))) + self._w(rv) + for operand in operands: + self._w(operand.vid) + return results + + if kind == "pulse.wf_scale": + rv = results[0].vid + scale_vid = self._get_numeric_vid(a["scale"], "f64") + self._ensure(4) + self._w(_header(_WF_SCALE, 3)) + self._w(rv) + self._w(operands[0].vid) + self._w(scale_vid) + return results + + raise CompilationError(f"unsupported packed pulse op: {kind}") + + def _get_parameter_base_vid(self, param: Parameter, + expected_dtype: str) -> int: + """Get a block-argument reference and infer its storage type.""" + if param.dtype == "unknown": + param.dtype = expected_dtype + if param.index in self._param_vids: + return self._param_vids[param.index] + vid = self._next_id + self._next_id += 1 + self._param_vids[param.index] = vid + self._ensure(3) + self._w(_header(_PARAM, 2)) + self._w(vid) + self._w(param.index) + return vid + + def _get_numeric_vid(self, value: Any, expected_dtype: str) -> int: + """Materialize a symbolic or literal number as an SSA value record.""" + if expected_dtype not in _NUMERIC_TYPE: + raise CompilationError(f"unsupported numeric type {expected_dtype}") + + if isinstance(value, Parameter): + base = self._get_parameter_base_vid(value, expected_dtype) + if value.dtype == expected_dtype: + return base + return self._emit_numeric_cast(base, value.dtype, expected_dtype) + + if isinstance(value, ParameterExpression): + cache_key = (value, expected_dtype) + if cache_key in self._numeric_vids: + return self._numeric_vids[cache_key] + + if value.op == "cast": + target_dtype = value.dtype + source_dtype = getattr(value.lhs, "dtype", "unknown") + if source_dtype == "unknown": + source_dtype = "f64" if target_dtype == "i64" else "i64" + source = self._get_numeric_vid(value.lhs, source_dtype) + result = self._emit_numeric_cast(source, source_dtype, + target_dtype) + if target_dtype != expected_dtype: + result = self._emit_numeric_cast(result, target_dtype, + expected_dtype) + self._numeric_vids[cache_key] = result + return result + + dtype = "f64" if value.op == "div" else expected_dtype + if value.op in ("floordiv", "mod"): + dtype = "i64" + if value.op == "pow": + raise CompilationError( + "symbolic exponentiation is not supported; specialize " + "that value before compilation") + if value.op == "neg": + operand = self._get_numeric_vid(value.lhs, dtype) + result = self._next_id + self._next_id += 1 + self._ensure(4) + self._w(_header(_NUM_NEG, 3)) + self._w(result) + self._w(_NUMERIC_TYPE[dtype]) + self._w(operand) + else: + opcode = _BINARY_OP.get(value.op) + if opcode is None: + raise CompilationError( + f"unsupported symbolic operation {value.op!r}") + lhs = self._get_numeric_vid(value.lhs, dtype) + rhs = self._get_numeric_vid(value.rhs, dtype) + result = self._next_id + self._next_id += 1 + self._ensure(6) + self._w(_header(_NUM_BINARY, 5)) + self._w(result) + self._w(_NUMERIC_TYPE[dtype]) + self._w(opcode) + self._w(lhs) + self._w(rhs) + if dtype != expected_dtype: + result = self._emit_numeric_cast(result, dtype, expected_dtype) + self._numeric_vids[cache_key] = result + return result + + result = self._next_id + self._next_id += 1 + self._ensure(4) + self._w(_header(_NUM_CONST, 3)) + self._w(result) + self._w(_NUMERIC_TYPE[expected_dtype]) + self._w( + _as_i64(value, "integer expression") if expected_dtype == + "i64" else _f2i(float(value))) + return result + + def _emit_numeric_cast(self, operand: int, source_dtype: str, + target_dtype: str) -> int: + if source_dtype == target_dtype: + return operand + result = self._next_id + self._next_id += 1 + self._ensure(5) + self._w(_header(_NUM_CAST, 4)) + self._w(result) + self._w(_NUMERIC_TYPE[source_dtype]) + self._w(_NUMERIC_TYPE[target_dtype]) + self._w(operand) + return result + + def _emit_wf(self, wf_code: int, rv: int, vals: list, + dtypes: list[str]) -> None: + """Emit a waveform record, handling mixed Parameter/concrete values. + + For each value slot, if the value is a Parameter, emit a PARAM record + first and encode the param vid. A param_mask bit flags parametric slots + so the C++ decoder can distinguish vids from literals. + """ + encoded: list[int] = [] + param_mask = 0 + for i, (v, dtype) in enumerate(zip(vals, dtypes)): + if is_symbolic(v): + pvid = self._get_numeric_vid(v, dtype) + encoded.append(pvid) + param_mask |= 1 << (i + 1) # +1 because slot 0 is the rv + elif dtype == "i64": + encoded.append(_as_i64(v, "integer operand")) + else: + encoded.append(_f2i(float(v))) + payload_len = 1 + len(encoded) # rv + values + self._ensure(1 + payload_len) + self._w(_header(wf_code, payload_len, param_mask)) + self._w(rv) + for e in encoded: + self._w(e) + + def _emit_custom(self, rv: int, duration: Any, callback: Any) -> None: + name = callback if isinstance(callback, str) else getattr( + callback, "__name__", "custom") + encoded = name.encode("utf-8") + if not encoded: + raise CompilationError("custom waveform callback name is empty") + padded = encoded + b"\0" * ((8 - len(encoded) % 8) % 8) + words = [ + struct.unpack("=q", padded[i:i + 8])[0] + for i in range(0, len(padded), 8) + ] + if 3 + len(words) > 255: + raise CompilationError("custom waveform callback name is too long") + param_mask = 0 + if is_symbolic(duration): + duration_word = self._get_numeric_vid(duration, "i64") + param_mask |= 1 << 1 + else: + duration_word = _as_i64(duration, "custom waveform duration") + self._ensure(4 + len(words)) + self._w(_header(_WF_CUSTOM, 3 + len(words), param_mask)) + self._w(rv) + self._w(duration_word) + self._w(len(encoded)) + for word in words: + self._w(word) + + def _emit_custom_samples(self, rv: int, samples: Any) -> None: + try: + sample_values = list(samples) + except TypeError as exc: + raise CompilationError( + "custom_samples() requires a finite sequence") from exc + if len(sample_values) > 253: + raise CompilationError( + "custom_samples() supports at most 253 samples per waveform") + if not sample_values: + raise CompilationError( + "custom_samples() requires at least one sample") + encoded: list[int] = [] + for sample in sample_values: + value = complex(sample) + if value.imag != 0.0: + raise CompilationError( + "complex custom samples are not supported yet; provide " + "real-valued envelope samples") + encoded.append(_f2i(value.real)) + self._ensure(3 + len(encoded)) + self._w(_header(_WF_CUSTOM_SAMPLES, 2 + len(encoded))) + self._w(rv) + self._w(len(encoded)) + for word in encoded: + self._w(word) + + @property + def param_names(self) -> list[str]: + """Return parameter names in index order.""" + if not self._param_vids: + return [] + max_idx = max(self._param_vids.keys()) + names: list[str] = [""] * (max_idx + 1) + return names + + @property + def has_parameters(self) -> bool: + return bool(self._param_vids) + + def get_buffer(self) -> np.ndarray: + """Return the trimmed packed buffer.""" + return self._buf[:self._cur].copy() + + def get_freq_array(self) -> np.ndarray: + """Return qubit frequencies as a float64 array indexed by qubit.""" + arr = np.zeros(self._next_qubit, dtype=np.float64) + for q, f in self._freq_hz.items(): + if q < self._next_qubit: + arr[q] = f + return arr + + @property + def n_qubits(self) -> int: + return self._next_qubit + + @property + def op_count(self) -> int: + return self._op_count + + def pretty(self) -> str: + return f"" diff --git a/pulse/core/frontend/cudaq_pulse/kernel/symbol_table.py b/pulse/core/frontend/cudaq_pulse/kernel/symbol_table.py new file mode 100644 index 00000000000..d4b8e189b07 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/kernel/symbol_table.py @@ -0,0 +1,58 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +LINEAR_TYPES = frozenset({"drive_line", "readout_line", "tone"}) +VALUE_TYPES = frozenset({"waveform", "duration", "measurement", "iq_data"}) + + +class SymbolTable: + """Manages name -> mlir.Value mappings with lexical scoping. + + Linear-typed values (drive_line, readout_line, tone) are automatically + rebound when an op produces a new SSA value of the same type: the + *source operand's* name is located and updated in-place. + + Value-typed results (waveform, duration, measurement, iq_data) must be + captured via explicit ``=`` assignment in the source program. + """ + + def __init__(self) -> None: + self._scopes: List[Dict[str, Any]] = [{}] + + def push_scope(self) -> None: + self._scopes.append({}) + + def pop_scope(self) -> Dict[str, Any]: + if len(self._scopes) <= 1: + raise RuntimeError("Cannot pop the global scope") + return self._scopes.pop() + + def bind(self, name: str, value: Any) -> None: + self._scopes[-1][name] = value + + def lookup(self, name: str) -> Optional[Any]: + for scope in reversed(self._scopes): + if name in scope: + return scope[name] + return None + + def rebind_linear(self, operand_value: Any, new_value: Any) -> None: + """Find the name currently bound to *operand_value* and rebind it + to *new_value*. Used for ops that consume and re-produce a + linear-typed SSA value (e.g. ``drive`` consumes a drive_line and + yields an updated one).""" + for scope in reversed(self._scopes): + for name, val in scope.items(): + if val is operand_value: + scope[name] = new_value + return + raise KeyError("operand value not found in any scope") diff --git a/pulse/core/frontend/cudaq_pulse/lower.py b/pulse/core/frontend/cudaq_pulse/lower.py new file mode 100644 index 00000000000..ee7a91a3348 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/lower.py @@ -0,0 +1,304 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Convert kernel IR (PythonIRBuilder) to pass-level IR (Program). + +This module bridges the gap between the frontend ``@cudaq_pulse.kernel`` +compilation output and the passes that operate on ``Program`` objects. +""" + +from __future__ import annotations + +from typing import Any + +from .kernel.ir_builder import ( + CompilationError, + IRValue, + Op as KernelOp, + PythonIRBuilder, +) +from .passes.ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, +) + +_VTYPE_MAP: dict[str, ValueType] = { + "drive_line": ValueType.DRIVE_LINE, + "readout_line": ValueType.READOUT_LINE, + "tone": ValueType.TONE, + "waveform": ValueType.WAVEFORM, + "qref": ValueType.QREF, + "measurement": ValueType.MEASUREMENT, + "iq_data": ValueType.IQ_DATA, +} + +_WAVEFORM_OPS = frozenset({ + "pulse.gaussian", + "pulse.square", + "pulse.drag", + "pulse.cosine", + "pulse.tanh_ramp", + "pulse.gaussian_square", + "pulse.custom", + "pulse.custom_samples", +}) + +_KIND_MAP: dict[str, str] = { + "pulse.drive": OpKind.DRIVE, + "pulse.readout": OpKind.READOUT, + "pulse.wait": OpKind.WAIT, + "pulse.sync": OpKind.SYNC, + "pulse.shift_phase": OpKind.SHIFT_PHASE, + "pulse.set_phase": OpKind.SET_PHASE, + "pulse.shift_frequency": OpKind.SHIFT_FREQUENCY, + "pulse.set_frequency": OpKind.SET_FREQUENCY, + "scf.for": OpKind.FOR_LOOP, + "scf.for_end": OpKind.END_FOR, +} + + +def _to_program( + ir: PythonIRBuilder, + *, + clock_ghz: float = 2.0, + qubit_freq_hz: dict[int, float] | None = None, +) -> Program: + """Lower kernel IR to a pass-level Program. + + Parameters + ---------- + ir : PythonIRBuilder + The IR produced by calling a ``@cudaq_pulse.kernel`` function. + clock_ghz : float + System clock frequency in GHz (VTU scaling). + qubit_freq_hz : dict[int, float] | None + Mapping from qubit index to qubit frequency in Hz. + When provided, ``get_drive_line`` / ``get_readout_line`` ops + are annotated with the target frequency. + """ + freq_hz = qubit_freq_hz or {} + val_map: dict[int, Value] = {} + qref_to_qubit: dict[int, int] = {} + wf_attrs: dict[int, dict[str, Any]] = {} + next_qubit = 0 + ops: list[Op] = [] + values: list[Value] = [] + collected_freqs: dict[int, float] = {} + + def _map_val(iv: IRValue) -> Value: + if iv.vid in val_map: + return val_map[iv.vid] + vtype = _VTYPE_MAP.get(iv.vtype) + if vtype is None: + raise CompilationError( + f"unknown IR value type {iv.vtype!r} for %{iv.vid} " + f"(name={iv.name!r}); add it to _VTYPE_MAP in lower.py") + v = Value(vid=iv.vid, vtype=vtype, name=iv.name) + val_map[iv.vid] = v + values.append(v) + return v + + for kop in ir.ops: + kind = kop.kind + + # Qubit argument / alloc → track qubit index + if kind in ("pulse.qudit_arg", "pulse.qudit_alloc"): + for r in kop.results: + v = _map_val(r) + idx = kop.attrs.get("index", next_qubit) + qref_to_qubit[r.vid] = idx + next_qubit = max(next_qubit, idx + 1) if kop.results else next_qubit + continue + + # get_drive_line / get_readout_line → ALLOC + if kind == "pulse.get_drive_line": + qref_vid = kop.operands[0].vid if kop.operands else None + qubit_idx = qref_to_qubit.get(qref_vid, + 0) if qref_vid is not None else 0 + if qubit_idx not in freq_hz: + raise CompilationError( + f"no frequency provided for qubit {qubit_idx}; " + f"pass qubit_freq_hz={{...}} to to_program()") + fhz = freq_hz[qubit_idx] + collected_freqs[qubit_idx] = fhz + result_vals = tuple(_map_val(r) for r in kop.results) + ops.append( + Op( + kind=OpKind.ALLOC_DRIVE, + operands=(), + results=result_vals, + attrs={ + "qubit": qubit_idx, + "frequency_hz": fhz + }, + )) + continue + + if kind == "pulse.get_readout_line": + qref_vid = kop.operands[0].vid if kop.operands else None + qubit_idx = qref_to_qubit.get(qref_vid, + 0) if qref_vid is not None else 0 + if qubit_idx not in freq_hz: + raise CompilationError( + f"no frequency provided for qubit {qubit_idx}; " + f"pass qubit_freq_hz={{...}} to to_program()") + fhz = freq_hz[qubit_idx] + collected_freqs[qubit_idx] = fhz + result_vals = tuple(_map_val(r) for r in kop.results) + ops.append( + Op( + kind=OpKind.ALLOC_READOUT, + operands=(), + results=result_vals, + attrs={ + "qubit": qubit_idx, + "frequency_hz": fhz + }, + )) + continue + + # Waveform constructors → MAKE_WAVEFORM + if kind in _WAVEFORM_OPS: + wf_type = kind.removeprefix("pulse.") + result_vals = tuple(_map_val(r) for r in kop.results) + attrs = {"waveform_type": wf_type} + if "duration" in kop.attrs: + attrs["duration_vtu"] = kop.attrs["duration"] + if "amplitude" in kop.attrs: + attrs["amplitude"] = kop.attrs["amplitude"] + for k, v in kop.attrs.items(): + if k not in ("duration", "amplitude"): + attrs[k] = v + if wf_type == "custom_samples": + attrs["duration_vtu"] = len(attrs.get("samples", ())) + if result_vals: + wf_attrs[result_vals[0].vid] = attrs + ops.append( + Op( + kind=OpKind.MAKE_WAVEFORM, + operands=(), + results=result_vals, + attrs=attrs, + )) + continue + + # Drive / readout — annotate with duration from waveform + if kind in ("pulse.drive", "pulse.readout"): + operand_vals = tuple(_map_val(o) for o in kop.operands) + result_vals = tuple(_map_val(r) for r in kop.results) + attrs = dict(kop.attrs) + for ov in operand_vals: + if ov.vtype == ValueType.WAVEFORM and ov.vid in wf_attrs: + wa = wf_attrs[ov.vid] + attrs.setdefault("duration_vtu", wa.get("duration_vtu", 0)) + attrs.setdefault("amplitude", wa.get("amplitude", 0)) + attrs.setdefault("waveform_type", + wa.get("waveform_type", "")) + pass_kind = _KIND_MAP.get(kind, kind) + ops.append( + Op( + kind=pass_kind, + operands=operand_vals, + results=result_vals, + attrs=attrs, + )) + continue + + # Wait → copy duration attr + if kind == "pulse.wait": + operand_vals = tuple(_map_val(o) for o in kop.operands) + result_vals = tuple(_map_val(r) for r in kop.results) + attrs = {} + if "duration" in kop.attrs: + attrs["duration_vtu"] = kop.attrs["duration"] + else: + attrs.update(kop.attrs) + ops.append( + Op( + kind=OpKind.WAIT, + operands=operand_vals, + results=result_vals, + attrs=attrs, + )) + continue + + # Shift/set phase or frequency — rename frontend attribute keys. + if kind in ( + "pulse.shift_phase", + "pulse.set_phase", + "pulse.shift_frequency", + "pulse.set_frequency", + ): + operand_vals = tuple(_map_val(o) for o in kop.operands) + result_vals = tuple(_map_val(r) for r in kop.results) + attrs = {} + if kind in ("pulse.shift_phase", + "pulse.set_phase") and "phase_rad" in kop.attrs: + if kind == "pulse.shift_phase": + attrs["delta_rad"] = kop.attrs["phase_rad"] + else: + attrs["phase_rad"] = kop.attrs["phase_rad"] + elif (kind in ("pulse.shift_frequency", "pulse.set_frequency") and + "freq_hz" in kop.attrs): + attrs["frequency_hz"] = kop.attrs["freq_hz"] + else: + attrs.update(kop.attrs) + pass_kind = _KIND_MAP[kind] + ops.append( + Op( + kind=pass_kind, + operands=operand_vals, + results=result_vals, + attrs=attrs, + )) + continue + + if kind in ("scf.if", "scf.else", "scf.if_end"): + raise CompilationError( + f"conditional control flow ({kind}) is not yet supported " + f"in the pass IR. Mid-circuit measurement branching requires " + f"IF_BEGIN/IF_END lowering (not yet implemented).") + + if kind == "scf.yield": + continue + + # Generic mapping for remaining ops + operand_vals = tuple(_map_val(o) for o in kop.operands) + result_vals = tuple(_map_val(r) for r in kop.results) + pass_kind = _KIND_MAP.get(kind, kind) + ops.append( + Op( + kind=pass_kind, + operands=operand_vals, + results=result_vals, + attrs=dict(kop.attrs), + )) + + # Allocation-driven register sizing: the simulated Hilbert space is + # defined by the qudits the kernel allocates (``qudit_arg`` / + # ``qudit_alloc``), not merely the ones it drives. Allocated-but-idle + # qudits stay in |0>, so the returned state has a predictable shape: + # N allocated qudits => 2**N register. Touched frequencies still take + # precedence (they may have been retuned via set_frequency lowering). + program_freqs: dict[int, float] = {} + for qidx in sorted(set(qref_to_qubit.values())): + if qidx in freq_hz: + program_freqs[qidx] = freq_hz[qidx] + program_freqs.update(collected_freqs) + if not program_freqs: + program_freqs = dict(freq_hz) + + return Program( + name=ir.name, + clock_ghz=clock_ghz, + ops=ops, + values=values, + qubit_freq_hz=program_freqs, + ) diff --git a/pulse/core/frontend/cudaq_pulse/native_emit.py b/pulse/core/frontend/cudaq_pulse/native_emit.py new file mode 100644 index 00000000000..439baff585e --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/native_emit.py @@ -0,0 +1,30 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Bridge between Python Program IR and C++ PulseModuleBuilder. + +Provides ``emit_pulse_module_packed`` which delegates to ``packed_emit.py`` +for zero-copy Program → MLIR module construction via the packed-buffer path. +""" +from __future__ import annotations + +from typing import Any + +from .passes.ir_types import Program + + +def emit_pulse_module_packed(prog: Program) -> Any: + """Build an in-memory PulseModule via the packed-buffer zero-copy path. + + Encodes the entire ``Program`` as a flat ``numpy.ndarray[int64]`` + and sends it to C++ in a single FFI call — zero per-op overhead. + + Returns a ``PulseModule`` (from ``_cudaq_pulse_native``). + Raises ImportError if native bindings are not available. + """ + from .packed_emit import emit_pulse_module_packed as _impl + return _impl(prog) diff --git a/pulse/core/frontend/cudaq_pulse/ops/__init__.py b/pulse/core/frontend/cudaq_pulse/ops/__init__.py new file mode 100644 index 00000000000..3489be4888c --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/ops/__init__.py @@ -0,0 +1,363 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Kernel DSL operations for pulse programming. + +These functions form the "language extension" vocabulary used as bare +names inside ``@pulse.kernel`` decorated functions. During bytecode +tracing the decorator intercepts calls to these names and lowers them +to MLIR ops. If called outside a kernel context they raise +``RuntimeError``. +""" + +from __future__ import annotations + +from typing import Any, Callable, Sequence, Union + +from ..kernel.ir_builder import Parameter + +Numeric = Union[int, float, Parameter] + +from ._context import get_active_context + +# ── Opaque type aliases (for IDE hover-docs, not enforced at runtime) ──── + + +class Waveform: + """Opaque waveform handle returned by envelope constructors.""" + + +class Line: + """Opaque drive or readout line handle from ``get_drive_line()``.""" + + +class Tone: + """Opaque tone handle for phase and frequency operations.""" + + +class MeasurementResult: + """Opaque measurement result from ``readout()``.""" + + +def _require_context(op_name: str) -> Any: + ctx = get_active_context() + if ctx is None: + raise RuntimeError( + f"{op_name}() must be called inside a @cudaq_pulse.kernel function") + return ctx + + +# ── Channel access ─────────────────────────────────────────────────────── + + +def get_drive_line(qubit: Any) -> tuple[Line, Tone]: + """Obtain the drive line and tone for a qubit. + + Args: + qubit: A ``QuditRef`` identifying the target qubit. + + Returns: + A ``(line, tone)`` tuple used by ``drive()`` and phase/frequency ops. + """ + return _require_context("get_drive_line") + + +def get_readout_line(qubit: Any) -> tuple[Line, Tone]: + """Obtain the readout line and tone for a qubit. + + Args: + qubit: A ``QuditRef`` identifying the target qubit. + + Returns: + A ``(line, tone)`` tuple used by ``readout()``. + """ + return _require_context("get_readout_line") + + +# ── Scheduling ops ────────────────────────────────────────────────────── + + +def drive(line: Line, waveform: Waveform, tone: Tone) -> None: + """Play a waveform on a drive line. + + Args: + line: Drive line from ``get_drive_line()``. + waveform: Waveform envelope to play (e.g. from ``gaussian()``). + tone: Tone handle from ``get_drive_line()``. + """ + _require_context("drive") + + +def readout(line: Line, waveform: Waveform, tone: Tone) -> MeasurementResult: + """Acquire a measurement through a readout line. + + Args: + line: Readout line from ``get_readout_line()``. + waveform: Readout waveform envelope. + tone: Tone handle from ``get_readout_line()``. + + Returns: + Measurement result handle. + """ + _require_context("readout") + + +def wait(target: Line, duration: Numeric) -> None: + """Insert an idle delay on a line. + + Args: + target: Drive or readout line. + duration: Wait duration in clock cycles. + """ + _require_context("wait") + + +def sync(*targets: Line) -> None: + """Synchronize multiple lines to a common time point. + + All lines are padded to the latest time among them before + subsequent operations proceed. + + Args: + targets: Two or more drive/readout lines to synchronize. + """ + _require_context("sync") + + +# ── Phase / frequency ops ─────────────────────────────────────────────── + + +def shift_phase(tone: Tone, phase: Numeric) -> Tone: + """Add a relative phase offset to a tone's rotating frame. + + Args: + tone: Tone handle (second element of ``get_drive_line()``). + phase: Phase increment in radians. + """ + _require_context("shift_phase") + + +def set_phase(tone: Tone, phase: Numeric) -> Tone: + """Set the absolute phase of a tone's rotating frame. + + Args: + tone: Tone handle. + phase: Absolute phase in radians. + """ + _require_context("set_phase") + + +def shift_frequency(tone: Tone, frequency: Numeric) -> Tone: + """Add a relative frequency offset to a tone. + + Args: + tone: Tone handle. + frequency: Frequency offset in Hz. + """ + _require_context("shift_frequency") + + +def set_frequency(tone: Tone, frequency: Numeric) -> Tone: + """Set the absolute frequency of a tone. + + Args: + tone: Tone handle. + frequency: Absolute frequency in Hz. + """ + _require_context("set_frequency") + + +# ── Waveform constructors ─────────────────────────────────────────────── + + +def gaussian(duration: Numeric, amplitude: Numeric, sigma: Numeric) -> Waveform: + """Create a Gaussian envelope waveform. + + Args: + duration: Pulse duration in clock cycles. + amplitude: Peak amplitude in ``[-1, 1]``. + sigma: Standard deviation in clock cycles. + + Returns: + Waveform value for use with ``drive()`` or waveform arithmetic. + """ + return _require_context("gaussian") + + +def square(duration: Numeric, amplitude: Numeric) -> Waveform: + """Create a flat-top (square) envelope waveform. + + Args: + duration: Pulse duration in clock cycles. + amplitude: Constant amplitude in ``[-1, 1]``. + + Returns: + Waveform value. + """ + return _require_context("square") + + +def drag( + duration: Numeric, + amplitude: Numeric, + sigma: Numeric, + beta: Numeric, +) -> Waveform: + """Create a DRAG (Derivative Removal by Adiabatic Gate) waveform. + + Args: + duration: Pulse duration in clock cycles. + amplitude: Peak amplitude in ``[-1, 1]``. + sigma: Gaussian standard deviation in clock cycles. + beta: DRAG correction coefficient. + + Returns: + Waveform value. + """ + return _require_context("drag") + + +def cosine(duration: Numeric, amplitude: Numeric) -> Waveform: + """Create a raised-cosine envelope waveform. + + Args: + duration: Pulse duration in clock cycles. + amplitude: Peak amplitude in ``[-1, 1]``. + + Returns: + Waveform value. + """ + return _require_context("cosine") + + +def tanh_ramp(duration: Numeric, amplitude: Numeric, + sigma: Numeric) -> Waveform: + """Create a hyperbolic-tangent ramp waveform. + + Args: + duration: Pulse duration in clock cycles. + amplitude: Peak amplitude in ``[-1, 1]``. + sigma: Rise/fall steepness in clock cycles. + + Returns: + Waveform value. + """ + return _require_context("tanh_ramp") + + +def gaussian_square( + duration: Numeric, + amplitude: Numeric, + sigma: Numeric, + width: Numeric, +) -> Waveform: + """Create a Gaussian-square (flat-top Gaussian) waveform. + + A square pulse with Gaussian rise and fall edges. + + Args: + duration: Total pulse duration in clock cycles. + amplitude: Peak amplitude in ``[-1, 1]``. + sigma: Gaussian edge standard deviation in clock cycles. + width: Flat-top width in clock cycles. + + Returns: + Waveform value. + """ + return _require_context("gaussian_square") + + +def custom(duration: int, envelope_fn: Callable[..., complex]) -> Waveform: + """Create a waveform from a callable envelope function. + + Args: + duration: Pulse duration in clock cycles. + envelope_fn: Callable ``f(t) -> complex`` defining the envelope. + + Returns: + Waveform value. + """ + return _require_context("custom") + + +def custom_samples(samples: Sequence[float]) -> Waveform: + """Create a waveform from pre-computed sample data. + + Args: + samples: Non-empty array-like of real envelope sample values. + + Returns: + Waveform value. + """ + return _require_context("custom_samples") + + +# ── Waveform arithmetic ───────────────────────────────────────────────── + + +def wf_add(left: Waveform, right: Waveform) -> Waveform: + """Add two waveforms element-wise. + + Args: + left: First waveform. + right: Second waveform (must have same duration as *left*). + + Returns: + Combined waveform ``left + right``. + """ + return _require_context("wf_add") + + +def wf_sub(left: Waveform, right: Waveform) -> Waveform: + """Subtract two waveforms element-wise. + + Args: + left: First waveform. + right: Second waveform. + + Returns: + Difference waveform ``left - right``. + """ + return _require_context("wf_sub") + + +def wf_mul(left: Waveform, right: Waveform) -> Waveform: + """Multiply two waveforms element-wise. + + Args: + left: First waveform. + right: Second waveform. + + Returns: + Product waveform ``left * right``. + """ + return _require_context("wf_mul") + + +def wf_scale(waveform: Waveform, scalar: Numeric) -> Waveform: + """Scale a waveform by a constant factor. + + Args: + waveform: Waveform to scale. + scalar: Real scaling factor. + + Returns: + Scaled waveform ``scalar * waveform``. + """ + return _require_context("wf_scale") + + +def wf_neg(waveform: Waveform) -> Waveform: + """Negate a waveform (flip sign of all samples). + + Args: + waveform: Waveform to negate. + + Returns: + Negated waveform ``-waveform``. + """ + return _require_context("wf_neg") diff --git a/pulse/core/frontend/cudaq_pulse/ops/_context.py b/pulse/core/frontend/cudaq_pulse/ops/_context.py new file mode 100644 index 00000000000..f81c52d7896 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/ops/_context.py @@ -0,0 +1,35 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field +from typing import Any, Optional + + +@dataclass +class CompilationContext: + """State carried through a single kernel compilation.""" + + module: Any + builder: Any + symbol_table: Any + function: Any + extra: dict = field(default_factory=dict) + + +_tls = threading.local() + + +def get_active_context() -> Optional[CompilationContext]: + return getattr(_tls, "active_context", None) + + +def set_active_context(ctx: Optional[CompilationContext]) -> None: + _tls.active_context = ctx diff --git a/pulse/core/frontend/cudaq_pulse/packed_emit.py b/pulse/core/frontend/cudaq_pulse/packed_emit.py new file mode 100644 index 00000000000..1b3638491c5 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/packed_emit.py @@ -0,0 +1,312 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Packed-buffer encoder for zero-copy Program → MLIR module construction. + +Encodes a ``Program`` as a flat ``numpy.ndarray[int64]`` that the C++ +``PulseModuleBuilder.build_from_packed()`` consumes via raw pointer +iteration — one FFI crossing for the entire program. + +Wire format +----------- +Each op is a variable-length record of int64 words:: + + [header] [payload_0] [payload_1] ... + +Header layout (64 bits): + bits 0-7 : OpCode (uint8 enum) + bits 8-15: payload length N (uint8, count of following int64 words) + bits 16-63: reserved (zero) + +Floats are stored as their IEEE-754 double bit-pattern reinterpreted +as int64 (``struct.pack('d', x)`` → ``struct.unpack('q', …)``). +""" +from __future__ import annotations + +import struct +from typing import Any + +import numpy as np + +from .passes.ir_types import OpKind, Program, ValueType + +# ── OpCode enum (must match C++ kOp* constants in bindings.cpp) ────────── + +ALLOC_DRIVE = 0 +ALLOC_READOUT = 1 +ALLOC_TONE = 2 +WF_GAUSSIAN = 3 +WF_SQUARE = 4 +WF_DRAG = 5 +WF_COSINE = 6 +WF_TANH_RAMP = 7 +WF_GAUSS_SQUARE = 8 +WF_CUSTOM = 9 +DRIVE = 10 +READOUT = 11 +SYNC = 12 +WAIT = 13 +SHIFT_PHASE = 14 +SET_PHASE = 15 +SHIFT_FREQ = 16 +SET_FREQ = 17 + +_UNSCHEDULED = -1 + +_WF_TYPE_MAP = { + "gaussian": WF_GAUSSIAN, + "square": WF_SQUARE, + "drag": WF_DRAG, + "cosine": WF_COSINE, + "tanh_ramp": WF_TANH_RAMP, + "gaussian_square": WF_GAUSS_SQUARE, +} + +_VTYPE_INT = { + ValueType.DRIVE_LINE: 0, + ValueType.READOUT_LINE: 1, + ValueType.TONE: 2, +} + +_pack_d = struct.Struct("=d") +_unpack_q = struct.Struct("=q") + + +def _f2i(x: float) -> int: + """Bit-cast a float64 to int64.""" + return _unpack_q.unpack(_pack_d.pack(float(x)))[0] + + +def _header(opcode: int, payload_len: int) -> int: + return opcode | (payload_len << 8) + + +def pack_program(prog: Program) -> np.ndarray: + """Encode a Program into a flat int64 numpy array (zero-copy ready).""" + buf = np.empty(len(prog.ops) * 10, dtype=np.int64) + c = 0 + + for op in prog.ops: + kind = op.kind + a = op.attrs + + if kind == OpKind.ALLOC_DRIVE: + qubit = int(a["qubit"]) + lv = op.results[0].vid + tv = op.results[1].vid + buf[c] = _header(ALLOC_DRIVE, 3) + buf[c + 1] = qubit + buf[c + 2] = lv + buf[c + 3] = tv + c += 4 + + elif kind == OpKind.ALLOC_READOUT: + qubit = int(a["qubit"]) + lv = op.results[0].vid + tv = op.results[1].vid + buf[c] = _header(ALLOC_READOUT, 3) + buf[c + 1] = qubit + buf[c + 2] = lv + buf[c + 3] = tv + c += 4 + + elif kind == OpKind.ALLOC_TONE: + tv = op.results[0].vid + freq = float(a.get("frequency_hz", 0.0)) + phase = float(a.get("phase_rad", 0.0)) + buf[c] = _header(ALLOC_TONE, 3) + buf[c + 1] = tv + buf[c + 2] = _f2i(freq) + buf[c + 3] = _f2i(phase) + c += 4 + + elif kind == OpKind.MAKE_WAVEFORM: + rv = op.results[0].vid + wf_type = a.get("waveform_type", "") + dur = int(a.get("duration_vtu", 0)) + opcode = _WF_TYPE_MAP.get(wf_type, WF_CUSTOM) + + if opcode == WF_GAUSSIAN: + amp = _extract_real(a.get("amplitude", 0.0)) + sigma = float(a.get("sigma", 1.0)) + buf[c] = _header(WF_GAUSSIAN, 4) + buf[c + 1] = rv + buf[c + 2] = dur + buf[c + 3] = _f2i(amp) + buf[c + 4] = _f2i(sigma) + c += 5 + + elif opcode == WF_SQUARE: + re, im = _extract_complex(a.get("amplitude", 0.0)) + buf[c] = _header(WF_SQUARE, 4) + buf[c + 1] = rv + buf[c + 2] = dur + buf[c + 3] = _f2i(re) + buf[c + 4] = _f2i(im) + c += 5 + + elif opcode == WF_DRAG: + amp = _extract_real(a.get("amplitude", 0.0)) + sigma = float(a.get("sigma", 1.0)) + beta = float(a.get("beta", 0.0)) + buf[c] = _header(WF_DRAG, 5) + buf[c + 1] = rv + buf[c + 2] = dur + buf[c + 3] = _f2i(amp) + buf[c + 4] = _f2i(sigma) + buf[c + 5] = _f2i(beta) + c += 6 + + elif opcode == WF_COSINE: + amp = _extract_real(a.get("amplitude", 0.0)) + buf[c] = _header(WF_COSINE, 3) + buf[c + 1] = rv + buf[c + 2] = dur + buf[c + 3] = _f2i(amp) + c += 4 + + elif opcode == WF_TANH_RAMP: + amp = _extract_real(a.get("amplitude", 0.0)) + sigma = float(a.get("sigma", 1.0)) + buf[c] = _header(WF_TANH_RAMP, 4) + buf[c + 1] = rv + buf[c + 2] = dur + buf[c + 3] = _f2i(amp) + buf[c + 4] = _f2i(sigma) + c += 5 + + elif opcode == WF_GAUSS_SQUARE: + amp = _extract_real(a.get("amplitude", 0.0)) + sigma = float(a.get("sigma", 1.0)) + risefall = int(a.get("risefall", 0)) + buf[c] = _header(WF_GAUSS_SQUARE, 5) + buf[c + 1] = rv + buf[c + 2] = dur + buf[c + 3] = _f2i(amp) + buf[c + 4] = _f2i(sigma) + buf[c + 5] = risefall + c += 6 + + else: + buf[c] = _header(WF_CUSTOM, 2) + buf[c + 1] = rv + buf[c + 2] = dur + c += 3 + + elif kind == OpKind.DRIVE: + lv = op.operands[0].vid + wv = op.operands[1].vid + tv = op.operands[2].vid + rlv = op.results[0].vid + rtv = op.results[1].vid + sv = int(a["start_vtu"]) if "start_vtu" in a else _UNSCHEDULED + dv = int(a["duration_vtu"]) if "duration_vtu" in a else _UNSCHEDULED + buf[c] = _header(DRIVE, 7) + buf[c + 1] = lv + buf[c + 2] = wv + buf[c + 3] = tv + buf[c + 4] = rlv + buf[c + 5] = rtv + buf[c + 6] = sv + buf[c + 7] = dv + c += 8 + + elif kind == OpKind.READOUT: + lv = op.operands[0].vid + wv = op.operands[1].vid + tv = op.operands[2].vid + rlv = op.results[0].vid + rtv = op.results[1].vid + mv = op.results[2].vid + buf[c] = _header(READOUT, 6) + buf[c + 1] = lv + buf[c + 2] = wv + buf[c + 3] = tv + buf[c + 4] = rlv + buf[c + 5] = rtv + buf[c + 6] = mv + c += 7 + + elif kind == OpKind.SYNC: + n = len(op.operands) + payload_len = 1 + 3 * n + buf[c] = _header(SYNC, payload_len) + buf[c + 1] = n + for j in range(n): + in_vid = op.operands[j].vid + out_vid = op.results[j].vid if j < len(op.results) else in_vid + vtype = _VTYPE_INT.get(op.results[j].vtype, 0) if j < len( + op.results) else 0 + buf[c + 2 + 3 * j] = in_vid + buf[c + 3 + 3 * j] = out_vid + buf[c + 4 + 3 * j] = vtype + c += 1 + payload_len + + elif kind == OpKind.WAIT: + lv = op.operands[0].vid + rlv = op.results[0].vid + dv = int(a.get("duration_vtu", 0)) + buf[c] = _header(WAIT, 3) + buf[c + 1] = lv + buf[c + 2] = rlv + buf[c + 3] = dv + c += 4 + + elif kind == OpKind.SHIFT_PHASE: + tv = op.operands[0].vid + rtv = op.results[0].vid if op.results else tv + delta = float(a.get("delta_rad", a.get("delta", 0.0))) + buf[c] = _header(SHIFT_PHASE, 3) + buf[c + 1] = tv + buf[c + 2] = rtv + buf[c + 3] = _f2i(delta) + c += 4 + + elif kind == OpKind.SET_PHASE: + tv = op.operands[0].vid + rtv = op.results[0].vid if op.results else tv + phase = float(a.get("phase_rad", 0.0)) + buf[c] = _header(SET_PHASE, 3) + buf[c + 1] = tv + buf[c + 2] = rtv + buf[c + 3] = _f2i(phase) + c += 4 + + # for_loop / end_for are structural, not encoded + # (scheduling is done in Python before packing) + + return buf[:c] + + +def _extract_real(val: Any) -> float: + if isinstance(val, complex): + return val.real + return float(val) + + +def _extract_complex(val: Any) -> tuple[float, float]: + if isinstance(val, complex): + return (val.real, val.imag) + return (float(val), 0.0) + + +def emit_pulse_module_packed(prog: Program) -> Any: + """Build an in-memory PulseModule via the packed-buffer zero-copy path. + + Returns a ``PulseModule`` whose ``.print()`` gives MLIR text and + ``.run_passes()`` / ``.run_full_lowering()`` operate in-memory. + """ + from ._native._cudaq_pulse_native import PulseModuleBuilder + + buf = pack_program(prog) + n_qubits = len(prog.qubit_freq_hz) + freq_arr = np.zeros(n_qubits, dtype=np.float64) + for q, f in prog.qubit_freq_hz.items(): + if q < n_qubits: + freq_arr[q] = f + builder = PulseModuleBuilder() + return builder.build_from_packed(buf, prog.clock_ghz, n_qubits, freq_arr) diff --git a/pulse/core/frontend/cudaq_pulse/passes/__init__.py b/pulse/core/frontend/cudaq_pulse/passes/__init__.py new file mode 100644 index 00000000000..62c7d690779 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/passes/__init__.py @@ -0,0 +1,87 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""cudaq-pulse compiler passes (experimental pass-authoring API). + +Provides verification, scheduling, canonicalization, and optimization passes +that operate on the lightweight ``Program``/``Op`` IR, plus the +``ProgramBuilder`` used to construct programs directly. + +Each pass is a plain ``Program -> Program`` (or ``Program -> (events, metrics)`` +for schedulers) function, so you can compose the built-ins or write your own +transform and apply it, then emit MLIR with ``program_to_pulse_mlir``. For the +common "compile a kernel end-to-end" path, use ``cudaq_pulse.compile()``. + +This surface is experimental and may change without notice. +""" + +from .ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + _mk, + clone_program, + duration_of, + is_loop_or_barrier, +) +from .scheduling import ScheduledEvent, ScheduleMetrics, MachineModel +from ._builder import ProgramBuilder + +from .verify import verify +from .verify import ( + check_linearity, + check_monotone_time, + check_drive_exclusivity, + check_loop_structure, + check_waveform_validity, +) +from .scheduling import ( + schedule_asap, + schedule_alap, + schedule_rcp, +) +from .canonicalize import run_canonicalize +from .virtual_z import run_virtual_z +from .fusion import run_fusion +from .loop_passes import run_licm, run_loop_strength_reduction +from .pulse_to_operator import run_pulse_to_operator, OperatorProgram +from .to_pulse_mlir import program_to_pulse_mlir + +__all__ = [ + "Op", + "OpKind", + "Program", + "Value", + "ValueType", + "_mk", + "clone_program", + "duration_of", + "is_loop_or_barrier", + "ScheduledEvent", + "ScheduleMetrics", + "MachineModel", + "ProgramBuilder", + "OperatorProgram", + "verify", + "check_linearity", + "check_monotone_time", + "check_drive_exclusivity", + "check_loop_structure", + "check_waveform_validity", + "schedule_asap", + "schedule_alap", + "schedule_rcp", + "run_canonicalize", + "run_virtual_z", + "run_fusion", + "run_licm", + "run_loop_strength_reduction", + "run_pulse_to_operator", + "program_to_pulse_mlir", +] diff --git a/pulse/core/frontend/cudaq_pulse/passes/_builder.py b/pulse/core/frontend/cudaq_pulse/passes/_builder.py new file mode 100644 index 00000000000..649936e67cc --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/passes/_builder.py @@ -0,0 +1,286 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Fluent ProgramBuilder for constructing pulse Programs programmatically. + +Provides a builder API that mirrors the paper's ``pulse_ref`` style: +``get_drive_line``, ``drive``, ``gaussian``, ``drag``, ``square``, ``wait``, +``sync``, ``shift_phase``, ``set_phase``. All methods return updated line/tone +handles for linear chaining. +""" + +from __future__ import annotations + +import math +from typing import Any + +from .ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + _mk, + _reset_vid_counter, +) + + +class ProgramBuilder: + """Fluent builder that constructs a ``Program`` incrementally. + + Usage:: + + p = ProgramBuilder("bell", clock_ghz=2.0) + d0, t0 = p.get_drive_line(0, 5.0e9) + sx = p.drag(40, 0.25, 10.0, 0.5) + d0, t0 = p.drive(d0, sx, t0) + program = p.build() # returns the underlying Program + """ + + def __init__(self, name: str, clock_ghz: float = 2.0): + _reset_vid_counter(0) + self._name = name + self._clock_ghz = clock_ghz + self._ops: list[Op] = [] + self._values: list[Value] = [] + self._qubit_freq_hz: dict[int, float] = {} + + def build(self) -> Program: + """Finalize and return the underlying ``Program``.""" + return Program( + name=self._name, + clock_ghz=self._clock_ghz, + ops=list(self._ops), + values=list(self._values), + qubit_freq_hz=dict(self._qubit_freq_hz), + ) + + def get_drive_line(self, qubit: int, freq_hz: float) -> tuple[Value, Value]: + """Allocate a drive line and tone for the given qubit.""" + self._qubit_freq_hz[qubit] = freq_hz + d = _mk(ValueType.DRIVE_LINE, f"d{qubit}") + t = _mk(ValueType.TONE, f"t{qubit}") + self._values.extend([d, t]) + self._ops.append( + Op( + kind=OpKind.ALLOC_DRIVE, + operands=(), + results=(d, t), + attrs={ + "qubit": qubit, + "frequency_hz": freq_hz + }, + )) + return d, t + + def get_readout_line(self, qubit: int, + freq_hz: float) -> tuple[Value, Value]: + """Allocate a readout line and tone.""" + r = _mk(ValueType.READOUT_LINE, f"ro{qubit}") + t = _mk(ValueType.TONE, f"rot{qubit}") + self._values.extend([r, t]) + self._ops.append( + Op( + kind=OpKind.ALLOC_READOUT, + operands=(), + results=(r, t), + attrs={ + "qubit": qubit, + "frequency_hz": freq_hz + }, + )) + return r, t + + def drive(self, line: Value, waveform: Value, + tone: Value) -> tuple[Value, Value]: + """Emit a drive operation. Returns updated (line, tone).""" + dur = 0.0 + for op in reversed(self._ops): + if op.results and any(r.vid == waveform.vid for r in op.results): + dur = float(op.attrs.get("duration_vtu", 0.0)) + break + + new_line = _mk(line.vtype, line.name) + new_tone = _mk(tone.vtype, tone.name) + self._values.extend([new_line, new_tone]) + + self._ops.append( + Op( + kind=OpKind.DRIVE, + operands=(line, waveform, tone), + results=(new_line, new_tone), + attrs={"duration_vtu": dur}, + )) + return new_line, new_tone + + def wait(self, line: Value, duration_vtu: float) -> Value: + """Insert an idle wait on a drive line.""" + new_line = _mk(line.vtype, line.name) + self._values.append(new_line) + self._ops.append( + Op( + kind=OpKind.WAIT, + operands=(line,), + results=(new_line,), + attrs={"duration_vtu": duration_vtu}, + )) + return new_line + + def sync(self, *lines: Value) -> tuple[Value, ...]: + """Synchronize multiple lines. Returns updated line handles.""" + new_lines = tuple(_mk(l.vtype, l.name) for l in lines) + self._values.extend(new_lines) + self._ops.append( + Op( + kind=OpKind.SYNC, + operands=lines, + results=new_lines, + attrs={}, + )) + return new_lines + + def shift_phase(self, tone: Value, delta_rad: float) -> Value: + """Shift the phase of a tone.""" + new_tone = _mk(tone.vtype, tone.name) + self._values.append(new_tone) + self._ops.append( + Op( + kind=OpKind.SHIFT_PHASE, + operands=(tone,), + results=(new_tone,), + attrs={"delta_rad": delta_rad}, + )) + return new_tone + + def set_phase(self, tone: Value, phase_rad: float) -> Value: + """Set the absolute phase of a tone.""" + new_tone = _mk(tone.vtype, tone.name) + self._values.append(new_tone) + self._ops.append( + Op( + kind=OpKind.SET_PHASE, + operands=(tone,), + results=(new_tone,), + attrs={"phase_rad": phase_rad}, + )) + return new_tone + + # -- Waveform constructors -- + + def gaussian(self, duration_vtu: float, amplitude: float, + sigma: float) -> Value: + """Create a Gaussian waveform.""" + w = _mk(ValueType.WAVEFORM, "gaussian") + self._values.append(w) + self._ops.append( + Op( + kind=OpKind.MAKE_WAVEFORM, + operands=(), + results=(w,), + attrs={ + "waveform_type": "gaussian", + "duration_vtu": duration_vtu, + "amplitude": amplitude, + "sigma": sigma, + }, + )) + return w + + def drag(self, duration_vtu: float, amplitude: float, sigma: float, + beta: float) -> Value: + """Create a DRAG waveform.""" + w = _mk(ValueType.WAVEFORM, "drag") + self._values.append(w) + self._ops.append( + Op( + kind=OpKind.MAKE_WAVEFORM, + operands=(), + results=(w,), + attrs={ + "waveform_type": "drag", + "duration_vtu": duration_vtu, + "amplitude": amplitude, + "sigma": sigma, + "beta": beta, + }, + )) + return w + + def square(self, duration_vtu: float, amplitude: complex) -> Value: + """Create a constant (square) waveform.""" + w = _mk(ValueType.WAVEFORM, "square") + self._values.append(w) + self._ops.append( + Op( + kind=OpKind.MAKE_WAVEFORM, + operands=(), + results=(w,), + attrs={ + "waveform_type": + "square", + "duration_vtu": + duration_vtu, + "amplitude": + abs(amplitude), + "phase": + math.atan2(amplitude.imag, amplitude.real) + if isinstance(amplitude, complex) else 0.0, + }, + )) + return w + + def cosine(self, duration_vtu: float, amplitude: float) -> Value: + """Create a cosine waveform.""" + w = _mk(ValueType.WAVEFORM, "cosine") + self._values.append(w) + self._ops.append( + Op( + kind=OpKind.MAKE_WAVEFORM, + operands=(), + results=(w,), + attrs={ + "waveform_type": "cosine", + "duration_vtu": duration_vtu, + "amplitude": amplitude, + }, + )) + return w + + def gaussian_square(self, duration_vtu: float, amplitude: float, + sigma: float, flat_top_vtu: float) -> Value: + """Create a Gaussian-square (flat-top Gaussian) waveform.""" + w = _mk(ValueType.WAVEFORM, "gaussian_square") + self._values.append(w) + self._ops.append( + Op( + kind=OpKind.MAKE_WAVEFORM, + operands=(), + results=(w,), + attrs={ + "waveform_type": "gaussian_square", + "duration_vtu": duration_vtu, + "amplitude": amplitude, + "sigma": sigma, + "flat_top_vtu": flat_top_vtu, + }, + )) + return w + + def readout(self, line: Value, waveform: Value, + tone: Value) -> tuple[Value, Value]: + """Emit a readout operation. Returns updated (line, tone).""" + new_line = _mk(line.vtype, line.name) + new_tone = _mk(tone.vtype, tone.name) + self._values.extend([new_line, new_tone]) + self._ops.append( + Op( + kind=OpKind.READOUT, + operands=(line, waveform, tone), + results=(new_line, new_tone), + attrs={}, + )) + return new_line, new_tone diff --git a/pulse/core/frontend/cudaq_pulse/passes/canonicalize.py b/pulse/core/frontend/cudaq_pulse/passes/canonicalize.py new file mode 100644 index 00000000000..589ba5d2986 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/passes/canonicalize.py @@ -0,0 +1,261 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Canonicalization pass for the pulse IR. + +Attempts to call the native CAPI (cudaqPulseRunCanonicalize); falls back to a +pure-Python implementation of the same transforms. +""" + +from __future__ import annotations + +import ctypes +import ctypes.util +import logging +from typing import Any + +from .ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + clone_program, + duration_of, + is_loop_or_barrier, + waveform_of, +) + +_logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Native CAPI attempt +# --------------------------------------------------------------------------- + +_native_lib = None + + +def _try_load_native() -> Any | None: + """Attempt to load the cudaq-pulse native library.""" + global _native_lib + if _native_lib is not None: + return _native_lib + + for name in ("libcudaq_pulse", "cudaq_pulse"): + path = ctypes.util.find_library(name) + if path: + try: + _native_lib = ctypes.CDLL(path) + return _native_lib + except OSError: + continue + return None + + +# --------------------------------------------------------------------------- +# Pure-Python canonicalization transforms +# --------------------------------------------------------------------------- + + +def _redundant_sync_elim(ops: list[Op]) -> list[Op]: + """Remove syncs where all input lines already share the same time.""" + line_roots: dict[int, int] = {} + line_clocks: dict[int, float] = {} + replacements: dict[int, Value] = {} + result: list[Op] = [] + + def resolve(value: Value) -> Value: + seen: set[int] = set() + while value.vid in replacements and value.vid not in seen: + seen.add(value.vid) + value = replacements[value.vid] + return value + + def line_values(values: tuple[Value, ...]) -> list[Value]: + return [ + value for value in values if value.vtype in ( + ValueType.DRIVE_LINE, + ValueType.READOUT_LINE, + ) + ] + + for original in ops: + operands = tuple(resolve(value) for value in original.operands) + op = Op(original.kind, operands, original.results, dict(original.attrs)) + inputs = line_values(operands) + outputs = line_values(op.results) + + if op.kind in (OpKind.ALLOC_DRIVE, OpKind.ALLOC_READOUT): + for output in outputs: + line_roots[output.vid] = output.vid + line_clocks[output.vid] = 0.0 + result.append(op) + continue + + if is_loop_or_barrier(op) and op.kind not in (OpKind.SYNC,): + line_clocks.clear() + result.append(op) + elif op.kind == OpKind.SYNC: + roots = [line_roots.get(value.vid, value.vid) for value in inputs] + if roots: + times = [line_clocks.get(root, 0.0) for root in roots] + if len(set(round(t, 10) for t in times)) <= 1: + for output, source in zip(outputs, inputs): + replacements[output.vid] = source + continue + sync_time = max(times) + for root in roots: + line_clocks[root] = sync_time + for output, root in zip(outputs, roots): + line_roots[output.vid] = root + result.append(op) + else: + roots = [line_roots.get(value.vid, value.vid) for value in inputs] + for output, root in zip(outputs, roots): + line_roots[output.vid] = root + duration = duration_of(op) + for root in set(roots): + line_clocks[root] = line_clocks.get(root, 0.0) + duration + result.append(op) + + return result + + +def _dead_line_elim(ops: list[Op]) -> list[Op]: + """Remove lines that are allocated but never driven or read.""" + linear_types = (ValueType.DRIVE_LINE, ValueType.READOUT_LINE, + ValueType.TONE) + roots: dict[int, int] = {} + used_roots: set[int] = set() + alloc_roots: dict[int, set[int]] = {} + + for idx, op in enumerate(ops): + if op.kind in (OpKind.ALLOC_DRIVE, OpKind.ALLOC_READOUT): + allocation = set() + for value in op.results: + if value.vtype in linear_types: + roots[value.vid] = value.vid + allocation.add(value.vid) + alloc_roots[idx] = allocation + continue + + inputs_by_type: dict[ValueType, list[int]] = {} + for value in op.operands: + if value.vtype not in linear_types: + continue + root = roots.get(value.vid, value.vid) + used_roots.add(root) + inputs_by_type.setdefault(value.vtype, []).append(root) + + output_offsets: dict[ValueType, int] = {} + for value in op.results: + candidates = inputs_by_type.get(value.vtype, []) + offset = output_offsets.get(value.vtype, 0) + if offset < len(candidates): + roots[value.vid] = candidates[offset] + output_offsets[value.vtype] = offset + 1 + elif value.vtype in linear_types: + roots[value.vid] = value.vid + + dead_indices = { + index for index, allocation in alloc_roots.items() + if allocation.isdisjoint(used_roots) + } + if not dead_indices: + return ops + return [op for idx, op in enumerate(ops) if idx not in dead_indices] + + +def _idle_compression(ops: list[Op]) -> list[Op]: + """Merge adjacent waits on the same line.""" + result: list[Op] = [] + + for op in ops: + if op.kind == OpKind.WAIT and result: + prev = result[-1] + previous_lines = [ + value for value in prev.results if value.vtype in ( + ValueType.DRIVE_LINE, + ValueType.READOUT_LINE, + ) + ] + current_lines = [ + value for value in op.operands if value.vtype in ( + ValueType.DRIVE_LINE, + ValueType.READOUT_LINE, + ) + ] + if (prev.kind == OpKind.WAIT and previous_lines and + current_lines and + previous_lines[0].vid == current_lines[0].vid): + merged_dur = duration_of(prev) + duration_of(op) + merged_attrs = dict(prev.attrs) + merged_attrs["duration_vtu"] = merged_dur + result[-1] = Op( + kind=OpKind.WAIT, + operands=prev.operands, + results=op.results, + attrs=merged_attrs, + ) + continue + result.append(op) + + return result + + +def _waveform_cse(ops: list[Op]) -> list[Op]: + """Deduplicate identical waveform constructions within the same scope.""" + seen: dict[tuple, Value] = {} + replacements: dict[int, Value] = {} + result: list[Op] = [] + + for op in ops: + if is_loop_or_barrier(op): + seen.clear() + if op.kind == OpKind.MAKE_WAVEFORM: + key = ( + op.attrs.get("waveform_type"), + op.attrs.get("duration_vtu"), + op.attrs.get("amplitude"), + op.attrs.get("frequency"), + op.attrs.get("phase"), + ) + if key in seen and op.results: + replacements[op.results[0].vid] = seen[key] + continue + elif op.results: + seen[key] = op.results[0] + + new_operands = tuple(replacements.get(v.vid, v) for v in op.operands) + result.append( + Op( + kind=op.kind, + operands=new_operands, + results=op.results, + attrs=op.attrs, + )) + + return result + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def run_canonicalize(program: Program) -> Program: + """Run canonicalization passes on the program. + + Runs pure-Python implementations of redundant-sync elimination, + dead-line elimination, idle compression, and waveform CSE. + """ + result = clone_program(program) + result.ops = _redundant_sync_elim(result.ops) + result.ops = _dead_line_elim(result.ops) + result.ops = _idle_compression(result.ops) + result.ops = _waveform_cse(result.ops) + return result diff --git a/pulse/core/frontend/cudaq_pulse/passes/fusion.py b/pulse/core/frontend/cudaq_pulse/passes/fusion.py new file mode 100644 index 00000000000..48824c266f2 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/passes/fusion.py @@ -0,0 +1,180 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Pulse fusion pass. + +Merges adjacent same-line constant-amplitude (square) pulses into a single +longer pulse when constraints are satisfied. +""" + +from __future__ import annotations + +from .ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + clone_program, + duration_of, +) + + +def _is_square_pulse(op: Op) -> bool: + """Check if an op represents a constant-amplitude (square) drive.""" + if op.kind != OpKind.DRIVE: + return False + wf_type = op.attrs.get("waveform_type", "") + return wf_type in ("square", "constant", "const") + + +def _can_fuse(a: Op, b: Op) -> bool: + """Check if two drive ops can be fused. + + Conditions: + - Both are square pulses + - Same line + - Same tone + - Same amplitude + - No phase difference (or both have identical phase) + """ + if not (_is_square_pulse(a) and _is_square_pulse(b)): + return False + + a_line_results = [ + value for value in a.results if value.vtype in ( + ValueType.DRIVE_LINE, + ValueType.READOUT_LINE, + ) + ] + b_line_operands = [ + value for value in b.operands if value.vtype in ( + ValueType.DRIVE_LINE, + ValueType.READOUT_LINE, + ) + ] + if not a_line_results or not b_line_operands or a_line_results[ + 0].vid != b_line_operands[0].vid: + return False + + a_tone_results = [ + value for value in a.results if value.vtype == ValueType.TONE + ] + b_tone_operands = [ + value for value in b.operands if value.vtype == ValueType.TONE + ] + if not a_tone_results or not b_tone_operands or a_tone_results[ + 0].vid != b_tone_operands[0].vid: + return False + + def complex_amplitude(value) -> complex: + if isinstance(value, (list, tuple)): + real = float(value[0]) if value else 0.0 + imaginary = float(value[1]) if len(value) > 1 else 0.0 + return complex(real, imaginary) + return complex(value) + + amp_a = complex_amplitude(a.attrs.get("amplitude", 1.0)) + amp_b = complex_amplitude(b.attrs.get("amplitude", 1.0)) + if abs(amp_a - amp_b) > 1e-12: + return False + + for key in ("phase", "phase_offset", "frame_phase_offset"): + phase_a = float(a.attrs.get(key, 0.0)) + phase_b = float(b.attrs.get(key, 0.0)) + if abs(phase_a - phase_b) > 1e-12: + return False + + return True + + +def _fuse_ops(a: Op, b: Op, waveform: Value) -> Op: + """Create a fused op from two adjacent compatible drive ops.""" + dur_a = duration_of(a) + dur_b = duration_of(b) + merged_attrs = dict(a.attrs) + merged_attrs["duration_vtu"] = dur_a + dur_b + merged_attrs["fused"] = True + merged_attrs["fused_count"] = a.attrs.get("fused_count", 1) + 1 + + return Op( + kind=OpKind.DRIVE, + operands=(a.operands[0], waveform, a.operands[2]), + results=b.results, + attrs=merged_attrs, + ) + + +def run_fusion(program: Program) -> Program: + """Merge adjacent same-line constant-amplitude pulses into single longer pulses. + + Only merges when: + - Same line + - Same tone + - Same amplitude + - No intervening ops on the line between the two drives + """ + result = clone_program(program) + next_vid = 1 + max( + (value.vid + for op in result.ops + for value in (*op.operands, *op.results)), + default=-1, + ) + new_ops: list[Op] = [] + drive_by_output_line: dict[int, int] = {} + + for op in result.ops: + input_lines = [ + value for value in op.operands if value.vtype in ( + ValueType.DRIVE_LINE, + ValueType.READOUT_LINE, + ) + ] + previous_index = drive_by_output_line.get( + input_lines[0].vid) if input_lines else None + + if (op.kind == OpKind.DRIVE and previous_index is not None and + _can_fuse(new_ops[previous_index], op)): + previous = new_ops[previous_index] + duration = duration_of(previous) + duration_of(op) + waveform = Value(next_vid, ValueType.WAVEFORM, + f"fused_square_{next_vid}") + next_vid += 1 + waveform_op = Op( + OpKind.MAKE_WAVEFORM, + (), + (waveform,), + { + "waveform_type": "square", + "duration_vtu": duration, + "amplitude": previous.attrs.get("amplitude", 0.0), + }, + ) + for line_vid, index in list(drive_by_output_line.items()): + if index >= previous_index: + drive_by_output_line[line_vid] = index + 1 + new_ops.insert(previous_index, waveform_op) + previous_index += 1 + fused = _fuse_ops(previous, op, waveform) + new_ops[previous_index] = fused + for value in fused.results: + if value.vtype in (ValueType.DRIVE_LINE, + ValueType.READOUT_LINE): + drive_by_output_line[value.vid] = previous_index + continue + + new_index = len(new_ops) + new_ops.append(op) + if op.kind == OpKind.DRIVE: + for value in op.results: + if value.vtype in (ValueType.DRIVE_LINE, + ValueType.READOUT_LINE): + drive_by_output_line[value.vid] = new_index + + result.ops = new_ops + return result diff --git a/pulse/core/frontend/cudaq_pulse/passes/ir_types.py b/pulse/core/frontend/cudaq_pulse/passes/ir_types.py new file mode 100644 index 00000000000..84b58e6f97f --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/passes/ir_types.py @@ -0,0 +1,204 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Shared IR types for the cudaq-pulse pass infrastructure. + +Provides a lightweight dataclass-based IR: Value, Op, Program. +""" + +from __future__ import annotations + +import enum +from dataclasses import dataclass, field +from typing import Any + + +class ValueType(enum.Enum): + """Classification of SSA values flowing through the pulse IR.""" + + DRIVE_LINE = "drive_line" + READOUT_LINE = "readout_line" + TONE = "tone" + WAVEFORM = "waveform" + IQ_DATA = "iq_data" + MEASUREMENT = "measurement" + QREF = "qref" + + +class OpKind: + """Well-known operation kinds in the pulse IR.""" + + ALLOC_DRIVE = "alloc_drive_line" + ALLOC_READOUT = "alloc_readout_line" + ALLOC_TONE = "alloc_tone" + DRIVE = "drive" + READOUT = "readout" + SYNC = "sync" + WAIT = "wait" + SHIFT_PHASE = "shift_phase" + SET_PHASE = "set_phase" + SHIFT_FREQUENCY = "shift_frequency" + SET_FREQUENCY = "set_frequency" + MAKE_WAVEFORM = "make_waveform" + FOR_LOOP = "for_loop" + END_FOR = "end_for" + + # Operator dialect (pulse_to_operator lowering targets) + QOP_SPIN = "qop.spin" + QOP_CONST_SCALAR = "qop.const_scalar" + QOP_MAKE_PRODUCT = "qop.make_product" + QOP_MAKE_SUM = "qop.make_sum" + QOP_CALLBACK_SCALAR = "qop.callback_scalar" + QOP_LINDBLAD = "qop.lindblad" + + +@dataclass(frozen=True) +class Value: + """An SSA value in the pulse IR.""" + + vid: int + vtype: ValueType + name: str = "" + + def __repr__(self) -> str: + tag = f":{self.name}" if self.name else "" + return f"%{self.vid}{tag}:{self.vtype.value}" + + +@dataclass() +class Op: + """A single operation in the pulse IR.""" + + kind: str + operands: tuple[Value, ...] + results: tuple[Value, ...] + attrs: dict[str, Any] + + def __repr__(self) -> str: + res = ", ".join(repr(r) for r in self.results) + ops = ", ".join(repr(o) for o in self.operands) + return f"{res} = {self.kind}({ops})" + + +@dataclass() +class Program: + """A complete pulse program — the unit of compilation.""" + + name: str + clock_ghz: float + ops: list[Op] + values: list[Value] = field(default_factory=list) + qubit_freq_hz: dict[int, float] = field(default_factory=dict) + + @property + def vtu_to_ns(self) -> float: + """Virtual time unit to nanoseconds conversion.""" + if self.clock_ghz <= 0: + raise ValueError( + f"clock_ghz must be positive, got {self.clock_ghz}") + return 1.0 / self.clock_ghz + + def op_count(self) -> int: + return len(self.ops) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_next_vid: int = 0 + + +def _reset_vid_counter(start: int = 0) -> None: + global _next_vid + _next_vid = start + + +def _mk(vtype: ValueType, name: str = "") -> Value: + """Allocate a fresh Value with a unique vid.""" + global _next_vid + v = Value(vid=_next_vid, vtype=vtype, name=name) + _next_vid += 1 + return v + + +def duration_of(op: Op) -> float: + """Extract duration from an Op's attrs; returns 0.0 if absent.""" + return float(op.attrs.get("duration_vtu", 0.0)) + + +def line_id_of(op: Op) -> int | None: + """Extract the line id from an Op's first operand if it is a line value.""" + if op.operands and op.operands[0].vtype in ( + ValueType.DRIVE_LINE, + ValueType.READOUT_LINE, + ): + return op.operands[0].vid + return None + + +def tone_id_of(op: Op) -> int | None: + """Extract the tone id from an Op's operands.""" + for operand in op.operands: + if operand.vtype == ValueType.TONE: + return operand.vid + return None + + +def is_linear_type(vtype: ValueType) -> bool: + """Return True if this value type has linear (use-once) semantics.""" + return vtype in (ValueType.DRIVE_LINE, ValueType.READOUT_LINE, + ValueType.TONE) + + +_BARRIER_KINDS = frozenset({ + OpKind.FOR_LOOP, + OpKind.END_FOR, + OpKind.SYNC, + "for_begin", + "for_end", +}) + + +def is_loop_or_barrier(op: Op) -> bool: + """Return True for ops that act as scheduling/fusion barriers.""" + return op.kind in _BARRIER_KINDS + + +def waveform_of(op: Op) -> int | None: + """Extract waveform vid from an Op's operands.""" + for operand in op.operands: + if operand.vtype == ValueType.WAVEFORM: + return operand.vid + return None + + +def collect_values(program: Program) -> dict[int, Value]: + """Build vid -> Value map for all values in a program.""" + table: dict[int, Value] = {} + for v in program.values: + table[v.vid] = v + for op in program.ops: + for v in op.results: + table[v.vid] = v + for v in op.operands: + table[v.vid] = v + return table + + +def clone_program(program: Program) -> Program: + """Deep-copy a program.""" + return Program( + name=program.name, + clock_ghz=program.clock_ghz, + ops=[ + Op(o.kind, o.operands, o.results, dict(o.attrs)) + for o in program.ops + ], + values=list(program.values), + qubit_freq_hz=dict(program.qubit_freq_hz), + ) diff --git a/pulse/core/frontend/cudaq_pulse/passes/loop_passes.py b/pulse/core/frontend/cudaq_pulse/passes/loop_passes.py new file mode 100644 index 00000000000..5c0745406d5 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/passes/loop_passes.py @@ -0,0 +1,234 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Loop optimization passes for the pulse IR. + +LICM (loop-invariant code motion) and loop strength reduction. +""" + +from __future__ import annotations + +from .ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + clone_program, +) + +# --------------------------------------------------------------------------- +# Loop structure analysis +# --------------------------------------------------------------------------- + + +def _find_loops(ops: list[Op]) -> list[tuple[int, int]]: + """Find (start_idx, end_idx) pairs for FOR_LOOP..END_FOR regions.""" + loops: list[tuple[int, int]] = [] + stack: list[int] = [] + + for idx, op in enumerate(ops): + if op.kind in (OpKind.FOR_LOOP, "for_begin"): + stack.append(idx) + elif op.kind in (OpKind.END_FOR, "for_end"): + if not stack: + raise ValueError( + f"END_FOR at op[{idx}] without matching FOR_LOOP") + start = stack.pop() + loops.append((start, idx)) + + if stack: + raise ValueError(f"FOR_LOOP at op[{stack}] without matching END_FOR") + + return loops + + +def _inner_loop_ranges( + loops: list[tuple[int, int]]) -> dict[tuple[int, int], set[int]]: + """For each loop, collect indices that belong to strictly inner loops.""" + result: dict[tuple[int, int], set[int]] = {} + for outer_s, outer_e in loops: + inner_idx: set[int] = set() + for inner_s, inner_e in loops: + if inner_s > outer_s and inner_e < outer_e: + for i in range(inner_s, inner_e + 1): + inner_idx.add(i) + result[(outer_s, outer_e)] = inner_idx + return result + + +def _values_defined_in_range(ops: list[Op], start: int, end: int) -> set[int]: + """Collect vids of all values defined (produced) within [start, end].""" + defined: set[int] = set() + for idx in range(start, end + 1): + for v in ops[idx].results: + defined.add(v.vid) + return defined + + +_WAVEFORM_OPS = frozenset({ + OpKind.MAKE_WAVEFORM, + "square", + "gaussian", + "drag", + "cosine", + "tanh_ramp", + "gaussian_square", + "custom", + "custom_samples", + "wf_add", + "wf_sub", + "wf_mul", + "wf_scale", + "wf_neg", +}) + + +def _op_is_loop_invariant(op: Op, loop_defined: set[int]) -> bool: + """An op is loop-invariant if none of its operands are defined inside the loop.""" + for v in op.operands: + if v.vid in loop_defined: + return False + return True + + +# --------------------------------------------------------------------------- +# LICM: Loop-Invariant Code Motion +# --------------------------------------------------------------------------- + + +def run_licm(program: Program) -> Program: + """Hoist loop-invariant waveform construction out of for loops. + + A waveform op is loop-invariant if all its operands are defined outside + the loop body. + """ + result = clone_program(program) + ops = result.ops + + changed = True + while changed: + changed = False + loops = _find_loops(ops) + inner_ranges = _inner_loop_ranges(loops) + + for loop_start, loop_end in reversed(loops): + loop_defined = _values_defined_in_range(ops, loop_start + 1, + loop_end - 1) + hoisted: list[Op] = [] + body_ops: list[Op] = [] + skip = inner_ranges.get((loop_start, loop_end), set()) + + for idx in range(loop_start + 1, loop_end): + op = ops[idx] + if idx in skip: + body_ops.append(op) + elif (op.kind in _WAVEFORM_OPS and + _op_is_loop_invariant(op, loop_defined)): + hoisted.append(op) + for v in op.results: + loop_defined.discard(v.vid) + changed = True + else: + body_ops.append(op) + + if hoisted: + new_ops = (ops[:loop_start] + hoisted + [ops[loop_start]] + + body_ops + [ops[loop_end]] + ops[loop_end + 1:]) + ops = new_ops + break + + result.ops = ops + return result + + +# --------------------------------------------------------------------------- +# Loop Strength Reduction +# --------------------------------------------------------------------------- + + +def _detect_linear_phase_progression( + ops: list[Op], + loop_start: int, + loop_end: int, + skip: set[int] | None = None) -> list[tuple[int, int, float]]: + """Detect shift_phase ops with constant delta inside the immediate loop body. + + Returns list of (op_index, tone_vid, delta). + """ + candidates: list[tuple[int, int, float]] = [] + skip = skip or set() + + for idx in range(loop_start + 1, loop_end): + if idx in skip: + continue + op = ops[idx] + if op.kind not in (OpKind.SHIFT_PHASE, "shift_phase"): + continue + + delta = op.attrs.get( + "delta_rad", + op.attrs.get("phase", op.attrs.get("phase_rad", + op.attrs.get("arg1")))) + if delta is None: + continue + + try: + delta_f = float(delta) + except (TypeError, ValueError): + raise TypeError( + f"shift_phase at op[{idx}] has non-numeric phase value: " + f"{delta!r} ({type(delta).__name__})") + + tone_vid = None + for v in op.operands: + if v.vtype == ValueType.TONE: + tone_vid = v.vid + break + + if tone_vid is not None: + candidates.append((idx, tone_vid, delta_f)) + + return candidates + + +def run_loop_strength_reduction(program: Program) -> Program: + """Convert linear phase progressions to incremental updates. + + For each for-loop, find shift_phase ops with a constant delta applied + every iteration. Mark them with metadata for downstream consumption. + """ + result = clone_program(program) + ops = result.ops + loops = _find_loops(ops) + inner_ranges = _inner_loop_ranges(loops) + + for loop_start, loop_end in reversed(loops): + skip = inner_ranges.get((loop_start, loop_end), set()) + candidates = _detect_linear_phase_progression(ops, loop_start, loop_end, + skip) + + for op_idx, tone_vid, delta in candidates: + op = ops[op_idx] + new_attrs = dict(op.attrs) + new_attrs["strength_reduced"] = True + new_attrs["increment_delta"] = delta + new_attrs["original_phase"] = delta + new_attrs["incremental"] = True + loop_attrs = ops[loop_start].attrs + new_attrs["loop_count"] = loop_attrs.get("count", + loop_attrs.get("ub", 1)) + + ops[op_idx] = Op( + kind=op.kind, + operands=op.operands, + results=op.results, + attrs=new_attrs, + ) + + result.ops = ops + return result diff --git a/pulse/core/frontend/cudaq_pulse/passes/pulse_to_operator.py b/pulse/core/frontend/cudaq_pulse/passes/pulse_to_operator.py new file mode 100644 index 00000000000..ed315b3cbbb --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/passes/pulse_to_operator.py @@ -0,0 +1,576 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Pulse-to-operator lowering pass. + +**Reference implementation** -- the production path uses MLIR lowering +via ``--pulse-to-qop`` (see ``core/mlir/conversions/PulseToQOp/PulseToQOp.cpp``). +This Python implementation is kept for testing, debugging, and as +documentation of the lowering semantics. It is used when the env var +``CUDAQ_PULSE_LEGACY_PYTHON_PATH=1`` is set. + +Lowers drive ops to time-dependent control terms in the qop (quantum operator) +dialect. Collects per-line static Hamiltonians and emits dissipator ops when +calibration data is attached. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any + +from .ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + duration_of, + line_id_of, + tone_id_of, +) + +# --------------------------------------------------------------------------- +# Operator program structure +# --------------------------------------------------------------------------- + + +@dataclass() +class OperatorTerm: + """A single term in the system Hamiltonian or Lindbladian.""" + + kind: str + qubit_indices: tuple + coefficient: complex = 1.0 + 0j + time_dependent: bool = False + callback_id: str = "" + + +@dataclass() +class OperatorProgram: + """The result of pulse-to-operator lowering.""" + + name: str = "operator" + ops: list = field(default_factory=list) + hamiltonian_terms: list = field(default_factory=list) + dissipator_terms: list = field(default_factory=list) + n_qubits: int = 0 + total_time_ns: float = 0.0 + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +_QOP_HANDLER = ValueType.TONE +_QOP_SCALAR = ValueType.WAVEFORM +_QOP_PRODUCT = ValueType.DRIVE_LINE +_QOP_OP = ValueType.READOUT_LINE +_QOP_SUPEROP = ValueType.IQ_DATA + + +def _build_vid_to_qubit_map(program: Program) -> dict[int, int]: + """Build a map from every line-type VID to its qubit index. + + Follows the SSA chain: ALLOC produces the initial VID, then each + DRIVE/WAIT/etc produces a new VID that inherits the same qubit. + """ + vid_to_qubit: dict[int, int] = {} + for op in program.ops: + if op.kind in (OpKind.ALLOC_DRIVE, OpKind.ALLOC_READOUT): + q = op.attrs.get("qubit") + if q is not None: + for v in op.results: + if v.vtype in (ValueType.DRIVE_LINE, + ValueType.READOUT_LINE): + vid_to_qubit[v.vid] = int(q) + + changed = True + while changed: + changed = False + for op in program.ops: + for v_in in op.operands: + if v_in.vid in vid_to_qubit: + q = vid_to_qubit[v_in.vid] + for v_out in op.results: + if (v_out.vtype in (ValueType.DRIVE_LINE, + ValueType.READOUT_LINE) and + v_out.vid not in vid_to_qubit): + vid_to_qubit[v_out.vid] = q + changed = True + + return vid_to_qubit + + +def _qubit_index_from_line(op: Op, + program: Program, + vid_to_qubit: dict[int, int] | None = None) -> int: + """Extract qubit index from a drive op's line or attrs.""" + qubit_idx = op.attrs.get("qubit_index", op.attrs.get("qubit")) + if qubit_idx is not None: + return int(qubit_idx) + + if vid_to_qubit is None: + vid_to_qubit = _build_vid_to_qubit_map(program) + + lid = line_id_of(op) + if lid is not None and lid in vid_to_qubit: + return vid_to_qubit[lid] + + for v in op.operands: + if v.vid in vid_to_qubit: + return vid_to_qubit[v.vid] + + if lid is not None: + raise ValueError( + f"cannot determine qubit index for drive on line %{lid}; " + f"add 'qubit' attr to the ALLOC op or the drive op") + raise ValueError("drive op has no line operand and no qubit attr; " + "cannot determine target qubit for Hamiltonian lowering") + + +def _make_value(vid_counter: list, vtype: ValueType, name: str = "") -> Value: + """Allocate a fresh Value for the operator program.""" + v = Value(vid=vid_counter[0], vtype=vtype, name=name) + vid_counter[0] += 1 + return v + + +# --------------------------------------------------------------------------- +# Static Hamiltonian emission +# --------------------------------------------------------------------------- + + +def _emit_static_hamiltonian( + program: Program, + vid_counter: list, +) -> tuple: + """Emit static Hamiltonian terms (qubit frequencies as sigma_z).""" + ops: list[Op] = [] + terms: list[OperatorTerm] = [] + ham_products: list[Value] = [] + + for qidx, freq_hz in sorted(program.qubit_freq_hz.items()): + handler = _make_value(vid_counter, _QOP_HANDLER, f"sz_q{qidx}") + ops.append( + Op( + kind=OpKind.QOP_SPIN, + operands=(), + results=(handler,), + attrs={ + "target": qidx, + "kind": "spin_z" + }, + )) + + coeff_val = freq_hz * math.pi * 1e-9 + coeff = _make_value(vid_counter, _QOP_SCALAR, f"freq_q{qidx}") + ops.append( + Op( + kind=OpKind.QOP_CONST_SCALAR, + operands=(), + results=(coeff,), + attrs={ + "real": coeff_val, + "imag": 0.0 + }, + )) + + product = _make_value(vid_counter, _QOP_PRODUCT, f"H0_q{qidx}") + ops.append( + Op( + kind=OpKind.QOP_MAKE_PRODUCT, + operands=(coeff, handler), + results=(product,), + attrs={}, + )) + ham_products.append(product) + + terms.append( + OperatorTerm( + kind="static_z", + qubit_indices=(qidx,), + coefficient=complex(coeff_val, 0), + time_dependent=False, + )) + + return ops, terms, ham_products + + +# --------------------------------------------------------------------------- +# Drive-to-control lowering +# --------------------------------------------------------------------------- + + +def _emit_drive_control( + op: Op, + drive_idx: int, + program: Program, + vid_counter: list, + vid_to_qubit: dict[int, int] | None = None, +) -> tuple: + """Lower a drive op to time-dependent control terms (X and optionally Y).""" + emitted: list[Op] = [] + products: list[Value] = [] + terms: list[OperatorTerm] = [] + qidx = _qubit_index_from_line(op, program, vid_to_qubit=vid_to_qubit) + + cr_target = op.attrs.get("cr_target") + target_qubit = int(cr_target) if cr_target is not None else qidx + + amplitude = float(op.attrs.get("amplitude", 1.0)) + phase = float(op.attrs.get("phase", 0.0)) + wf_type = op.attrs.get("waveform_type", "square") + callback_id = f"@drive_envelope_{drive_idx}" + + # X-component (in-phase) + sx = _make_value(vid_counter, _QOP_HANDLER, f"sx_drive{drive_idx}") + emitted.append( + Op( + kind=OpKind.QOP_SPIN, + operands=(), + results=(sx,), + attrs={ + "target": target_qubit, + "kind": "spin_x" + }, + )) + + coeff_x = _make_value(vid_counter, _QOP_SCALAR, f"coeff_x_drive{drive_idx}") + emitted.append( + Op( + kind=OpKind.QOP_CALLBACK_SCALAR, + operands=(), + results=(coeff_x,), + attrs={ + "callback": callback_id, + "waveform_type": wf_type, + "quadrature": "I", + }, + )) + + prod_x = _make_value(vid_counter, _QOP_PRODUCT, f"Hd_x_{drive_idx}") + emitted.append( + Op( + kind=OpKind.QOP_MAKE_PRODUCT, + operands=(coeff_x, sx), + results=(prod_x,), + attrs={}, + )) + products.append(prod_x) + terms.append( + OperatorTerm( + kind="drive_control_x", + qubit_indices=(target_qubit,), + coefficient=complex(amplitude * math.cos(phase), 0), + time_dependent=True, + callback_id=callback_id, + )) + + # Y-component (quadrature) — emitted for DRAG or when phase != 0 + if wf_type == "drag" or abs(math.sin(phase)) > 1e-12: + sy = _make_value(vid_counter, _QOP_HANDLER, f"sy_drive{drive_idx}") + emitted.append( + Op( + kind=OpKind.QOP_SPIN, + operands=(), + results=(sy,), + attrs={ + "target": target_qubit, + "kind": "spin_y" + }, + )) + + coeff_y = _make_value(vid_counter, _QOP_SCALAR, + f"coeff_y_drive{drive_idx}") + emitted.append( + Op( + kind=OpKind.QOP_CALLBACK_SCALAR, + operands=(), + results=(coeff_y,), + attrs={ + "callback": f"{callback_id}_Q", + "waveform_type": wf_type, + "quadrature": "Q", + }, + )) + + prod_y = _make_value(vid_counter, _QOP_PRODUCT, f"Hd_y_{drive_idx}") + emitted.append( + Op( + kind=OpKind.QOP_MAKE_PRODUCT, + operands=(coeff_y, sy), + results=(prod_y,), + attrs={}, + )) + products.append(prod_y) + terms.append( + OperatorTerm( + kind="drive_control_y", + qubit_indices=(target_qubit,), + coefficient=complex(0, amplitude * math.sin(phase)), + time_dependent=True, + callback_id=f"{callback_id}_Q", + )) + + return emitted, terms, products + + +# --------------------------------------------------------------------------- +# Dissipator emission +# --------------------------------------------------------------------------- + + +def _emit_dissipators( + program: Program, + vid_counter: list, + t1_times: dict = None, + t2_times: dict = None, +) -> tuple: + """Emit Lindblad dissipator ops from calibration data.""" + ops: list[Op] = [] + terms: list[OperatorTerm] = [] + t1_times = t1_times or {} + t2_times = t2_times or {} + + for qidx in sorted(program.qubit_freq_hz.keys()): + t1 = t1_times.get(qidx) + if t1 is not None and t1 > 0: + gamma1 = 1.0 / t1 + sm = _make_value(vid_counter, _QOP_HANDLER, f"sm_q{qidx}") + ops.append( + Op( + kind=OpKind.QOP_SPIN, + operands=(), + results=(sm,), + attrs={ + "target": qidx, + "kind": "lowering" + }, + )) + gamma_coeff = _make_value(vid_counter, _QOP_SCALAR, + f"gamma1_q{qidx}") + ops.append( + Op( + kind=OpKind.QOP_CONST_SCALAR, + operands=(), + results=(gamma_coeff,), + attrs={ + "real": gamma1**0.5, + "imag": 0.0 + }, + )) + L = _make_value(vid_counter, _QOP_PRODUCT, f"L1_q{qidx}") + ops.append( + Op( + kind=OpKind.QOP_MAKE_PRODUCT, + operands=(gamma_coeff, sm), + results=(L,), + attrs={}, + )) + terms.append( + OperatorTerm( + kind="dissipator_t1", + qubit_indices=(qidx,), + coefficient=complex(gamma1**0.5, 0), + )) + + t2 = t2_times.get(qidx) + if t2 is not None and t2 > 0: + gamma_phi = 1.0 / t2 + if t1 is not None and t1 > 0: + gamma_phi = max(0.0, 1.0 / t2 - 1.0 / (2.0 * t1)) + # D[sqrt(gamma_phi / 2) Z] damps coherences at gamma_phi. + gamma2 = gamma_phi / 2.0 + sz = _make_value(vid_counter, _QOP_HANDLER, f"sz_deph_q{qidx}") + ops.append( + Op( + kind=OpKind.QOP_SPIN, + operands=(), + results=(sz,), + attrs={ + "target": qidx, + "kind": "spin_z" + }, + )) + gamma_coeff = _make_value(vid_counter, _QOP_SCALAR, + f"gamma2_q{qidx}") + ops.append( + Op( + kind=OpKind.QOP_CONST_SCALAR, + operands=(), + results=(gamma_coeff,), + attrs={ + "real": gamma2**0.5, + "imag": 0.0 + }, + )) + L = _make_value(vid_counter, _QOP_PRODUCT, f"L2_q{qidx}") + ops.append( + Op( + kind=OpKind.QOP_MAKE_PRODUCT, + operands=(gamma_coeff, sz), + results=(L,), + attrs={}, + )) + terms.append( + OperatorTerm( + kind="dissipator_t2", + qubit_indices=(qidx,), + coefficient=complex(gamma2**0.5, 0), + )) + + return ops, terms + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def _compute_loop_aware_time(program: Program) -> float: + """Compute the physical makespan without counting waveform definitions.""" + timed_kinds = {OpKind.DRIVE, OpKind.READOUT, OpKind.WAIT} + ready: dict[int, float] = {} + makespan = 0.0 + for op in program.ops: + operand_ready = max( + (ready.get(value.vid, 0.0) for value in op.operands), default=0.0) + duration = duration_of(op) if op.kind in timed_kinds else 0.0 + start = float(op.attrs.get("start_vtu", operand_ready)) + end = start + duration + if op.kind in timed_kinds: + makespan = max(makespan, end) + result_ready = operand_ready if op.kind == OpKind.SYNC else end + for result in op.results: + ready[result.vid] = result_ready + + return makespan * program.vtu_to_ns + + +def run_pulse_to_operator( + program: Program, + *, + target: Any = None, + t1_times: dict = None, + t2_times: dict = None, +) -> OperatorProgram: + """Lower a pulse program to an operator program in the qop dialect. + + For each drive op, emit a time-dependent control term. Collect per-line + static Hamiltonians. Emit dissipator ops if calibration data is present. + + Parameters + ---------- + program : Program + The pulse IR program. + target : Target, optional + If provided, Hamiltonian and dissipator terms are sourced from + the target instead of raw dicts. + t1_times, t2_times : dict, optional + Legacy per-qubit decoherence dicts. Ignored if ``target`` is provided. + """ + if target is not None: + active_qubits = set(program.qubit_freq_hz) + missing = sorted(active_qubits - set(target.qubits)) + if missing: + raise ValueError( + f"target {target.name!r} does not define active qubits {missing}" + ) + # Target coherence times are specified in microseconds. Operator + # evolution uses nanoseconds throughout. + t1_times = { + idx: t * 1e3 + for idx, t in target.t1_times.items() + if idx in active_qubits + } + t2_times = { + idx: t * 1e3 + for idx, t in target.t2_times.items() + if idx in active_qubits + } + if not program.qubit_freq_hz: + program.qubit_freq_hz = dict(target.frequencies) + + vid_counter = [max((v.vid for v in program.values), default=0) + 1000] + all_ops: list[Op] = [] + all_ham_terms: list[OperatorTerm] = [] + all_diss_terms: list[OperatorTerm] = [] + + # Static Hamiltonian + static_ops, static_terms, ham_products = _emit_static_hamiltonian( + program, vid_counter) + all_ops.extend(static_ops) + all_ham_terms.extend(static_terms) + + # If target provided, also emit anharmonicity and coupling terms + if target is not None: + for tdict in target.hamiltonian_terms(): + if tdict["kind"] in ("anharmonicity", "coupling_xx", + "crosstalk_zz") and all( + index in program.qubit_freq_hz + for index in tdict["qubit_indices"]): + all_ham_terms.append( + OperatorTerm( + kind=tdict["kind"], + qubit_indices=tdict["qubit_indices"], + coefficient=tdict["coefficient"], + time_dependent=tdict.get("time_dependent", False), + )) + + # Drive control terms + vid_to_qubit = _build_vid_to_qubit_map(program) + drive_idx = 0 + for op in program.ops: + if op.kind == OpKind.DRIVE: + drive_ops, drive_terms, drive_products = _emit_drive_control( + op, drive_idx, program, vid_counter, vid_to_qubit=vid_to_qubit) + all_ops.extend(drive_ops) + all_ham_terms.extend(drive_terms) + ham_products.extend(drive_products) + drive_idx += 1 + + # Sum all Hamiltonian terms + if len(ham_products) > 1: + H_total = _make_value(vid_counter, _QOP_OP, "H_total") + all_ops.append( + Op( + kind=OpKind.QOP_MAKE_SUM, + operands=tuple(ham_products), + results=(H_total,), + attrs={}, + )) + + # Dissipators + diss_ops, diss_terms = _emit_dissipators(program, vid_counter, t1_times, + t2_times) + all_ops.extend(diss_ops) + all_diss_terms.extend(diss_terms) + + # Lindblad superoperator if dissipators present + if (diss_ops or all_diss_terms) and ham_products: + lindblad_val = _make_value(vid_counter, _QOP_SUPEROP, "lindbladian") + all_ops.append( + Op( + kind=OpKind.QOP_LINDBLAD, + operands=(), + results=(lindblad_val,), + attrs={"n_collapse_ops": len(all_diss_terms)}, + )) + + n_qubits = max(program.qubit_freq_hz, default=0) + 1 + total_time = _compute_loop_aware_time(program) + + return OperatorProgram( + name=f"{program.name}_operator", + ops=all_ops, + hamiltonian_terms=all_ham_terms, + dissipator_terms=all_diss_terms, + n_qubits=n_qubits, + total_time_ns=total_time, + ) diff --git a/pulse/core/frontend/cudaq_pulse/passes/scheduling.py b/pulse/core/frontend/cudaq_pulse/passes/scheduling.py new file mode 100644 index 00000000000..97321f197f9 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/passes/scheduling.py @@ -0,0 +1,244 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Dependency-correct scheduling passes for the lightweight pulse IR.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Any + +from .ir_types import OpKind, Program, ValueType, duration_of, tone_id_of, waveform_of + + +@dataclass() +class ScheduledEvent: + """A scheduled pulse event with absolute timing.""" + + op_index: int + kind: str + start_vtu: float + duration_vtu: float + line_id: int | None = None + tone_id: int | None = None + waveform_id: int | None = None + attrs: dict[str, Any] = field(default_factory=dict) + + @property + def end_vtu(self) -> float: + return self.start_vtu + self.duration_vtu + + +@dataclass() +class ScheduleMetrics: + """Summary statistics for a computed schedule.""" + + total_length_vtu: float = 0.0 + total_length_ns: float = 0.0 + per_line_length_vtu: dict[int, float] = field(default_factory=dict) + op_count: int = 0 + compile_time_ms: float = 0.0 + idle_total_vtu: float = 0.0 + idle_fraction: float = 0.0 + + +@dataclass() +class MachineModel: + """Hardware resource constraints for resource-constrained scheduling.""" + + max_concurrent_drives: int = 4 + max_concurrent_readouts: int = 2 + readout_latency_vtu: float = 0.0 + line_switch_penalty_vtu: float = 0.0 + qubit_connectivity: dict[int, list[int]] = field(default_factory=dict) + + +_TIMED_KINDS = frozenset({OpKind.DRIVE, OpKind.READOUT, OpKind.WAIT}) +_LINE_TYPES = frozenset({ValueType.DRIVE_LINE, ValueType.READOUT_LINE}) + + +def _validate_machine(machine: MachineModel) -> None: + if machine.max_concurrent_drives <= 0: + raise ValueError("max_concurrent_drives must be positive") + if machine.max_concurrent_readouts <= 0: + raise ValueError("max_concurrent_readouts must be positive") + if machine.readout_latency_vtu < 0: + raise ValueError("readout_latency_vtu cannot be negative") + if machine.line_switch_penalty_vtu < 0: + raise ValueError("line_switch_penalty_vtu cannot be negative") + + +def _line_roots(program: Program) -> dict[int, int]: + """Map every line SSA value to its physical line allocation.""" + roots: dict[int, int] = {} + for op in program.ops: + line_operands = [ + value for value in op.operands if value.vtype in _LINE_TYPES + ] + line_results = [ + value for value in op.results if value.vtype in _LINE_TYPES + ] + if op.kind in (OpKind.ALLOC_DRIVE, OpKind.ALLOC_READOUT): + for result in line_results: + roots[result.vid] = result.vid + elif op.kind == OpKind.SYNC: + for operand, result in zip(line_operands, line_results): + roots[result.vid] = roots.get(operand.vid, operand.vid) + elif line_operands: + root = roots.get(line_operands[0].vid, line_operands[0].vid) + for result in line_results: + roots[result.vid] = root + return roots + + +def _event(program: Program, roots: dict[int, int], index: int, start: float, + duration: float) -> ScheduledEvent: + op = program.ops[index] + line = next((value for value in op.operands if value.vtype in _LINE_TYPES), + None) + return ScheduledEvent( + op_index=index, + kind=op.kind, + start_vtu=start, + duration_vtu=duration, + line_id=roots.get(line.vid, line.vid) if line is not None else None, + tone_id=tone_id_of(op), + waveform_id=waveform_of(op), + attrs=dict(op.attrs), + ) + + +def _annotate(program: Program, events: list[ScheduledEvent]) -> None: + for event in events: + if event.kind in _TIMED_KINDS: + op = program.ops[event.op_index] + op.attrs["start_vtu"] = event.start_vtu + op.attrs["duration_vtu"] = event.duration_vtu + + +def _schedule_forward( + program: Program, + machine: MachineModel | None = None) -> list[ScheduledEvent]: + roots = _line_roots(program) + ready: dict[int, float] = {} + events: list[ScheduledEvent] = [] + drive_lanes = [ + 0.0 + ] * machine.max_concurrent_drives if machine is not None else [] + readout_lanes = [ + 0.0 + ] * machine.max_concurrent_readouts if machine is not None else [] + + for index, op in enumerate(program.ops): + operand_ready = max( + (ready.get(value.vid, 0.0) for value in op.operands), default=0.0) + duration = duration_of(op) if op.kind in _TIMED_KINDS else 0.0 + start = operand_ready + result_ready = start + duration + + if op.kind == OpKind.SYNC: + result_ready = operand_ready + elif machine is not None and op.kind in (OpKind.DRIVE, OpKind.READOUT): + lanes = drive_lanes if op.kind == OpKind.DRIVE else readout_lanes + lane = min(range(len(lanes)), key=lanes.__getitem__) + start = max(start, lanes[lane]) + result_ready = start + duration + lanes[lane] = result_ready + machine.line_switch_penalty_vtu + if op.kind == OpKind.READOUT: + result_ready += machine.readout_latency_vtu + + events.append(_event(program, roots, index, start, duration)) + for result in op.results: + ready[result.vid] = result_ready + + _annotate(program, events) + return events + + +def _schedule_backward(program: Program, + forward: list[ScheduledEvent]) -> list[ScheduledEvent]: + makespan = max((event.end_vtu for event in forward), default=0.0) + roots = _line_roots(program) + latest: dict[int, float] = {} + reversed_events: list[ScheduledEvent] = [] + + for index in range(len(program.ops) - 1, -1, -1): + op = program.ops[index] + duration = duration_of(op) if op.kind in _TIMED_KINDS else 0.0 + end = min((latest.get(value.vid, makespan) for value in op.results), + default=makespan) + start = end - duration + reversed_events.append(_event(program, roots, index, start, duration)) + for operand in op.operands: + latest[operand.vid] = min(latest.get(operand.vid, makespan), start) + + events = list(reversed(reversed_events)) + _annotate(program, events) + return events + + +def _compute_metrics(events: list[ScheduledEvent], + program: Program) -> ScheduleMetrics: + total_length = max((event.end_vtu for event in events), default=0.0) + per_line: dict[int, float] = {} + active: dict[int, float] = {} + first_start: dict[int, float] = {} + for event in events: + if event.line_id is None or event.duration_vtu <= 0: + continue + per_line[event.line_id] = max(per_line.get(event.line_id, 0.0), + event.end_vtu) + first_start[event.line_id] = min( + first_start.get(event.line_id, event.start_vtu), event.start_vtu) + active[event.line_id] = active.get(event.line_id, + 0.0) + event.duration_vtu + available = sum(per_line[line] - first_start[line] for line in per_line) + active_total = sum(active.values()) + idle_total = max(0.0, available - active_total) + return ScheduleMetrics( + total_length_vtu=total_length, + total_length_ns=total_length * program.vtu_to_ns, + per_line_length_vtu=per_line, + op_count=program.op_count(), + idle_total_vtu=idle_total, + idle_fraction=idle_total / available if available else 0.0, + ) + + +def schedule_asap( + program: Program) -> tuple[list[ScheduledEvent], ScheduleMetrics]: + """Schedule operations at their earliest dependency-ready times.""" + started = time.perf_counter() + events = _schedule_forward(program) + metrics = _compute_metrics(events, program) + metrics.compile_time_ms = (time.perf_counter() - started) * 1000.0 + return events, metrics + + +def schedule_alap( + program: Program) -> tuple[list[ScheduledEvent], ScheduleMetrics]: + """Schedule operations as late as dependencies allow at the ASAP makespan.""" + started = time.perf_counter() + events = _schedule_backward(program, _schedule_forward(program)) + metrics = _compute_metrics(events, program) + metrics.compile_time_ms = (time.perf_counter() - started) * 1000.0 + return events, metrics + + +def schedule_rcp( + program: Program, + machine: MachineModel | None = None, +) -> tuple[list[ScheduledEvent], ScheduleMetrics]: + """List-schedule with interval-correct drive and readout resource limits.""" + machine = machine or MachineModel() + _validate_machine(machine) + started = time.perf_counter() + events = _schedule_forward(program, machine) + metrics = _compute_metrics(events, program) + metrics.compile_time_ms = (time.perf_counter() - started) * 1000.0 + return events, metrics diff --git a/pulse/core/frontend/cudaq_pulse/passes/to_pulse_mlir.py b/pulse/core/frontend/cudaq_pulse/passes/to_pulse_mlir.py new file mode 100644 index 00000000000..87739e78c41 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/passes/to_pulse_mlir.py @@ -0,0 +1,652 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Emit Pulse dialect MLIR text from an optimized Python Program. + +This is the bridge between the Python pass IR and the C++ MLIR lowering +stack. It takes a `Program` (after verify, canonicalize, virtual-z, +fusion, LICM, scheduling) and emits syntactically-valid pulse dialect +MLIR text that can be parsed by ``mlir-opt`` with the pulse dialect +registered. + +The emitted text is a ``builtin.module { func.func @(...) { ... } }`` +wrapper around the pulse ops. +""" + +from __future__ import annotations + +import math +from typing import Any + +from .ir_types import Op, OpKind, Program, Value, ValueType + +_WAVEFORM_TYPE = "!pulse.waveform" +_DRIVE_LINE = "!pulse.drive_line" +_READOUT_LINE = "!pulse.readout_line" +_TONE = "!pulse.tone" +_QREF = "!pulse.qref" +_MEASUREMENT = "!pulse.measurement" +_DURATION = "!pulse.duration" + +_VTYPE_TO_MLIR = { + ValueType.DRIVE_LINE: _DRIVE_LINE, + ValueType.READOUT_LINE: _READOUT_LINE, + ValueType.TONE: _TONE, + ValueType.WAVEFORM: _WAVEFORM_TYPE, + ValueType.IQ_DATA: "!pulse.iq_data", + ValueType.MEASUREMENT: _MEASUREMENT, + ValueType.QREF: _QREF, +} + + +class _EmitterState: + """Tracks SSA names, indentation, and qubit allocation during emission.""" + + __slots__ = ("lines", "vid_to_ssa", "qubit_ssa", "indent", "_ssa_counter") + + def __init__(self) -> None: + self.lines: list[str] = [] + self.vid_to_ssa: dict[int, str] = {} + self.qubit_ssa: dict[int, str] = {} + self.indent: int = 2 + self._ssa_counter: int = 0 + + def fresh_ssa(self, hint: str = "") -> str: + name = f"%{hint}{self._ssa_counter}" if hint else f"%{self._ssa_counter}" + self._ssa_counter += 1 + return name + + def bind(self, vid: int, ssa: str) -> None: + self.vid_to_ssa[vid] = ssa + + def ref(self, vid: int) -> str: + if vid not in self.vid_to_ssa: + raise ValueError(f"value %{vid} is used before it is defined") + return self.vid_to_ssa[vid] + + def emit(self, text: str) -> None: + self.lines.append(" " * self.indent + text) + + +def _fmt_f64(val: float) -> str: + """Format a float as MLIR f64 literal.""" + if val == float("inf"): + return "0x7FF0000000000000" + if val == float("-inf"): + return "0xFFF0000000000000" + if math.isnan(val): + return "0x7FF8000000000000" + if val == 0.0 and math.copysign(1.0, val) < 0: + return "-0.0" + s = f"{val:.15e}" + return s.replace("e+0", "e+0").replace("e-0", "e-0") + + +def _real_of(val: Any) -> float: + """Extract the real part from a possibly-complex value.""" + if isinstance(val, complex): + return val.real + return float(val) + + +def _integer_vtu(value: Any, description: str) -> int: + """Return an integer VTU value without silently truncating floats.""" + if isinstance(value, bool): + raise ValueError(f"{description} must be an integer, got bool") + try: + converted = int(value) + numeric = float(value) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError( + f"{description} must be an integer virtual-time value, got {value!r}" + ) from exc + if not math.isfinite(numeric) or numeric != converted: + raise ValueError( + f"{description} must be an integer virtual-time value, got {value!r}" + ) + return converted + + +def _mlir_type(vtype: ValueType) -> str: + return _VTYPE_TO_MLIR[vtype] + + +def _emit_qudit_allocs(prog: Program, st: _EmitterState) -> None: + """Emit pulse.qudit_alloc for each qubit referenced by alloc_drive/readout ops.""" + seen_qubits: set[int] = set() + for op in prog.ops: + if op.kind in (OpKind.ALLOC_DRIVE, OpKind.ALLOC_READOUT): + qi = op.attrs.get("qubit", -1) + if not isinstance(qi, int) or qi < 0: + raise ValueError(f"invalid physical qubit index {qi!r}") + if qi not in seen_qubits: + seen_qubits.add(qi) + ssa = st.fresh_ssa("q") + st.qubit_ssa[qi] = ssa + st.emit( + f"{ssa} = pulse.qudit_alloc {{qubit = {qi} : i64}} : {_QREF}" + ) + + +def _emit_waveform(op: Op, st: _EmitterState) -> None: + """Emit a pulse.gaussian / pulse.square / etc. waveform construction op.""" + wf_type = op.attrs.get("waveform_type", "square") + duration = _integer_vtu(op.attrs.get("duration_vtu", 0), + f"{wf_type} duration") + amplitude = op.attrs.get("amplitude", 0.0) + result_ssa = st.fresh_ssa("wf") + st.bind(op.results[0].vid, result_ssa) + + def constant(value: float | int, typ: str, hint: str) -> str: + ssa = st.fresh_ssa(hint) + literal = str(value) if typ == "i64" else _fmt_f64(float(value)) + st.emit(f"{ssa} = arith.constant {literal} : {typ}") + return ssa + + duration_ssa = constant(duration, "i64", "dur") + + if wf_type == "gaussian": + sigma = float(op.attrs.get("sigma", 1.0)) + amplitude_ssa = constant(_real_of(amplitude), "f64", "amp") + sigma_ssa = constant(sigma, "f64", "sigma") + st.emit(f"{result_ssa} = pulse.gaussian {duration_ssa}, " + f"{amplitude_ssa}, {sigma_ssa} : i64, f64, f64 -> " + f"{_WAVEFORM_TYPE}") + elif wf_type == "square": + if isinstance(amplitude, (list, tuple)): + real = float(amplitude[0]) if amplitude else 0.0 + imaginary = float(amplitude[1]) if len(amplitude) > 1 else 0.0 + elif isinstance(amplitude, complex): + real, imaginary = amplitude.real, amplitude.imag + else: + real, imaginary = float(amplitude), 0.0 + real_ssa = constant(real, "f64", "amp") + imaginary_ssa = constant(imaginary, "f64", "amp") + st.emit(f"{result_ssa} = pulse.square {duration_ssa}, {real_ssa}, " + f"{imaginary_ssa} : i64, f64, f64 -> {_WAVEFORM_TYPE}") + elif wf_type == "drag": + sigma = float(op.attrs.get("sigma", 1.0)) + beta = float(op.attrs.get("beta", 0.0)) + amplitude_ssa = constant(_real_of(amplitude), "f64", "amp") + sigma_ssa = constant(sigma, "f64", "sigma") + beta_ssa = constant(beta, "f64", "beta") + st.emit(f"{result_ssa} = pulse.drag {duration_ssa}, {amplitude_ssa}, " + f"{sigma_ssa}, {beta_ssa} : i64, f64, f64, f64 -> " + f"{_WAVEFORM_TYPE}") + elif wf_type == "cosine": + amplitude_ssa = constant(_real_of(amplitude), "f64", "amp") + st.emit(f"{result_ssa} = pulse.cosine {duration_ssa}, {amplitude_ssa} " + f": i64, f64 -> {_WAVEFORM_TYPE}") + elif wf_type == "tanh_ramp": + sigma = float(op.attrs.get("sigma", 1.0)) + amplitude_ssa = constant(_real_of(amplitude), "f64", "amp") + sigma_ssa = constant(sigma, "f64", "sigma") + st.emit(f"{result_ssa} = pulse.tanh_ramp {duration_ssa}, " + f"{amplitude_ssa}, {sigma_ssa} : i64, f64, f64 -> " + f"{_WAVEFORM_TYPE}") + elif wf_type == "gaussian_square": + sigma = float(op.attrs.get("sigma", 1.0)) + if "risefall" in op.attrs: + risefall = _integer_vtu(op.attrs["risefall"], + "gaussian_square rise/fall") + else: + width = _integer_vtu(op.attrs.get("width", 0), + "gaussian_square width") + if width < 0 or width >= duration: + raise ValueError( + "gaussian_square width must satisfy " + f"0 <= width < duration, got {width} and {duration}") + if (duration - width) % 2: + raise ValueError( + "gaussian_square requires duration - width to be even " + "so its two edges have equal integer length") + risefall = (duration - width) // 2 + amplitude_ssa = constant(_real_of(amplitude), "f64", "amp") + sigma_ssa = constant(sigma, "f64", "sigma") + risefall_ssa = constant(risefall, "i64", "edge") + st.emit(f"{result_ssa} = pulse.gaussian_square {duration_ssa}, " + f"{amplitude_ssa}, {sigma_ssa}, {risefall_ssa} : " + f"i64, f64, f64, i64 -> {_WAVEFORM_TYPE}") + elif wf_type == "custom_samples": + samples = op.attrs.get("samples", ()) + sample_text = ", ".join(_fmt_f64(float(v)) for v in samples) + st.emit( + f"{result_ssa} = pulse.custom_samples [{sample_text}] : {_WAVEFORM_TYPE}" + ) + else: + name = str(op.attrs.get("name", wf_type)).lstrip("@") + st.emit( + f"{result_ssa} = pulse.custom @{name}, {duration_ssa} : i64 -> {_WAVEFORM_TYPE}" + ) + + +def _emit_alloc_drive(op: Op, st: _EmitterState) -> None: + qi = op.attrs.get("qubit", 0) + q_ssa = st.qubit_ssa[qi] + line_ssa = st.fresh_ssa("d") + tone_ssa = st.fresh_ssa("t") + st.bind(op.results[0].vid, line_ssa) + st.bind(op.results[1].vid, tone_ssa) + frequency = float(op.attrs.get("frequency_hz", 0.0)) + st.emit( + f"{line_ssa}, {tone_ssa} = pulse.get_drive_line {q_ssa} " + f"{{qubit = {qi} : i64, frequency_hz = {_fmt_f64(frequency)} : f64}} " + f": ({_QREF}) -> ({_DRIVE_LINE}, {_TONE})") + + +def _emit_alloc_readout(op: Op, st: _EmitterState) -> None: + qi = op.attrs.get("qubit", 0) + q_ssa = st.qubit_ssa[qi] + line_ssa = st.fresh_ssa("r") + tone_ssa = st.fresh_ssa("rt") + st.bind(op.results[0].vid, line_ssa) + st.bind(op.results[1].vid, tone_ssa) + frequency = float(op.attrs.get("frequency_hz", 0.0)) + st.emit( + f"{line_ssa}, {tone_ssa} = pulse.get_readout_line {q_ssa} " + f"{{qubit = {qi} : i64, frequency_hz = {_fmt_f64(frequency)} : f64}} " + f": ({_QREF}) -> ({_READOUT_LINE}, {_TONE})") + + +def _emit_drive(op: Op, st: _EmitterState) -> None: + line_in = st.ref(op.operands[0].vid) + wf_in = st.ref(op.operands[1].vid) + tone_in = st.ref(op.operands[2].vid) + line_out = st.fresh_ssa("d") + tone_out = st.fresh_ssa("t") + st.bind(op.results[0].vid, line_out) + st.bind(op.results[1].vid, tone_out) + + attrs = "" + sched_attrs = [] + for key in ("start_vtu", "duration_vtu"): + if key in op.attrs: + value = _integer_vtu(op.attrs[key], f"drive {key}") + sched_attrs.append(f"{key} = {value} : i64") + if "phase_offset" in op.attrs or "phase" in op.attrs: + phase = float(op.attrs.get("phase_offset", op.attrs.get("phase", 0.0))) + sched_attrs.append(f"phase_offset = {_fmt_f64(phase)} : f64") + if "frame_phase_offset" in op.attrs: + frame_phase = float(op.attrs["frame_phase_offset"]) + sched_attrs.append( + f"frame_phase_offset = {_fmt_f64(frame_phase)} : f64") + if sched_attrs: + attrs = " {" + ", ".join(sched_attrs) + "}" + + st.emit( + f"{line_out}, {tone_out} = pulse.drive {line_in}, {wf_in}, {tone_in}" + f"{attrs} : {_DRIVE_LINE}, {_WAVEFORM_TYPE}, {_TONE} " + f"-> {_DRIVE_LINE}, {_TONE}") + + +def _emit_readout(op: Op, st: _EmitterState) -> None: + line_in = st.ref(op.operands[0].vid) + wf_in = st.ref(op.operands[1].vid) + tone_in = st.ref(op.operands[2].vid) + line_out = st.fresh_ssa("r") + tone_out = st.fresh_ssa("rt") + meas_out = st.fresh_ssa("m") + st.bind(op.results[0].vid, line_out) + st.bind(op.results[1].vid, tone_out) + st.bind(op.results[2].vid, meas_out) + mode = op.attrs.get("mode", "iq") + st.emit(f"{line_out}, {tone_out}, {meas_out} = pulse.readout " + f'{line_in}, {wf_in}, {tone_in}, "{mode}" ' + f": {_READOUT_LINE}, {_WAVEFORM_TYPE}, {_TONE} " + f"-> {_READOUT_LINE}, {_TONE}, {_MEASUREMENT}") + + +def _emit_wait(op: Op, st: _EmitterState) -> None: + line_in = st.ref(op.operands[0].vid) + line_out = st.fresh_ssa("d") + st.bind(op.results[0].vid, line_out) + dur_vtu = _integer_vtu(op.attrs.get("duration_vtu", 0), "wait duration") + dur_const = st.fresh_ssa("c") + dur_ssa = st.fresh_ssa("dur") + line_type = _mlir_type(op.operands[0].vtype) + st.emit(f"{dur_const} = arith.constant {dur_vtu} : i64") + st.emit( + f"{dur_ssa} = pulse.duration_from_int {dur_const} : (i64) -> {_DURATION}" + ) + st.emit( + f"{line_out} = pulse.wait {line_in}, {dur_ssa} : ({line_type}, {_DURATION}) -> {line_type}" + ) + + +def _emit_sync(op: Op, st: _EmitterState) -> None: + in_ssas = [st.ref(o.vid) for o in op.operands] + in_types = [_mlir_type(o.vtype) for o in op.operands] + out_ssas = [] + for r in op.results: + s = st.fresh_ssa("s") + st.bind(r.vid, s) + out_ssas.append(s) + out_types = [_mlir_type(r.vtype) for r in op.results] + st.emit(f"{', '.join(out_ssas)} = pulse.sync {', '.join(in_ssas)} " + f": {', '.join(in_types)} -> {', '.join(out_types)}") + + +def _emit_shift_phase(op: Op, st: _EmitterState) -> None: + tone_in = st.ref(op.operands[0].vid) + tone_out = st.fresh_ssa("t") + if op.results: + st.bind(op.results[0].vid, tone_out) + delta = float(op.attrs.get("delta_rad", op.attrs.get("delta", 0.0))) + delta_ssa = st.fresh_ssa("ph") + st.emit(f"{delta_ssa} = arith.constant {_fmt_f64(delta)} : f64") + st.emit( + f"{tone_out} = pulse.shift_phase {tone_in}, {delta_ssa} : {_TONE}, f64 -> {_TONE}" + ) + + +def _emit_set_phase(op: Op, st: _EmitterState) -> None: + tone_in = st.ref(op.operands[0].vid) + tone_out = st.fresh_ssa("t") + if op.results: + st.bind(op.results[0].vid, tone_out) + phase = float(op.attrs.get("phase_rad", op.attrs.get("phase", 0.0))) + phase_ssa = st.fresh_ssa("ph") + st.emit(f"{phase_ssa} = arith.constant {_fmt_f64(phase)} : f64") + st.emit( + f"{tone_out} = pulse.set_phase {tone_in}, {phase_ssa} : {_TONE}, f64 -> {_TONE}" + ) + + +def _emit_frequency_op(op: Op, st: _EmitterState, *, shift: bool) -> None: + tone_in = st.ref(op.operands[0].vid) + tone_out = st.fresh_ssa("t") + if op.results: + st.bind(op.results[0].vid, tone_out) + frequency = float(op.attrs.get("frequency_hz", 0.0)) + frequency_ssa = st.fresh_ssa("freq") + st.emit(f"{frequency_ssa} = arith.constant {_fmt_f64(frequency)} : f64") + name = "shift_frequency" if shift else "set_frequency" + st.emit( + f"{tone_out} = pulse.{name} {tone_in}, {frequency_ssa} : {_TONE}, f64 -> {_TONE}" + ) + + +def _find_end_for(ops: list[Op], start_idx: int) -> int: + """Find the matching END_FOR for a FOR_LOOP at start_idx.""" + depth = 0 + for i in range(start_idx, len(ops)): + if ops[i].kind == OpKind.FOR_LOOP: + depth += 1 + elif ops[i].kind == OpKind.END_FOR: + depth -= 1 + if depth == 0: + return i + raise ValueError(f"Unbalanced FOR_LOOP at op[{start_idx}]") + + +def _linear_types_only(values: tuple[Value, ...]) -> list[Value]: + """Filter to only linear-typed values (drive_line, readout_line, tone).""" + return [ + v for v in values if v.vtype in (ValueType.DRIVE_LINE, + ValueType.READOUT_LINE, ValueType.TONE) + ] + + +def _emit_for_loop( + op: Op, + ops: list[Op], + idx: int, + st: _EmitterState, +) -> int: + """Emit scf.for region. Returns the index past the matching END_FOR.""" + lb = _integer_vtu(op.attrs.get("lb", 0), "loop lower bound") + ub = _integer_vtu(op.attrs.get("ub", 1), "loop upper bound") + step = _integer_vtu(op.attrs.get("step", 1), "loop step") + + end_idx = _find_end_for(ops, idx) + end_op = ops[end_idx] + + linear_results = _linear_types_only(end_op.results) + + init_vids: list[int] = [] + init_ssas: list[str] = [] + iter_types: list[str] = [] + iter_arg_ssas: list[str] = [] + pre_vid_for_result: list[int] = [] + + for lr in linear_results: + mlir_t = _mlir_type(lr.vtype) + found_pre = False + for pre_op in reversed(ops[:idx]): + for res in pre_op.results: + if res.vtype == lr.vtype and res.vid in st.vid_to_ssa: + init_ssas.append(st.ref(res.vid)) + init_vids.append(res.vid) + iter_types.append(mlir_t) + arg_ssa = st.fresh_ssa("arg") + iter_arg_ssas.append(arg_ssa) + pre_vid_for_result.append(res.vid) + found_pre = True + break + if found_pre: + break + + lb_ssa = st.fresh_ssa("lb") + ub_ssa = st.fresh_ssa("ub") + step_ssa = st.fresh_ssa("step") + st.emit(f"{lb_ssa} = arith.constant {lb} : index") + st.emit(f"{ub_ssa} = arith.constant {ub} : index") + st.emit(f"{step_ssa} = arith.constant {step} : index") + + iv_ssa = st.fresh_ssa("iv") + + if iter_arg_ssas: + result_ssas = [] + for i, lr in enumerate(linear_results): + s = st.fresh_ssa("loop") + st.bind(lr.vid, s) + result_ssas.append(s) + result_str = ", ".join(result_ssas) + init_str = ", ".join(init_ssas) + iter_type_str = ", ".join(iter_types) + iter_args_str = ", ".join( + f"{a} : {t}" for a, t in zip(iter_arg_ssas, iter_types)) + st.emit(f"{result_str} = scf.for {iv_ssa} = {lb_ssa} to {ub_ssa} " + f"step {step_ssa} iter_args({iter_args_str}) = ({init_str}) " + f"-> ({iter_type_str}) {{") + else: + st.emit(f"scf.for {iv_ssa} = {lb_ssa} to {ub_ssa} step {step_ssa} {{") + + saved_bindings = dict(st.vid_to_ssa) + for pre_vid, arg_ssa in zip(pre_vid_for_result, iter_arg_ssas): + st.vid_to_ssa[pre_vid] = arg_ssa + + st.indent += 2 + body_idx = idx + 1 + while body_idx < end_idx: + body_idx = _emit_op(ops, body_idx, st) + + if iter_arg_ssas: + yield_vals = [] + for lr in linear_results: + for body_i in range(end_idx - 1, idx, -1): + body_op = ops[body_i] + for res in body_op.results: + if res.vtype == lr.vtype and res.vid in st.vid_to_ssa: + yield_vals.append(st.ref(res.vid)) + break + else: + continue + break + else: + yield_vals.append(iter_arg_ssas[len(yield_vals)]) + yield_types = ", ".join(iter_types) + yield_str = ", ".join(yield_vals[:len(iter_arg_ssas)]) + st.emit(f"scf.yield {yield_str} : {yield_types}") + + st.indent -= 2 + st.emit("}") + + for k, v in saved_bindings.items(): + if k not in st.vid_to_ssa: + st.vid_to_ssa[k] = v + + return end_idx + 1 + + +def _emit_op(ops: list[Op], idx: int, st: _EmitterState) -> int: + """Emit a single op. Returns the next index to process.""" + op = ops[idx] + + if op.kind == OpKind.ALLOC_DRIVE: + _emit_alloc_drive(op, st) + elif op.kind == OpKind.ALLOC_READOUT: + _emit_alloc_readout(op, st) + elif op.kind == OpKind.ALLOC_TONE: + freq = float(op.attrs.get("frequency_hz", 0.0)) + phase = float(op.attrs.get("phase_rad", 0.0)) + freq_ssa = st.fresh_ssa("freq") + phase_ssa = st.fresh_ssa("ph") + tone_ssa = st.fresh_ssa("t") + st.bind(op.results[0].vid, tone_ssa) + st.emit(f"{freq_ssa} = arith.constant {_fmt_f64(freq)} : f64") + st.emit(f"{phase_ssa} = arith.constant {_fmt_f64(phase)} : f64") + st.emit( + f"{tone_ssa} = pulse.tone {freq_ssa}, {phase_ssa} : f64, f64 -> {_TONE}" + ) + elif op.kind == OpKind.MAKE_WAVEFORM: + _emit_waveform(op, st) + elif op.kind == OpKind.DRIVE: + _emit_drive(op, st) + elif op.kind == OpKind.READOUT: + _emit_readout(op, st) + elif op.kind == OpKind.WAIT: + _emit_wait(op, st) + elif op.kind == OpKind.SYNC: + _emit_sync(op, st) + elif op.kind == OpKind.SHIFT_PHASE: + _emit_shift_phase(op, st) + elif op.kind == OpKind.SET_PHASE: + _emit_set_phase(op, st) + elif op.kind == OpKind.SHIFT_FREQUENCY: + _emit_frequency_op(op, st, shift=True) + elif op.kind == OpKind.SET_FREQUENCY: + _emit_frequency_op(op, st, shift=False) + elif op.kind == OpKind.FOR_LOOP: + return _emit_for_loop(op, ops, idx, st) + elif op.kind == OpKind.END_FOR: + pass + else: + raise ValueError(f"cannot emit unsupported pulse op {op.kind!r}") + return idx + 1 + + +def program_to_pulse_mlir( + prog: Program, + *, + target: Any | None = None, + t_start: float | None = None, + t_end: float | None = None, + num_steps: int | None = None, + integrator: str | None = None, +) -> str: + """Convert an optimized Program to pulse dialect MLIR text. + + Parameters + ---------- + prog : Program + The optimized program (post-verify, canonicalize, virtual-z, + fusion, LICM, scheduling). + + Returns + ------- + str + MLIR module text parseable by ``mlir-opt`` with the pulse dialect. + """ + st = _EmitterState() + active_qubits = sorted(prog.qubit_freq_hz) + n_qubits = max(active_qubits, default=0) + 1 + attrs = [ + f"pulse.clock_ghz = {_fmt_f64(prog.clock_ghz)} : f64", + f"qop.n_qubits = {n_qubits} : i64", + ] + if active_qubits: + frequencies = [prog.qubit_freq_hz.get(i, 0.0) for i in range(n_qubits)] + attrs.append("pulse.qubit_freq_hz = array") + if t_start is not None: + attrs.append(f"qop.t_start = {_fmt_f64(t_start)} : f64") + if t_end is not None: + attrs.append(f"qop.t_end = {_fmt_f64(t_end)} : f64") + if num_steps is not None: + attrs.append(f"qop.num_steps = {int(num_steps)} : i64") + if integrator is not None: + attrs.append(f'qop.integrator = "{integrator}"') + + if target is not None: + missing = [q for q in active_qubits if q not in target.qubits] + if missing: + raise ValueError( + f"target {target.name!r} does not define active qubits {missing}" + ) + t1_ns = [0.0] * n_qubits + t2_ns = [0.0] * n_qubits + drive_scales = [1.0] * n_qubits + for qi in active_qubits: + qubit = target.qubits[qi] + t1_ns[qi] = max(0.0, float(qubit.t1_us)) * 1.0e3 + t2_ns[qi] = max(0.0, float(qubit.t2_star_us)) * 1.0e3 + drive_scales[qi] = target.drive_amplitude_scale(qi) + attrs.append("pulse.t1_times = [" + + ", ".join(f"{_fmt_f64(value)} : f64" for value in t1_ns) + + "]") + attrs.append("pulse.t2_times = [" + + ", ".join(f"{_fmt_f64(value)} : f64" for value in t2_ns) + + "]") + attrs.append("pulse.drive_scale_rad_per_ns = array") + couplings = [ + c for c in target.couplings + if c.qubit_a in active_qubits and c.qubit_b in active_qubits + ] + if couplings: + pairs = [ + index for c in couplings for index in (c.qubit_a, c.qubit_b) + ] + attrs.append("pulse.coupling_pairs = array") + attrs.append("pulse.coupling_strength_hz = array") + + crosstalk = [ + c for c in target.crosstalk + if c.qubit_a in active_qubits and c.qubit_b in active_qubits + ] + if crosstalk: + pairs = [ + index for c in crosstalk for index in (c.qubit_a, c.qubit_b) + ] + attrs.append("pulse.crosstalk_pairs = array") + attrs.append("pulse.crosstalk_strength_hz = array") + + st.lines.append(f"module @{prog.name} attributes {{") + for index, attr in enumerate(attrs): + comma = "," if index + 1 < len(attrs) else "" + st.lines.append(f" {attr}{comma}") + st.lines.append("} {") + st.lines.append(" func.func @main() {") + + _emit_qudit_allocs(prog, st) + + idx = 0 + while idx < len(prog.ops): + idx = _emit_op(prog.ops, idx, st) + + st.indent = 2 + st.emit("return") + st.lines.append(" }") + st.lines.append("}") + + return "\n".join(st.lines) + "\n" diff --git a/pulse/core/frontend/cudaq_pulse/passes/verify.py b/pulse/core/frontend/cudaq_pulse/passes/verify.py new file mode 100644 index 00000000000..7b21a02fce5 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/passes/verify.py @@ -0,0 +1,411 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Verification passes for the pulse IR. + +Checks linearity, monotone time, drive exclusivity, and cross-resonance +calibration heuristics. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + duration_of, + is_linear_type, + line_id_of, + tone_id_of, +) + +# --------------------------------------------------------------------------- +# Error hierarchy +# --------------------------------------------------------------------------- + + +@dataclass() +class TypeCheckError: + """Base class for all verification errors.""" + + message: str + op_index: int | None = None + severity: str = "error" + + def __str__(self) -> str: + loc = f" at op[{self.op_index}]" if self.op_index is not None else "" + return f"[{self.severity}]{loc}: {self.message}" + + +@dataclass() +class LinearityViolation(TypeCheckError): + """A linear value was produced more than once or consumed != 1 time. + + Unconsumed terminal values are warnings. Duplicate production and + multiple consumption are errors because line and tone handles are linear. + """ + + value: Value | None = None + detail: str = "" + severity: str = "warning" + + +@dataclass() +class UnintentionalOverlapError(TypeCheckError): + """Two drive ops on the same line overlap in time.""" + + line_vid: int | None = None + interval_a: tuple[float, float] = (0.0, 0.0) + interval_b: tuple[float, float] = (0.0, 0.0) + + +@dataclass() +class BackwardTimeTravelError(TypeCheckError): + """An operation references a time earlier than a predecessor on the same line.""" + + line_vid: int | None = None + expected_min: float = 0.0 + actual: float = 0.0 + + +@dataclass() +class PhaseBookkeepingError(TypeCheckError): + """Inconsistency detected in phase tracking for a tone.""" + + tone_vid: int | None = None + detail: str = "" + + +@dataclass() +class CrossResonanceMiscalibrationError(TypeCheckError): + """Heuristic: possible cross-resonance tone mismatch.""" + + target_qubit: int | None = None + tone_tag: str = "" + severity: str = "warning" + + +# --------------------------------------------------------------------------- +# Individual checks +# --------------------------------------------------------------------------- + + +def check_linearity(program: Program) -> list[TypeCheckError]: + """Every linear value must be produced exactly once and consumed exactly once.""" + errors: list[TypeCheckError] = [] + produced: dict[int, int] = {} # vid -> op_index that produced it + consumed: dict[int, int] = {} # vid -> consume count + + for idx, op in enumerate(program.ops): + for v in op.results: + if not is_linear_type(v.vtype): + continue + if v.vid in produced: + errors.append( + LinearityViolation( + message= + f"Linear value {v} produced again (first at op[{produced[v.vid]}])", + op_index=idx, + value=v, + detail="duplicate_production", + severity="error", + )) + else: + produced[v.vid] = idx + + for v in op.operands: + if not is_linear_type(v.vtype): + continue + consumed[v.vid] = consumed.get(v.vid, 0) + 1 + + for vid, prod_idx in produced.items(): + count = consumed.get(vid, 0) + if count == 0: + v_repr = f"%{vid}" + errors.append( + LinearityViolation( + message= + f"Linear value {v_repr} produced at op[{prod_idx}] but never consumed", + op_index=prod_idx, + detail="unconsumed", + )) + elif count > 1: + v_repr = f"%{vid}" + errors.append( + LinearityViolation( + message= + f"Linear value {v_repr} consumed {count} times (expected 1)", + op_index=prod_idx, + detail="multiple_consumption", + severity="error", + )) + + return errors + + +def _line_roots(program: Program) -> dict[int, int]: + """Map each line SSA value to its physical line allocation.""" + roots: dict[int, int] = {} + line_types = (ValueType.DRIVE_LINE, ValueType.READOUT_LINE) + for op in program.ops: + operands = [value for value in op.operands if value.vtype in line_types] + results = [value for value in op.results if value.vtype in line_types] + if op.kind in (OpKind.ALLOC_DRIVE, OpKind.ALLOC_READOUT): + for result in results: + roots[result.vid] = result.vid + elif op.kind == OpKind.SYNC: + for operand, result in zip(operands, results): + roots[result.vid] = roots.get(operand.vid, operand.vid) + elif operands: + root = roots.get(operands[0].vid, operands[0].vid) + for result in results: + roots[result.vid] = root + return roots + + +def check_monotone_time(program: Program) -> list[TypeCheckError]: + """Verify that time only moves forward on each line.""" + errors: list[TypeCheckError] = [] + roots = _line_roots(program) + line_clocks: dict[int, float] = {} + + for idx, op in enumerate(program.ops): + lid = line_id_of(op) + if lid is None: + continue + + root = roots.get(lid, lid) + start = float(op.attrs.get("start_vtu", line_clocks.get(root, 0.0))) + current = line_clocks.get(root, 0.0) + + if start < current - 1e-12: + errors.append( + BackwardTimeTravelError( + message= + f"Time goes backward on line %{root}: expected >= {current:.4f}, got {start:.4f}", + op_index=idx, + line_vid=root, + expected_min=current, + actual=start, + )) + + dur = duration_of(op) + end = start + dur + if end > current: + line_clocks[root] = end + + for r in op.results: + if r.vtype in (ValueType.DRIVE_LINE, ValueType.READOUT_LINE): + line_clocks[r.vid] = line_clocks.get(root, 0.0) + + return errors + + +def check_drive_exclusivity(program: Program) -> list[TypeCheckError]: + """Drive ops on the same line lineage must be totally ordered, non-overlapping. + + Only meaningful after scheduling — if no ops carry ``start_vtu`` attrs + the check is skipped (unscheduled programs trivially alias at t=0). + """ + errors: list[TypeCheckError] = [] + + has_schedule = any("start_vtu" in op.attrs + for op in program.ops + if op.kind == OpKind.DRIVE) + if not has_schedule: + return errors + + roots = _line_roots(program) + line_intervals: dict[int, list[tuple[float, float, int]]] = {} + + for idx, op in enumerate(program.ops): + if op.kind != OpKind.DRIVE: + continue + lid = line_id_of(op) + if lid is None: + continue + + start = float(op.attrs.get("start_vtu", 0.0)) + dur = duration_of(op) + end = start + dur + + root = roots.get(lid, lid) + if root not in line_intervals: + line_intervals[root] = [] + + for prev_start, prev_end, prev_idx in line_intervals[root]: + if start < prev_end - 1e-12 and end > prev_start + 1e-12: + errors.append( + UnintentionalOverlapError( + message= + (f"Drive ops overlap on line %{root}: " + f"op[{prev_idx}] [{prev_start:.2f},{prev_end:.2f}) vs " + f"op[{idx}] [{start:.2f},{end:.2f})"), + op_index=idx, + line_vid=root, + interval_a=(prev_start, prev_end), + interval_b=(start, end), + )) + + line_intervals[root].append((start, end, idx)) + + return errors + + +def check_cr_miscalibration(program: Program) -> list[TypeCheckError]: + """Heuristic check for cross-resonance tone tag mismatches. + + If a drive op is tagged as cross-resonance (attrs['cr_target'] is set), + verify the tone tag matches the target qubit's frequency label. + """ + errors: list[TypeCheckError] = [] + + for idx, op in enumerate(program.ops): + if op.kind != OpKind.DRIVE: + continue + cr_target = op.attrs.get("cr_target") + if cr_target is None: + continue + + tone_tag = op.attrs.get("tone_tag", "") + expected_tag = f"q{cr_target}" + + if tone_tag and expected_tag not in tone_tag: + errors.append( + CrossResonanceMiscalibrationError( + message= + (f"Cross-resonance drive at op[{idx}] targets qubit {cr_target} " + f"but tone tag is '{tone_tag}' (expected to contain '{expected_tag}')" + ), + op_index=idx, + target_qubit=cr_target, + tone_tag=tone_tag, + )) + + return errors + + +def check_loop_structure(program: Program) -> list[TypeCheckError]: + """Verify FOR_LOOP/END_FOR are balanced and carry required attrs.""" + errors: list[TypeCheckError] = [] + stack: list[int] = [] + + for idx, op in enumerate(program.ops): + if op.kind == OpKind.FOR_LOOP: + stack.append(idx) + if "ub" not in op.attrs and "count" not in op.attrs: + errors.append( + TypeCheckError( + message= + f"FOR_LOOP at op[{idx}] missing 'ub' or 'count' attr", + op_index=idx, + )) + elif op.kind == OpKind.END_FOR: + if not stack: + errors.append( + TypeCheckError( + message= + f"END_FOR at op[{idx}] without matching FOR_LOOP", + op_index=idx, + )) + else: + stack.pop() + + for start_idx in stack: + errors.append( + TypeCheckError( + message=f"FOR_LOOP at op[{start_idx}] without matching END_FOR", + op_index=start_idx, + )) + + return errors + + +def check_waveform_validity(program: Program) -> list[TypeCheckError]: + """Check waveform construction attrs for basic validity.""" + errors: list[TypeCheckError] = [] + + for idx, op in enumerate(program.ops): + if op.kind != OpKind.MAKE_WAVEFORM: + continue + + dur = op.attrs.get("duration_vtu") + if dur is not None and float(dur) <= 0: + errors.append( + TypeCheckError( + message= + f"Waveform at op[{idx}] has non-positive duration: {dur}", + op_index=idx, + )) + + amp = op.attrs.get("amplitude") + if amp is not None: + amp_vals = amp if isinstance(amp, (list, tuple)) else [amp] + for av in amp_vals: + try: + a = abs(complex(av)) if isinstance(av, + complex) else float(av) + if not (-1e6 < a < 1e6): + errors.append( + TypeCheckError( + message= + f"Waveform at op[{idx}] has extreme amplitude: {a}", + op_index=idx, + severity="warning", + )) + except (TypeError, ValueError): + errors.append( + TypeCheckError( + message= + f"Waveform at op[{idx}] has non-numeric amplitude component: {av!r}", + op_index=idx, + )) + + sigma = op.attrs.get("sigma") + if sigma is not None and float(sigma) <= 0: + errors.append( + TypeCheckError( + message= + f"Waveform at op[{idx}] has non-positive sigma: {sigma}", + op_index=idx, + )) + + return errors + + +# --------------------------------------------------------------------------- +# Aggregate verifier +# --------------------------------------------------------------------------- + + +def verify(program: Program, *, strict: bool = False) -> list[TypeCheckError]: + """Run all verification checks and return collected errors/warnings. + + Parameters + ---------- + strict : bool + If True, raise ``RuntimeError`` on the first error-severity issue. + """ + errors: list[TypeCheckError] = [] + errors.extend(check_linearity(program)) + errors.extend(check_loop_structure(program)) + errors.extend(check_waveform_validity(program)) + errors.extend(check_monotone_time(program)) + errors.extend(check_drive_exclusivity(program)) + errors.extend(check_cr_miscalibration(program)) + + if strict: + for e in errors: + if e.severity == "error": + raise RuntimeError(str(e)) + + return errors diff --git a/pulse/core/frontend/cudaq_pulse/passes/virtual_z.py b/pulse/core/frontend/cudaq_pulse/passes/virtual_z.py new file mode 100644 index 00000000000..2a0a41f72cb --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/passes/virtual_z.py @@ -0,0 +1,167 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Virtual-Z gate elimination pass. + +Folds shift_phase/set_phase ops into downstream drive ops by adjusting the +drive's persistent frame phase. Tracks phase through SSA tone lineage so that +shift_phase(tone_%2) -> tone_%3 followed by drive(..., tone_%3) correctly +absorbs the accumulated phase. +""" + +from __future__ import annotations + +import math +from .ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + clone_program, +) + + +def _normalize_phase(phase: float) -> float: + """Normalize phase to [0, 2*pi).""" + return phase % (2.0 * math.pi) + + +def _tone_lineage(op: Op) -> tuple[int | None, int | None]: + """Return (input_tone_vid, output_tone_vid) for an op.""" + in_tid = None + out_tid = None + for v in op.operands: + if v.vtype == ValueType.TONE: + in_tid = v.vid + break + for v in op.results: + if v.vtype == ValueType.TONE: + out_tid = v.vid + break + return in_tid, out_tid + + +def run_virtual_z(program: Program) -> Program: + """Fold shift_phase/set_phase ops into subsequent drive ops. + + Rules: + - Two consecutive shift_phase on the same tone merge into one. + - set_phase followed by shift_phase -> single set_phase. + - Accumulated phase is applied to every downstream drive in that frame. + - Phase state tracks through SSA tone lineage (shift_phase produces a new + tone VID, and the accumulated phase transfers to that new VID). + """ + result = clone_program(program) + + # Phase state keyed by tone VID: (mode, accumulated_phase) + tone_phase: dict[int, tuple[str, float]] = {} + tone_alias: dict[int, int] = {} + + new_ops: list[Op] = [] + + def resolved_tone(tone_vid: int) -> int: + seen: set[int] = set() + while tone_vid in tone_alias and tone_vid not in seen: + seen.add(tone_vid) + replacement = tone_alias[tone_vid] + if replacement == tone_vid: + break + tone_vid = replacement + return tone_vid + + for op in result.ops: + in_tid, out_tid = _tone_lineage(op) + + if op.kind == OpKind.SHIFT_PHASE and in_tid is not None: + delta = float( + op.attrs.get( + "delta_rad", + op.attrs.get("phase", op.attrs.get("phase_rad", 0.0)))) + current = tone_phase.pop(in_tid, None) + + if current is None: + new_phase = ("shift", delta) + elif current[0] == "shift": + new_phase = ("shift", current[1] + delta) + else: + new_phase = ("set", current[1] + delta) + + target_tid = out_tid if out_tid is not None else in_tid + tone_phase[target_tid] = new_phase + tone_alias[target_tid] = resolved_tone(in_tid) + continue + + if op.kind == OpKind.SET_PHASE and in_tid is not None: + phase_val = float( + op.attrs.get("phase_rad", op.attrs.get("phase", 0.0))) + tone_phase.pop(in_tid, None) + target_tid = out_tid if out_tid is not None else in_tid + tone_phase[target_tid] = ("set", phase_val) + tone_alias[target_tid] = resolved_tone(in_tid) + continue + + operands = list(op.operands) + if in_tid is not None: + resolved_vid = resolved_tone(in_tid) + if resolved_vid != in_tid: + resolved_value = _find_tone_value(result.ops, resolved_vid) + if resolved_value is None: + raise ValueError( + f"cannot resolve tone %{resolved_vid} after virtual-Z") + operands = [ + resolved_value if value.vid == in_tid else value + for value in operands + ] + + new_attrs = dict(op.attrs) + if op.kind in (OpKind.DRIVE, OpKind.READOUT) and in_tid is not None: + phase_info = tone_phase.pop(in_tid, None) + if phase_info is not None: + mode, accumulated = phase_info + existing_phase = float(new_attrs.get("frame_phase_offset", 0.0)) + + if mode == "shift": + new_attrs["frame_phase_offset"] = _normalize_phase( + existing_phase + accumulated) + elif mode == "set": + new_attrs["frame_phase_offset"] = _normalize_phase( + accumulated) + + new_attrs["virtual_z_applied"] = True + + if out_tid is not None: + tone_phase[out_tid] = phase_info + + elif in_tid is not None and out_tid is not None: + phase_info = tone_phase.pop(in_tid, None) + if phase_info is not None: + tone_phase[out_tid] = phase_info + + if out_tid is not None: + # Kept operations define a new, valid SSA tone. Future aliases + # should stop here rather than bypassing the operation. + tone_alias[out_tid] = out_tid + + new_ops.append(Op(op.kind, tuple(operands), op.results, new_attrs)) + + # A phase operation whose tone never reaches a drive/readout is dead. No + # residual operation is needed because pulse kernels do not return tones. + result.ops = new_ops + return result + + +def _find_tone_value(ops: list[Op], tid: int) -> Value | None: + """Find the Value instance for a given tone vid.""" + for op in ops: + for v in op.results: + if v.vid == tid and v.vtype == ValueType.TONE: + return v + for v in op.operands: + if v.vid == tid and v.vtype == ValueType.TONE: + return v + return None diff --git a/pulse/core/frontend/cudaq_pulse/py.typed b/pulse/core/frontend/cudaq_pulse/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pulse/core/frontend/cudaq_pulse/runtime/__init__.py b/pulse/core/frontend/cudaq_pulse/runtime/__init__.py new file mode 100644 index 00000000000..1ac7788adc8 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/runtime/__init__.py @@ -0,0 +1,8 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GPU execution implementation modules for cudaq-pulse.""" diff --git a/pulse/core/frontend/cudaq_pulse/runtime/evolve.py b/pulse/core/frontend/cudaq_pulse/runtime/evolve.py new file mode 100644 index 00000000000..2480574ed35 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/runtime/evolve.py @@ -0,0 +1,207 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""High-level evolve() entry point for pulse-level time evolution.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional + +import numpy as np + +from ..passes.verify import verify as _verify_pass +from ..passes.canonicalize import run_canonicalize as _run_canonicalize +from ..passes.virtual_z import run_virtual_z as _run_virtual_z +from ..passes.fusion import run_fusion as _run_fusion +from ..passes.loop_passes import run_licm as _run_licm +from ..passes.scheduling import schedule_alap as _schedule_alap +from ..passes.to_pulse_mlir import program_to_pulse_mlir as _program_to_pulse_mlir +from .jit import compile_and_run_pulse + +_VALID_INTEGRATORS = frozenset({ + "rk1", + "rk2", + "rk4", + "magnus", + "crank_nicolson", +}) + + +@dataclass() +class EvolveResult: + """Result of a pulse-level time evolution.""" + + final_state: np.ndarray + times: np.ndarray + expectation_values: Optional[Dict[str, np.ndarray]] = None + + +def evolve( + program: Any, + *, + target: Any, + t_start: float, + t_end: float, + num_steps: int, + integrator: str = "rk4", + clock_ghz: float = 2.0, + observables: Optional[Dict[str, Any]] = None, +) -> EvolveResult: + """Run a pulse program through the full compilation pipeline and evolve. + + The default path uses the MLIR lowering stack: + 1. verify (linearity, monotone time, drive exclusivity) + 2. canonicalize + virtual-z + fusion + LICM + 3. schedule (ALAP) + 4. program_to_pulse_mlir() (emit pulse dialect MLIR) + 5. pulse-to-qop -> qop-to-cudm -> cudm-to-llvm (MLIR passes) + 6. JIT compile & execute on GPU + + Parameters + ---------- + program: + A ``PythonIRBuilder`` (from calling a ``@cudaq_pulse.kernel``) or + a ``Program`` (from ``to_program()``). + target: + A ``Target`` providing the Hamiltonian, decoherence, and connectivity. + t_start, t_end: + Time window in nanoseconds. + num_steps: + Number of integration time steps. + integrator: + Time-integration strategy for the cuDensityMat runtime. One of the + explicit Runge-Kutta schemes ``"rk1"``, ``"rk2"``, ``"rk4"``, the + ``"magnus"`` Taylor-series midpoint expansion, or the + ``"crank_nicolson"`` predictor-corrector method. + clock_ghz: + Pulse virtual-clock rate in GHz when converting a traced kernel IR. + observables: + Optional dict mapping names to operator expressions. + + Returns + ------- + EvolveResult + """ + if target is None: + raise ValueError( + "target is required. Pass a Target to specify the system " + "Hamiltonian and decoherence model.") + if getattr(target, "architecture", None) != "transmon": + raise NotImplementedError( + "GPU evolution currently supports two-level transmon targets " + "only; neutral-atom and multilevel target lowering is not yet " + "implemented") + if integrator not in _VALID_INTEGRATORS: + raise ValueError( + f"Unknown integrator {integrator!r}. " + f"Choose from: {', '.join(sorted(_VALID_INTEGRATORS))}") + if t_end <= t_start: + raise ValueError(f"t_end ({t_end}) must be > t_start ({t_start})") + if num_steps < 1: + raise ValueError(f"num_steps must be >= 1, got {num_steps}") + if clock_ghz <= 0: + raise ValueError(f"clock_ghz must be positive, got {clock_ghz}") + if observables: + raise NotImplementedError( + "observable evaluation is not implemented in the research " + "preview; evolve the state and evaluate observables explicitly") + + ir_program = _extract_program(program, target=target, clock_ghz=clock_ghz) + + return _evolve_mlir( + ir_program, + target=target, + t_start=t_start, + t_end=t_end, + num_steps=num_steps, + integrator=integrator, + observables=observables, + ) + + +def _evolve_mlir( + ir_program: Any, + *, + target: Any, + t_start: float, + t_end: float, + num_steps: int, + integrator: str, + observables: Optional[Dict[str, Any]], +) -> EvolveResult: + """MLIR lowering path: pulse -> qop -> cudm -> LLVM -> GPU.""" + from ..passes.ir_types import Program + + if isinstance(ir_program, Program): + _run_verify_suite(ir_program) + ir_program = _run_canonicalize(ir_program) + ir_program = _run_virtual_z(ir_program) + ir_program = _run_fusion(ir_program) + ir_program = _run_licm(ir_program) + _events, _metrics = _schedule_alap(ir_program) + + n_qubits = max(ir_program.qubit_freq_hz, default=0) + 1 + pulse_mlir = _program_to_pulse_mlir( + ir_program, + target=target, + t_start=t_start, + t_end=t_end, + num_steps=num_steps, + integrator=integrator, + ) + else: + raise TypeError(f"Expected a Program, got {type(ir_program).__name__}") + + results = compile_and_run_pulse(pulse_mlir, entry="main", n_qubits=n_qubits) + + if not results: + raise RuntimeError("JIT execution returned no results.") + + times = np.linspace(t_start, t_end, num_steps + 1) + final_state = results[0].to_numpy() + + return EvolveResult(final_state=final_state, times=times) + + +def _extract_program(program: Any, *, target: Any, clock_ghz: float) -> Any: + """Extract the IR program, dispatching on type.""" + from ..kernel.ir_builder import PythonIRBuilder + from ..lower import _to_program + from ..passes.ir_types import Program + + if isinstance(program, PythonIRBuilder): + return _to_program(program, + clock_ghz=clock_ghz, + qubit_freq_hz=target.frequencies) + if isinstance(program, Program): + return program + + emitter = getattr(program, "__cudaq_pulse_emitter__", None) + if emitter is not None: + if not isinstance(emitter, PythonIRBuilder): + raise TypeError("compiled kernel emitter is not a PythonIRBuilder") + return _to_program(emitter, + clock_ghz=clock_ghz, + qubit_freq_hz=target.frequencies) + + raise TypeError( + f"Expected a PythonIRBuilder, Program, or compiled @cudaq_pulse.kernel, " + f"got {type(program).__name__}. Call the kernel to build its IR first.") + + +def _run_verify_suite(program: Any) -> None: + """Run the verification pass suite. Raises on failure.""" + from ..passes.ir_types import Program + + if isinstance(program, Program): + issues = _verify_pass(program) + errors = [i for i in issues if i.severity == "error"] + if errors: + msg = "\n".join(f" {e}" for e in errors) + raise RuntimeError( + f"Verification failed with {len(errors)} error(s):\n{msg}") diff --git a/pulse/core/frontend/cudaq_pulse/runtime/jit.py b/pulse/core/frontend/cudaq_pulse/runtime/jit.py new file mode 100644 index 00000000000..3baa822da3b --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/runtime/jit.py @@ -0,0 +1,428 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""JIT compiler for lowered MLIR modules targeting cuDensityMat runtime.""" + +from __future__ import annotations + +import ctypes +import os +import subprocess +import tempfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +import numpy as np + + +def _find_cuda_runtime() -> Optional[Path]: + """Locate the CUDA runtime library.""" + search_dirs = [ + os.environ.get("CUDA_HOME", ""), + os.environ.get("CUDA_PATH", ""), + "/usr/local/cuda", + "/usr/lib/x86_64-linux-gnu", + ] + for d in search_dirs: + if not d: + continue + for sub in ("lib64", "lib"): + candidate = Path(d) / sub / "libcudart.so" + if candidate.exists(): + return candidate + return None + + +def _find_cudm_runtime() -> Optional[Path]: + """Locate libcudm-runtime.so.""" + env_path = os.environ.get("CUDM_RUNTIME_LIB") + if env_path and Path(env_path).exists(): + return Path(env_path) + + build_dir = os.environ.get("CUDAQ_PULSE_BUILD_DIR", "") + if build_dir: + for relative_path in ( + "lib/libcudm-runtime.so", + "pulse/core/runtime/cudm/libcudm-runtime.so", + ): + candidate = Path(build_dir) / relative_path + if candidate.exists(): + return candidate + + search_paths = [ + Path("/usr/local/lib"), + Path("/usr/lib"), + ] + for p in search_paths: + candidate = p / "libcudm-runtime.so" + if candidate.exists(): + return candidate + return None + + +def _find_mlir_opt() -> Optional[Path]: + """Locate cudaq-pulse-opt (from build dir or PATH).""" + build_dir = os.environ.get("CUDAQ_PULSE_BUILD_DIR", "") + if build_dir: + for subpath in ("core/mlir/tools/cudaq-pulse-opt/cudaq-pulse-opt", + "bin/cudaq-pulse-opt"): + candidate = Path(build_dir) / subpath + if candidate.exists(): + return candidate + + mlir_dir = os.environ.get("MLIR_DIR", "") + if mlir_dir: + candidate = Path(mlir_dir) / ".." / ".." / "bin" / "mlir-opt" + if candidate.exists(): + return candidate.resolve() + + for p in os.environ.get("PATH", "").split(os.pathsep): + for name in ("cudaq-pulse-opt", "mlir-opt"): + candidate = Path(p) / name + if candidate.exists(): + return candidate + return None + + +def _check_gpu_available() -> bool: + """Check whether an NVIDIA GPU is accessible.""" + cuda_rt = _find_cuda_runtime() + if cuda_rt is None: + return False + try: + lib = ctypes.CDLL(str(cuda_rt)) + count = ctypes.c_int(0) + err = lib.cudaGetDeviceCount(ctypes.byref(count)) + return err == 0 and count.value > 0 + except OSError: + return False + + +@dataclass(frozen=True) +class JITResult: + """Container for values returned from JIT-compiled code.""" + + raw_ptr: int + shape: tuple[int, ...] + dtype: np.dtype + _array: Optional[np.ndarray] = field(default=None, + repr=False, + compare=False) + + def to_numpy(self) -> np.ndarray: + """Interpret the raw device-to-host copy as a NumPy array.""" + if self._array is not None: + return self._array.reshape(self.shape).copy() + n_elements = 1 + for s in self.shape: + n_elements *= s + buf = (ctypes.c_double * (n_elements * 2)).from_address(self.raw_ptr) + flat = np.frombuffer(buf, dtype=np.complex128, count=n_elements) + return flat.reshape(self.shape).copy() + + +def _try_native_pipeline(pulse_mlir: str) -> Optional[str]: + """Run native lowering and return textual LLVM IR when available.""" + try: + from .._native._cudaq_pulse_native import MLIRPipeline + + pipeline = MLIRPipeline() + return pipeline.run_full_pipeline_to_llvm_ir(pulse_mlir) + except ImportError: + return None + + +def _run_mlir_opt_pipeline(pulse_mlir: str, work_dir: Path) -> str: + """Run the MLIR pass pipeline via external mlir-opt process.""" + mlir_opt = _find_mlir_opt() + if mlir_opt is None: + raise FileNotFoundError( + "Cannot find mlir-opt or cudaq-pulse-opt. Set CUDAQ_PULSE_BUILD_DIR or MLIR_DIR." + ) + + input_path = work_dir / "pulse_input.mlir" + output_path = work_dir / "llvm_output.mlir" + input_path.write_text(pulse_mlir) + + subprocess.run( + [ + str(mlir_opt), + "--pulse-to-qop", + "--qop-to-cudm", + "--cudm-to-llvm", + "--canonicalize", + "--convert-arith-to-llvm", + "--convert-func-to-llvm", + "--reconcile-unrealized-casts", + str(input_path), + "-o", + str(output_path), + ], + check=True, + capture_output=True, + ) + return output_path.read_text() + + +class JITCompiler: + """Compiles pulse MLIR through the full lowering pipeline and executes. + + Pipeline: pulse MLIR -> qop -> cudm -> LLVM IR -> .so -> execute + """ + + def __init__(self, *, mlir_bin_dir: Optional[str] = None): + self._mlir_bin = Path( + mlir_bin_dir) if mlir_bin_dir else self._find_mlir_bin() + self._cudm_lib: Optional[ctypes.CDLL] = None + self._temporary_directories: list[tempfile.TemporaryDirectory] = [] + + @staticmethod + def _find_mlir_bin() -> Path: + explicit = os.environ.get("CUDAQ_PULSE_LLVM_BIN", "") + if explicit: + candidate = Path(explicit) + if (candidate / "llc").exists() and (candidate / "clang").exists(): + return candidate + mlir_dir = os.environ.get("MLIR_DIR", "") + if mlir_dir: + candidate = Path(mlir_dir) / ".." / ".." / "bin" + if (candidate / "llc").exists() and (candidate / "clang").exists(): + return candidate.resolve() + for p in os.environ.get("PATH", "").split(os.pathsep): + if (Path(p) / "llc").exists() and (Path(p) / "clang").exists(): + return Path(p) + cudaq_llvm = Path("/opt/cudaq-llvm/bin") + if (cudaq_llvm / "llc").exists() and (cudaq_llvm / "clang").exists(): + return cudaq_llvm + raise FileNotFoundError( + "Cannot find compatible LLVM tools. Set CUDAQ_PULSE_LLVM_BIN or " + "MLIR_DIR, or put llc and clang on PATH.") + + def _load_cudm_runtime(self) -> ctypes.CDLL: + if self._cudm_lib is not None: + return self._cudm_lib + lib_path = _find_cudm_runtime() + if lib_path is None: + raise FileNotFoundError( + "Cannot find libcudm-runtime.so. Set CUDM_RUNTIME_LIB.") + self._cudm_lib = ctypes.CDLL(str(lib_path), mode=ctypes.RTLD_GLOBAL) + return self._cudm_lib + + def _compile_to_so(self, llvm_ir: str, work_dir: Path) -> Path: + """Lower LLVM IR text to a shared object.""" + ll_path = work_dir / "module.ll" + obj_path = work_dir / "module.o" + so_path = work_dir / "module.so" + + ll_path.write_text(llvm_ir) + + llc = self._mlir_bin / "llc" + clang = self._mlir_bin / "clang" + if not llc.exists(): + raise FileNotFoundError(f"Cannot find llc in {self._mlir_bin}") + if not clang.exists(): + raise FileNotFoundError(f"Cannot find clang in {self._mlir_bin}") + + subprocess.run( + [ + str(llc), "-relocation-model=pic", "-filetype=obj", + str(ll_path), "-o", + str(obj_path) + ], + check=True, + capture_output=True, + ) + subprocess.run( + [str(clang), "-shared", "-o", + str(so_path), + str(obj_path)], + check=True, + capture_output=True, + ) + return so_path + + def compile_pulse_mlir(self, pulse_mlir: str) -> Path: + """Run full pipeline: pulse MLIR -> LLVM -> .so""" + temporary = tempfile.TemporaryDirectory(prefix="cudaq_pulse_jit_") + self._temporary_directories.append(temporary) + work_dir = Path(temporary.name) + + # In-process lowering guarantees that all MLIR and LLVM components come + # from the same build. The external path remains a developer fallback. + llvm_ir = _try_native_pipeline(pulse_mlir) + if llvm_ir is None: + llvm_mlir = _run_mlir_opt_pipeline(pulse_mlir, work_dir) + mlir_path = work_dir / "llvm_dialect.mlir" + llvm_path = work_dir / "module.ll" + mlir_path.write_text(llvm_mlir) + translate = self._mlir_bin / "mlir-translate" + subprocess.run( + [ + str(translate), "--mlir-to-llvmir", + str(mlir_path), "-o", + str(llvm_path) + ], + check=True, + capture_output=True, + ) + llvm_ir = llvm_path.read_text() + return self._compile_to_so(llvm_ir, work_dir) + + def compile_module(self, mlir_text: str) -> Path: + """Translate MLIR (LLVM dialect) to a shared library (legacy API).""" + temporary = tempfile.TemporaryDirectory(prefix="cudaq_pulse_jit_") + self._temporary_directories.append(temporary) + work_dir = Path(temporary.name) + mlir_path = work_dir / "module.mlir" + llvm_path = work_dir / "module.ll" + mlir_path.write_text(mlir_text) + + translate = self._mlir_bin / "mlir-translate" + subprocess.run( + [ + str(translate), "--mlir-to-llvmir", + str(mlir_path), "-o", + str(llvm_path) + ], + check=True, + capture_output=True, + ) + llvm_ir = llvm_path.read_text() + return self._compile_to_so(llvm_ir, work_dir) + + def load_and_run( + self, + so_path: Path, + entry: str = "main", + args: Optional[Sequence[Any]] = None, + n_qubits: int = 1, + ) -> List[JITResult]: + """Load a compiled .so and invoke its entry point.""" + runtime = self._load_cudm_runtime() + runtime.cudm_last_result_size.argtypes = [] + runtime.cudm_last_result_size.restype = ctypes.c_int64 + runtime.cudm_last_result_copy.argtypes = [ + ctypes.c_void_p, ctypes.c_int64 + ] + runtime.cudm_last_result_copy.restype = ctypes.c_int + runtime.cudm_last_error_message.argtypes = [] + runtime.cudm_last_error_message.restype = ctypes.c_char_p + module = ctypes.CDLL(str(so_path)) + func = getattr(module, entry) + func.restype = None + c_args = _marshal_args(args or []) + func(*c_args) + byte_count = runtime.cudm_last_result_size() + if byte_count <= 0: + raw_message = runtime.cudm_last_error_message() + message = (raw_message.decode("utf-8", errors="replace") + if raw_message else "unknown cuDensityMat error") + raise RuntimeError(f"cuDensityMat execution failed: {message}") + if byte_count % np.dtype(np.complex128).itemsize: + raise RuntimeError( + f"cuDensityMat returned {byte_count} bytes, which is not a complex128 state buffer" + ) + buffer = (ctypes.c_byte * byte_count)() + status = runtime.cudm_last_result_copy(buffer, byte_count) + if status != 0: + raise RuntimeError("could not copy the cuDensityMat result buffer") + array = np.frombuffer(buffer, dtype=np.complex128).copy() + hilbert_dim = 2**n_qubits + if array.size == hilbert_dim: + shape = (hilbert_dim,) + elif array.size == hilbert_dim * hilbert_dim: + shape = (hilbert_dim, hilbert_dim) + else: + shape = (array.size,) + return [ + JITResult(raw_ptr=array.ctypes.data, + shape=shape, + dtype=np.dtype(np.complex128), + _array=array) + ] + + def compile_and_run( + self, + program: Any, + *, + entry: str = "main", + n_qubits: Optional[int] = None, + ) -> List[JITResult]: + """Compile a pulse ``Program`` or Pulse-MLIR string and execute it.""" + if not _check_gpu_available(): + raise RuntimeError( + "No GPU available. cudaq-pulse requires an NVIDIA GPU.") + if isinstance(program, str): + pulse_mlir = program + else: + from ..passes.ir_types import Program + from ..passes.to_pulse_mlir import program_to_pulse_mlir + + if not isinstance(program, Program): + raise TypeError("expected a pulse Program or Pulse-MLIR string") + pulse_mlir = program_to_pulse_mlir(program) + if n_qubits is None: + n_qubits = len(program.qubit_freq_hz) or 1 + so_path = self.compile_pulse_mlir(pulse_mlir) + return self.load_and_run(so_path, entry=entry, n_qubits=n_qubits or 1) + + +def _marshal_args(args: Sequence[Any]) -> list: + """Convert Python arguments to ctypes-compatible values.""" + c_args: list = [] + for a in args: + if isinstance(a, (int, np.integer)): + c_args.append(ctypes.c_int64(int(a))) + elif isinstance(a, (float, np.floating)): + c_args.append(ctypes.c_double(float(a))) + elif isinstance(a, np.ndarray): + c_args.append(a.ctypes.data_as(ctypes.c_void_p)) + else: + c_args.append(ctypes.c_void_p(id(a))) + return c_args + + +def compile_and_run( + module_text: str, + args: Optional[Sequence[Any]] = None, + *, + entry: str = "main", + n_qubits: int = 1, +) -> List[JITResult]: + """One-shot: compile an MLIR module and execute it. + + Raises ``RuntimeError`` if no GPU is available. + """ + if not _check_gpu_available(): + raise RuntimeError( + "No GPU available. cudaq-pulse requires an NVIDIA GPU.") + compiler = JITCompiler() + so_path = compiler.compile_module(module_text) + return compiler.load_and_run(so_path, + entry=entry, + args=args or [], + n_qubits=n_qubits) + + +def compile_and_run_pulse( + pulse_mlir: str, + *, + entry: str = "main", + n_qubits: int = 1, +) -> List[JITResult]: + """One-shot: compile pulse MLIR through the full pipeline and execute. + + This is the new primary entry point that runs: + pulse -> qop -> cudm -> llvm -> .so -> execute + """ + if not _check_gpu_available(): + raise RuntimeError( + "No GPU available. cudaq-pulse requires an NVIDIA GPU.") + compiler = JITCompiler() + so_path = compiler.compile_pulse_mlir(pulse_mlir) + return compiler.load_and_run(so_path, entry=entry, n_qubits=n_qubits) diff --git a/pulse/core/frontend/cudaq_pulse/targets/__init__.py b/pulse/core/frontend/cudaq_pulse/targets/__init__.py new file mode 100644 index 00000000000..13af1a55f43 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/targets/__init__.py @@ -0,0 +1,25 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""QPU target definitions: Hamiltonians, decoherence models, connectivity.""" + +from .base import Target, Qubit, Coupling, CrosstalkEntry +from .transmon import transmon_krinner_17q, transmon_generic +from .rydberg import RydbergAtom, RydbergTarget, rydberg_chain, rydberg_square + +__all__ = [ + "Target", + "Qubit", + "Coupling", + "CrosstalkEntry", + "transmon_krinner_17q", + "transmon_generic", + "RydbergAtom", + "RydbergTarget", + "rydberg_chain", + "rydberg_square", +] diff --git a/pulse/core/frontend/cudaq_pulse/targets/base.py b/pulse/core/frontend/cudaq_pulse/targets/base.py new file mode 100644 index 00000000000..34618331aa1 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/targets/base.py @@ -0,0 +1,218 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Architecture-neutral target API for QPU Hamiltonians and decoherence models. + +A ``Target`` fully describes a quantum device: qubit frequencies, +anharmonicities, coupling topology, decoherence parameters, and readout. +It can generate Hamiltonian and Lindblad dissipator terms for the +``pulse_to_operator`` lowering pass. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence, Tuple + +_SECONDS_PER_NANOSECOND = 1.0e-9 +_NANOSECONDS_PER_MICROSECOND = 1.0e3 + + +@dataclass(frozen=True) +class Qubit: + """Single qubit in a target device.""" + + index: int + frequency_hz: float + anharmonicity_hz: float + t1_us: float + t2_star_us: float + label: str = "" + drive_params: Dict[str, float] = field(default_factory=dict) + readout_params: Dict[str, float] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Coupling: + """Coupling edge between two qubits.""" + + qubit_a: int + qubit_b: int + coupling_strength_hz: float + gate_type: str = "cz" + gate_duration_ns: float = 98.0 + gate_buffer_ns: float = 15.0 + gate_fidelity: float = 0.985 + gate_params: Dict[str, float] = field(default_factory=dict) + + +@dataclass(frozen=True) +class CrosstalkEntry: + """Residual ZZ or other parasitic coupling between qubit pairs.""" + + qubit_a: int + qubit_b: int + zz_coupling: float + static_zz_hz: float + freq_delta_hz: float + + +@dataclass() +class Target: + """Full description of a quantum processing unit.""" + + name: str + qubits: Dict[int, Qubit] = field(default_factory=dict) + couplings: List[Coupling] = field(default_factory=list) + crosstalk: List[CrosstalkEntry] = field(default_factory=list) + architecture: str = "transmon" + attribution: str = "" + + @property + def n_qubits(self) -> int: + return len(self.qubits) + + @property + def qubit_indices(self) -> List[int]: + return sorted(self.qubits.keys()) + + @property + def frequencies(self) -> Dict[int, float]: + return {q.index: q.frequency_hz for q in self.qubits.values()} + + @property + def anharmonicities(self) -> Dict[int, float]: + return {q.index: q.anharmonicity_hz for q in self.qubits.values()} + + @property + def t1_times(self) -> Dict[int, float]: + """T1 in microseconds, keyed by qubit index.""" + return {q.index: q.t1_us for q in self.qubits.values()} + + @property + def t2_times(self) -> Dict[int, float]: + """T2* in microseconds, keyed by qubit index.""" + return {q.index: q.t2_star_us for q in self.qubits.values()} + + @property + def coupling_map(self) -> List[Tuple[int, int]]: + return [(c.qubit_a, c.qubit_b) for c in self.couplings] + + def connectivity_graph(self) -> Dict[int, List[int]]: + """Adjacency list representation of qubit connectivity.""" + g: Dict[int, List[int]] = {idx: [] for idx in self.qubits} + for c in self.couplings: + g.setdefault(c.qubit_a, []).append(c.qubit_b) + g.setdefault(c.qubit_b, []).append(c.qubit_a) + return g + + def get_drive_params(self, qubit_index: int) -> Dict[str, float]: + """Per-qubit drive parameters (amp, sigma, beta, etc.).""" + if qubit_index not in self.qubits: + raise KeyError(f"Qubit {qubit_index} not in target {self.name!r}") + return dict(self.qubits[qubit_index].drive_params) + + def drive_amplitude_scale(self, qubit_index: int) -> float: + """Return the conversion from pulse amplitude to radians/ns. + + Targets may provide ``amplitude_scale_rad_per_ns`` explicitly. For + transmon calibration records containing a Gaussian/DRAG pi pulse + (``x_amp``, ``x_dur``, and ``x_sigma``), the scale is inferred from + its truncated-Gaussian area. Targets without either representation use + 1.0, meaning pulse amplitudes are already angular rates in rad/ns. + """ + params = self.get_drive_params(qubit_index) + explicit = params.get("amplitude_scale_rad_per_ns") + if explicit is not None: + if explicit <= 0: + raise ValueError( + f"amplitude_scale_rad_per_ns must be positive for qubit {qubit_index}" + ) + return float(explicit) + + amplitude = float(params.get("x_amp", 0.0)) + duration = float(params.get("x_dur", 0.0)) + sigma = float(params.get("x_sigma", 0.0)) + if amplitude > 0.0 and duration > 0.0 and sigma > 0.0: + area = (sigma * math.sqrt(2.0 * math.pi) * + math.erf(duration / (2.0 * math.sqrt(2.0) * sigma))) + return math.pi / (amplitude * area) + return 1.0 + + def hamiltonian_terms(self) -> List[Dict[str, Any]]: + """Generate two-level static and coupling Hamiltonian terms. + + Returns a list of term dicts compatible with ``OperatorTerm``. + Each dict has keys: kind, qubit_indices, coefficient, time_dependent. + Coefficients are angular frequencies in radians per nanosecond, matching + the pulse IR time unit. + + ``anharmonicity_hz`` is calibration metadata for leakage models and is + intentionally not emitted into this two-level spin Hamiltonian. A + faithful anharmonicity term requires a three-or-more-level mode. + """ + terms: List[Dict[str, Any]] = [] + + for q in self.qubits.values(): + omega = q.frequency_hz * 2.0 * math.pi * _SECONDS_PER_NANOSECOND + terms.append({ + "kind": "static_z", + "qubit_indices": (q.index,), + "coefficient": complex(omega / 2.0, 0), + "time_dependent": False, + }) + for c in self.couplings: + g = c.coupling_strength_hz * 2.0 * math.pi * _SECONDS_PER_NANOSECOND + terms.append({ + "kind": "coupling_xx", + "qubit_indices": (c.qubit_a, c.qubit_b), + "coefficient": complex(g, 0), + "time_dependent": False, + }) + + for xt in self.crosstalk: + zz = xt.static_zz_hz * 2.0 * math.pi * _SECONDS_PER_NANOSECOND + terms.append({ + "kind": "crosstalk_zz", + "qubit_indices": (xt.qubit_a, xt.qubit_b), + "coefficient": complex(zz, 0), + "time_dependent": False, + }) + + return terms + + def dissipator_terms(self) -> List[Dict[str, Any]]: + """Generate T1 / T2 Lindblad dissipator terms. + + Returns a list of term dicts, each with kind, qubit_indices, + and coefficient. Collapse-operator coefficients have units of inverse + square-root nanoseconds, matching evolution time in nanoseconds. + """ + terms: List[Dict[str, Any]] = [] + + for q in self.qubits.values(): + if q.t1_us > 0: + gamma1 = 1.0 / (q.t1_us * _NANOSECONDS_PER_MICROSECOND) + terms.append({ + "kind": "dissipator_t1", + "qubit_indices": (q.index,), + "coefficient": complex(math.sqrt(gamma1), 0), + }) + if q.t2_star_us > 0: + gamma_phi = 1.0 / (q.t2_star_us * _NANOSECONDS_PER_MICROSECOND) + if q.t1_us > 0: + gamma_phi -= 1.0 / (2.0 * q.t1_us * + _NANOSECONDS_PER_MICROSECOND) + if gamma_phi > 0: + terms.append({ + "kind": "dissipator_t2", + "qubit_indices": (q.index,), + "coefficient": complex(math.sqrt(gamma_phi / 2.0), 0), + }) + + return terms diff --git a/pulse/core/frontend/cudaq_pulse/targets/rydberg.py b/pulse/core/frontend/cudaq_pulse/targets/rydberg.py new file mode 100644 index 00000000000..1ce6331e490 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/targets/rydberg.py @@ -0,0 +1,248 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Neutral atom / Rydberg QPU target definitions. + +Models the Rydberg many-body Hamiltonian: + + H/hbar = sum_j Omega_j(t)/2 (e^{i phi_j} |g> Rydberg state. The +stored value is 862690 MHz um^6; Hamiltonian construction converts it to the +angular coefficient 862690 * 2pi rad MHz um^6. + +Parameters are based on standard Rb-87 Rydberg physics from the published +neutral-atom literature. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +from .base import Coupling, Qubit, Target + +# Rb-87, |70S_1/2> Rydberg state +_DEFAULT_C6_MHZ_UM6 = 862690.0 # Cyclic-frequency coefficient, MHz * um^6. + + +@dataclass(frozen=True) +class RydbergAtom: + """A single atom in a Rydberg array.""" + + index: int + position: Tuple[float, float] # (x, y) in micrometers + + +class RydbergTarget: + """Neutral atom target using Rydberg interactions. + + Parameters + ---------- + atoms : list[RydbergAtom] + Atom positions in um. + c6 : float + Van der Waals coefficient in MHz * um^6 (before conversion to angular + frequency). + global_rabi_mhz : float + Global Rabi frequency in MHz. + global_detuning_mhz : float + Global detuning in MHz. + """ + + def __init__( + self, + atoms: List[RydbergAtom], + c6: float = _DEFAULT_C6_MHZ_UM6, + global_rabi_mhz: float = 4.0, + global_detuning_mhz: float = 0.0, + ): + if not atoms: + raise ValueError("Must provide at least one atom.") + self.atoms = sorted(atoms, key=lambda a: a.index) + self.c6 = c6 + self.global_rabi_mhz = global_rabi_mhz + self.global_detuning_mhz = global_detuning_mhz + + @property + def n_atoms(self) -> int: + return len(self.atoms) + + def blockade_radius(self) -> float: + """Rydberg blockade radius in um: R_b = (C6 / Omega)^{1/6}.""" + if self.global_rabi_mhz <= 0: + raise ValueError( + "global_rabi_mhz must be > 0 to compute blockade radius.") + return (self.c6 / self.global_rabi_mhz)**(1.0 / 6.0) + + def _distance(self, a: RydbergAtom, b: RydbergAtom) -> float: + dx = a.position[0] - b.position[0] + dy = a.position[1] - b.position[1] + return math.sqrt(dx * dx + dy * dy) + + def interaction_strength(self, a: RydbergAtom, b: RydbergAtom) -> float: + """V_{jk} = C6 / |x_j - x_k|^6 in MHz.""" + r = self._distance(a, b) + if r < 1e-12: + raise ValueError( + f"Atoms {a.index} and {b.index} are at the same position.") + return self.c6 / (r**6) + + def to_target(self) -> Target: + """Convert to a generic Target for the compilation pipeline.""" + qubits: Dict[int, Qubit] = {} + for atom in self.atoms: + qubits[atom.index] = Qubit( + index=atom.index, + frequency_hz=self.global_rabi_mhz * 1e6, + anharmonicity_hz=0.0, + t1_us=1000.0, # Rydberg T1 ~ ms scale + t2_star_us=100.0, + label=f"atom_{atom.index}", + ) + + couplings: List[Coupling] = [] + for i, ai in enumerate(self.atoms): + for aj in self.atoms[i + 1:]: + v = self.interaction_strength(ai, aj) + couplings.append( + Coupling( + qubit_a=ai.index, + qubit_b=aj.index, + coupling_strength_hz=v * 1e6, + gate_type="rydberg_blockade", + gate_duration_ns=0.0, + gate_fidelity=1.0, + )) + + return Target( + name="rydberg_array", + qubits=qubits, + couplings=couplings, + architecture="neutral_atom", + attribution= + ("Rydberg Hamiltonian parameterization based on Rb-87 |70S_1/2> " + "state. C6 coefficient from the published neutral-atom literature." + ), + ) + + def hamiltonian_terms(self) -> List[Dict[str, Any]]: + """Generate Rydberg Hamiltonian terms. + + Terms: + - Rabi drive: Omega_j/2 * sigma_x_j (time-dependent) + - Detuning: -Delta_j * n_j + - Interaction: C6/r^6 * n_j * n_k + """ + terms: List[Dict[str, Any]] = [] + + for atom in self.atoms: + rabi_rad = self.global_rabi_mhz * 1e-3 * 2.0 * math.pi + terms.append({ + "kind": "rabi_drive", + "qubit_indices": (atom.index,), + "coefficient": complex(rabi_rad / 2.0, 0), + "time_dependent": True, + }) + + if self.global_detuning_mhz != 0: + delta_rad = self.global_detuning_mhz * 1e-3 * 2.0 * math.pi + terms.append({ + "kind": "detuning", + "qubit_indices": (atom.index,), + "coefficient": complex(-delta_rad, 0), + "time_dependent": True, + }) + + for i, ai in enumerate(self.atoms): + for aj in self.atoms[i + 1:]: + v_mhz = self.interaction_strength(ai, aj) + v_rad = v_mhz * 1e-3 * 2.0 * math.pi + terms.append({ + "kind": "rydberg_interaction", + "qubit_indices": (ai.index, aj.index), + "coefficient": complex(v_rad, 0), + "time_dependent": False, + }) + + return terms + + def dissipator_terms(self) -> List[Dict[str, Any]]: + """Rydberg dissipators: spontaneous emission from |r> to |g>.""" + terms: List[Dict[str, Any]] = [] + for atom in self.atoms: + gamma = 1.0 / (1000.0 * 1e3) # ~1/ms, expressed in 1/ns + terms.append({ + "kind": "dissipator_spontaneous", + "qubit_indices": (atom.index,), + "coefficient": complex(math.sqrt(gamma), 0), + }) + return terms + + +def rydberg_chain( + n: int, + spacing_um: float = 6.0, + c6: float = _DEFAULT_C6_MHZ_UM6, + global_rabi_mhz: float = 4.0, + global_detuning_mhz: float = 0.0, +) -> RydbergTarget: + """1D chain of n atoms with uniform spacing. + + Parameters + ---------- + n : int + Number of atoms. + spacing_um : float + Inter-atom spacing in micrometers. + """ + if n < 1: + raise ValueError(f"n must be >= 1, got {n}") + atoms = [ + RydbergAtom(index=i, position=(i * spacing_um, 0.0)) for i in range(n) + ] + return RydbergTarget(atoms, + c6=c6, + global_rabi_mhz=global_rabi_mhz, + global_detuning_mhz=global_detuning_mhz) + + +def rydberg_square( + rows: int, + cols: int, + spacing_um: float = 6.0, + c6: float = _DEFAULT_C6_MHZ_UM6, + global_rabi_mhz: float = 4.0, + global_detuning_mhz: float = 0.0, +) -> RydbergTarget: + """2D square lattice of atoms. + + Parameters + ---------- + rows, cols : int + Grid dimensions. + spacing_um : float + Lattice spacing in micrometers. + """ + if rows < 1 or cols < 1: + raise ValueError( + f"rows and cols must be >= 1, got rows={rows}, cols={cols}") + atoms = [] + for r in range(rows): + for c in range(cols): + atoms.append( + RydbergAtom( + index=r * cols + c, + position=(c * spacing_um, r * spacing_um), + )) + return RydbergTarget(atoms, + c6=c6, + global_rabi_mhz=global_rabi_mhz, + global_detuning_mhz=global_detuning_mhz) diff --git a/pulse/core/frontend/cudaq_pulse/targets/transmon.py b/pulse/core/frontend/cudaq_pulse/targets/transmon.py new file mode 100644 index 00000000000..da1ddc6fe65 --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/targets/transmon.py @@ -0,0 +1,689 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Transmon QPU target definitions. + +The default target is the 17-qubit device from: + Krinner et al., "Realizing Repeated Quantum Error Correction in a + Distance-Three Surface Code", PRX 12, 021049 (2022). + arXiv:2112.03708 + +Device parameters extracted from calibration data in the cudaq-qlx-cal +repository (Dialect/Cal/Krinner/*.mlir). +""" + +from __future__ import annotations + +from .base import Coupling, CrosstalkEntry, Qubit, Target + + +def transmon_krinner_17q() -> Target: + """17-qubit superconducting transmon target (Krinner et al. 2022). + + Layout (rotated d=3 surface code): + D1(0) -- Z1(9) -- D2(1) + | | + X1(13) X2(14) -- D3(2) + | | + D4(3) -- Z2(10)-- D5(4) -- Z3(11)-- D6(5) + | | | + X3(15) X4(16) + | | | + D7(6) ----------- D8(7) -- Z4(12)-- D9(8) + + 9 data qubits (D1-D9, idx 0-8), 4 Z-ancillas (idx 9-12), + 4 X-ancillas (idx 13-16). Native 2Q gate: CZ (98 ns). + """ + + qubits = { + # --- Data qubits D1-D9 (indices 0-8) --- + 0: + Qubit(index=0, + frequency_hz=5.100e9, + anharmonicity_hz=-330e6, + t1_us=24.0, + t2_star_us=15.0, + label="D1", + drive_params={ + "x_amp": 0.432, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.73 + }, + readout_params={ + "ro_freq": 6.850e9, + "ro_amp": 0.185, + "ro_dur": 600.0 + }), + 1: + Qubit(index=1, + frequency_hz=5.210e9, + anharmonicity_hz=-325e6, + t1_us=28.0, + t2_star_us=20.0, + label="D2", + drive_params={ + "x_amp": 0.445, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.68 + }, + readout_params={ + "ro_freq": 6.920e9, + "ro_amp": 0.190, + "ro_dur": 600.0 + }), + 2: + Qubit(index=2, + frequency_hz=5.050e9, + anharmonicity_hz=-335e6, + t1_us=21.0, + t2_star_us=8.0, + label="D3", + drive_params={ + "x_amp": 0.438, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.71 + }, + readout_params={ + "ro_freq": 6.980e9, + "ro_amp": 0.200, + "ro_dur": 600.0 + }), + 3: + Qubit(index=3, + frequency_hz=5.150e9, + anharmonicity_hz=-328e6, + t1_us=26.0, + t2_star_us=17.0, + label="D4", + drive_params={ + "x_amp": 0.441, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.69 + }, + readout_params={ + "ro_freq": 7.040e9, + "ro_amp": 0.192, + "ro_dur": 600.0 + }), + 4: + Qubit(index=4, + frequency_hz=5.180e9, + anharmonicity_hz=-332e6, + t1_us=19.0, + t2_star_us=5.0, + label="D5", + drive_params={ + "x_amp": 0.435, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.75 + }, + readout_params={ + "ro_freq": 7.100e9, + "ro_amp": 0.188, + "ro_dur": 600.0 + }), + 5: + Qubit(index=5, + frequency_hz=5.090e9, + anharmonicity_hz=-327e6, + t1_us=27.0, + t2_star_us=18.0, + label="D6", + drive_params={ + "x_amp": 0.448, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.72 + }, + readout_params={ + "ro_freq": 7.150e9, + "ro_amp": 0.210, + "ro_dur": 600.0 + }), + 6: + Qubit(index=6, + frequency_hz=5.220e9, + anharmonicity_hz=-331e6, + t1_us=23.0, + t2_star_us=12.0, + label="D7", + drive_params={ + "x_amp": 0.429, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.70 + }, + readout_params={ + "ro_freq": 6.880e9, + "ro_amp": 0.195, + "ro_dur": 600.0 + }), + 7: + Qubit(index=7, + frequency_hz=5.130e9, + anharmonicity_hz=-329e6, + t1_us=31.0, + t2_star_us=23.0, + label="D8", + drive_params={ + "x_amp": 0.440, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.74 + }, + readout_params={ + "ro_freq": 6.960e9, + "ro_amp": 0.205, + "ro_dur": 600.0 + }), + 8: + Qubit(index=8, + frequency_hz=5.070e9, + anharmonicity_hz=-334e6, + t1_us=25.0, + t2_star_us=14.0, + label="D9", + drive_params={ + "x_amp": 0.436, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.67 + }, + readout_params={ + "ro_freq": 7.020e9, + "ro_amp": 0.187, + "ro_dur": 600.0 + }), + # --- Z-ancillas Z1-Z4 (indices 9-12) --- + 9: + Qubit(index=9, + frequency_hz=4.250e9, + anharmonicity_hz=-340e6, + t1_us=18.0, + t2_star_us=6.0, + label="Z1", + drive_params={ + "x_amp": 0.452, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.66 + }, + readout_params={ + "ro_freq": 6.810e9, + "ro_amp": 0.198, + "ro_dur": 600.0 + }), + 10: + Qubit(index=10, + frequency_hz=4.320e9, + anharmonicity_hz=-338e6, + t1_us=22.0, + t2_star_us=10.0, + label="Z2", + drive_params={ + "x_amp": 0.447, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.71 + }, + readout_params={ + "ro_freq": 6.870e9, + "ro_amp": 0.202, + "ro_dur": 600.0 + }), + 11: + Qubit(index=11, + frequency_hz=4.410e9, + anharmonicity_hz=-336e6, + t1_us=20.0, + t2_star_us=7.0, + label="Z3", + drive_params={ + "x_amp": 0.443, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.69 + }, + readout_params={ + "ro_freq": 6.940e9, + "ro_amp": 0.191, + "ro_dur": 600.0 + }), + 12: + Qubit(index=12, + frequency_hz=4.480e9, + anharmonicity_hz=-342e6, + t1_us=17.0, + t2_star_us=2.0, + label="Z4", + drive_params={ + "x_amp": 0.439, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.74 + }, + readout_params={ + "ro_freq": 7.010e9, + "ro_amp": 0.208, + "ro_dur": 600.0 + }), + # --- X-ancillas X1-X4 (indices 13-16) --- + 13: + Qubit(index=13, + frequency_hz=4.620e9, + anharmonicity_hz=-337e6, + t1_us=17.0, + t2_star_us=3.0, + label="X1", + drive_params={ + "x_amp": 0.450, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.68 + }, + readout_params={ + "ro_freq": 7.080e9, + "ro_amp": 0.186, + "ro_dur": 600.0 + }), + 14: + Qubit(index=14, + frequency_hz=4.710e9, + anharmonicity_hz=-339e6, + t1_us=23.0, + t2_star_us=13.0, + label="X2", + drive_params={ + "x_amp": 0.446, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.72 + }, + readout_params={ + "ro_freq": 7.130e9, + "ro_amp": 0.215, + "ro_dur": 600.0 + }), + 15: + Qubit(index=15, + frequency_hz=4.830e9, + anharmonicity_hz=-335e6, + t1_us=19.0, + t2_star_us=9.0, + label="X3", + drive_params={ + "x_amp": 0.441, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.70 + }, + readout_params={ + "ro_freq": 7.190e9, + "ro_amp": 0.194, + "ro_dur": 600.0 + }), + 16: + Qubit(index=16, + frequency_hz=4.900e9, + anharmonicity_hz=-341e6, + t1_us=18.0, + t2_star_us=4.0, + label="X4", + drive_params={ + "x_amp": 0.434, + "x_dur": 20.0, + "x_sigma": 5.0, + "x_beta": 0.75 + }, + readout_params={ + "ro_freq": 7.060e9, + "ro_amp": 0.220, + "ro_dur": 600.0 + }), + } + + # 24 CZ coupling edges (ancilla <-> data pairs) + couplings = [ + # Z1 (wt-2): D1-Z1, D2-Z1 + Coupling(0, + 9, + coupling_strength_hz=3.20e6, + gate_fidelity=0.986, + gate_params={ + "cz_amp": 0.320, + "cz_phase_correction": 0.045 + }), + Coupling(1, + 9, + coupling_strength_hz=3.10e6, + gate_fidelity=0.984, + gate_params={ + "cz_amp": 0.310, + "cz_phase_correction": 0.052 + }), + # Z2 (wt-4): D1-Z2, D2-Z2, D4-Z2, D5-Z2 + Coupling(0, + 10, + coupling_strength_hz=3.35e6, + gate_fidelity=0.985, + gate_params={ + "cz_amp": 0.335, + "cz_phase_correction": 0.038 + }), + Coupling(1, + 10, + coupling_strength_hz=3.28e6, + gate_fidelity=0.987, + gate_params={ + "cz_amp": 0.328, + "cz_phase_correction": 0.041 + }), + Coupling(3, + 10, + coupling_strength_hz=3.40e6, + gate_fidelity=0.983, + gate_params={ + "cz_amp": 0.340, + "cz_phase_correction": 0.063 + }), + Coupling(4, + 10, + coupling_strength_hz=3.15e6, + gate_fidelity=0.986, + gate_params={ + "cz_amp": 0.315, + "cz_phase_correction": 0.057 + }), + # Z3 (wt-4): D5-Z3, D6-Z3, D8-Z3, D9-Z3 + Coupling(4, + 11, + coupling_strength_hz=3.30e6, + gate_fidelity=0.985, + gate_params={ + "cz_amp": 0.330, + "cz_phase_correction": 0.049 + }), + Coupling(5, + 11, + coupling_strength_hz=3.45e6, + gate_fidelity=0.982, + gate_params={ + "cz_amp": 0.345, + "cz_phase_correction": 0.071 + }), + Coupling(7, + 11, + coupling_strength_hz=3.18e6, + gate_fidelity=0.988, + gate_params={ + "cz_amp": 0.318, + "cz_phase_correction": 0.035 + }), + Coupling(8, + 11, + coupling_strength_hz=3.25e6, + gate_fidelity=0.984, + gate_params={ + "cz_amp": 0.325, + "cz_phase_correction": 0.055 + }), + # Z4 (wt-2): D8-Z4, D9-Z4 + Coupling(7, + 12, + coupling_strength_hz=3.10e6, + gate_fidelity=0.986, + gate_params={ + "cz_amp": 0.310, + "cz_phase_correction": 0.043 + }), + Coupling(8, + 12, + coupling_strength_hz=3.38e6, + gate_fidelity=0.983, + gate_params={ + "cz_amp": 0.338, + "cz_phase_correction": 0.067 + }), + # X1 (wt-2): D1-X1, D4-X1 + Coupling(0, + 13, + coupling_strength_hz=3.05e6, + gate_fidelity=0.987, + gate_params={ + "cz_amp": 0.305, + "cz_phase_correction": 0.059 + }), + Coupling(3, + 13, + coupling_strength_hz=3.42e6, + gate_fidelity=0.984, + gate_params={ + "cz_amp": 0.342, + "cz_phase_correction": 0.047 + }), + # X2 (wt-4): D2-X2, D3-X2, D5-X2, D6-X2 + Coupling(1, + 14, + coupling_strength_hz=3.27e6, + gate_fidelity=0.985, + gate_params={ + "cz_amp": 0.327, + "cz_phase_correction": 0.061 + }), + Coupling(2, + 14, + coupling_strength_hz=3.13e6, + gate_fidelity=0.988, + gate_params={ + "cz_amp": 0.313, + "cz_phase_correction": 0.032 + }), + Coupling(4, + 14, + coupling_strength_hz=3.36e6, + gate_fidelity=0.982, + gate_params={ + "cz_amp": 0.336, + "cz_phase_correction": 0.074 + }), + Coupling(5, + 14, + coupling_strength_hz=3.22e6, + gate_fidelity=0.986, + gate_params={ + "cz_amp": 0.322, + "cz_phase_correction": 0.050 + }), + # X3 (wt-4): D4-X3, D5-X3, D7-X3, D8-X3 + Coupling(3, + 15, + coupling_strength_hz=3.08e6, + gate_fidelity=0.987, + gate_params={ + "cz_amp": 0.308, + "cz_phase_correction": 0.040 + }), + Coupling(4, + 15, + coupling_strength_hz=3.47e6, + gate_fidelity=0.981, + gate_params={ + "cz_amp": 0.347, + "cz_phase_correction": 0.082 + }), + Coupling(6, + 15, + coupling_strength_hz=3.19e6, + gate_fidelity=0.986, + gate_params={ + "cz_amp": 0.319, + "cz_phase_correction": 0.054 + }), + Coupling(7, + 15, + coupling_strength_hz=3.31e6, + gate_fidelity=0.984, + gate_params={ + "cz_amp": 0.331, + "cz_phase_correction": 0.068 + }), + # X4 (wt-2): D6-X4, D9-X4 + Coupling(5, + 16, + coupling_strength_hz=3.24e6, + gate_fidelity=0.988, + gate_params={ + "cz_amp": 0.324, + "cz_phase_correction": 0.036 + }), + Coupling(8, + 16, + coupling_strength_hz=3.06e6, + gate_fidelity=0.983, + gate_params={ + "cz_amp": 0.306, + "cz_phase_correction": 0.078 + }), + ] + + crosstalk = [ + CrosstalkEntry(0, + 5, + zz_coupling=8.5e-4, + static_zz_hz=1.2e5, + freq_delta_hz=1.0e7), + CrosstalkEntry(2, + 8, + zz_coupling=6.2e-4, + static_zz_hz=8.5e4, + freq_delta_hz=2.0e7), + CrosstalkEntry(4, + 3, + zz_coupling=4.8e-4, + static_zz_hz=6.2e4, + freq_delta_hz=3.0e7), + CrosstalkEntry(1, + 6, + zz_coupling=9.1e-4, + static_zz_hz=1.35e5, + freq_delta_hz=1.0e7), + CrosstalkEntry(7, + 3, + zz_coupling=5.9e-4, + static_zz_hz=7.8e4, + freq_delta_hz=2.0e7), + CrosstalkEntry(4, + 10, + zz_coupling=2.1e-5, + static_zz_hz=3.5e3, + freq_delta_hz=8.6e8), + CrosstalkEntry(9, + 10, + zz_coupling=1.5e-4, + static_zz_hz=2.1e4, + freq_delta_hz=7.0e7), + CrosstalkEntry(11, + 12, + zz_coupling=1.4e-4, + static_zz_hz=1.9e4, + freq_delta_hz=7.0e7), + ] + + return Target( + name="transmon_krinner_17q", + qubits=qubits, + couplings=couplings, + crosstalk=crosstalk, + architecture="transmon", + attribution=( + "Krinner et al., 'Realizing Repeated Quantum Error Correction " + "in a Distance-Three Surface Code', PRX 12, 021049 (2022). " + "arXiv:2112.03708"), + ) + + +def transmon_generic( + n_qubits: int, + *, + base_frequency_hz: float = 5.0e9, + frequency_spread_hz: float = 100e6, + anharmonicity_hz: float = -330e6, + coupling_strength_hz: float = 3.0e6, + t1_us: float = 25.0, + t2_star_us: float = 15.0, + topology: str = "linear", +) -> Target: + """Build a generic transmon target with configurable parameters. + + Parameters + ---------- + n_qubits : int + Number of qubits. + base_frequency_hz : float + Frequency of qubit 0. + frequency_spread_hz : float + Frequency spacing between adjacent qubits. + anharmonicity_hz : float + Uniform anharmonicity (negative for transmon). + coupling_strength_hz : float + Uniform nearest-neighbor coupling. + t1_us, t2_star_us : float + Uniform decoherence times. + topology : str + ``"linear"`` (chain), ``"ring"``, or ``"grid"`` (square lattice + with side length = ceil(sqrt(n_qubits))). + """ + if n_qubits < 1: + raise ValueError(f"n_qubits must be >= 1, got {n_qubits}") + + import math as _math + + qubits = {} + for i in range(n_qubits): + qubits[i] = Qubit( + index=i, + frequency_hz=base_frequency_hz + + i * frequency_spread_hz / max(n_qubits - 1, 1), + anharmonicity_hz=anharmonicity_hz, + t1_us=t1_us, + t2_star_us=t2_star_us, + label=f"Q{i}", + ) + + edges: list[tuple[int, int]] = [] + if topology == "linear": + edges = [(i, i + 1) for i in range(n_qubits - 1)] + elif topology == "ring": + edges = [(i, (i + 1) % n_qubits) for i in range(n_qubits)] + elif topology == "grid": + side = _math.ceil(_math.sqrt(n_qubits)) + for i in range(n_qubits): + r, c = divmod(i, side) + if c + 1 < side and i + 1 < n_qubits: + edges.append((i, i + 1)) + if r + 1 < side and i + side < n_qubits: + edges.append((i, i + side)) + else: + raise ValueError( + f"Unknown topology {topology!r}. Use 'linear', 'ring', or 'grid'.") + + couplings = [ + Coupling(a, b, coupling_strength_hz=coupling_strength_hz) + for a, b in edges + ] + + return Target( + name=f"transmon_generic_{n_qubits}q_{topology}", + qubits=qubits, + couplings=couplings, + architecture="transmon", + ) diff --git a/pulse/core/frontend/cudaq_pulse/viz/__init__.py b/pulse/core/frontend/cudaq_pulse/viz/__init__.py new file mode 100644 index 00000000000..c6c6f4d157c --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/viz/__init__.py @@ -0,0 +1,7 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # diff --git a/pulse/core/frontend/cudaq_pulse/viz/timeline.py b/pulse/core/frontend/cudaq_pulse/viz/timeline.py new file mode 100644 index 00000000000..9fdb37e886c --- /dev/null +++ b/pulse/core/frontend/cudaq_pulse/viz/timeline.py @@ -0,0 +1,179 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Timeline visualization for pulse schedules. + +Plots a per-line timeline of drive, readout, wait, and sync events +using matplotlib. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from matplotlib.figure import Figure + +from ..passes.ir_types import Program +from ..passes.scheduling import ScheduledEvent + +_DRIVE_COLOR = "#4C72B0" +_READOUT_COLOR = "#DD8452" +_WAIT_COLOR = "#CCCCCC" +_SYNC_COLOR = "#55A868" +_TONE_MOD_COLOR = "#C44E52" + + +def plot_schedule( + events: list[ScheduledEvent], + program: Program | None = None, + figsize: tuple[float, float] = (14, 4), + title: str | None = None, +) -> Figure: + """Plot a pulse schedule timeline. + + Args: + events: Scheduled events from the scheduling pass. + program: Optional program for additional metadata. + figsize: Figure size (width, height). + title: Optional figure title. + + Returns: + matplotlib Figure. + """ + import matplotlib.pyplot as plt + import matplotlib.patches as mpatches + + line_ids: list[int] = [] + for ev in events: + if ev.line_id is not None and ev.line_id not in line_ids: + line_ids.append(ev.line_id) + + line_to_row = {lid: i for i, lid in enumerate(line_ids)} + n_rows = max(len(line_ids), 1) + + fig, ax = plt.subplots(figsize=figsize) + + for ev in events: + if ev.line_id is None: + continue + row = line_to_row.get(ev.line_id) + if row is None: + continue + + y = row + x = ev.start_vtu + w = ev.duration_vtu + + if ev.kind in ("drive",): + rect = mpatches.FancyBboxPatch( + (x, y - 0.35), + w, + 0.7, + boxstyle="round,pad=0.02", + facecolor=_DRIVE_COLOR, + edgecolor="black", + linewidth=0.5, + alpha=0.85, + ) + ax.add_patch(rect) + elif ev.kind in ("readout", "iq_acquire"): + rect = mpatches.FancyBboxPatch( + (x, y - 0.35), + w, + 0.7, + boxstyle="round,pad=0.02", + facecolor=_READOUT_COLOR, + edgecolor="black", + linewidth=0.5, + alpha=0.85, + ) + ax.add_patch(rect) + elif ev.kind == "wait" and w > 0: + rect = mpatches.Rectangle( + (x, y - 0.3), + w, + 0.6, + facecolor=_WAIT_COLOR, + edgecolor="gray", + linewidth=0.3, + alpha=0.5, + hatch="//", + ) + ax.add_patch(rect) + + sync_times: set[int] = set() + for ev in events: + if ev.kind == "sync": + sync_times.add(ev.start_vtu) + for t in sync_times: + ax.axvline(x=t, + color=_SYNC_COLOR, + linestyle="--", + linewidth=1, + alpha=0.7) + + for ev in events: + if ev.kind in ("shift_phase", "set_phase") and ev.tone_id is not None: + for lid, row in line_to_row.items(): + ax.plot( + ev.start_vtu, + row, + marker="^", + color=_TONE_MOD_COLOR, + markersize=6, + zorder=5, + ) + break + elif ev.kind in ("shift_frequency", + "set_frequency") and ev.tone_id is not None: + for lid, row in line_to_row.items(): + ax.plot( + ev.start_vtu, + row, + marker="s", + color=_TONE_MOD_COLOR, + markersize=5, + zorder=5, + ) + break + + ax.set_yticks(range(n_rows)) + ax.set_yticklabels([f"line {lid}" for lid in line_ids]) + ax.set_xlabel("Time (VTU)") + ax.set_ylim(-0.5, n_rows - 0.5) + + max_time = max((ev.start_vtu + ev.duration_vtu for ev in events), + default=100) + ax.set_xlim(-max_time * 0.02, max_time * 1.05) + + if title: + ax.set_title(title) + elif program: + ax.set_title(f"Pulse Schedule: {program.name}") + + legend_handles = [ + mpatches.Patch(color=_DRIVE_COLOR, label="Drive"), + mpatches.Patch(color=_READOUT_COLOR, label="Readout"), + mpatches.Patch(color=_WAIT_COLOR, label="Wait", hatch="//"), + ] + ax.legend(handles=legend_handles, loc="upper right", fontsize=8) + fig.tight_layout() + return fig + + +def save_schedule( + events: list[ScheduledEvent], + path: str, + program: Program | None = None, + **kwargs: Any, +) -> None: + """Plot and save a schedule to a file.""" + fig = plot_schedule(events, program=program, **kwargs) + fig.savefig(path, dpi=150, bbox_inches="tight") + import matplotlib.pyplot as plt + plt.close(fig) diff --git a/pulse/core/mlir/CMakeLists.txt b/pulse/core/mlir/CMakeLists.txt new file mode 100644 index 00000000000..4b754eec7b9 --- /dev/null +++ b/pulse/core/mlir/CMakeLists.txt @@ -0,0 +1,12 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_subdirectory(dialects) +add_subdirectory(transforms) +add_subdirectory(conversions) +add_subdirectory(capi) diff --git a/pulse/core/mlir/bindings/CMakeLists.txt b/pulse/core/mlir/bindings/CMakeLists.txt new file mode 100644 index 00000000000..d9a24558b8e --- /dev/null +++ b/pulse/core/mlir/bindings/CMakeLists.txt @@ -0,0 +1,69 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +nanobind_add_module( + _cudaq_pulse_native + NB_STATIC + bindings.cpp +) + +# Unlike cudaq-pulse-opt, this extension has to share a process with `cudaq`, +# whose `libcudaqMLIR.so` already contains MLIR and LLVM. Linking the static +# MLIR archives here as well would give the process two copies of every LLVM +# command-line option and dialect registry, which aborts at import time with +# "Option ... registered more than once". +# +# So instead of linking the CudaqPulse* archives (whose interface pulls in the +# whole static MLIR closure), consume their object files directly and resolve +# every MLIR/LLVM symbol from libcudaqMLIR. Only MLIR libraries that +# libcudaqMLIR does not bundle are still linked statically. +target_link_libraries(_cudaq_pulse_native PRIVATE + $ + $ + $ + $ + $ + $ + $ + # mlir::createReconcileUnrealizedCastsPass is the one symbol libcudaqMLIR + # does not export. Link the archive by path rather than by target name: the + # MLIRReconcileUnrealizedCasts target's link interface would pull the whole + # static MLIR closure back in. + $ + cudaq::MLIR +) + +# $ contributes object files but no build dependency. +add_dependencies(_cudaq_pulse_native + obj.CudaqPulsePulse + obj.CudaqPulsePulseTransforms + obj.CudaqPulseQOp + obj.CudaqPulseCuDensityMat + obj.CudaqPulsePulseToQOp + obj.CudaqPulseQOpToCuDensityMat + obj.CudaqPulseCuDensityMatToLLVM) + +# Resolve libcudaqMLIR at load time from the CUDA-Q wheel's library directory. +set_property(TARGET _cudaq_pulse_native APPEND PROPERTY + BUILD_RPATH "${CUDAQ_LIBRARY_DIR}") +set_property(TARGET _cudaq_pulse_native APPEND PROPERTY + INSTALL_RPATH "${CUDAQ_LIBRARY_DIR}") + +# Fail the build if this extension references MLIR/LLVM symbols libcudaqMLIR +# does not export. +cudaq_check_mlir_symbol_closure(_cudaq_pulse_native) + +set_target_properties(_cudaq_pulse_native PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/python/cudaq_pulse/_native" +) + +install( + TARGETS _cudaq_pulse_native + LIBRARY DESTINATION cudaq_pulse/_native + COMPONENT CudaqPulse +) diff --git a/pulse/core/mlir/bindings/bindings.cpp b/pulse/core/mlir/bindings/bindings.cpp new file mode 100644 index 00000000000..8b3d31d6c0c --- /dev/null +++ b/pulse/core/mlir/bindings/bindings.cpp @@ -0,0 +1,1817 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// nanobind Python bindings for cudaq-pulse MLIR dialects and passes. + +#include +#include +#include +#include +#include + +#include +#include + +#include "mlir/Conversion/ArithToLLVM/ArithToLLVM.h" +#include "mlir/Conversion/FuncToLLVM/ConvertFuncToLLVMPass.h" +#include "mlir/Conversion/ReconcileUnrealizedCasts/ReconcileUnrealizedCasts.h" +#include "mlir/Dialect/Arith/IR/Arith.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/Dialect/LLVMIR/LLVMDialect.h" +#include "mlir/Dialect/SCF/IR/SCF.h" +#include "mlir/IR/Builders.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/IR/DialectRegistry.h" +#include "mlir/IR/MLIRContext.h" +#include "mlir/Parser/Parser.h" +#include "mlir/Pass/Pass.h" +#include "mlir/Pass/PassManager.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/Transforms/Passes.h" + +#include "llvm/IR/LLVMContext.h" +#include "llvm/IR/Module.h" + +#include "cudaq-pulse/Dialect/Pulse/PulseDialect.h.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseTypes.h.inc" + +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseOps.h.inc" + +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.h.inc" +#include "cudaq-pulse/Dialect/QOp/QOpDialect.h.inc" + +namespace nb = nanobind; + +// Conversion passes +namespace pulse { +std::unique_ptr createPulseToQOpPass(); +} +namespace qop { +std::unique_ptr createQOpToCuDensityMatPass(); +} +namespace cudm { +std::unique_ptr createCuDensityMatToLLVMPass(); +} +// Pulse Transforms passes +namespace pulse { +std::unique_ptr createPulseVerifyPass(); +std::unique_ptr createPulseCanonicalizePass(); +std::unique_ptr createVirtualZPass(); +std::unique_ptr createPulseFusionPass(); +std::unique_ptr createPulseScheduleAsapPass(); +std::unique_ptr createPulseScheduleAlapPass(); +std::unique_ptr createPulseScheduleRcpPass(int64_t maxDrives, + int64_t maxReadouts, + int64_t readoutLatency, + int64_t switchPenalty, + bool isAlap); +} // namespace pulse + +namespace { + +mlir::DialectRegistry makeRegistry() { + mlir::DialectRegistry reg; + reg.insert(); + reg.insert(); + reg.insert(); + reg.insert(); + reg.insert(); + reg.insert(); + reg.insert(); + return reg; +} + +// Packed-buffer OpCode enum — must match Python packed_emit.py constants +constexpr int kOpAllocDrive = 0; +constexpr int kOpAllocReadout = 1; +constexpr int kOpAllocTone = 2; +constexpr int kOpWfGaussian = 3; +constexpr int kOpWfSquare = 4; +constexpr int kOpWfDrag = 5; +constexpr int kOpWfCosine = 6; +constexpr int kOpWfTanhRamp = 7; +constexpr int kOpWfGaussSquare = 8; +constexpr int kOpWfCustom = 9; +constexpr int kOpDrive = 10; +constexpr int kOpReadout = 11; +constexpr int kOpSync = 12; +constexpr int kOpWait = 13; +constexpr int kOpShiftPhase = 14; +constexpr int kOpSetPhase = 15; +constexpr int kOpShiftFreq = 16; +constexpr int kOpSetFreq = 17; +constexpr int kOpParam = 18; +constexpr int kOpNumConst = 19; +constexpr int kOpNumBinary = 20; +constexpr int kOpNumNeg = 21; +constexpr int kOpNumCast = 22; +constexpr int kOpWfCustomSamples = 23; +constexpr int kOpWfAdd = 24; +constexpr int kOpWfSub = 25; +constexpr int kOpWfMul = 26; +constexpr int kOpWfScale = 27; +constexpr int kOpWfNeg = 28; + +inline double i2f(int64_t bits) { + double d; + std::memcpy(&d, &bits, sizeof(double)); + return d; +} + +// ----------------------------------------------------------------------- +// PulseModule: opaque handle around an in-memory mlir::ModuleOp +// ----------------------------------------------------------------------- +// TODO(pulse): this wrapper owns the MLIRContext + ModuleOp and maps pass +// names through the if/else chain in run_passes() below. A cleaner design +// (tracked as a follow-up) is to expose MLIR's own MlirModule/MlirPassManager +// and register the pulse passes so PassManager pipeline strings parse +// directly, removing this bespoke wrapper. Deferred here because it threads +// through the Python frontend (compile.py/jit.py/evolve.py) and warrants its +// own change + validation pass. +class PulseModule { +public: + PulseModule(std::shared_ptr ctx, + mlir::OwningOpRef mod) + : ctx_(std::move(ctx)), module_(std::move(mod)) {} + + std::string print() { + std::string result; + llvm::raw_string_ostream os(result); + module_->print(os); + return result; + } + + std::string run_passes(const std::vector &pipeline) { + mlir::PassManager pm(ctx_.get()); + for (auto &name : pipeline) { + if (name == "pulse-to-qop") + pm.addPass(pulse::createPulseToQOpPass()); + else if (name == "qop-to-cudm") + pm.addPass(qop::createQOpToCuDensityMatPass()); + else if (name == "cudm-to-llvm") + pm.addPass(cudm::createCuDensityMatToLLVMPass()); + else if (name == "pulse-verify") + pm.addPass(pulse::createPulseVerifyPass()); + else if (name == "pulse-canonicalize") + pm.addNestedPass( + pulse::createPulseCanonicalizePass()); + else if (name == "pulse-virtual-z") + pm.addNestedPass(pulse::createVirtualZPass()); + else if (name == "pulse-fusion") + pm.addNestedPass(pulse::createPulseFusionPass()); + else if (name == "pulse-schedule-asap") + pm.addNestedPass( + pulse::createPulseScheduleAsapPass()); + else if (name == "pulse-schedule-alap") + pm.addNestedPass( + pulse::createPulseScheduleAlapPass()); + else if (name == "loop-invariant-code-motion") + pm.addPass(mlir::createLoopInvariantCodeMotionPass()); + else + throw std::runtime_error("Unknown pass: " + name); + } + if (mlir::failed(pm.run(*module_))) + throw std::runtime_error("MLIR pass pipeline failed"); + return print(); + } + + std::string run_full_lowering() { + // Lower a clone so inspection does not consume the scheduled Pulse module; + // callers may lower repeatedly and still execute the original artifact. + auto lowered = mlir::OwningOpRef(module_->clone()); + mlir::PassManager pm(ctx_.get()); + pm.addPass(pulse::createPulseToQOpPass()); + pm.addPass(qop::createQOpToCuDensityMatPass()); + pm.addPass(cudm::createCuDensityMatToLLVMPass()); + pm.addPass(mlir::createCanonicalizerPass()); + pm.addPass(mlir::createArithToLLVMConversionPass()); + pm.addPass(mlir::createConvertFuncToLLVMPass()); + pm.addPass(mlir::createReconcileUnrealizedCastsPass()); + if (mlir::failed(pm.run(*lowered))) + throw std::runtime_error("MLIR full lowering pipeline failed"); + std::string result; + llvm::raw_string_ostream os(result); + lowered->print(os); + return result; + } + + std::string schedule(const std::string &policy, int64_t maxDrives = 4, + int64_t maxReadouts = 2, int64_t readoutLatency = 0, + int64_t switchPenalty = 0) { + mlir::PassManager pm(ctx_.get()); + if (policy == "asap") + pm.addNestedPass( + pulse::createPulseScheduleAsapPass()); + else if (policy == "alap") + pm.addNestedPass( + pulse::createPulseScheduleAlapPass()); + else if (policy == "rcp" || policy == "alap_rcp") + pm.addNestedPass(pulse::createPulseScheduleRcpPass( + maxDrives, maxReadouts, readoutLatency, switchPenalty, + policy == "alap_rcp")); + else + throw std::invalid_argument("unknown schedule policy: " + policy); + if (mlir::failed(pm.run(*module_))) + throw std::runtime_error("pulse scheduling failed"); + return print(); + } + + bool is_parametric() { + auto funcOp = getFuncOp(); + return funcOp && funcOp.getNumArguments() > 0; + } + + std::vector param_names() { + std::vector names; + if (auto attr = module_->getOperation()->getAttrOfType( + "pulse.param_names")) { + for (auto a : attr) + names.push_back(mlir::cast(a).getValue().str()); + } + return names; + } + + PulseModule specialize(const std::vector &f64_args, + const std::vector &i64_args, + const std::string &schedule = "alap", + int64_t maxDrives = 4, int64_t maxReadouts = 2, + int64_t readoutLatency = 0, + int64_t switchPenalty = 0) { + auto clonedModule = module_->clone(); + auto funcOp = clonedModule.lookupSymbol("main"); + if (!funcOp) + throw std::runtime_error("specialize: no 'main' func in module"); + + auto &entryBlock = funcOp.getBody().front(); + auto loc = funcOp.getLoc(); + mlir::OpBuilder builder(ctx_.get()); + builder.setInsertionPointToStart(&entryBlock); + + size_t f64Idx = 0, i64Idx = 0; + auto f64Ty = builder.getF64Type(); + auto i64Ty = builder.getIntegerType(64); + + for (unsigned i = 0; i < entryBlock.getNumArguments(); ++i) { + auto arg = entryBlock.getArgument(i); + mlir::Value replacement; + if (arg.getType().isInteger(64)) { + if (i64Idx >= i64_args.size()) + throw std::runtime_error("specialize: not enough i64 arguments"); + replacement = mlir::arith::ConstantIntOp::create( + builder, loc, i64_args[i64Idx++], 64); + } else { + if (f64Idx >= f64_args.size()) + throw std::runtime_error("specialize: not enough f64 arguments"); + replacement = mlir::arith::ConstantFloatOp::create( + builder, loc, f64Ty, llvm::APFloat(f64_args[f64Idx++])); + } + arg.replaceAllUsesWith(replacement); + } + + // Remove block arguments (make func take no args) + while (entryBlock.getNumArguments() > 0) + entryBlock.eraseArgument(entryBlock.getNumArguments() - 1); + funcOp.setType(builder.getFunctionType({}, {})); + + // Fold parameter expressions before deriving concrete durations. + mlir::PassManager pm(ctx_.get()); + pm.addPass(mlir::createCanonicalizerPass()); + if (schedule == "asap") + pm.addNestedPass( + pulse::createPulseScheduleAsapPass()); + else if (schedule == "alap") + pm.addNestedPass( + pulse::createPulseScheduleAlapPass()); + else if (schedule == "rcp" || schedule == "alap_rcp") + pm.addNestedPass(pulse::createPulseScheduleRcpPass( + maxDrives, maxReadouts, readoutLatency, switchPenalty, + schedule == "alap_rcp")); + else + throw std::invalid_argument("unsupported specialization schedule: " + + schedule); + if (mlir::failed(pm.run(clonedModule))) + throw std::runtime_error("specialize: pass pipeline failed"); + + auto owning = mlir::OwningOpRef(clonedModule); + return PulseModule(ctx_, std::move(owning)); + } + +private: + mlir::func::FuncOp getFuncOp() { + return module_->lookupSymbol("main"); + } + + std::shared_ptr ctx_; + mlir::OwningOpRef module_; +}; + +// ----------------------------------------------------------------------- +// PulseModuleBuilder: construct in-memory Pulse IR from Python dicts +// Also supports streaming/stateful API for Phase D direct emission. +// ----------------------------------------------------------------------- +class PulseModuleBuilder { +public: + PulseModuleBuilder() { + auto reg = makeRegistry(); + ctx_ = std::make_shared(); + ctx_->appendDialectRegistry(reg); + ctx_->loadAllAvailableDialects(); + builder_ = std::make_unique(ctx_.get()); + } + + // ---- Streaming API (Phase D) ---- + + void begin_module(const std::string &name) { + handleTable_.clear(); + nextHandle_ = 0; + nextStreamingQubit_ = 0; + auto loc = builder_->getUnknownLoc(); + module_ = mlir::ModuleOp::create(loc, mlir::StringRef(name)); + auto funcType = builder_->getFunctionType({}, {}); + funcOp_ = mlir::func::FuncOp::create(loc, "main", funcType); + module_.push_back(funcOp_); + auto *entryBlock = funcOp_.addEntryBlock(); + builder_->setInsertionPointToStart(entryBlock); + } + + int64_t make_qudit() { + auto loc = builder_->getUnknownLoc(); + auto qrefTy = pulse::QrefType::get(ctx_.get()); + auto op = pulse::QuditAllocOp::create(*builder_, loc, qrefTy); + op->setAttr("qubit", builder_->getI64IntegerAttr(nextStreamingQubit_++)); + return storeVal(op.getResult()); + } + + std::pair get_drive_line(int64_t quditHandle) { + auto loc = builder_->getUnknownLoc(); + auto driveTy = pulse::DriveLineType::get(ctx_.get()); + auto toneTy = pulse::ToneType::get(ctx_.get()); + auto qudit = loadVal(quditHandle); + auto op = + pulse::GetDriveLineOp::create(*builder_, loc, driveTy, toneTy, qudit); + if (auto *def = qudit.getDefiningOp()) + if (auto qubit = def->getAttr("qubit")) + op->setAttr("qubit", qubit); + return {storeVal(op.getLine()), storeVal(op.getTone())}; + } + + std::pair get_readout_line(int64_t quditHandle) { + auto loc = builder_->getUnknownLoc(); + auto readoutTy = pulse::ReadoutLineType::get(ctx_.get()); + auto toneTy = pulse::ToneType::get(ctx_.get()); + auto qudit = loadVal(quditHandle); + auto op = pulse::GetReadoutLineOp::create(*builder_, loc, readoutTy, toneTy, + qudit); + if (auto *def = qudit.getDefiningOp()) + if (auto qubit = def->getAttr("qubit")) + op->setAttr("qubit", qubit); + return {storeVal(op.getLine()), storeVal(op.getTone())}; + } + + int64_t emit_gaussian(int64_t duration, double amplitude, double sigma) { + auto loc = builder_->getUnknownLoc(); + auto wfTy = pulse::WaveformType::get(ctx_.get()); + auto f64Ty = builder_->getF64Type(); + auto i64Ty = builder_->getIntegerType(64); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, duration, 64); + auto ampC = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(amplitude)); + auto sigC = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(sigma)); + auto op = + pulse::GaussianPulseOp::create(*builder_, loc, wfTy, durC.getResult(), + ampC.getResult(), sigC.getResult()); + return storeVal(op.getResult()); + } + + int64_t emit_square(int64_t duration, double ampReal, double ampImag) { + auto loc = builder_->getUnknownLoc(); + auto wfTy = pulse::WaveformType::get(ctx_.get()); + auto f64Ty = builder_->getF64Type(); + auto i64Ty = builder_->getIntegerType(64); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, duration, 64); + auto arC = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(ampReal)); + auto aiC = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(ampImag)); + auto op = + pulse::SquarePulseOp::create(*builder_, loc, wfTy, durC.getResult(), + arC.getResult(), aiC.getResult()); + return storeVal(op.getResult()); + } + + int64_t emit_drag(int64_t duration, double amplitude, double sigma, + double beta) { + auto loc = builder_->getUnknownLoc(); + auto wfTy = pulse::WaveformType::get(ctx_.get()); + auto f64Ty = builder_->getF64Type(); + auto i64Ty = builder_->getIntegerType(64); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, duration, 64); + auto ampC = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(amplitude)); + auto sigC = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(sigma)); + auto betaC = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(beta)); + auto op = pulse::DRAGPulseOp::create(*builder_, loc, wfTy, durC.getResult(), + ampC.getResult(), sigC.getResult(), + betaC.getResult()); + return storeVal(op.getResult()); + } + + int64_t emit_cosine(int64_t duration, double amplitude) { + auto loc = builder_->getUnknownLoc(); + auto wfTy = pulse::WaveformType::get(ctx_.get()); + auto f64Ty = builder_->getF64Type(); + auto i64Ty = builder_->getIntegerType(64); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, duration, 64); + auto ampC = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(amplitude)); + auto op = pulse::CosinePulseOp::create(*builder_, loc, wfTy, + durC.getResult(), ampC.getResult()); + return storeVal(op.getResult()); + } + + int64_t emit_tanh_ramp(int64_t duration, double amplitude, double sigma) { + auto loc = builder_->getUnknownLoc(); + auto wfTy = pulse::WaveformType::get(ctx_.get()); + auto f64Ty = builder_->getF64Type(); + auto i64Ty = builder_->getIntegerType(64); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, duration, 64); + auto ampC = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(amplitude)); + auto sigC = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(sigma)); + auto op = pulse::TanhRampOp::create(*builder_, loc, wfTy, durC.getResult(), + ampC.getResult(), sigC.getResult()); + return storeVal(op.getResult()); + } + + int64_t emit_gaussian_square(int64_t duration, double amplitude, double sigma, + int64_t risefall) { + auto loc = builder_->getUnknownLoc(); + auto wfTy = pulse::WaveformType::get(ctx_.get()); + auto f64Ty = builder_->getF64Type(); + auto i64Ty = builder_->getIntegerType(64); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, duration, 64); + auto ampC = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(amplitude)); + auto sigC = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(sigma)); + auto rfC = mlir::arith::ConstantIntOp::create(*builder_, loc, risefall, 64); + auto op = pulse::GaussianSquarePulseOp::create( + *builder_, loc, wfTy, durC.getResult(), ampC.getResult(), + sigC.getResult(), rfC.getResult()); + return storeVal(op.getResult()); + } + + std::pair emit_drive(int64_t lineH, int64_t wfH, + int64_t toneH) { + auto loc = builder_->getUnknownLoc(); + auto driveTy = pulse::DriveLineType::get(ctx_.get()); + auto toneTy = pulse::ToneType::get(ctx_.get()); + auto op = + pulse::DriveOp::create(*builder_, loc, driveTy, toneTy, loadVal(lineH), + loadVal(wfH), loadVal(toneH)); + return {storeVal(op.getUpdatedLine()), storeVal(op.getUpdatedTone())}; + } + + std::tuple emit_readout(int64_t lineH, int64_t wfH, + int64_t toneH, + const std::string &mode) { + auto loc = builder_->getUnknownLoc(); + auto readoutTy = pulse::ReadoutLineType::get(ctx_.get()); + auto toneTy = pulse::ToneType::get(ctx_.get()); + auto measTy = pulse::MeasurementType::get(ctx_.get()); + auto op = pulse::ReadoutOp::create( + *builder_, loc, readoutTy, toneTy, measTy, loadVal(lineH), loadVal(wfH), + loadVal(toneH), builder_->getStringAttr(mode)); + return {storeVal(op.getUpdatedLine()), storeVal(op.getUpdatedTone()), + storeVal(op.getResult())}; + } + + int64_t emit_wait(int64_t lineH, int64_t duration) { + auto loc = builder_->getUnknownLoc(); + auto lineVal = loadVal(lineH); + auto durTy = pulse::DurationType::get(ctx_.get()); + auto durConst = + mlir::arith::ConstantIntOp::create(*builder_, loc, duration, 64); + auto durOp = pulse::DurationFromIntOp::create(*builder_, loc, durTy, + durConst.getResult()); + auto waitOp = pulse::WaitOp::create(*builder_, loc, lineVal.getType(), + lineVal, durOp.getResult()); + return storeVal(waitOp.getResult()); + } + + std::vector emit_sync(const std::vector &lineHandles) { + auto loc = builder_->getUnknownLoc(); + llvm::SmallVector inVals; + llvm::SmallVector outTypes; + for (auto h : lineHandles) { + auto v = loadVal(h); + inVals.push_back(v); + outTypes.push_back(v.getType()); + } + auto syncOp = pulse::SyncOp::create(*builder_, loc, outTypes, inVals); + std::vector results; + for (unsigned i = 0; i < syncOp.getNumResults(); ++i) + results.push_back(storeVal(syncOp.getResult(i))); + return results; + } + + int64_t emit_shift_phase(int64_t toneH, double delta) { + auto loc = builder_->getUnknownLoc(); + auto toneTy = pulse::ToneType::get(ctx_.get()); + auto f64Ty = builder_->getF64Type(); + auto phConst = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(delta)); + auto op = pulse::ShiftPhaseOp::create(*builder_, loc, toneTy, + loadVal(toneH), phConst.getResult()); + return storeVal(op.getResult()); + } + + int64_t emit_set_phase(int64_t toneH, double phase) { + auto loc = builder_->getUnknownLoc(); + auto toneTy = pulse::ToneType::get(ctx_.get()); + auto f64Ty = builder_->getF64Type(); + auto phConst = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(phase)); + auto op = pulse::SetPhaseOp::create(*builder_, loc, toneTy, loadVal(toneH), + phConst.getResult()); + return storeVal(op.getResult()); + } + + int64_t emit_shift_frequency(int64_t toneH, double freqHz) { + auto loc = builder_->getUnknownLoc(); + auto toneTy = pulse::ToneType::get(ctx_.get()); + auto f64Ty = builder_->getF64Type(); + auto fConst = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(freqHz)); + auto op = pulse::ShiftFrequencyOp::create( + *builder_, loc, toneTy, loadVal(toneH), fConst.getResult()); + return storeVal(op.getResult()); + } + + int64_t emit_set_frequency(int64_t toneH, double freqHz) { + auto loc = builder_->getUnknownLoc(); + auto toneTy = pulse::ToneType::get(ctx_.get()); + auto f64Ty = builder_->getF64Type(); + auto fConst = mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(freqHz)); + auto op = pulse::SetFrequencyOp::create(*builder_, loc, toneTy, + loadVal(toneH), fConst.getResult()); + return storeVal(op.getResult()); + } + + PulseModule finish_module() { + auto loc = builder_->getUnknownLoc(); + mlir::func::ReturnOp::create(*builder_, loc); + auto owningModule = mlir::OwningOpRef(module_); + return PulseModule(ctx_, std::move(owningModule)); + } + + // ---- Batch API (Phase B) ---- + + PulseModule build_from_program(nb::dict prog_dict) { + auto loc = builder_->getUnknownLoc(); + + std::string name = "main"; + if (prog_dict.contains("name")) + name = nb::cast(prog_dict["name"]); + + auto module = mlir::ModuleOp::create(loc, mlir::StringRef(name)); + + double clock_ghz = 2.0; + if (prog_dict.contains("clock_ghz")) + clock_ghz = nb::cast(prog_dict["clock_ghz"]); + + // Module-level attributes + if (prog_dict.contains("qubit_freq_hz")) { + nb::dict freq = nb::cast(prog_dict["qubit_freq_hz"]); + int64_t n_qubits = nb::len(freq); + module->setAttr("pulse.n_qubits", builder_->getI64IntegerAttr(n_qubits)); + module->setAttr("pulse.clock_ghz", builder_->getF64FloatAttr(clock_ghz)); + } + + auto funcType = builder_->getFunctionType({}, {}); + auto funcOp = mlir::func::FuncOp::create(loc, "main", funcType); + module.push_back(funcOp); + + auto *entryBlock = funcOp.addEntryBlock(); + builder_->setInsertionPointToStart(entryBlock); + + // Type lookups + auto qrefTy = pulse::QrefType::get(ctx_.get()); + auto driveTy = pulse::DriveLineType::get(ctx_.get()); + auto readoutTy = pulse::ReadoutLineType::get(ctx_.get()); + auto toneTy = pulse::ToneType::get(ctx_.get()); + auto wfTy = pulse::WaveformType::get(ctx_.get()); + auto durTy = pulse::DurationType::get(ctx_.get()); + auto measTy = pulse::MeasurementType::get(ctx_.get()); + + // SSA value table: vid -> mlir::Value + llvm::DenseMap ssaTable; + + // Track qubits + llvm::DenseMap qubitSSA; + + auto bindResult = [&](int64_t vid, mlir::Value val) { + ssaTable[vid] = val; + }; + auto lookupVal = [&](int64_t vid) -> mlir::Value { + auto it = ssaTable.find(vid); + if (it == ssaTable.end()) + throw std::runtime_error("SSA value not found for vid=" + + std::to_string(vid)); + return it->second; + }; + + // Process ops + nb::list ops = nb::cast(prog_dict["ops"]); + for (size_t i = 0; i < nb::len(ops); ++i) { + nb::dict opDict = nb::cast(ops[i]); + std::string kind = nb::cast(opDict["kind"]); + nb::dict attrs = nb::cast(opDict["attrs"]); + nb::list results = nb::cast(opDict["results"]); + nb::list operands = nb::cast(opDict["operands"]); + + if (kind == "alloc_drive_line" || kind == "alloc_drive") { + int64_t qubit = nb::cast(attrs["qubit"]); + mlir::Value qref; + auto qit = qubitSSA.find(qubit); + if (qit == qubitSSA.end()) { + auto alloc = pulse::QuditAllocOp::create(*builder_, loc, qrefTy); + alloc->setAttr("qubit", builder_->getI64IntegerAttr(qubit)); + qref = alloc.getResult(); + qubitSSA[qubit] = qref; + } else { + qref = qit->second; + } + auto gdl = pulse::GetDriveLineOp::create(*builder_, loc, driveTy, + toneTy, qref); + gdl->setAttr("qubit", builder_->getI64IntegerAttr(qubit)); + if (attrs.contains("frequency_hz")) + gdl->setAttr("frequency_hz", + builder_->getF64FloatAttr( + nb::cast(attrs["frequency_hz"]))); + int64_t lineVid = + nb::cast(nb::cast(results[0])["vid"]); + int64_t toneVid = + nb::cast(nb::cast(results[1])["vid"]); + bindResult(lineVid, gdl.getLine()); + bindResult(toneVid, gdl.getTone()); + + } else if (kind == "alloc_readout_line" || kind == "alloc_readout") { + int64_t qubit = nb::cast(attrs["qubit"]); + mlir::Value qref; + auto qit = qubitSSA.find(qubit); + if (qit == qubitSSA.end()) { + auto alloc = pulse::QuditAllocOp::create(*builder_, loc, qrefTy); + alloc->setAttr("qubit", builder_->getI64IntegerAttr(qubit)); + qref = alloc.getResult(); + qubitSSA[qubit] = qref; + } else { + qref = qit->second; + } + auto grl = pulse::GetReadoutLineOp::create(*builder_, loc, readoutTy, + toneTy, qref); + grl->setAttr("qubit", builder_->getI64IntegerAttr(qubit)); + if (attrs.contains("frequency_hz")) + grl->setAttr("frequency_hz", + builder_->getF64FloatAttr( + nb::cast(attrs["frequency_hz"]))); + int64_t lineVid = + nb::cast(nb::cast(results[0])["vid"]); + int64_t toneVid = + nb::cast(nb::cast(results[1])["vid"]); + bindResult(lineVid, grl.getLine()); + bindResult(toneVid, grl.getTone()); + + } else if (kind == "alloc_tone") { + double freq = 0.0; + if (attrs.contains("frequency_hz")) + freq = nb::cast(attrs["frequency_hz"]); + double phase = 0.0; + if (attrs.contains("phase_rad")) + phase = nb::cast(attrs["phase_rad"]); + auto freqConst = mlir::arith::ConstantFloatOp::create( + *builder_, loc, builder_->getF64Type(), llvm::APFloat(freq)); + auto phaseConst = mlir::arith::ConstantFloatOp::create( + *builder_, loc, builder_->getF64Type(), llvm::APFloat(phase)); + auto toneOp = + pulse::ToneOp::create(*builder_, loc, toneTy, freqConst.getResult(), + phaseConst.getResult()); + int64_t resVid = + nb::cast(nb::cast(results[0])["vid"]); + bindResult(resVid, toneOp.getResult()); + + } else if (kind == "make_waveform") { + std::string wfType = nb::cast(attrs["waveform_type"]); + int64_t resVid = + nb::cast(nb::cast(results[0])["vid"]); + + auto f64Ty = builder_->getF64Type(); + auto i64Ty = builder_->getIntegerType(64); + + if (wfType == "gaussian") { + int64_t dur = nb::cast(attrs["duration_vtu"]); + double amp = extractReal(attrs["amplitude"]); + double sigma = nb::cast(attrs["sigma"]); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, dur, 64); + auto ampC = mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(amp)); + auto sigC = mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(sigma)); + auto op = pulse::GaussianPulseOp::create( + *builder_, loc, wfTy, durC.getResult(), ampC.getResult(), + sigC.getResult()); + bindResult(resVid, op.getResult()); + + } else if (wfType == "square") { + int64_t dur = nb::cast(attrs["duration_vtu"]); + auto ampPair = extractComplexPair(attrs["amplitude"]); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, dur, 64); + auto arC = mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(ampPair.first)); + auto aiC = mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(ampPair.second)); + auto op = pulse::SquarePulseOp::create( + *builder_, loc, wfTy, durC.getResult(), arC.getResult(), + aiC.getResult()); + bindResult(resVid, op.getResult()); + + } else if (wfType == "drag") { + int64_t dur = nb::cast(attrs["duration_vtu"]); + double amp = extractReal(attrs["amplitude"]); + double sigma = nb::cast(attrs["sigma"]); + double beta = nb::cast(attrs["beta"]); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, dur, 64); + auto ampC = mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(amp)); + auto sigC = mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(sigma)); + auto betaC = mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(beta)); + auto op = pulse::DRAGPulseOp::create( + *builder_, loc, wfTy, durC.getResult(), ampC.getResult(), + sigC.getResult(), betaC.getResult()); + bindResult(resVid, op.getResult()); + + } else if (wfType == "cosine") { + int64_t dur = nb::cast(attrs["duration_vtu"]); + double amp = extractReal(attrs["amplitude"]); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, dur, 64); + auto ampC = mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(amp)); + auto op = pulse::CosinePulseOp::create( + *builder_, loc, wfTy, durC.getResult(), ampC.getResult()); + bindResult(resVid, op.getResult()); + + } else if (wfType == "tanh_ramp") { + int64_t dur = nb::cast(attrs["duration_vtu"]); + double amp = extractReal(attrs["amplitude"]); + double sigma = nb::cast(attrs["sigma"]); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, dur, 64); + auto ampC = mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(amp)); + auto sigC = mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(sigma)); + auto op = + pulse::TanhRampOp::create(*builder_, loc, wfTy, durC.getResult(), + ampC.getResult(), sigC.getResult()); + bindResult(resVid, op.getResult()); + + } else if (wfType == "gaussian_square") { + int64_t dur = nb::cast(attrs["duration_vtu"]); + double amp = extractReal(attrs["amplitude"]); + double sigma = nb::cast(attrs["sigma"]); + int64_t risefall = nb::cast(attrs["risefall"]); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, dur, 64); + auto ampC = mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(amp)); + auto sigC = mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(sigma)); + auto rfC = + mlir::arith::ConstantIntOp::create(*builder_, loc, risefall, 64); + auto op = pulse::GaussianSquarePulseOp::create( + *builder_, loc, wfTy, durC.getResult(), ampC.getResult(), + sigC.getResult(), rfC.getResult()); + bindResult(resVid, op.getResult()); + + } else { + int64_t dur = nb::cast(attrs["duration_vtu"]); + auto callee = mlir::FlatSymbolRefAttr::get(ctx_.get(), wfType); + auto durC = + mlir::arith::ConstantIntOp::create(*builder_, loc, dur, 64); + auto op = pulse::CustomOp::create(*builder_, loc, wfTy, callee, + durC.getResult()); + bindResult(resVid, op.getResult()); + } + + } else if (kind == "drive") { + int64_t lineVid = + nb::cast(nb::cast(operands[0])["vid"]); + int64_t wfVid = + nb::cast(nb::cast(operands[1])["vid"]); + int64_t toneVid = + nb::cast(nb::cast(operands[2])["vid"]); + auto driveOp = pulse::DriveOp::create( + *builder_, loc, driveTy, toneTy, lookupVal(lineVid), + lookupVal(wfVid), lookupVal(toneVid)); + // Scheduling attrs + if (attrs.contains("start_vtu")) + driveOp->setAttr("start_vtu", + builder_->getI64IntegerAttr( + nb::cast(attrs["start_vtu"]))); + if (attrs.contains("duration_vtu")) + driveOp->setAttr("duration_vtu", + builder_->getI64IntegerAttr( + nb::cast(attrs["duration_vtu"]))); + int64_t rLineVid = + nb::cast(nb::cast(results[0])["vid"]); + int64_t rToneVid = + nb::cast(nb::cast(results[1])["vid"]); + bindResult(rLineVid, driveOp.getUpdatedLine()); + bindResult(rToneVid, driveOp.getUpdatedTone()); + + } else if (kind == "readout") { + int64_t lineVid = + nb::cast(nb::cast(operands[0])["vid"]); + int64_t wfVid = + nb::cast(nb::cast(operands[1])["vid"]); + int64_t toneVid = + nb::cast(nb::cast(operands[2])["vid"]); + std::string mode = "iq"; + if (attrs.contains("mode")) + mode = nb::cast(attrs["mode"]); + auto roOp = pulse::ReadoutOp::create( + *builder_, loc, readoutTy, toneTy, measTy, lookupVal(lineVid), + lookupVal(wfVid), lookupVal(toneVid), + builder_->getStringAttr(mode)); + int64_t rLineVid = + nb::cast(nb::cast(results[0])["vid"]); + int64_t rToneVid = + nb::cast(nb::cast(results[1])["vid"]); + int64_t rMeasVid = + nb::cast(nb::cast(results[2])["vid"]); + bindResult(rLineVid, roOp.getUpdatedLine()); + bindResult(rToneVid, roOp.getUpdatedTone()); + bindResult(rMeasVid, roOp.getResult()); + + } else if (kind == "wait") { + int64_t lineVid = + nb::cast(nb::cast(operands[0])["vid"]); + int64_t durVtu = 0; + if (attrs.contains("duration_vtu")) + durVtu = nb::cast(attrs["duration_vtu"]); + mlir::Value lineVal = lookupVal(lineVid); + auto lineType = lineVal.getType(); + auto durConst = + mlir::arith::ConstantIntOp::create(*builder_, loc, durVtu, 64); + auto durOp = pulse::DurationFromIntOp::create(*builder_, loc, durTy, + durConst.getResult()); + auto waitOp = pulse::WaitOp::create(*builder_, loc, lineType, lineVal, + durOp.getResult()); + int64_t rLineVid = + nb::cast(nb::cast(results[0])["vid"]); + bindResult(rLineVid, waitOp.getResult()); + + } else if (kind == "sync") { + llvm::SmallVector inVals; + llvm::SmallVector outTypes; + for (size_t j = 0; j < nb::len(operands); ++j) { + int64_t vid = + nb::cast(nb::cast(operands[j])["vid"]); + inVals.push_back(lookupVal(vid)); + } + for (size_t j = 0; j < nb::len(results); ++j) { + nb::dict rd = nb::cast(results[j]); + std::string vtype = nb::cast(rd["vtype"]); + if (vtype == "drive_line") + outTypes.push_back(driveTy); + else if (vtype == "readout_line") + outTypes.push_back(readoutTy); + else + outTypes.push_back(driveTy); + } + auto syncOp = pulse::SyncOp::create(*builder_, loc, outTypes, inVals); + for (size_t j = 0; j < nb::len(results); ++j) { + int64_t vid = + nb::cast(nb::cast(results[j])["vid"]); + bindResult(vid, syncOp.getResult(j)); + } + + } else if (kind == "shift_phase") { + int64_t toneVid = + nb::cast(nb::cast(operands[0])["vid"]); + double delta = 0.0; + if (attrs.contains("delta_rad")) + delta = nb::cast(attrs["delta_rad"]); + else if (attrs.contains("delta")) + delta = nb::cast(attrs["delta"]); + auto phConst = mlir::arith::ConstantFloatOp::create( + *builder_, loc, builder_->getF64Type(), llvm::APFloat(delta)); + auto spOp = pulse::ShiftPhaseOp::create( + *builder_, loc, toneTy, lookupVal(toneVid), phConst.getResult()); + if (nb::len(results) > 0) { + int64_t rVid = + nb::cast(nb::cast(results[0])["vid"]); + bindResult(rVid, spOp.getResult()); + } + + } else if (kind == "set_phase") { + int64_t toneVid = + nb::cast(nb::cast(operands[0])["vid"]); + double phase = 0.0; + if (attrs.contains("phase_rad")) + phase = nb::cast(attrs["phase_rad"]); + auto phConst = mlir::arith::ConstantFloatOp::create( + *builder_, loc, builder_->getF64Type(), llvm::APFloat(phase)); + auto spOp = pulse::SetPhaseOp::create( + *builder_, loc, toneTy, lookupVal(toneVid), phConst.getResult()); + if (nb::len(results) > 0) { + int64_t rVid = + nb::cast(nb::cast(results[0])["vid"]); + bindResult(rVid, spOp.getResult()); + } + + } else if (kind == "shift_frequency") { + int64_t toneVid = + nb::cast(nb::cast(operands[0])["vid"]); + double freq = 0.0; + if (attrs.contains("delta_hz")) + freq = nb::cast(attrs["delta_hz"]); + else if (attrs.contains("freq_hz")) + freq = nb::cast(attrs["freq_hz"]); + auto fConst = mlir::arith::ConstantFloatOp::create( + *builder_, loc, builder_->getF64Type(), llvm::APFloat(freq)); + auto sfOp = pulse::ShiftFrequencyOp::create( + *builder_, loc, toneTy, lookupVal(toneVid), fConst.getResult()); + if (nb::len(results) > 0) { + int64_t rVid = + nb::cast(nb::cast(results[0])["vid"]); + bindResult(rVid, sfOp.getResult()); + } + + } else if (kind == "set_frequency") { + int64_t toneVid = + nb::cast(nb::cast(operands[0])["vid"]); + double freq = 0.0; + if (attrs.contains("freq_hz")) + freq = nb::cast(attrs["freq_hz"]); + auto fConst = mlir::arith::ConstantFloatOp::create( + *builder_, loc, builder_->getF64Type(), llvm::APFloat(freq)); + auto sfOp = pulse::SetFrequencyOp::create( + *builder_, loc, toneTy, lookupVal(toneVid), fConst.getResult()); + if (nb::len(results) > 0) { + int64_t rVid = + nb::cast(nb::cast(results[0])["vid"]); + bindResult(rVid, sfOp.getResult()); + } + + } else if (kind == "for_loop" || kind == "end_for") { + // Loops are handled by the scheduling pass and by Phase D's + // streaming builder. The batch builder skips them. + } + } + + // Return terminator + mlir::func::ReturnOp::create(*builder_, loc); + + auto owningModule = mlir::OwningOpRef(module); + return PulseModule(ctx_, std::move(owningModule)); + } + + // ─── Packed-buffer decoder (zero-copy path) ────────────────────────── + PulseModule + build_from_packed(nb::ndarray, nb::c_contig> stream, + double clock_ghz, int64_t n_qubits, + nb::ndarray, nb::c_contig> qubit_freqs, + std::vector param_names = {}, + std::vector param_types = {}) { + + const int64_t *d = stream.data(); + const size_t len = stream.shape(0); + auto loc = builder_->getUnknownLoc(); + + if (clock_ghz <= 0.0) + throw std::invalid_argument("packed: clock_ghz must be positive"); + if (n_qubits < 0) + throw std::invalid_argument("packed: n_qubits cannot be negative"); + if (qubit_freqs.shape(0) != static_cast(n_qubits)) + throw std::invalid_argument( + "packed: qubit frequency count does not match n_qubits"); + if (param_names.size() != param_types.size()) + throw std::invalid_argument( + "packed: parameter names and types have different lengths"); + + auto module = mlir::ModuleOp::create(loc, mlir::StringRef("main")); + module->setAttr("pulse.n_qubits", builder_->getI64IntegerAttr(n_qubits)); + module->setAttr("pulse.clock_ghz", builder_->getF64FloatAttr(clock_ghz)); + + // Encode qubit frequencies as module attribute + const double *fdata = qubit_freqs.data(); + size_t flen = qubit_freqs.shape(0); + llvm::SmallVector freqVec(fdata, fdata + flen); + module->setAttr("pulse.qubit_freq_hz", + builder_->getF64ArrayAttr(llvm::ArrayRef(freqVec))); + + // Build func.func type: parameters become block arguments + llvm::SmallVector argTypes; + auto f64Ty = builder_->getF64Type(); + auto i64Ty = builder_->getIntegerType(64); + for (auto &pt : param_types) { + if (pt == "i64") + argTypes.push_back(i64Ty); + else if (pt == "f64") + argTypes.push_back(f64Ty); + else + throw std::invalid_argument("packed: unsupported parameter type '" + + pt + "'"); + } + + auto funcType = builder_->getFunctionType(argTypes, {}); + auto funcOp = mlir::func::FuncOp::create(loc, "main", funcType); + module.push_back(funcOp); + auto *entryBlock = funcOp.addEntryBlock(); + builder_->setInsertionPointToStart(entryBlock); + + // Store parameter name metadata on the module for later use + if (!param_names.empty()) { + llvm::SmallVector nameAttrs; + for (auto &n : param_names) + nameAttrs.push_back(builder_->getStringAttr(n)); + module->setAttr("pulse.param_names", builder_->getArrayAttr(nameAttrs)); + } + + auto qrefTy = pulse::QrefType::get(ctx_.get()); + auto driveTy = pulse::DriveLineType::get(ctx_.get()); + auto readoutTy = pulse::ReadoutLineType::get(ctx_.get()); + auto toneTy = pulse::ToneType::get(ctx_.get()); + auto wfTy = pulse::WaveformType::get(ctx_.get()); + auto durTy = pulse::DurationType::get(ctx_.get()); + auto measTy = pulse::MeasurementType::get(ctx_.get()); + + llvm::DenseMap ssa; + llvm::DenseMap qubitSSA; + + auto bind = [&](int64_t vid, mlir::Value v) { ssa[vid] = v; }; + auto look = [&](int64_t vid) -> mlir::Value { + auto it = ssa.find(vid); + if (it == ssa.end()) + throw std::runtime_error("packed: SSA value not found for vid=" + + std::to_string(vid)); + return it->second; + }; + auto getQubit = [&](int64_t q) -> mlir::Value { + if (q < 0 || q >= n_qubits) + throw std::invalid_argument("packed: qubit index out of range: " + + std::to_string(q)); + auto it = qubitSSA.find(q); + if (it != qubitSSA.end()) + return it->second; + auto alloc = pulse::QuditAllocOp::create(*builder_, loc, qrefTy); + alloc->setAttr("qubit", builder_->getI64IntegerAttr(q)); + auto v = alloc.getResult(); + qubitSSA[q] = v; + return v; + }; + + size_t cur = 0; + while (cur < len) { + int64_t hdr = d[cur]; + int opcode = static_cast(hdr & 0xFF); + int plen = static_cast((hdr >> 8) & 0xFF); + int pmask = static_cast((hdr >> 16) & 0xFFFF); + if (cur + 1 + static_cast(plen) > len) + throw std::invalid_argument("packed: truncated record at word " + + std::to_string(cur)); + const int64_t *p = d + cur + 1; + + auto requirePayload = [&](int expected) { + if (plen != expected) + throw std::invalid_argument( + "packed: opcode " + std::to_string(opcode) + " expects " + + std::to_string(expected) + " payload words, got " + + std::to_string(plen)); + }; + + // Helper: get SSA value for a waveform arg slot. If the param_mask + // bit is set, the slot holds a vid (block arg ref); otherwise it's + // a literal that needs an arith.constant. + auto getI64Arg = [&](int slot) -> mlir::Value { + if (pmask & (1 << slot)) + return look(p[slot]); + return mlir::arith::ConstantIntOp::create(*builder_, loc, p[slot], 64) + .getResult(); + }; + auto getF64Arg = [&](int slot) -> mlir::Value { + if (pmask & (1 << slot)) + return look(p[slot]); + return mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(i2f(p[slot]))) + .getResult(); + }; + + switch (opcode) { + + case kOpAllocDrive: { + requirePayload(3); + auto qref = getQubit(p[0]); + auto gdl = pulse::GetDriveLineOp::create(*builder_, loc, driveTy, + toneTy, qref); + gdl->setAttr("qubit", builder_->getI64IntegerAttr(p[0])); + gdl->setAttr("frequency_hz", builder_->getF64FloatAttr(freqVec[p[0]])); + bind(p[1], gdl.getLine()); + bind(p[2], gdl.getTone()); + break; + } + + case kOpAllocReadout: { + requirePayload(3); + auto qref = getQubit(p[0]); + auto grl = pulse::GetReadoutLineOp::create(*builder_, loc, readoutTy, + toneTy, qref); + grl->setAttr("qubit", builder_->getI64IntegerAttr(p[0])); + grl->setAttr("frequency_hz", builder_->getF64FloatAttr(freqVec[p[0]])); + bind(p[1], grl.getLine()); + bind(p[2], grl.getTone()); + break; + } + + case kOpAllocTone: { + requirePayload(3); + double freq = i2f(p[1]); + double phase = i2f(p[2]); + auto fc = mlir::arith::ConstantFloatOp::create( + *builder_, loc, builder_->getF64Type(), llvm::APFloat(freq)); + auto pc = mlir::arith::ConstantFloatOp::create( + *builder_, loc, builder_->getF64Type(), llvm::APFloat(phase)); + auto t = pulse::ToneOp::create(*builder_, loc, toneTy, fc.getResult(), + pc.getResult()); + bind(p[0], t.getResult()); + break; + } + + case kOpWfGaussian: { + requirePayload(4); + auto op = pulse::GaussianPulseOp::create( + *builder_, loc, wfTy, getI64Arg(1), getF64Arg(2), getF64Arg(3)); + bind(p[0], op.getResult()); + break; + } + + case kOpWfSquare: { + requirePayload(4); + auto op = pulse::SquarePulseOp::create( + *builder_, loc, wfTy, getI64Arg(1), getF64Arg(2), getF64Arg(3)); + bind(p[0], op.getResult()); + break; + } + + case kOpWfDrag: { + requirePayload(5); + auto op = pulse::DRAGPulseOp::create(*builder_, loc, wfTy, getI64Arg(1), + getF64Arg(2), getF64Arg(3), + getF64Arg(4)); + bind(p[0], op.getResult()); + break; + } + + case kOpWfCosine: { + requirePayload(3); + auto op = pulse::CosinePulseOp::create(*builder_, loc, wfTy, + getI64Arg(1), getF64Arg(2)); + bind(p[0], op.getResult()); + break; + } + + case kOpWfTanhRamp: { + requirePayload(4); + auto op = pulse::TanhRampOp::create(*builder_, loc, wfTy, getI64Arg(1), + getF64Arg(2), getF64Arg(3)); + bind(p[0], op.getResult()); + break; + } + + case kOpWfGaussSquare: { + requirePayload(5); + auto op = pulse::GaussianSquarePulseOp::create( + *builder_, loc, wfTy, getI64Arg(1), getF64Arg(2), getF64Arg(3), + getI64Arg(4)); + bind(p[0], op.getResult()); + break; + } + + case kOpWfCustom: { + if (plen < 4) + throw std::invalid_argument( + "packed: custom waveform record is too short"); + int64_t byteCount = p[2]; + if (byteCount <= 0) + throw std::invalid_argument( + "packed: custom waveform callback name is empty"); + size_t wordCount = (static_cast(byteCount) + 7) / 8; + if (3 + wordCount != static_cast(plen)) + throw std::invalid_argument( + "packed: custom waveform callback payload is malformed"); + std::string callbackName(reinterpret_cast(p + 3), + static_cast(byteCount)); + auto callee = mlir::FlatSymbolRefAttr::get(ctx_.get(), callbackName); + auto op = + pulse::CustomOp::create(*builder_, loc, wfTy, callee, getI64Arg(1)); + bind(p[0], op.getResult()); + break; + } + + case kOpWfCustomSamples: { + if (plen < 2 || p[1] < 0 || p[1] + 2 != plen) + throw std::invalid_argument( + "packed: custom sample payload is malformed"); + llvm::SmallVector samples; + samples.reserve(static_cast(p[1])); + for (int64_t i = 0; i < p[1]; ++i) + samples.push_back(i2f(p[2 + i])); + auto op = pulse::CustomSamplesOp::create( + *builder_, loc, wfTy, + builder_->getF64ArrayAttr(llvm::ArrayRef(samples))); + bind(p[0], op.getResult()); + break; + } + + case kOpParam: { + requirePayload(2); + // Parameter reference: p[0] = vid, p[1] = param_index + // The value comes from the func.func block argument + int64_t paramIdx = p[1]; + if (paramIdx < 0 || + paramIdx >= static_cast(entryBlock->getNumArguments())) + throw std::invalid_argument("packed: parameter index out of range: " + + std::to_string(paramIdx)); + bind(p[0], entryBlock->getArgument(paramIdx)); + break; + } + + case kOpNumConst: { + requirePayload(3); + if (p[1] == 0) { + bind(p[0], + mlir::arith::ConstantIntOp::create(*builder_, loc, p[2], 64)); + } else if (p[1] == 1) { + bind(p[0], mlir::arith::ConstantFloatOp::create( + *builder_, loc, f64Ty, llvm::APFloat(i2f(p[2])))); + } else { + throw std::invalid_argument("packed: invalid numeric constant type"); + } + break; + } + + case kOpNumBinary: { + requirePayload(5); + auto lhs = look(p[3]); + auto rhs = look(p[4]); + mlir::Value result; + if (p[1] == 0) { + switch (p[2]) { + case 0: + result = mlir::arith::AddIOp::create(*builder_, loc, lhs, rhs); + break; + case 1: + result = mlir::arith::SubIOp::create(*builder_, loc, lhs, rhs); + break; + case 2: + result = mlir::arith::MulIOp::create(*builder_, loc, lhs, rhs); + break; + case 3: + result = mlir::arith::DivSIOp::create(*builder_, loc, lhs, rhs); + break; + case 4: + result = + mlir::arith::FloorDivSIOp::create(*builder_, loc, lhs, rhs); + break; + case 5: + result = mlir::arith::RemSIOp::create(*builder_, loc, lhs, rhs); + break; + default: + throw std::invalid_argument("packed: invalid integer binary op"); + } + } else if (p[1] == 1) { + switch (p[2]) { + case 0: + result = mlir::arith::AddFOp::create(*builder_, loc, lhs, rhs); + break; + case 1: + result = mlir::arith::SubFOp::create(*builder_, loc, lhs, rhs); + break; + case 2: + result = mlir::arith::MulFOp::create(*builder_, loc, lhs, rhs); + break; + case 3: + result = mlir::arith::DivFOp::create(*builder_, loc, lhs, rhs); + break; + default: + throw std::invalid_argument("packed: invalid float binary op"); + } + } else { + throw std::invalid_argument("packed: invalid binary numeric type"); + } + bind(p[0], result); + break; + } + + case kOpNumNeg: { + requirePayload(3); + auto operand = look(p[2]); + if (p[1] == 0) { + auto zero = mlir::arith::ConstantIntOp::create(*builder_, loc, 0, 64); + bind(p[0], + mlir::arith::SubIOp::create(*builder_, loc, zero, operand)); + } else if (p[1] == 1) { + bind(p[0], mlir::arith::NegFOp::create(*builder_, loc, operand)); + } else { + throw std::invalid_argument("packed: invalid negation type"); + } + break; + } + + case kOpNumCast: { + requirePayload(4); + auto operand = look(p[3]); + if (p[1] == 0 && p[2] == 1) + bind(p[0], + mlir::arith::SIToFPOp::create(*builder_, loc, f64Ty, operand)); + else if (p[1] == 1 && p[2] == 0) + bind(p[0], + mlir::arith::FPToSIOp::create(*builder_, loc, i64Ty, operand)); + else + throw std::invalid_argument("packed: invalid numeric cast"); + break; + } + + case kOpWfAdd: + case kOpWfSub: + case kOpWfMul: { + requirePayload(3); + mlir::Value result; + if (opcode == kOpWfAdd) + result = pulse::PulseAddOp::create(*builder_, loc, wfTy, look(p[1]), + look(p[2])); + else if (opcode == kOpWfSub) + result = pulse::PulseSubOp::create(*builder_, loc, wfTy, look(p[1]), + look(p[2])); + else + result = pulse::PulseMulOp::create(*builder_, loc, wfTy, look(p[1]), + look(p[2])); + bind(p[0], result); + break; + } + + case kOpWfScale: { + requirePayload(3); + auto op = pulse::PulseScaleOp::create(*builder_, loc, wfTy, look(p[1]), + look(p[2])); + bind(p[0], op.getResult()); + break; + } + + case kOpWfNeg: { + requirePayload(2); + auto op = pulse::PulseNegOp::create(*builder_, loc, wfTy, look(p[1])); + bind(p[0], op.getResult()); + break; + } + + case kOpDrive: { + requirePayload(7); + auto driveOp = + pulse::DriveOp::create(*builder_, loc, driveTy, toneTy, look(p[0]), + look(p[1]), look(p[2])); + if (p[5] != -1) + driveOp->setAttr("start_vtu", builder_->getI64IntegerAttr(p[5])); + if (p[6] != -1) + driveOp->setAttr("duration_vtu", builder_->getI64IntegerAttr(p[6])); + bind(p[3], driveOp.getUpdatedLine()); + bind(p[4], driveOp.getUpdatedTone()); + break; + } + + case kOpReadout: { + requirePayload(6); + auto mode = builder_->getStringAttr("iq"); + auto roOp = + pulse::ReadoutOp::create(*builder_, loc, readoutTy, toneTy, measTy, + look(p[0]), look(p[1]), look(p[2]), mode); + bind(p[3], roOp.getUpdatedLine()); + bind(p[4], roOp.getUpdatedTone()); + bind(p[5], roOp.getResult()); + break; + } + + case kOpSync: { + if (plen < 1 || p[0] < 0 || 1 + 3 * p[0] != plen) + throw std::invalid_argument("packed: malformed sync payload"); + int64_t n = p[0]; + llvm::SmallVector inVals; + llvm::SmallVector outTypes; + for (int64_t j = 0; j < n; ++j) { + inVals.push_back(look(p[1 + 3 * j])); + int64_t vtype = p[3 + 3 * j]; + outTypes.push_back(vtype == 1 ? mlir::Type(readoutTy) + : mlir::Type(driveTy)); + } + auto syncOp = pulse::SyncOp::create(*builder_, loc, outTypes, inVals); + for (int64_t j = 0; j < n; ++j) + bind(p[2 + 3 * j], syncOp.getResult(j)); + break; + } + + case kOpWait: { + requirePayload(3); + mlir::Value lineVal = look(p[0]); + mlir::Value durCycles; + if (pmask & (1 << 2)) + durCycles = look(p[2]); + else + durCycles = + mlir::arith::ConstantIntOp::create(*builder_, loc, p[2], 64) + .getResult(); + auto durOp = + pulse::DurationFromIntOp::create(*builder_, loc, durTy, durCycles); + auto waitOp = pulse::WaitOp::create(*builder_, loc, lineVal.getType(), + lineVal, durOp.getResult()); + bind(p[1], waitOp.getResult()); + break; + } + + case kOpShiftPhase: { + requirePayload(3); + mlir::Value phaseVal = + (pmask & (1 << 2)) + ? look(p[2]) + : mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(i2f(p[2]))) + .getResult(); + auto spOp = pulse::ShiftPhaseOp::create(*builder_, loc, toneTy, + look(p[0]), phaseVal); + bind(p[1], spOp.getResult()); + break; + } + + case kOpSetPhase: { + requirePayload(3); + mlir::Value phaseVal = + (pmask & (1 << 2)) + ? look(p[2]) + : mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(i2f(p[2]))) + .getResult(); + auto spOp = pulse::SetPhaseOp::create(*builder_, loc, toneTy, + look(p[0]), phaseVal); + bind(p[1], spOp.getResult()); + break; + } + + case kOpShiftFreq: { + requirePayload(3); + mlir::Value fVal = + (pmask & (1 << 2)) + ? look(p[2]) + : mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(i2f(p[2]))) + .getResult(); + auto sfOp = pulse::ShiftFrequencyOp::create(*builder_, loc, toneTy, + look(p[0]), fVal); + bind(p[1], sfOp.getResult()); + break; + } + + case kOpSetFreq: { + requirePayload(3); + mlir::Value fVal = + (pmask & (1 << 2)) + ? look(p[2]) + : mlir::arith::ConstantFloatOp::create(*builder_, loc, f64Ty, + llvm::APFloat(i2f(p[2]))) + .getResult(); + auto sfOp = pulse::SetFrequencyOp::create(*builder_, loc, toneTy, + look(p[0]), fVal); + bind(p[1], sfOp.getResult()); + break; + } + + default: + throw std::invalid_argument("packed: unknown opcode " + + std::to_string(opcode)); + } + + cur += 1 + plen; + } + + mlir::func::ReturnOp::create(*builder_, loc); + auto owningModule = mlir::OwningOpRef(module); + return PulseModule(ctx_, std::move(owningModule)); + } + +private: + double extractReal(nb::handle val) { + if (nb::isinstance(val)) + return nb::cast(val); + if (nb::isinstance(val)) + return static_cast(nb::cast(val)); + // Complex: extract real part + nb::object obj = nb::borrow(val); + if (nb::hasattr(obj, "real")) + return nb::cast(obj.attr("real")); + return nb::cast(val); + } + + std::pair extractComplexPair(nb::handle val) { + if (nb::isinstance(val)) + return {nb::cast(val), 0.0}; + if (nb::isinstance(val)) + return {static_cast(nb::cast(val)), 0.0}; + nb::object obj = nb::borrow(val); + if (nb::hasattr(obj, "real") && nb::hasattr(obj, "imag")) + return {nb::cast(obj.attr("real")), + nb::cast(obj.attr("imag"))}; + if (nb::isinstance(val) || nb::isinstance(val)) { + nb::list lst = nb::cast(val); + double re = nb::cast(lst[0]); + double im = nb::len(lst) > 1 ? nb::cast(lst[1]) : 0.0; + return {re, im}; + } + return {nb::cast(val), 0.0}; + } + + int64_t storeVal(mlir::Value val) { + int64_t handle = nextHandle_++; + handleTable_[handle] = val; + return handle; + } + + mlir::Value loadVal(int64_t handle) { + auto it = handleTable_.find(handle); + if (it == handleTable_.end()) + throw std::runtime_error("Invalid MLIR value handle: " + + std::to_string(handle)); + return it->second; + } + + std::shared_ptr ctx_; + std::unique_ptr builder_; + mlir::ModuleOp module_; + mlir::func::FuncOp funcOp_; + llvm::DenseMap handleTable_; + int64_t nextHandle_ = 0; + int64_t nextStreamingQubit_ = 0; +}; + +// ----------------------------------------------------------------------- +// MLIRPipeline: text-in / text-out convenience wrapper (legacy API) +// ----------------------------------------------------------------------- +class MLIRPipeline { +public: + MLIRPipeline() : registry_(makeRegistry()) {} + + std::string parse_and_print(const std::string &mlir_text) { + mlir::MLIRContext ctx; + ctx.appendDialectRegistry(registry_); + ctx.loadAllAvailableDialects(); + auto module = mlir::parseSourceString(mlir_text, &ctx); + if (!module) + throw std::runtime_error("Failed to parse MLIR text"); + std::string result; + llvm::raw_string_ostream os(result); + module->print(os); + return result; + } + + std::string run_full_pipeline(const std::string &pulse_mlir) { + mlir::MLIRContext ctx; + ctx.appendDialectRegistry(registry_); + ctx.loadAllAvailableDialects(); + auto module = mlir::parseSourceString(pulse_mlir, &ctx); + if (!module) + throw std::runtime_error("Failed to parse pulse MLIR text"); + mlir::PassManager pm(&ctx); + pm.addPass(pulse::createPulseToQOpPass()); + pm.addPass(qop::createQOpToCuDensityMatPass()); + pm.addPass(cudm::createCuDensityMatToLLVMPass()); + pm.addPass(mlir::createCanonicalizerPass()); + pm.addPass(mlir::createArithToLLVMConversionPass()); + pm.addPass(mlir::createConvertFuncToLLVMPass()); + pm.addPass(mlir::createReconcileUnrealizedCastsPass()); + if (mlir::failed(pm.run(*module))) + throw std::runtime_error("MLIR pass pipeline failed"); + std::string result; + llvm::raw_string_ostream os(result); + module->print(os); + return result; + } + + std::string run_full_pipeline_to_llvm_ir(const std::string &pulse_mlir) { + mlir::MLIRContext ctx; + ctx.appendDialectRegistry(registry_); + ctx.loadAllAvailableDialects(); + mlir::registerBuiltinDialectTranslation(ctx); + mlir::registerLLVMDialectTranslation(ctx); + auto module = mlir::parseSourceString(pulse_mlir, &ctx); + if (!module) + throw std::runtime_error("Failed to parse pulse MLIR text"); + mlir::PassManager pm(&ctx); + pm.addPass(pulse::createPulseToQOpPass()); + pm.addPass(qop::createQOpToCuDensityMatPass()); + pm.addPass(cudm::createCuDensityMatToLLVMPass()); + pm.addPass(mlir::createCanonicalizerPass()); + pm.addPass(mlir::createArithToLLVMConversionPass()); + pm.addPass(mlir::createConvertFuncToLLVMPass()); + pm.addPass(mlir::createReconcileUnrealizedCastsPass()); + if (mlir::failed(pm.run(*module))) + throw std::runtime_error("MLIR pass pipeline failed"); + + llvm::LLVMContext llvmContext; + auto llvmModule = mlir::translateModuleToLLVMIR(*module, llvmContext); + if (!llvmModule) + throw std::runtime_error("Failed to translate LLVM-dialect MLIR"); + std::string result; + llvm::raw_string_ostream os(result); + llvmModule->print(os, nullptr); + return result; + } + + std::string run_pulse_to_qop(const std::string &pulse_mlir) { + mlir::MLIRContext ctx; + ctx.appendDialectRegistry(registry_); + ctx.loadAllAvailableDialects(); + auto module = mlir::parseSourceString(pulse_mlir, &ctx); + if (!module) + throw std::runtime_error("Failed to parse pulse MLIR text"); + mlir::PassManager pm(&ctx); + pm.addPass(pulse::createPulseToQOpPass()); + if (mlir::failed(pm.run(*module))) + throw std::runtime_error("pulse-to-qop pass failed"); + std::string result; + llvm::raw_string_ostream os(result); + module->print(os); + return result; + } + + std::string run_qop_to_cudm(const std::string &qop_mlir) { + mlir::MLIRContext ctx; + ctx.appendDialectRegistry(registry_); + ctx.loadAllAvailableDialects(); + auto module = mlir::parseSourceString(qop_mlir, &ctx); + if (!module) + throw std::runtime_error("Failed to parse qop MLIR text"); + mlir::PassManager pm(&ctx); + pm.addPass(qop::createQOpToCuDensityMatPass()); + if (mlir::failed(pm.run(*module))) + throw std::runtime_error("qop-to-cudm pass failed"); + std::string result; + llvm::raw_string_ostream os(result); + module->print(os); + return result; + } + + std::string run_cudm_to_llvm(const std::string &cudm_mlir) { + mlir::MLIRContext ctx; + ctx.appendDialectRegistry(registry_); + ctx.loadAllAvailableDialects(); + auto module = mlir::parseSourceString(cudm_mlir, &ctx); + if (!module) + throw std::runtime_error("Failed to parse cudm MLIR text"); + mlir::PassManager pm(&ctx); + pm.addPass(cudm::createCuDensityMatToLLVMPass()); + if (mlir::failed(pm.run(*module))) + throw std::runtime_error("cudm-to-llvm pass failed"); + std::string result; + llvm::raw_string_ostream os(result); + module->print(os); + return result; + } + + PulseModule parse_to_module(const std::string &mlir_text) { + auto ctx = std::make_shared(); + ctx->appendDialectRegistry(registry_); + ctx->loadAllAvailableDialects(); + auto module = mlir::parseSourceString(mlir_text, ctx.get()); + if (!module) + throw std::runtime_error("Failed to parse MLIR text"); + return PulseModule(ctx, std::move(module)); + } + +private: + mlir::DialectRegistry registry_; +}; + +} // namespace + +NB_MODULE(_cudaq_pulse_native, m) { + m.doc() = "cudaq-pulse native MLIR pipeline bindings"; + + nb::class_(m, "PulseModule") + .def("print", &PulseModule::print, "Print MLIR text representation") + .def("run_passes", &PulseModule::run_passes, + "Run named MLIR passes on this module") + .def("run_full_lowering", &PulseModule::run_full_lowering, + "Run full lowering: pulse -> qop -> cudm -> llvm") + .def("schedule", &PulseModule::schedule, "Schedule pulse operations", + nb::arg("policy"), nb::arg("max_drives") = 4, + nb::arg("max_readouts") = 2, nb::arg("readout_latency") = 0, + nb::arg("switch_penalty") = 0) + .def("is_parametric", &PulseModule::is_parametric, + "True if the module has func.func block arguments (parameters)") + .def("param_names", &PulseModule::param_names, "Get parameter names") + .def( + "specialize", &PulseModule::specialize, + "Clone module, substitute parameters with constants, fold, schedule"); + + nb::class_(m, "PulseModuleBuilder") + .def(nb::init<>()) + .def("build_from_program", &PulseModuleBuilder::build_from_program, + "Build an in-memory PulseModule from a program dict") + .def("build_from_packed", &PulseModuleBuilder::build_from_packed, + "Build an in-memory PulseModule from a packed int64 numpy buffer", + nb::arg("stream"), nb::arg("clock_ghz"), nb::arg("n_qubits"), + nb::arg("qubit_freqs"), + nb::arg("param_names") = std::vector{}, + nb::arg("param_types") = std::vector{}) + .def("begin_module", &PulseModuleBuilder::begin_module, + "Start building a new module") + .def("make_qudit", &PulseModuleBuilder::make_qudit, + "Allocate a qudit, returns handle") + .def("get_drive_line", &PulseModuleBuilder::get_drive_line, + "Get drive line+tone for a qudit, returns (line_h, tone_h)") + .def("get_readout_line", &PulseModuleBuilder::get_readout_line, + "Get readout line+tone for a qudit, returns (line_h, tone_h)") + .def("emit_gaussian", &PulseModuleBuilder::emit_gaussian, + "Emit gaussian waveform, returns wf handle") + .def("emit_square", &PulseModuleBuilder::emit_square, + "Emit square waveform, returns wf handle") + .def("emit_drag", &PulseModuleBuilder::emit_drag, + "Emit DRAG waveform, returns wf handle") + .def("emit_cosine", &PulseModuleBuilder::emit_cosine, + "Emit cosine waveform, returns wf handle") + .def("emit_tanh_ramp", &PulseModuleBuilder::emit_tanh_ramp, + "Emit tanh_ramp waveform, returns wf handle") + .def("emit_gaussian_square", &PulseModuleBuilder::emit_gaussian_square, + "Emit gaussian_square waveform, returns wf handle") + .def("emit_drive", &PulseModuleBuilder::emit_drive, + "Emit drive op, returns (line_h, tone_h)") + .def("emit_readout", &PulseModuleBuilder::emit_readout, + "Emit readout op, returns (line_h, tone_h, meas_h)") + .def("emit_wait", &PulseModuleBuilder::emit_wait, + "Emit wait op, returns line handle") + .def("emit_sync", &PulseModuleBuilder::emit_sync, + "Emit sync op, returns list of line handles") + .def("emit_shift_phase", &PulseModuleBuilder::emit_shift_phase, + "Emit shift_phase op, returns tone handle") + .def("emit_set_phase", &PulseModuleBuilder::emit_set_phase, + "Emit set_phase op, returns tone handle") + .def("emit_shift_frequency", &PulseModuleBuilder::emit_shift_frequency, + "Emit shift_frequency op, returns tone handle") + .def("emit_set_frequency", &PulseModuleBuilder::emit_set_frequency, + "Emit set_frequency op, returns tone handle") + .def("finish_module", &PulseModuleBuilder::finish_module, + "Finalize and return the PulseModule"); + + nb::class_(m, "MLIRPipeline") + .def(nb::init<>()) + .def("parse_and_print", &MLIRPipeline::parse_and_print, + "Parse MLIR text and print it back (roundtrip)") + .def("run_full_pipeline", &MLIRPipeline::run_full_pipeline, + "Run full pipeline: pulse -> qop -> cudm -> llvm") + .def("run_full_pipeline_to_llvm_ir", + &MLIRPipeline::run_full_pipeline_to_llvm_ir, + "Run the full pipeline and translate to textual LLVM IR") + .def("run_pulse_to_qop", &MLIRPipeline::run_pulse_to_qop, + "Run pulse-to-qop pass only") + .def("run_qop_to_cudm", &MLIRPipeline::run_qop_to_cudm, + "Run qop-to-cudm pass only") + .def("run_cudm_to_llvm", &MLIRPipeline::run_cudm_to_llvm, + "Run cudm-to-llvm pass only") + .def("parse_to_module", &MLIRPipeline::parse_to_module, + "Parse MLIR text into a PulseModule"); +} diff --git a/pulse/core/mlir/capi/CMakeLists.txt b/pulse/core/mlir/capi/CMakeLists.txt new file mode 100644 index 00000000000..29418c1f31c --- /dev/null +++ b/pulse/core/mlir/capi/CMakeLists.txt @@ -0,0 +1,16 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_mlir_public_c_api_library(CudaqPulseCAPI + PulseCanonicalize.cpp + + LINK_LIBS PUBLIC + CudaqPulsePulse + MLIRTransforms + MLIRCAPIIR +) diff --git a/pulse/core/mlir/capi/PulseCanonicalize.cpp b/pulse/core/mlir/capi/PulseCanonicalize.cpp new file mode 100644 index 00000000000..bb885f98b77 --- /dev/null +++ b/pulse/core/mlir/capi/PulseCanonicalize.cpp @@ -0,0 +1,44 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// CAPI entry point for pulse-canonicalize: exposes MLIR's greedy pattern +// rewrite driver to Python callers via a thin C API. It runs the same +// canonicalization every loaded op registers (e.g. the Pulse ops declared with +// `hasCanonicalizer = 1`), gathered the way MLIR's own `-canonicalize` pass +// does. + +#include "mlir-c/IR.h" +#include "mlir/CAPI/IR.h" +#include "mlir/IR/MLIRContext.h" +#include "mlir/IR/OperationSupport.h" +#include "mlir/IR/PatternMatch.h" +#include "mlir/Transforms/GreedyPatternRewriteDriver.h" + +extern "C" { + +MLIR_CAPI_EXPORTED MlirLogicalResult +cudaqPulseRunCanonicalize(MlirOperation op) { + mlir::Operation *cppOp = unwrap(op); + mlir::MLIRContext *context = cppOp->getContext(); + + // Collect the canonicalization patterns registered by every loaded op, + // exactly as the built-in canonicalizer pass does, so pulse ops with + // `hasCanonicalizer = 1` actually fold here instead of this being a no-op. + mlir::RewritePatternSet patterns(context); + for (mlir::RegisteredOperationName registeredOp : + context->getRegisteredOperations()) + registeredOp.getCanonicalizationPatterns(patterns, context); + + mlir::GreedyRewriteConfig config; + auto result = mlir::applyPatternsGreedily(cppOp, std::move(patterns), config); + MlirLogicalResult mlirResult; + mlirResult.value = mlir::succeeded(result) ? 1 : 0; + return mlirResult; +} + +} // extern "C" diff --git a/pulse/core/mlir/conversions/CMakeLists.txt b/pulse/core/mlir/conversions/CMakeLists.txt new file mode 100644 index 00000000000..bcc8beef6e0 --- /dev/null +++ b/pulse/core/mlir/conversions/CMakeLists.txt @@ -0,0 +1,11 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_subdirectory(PulseToQOp) +add_subdirectory(QOpToCuDensityMat) +add_subdirectory(CuDensityMatToLLVM) diff --git a/pulse/core/mlir/conversions/CuDensityMatToLLVM/CMakeLists.txt b/pulse/core/mlir/conversions/CuDensityMatToLLVM/CMakeLists.txt new file mode 100644 index 00000000000..dfb3c659939 --- /dev/null +++ b/pulse/core/mlir/conversions/CuDensityMatToLLVM/CMakeLists.txt @@ -0,0 +1,20 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_mlir_conversion_library(CudaqPulseCuDensityMatToLLVM + CuDensityMatToLLVM.cpp + + DEPENDS + CudaqPulseCuDensityMatIncGen + + LINK_LIBS PUBLIC + CudaqPulseCuDensityMat + MLIRLLVMDialect + MLIRLLVMCommonConversion + MLIRTransforms +) diff --git a/pulse/core/mlir/conversions/CuDensityMatToLLVM/CuDensityMatToLLVM.cpp b/pulse/core/mlir/conversions/CuDensityMatToLLVM/CuDensityMatToLLVM.cpp new file mode 100644 index 00000000000..bfd7f8e70bf --- /dev/null +++ b/pulse/core/mlir/conversions/CuDensityMatToLLVM/CuDensityMatToLLVM.cpp @@ -0,0 +1,663 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// CuDensityMatToLLVM: lower cudm dialect ops to llvm.call sequences +// targeting the libcudm-runtime C ABI. + +#include "mlir/Conversion/LLVMCommon/ConversionTarget.h" +#include "mlir/Conversion/LLVMCommon/Pattern.h" +#include "mlir/Dialect/Arith/IR/Arith.h" +#include "mlir/Dialect/LLVMIR/LLVMDialect.h" +#include "mlir/IR/Builders.h" +#include "mlir/Pass/Pass.h" +#include "mlir/Transforms/DialectConversion.h" + +#include "mlir/Bytecode/BytecodeOpInterface.h" +#include "mlir/Interfaces/SideEffectInterfaces.h" + +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.h.inc" +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatEnums.h.inc" +#define GET_ATTRDEF_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatAttrs.h.inc" +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatOps.h.inc" + +using namespace mlir; + +namespace { + +// All cudm types lower to !llvm.ptr (opaque handles) +class CudmTypeConverter : public TypeConverter { +public: + CudmTypeConverter(MLIRContext *ctx) { + auto ptrTy = LLVM::LLVMPointerType::get(ctx); + addConversion([](Type t) { return t; }); + addConversion([ptrTy](cudm::HandleType) -> Type { return ptrTy; }); + addConversion([ptrTy](cudm::StateType) -> Type { return ptrTy; }); + addConversion([ptrTy](cudm::WorkspaceType) -> Type { return ptrTy; }); + addConversion([ptrTy](cudm::ElementaryOpType) -> Type { return ptrTy; }); + addConversion([ptrTy](cudm::OpTermType) -> Type { return ptrTy; }); + addConversion([ptrTy](cudm::OperatorType) -> Type { return ptrTy; }); + addConversion([ptrTy](cudm::ExpectationType) -> Type { return ptrTy; }); + } +}; + +// Helper: get or insert an extern func decl returning i32 +static LLVM::LLVMFuncOp getOrInsertRuntimeFn(ModuleOp module, OpBuilder &b, + StringRef name, Type retTy, + ArrayRef argTys) { + if (auto fn = module.lookupSymbol(name)) + return fn; + OpBuilder::InsertionGuard guard(b); + b.setInsertionPointToStart(module.getBody()); + auto fnTy = LLVM::LLVMFunctionType::get(retTy, argTys); + return LLVM::LLVMFuncOp::create(b, module.getLoc(), name, fnTy); +} + +// Helpers for common LLVM types +static Type i32(MLIRContext *c) { return IntegerType::get(c, 32); } +static Type i64(MLIRContext *c) { return IntegerType::get(c, 64); } +static Type f64(MLIRContext *c) { return Float64Type::get(c); } +static Type ptr(MLIRContext *c) { return LLVM::LLVMPointerType::get(c); } + +// ---- InitHandleOp -> llvm.call @cudm_init ---- +struct InitHandleLowering : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::InitHandleOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto loc = op.getLoc(); + // Allocate stack space for handle pointer, call cudm_init(&handle) + auto one = LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(1)); + auto handleSlot = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), ptr(ctx), one); + auto fn = getOrInsertRuntimeFn(module, rewriter, "cudm_init", i32(ctx), + {ptr(ctx)}); + LLVM::CallOp::create(rewriter, loc, fn, ValueRange{handleSlot}); + auto handle = LLVM::LoadOp::create(rewriter, loc, ptr(ctx), handleSlot); + rewriter.replaceOp(op, handle.getResult()); + return success(); + } +}; + +// ---- DestroyHandleOp -> llvm.call @cudm_destroy ---- +struct DestroyHandleLowering + : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::DestroyHandleOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto fn = getOrInsertRuntimeFn(module, rewriter, "cudm_destroy", i32(ctx), + {ptr(ctx)}); + LLVM::CallOp::create(rewriter, op.getLoc(), fn, adaptor.getHandle()); + rewriter.eraseOp(op); + return success(); + } +}; + +// ---- CreateStateOp -> llvm.call @cudm_state_alloc ---- +struct CreateStateLowering : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::CreateStateOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto loc = op.getLoc(); + + auto modeExtents = op.getModeExtents(); + int32_t numModes = modeExtents.size(); + int32_t purity = static_cast(op.getPurity()); + int32_t dataType = static_cast(op.getDataType()); + + // Allocate stack arrays for mode_extents + auto numModesVal = LLVM::ConstantOp::create( + rewriter, loc, i64(ctx), rewriter.getI64IntegerAttr(numModes)); + auto extentsPtr = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), i64(ctx), numModesVal); + for (int i = 0; i < numModes; i++) { + auto idx = LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(i)); + auto elemPtr = LLVM::GEPOp::create(rewriter, loc, ptr(ctx), i64(ctx), + extentsPtr, ValueRange{idx}); + auto val = LLVM::ConstantOp::create( + rewriter, loc, i64(ctx), rewriter.getI64IntegerAttr(modeExtents[i])); + LLVM::StoreOp::create(rewriter, loc, val, elemPtr); + } + + // &state + auto one = LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(1)); + auto stateSlot = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), ptr(ctx), one); + + auto numModesI32 = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), rewriter.getI32IntegerAttr(numModes)); + auto purityI32 = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), rewriter.getI32IntegerAttr(purity)); + auto dtypeI32 = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), rewriter.getI32IntegerAttr(dataType)); + + auto fn = getOrInsertRuntimeFn( + module, rewriter, "cudm_state_alloc", i32(ctx), + {ptr(ctx), ptr(ctx), ptr(ctx), i32(ctx), i32(ctx), i32(ctx)}); + LLVM::CallOp::create(rewriter, loc, fn, + ValueRange{adaptor.getHandle(), stateSlot, extentsPtr, + numModesI32, purityI32, dtypeI32}); + auto state = LLVM::LoadOp::create(rewriter, loc, ptr(ctx), stateSlot); + rewriter.replaceOp(op, state.getResult()); + return success(); + } +}; + +// ---- DestroyStateOp -> llvm.call @cudm_state_destroy ---- +struct DestroyStateLowering : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::DestroyStateOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto fn = getOrInsertRuntimeFn(module, rewriter, "cudm_state_destroy", + i32(ctx), {ptr(ctx)}); + LLVM::CallOp::create(rewriter, op.getLoc(), fn, adaptor.getState()); + rewriter.eraseOp(op); + return success(); + } +}; + +// ---- CreateWorkspaceOp -> llvm.call @cudm_workspace_create ---- +struct CreateWorkspaceLowering + : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::CreateWorkspaceOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto loc = op.getLoc(); + auto one = LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(1)); + auto wsSlot = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), ptr(ctx), one); + auto fn = getOrInsertRuntimeFn(module, rewriter, "cudm_workspace_create", + i32(ctx), {ptr(ctx), ptr(ctx)}); + LLVM::CallOp::create(rewriter, loc, fn, + ValueRange{adaptor.getHandle(), wsSlot}); + auto ws = LLVM::LoadOp::create(rewriter, loc, ptr(ctx), wsSlot); + rewriter.replaceOp(op, ws.getResult()); + return success(); + } +}; + +// ---- DestroyWorkspaceOp -> llvm.call @cudm_workspace_destroy ---- +struct DestroyWorkspaceLowering + : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::DestroyWorkspaceOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto fn = getOrInsertRuntimeFn(module, rewriter, "cudm_workspace_destroy", + i32(ctx), {ptr(ctx)}); + LLVM::CallOp::create(rewriter, op.getLoc(), fn, adaptor.getWorkspace()); + rewriter.eraseOp(op); + return success(); + } +}; + +// ---- CreateOperatorOp -> llvm.call @cudm_operator_create ---- +struct CreateOperatorLowering + : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::CreateOperatorOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto loc = op.getLoc(); + auto modeExtents = op.getModeExtents(); + int32_t numModes = modeExtents.size(); + + auto numModesVal = LLVM::ConstantOp::create( + rewriter, loc, i64(ctx), rewriter.getI64IntegerAttr(numModes)); + auto extentsPtr = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), i64(ctx), numModesVal); + for (int i = 0; i < numModes; i++) { + auto idx = LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(i)); + auto elemPtr = LLVM::GEPOp::create(rewriter, loc, ptr(ctx), i64(ctx), + extentsPtr, ValueRange{idx}); + auto val = LLVM::ConstantOp::create( + rewriter, loc, i64(ctx), rewriter.getI64IntegerAttr(modeExtents[i])); + LLVM::StoreOp::create(rewriter, loc, val, elemPtr); + } + + auto one = LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(1)); + auto opSlot = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), ptr(ctx), one); + auto numModesI32 = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), rewriter.getI32IntegerAttr(numModes)); + auto fn = + getOrInsertRuntimeFn(module, rewriter, "cudm_operator_create", i32(ctx), + {ptr(ctx), ptr(ctx), ptr(ctx), i32(ctx)}); + LLVM::CallOp::create( + rewriter, loc, fn, + ValueRange{adaptor.getHandle(), opSlot, extentsPtr, numModesI32}); + auto result = LLVM::LoadOp::create(rewriter, loc, ptr(ctx), opSlot); + rewriter.replaceOp(op, result.getResult()); + return success(); + } +}; + +// ---- DestroyOperatorOp -> llvm.call @cudm_operator_destroy ---- +struct DestroyOperatorLowering + : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::DestroyOperatorOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto fn = getOrInsertRuntimeFn(module, rewriter, "cudm_operator_destroy", + i32(ctx), {ptr(ctx)}); + LLVM::CallOp::create(rewriter, op.getLoc(), fn, adaptor.getOp()); + rewriter.eraseOp(op); + return success(); + } +}; + +// ---- CreateElementaryOpOp -> llvm.call @cudm_elementary_op_create ---- +struct CreateElementaryOpLowering + : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::CreateElementaryOpOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto loc = op.getLoc(); + + auto one = LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(1)); + auto elemSlot = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), ptr(ctx), one); + + auto tensorConstant = op.getTensorData().getDefiningOp(); + if (!tensorConstant) + return rewriter.notifyMatchFailure( + op, "elementary operator data must be an arith constant"); + auto tensorValues = + dyn_cast(tensorConstant.getValue()); + if (!tensorValues) + return rewriter.notifyMatchFailure( + op, "elementary operator data must contain floating-point values"); + + const auto numValues = tensorValues.getNumElements(); + auto numValuesConstant = LLVM::ConstantOp::create( + rewriter, loc, i64(ctx), rewriter.getI64IntegerAttr(numValues)); + auto tensorData = LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), f64(ctx), + numValuesConstant); + int64_t index = 0; + for (const auto &value : tensorValues.getValues()) { + auto indexConstant = LLVM::ConstantOp::create( + rewriter, loc, i64(ctx), rewriter.getI64IntegerAttr(index++)); + auto elementPointer = + LLVM::GEPOp::create(rewriter, loc, ptr(ctx), f64(ctx), tensorData, + ValueRange{indexConstant}); + auto elementValue = LLVM::ConstantOp::create( + rewriter, loc, f64(ctx), + rewriter.getF64FloatAttr(value.convertToDouble())); + LLVM::StoreOp::create(rewriter, loc, elementValue, elementPointer); + } + + auto modeExtents = op.getModeExtents(); + auto numModes = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), + rewriter.getI32IntegerAttr(static_cast(modeExtents.size()))); + auto extentCount = LLVM::ConstantOp::create( + rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(static_cast(modeExtents.size()))); + auto extents = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), i64(ctx), extentCount); + for (auto [i, extent] : llvm::enumerate(modeExtents)) { + auto indexValue = LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(i)); + auto element = + LLVM::GEPOp::create(rewriter, loc, ptr(ctx), i64(ctx), extents, + ValueRange{indexValue.getResult()}); + auto extentValue = LLVM::ConstantOp::create( + rewriter, loc, i64(ctx), rewriter.getI64IntegerAttr(extent)); + LLVM::StoreOp::create(rewriter, loc, extentValue, element); + } + auto dataType = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), + rewriter.getI32IntegerAttr(static_cast(op.getDataType()))); + + auto fn = getOrInsertRuntimeFn( + module, rewriter, "cudm_elementary_op_create", i32(ctx), + {ptr(ctx), ptr(ctx), ptr(ctx), i64(ctx), ptr(ctx), i32(ctx), i32(ctx)}); + LLVM::CallOp::create(rewriter, loc, fn, + ValueRange{adaptor.getHandle(), elemSlot, tensorData, + numValuesConstant, extents, numModes, + dataType}); + auto elem = LLVM::LoadOp::create(rewriter, loc, ptr(ctx), elemSlot); + rewriter.replaceOp(op, elem.getResult()); + return success(); + } +}; + +// ---- DestroyElementaryOpOp -> llvm.call @cudm_elementary_op_destroy ---- +struct DestroyElementaryOpLowering + : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::DestroyElementaryOpOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto fn = getOrInsertRuntimeFn( + module, rewriter, "cudm_elementary_op_destroy", i32(ctx), {ptr(ctx)}); + LLVM::CallOp::create(rewriter, op.getLoc(), fn, adaptor.getElemOp()); + rewriter.eraseOp(op); + return success(); + } +}; + +// ---- CreateOpTermOp -> llvm.call @cudm_op_term_create ---- +struct CreateOpTermLowering : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::CreateOpTermOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto loc = op.getLoc(); + + auto one = LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(1)); + auto termSlot = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), ptr(ctx), one); + auto modeExtents = op.getModeExtents(); + auto numModes = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), + rewriter.getI32IntegerAttr(static_cast(modeExtents.size()))); + auto extentCount = LLVM::ConstantOp::create( + rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(static_cast(modeExtents.size()))); + auto extents = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), i64(ctx), extentCount); + for (auto [i, extent] : llvm::enumerate(modeExtents)) { + auto indexValue = LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(i)); + auto element = + LLVM::GEPOp::create(rewriter, loc, ptr(ctx), i64(ctx), extents, + ValueRange{indexValue.getResult()}); + auto extentValue = LLVM::ConstantOp::create( + rewriter, loc, i64(ctx), rewriter.getI64IntegerAttr(extent)); + LLVM::StoreOp::create(rewriter, loc, extentValue, element); + } + auto fn = + getOrInsertRuntimeFn(module, rewriter, "cudm_op_term_create", i32(ctx), + {ptr(ctx), ptr(ctx), ptr(ctx), i32(ctx)}); + LLVM::CallOp::create( + rewriter, loc, fn, + ValueRange{adaptor.getHandle(), termSlot, extents, numModes}); + auto term = LLVM::LoadOp::create(rewriter, loc, ptr(ctx), termSlot); + rewriter.replaceOp(op, term.getResult()); + return success(); + } +}; + +struct DestroyOpTermLowering + : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::DestroyOpTermOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto fn = getOrInsertRuntimeFn(module, rewriter, "cudm_op_term_destroy", + i32(ctx), {ptr(ctx)}); + LLVM::CallOp::create(rewriter, op.getLoc(), fn, adaptor.getOpTerm()); + rewriter.eraseOp(op); + return success(); + } +}; + +// ---- AppendElementaryProductOp -> llvm.call @cudm_op_term_append ---- +struct AppendElementaryProductLowering + : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::AppendElementaryProductOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto loc = op.getLoc(); + + auto coeffReal = LLVM::ConstantOp::create(rewriter, loc, f64(ctx), + op.getCoeffRealAttr()); + auto coeffImag = LLVM::ConstantOp::create(rewriter, loc, f64(ctx), + op.getCoeffImagAttr()); + + int32_t callbackKind = 0; + ArrayRef callbackParameters; + if (auto kind = op->getAttrOfType("cudm.callback_kind")) + callbackKind = static_cast(kind.getInt()); + if (auto parameters = + op->getAttrOfType("cudm.callback_params")) + callbackParameters = parameters.asArrayRef(); + auto callbackKindValue = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), rewriter.getI32IntegerAttr(callbackKind)); + auto callbackCountValue = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), + rewriter.getI32IntegerAttr(callbackParameters.size())); + auto callbackAllocationCount = + LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(std::max( + 1, callbackParameters.size()))); + auto callbackData = LLVM::AllocaOp::create( + rewriter, loc, ptr(ctx), f64(ctx), callbackAllocationCount); + for (auto [i, parameter] : llvm::enumerate(callbackParameters)) { + auto index = LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(i)); + auto element = + LLVM::GEPOp::create(rewriter, loc, ptr(ctx), f64(ctx), callbackData, + ValueRange{index.getResult()}); + auto value = LLVM::ConstantOp::create( + rewriter, loc, f64(ctx), rewriter.getF64FloatAttr(parameter)); + LLVM::StoreOp::create(rewriter, loc, value, element); + } + + int32_t numElems = adaptor.getElemOps().size(); + auto numElemsVal = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), rewriter.getI32IntegerAttr(numElems)); + + auto numElemsI64 = LLVM::ConstantOp::create( + rewriter, loc, i64(ctx), rewriter.getI64IntegerAttr(numElems)); + auto elemOps = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), ptr(ctx), numElemsI64); + auto modes = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), i32(ctx), numElemsI64); + auto duality = + LLVM::AllocaOp::create(rewriter, loc, ptr(ctx), i32(ctx), numElemsI64); + auto modeValues = op.getModesActedOn(); + auto dualityValues = op.getDuality(); + if (modeValues.size() != static_cast(numElems) || + dualityValues.size() != static_cast(numElems)) + return rewriter.notifyMatchFailure( + op, "mode and duality arrays must match elementary operator count"); + for (int32_t i = 0; i < numElems; ++i) { + auto index = LLVM::ConstantOp::create(rewriter, loc, i64(ctx), + rewriter.getI64IntegerAttr(i)); + auto opElement = + LLVM::GEPOp::create(rewriter, loc, ptr(ctx), ptr(ctx), elemOps, + ValueRange{index.getResult()}); + LLVM::StoreOp::create(rewriter, loc, adaptor.getElemOps()[i], opElement); + auto modeElement = + LLVM::GEPOp::create(rewriter, loc, ptr(ctx), i32(ctx), modes, + ValueRange{index.getResult()}); + auto modeValue = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), rewriter.getI32IntegerAttr(modeValues[i])); + LLVM::StoreOp::create(rewriter, loc, modeValue, modeElement); + auto dualityElement = + LLVM::GEPOp::create(rewriter, loc, ptr(ctx), i32(ctx), duality, + ValueRange{index.getResult()}); + auto dualityValue = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), + rewriter.getI32IntegerAttr(dualityValues[i])); + LLVM::StoreOp::create(rewriter, loc, dualityValue, dualityElement); + } + + auto fn = getOrInsertRuntimeFn( + module, rewriter, "cudm_op_term_append", i32(ctx), + {ptr(ctx), ptr(ctx), ptr(ctx), ptr(ctx), ptr(ctx), i32(ctx), f64(ctx), + f64(ctx), i32(ctx), ptr(ctx), i32(ctx)}); + LLVM::CallOp::create(rewriter, loc, fn, + ValueRange{adaptor.getHandle(), adaptor.getOpTerm(), + elemOps, modes, duality, numElemsVal, + coeffReal, coeffImag, callbackKindValue, + callbackData, callbackCountValue}); + rewriter.eraseOp(op); + return success(); + } +}; + +// ---- OperatorAppendTermOp -> llvm.call @cudm_operator_append ---- +struct OperatorAppendTermLowering + : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::OperatorAppendTermOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto loc = op.getLoc(); + + auto duality = + LLVM::ConstantOp::create(rewriter, loc, i32(ctx), op.getDualityAttr()); + auto coeffReal = LLVM::ConstantOp::create(rewriter, loc, f64(ctx), + op.getCoeffRealAttr()); + auto coeffImag = LLVM::ConstantOp::create(rewriter, loc, f64(ctx), + op.getCoeffImagAttr()); + + auto fn = getOrInsertRuntimeFn( + module, rewriter, "cudm_operator_append", i32(ctx), + {ptr(ctx), ptr(ctx), ptr(ctx), i32(ctx), f64(ctx), f64(ctx)}); + LLVM::CallOp::create(rewriter, loc, fn, + ValueRange{adaptor.getHandle(), adaptor.getOp(), + adaptor.getTerm(), duality, coeffReal, + coeffImag}); + rewriter.eraseOp(op); + return success(); + } +}; + +// ---- EvolveOp -> one runtime integration call + host result capture ---- +struct EvolveLowering : public OpConversionPattern { + using OpConversionPattern::OpConversionPattern; + LogicalResult + matchAndRewrite(cudm::EvolveOp op, OpAdaptor adaptor, + ConversionPatternRewriter &rewriter) const override { + auto module = op->getParentOfType(); + auto *ctx = rewriter.getContext(); + auto loc = op.getLoc(); + + double tStart = op.getTStart().convertToDouble(); + double tEnd = op.getTEnd().convertToDouble(); + int64_t numSteps = op.getNumSteps(); + int32_t integrator = static_cast(op.getIntegrator()); + + auto fn = + getOrInsertRuntimeFn(module, rewriter, "cudm_evolve", i32(ctx), + {ptr(ctx), ptr(ctx), ptr(ctx), ptr(ctx), ptr(ctx), + f64(ctx), f64(ctx), i64(ctx), i32(ctx)}); + + auto integratorVal = LLVM::ConstantOp::create( + rewriter, loc, i32(ctx), rewriter.getI32IntegerAttr(integrator)); + auto startVal = LLVM::ConstantOp::create(rewriter, loc, f64(ctx), + rewriter.getF64FloatAttr(tStart)); + auto endVal = LLVM::ConstantOp::create(rewriter, loc, f64(ctx), + rewriter.getF64FloatAttr(tEnd)); + auto stepsVal = LLVM::ConstantOp::create( + rewriter, loc, i64(ctx), rewriter.getI64IntegerAttr(numSteps)); + LLVM::CallOp::create(rewriter, loc, fn, + ValueRange{adaptor.getHandle(), adaptor.getOp(), + adaptor.getStateIn(), adaptor.getStateOut(), + adaptor.getWorkspace(), startVal, endVal, + stepsVal, integratorVal}); + + auto capture = getOrInsertRuntimeFn(module, rewriter, "cudm_state_capture", + i32(ctx), {ptr(ctx)}); + LLVM::CallOp::create(rewriter, loc, capture, adaptor.getStateOut()); + + // The result is state_out (same pointer) + rewriter.replaceOp(op, adaptor.getStateOut()); + return success(); + } +}; + +// ---- The pass itself ---- +struct CuDensityMatToLLVMPass + : public PassWrapper> { + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(CuDensityMatToLLVMPass) + + StringRef getArgument() const final { return "cudm-to-llvm"; } + StringRef getDescription() const final { + return "Lower cudm dialect ops to LLVM IR call sequences targeting " + "libcudm-runtime"; + } + + void getDependentDialects(DialectRegistry ®istry) const override { + registry.insert(); + } + + void runOnOperation() override { + auto module = getOperation(); + auto *ctx = &getContext(); + + CudmTypeConverter typeConverter(ctx); + ConversionTarget target(*ctx); + target.addLegalDialect(); + target.addLegalOp(); + target.addIllegalDialect(); + + RewritePatternSet patterns(ctx); + patterns.add(typeConverter, + ctx); + + if (failed(applyPartialConversion(module, target, std::move(patterns)))) + signalPassFailure(); + } +}; + +} // namespace + +namespace cudm { + +std::unique_ptr createCuDensityMatToLLVMPass() { + return std::make_unique(); +} + +} // namespace cudm diff --git a/pulse/core/mlir/conversions/PulseToQOp/CMakeLists.txt b/pulse/core/mlir/conversions/PulseToQOp/CMakeLists.txt new file mode 100644 index 00000000000..d9addd3392d --- /dev/null +++ b/pulse/core/mlir/conversions/PulseToQOp/CMakeLists.txt @@ -0,0 +1,23 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_mlir_conversion_library(CudaqPulsePulseToQOp + PulseToQOp.cpp + + DEPENDS + CudaqPulsePulseIncGen + CudaqPulseQOpIncGen + + LINK_LIBS PUBLIC + CudaqPulsePulse + CudaqPulseQOp + MLIRArithDialect + MLIRFuncDialect + MLIRSCFDialect + MLIRTransforms +) diff --git a/pulse/core/mlir/conversions/PulseToQOp/PulseToQOp.cpp b/pulse/core/mlir/conversions/PulseToQOp/PulseToQOp.cpp new file mode 100644 index 00000000000..a8e1a2b487a --- /dev/null +++ b/pulse/core/mlir/conversions/PulseToQOp/PulseToQOp.cpp @@ -0,0 +1,650 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// PulseToQOp conversion pass: lower pulse dialect ops to qop dialect ops +// for Hamiltonian/Lindblad construction. + +#include "mlir/Dialect/Arith/IR/Arith.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/Dialect/SCF/IR/SCF.h" +#include "mlir/IR/Builders.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/IR/Matchers.h" +#include "mlir/IR/PatternMatch.h" +#include "mlir/Pass/Pass.h" +#include "mlir/Transforms/DialectConversion.h" + +#include "mlir/Bytecode/BytecodeOpInterface.h" +#include "mlir/Interfaces/ControlFlowInterfaces.h" +#include "mlir/Interfaces/SideEffectInterfaces.h" + +#include "cudaq-pulse/Dialect/Pulse/PulseDialect.h.inc" +#include "cudaq-pulse/Dialect/Pulse/PulseEnums.h.inc" +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseOps.h.inc" + +#include "cudaq-pulse/Dialect/QOp/QOpDialect.h.inc" +#include "cudaq-pulse/Dialect/QOp/QOpEnums.h.inc" +#define GET_ATTRDEF_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpAttrs.h.inc" +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpOps.h.inc" + +#include +#include +#include + +using namespace mlir; + +namespace { + +static std::optional traceQubit(Value line, + llvm::DenseSet &visited) { + if (!line || !visited.insert(line).second) + return std::nullopt; + auto *def = line.getDefiningOp(); + if (!def) + return std::nullopt; + if (auto qubit = def->getAttrOfType("qubit")) + return qubit.getInt(); + + // Sync preserves line identity positionally. Other line-transforming pulse + // ops carry their physical line in the first operand. + if (def->getName().getStringRef() == "pulse.sync") { + auto result = dyn_cast(line); + if (!result || result.getResultNumber() >= def->getNumOperands()) + return std::nullopt; + return traceQubit(def->getOperand(result.getResultNumber()), visited); + } + for (Value operand : def->getOperands()) { + if (isa(operand.getType())) + return traceQubit(operand, visited); + } + return std::nullopt; +} + +static std::optional traceQubit(Value line) { + llvm::DenseSet visited; + return traceQubit(line, visited); +} + +static std::optional constantNumber(Value value); + +struct ToneState { + double frequencyHz; + double frameFrequencyHz; + double phase; + double timeNs; +}; + +static std::optional traceTone(Value tone, double clockGHz, + llvm::DenseSet &visited) { + if (!tone || !visited.insert(tone).second) + return std::nullopt; + Operation *def = tone.getDefiningOp(); + if (!def) + return std::nullopt; + StringRef name = def->getName().getStringRef(); + if (name == "pulse.get_drive_line" || name == "pulse.get_readout_line") { + auto frequency = def->getAttrOfType("frequency_hz"); + if (!frequency) + return std::nullopt; + double value = frequency.getValueAsDouble(); + return ToneState{value, value, 0.0, 0.0}; + } + if (name == "pulse.tone" && def->getNumOperands() == 2) { + auto frequency = constantNumber(def->getOperand(0)); + auto phase = constantNumber(def->getOperand(1)); + if (!frequency || !phase) + return std::nullopt; + return ToneState{*frequency, *frequency, *phase, 0.0}; + } + if (name == "pulse.drive" && def->getNumOperands() >= 3) { + auto state = traceTone(def->getOperand(2), clockGHz, visited); + auto start = def->getAttrOfType("start_vtu"); + auto duration = def->getAttrOfType("duration_vtu"); + if (!state || !duration) + return std::nullopt; + if (auto framePhase = def->getAttrOfType("frame_phase_offset")) + state->phase += framePhase.getValueAsDouble(); + double endNs = + static_cast((start ? start.getInt() : 0) + duration.getInt()) / + clockGHz; + state->phase += 2.0 * M_PI * + (state->frequencyHz - state->frameFrequencyHz) * 1.0e-9 * + (endNs - state->timeNs); + state->timeNs = endNs; + return state; + } + if ((name == "pulse.shift_phase" || name == "pulse.set_phase" || + name == "pulse.shift_frequency" || name == "pulse.set_frequency") && + def->getNumOperands() == 2) { + auto state = traceTone(def->getOperand(0), clockGHz, visited); + auto value = constantNumber(def->getOperand(1)); + if (!state || !value) + return std::nullopt; + if (name == "pulse.shift_phase") + state->phase += *value; + else if (name == "pulse.set_phase") + state->phase = *value; + else if (name == "pulse.shift_frequency") + state->frequencyHz += *value; + else + state->frequencyHz = *value; + return state; + } + return std::nullopt; +} + +static std::optional traceTone(Value tone, double clockGHz) { + llvm::DenseSet visited; + return traceTone(tone, clockGHz, visited); +} + +static std::optional constantNumber(Value value) { + Attribute attribute; + if (!matchPattern(value, m_Constant(&attribute))) + return std::nullopt; + if (auto number = dyn_cast(attribute)) + return number.getValueAsDouble(); + if (auto number = dyn_cast(attribute)) + return static_cast(number.getInt()); + return std::nullopt; +} + +struct WaveformCallbackData { + int32_t kind; + SmallVector parameters; +}; + +static std::optional +getWaveformCallbackData(Operation *drive, double clockGHz, + double qubitFrequencyHz, double driveScale, + int32_t quadrature) { + if (drive->getNumOperands() < 3) + return std::nullopt; + auto *waveform = drive->getOperand(1).getDefiningOp(); + if (!waveform) + return std::nullopt; + auto start = drive->getAttrOfType("start_vtu"); + auto duration = drive->getAttrOfType("duration_vtu"); + if (!start || !duration) + return std::nullopt; + + const double scale = 1.0 / clockGHz; + const double startNs = static_cast(start.getInt()) * scale; + const double durationNs = static_cast(duration.getInt()) * scale; + auto tone = traceTone(drive->getOperand(2), clockGHz); + if (!tone) + return std::nullopt; + const double phaseOffset = + drive->getAttrOfType("phase_offset") + ? drive->getAttrOfType("phase_offset").getValueAsDouble() + : 0.0; + const double framePhaseOffset = + drive->getAttrOfType("frame_phase_offset") + ? drive->getAttrOfType("frame_phase_offset") + .getValueAsDouble() + : 0.0; + const double phase = + tone->phase + + 2.0 * M_PI * (tone->frequencyHz - tone->frameFrequencyHz) * 1.0e-9 * + (startNs - tone->timeNs) + + 2.0 * M_PI * (tone->frameFrequencyHz - qubitFrequencyHz) * 1.0e-9 * + startNs + + phaseOffset + framePhaseOffset; + const double detuning = + 2.0 * M_PI * (tone->frequencyHz - qubitFrequencyHz) * 1.0e-9; + // Common layout: start, duration, real amplitude, imaginary amplitude, + // sigma, beta, rise/fall, phase-at-start, quadrature, detuning (rad/ns), + // followed by sample data. + WaveformCallbackData result{0, + {startNs, durationNs, 0.0, 0.0, 0.0, 0.0, 0.0, + phase, static_cast(quadrature), + detuning}}; + auto set = [&](int32_t kind, Value amplitude, Value sigma = {}, + Value beta = {}) -> bool { + auto amplitudeValue = constantNumber(amplitude); + if (!amplitudeValue) + return false; + result.kind = kind; + result.parameters[2] = *amplitudeValue * driveScale; + if (sigma) { + auto sigmaValue = constantNumber(sigma); + if (!sigmaValue) + return false; + result.parameters[4] = *sigmaValue * scale; + } + if (beta) { + auto betaValue = constantNumber(beta); + if (!betaValue) + return false; + result.parameters[5] = *betaValue; + } + return true; + }; + + if (auto op = dyn_cast(waveform)) { + auto real = constantNumber(op.getAmpReal()); + auto imag = constantNumber(op.getAmpImag()); + if (!real || !imag) + return std::nullopt; + result.kind = 1; + result.parameters[2] = *real * driveScale; + result.parameters[3] = *imag * driveScale; + } else if (auto op = dyn_cast(waveform)) { + if (!set(2, op.getAmplitude(), op.getSigma())) + return std::nullopt; + } else if (auto op = dyn_cast(waveform)) { + if (!set(3, op.getAmplitude(), op.getSigma(), op.getBeta())) + return std::nullopt; + } else if (auto op = dyn_cast(waveform)) { + if (!set(4, op.getAmplitude())) + return std::nullopt; + } else if (auto op = dyn_cast(waveform)) { + if (!set(5, op.getAmplitude(), op.getSigma())) + return std::nullopt; + } else if (auto op = dyn_cast(waveform)) { + if (!set(6, op.getAmplitude(), op.getSigma())) + return std::nullopt; + auto riseFall = constantNumber(op.getRisefall()); + if (!riseFall) + return std::nullopt; + result.parameters[6] = *riseFall * scale; + } else if (auto op = dyn_cast(waveform)) { + result.kind = 7; + for (Attribute sample : op.getSamples()) { + auto value = dyn_cast(sample); + if (!value) + return std::nullopt; + result.parameters.push_back(value.getValueAsDouble() * driveScale); + } + } else { + return std::nullopt; + } + return result; +} + +// ---- Build a qop.spin + qop.const_scalar + qop.make_product term ---- +static Value buildStaticTerm(OpBuilder &b, Location loc, Value target, + StringRef spinKind, double coeffReal, + double coeffImag) { + auto handlerTy = qop::HandlerType::get(b.getContext()); + auto scalarTy = qop::ScalarType::get(b.getContext()); + auto productTy = qop::ProductType::get(b.getContext()); + + // qop.spin + auto kindAttr = qop::symbolizeHandlerKind(spinKind); + if (!kindAttr) + return {}; + auto spin = + qop::SpinOp::create(b, loc, handlerTy, target, + qop::HandlerKindAttr::get(b.getContext(), *kindAttr)); + + // qop.const_scalar + auto scalar = + qop::ConstScalarOp::create(b, loc, scalarTy, b.getF64FloatAttr(coeffReal), + b.getF64FloatAttr(coeffImag)); + + // qop.make_product + auto product = + qop::MakeProductOp::create(b, loc, productTy, scalar, ValueRange{spin}); + + return product; +} + +static Value +buildStaticProduct(OpBuilder &b, Location loc, + ArrayRef> targetAndKinds, + double coefficient) { + auto handlerTy = qop::HandlerType::get(b.getContext()); + auto scalarTy = qop::ScalarType::get(b.getContext()); + auto productTy = qop::ProductType::get(b.getContext()); + SmallVector factors; + for (auto [target, kindName] : targetAndKinds) { + auto kind = qop::symbolizeHandlerKind(kindName); + if (!kind) + return {}; + factors.push_back( + qop::SpinOp::create(b, loc, handlerTy, target, + qop::HandlerKindAttr::get(b.getContext(), *kind))); + } + auto scalar = qop::ConstScalarOp::create( + b, loc, scalarTy, b.getF64FloatAttr(coefficient), b.getF64FloatAttr(0.0)); + return qop::MakeProductOp::create(b, loc, productTy, scalar, factors); +} + +// ---- Build a time-dependent drive term with callback ---- +static Value buildDriveTerm(OpBuilder &b, Location loc, Value target, + StringRef spinKind, StringRef callbackName, + const WaveformCallbackData &callbackData) { + auto handlerTy = qop::HandlerType::get(b.getContext()); + auto scalarTy = qop::ScalarType::get(b.getContext()); + auto productTy = qop::ProductType::get(b.getContext()); + + auto kindAttr = qop::symbolizeHandlerKind(spinKind); + if (!kindAttr) + return {}; + auto spin = + qop::SpinOp::create(b, loc, handlerTy, target, + qop::HandlerKindAttr::get(b.getContext(), *kindAttr)); + auto cbScalar = qop::CallbackScalarOp::create( + b, loc, scalarTy, FlatSymbolRefAttr::get(b.getContext(), callbackName)); + cbScalar->setAttr("cudm.callback_kind", + b.getI32IntegerAttr(callbackData.kind)); + cbScalar->setAttr("cudm.callback_params", + b.getDenseF64ArrayAttr(callbackData.parameters)); + auto product = + qop::MakeProductOp::create(b, loc, productTy, cbScalar, ValueRange{spin}); + return product; +} + +struct PulseToQOpPass + : public PassWrapper> { + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(PulseToQOpPass) + + StringRef getArgument() const final { return "pulse-to-qop"; } + StringRef getDescription() const final { + return "Lower pulse dialect ops to qop Hamiltonian/Lindblad construction"; + } + + void getDependentDialects(DialectRegistry ®istry) const override { + registry.insert(); + registry.insert(); + registry.insert(); + } + + void runOnOperation() override { + auto module = getOperation(); + OpBuilder b(module.getContext()); + + // Collect drive ops, qubit info, and dissipator metadata + SmallVector driveOps; + DenseMap qubitFreqHz; + Operation *unsupportedMeasurement = nullptr; + + if (auto frequencies = + module->getAttrOfType("pulse.qubit_freq_hz")) { + for (auto [index, frequency] : llvm::enumerate(frequencies.asArrayRef())) + qubitFreqHz[static_cast(index)] = frequency; + } + + module.walk([&](Operation *op) { + if (op->getName().getStringRef() == "pulse.get_drive_line") { + if (auto qubitAttr = op->getAttrOfType("qubit")) { + int64_t qi = qubitAttr.getInt(); + if (auto freqAttr = op->getAttrOfType("frequency_hz")) + qubitFreqHz[qi] = freqAttr.getValueAsDouble(); + } + } + if (op->getName().getStringRef() == "pulse.drive") + driveOps.push_back(op); + if (op->getName().getStringRef() == "pulse.readout" || + op->getName().getStringRef() == "pulse.iq_acquire") + unsupportedMeasurement = op; + }); + if (unsupportedMeasurement) { + unsupportedMeasurement->emitError( + "measurement is not supported by cuDensityMat evolution; remove " + "readout/acquisition operations before lowering"); + return signalPassFailure(); + } + + // Carry the scheduled pulse interval into the evolution lowering. Pulse + // time is expressed in virtual clock units; cuDensityMat callbacks and + // integrators use nanoseconds. + int64_t endVtu = 0; + module.walk([&](Operation *op) { + auto start = op->getAttrOfType("start_vtu"); + auto duration = op->getAttrOfType("duration_vtu"); + if (start && duration) + endVtu = std::max(endVtu, start.getInt() + duration.getInt()); + }); + double clockGHz = 1.0; + if (auto clock = module->getAttrOfType("pulse.clock_ghz")) + clockGHz = clock.getValueAsDouble(); + if (clockGHz <= 0.0) { + module.emitError("pulse.clock_ghz must be positive"); + return signalPassFailure(); + } + if (endVtu > 0) { + const double endNs = static_cast(endVtu) / clockGHz; + if (!module->hasAttr("qop.t_start")) + module->setAttr("qop.t_start", b.getF64FloatAttr(0.0)); + if (!module->hasAttr("qop.t_end")) + module->setAttr("qop.t_end", b.getF64FloatAttr(endNs)); + if (!module->hasAttr("qop.num_steps")) + module->setAttr("qop.num_steps", + b.getI64IntegerAttr(std::max(100, endVtu))); + if (!module->hasAttr("qop.integrator")) + module->setAttr("qop.integrator", b.getStringAttr("rk4")); + } + + // Find the func.func @main and insert QOp construction at the end + func::FuncOp mainFunc; + module.walk([&](func::FuncOp fn) { + if (fn.getName() == "main") + mainFunc = fn; + }); + if (!mainFunc) { + module.emitError("no @main function found"); + return signalPassFailure(); + } + + // Insert before the return op + auto &block = mainFunc.getBody().front(); + Operation *returnOp = block.getTerminator(); + b.setInsertionPoint(returnOp); + auto loc = returnOp->getLoc(); + + auto productTy = qop::ProductType::get(b.getContext()); + auto opTy = qop::OpType::get(b.getContext()); + auto superOpTy = qop::SuperOpType::get(b.getContext()); + + SmallVector allProducts; + + // Evolution uses one rotating frame per qubit. Bare qubit frequencies are + // therefore removed from H; tone detuning is encoded in each drive + // callback below. This avoids a numerically stiff multi-GHz lab-frame ODE. + auto appendInteractions = [&](StringRef pairAttrName, + StringRef strengthAttrName, + StringRef spinKind) -> LogicalResult { + auto pairs = module->getAttrOfType(pairAttrName); + auto strengths = + module->getAttrOfType(strengthAttrName); + if (!pairs && !strengths) + return success(); + if (!pairs || !strengths || pairs.size() != 2 * strengths.size()) { + module.emitError("interaction pair and strength metadata disagree for ") + << pairAttrName; + return failure(); + } + for (auto [index, strength] : llvm::enumerate(strengths.asArrayRef())) { + int64_t first = pairs[2 * index]; + int64_t second = pairs[2 * index + 1]; + if (first < 0 || second < 0 || !qubitFreqHz.count(first) || + !qubitFreqHz.count(second)) { + module.emitError("interaction references an inactive qubit in ") + << pairAttrName; + return failure(); + } + auto firstTarget = + arith::ConstantOp::create(b, loc, b.getI64IntegerAttr(first)); + auto secondTarget = + arith::ConstantOp::create(b, loc, b.getI64IntegerAttr(second)); + SmallVector> factors = { + {firstTarget, spinKind}, {secondTarget, spinKind}}; + auto term = + buildStaticProduct(b, loc, factors, strength * 2.0 * M_PI * 1.0e-9); + if (term) + allProducts.push_back(term); + } + return success(); + }; + if (failed(appendInteractions("pulse.coupling_pairs", + "pulse.coupling_strength_hz", "spin_x")) || + failed(appendInteractions("pulse.crosstalk_pairs", + "pulse.crosstalk_strength_hz", "spin_z"))) + return signalPassFailure(); + + // 2. Time-dependent drive terms + int driveIdx = 0; + for (auto *op : driveOps) { + std::string cbName = "drive_envelope_" + std::to_string(driveIdx); + auto qubitIdx = traceQubit(op->getOperand(0)); + if (!qubitIdx) { + op->emitError("cannot determine the physical qubit for this drive; " + "the line must originate from pulse.get_drive_line " + "with a 'qubit' attribute"); + return signalPassFailure(); + } + + auto target = + arith::ConstantOp::create(b, loc, b.getI64IntegerAttr(*qubitIdx)); + + auto qubitFrequency = qubitFreqHz.find(*qubitIdx); + if (qubitFrequency == qubitFreqHz.end()) { + op->emitError("cannot determine the physical qubit frequency for " + "rotating-frame lowering"); + return signalPassFailure(); + } + double driveScale = 1.0; + if (auto scales = module->getAttrOfType( + "pulse.drive_scale_rad_per_ns")) { + if (*qubitIdx < 0 || static_cast(*qubitIdx) >= scales.size() || + scales.asArrayRef()[*qubitIdx] <= 0.0) { + op->emitError("drive amplitude scale is missing or non-positive"); + return signalPassFailure(); + } + driveScale = scales.asArrayRef()[*qubitIdx]; + } + + auto callbackX = getWaveformCallbackData( + op, clockGHz, qubitFrequency->second, driveScale, 0); + auto callbackY = getWaveformCallbackData( + op, clockGHz, qubitFrequency->second, driveScale, 1); + if (!callbackX || !callbackY) { + op->emitError("waveform cannot be lowered to a cuDensityMat callback; " + "schedule the program, specialize all numeric values, " + "and use a built-in or custom_samples waveform"); + return signalPassFailure(); + } + + // X-component + auto termX = + buildDriveTerm(b, loc, target, "spin_x", cbName + "_x", *callbackX); + if (termX) + allProducts.push_back(termX); + + // Y-component (for DRAG or nonzero phase) + auto termY = + buildDriveTerm(b, loc, target, "spin_y", cbName + "_y", *callbackY); + if (termY) + allProducts.push_back(termY); + + driveIdx++; + } + + // 3. Assemble Hamiltonian: qop.make_sum + Value hamiltonian; + if (!allProducts.empty()) { + hamiltonian = qop::MakeSumOp::create(b, loc, opTy, allProducts); + } else { + // Trivial Hamiltonian: identity + auto target = arith::ConstantOp::create(b, loc, b.getI64IntegerAttr(0)); + auto term = buildStaticTerm(b, loc, target, "spin_i", 0.0, 0.0); + hamiltonian = qop::MakeSumOp::create(b, loc, opTy, ValueRange{term}); + } + + // 4. Dissipators from module attributes (T1, T2) + SmallVector collapseOps; + + auto t1AttrRaw = module->getAttrOfType("pulse.t1_times"); + if (!t1AttrRaw) + t1AttrRaw = module->getAttrOfType("t1_times"); + if (auto t1Attr = t1AttrRaw) { + for (int64_t qi = 0; qi < (int64_t)t1Attr.size(); qi++) { + double t1 = cast(t1Attr[qi]).getValueAsDouble(); + if (t1 > 0) { + double gamma = 1.0 / t1; + auto target = + arith::ConstantOp::create(b, loc, b.getI64IntegerAttr(qi)); + auto lowering = buildStaticTerm(b, loc, target, "spin_lowering", + std::sqrt(gamma), 0.0); + if (lowering) { + auto collapseOp = + qop::MakeSumOp::create(b, loc, opTy, ValueRange{lowering}); + collapseOps.push_back(collapseOp); + } + } + } + } + + auto t2AttrRaw = module->getAttrOfType("pulse.t2_times"); + if (!t2AttrRaw) + t2AttrRaw = module->getAttrOfType("t2_times"); + if (auto t2Attr = t2AttrRaw) { + for (int64_t qi = 0; qi < (int64_t)t2Attr.size(); qi++) { + double t2 = cast(t2Attr[qi]).getValueAsDouble(); + double t1 = 0.0; + auto t1a = module->getAttrOfType("pulse.t1_times"); + if (!t1a) + t1a = module->getAttrOfType("t1_times"); + if (t1a) + if (qi < (int64_t)t1a.size()) + t1 = cast(t1a[qi]).getValueAsDouble(); + double gammaPhi = 0.0; + if (t2 > 0) { + gammaPhi = 1.0 / t2; + if (t1 > 0) + gammaPhi -= 1.0 / (2.0 * t1); + if (gammaPhi < 0) + gammaPhi = 0; + } + if (gammaPhi > 0) { + auto target = + arith::ConstantOp::create(b, loc, b.getI64IntegerAttr(qi)); + auto dephase = buildStaticTerm(b, loc, target, "spin_z", + std::sqrt(gammaPhi / 2.0), 0.0); + if (dephase) { + auto collapseOp = + qop::MakeSumOp::create(b, loc, opTy, ValueRange{dephase}); + collapseOps.push_back(collapseOp); + } + } + } + } + + // 5. Construct Lindblad super-operator + qop::LindbladOp::create(b, loc, superOpTy, hamiltonian, collapseOps); + + // The QOp graph above is self-contained. Consume the source Pulse graph so + // the next conversion stage receives only QOp plus standard dialect ops. + SmallVector pulseOps; + module.walk([&](Operation *op) { + if (op->getName().getDialectNamespace() == "pulse") + pulseOps.push_back(op); + }); + for (auto iter = pulseOps.rbegin(); iter != pulseOps.rend(); ++iter) + (*iter)->erase(); + } +}; + +} // namespace + +namespace pulse { + +std::unique_ptr createPulseToQOpPass() { + return std::make_unique(); +} + +} // namespace pulse diff --git a/pulse/core/mlir/conversions/QOpToCuDensityMat/CMakeLists.txt b/pulse/core/mlir/conversions/QOpToCuDensityMat/CMakeLists.txt new file mode 100644 index 00000000000..37273e774a3 --- /dev/null +++ b/pulse/core/mlir/conversions/QOpToCuDensityMat/CMakeLists.txt @@ -0,0 +1,22 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_mlir_conversion_library(CudaqPulseQOpToCuDensityMat + QOpToCuDensityMat.cpp + + DEPENDS + CudaqPulseQOpIncGen + CudaqPulseCuDensityMatIncGen + + LINK_LIBS PUBLIC + CudaqPulseQOp + CudaqPulseCuDensityMat + MLIRArithDialect + MLIRFuncDialect + MLIRTransforms +) diff --git a/pulse/core/mlir/conversions/QOpToCuDensityMat/QOpToCuDensityMat.cpp b/pulse/core/mlir/conversions/QOpToCuDensityMat/QOpToCuDensityMat.cpp new file mode 100644 index 00000000000..7098718260b --- /dev/null +++ b/pulse/core/mlir/conversions/QOpToCuDensityMat/QOpToCuDensityMat.cpp @@ -0,0 +1,459 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// QOpToCuDensityMat conversion pass: lower qop dialect ops to cudm dialect ops +// for GPU-accelerated quantum state evolution. + +#include "mlir/Dialect/Arith/IR/Arith.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/IR/Builders.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/Pass/Pass.h" +#include "mlir/Transforms/DialectConversion.h" + +#include "mlir/Bytecode/BytecodeOpInterface.h" +#include "mlir/Interfaces/SideEffectInterfaces.h" + +#include "cudaq-pulse/Dialect/QOp/QOpDialect.h.inc" +#include "cudaq-pulse/Dialect/QOp/QOpEnums.h.inc" +#define GET_ATTRDEF_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpAttrs.h.inc" +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpOps.h.inc" + +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.h.inc" +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatEnums.h.inc" +#define GET_ATTRDEF_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatAttrs.h.inc" +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatOps.h.inc" + +using namespace mlir; + +namespace { + +struct QOpToCuDensityMatPass + : public PassWrapper> { + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(QOpToCuDensityMatPass) + + StringRef getArgument() const final { return "qop-to-cudm"; } + StringRef getDescription() const final { + return "Lower qop dialect Hamiltonian/Lindblad ops to cudm operator " + "construction and evolve"; + } + + void getDependentDialects(DialectRegistry ®istry) const override { + registry.insert(); + registry.insert(); + registry.insert(); + } + + void runOnOperation() override { + auto module = getOperation(); + OpBuilder b(module.getContext()); + + // Find @main + func::FuncOp mainFunc; + module.walk([&](func::FuncOp fn) { + if (fn.getName() == "main") + mainFunc = fn; + }); + if (!mainFunc) { + module.emitError("no @main function found"); + return signalPassFailure(); + } + + auto &block = mainFunc.getBody().front(); + Operation *returnOp = block.getTerminator(); + b.setInsertionPoint(returnOp); + auto loc = returnOp->getLoc(); + + // --- Determine n_qubits from module attributes or from qop.spin ops --- + int64_t nQubits = 1; + auto nqAttr = module->getAttrOfType("qop.n_qubits"); + if (!nqAttr) + nqAttr = module->getAttrOfType("n_qubits"); + if (nqAttr) + nQubits = nqAttr.getInt(); + else { + // Count unique qubit targets from qop.spin ops + DenseSet targets; + module.walk([&](qop::SpinOp spin) { + if (auto cst = spin.getTarget().getDefiningOp()) { + if (auto ia = dyn_cast(cst.getValue())) + targets.insert(ia.getInt()); + } + }); + if (!targets.empty()) + nQubits = *std::max_element(targets.begin(), targets.end()) + 1; + } + + SmallVector modeExtents(nQubits, 2); + + auto handleTy = cudm::HandleType::get(b.getContext()); + auto stateTy = cudm::StateType::get(b.getContext()); + auto wsTy = cudm::WorkspaceType::get(b.getContext()); + auto elemOpTy = cudm::ElementaryOpType::get(b.getContext()); + auto opTermTy = cudm::OpTermType::get(b.getContext()); + auto operatorTy = cudm::OperatorType::get(b.getContext()); + + // 1. cudm.init_handle + auto handle = cudm::InitHandleOp::create(b, loc, handleTy); + + SmallVector lindbladOps; + module.walk([&](qop::LindbladOp op) { lindbladOps.push_back(op); }); + if (lindbladOps.size() != 1) { + module.emitError("qop-to-cudm requires exactly one qop.lindblad op"); + return signalPassFailure(); + } + const bool hasDissipation = !lindbladOps.front().getCollapseOps().empty(); + + // 2. cudm.create_state (|0...0>). Open-system evolution requires a + // density matrix; unitary evolution can retain the cheaper state vector. + auto purityAttr = cudm::StatePurityAttr::get( + b.getContext(), + hasDissipation ? cudm::StatePurity::Mixed : cudm::StatePurity::Pure); + auto dtypeAttr = + cudm::ComputeTypeAttr::get(b.getContext(), cudm::ComputeType::F64); + auto modeExtentsAttr = b.getDenseI64ArrayAttr(modeExtents); + auto stateIn = cudm::CreateStateOp::create( + b, loc, stateTy, handle, purityAttr, dtypeAttr, modeExtentsAttr, + b.getI64IntegerAttr(0), b.getBoolAttr(false)); + auto stateOut = cudm::CreateStateOp::create( + b, loc, stateTy, handle, purityAttr, dtypeAttr, modeExtentsAttr, + b.getI64IntegerAttr(0), b.getBoolAttr(false)); + + // 3. cudm.create_workspace + auto workspace = cudm::CreateWorkspaceOp::create(b, loc, wsTy, handle); + + // 4. cudm.create_operator (the composite Hamiltonian operator) + auto compositeOp = cudm::CreateOperatorOp::create(b, loc, operatorTy, + handle, modeExtentsAttr); + + // 5. Walk qop.spin -> cudm.create_elementary_op for each Pauli leaf + DenseMap spinToCudmElem; + DenseMap spinToDaggerCudmElem; + SmallVector allElementaryOps; + module.walk([&](qop::SpinOp spin) { + b.setInsertionPoint(returnOp); + auto kind = spin.getKind(); + + // Map spin kind to 2x2 Pauli matrix data (dense, f64, real+imag + // interleaved) + SmallVector pauliData; + switch (kind) { + case qop::HandlerKind::SpinX: + pauliData = {0, 0, 1, 0, 1, 0, 0, 0}; // [[0,1],[1,0]] + break; + case qop::HandlerKind::SpinY: + // cuDensityMat uses column-major storage: [[0,-i],[i,0]]. + pauliData = {0, 0, 0, 1, 0, -1, 0, 0}; + break; + case qop::HandlerKind::SpinZ: + pauliData = {1, 0, 0, 0, 0, 0, -1, 0}; // [[1,0],[0,-1]] + break; + case qop::HandlerKind::SpinI: + pauliData = {1, 0, 0, 0, 0, 0, 1, 0}; // [[1,0],[0,1]] + break; + case qop::HandlerKind::SpinLowering: + pauliData = {0, 0, 0, 0, 1, 0, 0, 0}; // [[0,1],[0,0]] + break; + case qop::HandlerKind::SpinRaising: + pauliData = {0, 0, 1, 0, 0, 0, 0, 0}; // [[0,0],[1,0]] + break; + default: + spin.emitError("unsupported spin kind for cudm lowering"); + return; + } + + auto dataType = RankedTensorType::get({2, 2, 2}, b.getF64Type()); + auto dataAttr = DenseFPElementsAttr::get(dataType, pauliData); + auto tensorVal = arith::ConstantOp::create(b, loc, dataAttr); + + auto sparsityAttr = + cudm::SparsityAttr::get(b.getContext(), cudm::Sparsity::None); + SmallVector elemExtents = {2}; + + auto elemOp = cudm::CreateElementaryOpOp::create( + b, loc, elemOpTy, handle, tensorVal, sparsityAttr, dtypeAttr, + b.getDenseI64ArrayAttr(elemExtents), FlatSymbolRefAttr()); + + spinToCudmElem[spin.getOperation()] = elemOp; + allElementaryOps.push_back(elemOp); + + // Pauli operators are self-adjoint. Raising and lowering are each + // other's adjoints, so materialize the counterpart for C^dagger C in + // Lindblad anticommutator terms. + if (kind != qop::HandlerKind::SpinLowering && + kind != qop::HandlerKind::SpinRaising) { + spinToDaggerCudmElem[spin.getOperation()] = elemOp; + } else { + SmallVector daggerData; + if (kind == qop::HandlerKind::SpinLowering) + daggerData = {0, 0, 1, 0, 0, 0, 0, 0}; + else + daggerData = {0, 0, 0, 0, 1, 0, 0, 0}; + auto daggerAttr = DenseFPElementsAttr::get(dataType, daggerData); + auto daggerTensor = arith::ConstantOp::create(b, loc, daggerAttr); + auto daggerElem = cudm::CreateElementaryOpOp::create( + b, loc, elemOpTy, handle, daggerTensor, sparsityAttr, dtypeAttr, + b.getDenseI64ArrayAttr(elemExtents), FlatSymbolRefAttr()); + spinToDaggerCudmElem[spin.getOperation()] = daggerElem; + allElementaryOps.push_back(daggerElem); + } + }); + + // 6. Lower qop products to reusable cuDensityMat operator terms. + struct ProductData { + SmallVector factors; + SmallVector elementaryOps; + SmallVector modes; + double coefficientReal = 1.0; + double coefficientImag = 0.0; + bool hasCallback = false; + }; + DenseMap productData; + DenseMap productToCudmTerm; + SmallVector allOperatorTerms; + bool conversionFailed = false; + + auto createTerm = [&](ArrayRef elementaryOps, + ArrayRef modes, ArrayRef dualities, + double coefficientReal, double coefficientImag, + qop::CallbackScalarOp callback = {}) -> Value { + auto term = cudm::CreateOpTermOp::create(b, loc, opTermTy, handle, + modeExtentsAttr); + FlatSymbolRefAttr callbackAttr; + if (callback) + callbackAttr = callback.getCallbackAttr(); + auto append = cudm::AppendElementaryProductOp::create( + b, loc, handle, term, elementaryOps, b.getDenseI32ArrayAttr(modes), + b.getDenseI32ArrayAttr(dualities), b.getF64FloatAttr(coefficientReal), + b.getF64FloatAttr(coefficientImag), callbackAttr); + if (callback) { + if (auto kind = callback->getAttr("cudm.callback_kind")) + append->setAttr("cudm.callback_kind", kind); + if (auto parameters = callback->getAttr("cudm.callback_params")) + append->setAttr("cudm.callback_params", parameters); + } + allOperatorTerms.push_back(term); + return term; + }; + + module.walk([&](qop::MakeProductOp product) { + b.setInsertionPoint(returnOp); + ProductData data; + qop::CallbackScalarOp callback; + if (auto scalar = + product.getCoefficient().getDefiningOp()) { + data.coefficientReal = scalar.getReal().convertToDouble(); + data.coefficientImag = scalar.getImag().convertToDouble(); + } else if (auto scalar = product.getCoefficient() + .getDefiningOp()) { + callback = scalar; + data.hasCallback = true; + } else { + product.emitError("coefficient must be a qop constant or callback"); + conversionFailed = true; + return; + } + + for (Value factor : product.getFactors()) { + auto spin = factor.getDefiningOp(); + if (!spin) { + product.emitError("only qop.spin factors are currently supported"); + conversionFailed = true; + return; + } + auto elementary = spinToCudmElem.find(spin.getOperation()); + auto target = spin.getTarget().getDefiningOp(); + auto targetAttr = + target ? dyn_cast(target.getValue()) : IntegerAttr{}; + if (elementary == spinToCudmElem.end() || !targetAttr || + targetAttr.getInt() < 0 || targetAttr.getInt() >= nQubits) { + product.emitError("spin target must be a valid constant mode index"); + conversionFailed = true; + return; + } + data.factors.push_back(spin.getOperation()); + data.elementaryOps.push_back(elementary->second); + data.modes.push_back(static_cast(targetAttr.getInt())); + } + if (data.elementaryOps.empty()) { + product.emitError("operator product must contain at least one factor"); + conversionFailed = true; + return; + } + + SmallVector ketDualities(data.elementaryOps.size(), 0); + auto term = + createTerm(data.elementaryOps, data.modes, ketDualities, + data.coefficientReal, data.coefficientImag, callback); + productData[product.getOperation()] = std::move(data); + productToCudmTerm[product.getOperation()] = term; + }); + if (conversionFailed) + return signalPassFailure(); + + // 7. Build -i[H,rho]. A pure-state evolution only needs the ket action; + // a mixed-state evolution also needs the opposite-sign bra action. + auto hamiltonian = + lindbladOps.front().getHamiltonian().getDefiningOp(); + if (!hamiltonian) { + lindbladOps.front().emitError( + "Hamiltonian must be represented by qop.make_sum"); + return signalPassFailure(); + } + for (Value product : hamiltonian.getTerms()) { + auto term = productToCudmTerm.find(product.getDefiningOp()); + if (term == productToCudmTerm.end()) { + hamiltonian.emitError("contains an unsupported product term"); + return signalPassFailure(); + } + cudm::OperatorAppendTermOp::create( + b, loc, handle, compositeOp, term->second, b.getI32IntegerAttr(0), + b.getF64FloatAttr(0.0), b.getF64FloatAttr(-1.0), FlatSymbolRefAttr()); + if (hasDissipation) + cudm::OperatorAppendTermOp::create( + b, loc, handle, compositeOp, term->second, b.getI32IntegerAttr(1), + b.getF64FloatAttr(0.0), b.getF64FloatAttr(1.0), + FlatSymbolRefAttr()); + } + + // 8. Build D[C](rho) = C rho C^dagger + // - 1/2 {C^dagger C, rho}. + // Pulse-generated collapse operators contain one product each. Reject a + // sum here rather than silently dropping the required cross terms. + for (Value collapse : lindbladOps.front().getCollapseOps()) { + auto sum = collapse.getDefiningOp(); + if (!sum || sum.getTerms().size() != 1) { + lindbladOps.front().emitError( + "each collapse operator must contain exactly one product"); + return signalPassFailure(); + } + auto product = sum.getTerms().front().getDefiningOp(); + auto found = productData.find(product); + if (found == productData.end() || found->second.hasCallback) { + lindbladOps.front().emitError( + "collapse products must have constant coefficients"); + return signalPassFailure(); + } + const ProductData &data = found->second; + const double coefficientNorm = + data.coefficientReal * data.coefficientReal + + data.coefficientImag * data.coefficientImag; + + SmallVector jumpOps(data.elementaryOps.begin(), + data.elementaryOps.end()); + SmallVector jumpModes(data.modes.begin(), data.modes.end()); + // C rho C^dagger: the bra-side product is the reversed sequence of + // adjoint factors, not a second copy of C. This distinction is essential + // for non-Hermitian collapse operators such as sigma-minus. + for (auto [factor, mode] : + llvm::reverse(llvm::zip(data.factors, data.modes))) { + auto dagger = spinToDaggerCudmElem.find(factor); + if (dagger == spinToDaggerCudmElem.end()) { + lindbladOps.front().emitError( + "could not form the adjoint of a collapse factor"); + return signalPassFailure(); + } + jumpOps.push_back(dagger->second); + jumpModes.push_back(mode); + } + SmallVector jumpDualities(data.elementaryOps.size(), 0); + jumpDualities.append(data.elementaryOps.size(), 1); + auto jumpTerm = createTerm(jumpOps, jumpModes, jumpDualities, 1.0, 0.0); + cudm::OperatorAppendTermOp::create( + b, loc, handle, compositeOp, jumpTerm, b.getI32IntegerAttr(0), + b.getF64FloatAttr(coefficientNorm), b.getF64FloatAttr(0.0), + FlatSymbolRefAttr()); + + // cuDensityMat composes an elementary product in application order. Its + // documented C * C.dag() construction is therefore represented by the + // same factor sequence as the two-sided term, with every factor acting + // on the ket side. + SmallVector normDualities(jumpOps.size(), 0); + auto normTerm = createTerm(jumpOps, jumpModes, normDualities, 1.0, 0.0); + for (int32_t duality : {0, 1}) + cudm::OperatorAppendTermOp::create( + b, loc, handle, compositeOp, normTerm, b.getI32IntegerAttr(duality), + b.getF64FloatAttr(-0.5 * coefficientNorm), b.getF64FloatAttr(0.0), + FlatSymbolRefAttr()); + } + + // 9. cudm.evolve + double tStart = 0.0, tEnd = 100.0; + int64_t numSteps = 100; + auto tsAttr = module->getAttrOfType("qop.t_start"); + if (!tsAttr) + tsAttr = module->getAttrOfType("t_start"); + if (tsAttr) + tStart = tsAttr.getValueAsDouble(); + auto teAttr = module->getAttrOfType("qop.t_end"); + if (!teAttr) + teAttr = module->getAttrOfType("t_end"); + if (teAttr) + tEnd = teAttr.getValueAsDouble(); + auto nsAttr = module->getAttrOfType("qop.num_steps"); + if (!nsAttr) + nsAttr = module->getAttrOfType("num_steps"); + if (nsAttr) + numSteps = nsAttr.getInt(); + + auto integrator = cudm::IntegratorKind::RungeKutta4; + if (auto value = module->getAttrOfType("qop.integrator")) { + auto parsed = cudm::symbolizeIntegratorKind(value.getValue()); + if (!parsed) { + module.emitError("unsupported qop.integrator: ") << value.getValue(); + return signalPassFailure(); + } + integrator = *parsed; + } + auto integratorAttr = + cudm::IntegratorKindAttr::get(b.getContext(), integrator); + + cudm::EvolveOp::create( + b, loc, stateTy, handle, compositeOp, stateIn, stateOut, workspace, + integratorAttr, b.getF64FloatAttr(tStart), b.getF64FloatAttr(tEnd), + b.getI64IntegerAttr(numSteps), cudm::ComputeTypeAttr()); + + // 10. Cleanup (destroy in reverse order) + cudm::DestroyOperatorOp::create(b, loc, compositeOp); + for (Value term : llvm::reverse(allOperatorTerms)) + cudm::DestroyOpTermOp::create(b, loc, term); + for (Value elementaryOp : llvm::reverse(allElementaryOps)) + cudm::DestroyElementaryOpOp::create(b, loc, elementaryOp); + cudm::DestroyWorkspaceOp::create(b, loc, workspace); + cudm::DestroyStateOp::create(b, loc, stateOut); + cudm::DestroyStateOp::create(b, loc, stateIn); + cudm::DestroyHandleOp::create(b, loc, handle); + + // The cuDensityMat graph no longer depends on the source QOp graph. + SmallVector qopOps; + module.walk([&](Operation *op) { + if (op->getName().getDialectNamespace() == "qop") + qopOps.push_back(op); + }); + for (auto iter = qopOps.rbegin(); iter != qopOps.rend(); ++iter) + (*iter)->erase(); + } +}; + +} // namespace + +namespace qop { + +std::unique_ptr createQOpToCuDensityMatPass() { + return std::make_unique(); +} + +} // namespace qop diff --git a/pulse/core/mlir/dialects/CMakeLists.txt b/pulse/core/mlir/dialects/CMakeLists.txt new file mode 100644 index 00000000000..57f2aeedf46 --- /dev/null +++ b/pulse/core/mlir/dialects/CMakeLists.txt @@ -0,0 +1,11 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_subdirectory(Pulse) +add_subdirectory(QOp) +add_subdirectory(CuDensityMat) diff --git a/pulse/core/mlir/dialects/CuDensityMat/CMakeLists.txt b/pulse/core/mlir/dialects/CuDensityMat/CMakeLists.txt new file mode 100644 index 00000000000..aee1dcd7105 --- /dev/null +++ b/pulse/core/mlir/dialects/CuDensityMat/CMakeLists.txt @@ -0,0 +1,21 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_mlir_dialect_library(CudaqPulseCuDensityMat + CuDensityMatDialect.cpp + CuDensityMatOps.cpp + CuDensityMatTypes.cpp + + DEPENDS + CudaqPulseCuDensityMatIncGen + + LINK_LIBS PUBLIC + MLIRIR + MLIRSupport + MLIRBytecodeOpInterface +) diff --git a/pulse/core/mlir/dialects/CuDensityMat/CuDensityMatDialect.cpp b/pulse/core/mlir/dialects/CuDensityMat/CuDensityMatDialect.cpp new file mode 100644 index 00000000000..33de3a8ef6c --- /dev/null +++ b/pulse/core/mlir/dialects/CuDensityMat/CuDensityMatDialect.cpp @@ -0,0 +1,56 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +#include "llvm/ADT/TypeSwitch.h" +#include "mlir/Bytecode/BytecodeOpInterface.h" +#include "mlir/IR/Builders.h" +#include "mlir/IR/DialectImplementation.h" +#include "mlir/Interfaces/SideEffectInterfaces.h" + +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.h.inc" +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatEnums.h.inc" + +#define GET_ATTRDEF_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatAttrs.h.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatTypes.h.inc" + +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatOps.h.inc" + +using namespace mlir; + +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.cpp.inc" + +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatEnums.cpp.inc" + +#define GET_ATTRDEF_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatAttrs.cpp.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatTypes.cpp.inc" + +namespace cudm { + +void CuDensityMatDialect::initialize() { + addTypes< +#define GET_TYPEDEF_LIST +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatTypes.cpp.inc" + >(); + addAttributes< +#define GET_ATTRDEF_LIST +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatAttrs.cpp.inc" + >(); + addOperations< +#define GET_OP_LIST +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatOps.cpp.inc" + >(); +} + +} // namespace cudm diff --git a/pulse/core/mlir/dialects/CuDensityMat/CuDensityMatOps.cpp b/pulse/core/mlir/dialects/CuDensityMat/CuDensityMatOps.cpp new file mode 100644 index 00000000000..c915810361c --- /dev/null +++ b/pulse/core/mlir/dialects/CuDensityMat/CuDensityMatOps.cpp @@ -0,0 +1,107 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +#include "llvm/ADT/TypeSwitch.h" +#include "mlir/IR/Builders.h" +#include "mlir/IR/DialectImplementation.h" +#include "mlir/IR/OpImplementation.h" +#include "mlir/Interfaces/SideEffectInterfaces.h" + +#include "mlir/Bytecode/BytecodeOpInterface.h" + +// Declarations only — .cpp.inc definitions live in CuDensityMatDialect.cpp +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.h.inc" +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatEnums.h.inc" + +#define GET_ATTRDEF_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatAttrs.h.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatTypes.h.inc" + +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatOps.h.inc" + +using namespace mlir; + +namespace cudm { + +LogicalResult CreateStateOp::verify() { + auto extents = getModeExtents(); + if (extents.empty()) + return emitOpError("mode_extents must have at least one element"); + for (int64_t e : extents) { + if (e < 2) + return emitOpError("each mode extent must be >= 2, got ") << e; + } + return success(); +} + +LogicalResult CreateElementaryOpOp::verify() { + auto extents = getModeExtents(); + if (extents.empty()) + return emitOpError("mode_extents must have at least one element"); + for (int64_t e : extents) { + if (e < 2) + return emitOpError("each mode extent must be >= 2, got ") << e; + } + return success(); +} + +LogicalResult AppendElementaryProductOp::verify() { + if (getElemOps().empty()) + return emitOpError("at least one elementary operator must be provided"); + auto modes = getModesActedOn(); + auto duality = getDuality(); + if (modes.size() != getElemOps().size()) + return emitOpError("modes_acted_on size (") + << modes.size() << ") must match number of elementary operators (" + << getElemOps().size() << ")"; + if (duality.size() != getElemOps().size()) + return emitOpError("duality size (") + << duality.size() << ") must match number of elementary operators (" + << getElemOps().size() << ")"; + return success(); +} + +LogicalResult OperatorAppendTermOp::verify() { + int32_t d = getDuality(); + if (d != 0 && d != 1) + return emitOpError("duality must be 0 (bra) or 1 (ket), got ") << d; + return success(); +} + +LogicalResult OperatorPrepareActionOp::verify() { + if (getWorkspaceLimit() < 0) + return emitOpError("workspace_limit must be non-negative, got ") + << getWorkspaceLimit(); + return success(); +} + +LogicalResult OperatorComputeActionOp::verify() { return success(); } + +LogicalResult ExpectationComputeOp::verify() { return success(); } + +LogicalResult SSEEvolveOp::verify() { + if (getNumTrajectories() < 1) + return emitOpError("num_trajectories must be >= 1, got ") + << getNumTrajectories(); + if (getNumSteps() < 1) + return emitOpError("num_steps must be >= 1, got ") << getNumSteps(); + double tStart = getTStart().convertToDouble(); + double tEnd = getTEnd().convertToDouble(); + if (tEnd <= tStart) + return emitOpError("t_end (") + << tEnd << ") must be greater than t_start (" << tStart << ")"; + return success(); +} + +} // namespace cudm + +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatOps.cpp.inc" diff --git a/pulse/core/mlir/dialects/CuDensityMat/CuDensityMatTypes.cpp b/pulse/core/mlir/dialects/CuDensityMat/CuDensityMatTypes.cpp new file mode 100644 index 00000000000..ed6c9a88bac --- /dev/null +++ b/pulse/core/mlir/dialects/CuDensityMat/CuDensityMatTypes.cpp @@ -0,0 +1,9 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// Type registration is handled in CuDensityMatDialect.cpp. diff --git a/pulse/core/mlir/dialects/Pulse/CMakeLists.txt b/pulse/core/mlir/dialects/Pulse/CMakeLists.txt new file mode 100644 index 00000000000..b35e37614e6 --- /dev/null +++ b/pulse/core/mlir/dialects/Pulse/CMakeLists.txt @@ -0,0 +1,21 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_mlir_dialect_library(CudaqPulsePulse + PulseDialect.cpp + PulseOps.cpp + PulseTypes.cpp + + DEPENDS + CudaqPulsePulseIncGen + + LINK_LIBS PUBLIC + MLIRIR + MLIRSupport + MLIRBytecodeOpInterface +) diff --git a/pulse/core/mlir/dialects/Pulse/PulseDialect.cpp b/pulse/core/mlir/dialects/Pulse/PulseDialect.cpp new file mode 100644 index 00000000000..f3c28b07762 --- /dev/null +++ b/pulse/core/mlir/dialects/Pulse/PulseDialect.cpp @@ -0,0 +1,45 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +#include "llvm/ADT/TypeSwitch.h" +#include "mlir/Bytecode/BytecodeOpInterface.h" +#include "mlir/IR/DialectImplementation.h" +#include "mlir/Interfaces/SideEffectInterfaces.h" + +#include "cudaq-pulse/Dialect/Pulse/PulseDialect.h.inc" +#include "cudaq-pulse/Dialect/Pulse/PulseEnums.h.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseTypes.h.inc" + +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseOps.h.inc" + +using namespace mlir; + +#include "cudaq-pulse/Dialect/Pulse/PulseDialect.cpp.inc" + +#include "cudaq-pulse/Dialect/Pulse/PulseEnums.cpp.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseTypes.cpp.inc" + +namespace pulse { + +void PulseDialect::initialize() { + addTypes< +#define GET_TYPEDEF_LIST +#include "cudaq-pulse/Dialect/Pulse/PulseTypes.cpp.inc" + >(); + addOperations< +#define GET_OP_LIST +#include "cudaq-pulse/Dialect/Pulse/PulseOps.cpp.inc" + >(); +} + +} // namespace pulse diff --git a/pulse/core/mlir/dialects/Pulse/PulseOps.cpp b/pulse/core/mlir/dialects/Pulse/PulseOps.cpp new file mode 100644 index 00000000000..cd60ab06755 --- /dev/null +++ b/pulse/core/mlir/dialects/Pulse/PulseOps.cpp @@ -0,0 +1,294 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +#include "llvm/ADT/TypeSwitch.h" +#include "mlir/Bytecode/BytecodeOpInterface.h" +#include "mlir/Dialect/Arith/IR/Arith.h" +#include "mlir/IR/Builders.h" +#include "mlir/IR/DialectImplementation.h" +#include "mlir/IR/OpImplementation.h" +#include "mlir/IR/PatternMatch.h" +#include "mlir/Interfaces/ControlFlowInterfaces.h" +#include "mlir/Interfaces/SideEffectInterfaces.h" + +#include "cudaq-pulse/Dialect/Pulse/PulseDialect.h.inc" +#include "cudaq-pulse/Dialect/Pulse/PulseEnums.h.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseTypes.h.inc" + +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseOps.h.inc" + +using namespace mlir; + +namespace pulse { + +// ===----------------------------------------------------------------------===// +// SSA value helpers for tracing through arith.constant defining ops. +// Returns std::nullopt when the value is a block argument (parametric). +// ===----------------------------------------------------------------------===// + +static std::optional getConstantI64(Value v) { + if (auto cst = v.getDefiningOp()) + return cst.value(); + if (auto cst = v.getDefiningOp()) { + if (auto ia = dyn_cast(cst.getValue())) + return ia.getInt(); + } + return std::nullopt; +} + +static std::optional getConstantF64(Value v) { + if (auto cst = v.getDefiningOp()) + return cst.value().convertToDouble(); + if (auto cst = v.getDefiningOp()) { + if (auto fa = dyn_cast(cst.getValue())) + return fa.getValueAsDouble(); + } + return std::nullopt; +} + +// ===----------------------------------------------------------------------===// +// Verifiers — real semantic checks matching the Python verify pass logic. +// +// Values that trace to arith.constant are checked for constraints. +// Block arguments (parametric values) skip runtime checks — they are +// verified at evaluation time when concrete values are substituted. +// ===----------------------------------------------------------------------===// + +LogicalResult ToneOp::verify() { return success(); } + +LogicalResult SquarePulseOp::verify() { + if (auto dur = getConstantI64(getDuration())) { + if (*dur <= 0) + return emitOpError("duration must be positive, got ") << *dur; + } + return success(); +} + +LogicalResult GaussianPulseOp::verify() { + if (auto dur = getConstantI64(getDuration())) { + if (*dur <= 0) + return emitOpError("duration must be positive, got ") << *dur; + } + if (auto sig = getConstantF64(getSigma())) { + if (*sig <= 0.0) + return emitOpError("sigma must be positive"); + } + return success(); +} + +LogicalResult GaussianSquarePulseOp::verify() { + auto dur = getConstantI64(getDuration()); + auto rf = getConstantI64(getRisefall()); + if (dur && *dur <= 0) + return emitOpError("duration must be positive, got ") << *dur; + if (auto sig = getConstantF64(getSigma())) { + if (*sig <= 0.0) + return emitOpError("sigma must be positive"); + } + if (rf && *rf <= 0) + return emitOpError("risefall must be positive, got ") << *rf; + if (dur && rf && 2 * (*rf) > *dur) + return emitOpError("2*risefall (") + << 2 * (*rf) << ") exceeds duration (" << *dur << ")"; + return success(); +} + +LogicalResult DRAGPulseOp::verify() { + if (auto dur = getConstantI64(getDuration())) { + if (*dur <= 0) + return emitOpError("duration must be positive, got ") << *dur; + } + if (auto sig = getConstantF64(getSigma())) { + if (*sig <= 0.0) + return emitOpError("sigma must be positive"); + } + return success(); +} + +LogicalResult CosinePulseOp::verify() { + if (auto dur = getConstantI64(getDuration())) { + if (*dur <= 0) + return emitOpError("duration must be positive, got ") << *dur; + } + return success(); +} + +LogicalResult TanhRampOp::verify() { + if (auto dur = getConstantI64(getDuration())) { + if (*dur <= 0) + return emitOpError("duration must be positive, got ") << *dur; + } + if (auto sig = getConstantF64(getSigma())) { + if (*sig <= 0.0) + return emitOpError("sigma must be positive"); + } + return success(); +} + +LogicalResult CustomOp::verify() { + if (auto dur = getConstantI64(getDuration())) { + if (*dur <= 0) + return emitOpError("duration must be positive, got ") << *dur; + } + return success(); +} + +LogicalResult CustomSamplesOp::verify() { + if (getSamples().empty()) + return emitOpError("samples array must not be empty"); + return success(); +} + +LogicalResult WaitOp::verify() { + // Input and output line types must agree (drive stays drive, readout stays + // readout) — the type system already enforces AnyLineType but we verify + // the specific subtype matches. + if (getLine().getType() != getUpdatedLine().getType()) + return emitOpError("input and output line types must match"); + return success(); +} + +LogicalResult SyncOp::verify() { + // Sync semantics: align time of multiple lines. Two or more lines required. + if (getLines().size() < 2) + return emitOpError("sync requires at least 2 lines, got ") + << getLines().size(); + if (getLines().size() != getSyncedLines().size()) + return emitOpError("number of input lines (") + << getLines().size() << ") must match number of output lines (" + << getSyncedLines().size() << ")"; + return success(); +} + +LogicalResult PulseAddOp::verify() { return success(); } + +LogicalResult PulseSubOp::verify() { return success(); } + +LogicalResult PulseMulOp::verify() { return success(); } + +LogicalResult AtomicOp::verify() { + // The body must receive the same number of lines as produced + if (getLines().size() != getUpdatedLines().size()) + return emitOpError("number of input lines (") + << getLines().size() << ") must match number of output lines (" + << getUpdatedLines().size() << ")"; + return success(); +} + +LogicalResult YieldOp::verify() { return success(); } + +// ===----------------------------------------------------------------------===// +// Folders — fold away no-op operations (zero shift, scale by 1, double neg). +// +// These correspond to the Python canonicalize.py transforms: +// _idle_compression (WaitOp zero-dur), _redundant_sync_elim (SyncOp), +// and virtual_z.py transforms: +// consecutive shift_phase/set_phase merging. +// ===----------------------------------------------------------------------===// + +OpFoldResult WaitOp::fold(FoldAdaptor adaptor) { + // Cannot fold via adaptor since DurationType is a custom type. + // Zero-duration wait elimination handled by canonicalize pass. + return nullptr; +} + +// SyncOp has variadic results → multi-result fold signature +LogicalResult SyncOp::fold(FoldAdaptor adaptor, + SmallVectorImpl &results) { + // Python canonicalize.py:_redundant_sync_elim removes syncs where all + // lines already share the same time. That's a program-wide analysis; + // per-op we can't determine that. Return failure = no fold. + return failure(); +} + +OpFoldResult ShiftFrequencyOp::fold(FoldAdaptor adaptor) { + // Fold shift_frequency by 0 Hz → identity (return input tone) + if (auto freq = dyn_cast_or_null(adaptor.getFrequencyHz())) { + if (freq.getValueAsDouble() == 0.0) + return getTone(); + } + return nullptr; +} + +OpFoldResult ShiftPhaseOp::fold(FoldAdaptor adaptor) { + // Fold shift_phase by 0 rad → identity (virtual-Z of angle 0 is no-op) + if (auto phase = dyn_cast_or_null(adaptor.getPhaseRad())) { + if (phase.getValueAsDouble() == 0.0) + return getTone(); + } + return nullptr; +} + +OpFoldResult SetPhaseOp::fold(FoldAdaptor adaptor) { + // set_phase cannot be folded away in general (even setting to 0 is + // semantically meaningful — it resets the phase) + return nullptr; +} + +OpFoldResult SetFrequencyOp::fold(FoldAdaptor adaptor) { return nullptr; } + +OpFoldResult PulseAddOp::fold(FoldAdaptor adaptor) { return nullptr; } + +OpFoldResult PulseSubOp::fold(FoldAdaptor adaptor) { + // fold x - x → zero waveform (would need zero waveform constant) + return nullptr; +} + +OpFoldResult PulseMulOp::fold(FoldAdaptor adaptor) { return nullptr; } + +OpFoldResult PulseScaleOp::fold(FoldAdaptor adaptor) { + // Fold scale(pulse, 1.0) → pulse (identity) + if (auto scale = dyn_cast_or_null(adaptor.getScale())) { + if (scale.getValueAsDouble() == 1.0) + return getPulse(); + } + return nullptr; +} + +OpFoldResult PulseNegOp::fold(FoldAdaptor adaptor) { + // neg(neg(x)) → x + if (auto innerNeg = getPulse().getDefiningOp()) + return innerNeg.getPulse(); + return nullptr; +} + +// ===----------------------------------------------------------------------===// +// Canonicalization patterns +// +// The heavy lifting (idle compression, waveform CSE, virtual-Z folding, +// dead-line elimination, redundant-sync elimination) is performed by +// dedicated Python or MLIR passes. Op-level getCanonicalizationPatterns +// registers lightweight rewrite patterns that the generic MLIR canonicalizer +// pass picks up. +// ===----------------------------------------------------------------------===// + +void WaitOp::getCanonicalizationPatterns(RewritePatternSet &patterns, + MLIRContext *ctx) {} + +void ShiftFrequencyOp::getCanonicalizationPatterns(RewritePatternSet &patterns, + MLIRContext *ctx) {} + +void ShiftPhaseOp::getCanonicalizationPatterns(RewritePatternSet &patterns, + MLIRContext *ctx) {} + +void SetPhaseOp::getCanonicalizationPatterns(RewritePatternSet &patterns, + MLIRContext *ctx) {} + +void SetFrequencyOp::getCanonicalizationPatterns(RewritePatternSet &patterns, + MLIRContext *ctx) {} + +void PulseNegOp::getCanonicalizationPatterns(RewritePatternSet &patterns, + MLIRContext *ctx) {} + +} // namespace pulse + +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseOps.cpp.inc" diff --git a/pulse/core/mlir/dialects/Pulse/PulseTypes.cpp b/pulse/core/mlir/dialects/Pulse/PulseTypes.cpp new file mode 100644 index 00000000000..1fe19cf6469 --- /dev/null +++ b/pulse/core/mlir/dialects/Pulse/PulseTypes.cpp @@ -0,0 +1,9 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// Type registration is handled in PulseDialect.cpp. diff --git a/pulse/core/mlir/dialects/QOp/CMakeLists.txt b/pulse/core/mlir/dialects/QOp/CMakeLists.txt new file mode 100644 index 00000000000..0e40f3cbb19 --- /dev/null +++ b/pulse/core/mlir/dialects/QOp/CMakeLists.txt @@ -0,0 +1,21 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_mlir_dialect_library(CudaqPulseQOp + QOpDialect.cpp + QOpOps.cpp + QOpTypes.cpp + + DEPENDS + CudaqPulseQOpIncGen + + LINK_LIBS PUBLIC + MLIRIR + MLIRSupport + MLIRBytecodeOpInterface +) diff --git a/pulse/core/mlir/dialects/QOp/QOpDialect.cpp b/pulse/core/mlir/dialects/QOp/QOpDialect.cpp new file mode 100644 index 00000000000..2013e7ec18b --- /dev/null +++ b/pulse/core/mlir/dialects/QOp/QOpDialect.cpp @@ -0,0 +1,56 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +#include "llvm/ADT/TypeSwitch.h" +#include "mlir/Bytecode/BytecodeOpInterface.h" +#include "mlir/IR/Builders.h" +#include "mlir/IR/DialectImplementation.h" +#include "mlir/Interfaces/SideEffectInterfaces.h" + +#include "cudaq-pulse/Dialect/QOp/QOpDialect.h.inc" +#include "cudaq-pulse/Dialect/QOp/QOpEnums.h.inc" + +#define GET_ATTRDEF_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpAttrs.h.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpTypes.h.inc" + +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpOps.h.inc" + +using namespace mlir; + +#include "cudaq-pulse/Dialect/QOp/QOpDialect.cpp.inc" + +#include "cudaq-pulse/Dialect/QOp/QOpEnums.cpp.inc" + +#define GET_ATTRDEF_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpAttrs.cpp.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpTypes.cpp.inc" + +namespace qop { + +void QOpDialect::initialize() { + addTypes< +#define GET_TYPEDEF_LIST +#include "cudaq-pulse/Dialect/QOp/QOpTypes.cpp.inc" + >(); + addAttributes< +#define GET_ATTRDEF_LIST +#include "cudaq-pulse/Dialect/QOp/QOpAttrs.cpp.inc" + >(); + addOperations< +#define GET_OP_LIST +#include "cudaq-pulse/Dialect/QOp/QOpOps.cpp.inc" + >(); +} + +} // namespace qop diff --git a/pulse/core/mlir/dialects/QOp/QOpOps.cpp b/pulse/core/mlir/dialects/QOp/QOpOps.cpp new file mode 100644 index 00000000000..a2b7e9a6c00 --- /dev/null +++ b/pulse/core/mlir/dialects/QOp/QOpOps.cpp @@ -0,0 +1,98 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +#include "llvm/ADT/TypeSwitch.h" +#include "mlir/IR/Builders.h" +#include "mlir/IR/DialectImplementation.h" +#include "mlir/IR/OpImplementation.h" +#include "mlir/Interfaces/SideEffectInterfaces.h" + +#include "mlir/Bytecode/BytecodeOpInterface.h" + +// Declarations only — .cpp.inc definitions live in QOpDialect.cpp +#include "cudaq-pulse/Dialect/QOp/QOpDialect.h.inc" +#include "cudaq-pulse/Dialect/QOp/QOpEnums.h.inc" + +#define GET_ATTRDEF_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpAttrs.h.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpTypes.h.inc" + +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpOps.h.inc" + +using namespace mlir; + +namespace qop { + +LogicalResult SpinOp::verify() { + auto k = getKind(); + if (k != HandlerKind::SpinI && k != HandlerKind::SpinX && + k != HandlerKind::SpinY && k != HandlerKind::SpinZ && + k != HandlerKind::SpinLowering && k != HandlerKind::SpinRaising) + return emitOpError("invalid handler kind for spin operator"); + return success(); +} + +LogicalResult BosonOp::verify() { + auto k = getKind(); + if (k != HandlerKind::BosonIdentity && k != HandlerKind::BosonCreate && + k != HandlerKind::BosonAnnihilate && k != HandlerKind::BosonNumber) + return emitOpError("invalid handler kind for boson operator"); + if (getDimension() < 2) + return emitOpError("boson dimension must be >= 2, got ") << getDimension(); + return success(); +} + +LogicalResult FermionOp::verify() { + auto k = getKind(); + if (k != HandlerKind::FermionIdentity && k != HandlerKind::FermionCreate && + k != HandlerKind::FermionAnnihilate && k != HandlerKind::FermionNumber) + return emitOpError("invalid handler kind for fermion operator"); + return success(); +} + +LogicalResult MatrixLeafOp::verify() { + if (getTargets().empty()) + return emitOpError("matrix leaf must target at least one mode"); + if (getTargets().size() != getDimensions().size()) + return emitOpError("number of targets (") + << getTargets().size() << ") must match number of dimensions (" + << getDimensions().size() << ")"; + return success(); +} + +LogicalResult CallbackScalarOp::verify() { + if (getCallback().empty()) + return emitOpError("callback symbol name must not be empty"); + return success(); +} + +LogicalResult MakeSumOp::verify() { + if (getTerms().empty()) + return emitOpError("sum must have at least one product term"); + return success(); +} + +LogicalResult ToMatrixOp::verify() { + if (getDimensions().empty()) + return emitOpError("dimensions array must not be empty"); + for (int64_t d : getDimensions()) { + if (d < 2) + return emitOpError("each dimension must be >= 2, got ") << d; + } + return success(); +} + +LogicalResult DegreesOp::verify() { return success(); } + +} // namespace qop + +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpOps.cpp.inc" diff --git a/pulse/core/mlir/dialects/QOp/QOpTypes.cpp b/pulse/core/mlir/dialects/QOp/QOpTypes.cpp new file mode 100644 index 00000000000..b51cfbb1214 --- /dev/null +++ b/pulse/core/mlir/dialects/QOp/QOpTypes.cpp @@ -0,0 +1,9 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// Type registration is handled in QOpDialect.cpp. diff --git a/pulse/core/mlir/include/cudaq-pulse/CMakeLists.txt b/pulse/core/mlir/include/cudaq-pulse/CMakeLists.txt new file mode 100644 index 00000000000..d43bce8dd60 --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/CMakeLists.txt @@ -0,0 +1,11 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_subdirectory(Dialect/Pulse) +add_subdirectory(Dialect/QOp) +add_subdirectory(Dialect/CuDensityMat) diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CMakeLists.txt b/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CMakeLists.txt new file mode 100644 index 00000000000..01425fdda38 --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +set(LLVM_TARGET_DEFINITIONS CuDensityMatOps.td) +mlir_tablegen(CuDensityMatOps.h.inc -gen-op-decls) +mlir_tablegen(CuDensityMatOps.cpp.inc -gen-op-defs) +mlir_tablegen(CuDensityMatDialect.h.inc -gen-dialect-decls) +mlir_tablegen(CuDensityMatDialect.cpp.inc -gen-dialect-defs) + +set(LLVM_TARGET_DEFINITIONS CuDensityMatTypes.td) +mlir_tablegen(CuDensityMatTypes.h.inc -gen-typedef-decls) +mlir_tablegen(CuDensityMatTypes.cpp.inc -gen-typedef-defs) + +set(LLVM_TARGET_DEFINITIONS CuDensityMatAttrs.td) +mlir_tablegen(CuDensityMatAttrs.h.inc -gen-attrdef-decls) +mlir_tablegen(CuDensityMatAttrs.cpp.inc -gen-attrdef-defs) +mlir_tablegen(CuDensityMatEnums.h.inc -gen-enum-decls) +mlir_tablegen(CuDensityMatEnums.cpp.inc -gen-enum-defs) + +add_public_tablegen_target(CudaqPulseCuDensityMatIncGen) diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CuDensityMatAttrs.td b/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CuDensityMatAttrs.td new file mode 100644 index 00000000000..b4473f54891 --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CuDensityMatAttrs.td @@ -0,0 +1,107 @@ +/********************************************************** -*- tablegen -*- *** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *****************************************************************************/ + +#ifndef CUDAQ_PULSE_DIALECT_CUDENSITYMAT_ATTRS +#define CUDAQ_PULSE_DIALECT_CUDENSITYMAT_ATTRS + +include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.td" +include "mlir/IR/EnumAttr.td" + +def CUDM_StatePurity : I32EnumAttr<"StatePurity", + "Quantum state purity", + [ + I32EnumAttrCase<"Pure", 0, "pure">, + I32EnumAttrCase<"Mixed", 1, "mixed"> + ]> { + let cppNamespace = "::cudm"; + let genSpecializedAttr = 0; +} +def CUDM_StatePurityAttr : EnumAttr; + +def CUDM_ComputeType : I32EnumAttr<"ComputeType", + "Floating-point compute precision", + [ + I32EnumAttrCase<"F32", 4, "f32">, + I32EnumAttrCase<"F64", 16, "f64"> + ]> { + let cppNamespace = "::cudm"; + let genSpecializedAttr = 0; +} +def CUDM_ComputeTypeAttr : EnumAttr; + +def CUDM_Memspace : I32EnumAttr<"Memspace", + "Memory space for workspace allocation", + [ + I32EnumAttrCase<"Device", 0, "device">, + I32EnumAttrCase<"Host", 1, "host"> + ]> { + let cppNamespace = "::cudm"; + let genSpecializedAttr = 0; +} +def CUDM_MemspaceAttr : EnumAttr; + +def CUDM_WorkspaceKind : I32EnumAttr<"WorkspaceKind", + "Kind of workspace memory buffer", + [ + I32EnumAttrCase<"Scratch", 0, "scratch"> + ]> { + let cppNamespace = "::cudm"; + let genSpecializedAttr = 0; +} +def CUDM_WorkspaceKindAttr : EnumAttr; + +def CUDM_Sparsity : I32EnumAttr<"Sparsity", + "Elementary operator sparsity pattern", + [ + I32EnumAttrCase<"None", 0, "none">, + I32EnumAttrCase<"Multidiagonal", 1, "multidiagonal"> + ]> { + let cppNamespace = "::cudm"; + let genSpecializedAttr = 0; +} +def CUDM_SparsityAttr : EnumAttr; + +def CUDM_SpectrumKind : I32EnumAttr<"SpectrumKind", + "Which extreme eigenvalues to compute", + [ + I32EnumAttrCase<"Largest", 0, "largest">, + I32EnumAttrCase<"Smallest", 1, "smallest">, + I32EnumAttrCase<"LargestReal", 2, "largest_real">, + I32EnumAttrCase<"SmallestReal", 3, "smallest_real"> + ]> { + let cppNamespace = "::cudm"; + let genSpecializedAttr = 0; +} +def CUDM_SpectrumKindAttr : EnumAttr; + +def CUDM_DistProvider : I32EnumAttr<"DistributedProvider", + "Distributed communication provider", + [ + I32EnumAttrCase<"DistNone", 0, "none">, + I32EnumAttrCase<"MPI", 1, "mpi"> + ]> { + let cppNamespace = "::cudm"; + let genSpecializedAttr = 0; +} +def CUDM_DistProviderAttr : EnumAttr; + +def CUDM_IntegratorKind : I32EnumAttr<"IntegratorKind", + "Time-integration strategy", + [ + I32EnumAttrCase<"RungeKutta1", 2, "rk1">, + I32EnumAttrCase<"RungeKutta2", 3, "rk2">, + I32EnumAttrCase<"RungeKutta4", 4, "rk4">, + I32EnumAttrCase<"Magnus", 5, "magnus">, + I32EnumAttrCase<"CrankNicolson", 6, "crank_nicolson"> + ]> { + let cppNamespace = "::cudm"; + let genSpecializedAttr = 0; +} +def CUDM_IntegratorKindAttr : EnumAttr; + +#endif // CUDAQ_PULSE_DIALECT_CUDENSITYMAT_ATTRS diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.td b/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.td new file mode 100644 index 00000000000..53c628c5bd1 --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.td @@ -0,0 +1,36 @@ +/********************************************************** -*- tablegen -*- *** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *****************************************************************************/ + +#ifndef CUDAQ_PULSE_DIALECT_CUDENSITYMAT +#define CUDAQ_PULSE_DIALECT_CUDENSITYMAT + +include "mlir/IR/OpBase.td" + +def CuDensityMatDialect : Dialect { + let name = "cudm"; + let summary = "Dialect wrapping the NVIDIA cuDensityMat API for quantum dynamics"; + let description = [{ + The `cudm` dialect provides an MLIR representation of the NVIDIA cuDensityMat + library API for GPU-accelerated quantum dynamics simulation. It serves as + the lowering target for the `qop` dialect and the source for call + generation targeting the cuDensityMat runtime API. + + Two tiers of operations: + - **Tier 1 (API ops)**: 1:1 wrappers around `cudensitymat*` C API functions + - **Tier 2 (Solver ops)**: Higher-level time-integration, SSE, and + decoherence ops that lower to Tier 1 via `cudm-expand-integration` + }]; + let cppNamespace = "::cudm"; + let useDefaultTypePrinterParser = 1; + let useDefaultAttributePrinterParser = 1; + let extraClassDeclaration = [{ + void registerTypes(); + }]; +} + +#endif // CUDAQ_PULSE_DIALECT_CUDENSITYMAT diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CuDensityMatOps.td b/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CuDensityMatOps.td new file mode 100644 index 00000000000..5f66586c23a --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CuDensityMatOps.td @@ -0,0 +1,263 @@ +/********************************************************** -*- tablegen -*- *** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *****************************************************************************/ + +#ifndef CUDAQ_PULSE_DIALECT_CUDENSITYMAT_OPS +#define CUDAQ_PULSE_DIALECT_CUDENSITYMAT_OPS + +include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.td" +include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatTypes.td" +include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatAttrs.td" +include "mlir/Interfaces/SideEffectInterfaces.td" +include "mlir/IR/OpBase.td" + +class CuDensityMatOp traits = []> : + Op; + +// ---- Context Management ---- + +def CUDM_InitHandleOp : CuDensityMatOp<"init_handle", [MemoryEffects<[MemWrite]>]> { + let summary = "Create cuDensityMat library context"; + let results = (outs CUDM_HandleType:$handle); + let assemblyFormat = "attr-dict `:` type($handle)"; +} + +def CUDM_DestroyHandleOp : CuDensityMatOp<"destroy_handle", [MemoryEffects<[MemWrite]>]> { + let summary = "Destroy cuDensityMat library context"; + let arguments = (ins CUDM_HandleType:$handle); + let assemblyFormat = "$handle attr-dict `:` type($handle)"; +} + +// ---- Workspace Management ---- + +def CUDM_CreateWorkspaceOp : CuDensityMatOp<"create_workspace", [MemoryEffects<[MemWrite]>]> { + let summary = "Create a workspace descriptor"; + let arguments = (ins CUDM_HandleType:$handle); + let results = (outs CUDM_WorkspaceType:$workspace); + let assemblyFormat = "$handle attr-dict `:` functional-type($handle, $workspace)"; +} + +def CUDM_DestroyWorkspaceOp : CuDensityMatOp<"destroy_workspace", [MemoryEffects<[MemWrite]>]> { + let summary = "Destroy a workspace descriptor"; + let arguments = (ins CUDM_WorkspaceType:$workspace); + let assemblyFormat = "$workspace attr-dict `:` type($workspace)"; +} + +// ---- Quantum State ---- + +def CUDM_CreateStateOp : CuDensityMatOp<"create_state", [MemoryEffects<[MemWrite]>]> { + let summary = "Define an empty quantum state of given purity and shape"; + let arguments = (ins + CUDM_HandleType:$handle, + CUDM_StatePurityAttr:$purity, + CUDM_ComputeTypeAttr:$data_type, + DenseI64ArrayAttr:$mode_extents, + DefaultValuedAttr:$batch_size, + DefaultValuedAttr:$distributed + ); + let results = (outs CUDM_StateType:$state); + let assemblyFormat = "$handle attr-dict `:` functional-type($handle, $state)"; + let hasVerifier = 1; +} + +def CUDM_DestroyStateOp : CuDensityMatOp<"destroy_state", [MemoryEffects<[MemWrite]>]> { + let summary = "Destroy a quantum state"; + let arguments = (ins CUDM_StateType:$state); + let assemblyFormat = "$state attr-dict `:` type($state)"; +} + +// ---- Elementary Operator ---- + +def CUDM_CreateElementaryOpOp + : CuDensityMatOp<"create_elementary_op", [MemoryEffects<[MemWrite]>]> { + let summary = "Create an elementary tensor operator"; + let arguments = (ins + CUDM_HandleType:$handle, + AnyType:$tensor_data, + CUDM_SparsityAttr:$sparsity, + CUDM_ComputeTypeAttr:$data_type, + DenseI64ArrayAttr:$mode_extents, + OptionalAttr:$callback + ); + let results = (outs CUDM_ElementaryOpType:$elem_op); + let assemblyFormat = "$handle `,` $tensor_data attr-dict `:` type($handle) `,` type($tensor_data) `->` type($elem_op)"; + let hasVerifier = 1; +} + +def CUDM_DestroyElementaryOpOp + : CuDensityMatOp<"destroy_elementary_op", [MemoryEffects<[MemWrite]>]> { + let summary = "Destroy an elementary tensor operator"; + let arguments = (ins CUDM_ElementaryOpType:$elem_op); + let assemblyFormat = "$elem_op attr-dict `:` type($elem_op)"; +} + +// ---- Operator Term ---- + +def CUDM_CreateOpTermOp : CuDensityMatOp<"create_op_term", [MemoryEffects<[MemWrite]>]> { + let summary = "Create an empty operator term"; + let arguments = (ins CUDM_HandleType:$handle, DenseI64ArrayAttr:$mode_extents); + let results = (outs CUDM_OpTermType:$op_term); + let assemblyFormat = "$handle attr-dict `:` functional-type($handle, $op_term)"; +} + +def CUDM_AppendElementaryProductOp + : CuDensityMatOp<"append_elementary_product", [MemoryEffects<[MemWrite]>]> { + let summary = "Append a product of elementary operators to a term"; + let arguments = (ins + CUDM_HandleType:$handle, + CUDM_OpTermType:$op_term, + Variadic:$elem_ops, + DenseI32ArrayAttr:$modes_acted_on, + DenseI32ArrayAttr:$duality, + F64Attr:$coeff_real, + F64Attr:$coeff_imag, + OptionalAttr:$callback + ); + let assemblyFormat = "$handle `,` $op_term `,` $elem_ops attr-dict `:` type($handle) `,` type($op_term) `,` type($elem_ops)"; + let hasVerifier = 1; +} + +def CUDM_DestroyOpTermOp : CuDensityMatOp<"destroy_op_term", [MemoryEffects<[MemWrite]>]> { + let summary = "Destroy an operator term"; + let arguments = (ins CUDM_OpTermType:$op_term); + let assemblyFormat = "$op_term attr-dict `:` type($op_term)"; +} + +// ---- Composite Operator ---- + +def CUDM_CreateOperatorOp : CuDensityMatOp<"create_operator", [MemoryEffects<[MemWrite]>]> { + let summary = "Create an empty composite operator (super-operator)"; + let arguments = (ins CUDM_HandleType:$handle, DenseI64ArrayAttr:$mode_extents); + let results = (outs CUDM_OperatorType:$op); + let assemblyFormat = "$handle attr-dict `:` functional-type($handle, $op)"; +} + +def CUDM_OperatorAppendTermOp + : CuDensityMatOp<"operator_append_term", [MemoryEffects<[MemWrite]>]> { + let summary = "Append an operator term to a composite operator"; + let arguments = (ins + CUDM_HandleType:$handle, + CUDM_OperatorType:$op, + CUDM_OpTermType:$term, + I32Attr:$duality, + F64Attr:$coeff_real, + F64Attr:$coeff_imag, + OptionalAttr:$callback + ); + let assemblyFormat = "$handle `,` $op `,` $term attr-dict `:` type($handle) `,` type($op) `,` type($term)"; + let hasVerifier = 1; +} + +def CUDM_DestroyOperatorOp : CuDensityMatOp<"destroy_operator", [MemoryEffects<[MemWrite]>]> { + let summary = "Destroy a composite operator"; + let arguments = (ins CUDM_OperatorType:$op); + let assemblyFormat = "$op attr-dict `:` type($op)"; +} + +// ---- Operator Action ---- + +def CUDM_OperatorPrepareActionOp + : CuDensityMatOp<"operator_prepare_action", [MemoryEffects<[MemWrite]>]> { + let summary = "Prepare operator for action on quantum state"; + let arguments = (ins + CUDM_HandleType:$handle, CUDM_OperatorType:$op, + CUDM_StateType:$state_in, CUDM_StateType:$state_out, + CUDM_ComputeTypeAttr:$compute_type, + I64Attr:$workspace_limit, + CUDM_WorkspaceType:$workspace + ); + let assemblyFormat = "$handle `,` $op `,` $state_in `,` $state_out `,` $workspace attr-dict `:` type($handle) `,` type($op) `,` type($state_in) `,` type($state_out) `,` type($workspace)"; + let hasVerifier = 1; +} + +def CUDM_OperatorComputeActionOp + : CuDensityMatOp<"operator_compute_action", [MemoryEffects<[MemWrite]>]> { + let summary = "Compute operator action: state_out += Op(t, params) * state_in"; + let arguments = (ins + CUDM_HandleType:$handle, CUDM_OperatorType:$op, + F64:$time, I64:$batch_size, I32:$num_params, AnyType:$params, + CUDM_StateType:$state_in, CUDM_StateType:$state_out, + CUDM_WorkspaceType:$workspace + ); + let assemblyFormat = "$handle `,` $op `,` $time `,` $batch_size `,` $num_params `,` $params `,` $state_in `,` $state_out `,` $workspace attr-dict `:` type($handle) `,` type($op) `,` type($params) `,` type($state_in) `,` type($state_out) `,` type($workspace)"; + let hasVerifier = 1; +} + +// ---- Expectation ---- + +def CUDM_CreateExpectationOp + : CuDensityMatOp<"create_expectation", [MemoryEffects<[MemWrite]>]> { + let summary = "Create expectation value computation object"; + let arguments = (ins CUDM_HandleType:$handle, CUDM_OperatorType:$op); + let results = (outs CUDM_ExpectationType:$expectation); + let assemblyFormat = "$handle `,` $op attr-dict `:` type($handle) `,` type($op) `->` type($expectation)"; +} + +def CUDM_ExpectationComputeOp + : CuDensityMatOp<"expectation_compute", [MemoryEffects<[MemWrite]>]> { + let summary = "Compute operator expectation value(s)"; + let arguments = (ins + CUDM_HandleType:$handle, CUDM_ExpectationType:$expectation, + F64:$time, I64:$batch_size, I32:$num_params, AnyType:$params, + CUDM_StateType:$state, CUDM_WorkspaceType:$workspace + ); + let results = (outs AnyType:$value); + let assemblyFormat = "$handle `,` $expectation `,` $time `,` $batch_size `,` $num_params `,` $params `,` $state `,` $workspace attr-dict `:` type($handle) `,` type($expectation) `,` type($params) `,` type($state) `,` type($workspace) `->` type($value)"; + let hasVerifier = 1; +} + +def CUDM_DestroyExpectationOp + : CuDensityMatOp<"destroy_expectation", [MemoryEffects<[MemWrite]>]> { + let summary = "Destroy expectation computation object"; + let arguments = (ins CUDM_ExpectationType:$expectation); + let assemblyFormat = "$expectation attr-dict `:` type($expectation)"; +} + +// ---- Tier 2: Integration / Solver ops ---- + +def CUDM_EvolveOp : CuDensityMatOp<"evolve", [MemoryEffects<[MemWrite]>]> { + let summary = "Time-evolve a quantum state under an operator"; + let arguments = (ins + CUDM_HandleType:$handle, CUDM_OperatorType:$op, + CUDM_StateType:$state_in, CUDM_StateType:$state_out, + CUDM_WorkspaceType:$workspace, + CUDM_IntegratorKindAttr:$integrator, + F64Attr:$t_start, F64Attr:$t_end, I64Attr:$num_steps, + OptionalAttr:$compute_type + ); + let results = (outs CUDM_StateType:$result); + let assemblyFormat = "$handle `,` $op `,` $state_in `,` $state_out `,` $workspace attr-dict `:` type($handle) `,` type($op) `,` type($state_in) `,` type($state_out) `,` type($workspace) `->` type($result)"; +} + +def CUDM_ApplyDecoherenceOp : CuDensityMatOp<"apply_decoherence", [MemoryEffects<[MemWrite]>]> { + let summary = "Apply Lindblad T1/T2 decoherence for one time step"; + let arguments = (ins + CUDM_HandleType:$handle, CUDM_StateType:$state, + CUDM_DecoherenceModelType:$model, F64:$dt + ); + let assemblyFormat = "$handle `,` $state `,` $model `,` $dt attr-dict `:` type($handle) `,` type($state) `,` type($model)"; +} + +def CUDM_SSEEvolveOp : CuDensityMatOp<"sse_evolve", [MemoryEffects<[MemWrite]>]> { + let summary = "Stochastic Schrodinger equation ensemble evolution"; + let arguments = (ins + CUDM_HandleType:$handle, + CUDM_OperatorType:$hamiltonian, + Variadic:$collapse_ops, + CUDM_StateType:$initial_state, + CUDM_WorkspaceType:$workspace, + CUDM_IntegratorKindAttr:$integrator, + I64Attr:$num_trajectories, + F64Attr:$t_start, F64Attr:$t_end, I64Attr:$num_steps, + I64Attr:$seed + ); + let results = (outs CUDM_TrajectoryResultType:$result); + let assemblyFormat = "$handle `,` $hamiltonian `,` `(` $collapse_ops `)` `,` $initial_state `,` $workspace attr-dict `:` type($handle) `,` type($hamiltonian) `,` `(` type($collapse_ops) `)` `,` type($initial_state) `,` type($workspace) `->` type($result)"; + let hasVerifier = 1; +} + +#endif // CUDAQ_PULSE_DIALECT_CUDENSITYMAT_OPS diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CuDensityMatTypes.td b/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CuDensityMatTypes.td new file mode 100644 index 00000000000..a2c551f85aa --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/CuDensityMatTypes.td @@ -0,0 +1,73 @@ +/********************************************************** -*- tablegen -*- *** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *****************************************************************************/ + +#ifndef CUDAQ_PULSE_DIALECT_CUDENSITYMAT_TYPES +#define CUDAQ_PULSE_DIALECT_CUDENSITYMAT_TYPES + +include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.td" +include "mlir/IR/AttrTypeBase.td" + +class CuDensityMatType traits = [], + string baseCppClass = "mlir::Type"> + : TypeDef { + let mnemonic = typeMnemonic; + let genStorageClass = 0; +} + +def CUDM_HandleType : CuDensityMatType<"Handle", "handle"> { + let summary = "cuDensityMat library context handle"; +} + +def CUDM_WorkspaceType : CuDensityMatType<"Workspace", "workspace"> { + let summary = "Workspace buffer descriptor"; +} + +def CUDM_StateType : CuDensityMatType<"State", "state"> { + let summary = "Quantum state (pure or mixed, optionally batched)"; +} + +def CUDM_ElementaryOpType : CuDensityMatType<"ElementaryOp", "elementary_op"> { + let summary = "Elementary tensor operator acting on specific modes"; +} + +def CUDM_MatrixOpType : CuDensityMatType<"MatrixOp", "matrix_op"> { + let summary = "Full matrix operator acting on all modes"; +} + +def CUDM_OpTermType : CuDensityMatType<"OpTerm", "op_term"> { + let summary = "Operator term: sum of products of elementary/matrix operators"; +} + +def CUDM_OperatorType : CuDensityMatType<"Operator", "operator"> { + let summary = "Composite operator / super-operator"; +} + +def CUDM_OpActionType : CuDensityMatType<"OpAction", "op_action"> { + let summary = "Aggregate operator action for coupled ODE systems"; +} + +def CUDM_ExpectationType : CuDensityMatType<"Expectation", "expectation"> { + let summary = "Expectation value computation handle"; +} + +def CUDM_SpectrumType : CuDensityMatType<"Spectrum", "spectrum"> { + let summary = "Operator eigenspectrum computation handle"; +} + +def CUDM_DecoherenceModelType + : CuDensityMatType<"DecoherenceModel", "decoherence_model"> { + let summary = "T1/T2 decoherence parameters per qubit mode"; +} + +def CUDM_TrajectoryResultType + : CuDensityMatType<"TrajectoryResult", "trajectory_result"> { + let summary = "Ensemble results from the SSE quantum trajectory solver"; +} + +#endif // CUDAQ_PULSE_DIALECT_CUDENSITYMAT_TYPES diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/Pulse/CMakeLists.txt b/pulse/core/mlir/include/cudaq-pulse/Dialect/Pulse/CMakeLists.txt new file mode 100644 index 00000000000..7436089370b --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/Pulse/CMakeLists.txt @@ -0,0 +1,21 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +set(LLVM_TARGET_DEFINITIONS PulseOps.td) +mlir_tablegen(PulseOps.h.inc -gen-op-decls) +mlir_tablegen(PulseOps.cpp.inc -gen-op-defs) +mlir_tablegen(PulseDialect.h.inc -gen-dialect-decls) +mlir_tablegen(PulseDialect.cpp.inc -gen-dialect-defs) + +set(LLVM_TARGET_DEFINITIONS PulseTypes.td) +mlir_tablegen(PulseTypes.h.inc -gen-typedef-decls) +mlir_tablegen(PulseTypes.cpp.inc -gen-typedef-defs) +mlir_tablegen(PulseEnums.h.inc -gen-enum-decls) +mlir_tablegen(PulseEnums.cpp.inc -gen-enum-defs) + +add_public_tablegen_target(CudaqPulsePulseIncGen) diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/Pulse/PulseDialect.td b/pulse/core/mlir/include/cudaq-pulse/Dialect/Pulse/PulseDialect.td new file mode 100644 index 00000000000..40b9fc4cccf --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/Pulse/PulseDialect.td @@ -0,0 +1,48 @@ +/********************************************************** -*- tablegen -*- *** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *****************************************************************************/ + +#ifndef CUDAQ_PULSE_DIALECT_PULSE +#define CUDAQ_PULSE_DIALECT_PULSE + +include "mlir/IR/OpBase.td" + +def PulseDialect : Dialect { + let name = "pulse"; + let summary = "Linear-typed MLIR dialect for pulse-level quantum control"; + let description = [{ + The `pulse` dialect provides explicit timing control and pulse-level + operations on quantum hardware with compile-time verification of + drive-operation exclusivity, monotone time, and tone-as-linear-resource + semantics. + + Key concepts: + - **Lines**: Drive and readout lines connected to qudits (linear resources) + - **Tones**: Carrier signals with frequency and phase (linear resources) + - **Waveforms**: Pulse envelopes (value types, freely copyable) + - **Timing**: Current time tracked per line, explicit waits and syncs + - **Waveform algebra**: Element-wise add/sub/mul/scale/neg on envelopes + + Example: + ```mlir + %q0 = pulse.qudit_alloc : !pulse.qref + %d0, %tone_q0 = pulse.get_drive_line %q0 + : (!pulse.qref) -> (!pulse.drive_line, !pulse.tone) + %wf = pulse.gaussian 40, 0.3, 10.0 : !pulse.waveform + %d0_out, %tone_out = pulse.drive %d0, %wf, %tone_q0 + : !pulse.drive_line, !pulse.waveform, !pulse.tone + -> !pulse.drive_line, !pulse.tone + ``` + }]; + let cppNamespace = "::pulse"; + let useDefaultTypePrinterParser = 1; + let extraClassDeclaration = [{ + void registerTypes(); + }]; +} + +#endif // CUDAQ_PULSE_DIALECT_PULSE diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/Pulse/PulseOps.td b/pulse/core/mlir/include/cudaq-pulse/Dialect/Pulse/PulseOps.td new file mode 100644 index 00000000000..c26766e2eb8 --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/Pulse/PulseOps.td @@ -0,0 +1,449 @@ +/********************************************************** -*- tablegen -*- *** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *****************************************************************************/ + +#ifndef CUDAQ_PULSE_DIALECT_PULSE_OPS +#define CUDAQ_PULSE_DIALECT_PULSE_OPS + +include "cudaq-pulse/Dialect/Pulse/PulseDialect.td" +include "cudaq-pulse/Dialect/Pulse/PulseTypes.td" +include "mlir/Interfaces/SideEffectInterfaces.td" +include "mlir/Interfaces/ControlFlowInterfaces.td" +include "mlir/IR/OpBase.td" + +class PulseOp traits = []> : + Op; + +//===----------------------------------------------------------------------===// +// Qudit allocation +//===----------------------------------------------------------------------===// + +def QuditAllocOp : PulseOp<"qudit_alloc", [Pure]> { + let summary = "Allocate a pulse-level qudit reference"; + let results = (outs QrefType:$qref); + let assemblyFormat = "attr-dict `:` type($qref)"; +} + +def QvecAllocOp : PulseOp<"qvec_alloc", [Pure]> { + let summary = "Allocate a vector of pulse-level qudit references"; + let arguments = (ins I64Attr:$size); + let results = (outs Variadic:$qrefs); + let assemblyFormat = "$size attr-dict `:` type($qrefs)"; +} + +//===----------------------------------------------------------------------===// +// Line operations +//===----------------------------------------------------------------------===// + +def GetDriveLineOp : PulseOp<"get_drive_line", [Pure]> { + let summary = "Get the drive line and tone for a qudit"; + let description = [{ + Returns the drive line and native tone associated with a qudit. + The returned drive line starts at time t=0. + }]; + let arguments = (ins QrefType:$qudit); + let results = (outs DriveLineType:$line, ToneType:$tone); + let assemblyFormat = "$qudit attr-dict `:` functional-type(operands, results)"; +} + +def GetReadoutLineOp : PulseOp<"get_readout_line", [Pure]> { + let summary = "Get the readout line and tone for a qudit"; + let arguments = (ins QrefType:$qudit); + let results = (outs ReadoutLineType:$line, ToneType:$tone); + let assemblyFormat = "$qudit attr-dict `:` functional-type(operands, results)"; +} + +//===----------------------------------------------------------------------===// +// Tone operations +//===----------------------------------------------------------------------===// + +def ToneOp : PulseOp<"tone", [Pure]> { + let summary = "Create a pulse tone with explicit frequency and phase"; + let arguments = (ins + AnyFloat:$frequency_hz, + AnyFloat:$phase_rad + ); + let results = (outs ToneType:$tone); + let assemblyFormat = "$frequency_hz `,` $phase_rad attr-dict `:` qualified(type($frequency_hz)) `,` qualified(type($phase_rad)) `->` qualified(type($tone))"; + let hasVerifier = 1; +} + +//===----------------------------------------------------------------------===// +// Waveform construction +//===----------------------------------------------------------------------===// + +def SquarePulseOp : PulseOp<"square", [Pure]> { + let summary = "Create a constant-amplitude (square) pulse"; + let arguments = (ins + I64:$duration, + AnyFloat:$amp_real, + AnyFloat:$amp_imag + ); + let results = (outs WaveformType:$pulse); + let assemblyFormat = "$duration `,` $amp_real `,` $amp_imag attr-dict `:` type($duration) `,` type($amp_real) `,` type($amp_imag) `->` type($pulse)"; + let hasVerifier = 1; +} + +def GaussianPulseOp : PulseOp<"gaussian", [Pure]> { + let summary = "Create a Gaussian pulse"; + let arguments = (ins + I64:$duration, + AnyFloat:$amplitude, + AnyFloat:$sigma + ); + let results = (outs WaveformType:$pulse); + let assemblyFormat = "$duration `,` $amplitude `,` $sigma attr-dict `:` type($duration) `,` type($amplitude) `,` type($sigma) `->` type($pulse)"; + let hasVerifier = 1; +} + +def GaussianSquarePulseOp : PulseOp<"gaussian_square", [Pure]> { + let summary = "Create a flat-topped Gaussian pulse"; + let description = [{ + A Gaussian rising edge, a flat top at the given amplitude, and a + Gaussian falling edge. `risefall` is the duration of each edge in VTU. + }]; + let arguments = (ins + I64:$duration, + AnyFloat:$amplitude, + AnyFloat:$sigma, + I64:$risefall + ); + let results = (outs WaveformType:$pulse); + let assemblyFormat = "$duration `,` $amplitude `,` $sigma `,` $risefall attr-dict `:` type($duration) `,` type($amplitude) `,` type($sigma) `,` type($risefall) `->` type($pulse)"; + let hasVerifier = 1; +} + +def DRAGPulseOp : PulseOp<"drag", [Pure]> { + let summary = "Create a DRAG pulse for reducing leakage"; + let arguments = (ins + I64:$duration, + AnyFloat:$amplitude, + AnyFloat:$sigma, + AnyFloat:$beta + ); + let results = (outs WaveformType:$pulse); + let assemblyFormat = "$duration `,` $amplitude `,` $sigma `,` $beta attr-dict `:` type($duration) `,` type($amplitude) `,` type($sigma) `,` type($beta) `->` type($pulse)"; + let hasVerifier = 1; +} + +def CosinePulseOp : PulseOp<"cosine", [Pure]> { + let summary = "Create a raised-cosine envelope pulse"; + let arguments = (ins + I64:$duration, + AnyFloat:$amplitude + ); + let results = (outs WaveformType:$pulse); + let assemblyFormat = "$duration `,` $amplitude attr-dict `:` type($duration) `,` type($amplitude) `->` type($pulse)"; + let hasVerifier = 1; +} + +def TanhRampOp : PulseOp<"tanh_ramp", [Pure]> { + let summary = "Create a hyperbolic-tangent ramp pulse"; + let description = [{ + Smooth ramp from 0 to `amplitude` using tanh shaping. `sigma` controls + the steepness of the ramp. + }]; + let arguments = (ins + I64:$duration, + AnyFloat:$amplitude, + AnyFloat:$sigma + ); + let results = (outs WaveformType:$pulse); + let assemblyFormat = "$duration `,` $amplitude `,` $sigma attr-dict `:` type($duration) `,` type($amplitude) `,` type($sigma) `->` type($pulse)"; + let hasVerifier = 1; +} + +def CustomOp : PulseOp<"custom", [Pure]> { + let summary = "Create a custom waveform via callback function"; + let arguments = (ins + FlatSymbolRefAttr:$callee, + I64:$duration + ); + let results = (outs WaveformType:$pulse); + let assemblyFormat = "$callee `,` $duration attr-dict `:` type($duration) `->` type($pulse)"; + let hasVerifier = 1; +} + +def CustomSamplesOp : PulseOp<"custom_samples", [Pure]> { + let summary = "Create a waveform from raw sample data"; + let arguments = (ins + F64ArrayAttr:$samples + ); + let results = (outs WaveformType:$pulse); + let assemblyFormat = "$samples attr-dict `:` type($pulse)"; + let hasVerifier = 1; +} + +//===----------------------------------------------------------------------===// +// Pulse application operations +//===----------------------------------------------------------------------===// + +def DriveOp : PulseOp<"drive", []> { + let summary = "Apply a waveform to a drive line at a tone"; + let description = [{ + Applies a waveform to a drive line at the specified tone's frequency and + phase. Advances line time by pulse duration. Returns the updated drive + line and tone. + }]; + let arguments = (ins + DriveLineType:$line, + WaveformType:$pulse, + ToneType:$tone + ); + let results = (outs DriveLineType:$updated_line, ToneType:$updated_tone); + let assemblyFormat = [{ + $line `,` $pulse `,` $tone attr-dict `:` qualified(type($line)) + `,` qualified(type($pulse)) `,` qualified(type($tone)) + `->` qualified(type($updated_line)) `,` qualified(type($updated_tone)) + }]; +} + +def ReadoutOp : PulseOp<"readout", []> { + let summary = "Apply a readout pulse and produce a measurement result"; + let arguments = (ins + ReadoutLineType:$line, + WaveformType:$pulse, + ToneType:$tone, + StrAttr:$mode + ); + let results = (outs + ReadoutLineType:$updated_line, + ToneType:$updated_tone, + MeasurementType:$result + ); + let assemblyFormat = [{ + $line `,` $pulse `,` $tone `,` $mode attr-dict `:` qualified(type($line)) + `,` qualified(type($pulse)) `,` qualified(type($tone)) + `->` qualified(type($updated_line)) `,` qualified(type($updated_tone)) + `,` qualified(type($result)) + }]; +} + +//===----------------------------------------------------------------------===// +// Timing operations +//===----------------------------------------------------------------------===// + +def WaitOp : PulseOp<"wait", []> { + let summary = "Idle a line for a specified duration"; + let arguments = (ins + AnyLineType:$line, + DurationType:$duration + ); + let results = (outs AnyLineType:$updated_line); + let assemblyFormat = "$line `,` $duration attr-dict `:` functional-type(operands, results)"; + let hasVerifier = 1; + let hasFolder = 1; + let hasCanonicalizer = 1; +} + +def SyncOp : PulseOp<"sync", []> { + let summary = "Synchronize multiple lines to the same time"; + let description = [{ + Aligns time state of multiple lines. All returned lines will have + time = max(T1, ..., Tk). + }]; + let arguments = (ins Variadic:$lines); + let results = (outs Variadic:$synced_lines); + let assemblyFormat = "$lines attr-dict `:` qualified(type($lines)) `->` qualified(type($synced_lines))"; + let hasVerifier = 1; + let hasFolder = 1; +} + +def GetCurrentTimeOp : PulseOp<"get_current_time", [Pure]> { + let summary = "Get the current time of a line as a timepoint"; + let arguments = (ins AnyLineType:$line); + let results = (outs TimepointType:$time); + let assemblyFormat = "$line attr-dict `:` qualified(type($line)) `->` qualified(type($time))"; +} + +def TimepointToIntOp : PulseOp<"timepoint_to_int", [Pure]> { + let summary = "Convert a timepoint to integer cycles"; + let arguments = (ins TimepointType:$time); + let results = (outs I64:$time_in_cycles); + let assemblyFormat = "$time attr-dict `:` qualified(type($time)) `->` qualified(type($time_in_cycles))"; +} + +def TimepointSubtractOp : PulseOp<"timepoint_subtract", [Pure]> { + let summary = "Compute the duration between two timepoints"; + let arguments = (ins TimepointType:$lhs, TimepointType:$rhs); + let results = (outs DurationType:$duration); + let assemblyFormat = "$lhs `,` $rhs attr-dict `:` functional-type(operands, results)"; +} + +def DurationFromIntOp : PulseOp<"duration_from_int", [Pure]> { + let summary = "Convert an integer to a duration"; + let arguments = (ins I64:$cycles); + let results = (outs DurationType:$duration); + let assemblyFormat = "$cycles attr-dict `:` functional-type(operands, results)"; +} + +def DurationToIntOp : PulseOp<"duration_to_int", [Pure]> { + let summary = "Convert a duration to an integer"; + let arguments = (ins DurationType:$duration); + let results = (outs I64:$cycles); + let assemblyFormat = "$duration attr-dict `:` functional-type(operands, results)"; +} + +//===----------------------------------------------------------------------===// +// Tone modulation operations (zero-duration, instantaneous) +//===----------------------------------------------------------------------===// + +def ShiftFrequencyOp : PulseOp<"shift_frequency", []> { + let summary = "Apply a relative frequency shift to a tone"; + let arguments = (ins ToneType:$tone, F64:$frequency_hz); + let results = (outs ToneType:$updated_tone); + let assemblyFormat = "$tone `,` $frequency_hz attr-dict `:` qualified(type($tone)) `,` type($frequency_hz) `->` qualified(type($updated_tone))"; + let hasFolder = 1; + let hasCanonicalizer = 1; +} + +def ShiftPhaseOp : PulseOp<"shift_phase", []> { + let summary = "Apply a relative phase shift to a tone (virtual Z gate)"; + let arguments = (ins ToneType:$tone, F64:$phase_rad); + let results = (outs ToneType:$updated_tone); + let assemblyFormat = "$tone `,` $phase_rad attr-dict `:` qualified(type($tone)) `,` type($phase_rad) `->` qualified(type($updated_tone))"; + let hasFolder = 1; + let hasCanonicalizer = 1; +} + +def SetPhaseOp : PulseOp<"set_phase", []> { + let summary = "Set absolute phase of a tone"; + let arguments = (ins ToneType:$tone, F64:$phase_rad); + let results = (outs ToneType:$updated_tone); + let assemblyFormat = "$tone `,` $phase_rad attr-dict `:` qualified(type($tone)) `,` type($phase_rad) `->` qualified(type($updated_tone))"; + let hasFolder = 1; + let hasCanonicalizer = 1; +} + +def SetFrequencyOp : PulseOp<"set_frequency", []> { + let summary = "Set absolute frequency of a tone"; + let arguments = (ins ToneType:$tone, F64:$frequency_hz); + let results = (outs ToneType:$updated_tone); + let assemblyFormat = "$tone `,` $frequency_hz attr-dict `:` qualified(type($tone)) `,` type($frequency_hz) `->` qualified(type($updated_tone))"; + let hasFolder = 1; + let hasCanonicalizer = 1; +} + +//===----------------------------------------------------------------------===// +// Waveform algebra +//===----------------------------------------------------------------------===// + +def PulseAddOp : PulseOp<"add", [Pure, Commutative]> { + let summary = "Add two waveforms element-wise"; + let arguments = (ins WaveformType:$lhs, WaveformType:$rhs); + let results = (outs WaveformType:$result); + let assemblyFormat = "$lhs `,` $rhs attr-dict `:` qualified(type($result))"; + let hasFolder = 1; + let hasVerifier = 1; +} + +def PulseSubOp : PulseOp<"sub", [Pure]> { + let summary = "Subtract two waveforms element-wise"; + let arguments = (ins WaveformType:$lhs, WaveformType:$rhs); + let results = (outs WaveformType:$result); + let assemblyFormat = "$lhs `,` $rhs attr-dict `:` qualified(type($result))"; + let hasFolder = 1; + let hasVerifier = 1; +} + +def PulseMulOp : PulseOp<"mul", [Pure, Commutative]> { + let summary = "Multiply two waveforms element-wise"; + let arguments = (ins WaveformType:$lhs, WaveformType:$rhs); + let results = (outs WaveformType:$result); + let assemblyFormat = "$lhs `,` $rhs attr-dict `:` qualified(type($result))"; + let hasFolder = 1; + let hasVerifier = 1; +} + +def PulseScaleOp : PulseOp<"scale", [Pure]> { + let summary = "Scale a waveform by a scalar factor"; + let arguments = (ins + WaveformType:$pulse, + AnyTypeOf<[AnyFloat, AnyComplex]>:$scale + ); + let results = (outs WaveformType:$result); + let assemblyFormat = "$pulse `,` $scale attr-dict `:` qualified(type($pulse)) `,` type($scale) `->` qualified(type($result))"; + let hasFolder = 1; +} + +def PulseNegOp : PulseOp<"neg", [Pure]> { + let summary = "Negate a waveform"; + let arguments = (ins WaveformType:$pulse); + let results = (outs WaveformType:$result); + let assemblyFormat = "$pulse attr-dict `:` qualified(type($result))"; + let hasFolder = 1; + let hasCanonicalizer = 1; +} + +//===----------------------------------------------------------------------===// +// Schedule and atomic region +//===----------------------------------------------------------------------===// + +def AtomicOp : PulseOp<"atomic", [RecursiveMemoryEffects, NoTerminator]> { + let summary = "Atomic pulse scheduling region"; + let arguments = (ins Variadic:$lines); + let results = (outs Variadic:$updated_lines); + let regions = (region SizedRegion<1>:$body); + let assemblyFormat = "$lines attr-dict `:` functional-type($lines, $updated_lines) $body"; + let hasVerifier = 1; +} + +def YieldOp : PulseOp<"yield", [Pure, Terminator]> { + let summary = "Yield from a pulse region"; + let arguments = (ins Variadic:$lines); + let assemblyFormat = "attr-dict (`(` $lines^ `:` type($lines) `)`)?"; + let hasVerifier = 1; +} + +//===----------------------------------------------------------------------===// +// IQ operations +//===----------------------------------------------------------------------===// + +def IQAcquireOp : PulseOp<"iq_acquire", []> { + let summary = "Acquire raw IQ data from a readout line"; + let arguments = (ins + ReadoutLineType:$line, + WaveformType:$pulse, + ToneType:$tone + ); + let results = (outs ReadoutLineType:$updated_line, IQDataType:$iq_data); + let assemblyFormat = [{ + $line `,` $pulse `,` $tone attr-dict `:` qualified(type($line)) + `,` qualified(type($pulse)) `,` qualified(type($tone)) + `->` qualified(type($updated_line)) `,` qualified(type($iq_data)) + }]; +} + +def ExtractIQOp : PulseOp<"extract_iq", [Pure]> { + let summary = "Extract I and Q components from IQ data"; + let arguments = (ins IQDataType:$iq_data); + let results = (outs F64:$i_value, F64:$q_value); + let assemblyFormat = "$iq_data attr-dict `:` qualified(type($iq_data)) `->` type($i_value) `,` type($q_value)"; +} + +def CreateIQOp : PulseOp<"create_iq", [Pure]> { + let summary = "Create IQ data from I and Q values"; + let arguments = (ins F64:$i_value, F64:$q_value); + let results = (outs IQDataType:$iq_data); + let assemblyFormat = "$i_value `,` $q_value attr-dict `:` type($i_value) `,` type($q_value) `->` qualified(type($iq_data))"; +} + +def IQMagnitudeOp : PulseOp<"iq_magnitude", [Pure]> { + let summary = "Compute magnitude of IQ data: sqrt(I^2 + Q^2)"; + let arguments = (ins IQDataType:$iq_data); + let results = (outs F64:$magnitude); + let assemblyFormat = "$iq_data attr-dict `:` qualified(type($iq_data)) `->` type($magnitude)"; +} + +def IQPhaseOp : PulseOp<"iq_phase", [Pure]> { + let summary = "Compute phase of IQ data: atan2(Q, I)"; + let arguments = (ins IQDataType:$iq_data); + let results = (outs F64:$phase); + let assemblyFormat = "$iq_data attr-dict `:` qualified(type($iq_data)) `->` type($phase)"; +} + +#endif // CUDAQ_PULSE_DIALECT_PULSE_OPS diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/Pulse/PulseTypes.td b/pulse/core/mlir/include/cudaq-pulse/Dialect/Pulse/PulseTypes.td new file mode 100644 index 00000000000..1cf0a4a02d1 --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/Pulse/PulseTypes.td @@ -0,0 +1,126 @@ +/********************************************************** -*- tablegen -*- *** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *****************************************************************************/ + +#ifndef CUDAQ_PULSE_DIALECT_PULSE_TYPES +#define CUDAQ_PULSE_DIALECT_PULSE_TYPES + +include "cudaq-pulse/Dialect/Pulse/PulseDialect.td" +include "mlir/IR/AttrTypeBase.td" +include "mlir/IR/EnumAttr.td" + +class PulseType traits = [], + string baseCppClass = "mlir::Type"> + : TypeDef { + let mnemonic = typeMnemonic; +} + +//===----------------------------------------------------------------------===// +// Qudit reference (opaque, default dim=2, future-proofed for d>2) +//===----------------------------------------------------------------------===// + +def QrefType : PulseType<"Qref", "qref"> { + let summary = "Opaque pulse-level qudit reference (dim defaults to 2)"; +} + +//===----------------------------------------------------------------------===// +// Measurement result +//===----------------------------------------------------------------------===// + +def MeasurementType : PulseType<"Measurement", "measurement"> { + let summary = "Classical bit produced by a readout operation"; +} + +//===----------------------------------------------------------------------===// +// Drive and readout lines (linear resources) +//===----------------------------------------------------------------------===// + +def DriveLineType : PulseType<"DriveLine", "drive_line"> { + let summary = "Physical drive channel (linear resource)"; + let description = [{ + A `drive_line` represents a physical drive channel. It is a linear resource: + each value is produced exactly once and consumed exactly once. Operations + that consume a drive line produce a new value with advanced time state. + }]; +} + +def ReadoutLineType : PulseType<"ReadoutLine", "readout_line"> { + let summary = "Physical readout channel (linear resource)"; + let description = [{ + A `readout_line` represents a physical readout channel. Like drive lines, + readout lines are linear resources with time state. + }]; +} + +//===----------------------------------------------------------------------===// +// Tone and timing types +//===----------------------------------------------------------------------===// + +def ToneType : PulseType<"Tone", "tone"> { + let summary = "Carrier signal tone (frequency and phase, linear resource)"; + let description = [{ + A `tone` represents a carrier tone tracking frequency and phase. Tones are + separate from physical lines, enabling cross-resonance and + position-independent calibrations. + }]; +} + +def TimepointType : PulseType<"Timepoint", "timepoint"> { + let summary = "Abstract point in time on a line"; +} + +def DurationType : PulseType<"Duration", "duration"> { + let summary = "Time interval in virtual time units (VTU)"; +} + +//===----------------------------------------------------------------------===// +// Waveform (value type, not linear) +//===----------------------------------------------------------------------===// + +def WaveformType : PulseType<"Waveform", "waveform"> { + let summary = "Pulse envelope shape (value type, not linear)"; + let description = [{ + A `waveform` represents a time-domain pulse envelope. Waveforms are value + types and can be freely passed around and applied to multiple lines. + }]; +} + +//===----------------------------------------------------------------------===// +// IQ data +//===----------------------------------------------------------------------===// + +def IQDataType : PulseType<"IQData", "iq_data"> { + let summary = "Raw IQ signal data from readout (value type)"; +} + +//===----------------------------------------------------------------------===// +// Waveform enum attribute +//===----------------------------------------------------------------------===// + +def WaveformAttr : I32EnumAttr<"WaveformKind", "Type of pulse waveform", + [ + I32EnumAttrCase<"Square", 0, "square">, + I32EnumAttrCase<"Gaussian", 1, "gaussian">, + I32EnumAttrCase<"GaussianSquare", 2, "gaussian_square">, + I32EnumAttrCase<"DRAG", 3, "drag">, + I32EnumAttrCase<"Cosine", 4, "cosine">, + I32EnumAttrCase<"TanhRamp", 5, "tanh_ramp">, + I32EnumAttrCase<"Custom", 6, "custom"> + ]> { + let cppNamespace = "::pulse"; +} + +//===----------------------------------------------------------------------===// +// Composite type predicates +//===----------------------------------------------------------------------===// + +def AnyLineType : Type< + Or<[DriveLineType.predicate, ReadoutLineType.predicate]>, + "any pulse line type" +>; + +#endif // CUDAQ_PULSE_DIALECT_PULSE_TYPES diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/CMakeLists.txt b/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/CMakeLists.txt new file mode 100644 index 00000000000..fd337c6284b --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/CMakeLists.txt @@ -0,0 +1,25 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +set(LLVM_TARGET_DEFINITIONS QOpOps.td) +mlir_tablegen(QOpOps.h.inc -gen-op-decls) +mlir_tablegen(QOpOps.cpp.inc -gen-op-defs) +mlir_tablegen(QOpDialect.h.inc -gen-dialect-decls) +mlir_tablegen(QOpDialect.cpp.inc -gen-dialect-defs) + +set(LLVM_TARGET_DEFINITIONS QOpTypes.td) +mlir_tablegen(QOpTypes.h.inc -gen-typedef-decls) +mlir_tablegen(QOpTypes.cpp.inc -gen-typedef-defs) + +set(LLVM_TARGET_DEFINITIONS QOpAttrs.td) +mlir_tablegen(QOpAttrs.h.inc -gen-attrdef-decls) +mlir_tablegen(QOpAttrs.cpp.inc -gen-attrdef-defs) +mlir_tablegen(QOpEnums.h.inc -gen-enum-decls) +mlir_tablegen(QOpEnums.cpp.inc -gen-enum-defs) + +add_public_tablegen_target(CudaqPulseQOpIncGen) diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/QOpAttrs.td b/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/QOpAttrs.td new file mode 100644 index 00000000000..cc7943c32c4 --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/QOpAttrs.td @@ -0,0 +1,49 @@ +/********************************************************** -*- tablegen -*- *** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *****************************************************************************/ + +#ifndef CUDAQ_PULSE_DIALECT_QOP_ATTRS +#define CUDAQ_PULSE_DIALECT_QOP_ATTRS + +include "cudaq-pulse/Dialect/QOp/QOpDialect.td" +include "mlir/IR/EnumAttr.td" + +def QOP_HandlerKind_SpinI : I32EnumAttrCase<"SpinI", 0, "spin_i">; +def QOP_HandlerKind_SpinX : I32EnumAttrCase<"SpinX", 1, "spin_x">; +def QOP_HandlerKind_SpinY : I32EnumAttrCase<"SpinY", 2, "spin_y">; +def QOP_HandlerKind_SpinZ : I32EnumAttrCase<"SpinZ", 3, "spin_z">; +def QOP_HandlerKind_BosonIdentity : I32EnumAttrCase<"BosonIdentity", 10, "boson_i">; +def QOP_HandlerKind_BosonCreate : I32EnumAttrCase<"BosonCreate", 11, "boson_create">; +def QOP_HandlerKind_BosonAnnihilate : I32EnumAttrCase<"BosonAnnihilate", 12, "boson_annihilate">; +def QOP_HandlerKind_BosonNumber : I32EnumAttrCase<"BosonNumber", 13, "boson_number">; +def QOP_HandlerKind_FermionIdentity : I32EnumAttrCase<"FermionIdentity", 20, "fermion_i">; +def QOP_HandlerKind_FermionCreate : I32EnumAttrCase<"FermionCreate", 21, "fermion_create">; +def QOP_HandlerKind_FermionAnnihilate: I32EnumAttrCase<"FermionAnnihilate", 22, "fermion_annihilate">; +def QOP_HandlerKind_FermionNumber : I32EnumAttrCase<"FermionNumber", 23, "fermion_number">; +def QOP_HandlerKind_SpinLowering : I32EnumAttrCase<"SpinLowering", 4, "spin_lowering">; +def QOP_HandlerKind_SpinRaising : I32EnumAttrCase<"SpinRaising", 5, "spin_raising">; +def QOP_HandlerKind_Matrix : I32EnumAttrCase<"Matrix", 30, "matrix">; + +def QOP_HandlerKind : I32EnumAttr<"HandlerKind", + "Kind of leaf quantum operator", + [ + QOP_HandlerKind_SpinI, QOP_HandlerKind_SpinX, + QOP_HandlerKind_SpinY, QOP_HandlerKind_SpinZ, + QOP_HandlerKind_SpinLowering, QOP_HandlerKind_SpinRaising, + QOP_HandlerKind_BosonIdentity, QOP_HandlerKind_BosonCreate, + QOP_HandlerKind_BosonAnnihilate, QOP_HandlerKind_BosonNumber, + QOP_HandlerKind_FermionIdentity, QOP_HandlerKind_FermionCreate, + QOP_HandlerKind_FermionAnnihilate, QOP_HandlerKind_FermionNumber, + QOP_HandlerKind_Matrix + ]> { + let cppNamespace = "::qop"; + let genSpecializedAttr = 0; +} + +def QOP_HandlerKindAttr : EnumAttr; + +#endif // CUDAQ_PULSE_DIALECT_QOP_ATTRS diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/QOpDialect.td b/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/QOpDialect.td new file mode 100644 index 00000000000..0a46e972226 --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/QOpDialect.td @@ -0,0 +1,35 @@ +/********************************************************** -*- tablegen -*- *** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *****************************************************************************/ + +#ifndef CUDAQ_PULSE_DIALECT_QOP +#define CUDAQ_PULSE_DIALECT_QOP + +include "mlir/IR/OpBase.td" + +def QOpDialect : Dialect { + let name = "qop"; + let summary = "Backend-agnostic quantum operator algebra dialect"; + let description = [{ + The `qop` dialect captures the algebraic structure of quantum operators + (Hamiltonians, Lindbladians, observables) as first-class MLIR values. + + Key concepts: + - **Handlers**: Leaf operators (Pauli, bosonic, fermionic, matrix) + - **Products**: Tensor products of handlers with scalar coefficients + - **Sums**: Linear combinations of products (the general operator) + - **Super-operators**: Left/right action pairs for master equations + }]; + let cppNamespace = "::qop"; + let useDefaultTypePrinterParser = 1; + let useDefaultAttributePrinterParser = 1; + let extraClassDeclaration = [{ + void registerTypes(); + }]; +} + +#endif // CUDAQ_PULSE_DIALECT_QOP diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/QOpOps.td b/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/QOpOps.td new file mode 100644 index 00000000000..6d45db84ccd --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/QOpOps.td @@ -0,0 +1,188 @@ +/********************************************************** -*- tablegen -*- *** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *****************************************************************************/ + +#ifndef CUDAQ_PULSE_DIALECT_QOP_OPS +#define CUDAQ_PULSE_DIALECT_QOP_OPS + +include "cudaq-pulse/Dialect/QOp/QOpDialect.td" +include "cudaq-pulse/Dialect/QOp/QOpTypes.td" +include "cudaq-pulse/Dialect/QOp/QOpAttrs.td" +include "mlir/Interfaces/SideEffectInterfaces.td" +include "mlir/IR/OpBase.td" + +class QOpOp traits = []> : + Op; + +// ---- Leaf operator construction ---- + +def QOP_SpinOp : QOpOp<"spin", [Pure]> { + let summary = "Create a Pauli spin operator on a target qubit"; + let arguments = (ins I64:$target, QOP_HandlerKindAttr:$kind); + let results = (outs QOP_HandlerType:$result); + let assemblyFormat = "`(` $target `)` attr-dict `:` type($result)"; + let hasVerifier = 1; +} + +def QOP_BosonOp : QOpOp<"boson", [Pure]> { + let summary = "Create a bosonic operator on a target mode"; + let arguments = (ins I64:$target, I64Attr:$dimension, QOP_HandlerKindAttr:$kind); + let results = (outs QOP_HandlerType:$result); + let assemblyFormat = "`(` $target `)` attr-dict `:` type($result)"; + let hasVerifier = 1; +} + +def QOP_FermionOp : QOpOp<"fermion", [Pure]> { + let summary = "Create a fermionic operator on a target mode"; + let arguments = (ins I64:$target, QOP_HandlerKindAttr:$kind); + let results = (outs QOP_HandlerType:$result); + let assemblyFormat = "`(` $target `)` attr-dict `:` type($result)"; + let hasVerifier = 1; +} + +def QOP_MatrixLeafOp : QOpOp<"matrix_leaf", [Pure]> { + let summary = "Create a user-defined matrix leaf operator"; + let arguments = (ins + DenseI64ArrayAttr:$targets, + AnyType:$data, + DenseI64ArrayAttr:$dimensions + ); + let results = (outs QOP_HandlerType:$result); + let assemblyFormat = "`(` $data `)` attr-dict `:` type($data) `->` type($result)"; + let hasVerifier = 1; +} + +// ---- Scalar coefficient construction ---- + +def QOP_ConstScalarOp : QOpOp<"const_scalar", [Pure]> { + let summary = "Create a constant complex scalar coefficient"; + let arguments = (ins F64Attr:$real, F64Attr:$imag); + let results = (outs QOP_ScalarType:$result); + let assemblyFormat = "attr-dict `:` type($result)"; +} + +def QOP_CallbackScalarOp : QOpOp<"callback_scalar", [Pure]> { + let summary = "Create a time/parameter-dependent scalar coefficient"; + let arguments = (ins FlatSymbolRefAttr:$callback); + let results = (outs QOP_ScalarType:$result); + let assemblyFormat = "$callback attr-dict `:` type($result)"; + let hasVerifier = 1; +} + +// ---- Operator composition ---- + +def QOP_MakeProductOp : QOpOp<"make_product", [Pure]> { + let summary = "Create a tensor product of handlers with a coefficient"; + let arguments = (ins QOP_ScalarType:$coefficient, Variadic:$factors); + let results = (outs QOP_ProductType:$result); + let assemblyFormat = "`(` $coefficient `,` $factors `)` attr-dict `:` type($result)"; +} + +def QOP_MakeSumOp : QOpOp<"make_sum", [Pure]> { + let summary = "Create a sum of product terms"; + let arguments = (ins Variadic:$terms); + let results = (outs QOP_OpType:$result); + let assemblyFormat = "`(` $terms `)` attr-dict `:` type($result)"; + let hasVerifier = 1; +} + +def QOP_AddOp : QOpOp<"add", [Pure]> { + let summary = "Add two operators"; + let arguments = (ins QOP_OpType:$lhs, QOP_OpType:$rhs); + let results = (outs QOP_OpType:$result); + let assemblyFormat = "$lhs `,` $rhs attr-dict `:` type($result)"; +} + +def QOP_ScaleOp : QOpOp<"scale", [Pure]> { + let summary = "Multiply an operator by a scalar coefficient"; + let arguments = (ins QOP_ScalarType:$coefficient, QOP_OpType:$operand); + let results = (outs QOP_OpType:$result); + let assemblyFormat = "$coefficient `,` $operand attr-dict `:` type($result)"; +} + +def QOP_DaggerOp : QOpOp<"dagger", [Pure]> { + let summary = "Hermitian conjugate of an operator"; + let arguments = (ins QOP_OpType:$operand); + let results = (outs QOP_OpType:$result); + let assemblyFormat = "$operand attr-dict `:` type($result)"; +} + +def QOP_CommutatorOp : QOpOp<"commutator", [Pure]> { + let summary = "Compute [A, B] = AB - BA"; + let arguments = (ins QOP_OpType:$lhs, QOP_OpType:$rhs); + let results = (outs QOP_OpType:$result); + let assemblyFormat = "$lhs `,` $rhs attr-dict `:` type($result)"; +} + +def QOP_AnticommutatorOp : QOpOp<"anticommutator", [Pure]> { + let summary = "Compute {A, B} = AB + BA"; + let arguments = (ins QOP_OpType:$lhs, QOP_OpType:$rhs); + let results = (outs QOP_OpType:$result); + let assemblyFormat = "$lhs `,` $rhs attr-dict `:` type($result)"; +} + +// ---- Lindblad / super-operator construction ---- + +def QOP_LindbladOp : QOpOp<"lindblad", [Pure]> { + let summary = "Construct Lindblad super-operator from H and collapse ops"; + let arguments = (ins QOP_OpType:$hamiltonian, Variadic:$collapse_ops); + let results = (outs QOP_SuperOpType:$result); + let assemblyFormat = "`(` $hamiltonian `,` $collapse_ops `)` attr-dict `:` type($result)"; +} + +def QOP_SuperLeftOp : QOpOp<"super_left", [Pure]> { + let summary = "Super-operator for left multiplication: S[rho] = A * rho"; + let arguments = (ins QOP_OpType:$operand); + let results = (outs QOP_SuperOpType:$result); + let assemblyFormat = "$operand attr-dict `:` type($result)"; +} + +def QOP_SuperRightOp : QOpOp<"super_right", [Pure]> { + let summary = "Super-operator for right multiplication: S[rho] = rho * A"; + let arguments = (ins QOP_OpType:$operand); + let results = (outs QOP_SuperOpType:$result); + let assemblyFormat = "$operand attr-dict `:` type($result)"; +} + +def QOP_SuperBothOp : QOpOp<"super_both", [Pure]> { + let summary = "Super-operator: S[rho] = A * rho * B"; + let arguments = (ins QOP_OpType:$left, QOP_OpType:$right); + let results = (outs QOP_SuperOpType:$result); + let assemblyFormat = "$left `,` $right attr-dict `:` type($result)"; +} + +def QOP_SuperAddOp : QOpOp<"super_add", [Pure]> { + let summary = "Add two super-operators"; + let arguments = (ins QOP_SuperOpType:$lhs, QOP_SuperOpType:$rhs); + let results = (outs QOP_SuperOpType:$result); + let assemblyFormat = "$lhs `,` $rhs attr-dict `:` type($result)"; +} + +// ---- Evaluation / materialization ---- + +def QOP_ToMatrixOp : QOpOp<"to_matrix", [MemoryEffects<[MemAlloc, MemWrite]>]> { + let summary = "Materialize operator to dense matrix at given time/params"; + let arguments = (ins + QOP_OpType:$operand, + DenseI64ArrayAttr:$dimensions, + F64:$time, + AnyType:$params + ); + let results = (outs AnyType:$matrix); + let assemblyFormat = "$operand `,` $time `,` $params attr-dict `:` type($operand) `,` type($params) `->` type($matrix)"; + let hasVerifier = 1; +} + +def QOP_DegreesOp : QOpOp<"degrees", [MemoryEffects<[MemAlloc, MemWrite]>]> { + let summary = "Query which degrees of freedom an operator acts on"; + let arguments = (ins QOP_OpType:$operand); + let results = (outs AnyType:$result); + let assemblyFormat = "$operand attr-dict `:` type($operand) `->` type($result)"; + let hasVerifier = 1; +} + +#endif // CUDAQ_PULSE_DIALECT_QOP_OPS diff --git a/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/QOpTypes.td b/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/QOpTypes.td new file mode 100644 index 00000000000..a2edfc45581 --- /dev/null +++ b/pulse/core/mlir/include/cudaq-pulse/Dialect/QOp/QOpTypes.td @@ -0,0 +1,46 @@ +/********************************************************** -*- tablegen -*- *** + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *****************************************************************************/ + +#ifndef CUDAQ_PULSE_DIALECT_QOP_TYPES +#define CUDAQ_PULSE_DIALECT_QOP_TYPES + +include "cudaq-pulse/Dialect/QOp/QOpDialect.td" +include "mlir/IR/AttrTypeBase.td" + +class QOpType traits = [], + string baseCppClass = "mlir::Type"> + : TypeDef { + let mnemonic = typeMnemonic; +} + +def QOP_ScalarType : QOpType<"Scalar", "scalar"> { + let summary = "Scalar coefficient (constant or time/parameter-dependent)"; + let genStorageClass = 0; +} + +def QOP_HandlerType : QOpType<"Handler", "handler"> { + let summary = "Leaf operator acting on specific degrees of freedom"; + let genStorageClass = 0; +} + +def QOP_ProductType : QOpType<"Product", "product"> { + let summary = "Tensor product of handlers with a scalar coefficient"; + let genStorageClass = 0; +} + +def QOP_OpType : QOpType<"Op", "op"> { + let summary = "Sum of products -- the general quantum operator"; + let genStorageClass = 0; +} + +def QOP_SuperOpType : QOpType<"SuperOp", "super_op"> { + let summary = "Super-operator for density matrix evolution"; + let genStorageClass = 0; +} + +#endif // CUDAQ_PULSE_DIALECT_QOP_TYPES diff --git a/pulse/core/mlir/tools/cudaq-pulse-opt/CMakeLists.txt b/pulse/core/mlir/tools/cudaq-pulse-opt/CMakeLists.txt new file mode 100644 index 00000000000..3bb0991e4e2 --- /dev/null +++ b/pulse/core/mlir/tools/cudaq-pulse-opt/CMakeLists.txt @@ -0,0 +1,42 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +set(LLVM_LINK_COMPONENTS Support) + +get_property(dialect_libs GLOBAL PROPERTY MLIR_DIALECT_LIBS) +get_property(conversion_libs GLOBAL PROPERTY MLIR_CONVERSION_LIBS) + +add_llvm_executable(cudaq-pulse-opt + cudaq-pulse-opt.cpp +) + +target_link_libraries(cudaq-pulse-opt PRIVATE + CudaqPulsePulse + CudaqPulsePulseTransforms + CudaqPulseQOp + CudaqPulseCuDensityMat + CudaqPulsePulseToQOp + CudaqPulseQOpToCuDensityMat + CudaqPulseCuDensityMatToLLVM + MLIRFuncDialect + MLIRArithDialect + MLIRArithToLLVM + MLIRFuncToLLVM + MLIRLLVMDialect + MLIRReconcileUnrealizedCasts + MLIRRegisterAllPasses + MLIROptLib + MLIRPass + MLIRIR + MLIRParser + MLIRSupport + MLIRTransformUtils +) + +llvm_update_compile_flags(cudaq-pulse-opt) +mlir_check_all_link_libraries(cudaq-pulse-opt) diff --git a/pulse/core/mlir/tools/cudaq-pulse-opt/cudaq-pulse-opt.cpp b/pulse/core/mlir/tools/cudaq-pulse-opt/cudaq-pulse-opt.cpp new file mode 100644 index 00000000000..86e6c6c8f4d --- /dev/null +++ b/pulse/core/mlir/tools/cudaq-pulse-opt/cudaq-pulse-opt.cpp @@ -0,0 +1,92 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +#include "mlir/Dialect/Arith/IR/Arith.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/Dialect/LLVMIR/LLVMDialect.h" +#include "mlir/IR/DialectRegistry.h" +#include "mlir/IR/MLIRContext.h" +#include "mlir/InitAllPasses.h" +#include "mlir/Pass/Pass.h" +#include "mlir/Pass/PassManager.h" +#include "mlir/Pass/PassRegistry.h" +#include "mlir/Tools/mlir-opt/MlirOptMain.h" + +#include "mlir/Bytecode/BytecodeOpInterface.h" +#include "mlir/Interfaces/ControlFlowInterfaces.h" +#include "mlir/Interfaces/SideEffectInterfaces.h" + +#include "cudaq-pulse/Dialect/Pulse/PulseDialect.h.inc" +#include "cudaq-pulse/Dialect/Pulse/PulseEnums.h.inc" +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseOps.h.inc" + +#include "cudaq-pulse/Dialect/QOp/QOpDialect.h.inc" +#include "cudaq-pulse/Dialect/QOp/QOpEnums.h.inc" +#define GET_ATTRDEF_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpAttrs.h.inc" +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/QOp/QOpOps.h.inc" + +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatDialect.h.inc" +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatEnums.h.inc" +#define GET_ATTRDEF_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatAttrs.h.inc" +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/CuDensityMat/CuDensityMatOps.h.inc" + +// Conversion passes +namespace pulse { +std::unique_ptr createPulseToQOpPass(); +} +namespace qop { +std::unique_ptr createQOpToCuDensityMatPass(); +} +namespace cudm { +std::unique_ptr createCuDensityMatToLLVMPass(); +} +// Pulse Transforms passes +namespace pulse { +std::unique_ptr createPulseVerifyPass(); +std::unique_ptr createVirtualZPass(); +std::unique_ptr createPulseFusionPass(); +std::unique_ptr createPulseScheduleAsapPass(); +std::unique_ptr createPulseScheduleAlapPass(); +} // namespace pulse + +int main(int argc, char **argv) { + mlir::registerAllPasses(); + mlir::DialectRegistry registry; + registry.insert(); + registry.insert(); + registry.insert(); + registry.insert(); + registry.insert(); + registry.insert(); + + // Conversion passes + mlir::registerPass(pulse::createPulseToQOpPass); + mlir::registerPass(qop::createQOpToCuDensityMatPass); + mlir::registerPass(cudm::createCuDensityMatToLLVMPass); + + // Pulse Transforms passes + mlir::registerPass(pulse::createPulseVerifyPass); + mlir::registerPass(pulse::createVirtualZPass); + mlir::registerPass(pulse::createPulseFusionPass); + mlir::registerPass(pulse::createPulseScheduleAsapPass); + mlir::registerPass(pulse::createPulseScheduleAlapPass); + + return mlir::asMainReturnCode( + mlir::MlirOptMain(argc, argv, "cudaq-pulse MLIR optimizer\n", registry)); +} diff --git a/pulse/core/mlir/transforms/CMakeLists.txt b/pulse/core/mlir/transforms/CMakeLists.txt new file mode 100644 index 00000000000..197739e99fc --- /dev/null +++ b/pulse/core/mlir/transforms/CMakeLists.txt @@ -0,0 +1,27 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_mlir_dialect_library(CudaqPulsePulseTransforms + PulseVerify.cpp + Canonicalize.cpp + VirtualZ.cpp + Fusion.cpp + ScheduleAlap.cpp + + DEPENDS + CudaqPulsePulseIncGen + + LINK_LIBS PUBLIC + CudaqPulsePulse + MLIRIR + MLIRPass + MLIRSupport + MLIRFuncDialect + MLIRArithDialect + MLIRTransformUtils +) diff --git a/pulse/core/mlir/transforms/Canonicalize.cpp b/pulse/core/mlir/transforms/Canonicalize.cpp new file mode 100644 index 00000000000..6e9d35242c9 --- /dev/null +++ b/pulse/core/mlir/transforms/Canonicalize.cpp @@ -0,0 +1,113 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// Pulse canonicalization pass: redundant sync elimination, dead line +// elimination, waveform CSE. + +#include "mlir/Dialect/Arith/IR/Arith.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/IR/PatternMatch.h" +#include "mlir/Pass/Pass.h" +#include "mlir/Transforms/GreedyPatternRewriteDriver.h" + +#include "cudaq-pulse/Dialect/Pulse/PulseDialect.h.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseOps.h.inc" + +namespace { + +/// Remove sync ops with only one operand (no-op synchronization). +struct RemoveSingleOperandSync : public mlir::OpRewritePattern { + using OpRewritePattern::OpRewritePattern; + + mlir::LogicalResult + matchAndRewrite(pulse::SyncOp op, + mlir::PatternRewriter &rewriter) const override { + if (op.getNumOperands() <= 1) { + if (op.getNumResults() == 1 && op.getNumOperands() == 1) { + rewriter.replaceOp(op, op.getOperand(0)); + } else if (op.getNumResults() == 0) { + rewriter.eraseOp(op); + } else { + return mlir::failure(); + } + return mlir::success(); + } + return mlir::failure(); + } +}; + +/// Remove consecutive duplicate sync ops on the same set of lines. +struct RemoveRedundantSync : public mlir::OpRewritePattern { + using OpRewritePattern::OpRewritePattern; + + mlir::LogicalResult + matchAndRewrite(pulse::SyncOp op, + mlir::PatternRewriter &rewriter) const override { + auto prevOp = op->getPrevNode(); + if (!prevOp) + return mlir::failure(); + + auto prevSync = mlir::dyn_cast(prevOp); + if (!prevSync) + return mlir::failure(); + + if (prevSync.getNumResults() != op.getNumOperands()) + return mlir::failure(); + + bool allMatch = true; + for (unsigned i = 0; i < op.getNumOperands(); ++i) { + if (op.getOperand(i) != prevSync.getResult(i)) { + allMatch = false; + break; + } + } + + if (!allMatch) + return mlir::failure(); + + // This sync immediately follows the previous one on the same lines + rewriter.replaceOp(op, prevSync.getResults()); + return mlir::success(); + } +}; + +struct PulseCanonicalizePass + : public mlir::PassWrapper> { + + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(PulseCanonicalizePass) + + llvm::StringRef getArgument() const override { return "pulse-canonicalize"; } + llvm::StringRef getDescription() const override { + return "Pulse-level canonicalization: sync elimination, waveform CSE"; + } + + void runOnOperation() override { + mlir::RewritePatternSet patterns(&getContext()); + patterns.add(&getContext()); + patterns.add(&getContext()); + + mlir::GreedyRewriteConfig config; + if (mlir::failed(mlir::applyPatternsGreedily(getOperation(), + std::move(patterns), config))) + signalPassFailure(); + } +}; + +} // namespace + +namespace pulse { +std::unique_ptr createPulseCanonicalizePass() { + return std::make_unique(); +} +} // namespace pulse diff --git a/pulse/core/mlir/transforms/Fusion.cpp b/pulse/core/mlir/transforms/Fusion.cpp new file mode 100644 index 00000000000..df99d3d5b15 --- /dev/null +++ b/pulse/core/mlir/transforms/Fusion.cpp @@ -0,0 +1,141 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// Fusion pass: merge adjacent drive ops on the same line with same-amplitude +// square waveforms into a single drive with summed duration. + +#include "mlir/Dialect/Arith/IR/Arith.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/IR/PatternMatch.h" +#include "mlir/Pass/Pass.h" +#include "mlir/Transforms/GreedyPatternRewriteDriver.h" + +#include "cudaq-pulse/Dialect/Pulse/PulseDialect.h.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseOps.h.inc" + +namespace { + +static std::optional traceConstantI64(mlir::Value v) { + if (auto cst = v.getDefiningOp()) + return cst.value(); + if (auto cst = v.getDefiningOp()) { + if (auto ia = mlir::dyn_cast(cst.getValue())) + return ia.getInt(); + } + return std::nullopt; +} + +static bool sameSSAValue(mlir::Value a, mlir::Value b) { + if (a == b) + return true; + auto *aOp = a.getDefiningOp(); + auto *bOp = b.getDefiningOp(); + if (!aOp || !bOp) + return false; + auto aCst = mlir::dyn_cast(aOp); + auto bCst = mlir::dyn_cast(bOp); + if (aCst && bCst) + return aCst.value().bitwiseIsEqual(bCst.value()); + return false; +} + +struct FuseAdjacentSquareDrives + : public mlir::OpRewritePattern { + using OpRewritePattern::OpRewritePattern; + + mlir::LogicalResult + matchAndRewrite(pulse::DriveOp firstDrive, + mlir::PatternRewriter &rewriter) const override { + auto firstWf = firstDrive.getPulse().getDefiningOp(); + if (!firstWf) + return mlir::failure(); + + auto updatedLine = firstDrive.getUpdatedLine(); + if (!updatedLine.hasOneUse()) + return mlir::failure(); + + auto *user = *updatedLine.getUsers().begin(); + auto secondDrive = mlir::dyn_cast(user); + if (!secondDrive || secondDrive.getLine() != updatedLine) + return mlir::failure(); + + auto secondWf = + secondDrive.getPulse().getDefiningOp(); + if (!secondWf) + return mlir::failure(); + + if (!sameSSAValue(firstWf.getAmpReal(), secondWf.getAmpReal()) || + !sameSSAValue(firstWf.getAmpImag(), secondWf.getAmpImag())) + return mlir::failure(); + + if (secondDrive.getTone() != firstDrive.getUpdatedTone()) + return mlir::failure(); + + auto dur1 = traceConstantI64(firstWf.getDuration()); + auto dur2 = traceConstantI64(secondWf.getDuration()); + if (!dur1 || !dur2) + return mlir::failure(); + int64_t fusedDur = *dur1 + *dur2; + + auto loc = firstDrive.getLoc(); + auto i64Ty = rewriter.getIntegerType(64); + auto durConst = + mlir::arith::ConstantIntOp::create(rewriter, loc, fusedDur, 64); + auto fusedWf = pulse::SquarePulseOp::create( + rewriter, loc, firstWf.getType(), durConst.getResult(), + firstWf.getAmpReal(), firstWf.getAmpImag()); + + auto fusedDrive = pulse::DriveOp::create( + rewriter, loc, firstDrive.getUpdatedLine().getType(), + firstDrive.getUpdatedTone().getType(), firstDrive.getLine(), + fusedWf.getResult(), firstDrive.getTone()); + + if (auto a = firstDrive->getAttrOfType("start_vtu")) + fusedDrive->setAttr("start_vtu", a); + fusedDrive->setAttr("duration_vtu", rewriter.getI64IntegerAttr(fusedDur)); + fusedDrive->setAttr("fused", rewriter.getUnitAttr()); + + rewriter.replaceOp(secondDrive, fusedDrive->getResults()); + rewriter.replaceOp(firstDrive, fusedDrive->getResults()); + return mlir::success(); + } +}; + +struct PulseFusionPass + : public mlir::PassWrapper> { + + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(PulseFusionPass) + + llvm::StringRef getArgument() const override { return "pulse-fusion"; } + llvm::StringRef getDescription() const override { + return "Fuse adjacent same-amplitude square-pulse drives into one"; + } + + void runOnOperation() override { + mlir::RewritePatternSet patterns(&getContext()); + patterns.add(&getContext()); + mlir::GreedyRewriteConfig config; + if (mlir::failed(mlir::applyPatternsGreedily(getOperation(), + std::move(patterns), config))) + signalPassFailure(); + } +}; + +} // namespace + +namespace pulse { +std::unique_ptr createPulseFusionPass() { + return std::make_unique(); +} +} // namespace pulse diff --git a/pulse/core/mlir/transforms/PulseVerify.cpp b/pulse/core/mlir/transforms/PulseVerify.cpp new file mode 100644 index 00000000000..04272d0f3bc --- /dev/null +++ b/pulse/core/mlir/transforms/PulseVerify.cpp @@ -0,0 +1,174 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// Module-level verification pass for the Pulse dialect. +// Checks linearity, monotone time, drive exclusivity, and waveform validity +// beyond what individual op verifiers can check. + +#include "mlir/Dialect/Arith/IR/Arith.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/IR/Diagnostics.h" +#include "mlir/Pass/Pass.h" + +#include "cudaq-pulse/Dialect/Pulse/PulseDialect.h.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseOps.h.inc" + +#include +#include + +namespace { + +static std::optional traceConstantI64(mlir::Value v) { + if (auto cst = v.getDefiningOp()) + return cst.value(); + if (auto cst = v.getDefiningOp()) { + if (auto ia = mlir::dyn_cast(cst.getValue())) + return ia.getInt(); + } + return std::nullopt; +} + +static mlir::Operation *tracePhysicalLine(mlir::Value line, + llvm::DenseSet &seen) { + if (!line || !seen.insert(line).second) + return nullptr; + auto *def = line.getDefiningOp(); + if (!def) + return nullptr; + if (mlir::isa(def)) + return def; + if (mlir::isa(def)) { + auto result = mlir::dyn_cast(line); + if (!result || result.getResultNumber() >= def->getNumOperands()) + return nullptr; + return tracePhysicalLine(def->getOperand(result.getResultNumber()), seen); + } + for (mlir::Value operand : def->getOperands()) + if (mlir::isa( + operand.getType())) + return tracePhysicalLine(operand, seen); + return nullptr; +} + +static mlir::Operation *tracePhysicalLine(mlir::Value line) { + llvm::DenseSet seen; + return tracePhysicalLine(line, seen); +} + +struct PulseVerifyPass + : public mlir::PassWrapper> { + + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(PulseVerifyPass) + + llvm::StringRef getArgument() const override { return "pulse-verify"; } + llvm::StringRef getDescription() const override { + return "Module-level verification of Pulse IR constraints"; + } + + void runOnOperation() override { + auto module = getOperation(); + bool hadError = false; + + // Check 1: Linearity -- lines must be consumed. + module.walk([&](pulse::GetDriveLineOp op) { + if (op.getLine().use_empty()) + op.emitWarning("drive line allocated but never used"); + }); + module.walk([&](pulse::GetReadoutLineOp op) { + if (op.getLine().use_empty()) + op.emitWarning("readout line allocated but never used"); + }); + module.walk([&](mlir::Operation *op) { + for (mlir::Value result : op->getResults()) { + if (!mlir::isa(result.getType())) + continue; + if (!result.use_empty() && !result.hasOneUse()) { + op->emitError("linear pulse value has ") + << std::distance(result.use_begin(), result.use_end()) + << " uses; expected at most one"; + hadError = true; + } + } + }); + + // Check 2: Waveform validity -- trace SSA duration to constant, skip + // parametric (block arg) values which are checked at evaluation time. + auto checkDuration = [&](mlir::Operation *op, mlir::Value durVal) { + if (auto dur = traceConstantI64(durVal)) { + if (*dur <= 0) { + op->emitError("waveform duration must be positive, got ") << *dur; + hadError = true; + } + } + }; + module.walk([&](pulse::GaussianPulseOp op) { + checkDuration(op, op.getDuration()); + }); + module.walk( + [&](pulse::SquarePulseOp op) { checkDuration(op, op.getDuration()); }); + module.walk( + [&](pulse::DRAGPulseOp op) { checkDuration(op, op.getDuration()); }); + + // Check 3: Monotone, non-overlapping time on each physical line. SSA line + // values change after every operation, so trace them back to allocation. + module.walk([&](mlir::func::FuncOp funcOp) { + llvm::DenseMap lastEnd; + auto checkTimed = [&](mlir::Operation *op, mlir::Value line) { + auto startAttr = op->getAttrOfType("start_vtu"); + auto durationAttr = + op->getAttrOfType("duration_vtu"); + if (!startAttr || !durationAttr) + return; + int64_t start = startAttr.getInt(); + int64_t duration = durationAttr.getInt(); + auto *physicalLine = tracePhysicalLine(line); + if (!physicalLine) { + op->emitError("cannot trace timed operation to a physical line"); + hadError = true; + return; + } + auto it = lastEnd.find(physicalLine); + if (it != lastEnd.end() && start < it->second) { + op->emitError("operation overlaps or precedes its physical-line " + "predecessor: start ") + << start << " < prior end " << it->second; + hadError = true; + } + lastEnd[physicalLine] = + std::max(lastEnd.lookup(physicalLine), start + duration); + }; + funcOp.walk([&](pulse::DriveOp op) { + checkTimed(op.getOperation(), op.getLine()); + }); + funcOp.walk([&](pulse::ReadoutOp op) { + checkTimed(op.getOperation(), op.getLine()); + }); + funcOp.walk([&](pulse::WaitOp op) { + checkTimed(op.getOperation(), op.getLine()); + }); + }); + + if (hadError) + signalPassFailure(); + } +}; + +} // namespace + +namespace pulse { +std::unique_ptr createPulseVerifyPass() { + return std::make_unique(); +} +} // namespace pulse diff --git a/pulse/core/mlir/transforms/ScheduleAlap.cpp b/pulse/core/mlir/transforms/ScheduleAlap.cpp new file mode 100644 index 00000000000..3b0bb749bfe --- /dev/null +++ b/pulse/core/mlir/transforms/ScheduleAlap.cpp @@ -0,0 +1,359 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// Deterministic ASAP and ALAP scheduling passes for the Pulse dialect. + +#include "llvm/ADT/STLExtras.h" +#include "mlir/Dialect/Arith/IR/Arith.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/Pass/Pass.h" + +#include + +#include "cudaq-pulse/Dialect/Pulse/PulseDialect.h.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseOps.h.inc" + +namespace { + +static std::optional traceConstantI64(mlir::Value value) { + if (auto constant = value.getDefiningOp()) + return constant.value(); + if (auto constant = value.getDefiningOp()) + if (auto attr = mlir::dyn_cast(constant.getValue())) + return attr.getInt(); + return std::nullopt; +} + +static int64_t getWaveformDuration(mlir::Value waveform) { + auto *definition = waveform.getDefiningOp(); + if (!definition) + return 0; + + mlir::Value duration; + if (auto op = mlir::dyn_cast(definition)) + duration = op.getDuration(); + else if (auto op = mlir::dyn_cast(definition)) + duration = op.getDuration(); + else if (auto op = mlir::dyn_cast(definition)) + duration = op.getDuration(); + else if (auto op = mlir::dyn_cast(definition)) + duration = op.getDuration(); + else if (auto op = mlir::dyn_cast(definition)) + duration = op.getDuration(); + else if (auto op = mlir::dyn_cast(definition)) + duration = op.getDuration(); + else if (auto op = mlir::dyn_cast(definition)) + duration = op.getDuration(); + else if (auto op = mlir::dyn_cast(definition)) + return static_cast(op.getSamples().size()); + else if (auto op = mlir::dyn_cast(definition)) + return getWaveformDuration(op.getLhs()); + else if (auto op = mlir::dyn_cast(definition)) + return getWaveformDuration(op.getLhs()); + else if (auto op = mlir::dyn_cast(definition)) + return getWaveformDuration(op.getLhs()); + else if (auto op = mlir::dyn_cast(definition)) + return getWaveformDuration(op.getPulse()); + else if (auto op = mlir::dyn_cast(definition)) + return getWaveformDuration(op.getPulse()); + else + return 0; + + return traceConstantI64(duration).value_or(0); +} + +static int64_t getWaitDuration(pulse::WaitOp wait) { + if (auto conversion = + wait.getDuration().getDefiningOp()) + return traceConstantI64(conversion.getCycles()).value_or(0); + return 0; +} + +static void setTiming(mlir::Operation *operation, mlir::IntegerType i64Type, + int64_t start, int64_t duration) { + operation->setAttr("start_vtu", mlir::IntegerAttr::get(i64Type, start)); + operation->setAttr("duration_vtu", mlir::IntegerAttr::get(i64Type, duration)); +} + +/// Assign an ASAP schedule and return its makespan. +static int64_t scheduleAsap(mlir::func::FuncOp function) { + llvm::DenseMap lineReady; + auto i64Type = mlir::IntegerType::get(function.getContext(), 64); + int64_t makespan = 0; + + function.walk([&](mlir::Operation *operation) { + if (auto drive = mlir::dyn_cast(operation)) { + int64_t start = lineReady.lookup(drive.getLine()); + int64_t duration = getWaveformDuration(drive.getPulse()); + setTiming(operation, i64Type, start, duration); + lineReady[drive.getUpdatedLine()] = start + duration; + makespan = std::max(makespan, start + duration); + } else if (auto readout = mlir::dyn_cast(operation)) { + int64_t start = lineReady.lookup(readout.getLine()); + int64_t duration = getWaveformDuration(readout.getPulse()); + setTiming(operation, i64Type, start, duration); + lineReady[readout.getUpdatedLine()] = start + duration; + makespan = std::max(makespan, start + duration); + } else if (auto wait = mlir::dyn_cast(operation)) { + int64_t start = lineReady.lookup(wait.getLine()); + int64_t duration = getWaitDuration(wait); + setTiming(operation, i64Type, start, duration); + lineReady[wait.getUpdatedLine()] = start + duration; + makespan = std::max(makespan, start + duration); + } else if (auto sync = mlir::dyn_cast(operation)) { + int64_t syncTime = 0; + for (auto line : sync.getLines()) + syncTime = std::max(syncTime, lineReady.lookup(line)); + for (auto result : sync.getResults()) + lineReady[result] = syncTime; + makespan = std::max(makespan, syncTime); + } + }); + return makespan; +} + +static void scheduleAlap(mlir::func::FuncOp function) { + const int64_t makespan = scheduleAsap(function); + auto i64Type = mlir::IntegerType::get(function.getContext(), 64); + llvm::DenseMap lineLatest; + llvm::SmallVector operations; + function.walk([&](mlir::Operation *operation) { + if (mlir::isa(operation)) + operations.push_back(operation); + }); + + auto latestFor = [&](mlir::Value line) { + auto iterator = lineLatest.find(line); + return iterator == lineLatest.end() ? makespan : iterator->second; + }; + + for (mlir::Operation *operation : llvm::reverse(operations)) { + if (auto drive = mlir::dyn_cast(operation)) { + int64_t duration = getWaveformDuration(drive.getPulse()); + int64_t start = latestFor(drive.getUpdatedLine()) - duration; + setTiming(operation, i64Type, start, duration); + lineLatest[drive.getLine()] = start; + } else if (auto readout = mlir::dyn_cast(operation)) { + int64_t duration = getWaveformDuration(readout.getPulse()); + int64_t start = latestFor(readout.getUpdatedLine()) - duration; + setTiming(operation, i64Type, start, duration); + lineLatest[readout.getLine()] = start; + } else if (auto wait = mlir::dyn_cast(operation)) { + int64_t duration = getWaitDuration(wait); + int64_t start = latestFor(wait.getUpdatedLine()) - duration; + setTiming(operation, i64Type, start, duration); + lineLatest[wait.getLine()] = start; + } else if (auto sync = mlir::dyn_cast(operation)) { + int64_t syncTime = makespan; + for (auto result : sync.getResults()) + syncTime = std::min(syncTime, latestFor(result)); + for (auto line : sync.getLines()) + lineLatest[line] = syncTime; + } + } +} + +static size_t earliestLane(llvm::ArrayRef boundaries) { + return static_cast( + std::distance(boundaries.begin(), + std::min_element(boundaries.begin(), boundaries.end()))); +} + +static size_t latestLane(llvm::ArrayRef boundaries) { + return static_cast( + std::distance(boundaries.begin(), + std::max_element(boundaries.begin(), boundaries.end()))); +} + +/// Dependency- and interval-correct list scheduling with bounded drive and +/// readout resources. ``isAlap`` schedules the same dependency graph backward +/// within the makespan of the corresponding forward resource schedule. +static void scheduleRcp(mlir::func::FuncOp function, int64_t maxDrives, + int64_t maxReadouts, int64_t readoutLatency, + int64_t switchPenalty, bool isAlap) { + if (maxDrives <= 0 || maxReadouts <= 0) { + function.emitError("resource limits must be positive"); + return; + } + + auto scheduleForward = [&]() { + llvm::DenseMap lineReady; + llvm::SmallVector driveLanes(static_cast(maxDrives), 0); + llvm::SmallVector readoutLanes(static_cast(maxReadouts), + 0); + auto i64Type = mlir::IntegerType::get(function.getContext(), 64); + int64_t makespan = 0; + + function.walk([&](mlir::Operation *operation) { + auto place = [&](mlir::Value input, mlir::Value output, int64_t duration, + llvm::SmallVectorImpl &lanes, int64_t latency) { + size_t lane = earliestLane(lanes); + int64_t start = std::max(lineReady.lookup(input), lanes[lane]); + setTiming(operation, i64Type, start, duration); + lanes[lane] = start + duration + switchPenalty; + lineReady[output] = start + duration + latency; + makespan = std::max(makespan, lineReady[output]); + }; + + if (auto drive = mlir::dyn_cast(operation)) { + place(drive.getLine(), drive.getUpdatedLine(), + getWaveformDuration(drive.getPulse()), driveLanes, 0); + } else if (auto readout = mlir::dyn_cast(operation)) { + place(readout.getLine(), readout.getUpdatedLine(), + getWaveformDuration(readout.getPulse()), readoutLanes, + readoutLatency); + } else if (auto wait = mlir::dyn_cast(operation)) { + int64_t start = lineReady.lookup(wait.getLine()); + int64_t duration = getWaitDuration(wait); + setTiming(operation, i64Type, start, duration); + lineReady[wait.getUpdatedLine()] = start + duration; + makespan = std::max(makespan, start + duration); + } else if (auto sync = mlir::dyn_cast(operation)) { + int64_t syncTime = 0; + for (auto line : sync.getLines()) + syncTime = std::max(syncTime, lineReady.lookup(line)); + for (auto result : sync.getResults()) + lineReady[result] = syncTime; + makespan = std::max(makespan, syncTime); + } + }); + return makespan; + }; + + const int64_t makespan = scheduleForward(); + if (!isAlap) + return; + + auto i64Type = mlir::IntegerType::get(function.getContext(), 64); + llvm::DenseMap lineLatest; + llvm::SmallVector driveLanes(static_cast(maxDrives), + makespan); + llvm::SmallVector readoutLanes(static_cast(maxReadouts), + makespan); + llvm::SmallVector operations; + function.walk([&](mlir::Operation *operation) { + if (mlir::isa(operation)) + operations.push_back(operation); + }); + auto latestFor = [&](mlir::Value line) { + auto iterator = lineLatest.find(line); + return iterator == lineLatest.end() ? makespan : iterator->second; + }; + + auto place = [&](mlir::Operation *operation, mlir::Value input, + mlir::Value output, int64_t duration, + llvm::SmallVectorImpl &lanes, int64_t latency) { + size_t lane = latestLane(lanes); + int64_t end = std::min(latestFor(output) - latency, lanes[lane]); + int64_t start = end - duration; + setTiming(operation, i64Type, start, duration); + lanes[lane] = start - switchPenalty; + lineLatest[input] = start; + }; + + for (mlir::Operation *operation : llvm::reverse(operations)) { + if (auto drive = mlir::dyn_cast(operation)) { + place(operation, drive.getLine(), drive.getUpdatedLine(), + getWaveformDuration(drive.getPulse()), driveLanes, 0); + } else if (auto readout = mlir::dyn_cast(operation)) { + place(operation, readout.getLine(), readout.getUpdatedLine(), + getWaveformDuration(readout.getPulse()), readoutLanes, + readoutLatency); + } else if (auto wait = mlir::dyn_cast(operation)) { + int64_t duration = getWaitDuration(wait); + int64_t start = latestFor(wait.getUpdatedLine()) - duration; + setTiming(operation, i64Type, start, duration); + lineLatest[wait.getLine()] = start; + } else if (auto sync = mlir::dyn_cast(operation)) { + int64_t syncTime = makespan; + for (auto result : sync.getResults()) + syncTime = std::min(syncTime, latestFor(result)); + for (auto line : sync.getLines()) + lineLatest[line] = syncTime; + } + } +} + +template +struct PulseSchedulePass + : public mlir::PassWrapper, + mlir::OperationPass> { + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(PulseSchedulePass) + + llvm::StringRef getArgument() const override { + return IsAlap ? "pulse-schedule-alap" : "pulse-schedule-asap"; + } + + llvm::StringRef getDescription() const override { + return IsAlap ? "Assign a dependency-correct ALAP pulse schedule" + : "Assign a dependency-correct ASAP pulse schedule"; + } + + void runOnOperation() override { + if constexpr (IsAlap) + scheduleAlap(this->getOperation()); + else + scheduleAsap(this->getOperation()); + } +}; + +struct PulseScheduleRcpPass + : public mlir::PassWrapper> { + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(PulseScheduleRcpPass) + + PulseScheduleRcpPass(int64_t maxDrives, int64_t maxReadouts, + int64_t readoutLatency, int64_t switchPenalty, + bool isAlap) + : maxDrives(maxDrives), maxReadouts(maxReadouts), + readoutLatency(readoutLatency), switchPenalty(switchPenalty), + isAlap(isAlap) {} + + llvm::StringRef getArgument() const override { return "pulse-schedule-rcp"; } + llvm::StringRef getDescription() const override { + return "Assign a resource-constrained pulse schedule"; + } + void runOnOperation() override { + scheduleRcp(getOperation(), maxDrives, maxReadouts, readoutLatency, + switchPenalty, isAlap); + } + + int64_t maxDrives; + int64_t maxReadouts; + int64_t readoutLatency; + int64_t switchPenalty; + bool isAlap; +}; + +} // namespace + +namespace pulse { +std::unique_ptr createPulseScheduleAsapPass() { + return std::make_unique>(); +} + +std::unique_ptr createPulseScheduleAlapPass() { + return std::make_unique>(); +} + +std::unique_ptr createPulseScheduleRcpPass(int64_t maxDrives, + int64_t maxReadouts, + int64_t readoutLatency, + int64_t switchPenalty, + bool isAlap) { + return std::make_unique( + maxDrives, maxReadouts, readoutLatency, switchPenalty, isAlap); +} +} // namespace pulse diff --git a/pulse/core/mlir/transforms/VirtualZ.cpp b/pulse/core/mlir/transforms/VirtualZ.cpp new file mode 100644 index 00000000000..07e674aaa4d --- /dev/null +++ b/pulse/core/mlir/transforms/VirtualZ.cpp @@ -0,0 +1,91 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +// Virtual-Z pass: fold shift_phase ops into the next drive's waveform phase. + +#include "mlir/Dialect/Arith/IR/Arith.h" +#include "mlir/Dialect/Func/IR/FuncOps.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/IR/PatternMatch.h" +#include "mlir/Pass/Pass.h" +#include "mlir/Transforms/GreedyPatternRewriteDriver.h" + +#include "cudaq-pulse/Dialect/Pulse/PulseDialect.h.inc" + +#define GET_TYPEDEF_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseTypes.h.inc" +#define GET_OP_CLASSES +#include "cudaq-pulse/Dialect/Pulse/PulseOps.h.inc" + +namespace { + +struct FoldShiftPhaseIntoDrive + : public mlir::OpRewritePattern { + using OpRewritePattern::OpRewritePattern; + + mlir::LogicalResult + matchAndRewrite(pulse::ShiftPhaseOp op, + mlir::PatternRewriter &rewriter) const override { + auto result = op.getResult(); + if (!result.hasOneUse()) + return mlir::failure(); + + auto *user = *result.getUsers().begin(); + auto driveOp = mlir::dyn_cast(user); + if (!driveOp || driveOp.getTone() != result) + return mlir::failure(); + + double existing = 0.0; + if (auto attr = + driveOp->getAttrOfType("frame_phase_offset")) + existing = attr.getValueAsDouble(); + + auto phaseVal = op.getPhaseRad(); + if (auto cst = phaseVal.getDefiningOp()) { + double delta = cst.value().convertToDouble(); + driveOp->setAttr("frame_phase_offset", + rewriter.getF64FloatAttr(existing + delta)); + } else { + return mlir::failure(); + } + + // Drive now consumes the original tone (input to shift_phase) + driveOp.getToneMutable().assign(op.getTone()); + rewriter.replaceOp(op, op.getTone()); + return mlir::success(); + } +}; + +struct VirtualZPass + : public mlir::PassWrapper> { + + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(VirtualZPass) + + llvm::StringRef getArgument() const override { return "pulse-virtual-z"; } + llvm::StringRef getDescription() const override { + return "Fold shift_phase ops into adjacent drive ops as phase attributes"; + } + + void runOnOperation() override { + mlir::RewritePatternSet patterns(&getContext()); + patterns.add(&getContext()); + mlir::GreedyRewriteConfig config; + if (mlir::failed(mlir::applyPatternsGreedily(getOperation(), + std::move(patterns), config))) + signalPassFailure(); + } +}; + +} // namespace + +namespace pulse { +std::unique_ptr createVirtualZPass() { + return std::make_unique(); +} +} // namespace pulse diff --git a/pulse/core/runtime/CMakeLists.txt b/pulse/core/runtime/CMakeLists.txt new file mode 100644 index 00000000000..f22c31e95a7 --- /dev/null +++ b/pulse/core/runtime/CMakeLists.txt @@ -0,0 +1,9 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_subdirectory(cudm) diff --git a/pulse/core/runtime/cudm/CMakeLists.txt b/pulse/core/runtime/cudm/CMakeLists.txt new file mode 100644 index 00000000000..5ecb10d6a69 --- /dev/null +++ b/pulse/core/runtime/cudm/CMakeLists.txt @@ -0,0 +1,51 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +add_library(cudm_runtime SHARED + cudm_runtime.cpp +) + +# Use build/install-interface generator expressions: the raw source path is only +# valid while building from this source tree, whereas consumers of an installed +# cudaq-pulse resolve the header from the install prefix. +target_include_directories(cudm_runtime PUBLIC + $ + $ +) + +target_link_libraries(cudm_runtime PRIVATE + cuDensityMat::cuDensityMat + CUDA::cudart + ${CMAKE_DL_LIBS}) + +get_target_property(_cudensitymat_location + cuDensityMat::cuDensityMat IMPORTED_LOCATION) +if(_cudensitymat_location) + get_filename_component(_cudensitymat_library_dir + "${_cudensitymat_location}" DIRECTORY) + set_target_properties(cudm_runtime PROPERTIES + BUILD_RPATH "${_cudensitymat_library_dir}" + INSTALL_RPATH "${_cudensitymat_library_dir}") +endif() + +set_target_properties(cudm_runtime PROPERTIES + OUTPUT_NAME "cudm-runtime" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" + VERSION 0.1.0 + SOVERSION 0 +) + +# The public C ABI header ships alongside the library so INSTALL_INTERFACE:include +# resolves for consumers of an installed pulse tree. +install(TARGETS cudm_runtime + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib + COMPONENT CudaqPulse +) + +install(FILES cudm_runtime.h DESTINATION include COMPONENT CudaqPulse) diff --git a/pulse/core/runtime/cudm/cudm_runtime.cpp b/pulse/core/runtime/cudm/cudm_runtime.cpp new file mode 100644 index 00000000000..bab4742c990 --- /dev/null +++ b/pulse/core/runtime/cudm/cudm_runtime.cpp @@ -0,0 +1,1020 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +#include "cudm_runtime.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +struct HandleData { + cudensitymatHandle_t native = nullptr; +}; + +struct StateData { + HandleData *owner = nullptr; + cudensitymatState_t native = nullptr; + std::vector modeExtents; + std::vector buffers; + std::vector bufferSizes; + int32_t purity = 0; + int32_t dataType = 16; +}; + +struct WorkspaceData { + HandleData *owner = nullptr; + cudensitymatWorkspaceDescriptor_t native = nullptr; + void *deviceBuffer = nullptr; + size_t deviceBufferSize = 0; +}; + +struct OperatorData { + HandleData *owner = nullptr; + cudensitymatOperator_t native = nullptr; +}; + +struct ElementaryOpData { + HandleData *owner = nullptr; + cudensitymatElementaryOperator_t native = nullptr; + void *tensorData = nullptr; +}; + +struct OpTermData { + HandleData *owner = nullptr; + cudensitymatOperatorTerm_t native = nullptr; + std::vector callbackSlots; +}; + +struct WaveformCallback { + bool active = false; + int32_t kind = 0; + std::vector parameters; +}; + +constexpr size_t callbackCapacity = 256; +std::array waveformCallbacks; +std::mutex waveformCallbackMutex; + +int32_t evaluateWaveformCallback(size_t slot, double time, + cudaDataType_t dataType, void *scalarStorage) { + WaveformCallback callback; + { + std::lock_guard lock(waveformCallbackMutex); + if (slot >= waveformCallbacks.size() || !waveformCallbacks[slot].active) + return 1; + callback = waveformCallbacks[slot]; + } + if (!scalarStorage || callback.parameters.size() < 10) + return 2; + const auto &p = callback.parameters; + const double localTime = time - p[0]; + const double duration = p[1]; + double real = 0.0; + double imaginary = 0.0; + if (localTime >= 0.0 && localTime < duration && duration > 0.0) { + const double centered = localTime - duration / 2.0; + switch (callback.kind) { + case 1: // square + real = p[2]; + imaginary = p[3]; + break; + case 2: { // Gaussian + const double gaussian = + std::exp(-0.5 * centered * centered / (p[4] * p[4])); + real = p[2] * gaussian; + break; + } + case 3: { // DRAG: Gaussian plus beta times its derivative. + const double gaussian = + std::exp(-0.5 * centered * centered / (p[4] * p[4])); + real = p[2] * gaussian; + imaginary = -p[5] * centered * real / (p[4] * p[4]); + break; + } + case 4: // raised cosine + real = p[2] * 0.5 * (1.0 - std::cos(2.0 * M_PI * localTime / duration)); + break; + case 5: // smooth tanh ramp from zero to the requested amplitude + real = + p[2] * 0.5 * (1.0 + std::tanh((localTime - duration / 2.0) / p[4])); + break; + case 6: { // Gaussian-square + const double edge = p[6]; + if (localTime < edge) { + const double x = localTime - edge; + real = p[2] * std::exp(-0.5 * x * x / (p[4] * p[4])); + } else if (localTime >= duration - edge) { + const double x = localTime - (duration - edge); + real = p[2] * std::exp(-0.5 * x * x / (p[4] * p[4])); + } else { + real = p[2]; + } + break; + } + case 7: { // custom samples, held piecewise constant over the duration + const size_t sampleCount = p.size() - 10; + if (sampleCount > 0) { + const auto index = + std::min(sampleCount - 1, + static_cast(localTime * sampleCount / duration)); + real = p[10 + index]; + } + break; + } + default: + return 3; + } + } + + const double angle = p[7] + p[9] * localTime; + const double cosine = std::cos(angle); + const double sine = std::sin(angle); + const double rotatedReal = real * cosine - imaginary * sine; + const double rotatedImaginary = real * sine + imaginary * cosine; + // H_control = (Re(envelope) X + Im(envelope) Y) / 2. + const double value = 0.5 * (p[8] == 0.0 ? rotatedReal : rotatedImaginary); + if (dataType == CUDA_C_32F) + *static_cast(scalarStorage) = + make_cuFloatComplex(static_cast(value), 0.0F); + else if (dataType == CUDA_C_64F) + *static_cast(scalarStorage) = + make_cuDoubleComplex(value, 0.0); + else + return 4; + return 0; +} + +template +int32_t waveformCallback(double time, int64_t, int32_t, const double *, + cudaDataType_t dataType, void *scalarStorage, + cudaStream_t) { + return evaluateWaveformCallback(Slot, time, dataType, scalarStorage); +} + +template +constexpr auto makeWaveformCallbackTable(std::index_sequence) { + return std::array{ + &waveformCallback...}; +} + +constexpr auto waveformCallbackTable = + makeWaveformCallbackTable(std::make_index_sequence{}); + +std::optional allocateCallbackSlot(int32_t kind, + const double *parameters, + int32_t parameterCount) { + if (kind == 0) + return std::nullopt; + if (!parameters || parameterCount < 10) + return std::nullopt; + std::lock_guard lock(waveformCallbackMutex); + for (size_t slot = 0; slot < waveformCallbacks.size(); ++slot) { + if (!waveformCallbacks[slot].active) { + waveformCallbacks[slot].active = true; + waveformCallbacks[slot].kind = kind; + waveformCallbacks[slot].parameters.assign(parameters, + parameters + parameterCount); + return slot; + } + } + return std::nullopt; +} + +void releaseCallbackSlot(size_t slot) { + std::lock_guard lock(waveformCallbackMutex); + waveformCallbacks[slot] = {}; +} + +thread_local std::vector lastResult; +thread_local std::string lastError; + +CudmStatus fail(CudmStatus status, const char *message) { + lastError = message; + lastResult.clear(); + return status; +} + +CudmStatus checkCudm(cudensitymatStatus_t status, const char *operation) { + if (status == CUDENSITYMAT_STATUS_SUCCESS) + return CUDM_SUCCESS; + lastError = std::string(operation) + " failed with cuDensityMat status " + + std::to_string(static_cast(status)); + lastResult.clear(); + return CUDM_ERROR_CUDA; +} + +CudmStatus checkCuda(cudaError_t status, const char *operation) { + if (status == cudaSuccess) + return CUDM_SUCCESS; + lastError = std::string(operation) + " failed: " + cudaGetErrorString(status); + lastResult.clear(); + return status == cudaErrorNoDevice ? CUDM_ERROR_NO_GPU : CUDM_ERROR_CUDA; +} + +cudaDataType_t toCudaDataType(int32_t dataType) { + return dataType == 4 ? CUDA_C_32F : CUDA_C_64F; +} + +cudensitymatComputeType_t computeType(int32_t dataType) { + return dataType == 4 ? CUDENSITYMAT_COMPUTE_32F : CUDENSITYMAT_COMPUTE_64F; +} + +CudmStatus initializeBasisZero(StateData *state) { + auto status = checkCudm(cudensitymatStateInitializeZero( + state->owner->native, state->native, nullptr), + "cudensitymatStateInitializeZero"); + if (status != CUDM_SUCCESS) + return status; + if (state->buffers.empty() || state->bufferSizes.front() == 0) + return fail(CUDM_ERROR_INVALID_STATE, + "cuDensityMat state has no component storage"); + + if (state->dataType == 4) { + const cuFloatComplex one = make_cuFloatComplex(1.0F, 0.0F); + return checkCuda(cudaMemcpy(state->buffers.front(), &one, sizeof(one), + cudaMemcpyHostToDevice), + "cudaMemcpy(initial state)"); + } + const cuDoubleComplex one = make_cuDoubleComplex(1.0, 0.0); + return checkCuda(cudaMemcpy(state->buffers.front(), &one, sizeof(one), + cudaMemcpyHostToDevice), + "cudaMemcpy(initial state)"); +} + +CudmStatus setZero(StateData *state) { + return checkCudm(cudensitymatStateInitializeZero(state->owner->native, + state->native, nullptr), + "cudensitymatStateInitializeZero"); +} + +CudmStatus setFactor(void *deviceFactor, int32_t dataType, + cuDoubleComplex value) { + if (dataType == 4) { + const cuFloatComplex converted = make_cuFloatComplex( + static_cast(cuCreal(value)), static_cast(cuCimag(value))); + return checkCuda(cudaMemcpy(deviceFactor, &converted, sizeof(converted), + cudaMemcpyHostToDevice), + "cudaMemcpy(state factor)"); + } + return checkCuda( + cudaMemcpy(deviceFactor, &value, sizeof(value), cudaMemcpyHostToDevice), + "cudaMemcpy(state factor)"); +} + +CudmStatus accumulate(StateData *source, StateData *destination, + void *deviceFactor, cuDoubleComplex factor) { + auto status = setFactor(deviceFactor, source->dataType, factor); + if (status != CUDM_SUCCESS) + return status; + return checkCudm(cudensitymatStateComputeAccumulation( + source->owner->native, source->native, + destination->native, deviceFactor, nullptr), + "cudensitymatStateComputeAccumulation"); +} + +CudmStatus copyState(StateData *source, StateData *destination, + void *deviceFactor) { + auto status = setZero(destination); + if (status != CUDM_SUCCESS) + return status; + return accumulate(source, destination, deviceFactor, + make_cuDoubleComplex(1.0, 0.0)); +} + +CudmStatus prepareWorkspace(HandleData *handle, OperatorData *op, + StateData *stateIn, StateData *stateOut, + WorkspaceData *workspace) { + auto status = checkCudm(cudensitymatOperatorPrepareAction( + handle->native, op->native, stateIn->native, + stateOut->native, computeType(stateIn->dataType), + std::numeric_limits::max(), + workspace->native, nullptr), + "cudensitymatOperatorPrepareAction"); + if (status != CUDM_SUCCESS) + return status; + + size_t required = 0; + status = checkCudm(cudensitymatWorkspaceGetMemorySize( + handle->native, workspace->native, + CUDENSITYMAT_MEMSPACE_DEVICE, + CUDENSITYMAT_WORKSPACE_SCRATCH, &required), + "cudensitymatWorkspaceGetMemorySize"); + if (status != CUDM_SUCCESS || required <= workspace->deviceBufferSize) + return status; + + if (workspace->deviceBuffer) + cudaFree(workspace->deviceBuffer); + workspace->deviceBuffer = nullptr; + workspace->deviceBufferSize = 0; + status = checkCuda(cudaMalloc(&workspace->deviceBuffer, required), + "cudaMalloc(workspace)"); + if (status != CUDM_SUCCESS) + return status; + workspace->deviceBufferSize = required; + return checkCudm( + cudensitymatWorkspaceSetMemory( + handle->native, workspace->native, CUDENSITYMAT_MEMSPACE_DEVICE, + CUDENSITYMAT_WORKSPACE_SCRATCH, workspace->deviceBuffer, required), + "cudensitymatWorkspaceSetMemory"); +} + +CudmStatus rhs(HandleData *handle, OperatorData *op, StateData *stateIn, + StateData *stateOut, WorkspaceData *workspace, double time) { + auto status = setZero(stateOut); + if (status != CUDM_SUCCESS) + return status; + return checkCudm(cudensitymatOperatorComputeAction( + handle->native, op->native, time, 1, 0, nullptr, + stateIn->native, stateOut->native, workspace->native, + nullptr), + "cudensitymatOperatorComputeAction"); +} + +// Pulse callbacks are right-continuous and use half-open intervals. When a +// stage must sample the right endpoint of the current step, pull the sample +// just inside the interval so a discontinuity at the next pulse boundary does +// not bleed the following pulse's coefficients into this step. The next step's +// first stage still samples the new pulse at the exact boundary. +double boundarySafeSampleTime(double time, double dt) { + const double endpoint = time + dt; + const double boundaryEpsilon = std::max( + std::abs(dt) * 1.0e-9, std::numeric_limits::epsilon() * + std::max(1.0, std::abs(endpoint)) * 8.0); + return std::max(time, endpoint - boundaryEpsilon); +} + +StateData *allocateLike(HandleData *handle, StateData *prototype) { + CudmState state = nullptr; + if (cudm_state_alloc(handle, &state, prototype->modeExtents.data(), + static_cast(prototype->modeExtents.size()), + prototype->purity, prototype->dataType) != CUDM_SUCCESS) + return nullptr; + auto *result = static_cast(state); + if (setZero(result) != CUDM_SUCCESS) { + cudm_state_destroy(result); + return nullptr; + } + return result; +} + +CudmStatus integrateStep(HandleData *handle, OperatorData *op, + StateData *current, StateData *next, + StateData *temporary, StateData *k1, StateData *k2, + StateData *k3, StateData *k4, WorkspaceData *workspace, + void *deviceFactor, double time, double dt, + int32_t integrator) { + auto status = rhs(handle, op, current, k1, workspace, time); + if (status != CUDM_SUCCESS) + return status; + + if (integrator == 2) { + status = copyState(current, next, deviceFactor); + if (status == CUDM_SUCCESS) + status = + accumulate(k1, next, deviceFactor, make_cuDoubleComplex(dt, 0.0)); + return status; + } + + // RK2 midpoint, or the first half of RK4. + status = copyState(current, temporary, deviceFactor); + if (status == CUDM_SUCCESS) + status = accumulate(k1, temporary, deviceFactor, + make_cuDoubleComplex(0.5 * dt, 0.0)); + if (status == CUDM_SUCCESS) + status = rhs(handle, op, temporary, k2, workspace, time + 0.5 * dt); + if (status != CUDM_SUCCESS) + return status; + + if (integrator == 3) { + status = copyState(current, next, deviceFactor); + if (status == CUDM_SUCCESS) + status = + accumulate(k2, next, deviceFactor, make_cuDoubleComplex(dt, 0.0)); + return status; + } + + status = copyState(current, temporary, deviceFactor); + if (status == CUDM_SUCCESS) + status = accumulate(k2, temporary, deviceFactor, + make_cuDoubleComplex(0.5 * dt, 0.0)); + if (status == CUDM_SUCCESS) + status = rhs(handle, op, temporary, k3, workspace, time + 0.5 * dt); + if (status == CUDM_SUCCESS) + status = copyState(current, temporary, deviceFactor); + if (status == CUDM_SUCCESS) + status = + accumulate(k3, temporary, deviceFactor, make_cuDoubleComplex(dt, 0.0)); + // Sample the final RK stage from inside the current interval so a + // discontinuity at the next pulse boundary does not shorten this interval by + // dt / 6 (see boundarySafeSampleTime). + if (status == CUDM_SUCCESS) + status = rhs(handle, op, temporary, k4, workspace, + boundarySafeSampleTime(time, dt)); + if (status == CUDM_SUCCESS) + status = copyState(current, next, deviceFactor); + if (status == CUDM_SUCCESS) + status = + accumulate(k1, next, deviceFactor, make_cuDoubleComplex(dt / 6.0, 0.0)); + if (status == CUDM_SUCCESS) + status = + accumulate(k2, next, deviceFactor, make_cuDoubleComplex(dt / 3.0, 0.0)); + if (status == CUDM_SUCCESS) + status = + accumulate(k3, next, deviceFactor, make_cuDoubleComplex(dt / 3.0, 0.0)); + if (status == CUDM_SUCCESS) + status = + accumulate(k4, next, deviceFactor, make_cuDoubleComplex(dt / 6.0, 0.0)); + return status; +} + +// Magnus expansion (first-order / midpoint) with a Taylor series for the +// matrix exponential action. Mirrors cudaq::integrators::magnus_expansion: +// with the Liouvillian L frozen at the interval midpoint, advance +// next = sum_{k=0}^{N} (dt L)^k / k! * current. +// Each Taylor term reuses the previous one via w_k = L * w_{k-1}, so a single +// Liouvillian action per term suffices. `result` receives the new state; +// `w` and `Lw` are scratch buffers. +CudmStatus integrateStepMagnus(HandleData *handle, OperatorData *op, + StateData *current, StateData *result, + StateData *w, StateData *Lw, + WorkspaceData *workspace, void *deviceFactor, + double time, double dt, int numTaylorTerms) { + const double tMid = time + 0.5 * dt; + // k = 0 term: result = current; running vector w_0 = current. + auto status = copyState(current, result, deviceFactor); + if (status == CUDM_SUCCESS) + status = copyState(current, w, deviceFactor); + + double coeff = 1.0; + for (int k = 1; status == CUDM_SUCCESS && k <= numTaylorTerms; ++k) { + status = rhs(handle, op, w, Lw, workspace, tMid); + if (status != CUDM_SUCCESS) + break; + coeff *= dt / static_cast(k); + status = + accumulate(Lw, result, deviceFactor, make_cuDoubleComplex(coeff, 0.0)); + // Advance the running vector: w_k <- L * w_{k-1}. Swapping reuses buffers; + // the next rhs() zero-initializes its output before accumulating. + std::swap(w, Lw); + } + return status; +} + +// Crank-Nicolson predictor-corrector. Mirrors +// cudaq::integrators::crank_nicolson: +// k1 = L(t) * current +// rho_iter = current + dt * k1 (explicit predictor) +// repeat: k2 = L(t + dt) * rho_iter +// rho_iter = current + (dt/2) (k1 + k2) (trapezoidal corrector) +// The endpoint sample uses boundarySafeSampleTime so pulse discontinuities at +// the next boundary do not leak into this step. `next` receives the new state. +CudmStatus integrateStepCrankNicolson(HandleData *handle, OperatorData *op, + StateData *current, StateData *next, + StateData *k1, StateData *k2, + StateData *rhoIter, StateData *rhoNext, + WorkspaceData *workspace, + void *deviceFactor, double time, + double dt, int numCorrectorSteps) { + auto status = rhs(handle, op, current, k1, workspace, time); + if (status != CUDM_SUCCESS) + return status; + + status = copyState(current, rhoIter, deviceFactor); + if (status == CUDM_SUCCESS) + status = + accumulate(k1, rhoIter, deviceFactor, make_cuDoubleComplex(dt, 0.0)); + + const double tNext = boundarySafeSampleTime(time, dt); + for (int iter = 0; status == CUDM_SUCCESS && iter < numCorrectorSteps; + ++iter) { + status = rhs(handle, op, rhoIter, k2, workspace, tNext); + if (status == CUDM_SUCCESS) + status = copyState(current, rhoNext, deviceFactor); + if (status == CUDM_SUCCESS) + status = accumulate(k1, rhoNext, deviceFactor, + make_cuDoubleComplex(0.5 * dt, 0.0)); + if (status == CUDM_SUCCESS) + status = accumulate(k2, rhoNext, deviceFactor, + make_cuDoubleComplex(0.5 * dt, 0.0)); + std::swap(rhoIter, rhoNext); + } + + if (status == CUDM_SUCCESS) + status = copyState(rhoIter, next, deviceFactor); + return status; +} + +} // namespace + +extern "C" { + +int64_t cudm_runtime_version(void) { + return static_cast(cudensitymatGetVersion()); +} + +const char *cudm_last_error_message(void) { return lastError.c_str(); } + +CudmStatus cudm_init(CudmHandle *handle) { + if (!handle) + return fail(CUDM_ERROR_INVALID_HANDLE, "cudm_init received a null output"); + *handle = nullptr; + auto *data = new (std::nothrow) HandleData{}; + if (!data) + return fail(CUDM_ERROR_INTERNAL, "failed to allocate cuDensityMat handle"); + auto status = + checkCudm(cudensitymatCreate(&data->native), "cudensitymatCreate"); + if (status != CUDM_SUCCESS) { + delete data; + return status; + } + lastError.clear(); + lastResult.clear(); + *handle = data; + return CUDM_SUCCESS; +} + +CudmStatus cudm_destroy(CudmHandle handle) { + if (!handle) + return CUDM_SUCCESS; + auto *data = static_cast(handle); + auto status = + checkCudm(cudensitymatDestroy(data->native), "cudensitymatDestroy"); + delete data; + return status; +} + +CudmStatus cudm_state_alloc(CudmHandle handle, CudmState *state, + const int64_t *modeExtents, int32_t numModes, + int32_t purity, int32_t dataType) { + if (state) + *state = nullptr; + if (!handle || !state || !modeExtents || numModes <= 0) + return fail(CUDM_ERROR_INVALID_HANDLE, + "cudm_state_alloc received invalid arguments"); + if (dataType != 4 && dataType != 16) + return fail(CUDM_ERROR_INTERNAL, "unsupported cuDensityMat data type"); + auto *owner = static_cast(handle); + auto *data = new (std::nothrow) StateData{}; + if (!data) + return fail(CUDM_ERROR_INTERNAL, "failed to allocate state wrapper"); + data->owner = owner; + data->modeExtents.assign(modeExtents, modeExtents + numModes); + data->purity = purity; + data->dataType = dataType; + auto nativePurity = purity == 0 ? CUDENSITYMAT_STATE_PURITY_PURE + : CUDENSITYMAT_STATE_PURITY_MIXED; + auto status = checkCudm(cudensitymatCreateState(owner->native, nativePurity, + numModes, modeExtents, 0, + toCudaDataType(dataType), + &data->native), + "cudensitymatCreateState"); + int32_t numComponents = 0; + if (status == CUDM_SUCCESS) + status = checkCudm(cudensitymatStateGetNumComponents( + owner->native, data->native, &numComponents), + "cudensitymatStateGetNumComponents"); + if (status == CUDM_SUCCESS && numComponents <= 0) + status = fail(CUDM_ERROR_INVALID_STATE, + "cuDensityMat created a state with no components"); + if (status == CUDM_SUCCESS) { + data->buffers.resize(numComponents, nullptr); + data->bufferSizes.resize(numComponents, 0); + status = checkCudm(cudensitymatStateGetComponentStorageSize( + owner->native, data->native, numComponents, + data->bufferSizes.data()), + "cudensitymatStateGetComponentStorageSize"); + } + for (int32_t i = 0; status == CUDM_SUCCESS && i < numComponents; ++i) + status = checkCuda(cudaMalloc(&data->buffers[i], data->bufferSizes[i]), + "cudaMalloc(state component)"); + if (status == CUDM_SUCCESS) + status = checkCudm(cudensitymatStateAttachComponentStorage( + owner->native, data->native, numComponents, + data->buffers.data(), data->bufferSizes.data()), + "cudensitymatStateAttachComponentStorage"); + if (status == CUDM_SUCCESS) + status = initializeBasisZero(data); + if (status != CUDM_SUCCESS) { + for (void *buffer : data->buffers) + if (buffer) + cudaFree(buffer); + if (data->native) + cudensitymatDestroyState(data->native); + delete data; + return status; + } + *state = data; + return CUDM_SUCCESS; +} + +CudmStatus cudm_state_destroy(CudmState state) { + if (!state) + return CUDM_SUCCESS; + auto *data = static_cast(state); + auto status = checkCudm(cudensitymatDestroyState(data->native), + "cudensitymatDestroyState"); + for (void *buffer : data->buffers) + if (buffer) + cudaFree(buffer); + delete data; + return status; +} + +CudmStatus cudm_state_init_zero(CudmHandle handle, CudmState state) { + if (!handle) + return fail(CUDM_ERROR_INVALID_HANDLE, "state init received null handle"); + if (!state) + return fail(CUDM_ERROR_INVALID_STATE, "state init received null state"); + return initializeBasisZero(static_cast(state)); +} + +CudmStatus cudm_state_capture(CudmState state) { + // Generated LLVM currently continues into cleanup after a runtime call + // fails. Preserve the first diagnostic and, critically, do not turn an + // unsuccessful evolution into an apparently valid |0> result. + if (!lastError.empty()) { + lastResult.clear(); + return CUDM_ERROR_INTERNAL; + } + if (!state) + return fail(CUDM_ERROR_INVALID_STATE, "cannot capture a null state"); + auto *data = static_cast(state); + const size_t total = [&] { + size_t size = 0; + for (auto componentSize : data->bufferSizes) + size += componentSize; + return size; + }(); + lastResult.resize(total); + size_t offset = 0; + for (size_t i = 0; i < data->buffers.size(); ++i) { + auto status = + checkCuda(cudaMemcpy(lastResult.data() + offset, data->buffers[i], + data->bufferSizes[i], cudaMemcpyDeviceToHost), + "cudaMemcpy(capture state)"); + if (status != CUDM_SUCCESS) + return status; + offset += data->bufferSizes[i]; + } + lastError.clear(); + return CUDM_SUCCESS; +} + +int64_t cudm_last_result_size(void) { + return static_cast(lastResult.size()); +} + +CudmStatus cudm_last_result_copy(void *destination, int64_t destinationSize) { + if (!destination || destinationSize < 0 || + static_cast(destinationSize) < lastResult.size()) + return fail(CUDM_ERROR_INTERNAL, "result destination is too small"); + std::memcpy(destination, lastResult.data(), lastResult.size()); + return CUDM_SUCCESS; +} + +CudmStatus cudm_workspace_create(CudmHandle handle, CudmWorkspace *workspace) { + if (workspace) + *workspace = nullptr; + if (!handle || !workspace) + return fail(CUDM_ERROR_INVALID_HANDLE, + "workspace create received invalid arguments"); + auto *data = new (std::nothrow) WorkspaceData{}; + if (!data) + return fail(CUDM_ERROR_INTERNAL, "failed to allocate workspace wrapper"); + data->owner = static_cast(handle); + auto status = + checkCudm(cudensitymatCreateWorkspace(data->owner->native, &data->native), + "cudensitymatCreateWorkspace"); + if (status != CUDM_SUCCESS) { + delete data; + return status; + } + *workspace = data; + return CUDM_SUCCESS; +} + +CudmStatus cudm_workspace_destroy(CudmWorkspace workspace) { + if (!workspace) + return CUDM_SUCCESS; + auto *data = static_cast(workspace); + auto status = checkCudm(cudensitymatDestroyWorkspace(data->native), + "cudensitymatDestroyWorkspace"); + if (data->deviceBuffer) + cudaFree(data->deviceBuffer); + delete data; + return status; +} + +CudmStatus cudm_operator_create(CudmHandle handle, CudmOperator *op, + const int64_t *modeExtents, int32_t numModes) { + if (op) + *op = nullptr; + if (!handle || !op || !modeExtents || numModes <= 0) + return fail(CUDM_ERROR_INVALID_HANDLE, + "operator create received invalid arguments"); + auto *data = new (std::nothrow) OperatorData{}; + if (!data) + return fail(CUDM_ERROR_INTERNAL, "failed to allocate operator wrapper"); + data->owner = static_cast(handle); + auto status = + checkCudm(cudensitymatCreateOperator(data->owner->native, numModes, + modeExtents, &data->native), + "cudensitymatCreateOperator"); + if (status != CUDM_SUCCESS) { + delete data; + return status; + } + *op = data; + return CUDM_SUCCESS; +} + +CudmStatus cudm_operator_destroy(CudmOperator op) { + if (!op) + return CUDM_SUCCESS; + auto *data = static_cast(op); + auto status = checkCudm(cudensitymatDestroyOperator(data->native), + "cudensitymatDestroyOperator"); + delete data; + return status; +} + +CudmStatus cudm_elementary_op_create(CudmHandle handle, + CudmElementaryOp *elementaryOp, + const void *tensorData, + int64_t tensorValueCount, + const int64_t *modeExtents, + int32_t numModes, int32_t dataType) { + if (elementaryOp) + *elementaryOp = nullptr; + if (!handle || !elementaryOp || !tensorData || tensorValueCount <= 0 || + !modeExtents || numModes <= 0) + return fail(CUDM_ERROR_INVALID_HANDLE, + "elementary operator create received invalid arguments"); + auto *data = new (std::nothrow) ElementaryOpData{}; + if (!data) + return fail(CUDM_ERROR_INTERNAL, + "failed to allocate elementary operator wrapper"); + data->owner = static_cast(handle); + const size_t bytes = static_cast(tensorValueCount) * sizeof(double); + auto status = checkCuda(cudaMalloc(&data->tensorData, bytes), + "cudaMalloc(elementary tensor)"); + if (status == CUDM_SUCCESS) + status = checkCuda( + cudaMemcpy(data->tensorData, tensorData, bytes, cudaMemcpyHostToDevice), + "cudaMemcpy(elementary tensor)"); + if (status == CUDM_SUCCESS) + status = + checkCudm(cudensitymatCreateElementaryOperator( + data->owner->native, numModes, modeExtents, + CUDENSITYMAT_OPERATOR_SPARSITY_NONE, 0, nullptr, + toCudaDataType(dataType), data->tensorData, + cudensitymatTensorCallbackNone, + cudensitymatTensorGradientCallbackNone, &data->native), + "cudensitymatCreateElementaryOperator"); + if (status != CUDM_SUCCESS) { + if (data->tensorData) + cudaFree(data->tensorData); + delete data; + return status; + } + *elementaryOp = data; + return CUDM_SUCCESS; +} + +CudmStatus cudm_elementary_op_destroy(CudmElementaryOp elementaryOp) { + if (!elementaryOp) + return CUDM_SUCCESS; + auto *data = static_cast(elementaryOp); + auto status = checkCudm(cudensitymatDestroyElementaryOperator(data->native), + "cudensitymatDestroyElementaryOperator"); + if (data->tensorData) + cudaFree(data->tensorData); + delete data; + return status; +} + +CudmStatus cudm_op_term_create(CudmHandle handle, CudmOpTerm *term, + const int64_t *modeExtents, int32_t numModes) { + if (term) + *term = nullptr; + if (!handle || !term || !modeExtents || numModes <= 0) + return fail(CUDM_ERROR_INVALID_HANDLE, + "operator term create received invalid arguments"); + auto *data = new (std::nothrow) OpTermData{}; + if (!data) + return fail(CUDM_ERROR_INTERNAL, + "failed to allocate operator term wrapper"); + data->owner = static_cast(handle); + auto status = + checkCudm(cudensitymatCreateOperatorTerm(data->owner->native, numModes, + modeExtents, &data->native), + "cudensitymatCreateOperatorTerm"); + if (status != CUDM_SUCCESS) { + delete data; + return status; + } + *term = data; + return CUDM_SUCCESS; +} + +CudmStatus cudm_op_term_append(CudmHandle handle, CudmOpTerm term, + const CudmElementaryOp *elementaryOps, + const int32_t *modesActedOn, + const int32_t *duality, int32_t numElementaryOps, + double coefficientReal, double coefficientImag, + int32_t callbackKind, + const double *callbackParameters, + int32_t callbackParameterCount) { + if (!handle || !term || !elementaryOps || !modesActedOn || !duality || + numElementaryOps <= 0) + return fail(CUDM_ERROR_INVALID_HANDLE, + "operator term append received invalid arguments"); + std::vector nativeOps; + nativeOps.reserve(numElementaryOps); + for (int32_t i = 0; i < numElementaryOps; ++i) { + if (!elementaryOps[i]) + return fail(CUDM_ERROR_INTERNAL, + "operator term contains a null elementary operator"); + nativeOps.push_back( + static_cast(elementaryOps[i])->native); + } + auto *owner = static_cast(handle); + auto *termData = static_cast(term); + cudensitymatWrappedScalarCallback_t wrappedCallback = + cudensitymatScalarCallbackNone; + std::optional callbackSlot; + if (callbackKind != 0) { + callbackSlot = allocateCallbackSlot(callbackKind, callbackParameters, + callbackParameterCount); + if (!callbackSlot) + return fail(CUDM_ERROR_INTERNAL, + "could not allocate a waveform callback slot"); + wrappedCallback.callback = waveformCallbackTable[*callbackSlot]; + wrappedCallback.device = CUDENSITYMAT_CALLBACK_DEVICE_CPU; + wrappedCallback.wrapper = nullptr; + } + auto status = + checkCudm(cudensitymatOperatorTermAppendElementaryProduct( + owner->native, termData->native, numElementaryOps, + nativeOps.data(), modesActedOn, duality, + make_cuDoubleComplex(coefficientReal, coefficientImag), + wrappedCallback, cudensitymatScalarGradientCallbackNone), + "cudensitymatOperatorTermAppendElementaryProduct"); + if (status == CUDM_SUCCESS && callbackSlot) + termData->callbackSlots.push_back(*callbackSlot); + else if (callbackSlot) + releaseCallbackSlot(*callbackSlot); + return status; +} + +CudmStatus cudm_op_term_destroy(CudmOpTerm term) { + if (!term) + return CUDM_SUCCESS; + auto *data = static_cast(term); + auto status = checkCudm(cudensitymatDestroyOperatorTerm(data->native), + "cudensitymatDestroyOperatorTerm"); + for (size_t slot : data->callbackSlots) + releaseCallbackSlot(slot); + delete data; + return status; +} + +CudmStatus cudm_operator_append(CudmHandle handle, CudmOperator op, + CudmOpTerm term, int32_t duality, + double coefficientReal, + double coefficientImag) { + if (!handle || !op || !term) + return fail(CUDM_ERROR_INVALID_HANDLE, + "operator append received invalid arguments"); + auto *owner = static_cast(handle); + auto *opData = static_cast(op); + auto *termData = static_cast(term); + return checkCudm(cudensitymatOperatorAppendTerm( + owner->native, opData->native, termData->native, duality, + make_cuDoubleComplex(coefficientReal, coefficientImag), + cudensitymatScalarCallbackNone, + cudensitymatScalarGradientCallbackNone), + "cudensitymatOperatorAppendTerm"); +} + +CudmStatus cudm_evolve_step(CudmHandle handle, CudmOperator op, + CudmState stateIn, CudmState stateOut, + CudmWorkspace workspace, double time, double dt, + int32_t integrator) { + return cudm_evolve(handle, op, stateIn, stateOut, workspace, time, time + dt, + 1, integrator); +} + +CudmStatus cudm_evolve(CudmHandle handle, CudmOperator op, CudmState stateIn, + CudmState stateOut, CudmWorkspace workspace, + double timeStart, double timeEnd, int64_t numSteps, + int32_t integrator) { + if (!handle || !op || !stateIn || !stateOut || !workspace) + return fail(CUDM_ERROR_INVALID_HANDLE, + "cudm_evolve received a null runtime object"); + if (numSteps <= 0 || !(timeEnd > timeStart)) + return fail(CUDM_ERROR_INTERNAL, "invalid evolution interval"); + // The dialect IntegratorKind values map as: 2=rk1, 3=rk2, 4=rk4, + // 5=magnus (Taylor-series midpoint), 6=crank_nicolson (predictor-corrector). + // These mirror the mainlined cudaq::integrators algorithms of the same name, + // driven here through the cuDensityMat Liouvillian action. + if (integrator < 2 || integrator > 6) + return fail(CUDM_ERROR_INTERNAL, + "cudm-runtime supports rk1, rk2, rk4, magnus, and " + "crank_nicolson integrators"); + + // Match the mainlined integrator defaults for parity. + constexpr int kMagnusTaylorTerms = 10; + constexpr int kCrankNicolsonCorrectorSteps = 2; + + auto *owner = static_cast(handle); + auto *operatorData = static_cast(op); + auto *input = static_cast(stateIn); + auto *output = static_cast(stateOut); + auto *workspaceData = static_cast(workspace); + + std::vector scratch; + scratch.reserve(7); + for (int i = 0; i < 7; ++i) { + auto *state = allocateLike(owner, input); + if (!state) { + for (auto *allocated : scratch) + cudm_state_destroy(allocated); + return CUDM_ERROR_CUDA; + } + scratch.push_back(state); + } + auto *current = scratch[0]; + auto *next = scratch[1]; + auto *temporary = scratch[2]; + auto *k1 = scratch[3]; + auto *k2 = scratch[4]; + auto *k3 = scratch[5]; + auto *k4 = scratch[6]; + + const size_t factorSize = + input->dataType == 4 ? sizeof(cuFloatComplex) : sizeof(cuDoubleComplex); + void *deviceFactor = nullptr; + auto status = checkCuda(cudaMalloc(&deviceFactor, factorSize), + "cudaMalloc(integration factor)"); + if (status == CUDM_SUCCESS) + status = copyState(input, current, deviceFactor); + if (status == CUDM_SUCCESS) + status = prepareWorkspace(owner, operatorData, current, k1, workspaceData); + + const double dt = (timeEnd - timeStart) / static_cast(numSteps); + for (int64_t step = 0; status == CUDM_SUCCESS && step < numSteps; ++step) { + const double stepTime = timeStart + static_cast(step) * dt; + if (integrator == 5) { + // Magnus: next=result, temporary=w, k1=Lw. + status = integrateStepMagnus(owner, operatorData, current, next, + temporary, k1, workspaceData, deviceFactor, + stepTime, dt, kMagnusTaylorTerms); + } else if (integrator == 6) { + // Crank-Nicolson: k1/k2 = Liouvillian actions, k3=rho_iter, k4=rho_next. + status = integrateStepCrankNicolson( + owner, operatorData, current, next, k1, k2, k3, k4, workspaceData, + deviceFactor, stepTime, dt, kCrankNicolsonCorrectorSteps); + } else { + status = integrateStep(owner, operatorData, current, next, temporary, k1, + k2, k3, k4, workspaceData, deviceFactor, stepTime, + dt, integrator); + } + std::swap(current, next); + } + if (status == CUDM_SUCCESS) + status = copyState(current, output, deviceFactor); + if (deviceFactor) + cudaFree(deviceFactor); + for (auto *allocated : scratch) + cudm_state_destroy(allocated); + return status; +} + +CudmStatus cudm_observe(CudmHandle, CudmOperator, CudmState, CudmWorkspace, + double, double *, double *) { + return fail(CUDM_ERROR_INTERNAL, + "cudm_observe is not implemented in this research preview"); +} + +} // extern "C" diff --git a/pulse/core/runtime/cudm/cudm_runtime.h b/pulse/core/runtime/cudm/cudm_runtime.h new file mode 100644 index 00000000000..c98e795139c --- /dev/null +++ b/pulse/core/runtime/cudm/cudm_runtime.h @@ -0,0 +1,110 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +#ifndef CUDAQ_PULSE_RUNTIME_CUDM_RUNTIME_H +#define CUDAQ_PULSE_RUNTIME_CUDM_RUNTIME_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void *CudmHandle; +typedef void *CudmState; +typedef void *CudmWorkspace; +typedef void *CudmOperator; +typedef void *CudmElementaryOp; +typedef void *CudmOpTerm; + +typedef enum { + CUDM_SUCCESS = 0, + CUDM_ERROR_INVALID_HANDLE = 1, + CUDM_ERROR_INVALID_STATE = 2, + CUDM_ERROR_NO_GPU = 3, + CUDM_ERROR_CUDA = 4, + CUDM_ERROR_INTERNAL = 99, +} CudmStatus; + +// Linked cuDensityMat library version (major * 10000 + minor * 100 + patch). +int64_t cudm_runtime_version(void); + +// Context management +CudmStatus cudm_init(CudmHandle *handle); +CudmStatus cudm_destroy(CudmHandle handle); + +// State management +CudmStatus cudm_state_alloc(CudmHandle handle, CudmState *state, + const int64_t *mode_extents, int32_t num_modes, + int32_t purity, int32_t data_type); +CudmStatus cudm_state_destroy(CudmState state); +CudmStatus cudm_state_init_zero(CudmHandle handle, CudmState state); +CudmStatus cudm_state_capture(CudmState state); +int64_t cudm_last_result_size(void); +CudmStatus cudm_last_result_copy(void *destination, int64_t destination_size); +const char *cudm_last_error_message(void); + +// Workspace management +CudmStatus cudm_workspace_create(CudmHandle handle, CudmWorkspace *ws); +CudmStatus cudm_workspace_destroy(CudmWorkspace ws); + +// Operator management +CudmStatus cudm_operator_create(CudmHandle handle, CudmOperator *op, + const int64_t *mode_extents, int32_t num_modes); +CudmStatus cudm_operator_destroy(CudmOperator op); + +// Elementary operator +CudmStatus cudm_elementary_op_create(CudmHandle handle, + CudmElementaryOp *elem_op, + const void *tensor_data, + int64_t tensor_value_count, + const int64_t *mode_extents, + int32_t num_modes, int32_t data_type); +CudmStatus cudm_elementary_op_destroy(CudmElementaryOp elem_op); + +// Operator term +CudmStatus cudm_op_term_create(CudmHandle handle, CudmOpTerm *term, + const int64_t *mode_extents, int32_t num_modes); +CudmStatus cudm_op_term_append(CudmHandle handle, CudmOpTerm term, + const CudmElementaryOp *elementary_ops, + const int32_t *modes_acted_on, + const int32_t *duality, + int32_t num_elementary_ops, double coeff_real, + double coeff_imag, int32_t callback_kind, + const double *callback_parameters, + int32_t callback_parameter_count); +CudmStatus cudm_op_term_destroy(CudmOpTerm term); + +// Composite operator assembly +CudmStatus cudm_operator_append(CudmHandle handle, CudmOperator op, + CudmOpTerm term, int32_t duality, + double coeff_real, double coeff_imag); + +// Time evolution. +// The `integrator` code matches the CuDensityMat dialect IntegratorKind enum: +// 2 = rk1, 3 = rk2, 4 = rk4, 5 = magnus, 6 = crank_nicolson. +CudmStatus cudm_evolve_step(CudmHandle handle, CudmOperator op, + CudmState state_in, CudmState state_out, + CudmWorkspace ws, double t, double dt, + int32_t integrator); + +// Full evolve (high-level convenience) +CudmStatus cudm_evolve(CudmHandle handle, CudmOperator op, CudmState state_in, + CudmState state_out, CudmWorkspace ws, double t_start, + double t_end, int64_t num_steps, int32_t integrator); + +// Expectation value +CudmStatus cudm_observe(CudmHandle handle, CudmOperator op, CudmState state, + CudmWorkspace ws, double t, double *real, double *imag); + +#ifdef __cplusplus +} +#endif + +#endif // CUDAQ_PULSE_RUNTIME_CUDM_RUNTIME_H diff --git a/pulse/core/runtime/cudm/integrators/AdaptiveIntegrator.cpp b/pulse/core/runtime/cudm/integrators/AdaptiveIntegrator.cpp new file mode 100644 index 00000000000..111650cca5c --- /dev/null +++ b/pulse/core/runtime/cudm/integrators/AdaptiveIntegrator.cpp @@ -0,0 +1,208 @@ +/******************************************************************************* + * Copyright (c) 2022 - 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +#include "CuDensityMatIntegratorBase.h" +#include "CuDensityMatUtils.h" +#include "cudaq/algorithms/integrator.h" +#include "cudaq/runtime/logger/logger.h" +#include +#include +#include +#include +#include + +namespace cudaq::integrators { + +// Dormand-Prince RK5(4) adaptive integrator. +// Reference: Dormand, J. R.; Prince, P. J. (1980), "A family of embedded +// Runge-Kutta formulae", Journal of Computational and Applied Mathematics. +// +// Uses the mainlined cuDensityMat time stepper to evaluate the Liouvillian +// action f(t, y) = L(t) y, forms the embedded 5th- and 4th-order solutions, +// and adapts the step size from the scaled error between them. + +using cudmIntHelp = CuDensityMatIntegratorHelper; + +namespace { +// Butcher tableau (nodes). +constexpr std::array kNodes = {0.0, 0.2, 0.3, 0.8, + 8.0 / 9.0, 1.0, 1.0}; + +// Lower-triangular a_{ij} coefficients. +constexpr std::array, 7> kA = { + {{0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, + {0.2, 0.0, 0.0, 0.0, 0.0, 0.0}, + {3.0 / 40.0, 9.0 / 40.0, 0.0, 0.0, 0.0, 0.0}, + {44.0 / 45.0, -56.0 / 15.0, 32.0 / 9.0, 0.0, 0.0, 0.0}, + {19372.0 / 6561.0, -25360.0 / 2187.0, 64448.0 / 6561.0, -212.0 / 729.0, + 0.0, 0.0}, + {9017.0 / 3168.0, -355.0 / 33.0, 46732.0 / 5247.0, 49.0 / 176.0, + -5103.0 / 18656.0, 0.0}, + {35.0 / 384.0, 0.0, 500.0 / 1113.0, 125.0 / 192.0, -2187.0 / 6784.0, + 11.0 / 84.0}}}; + +// 5th-order solution weights. +constexpr std::array kB5 = { + 35.0 / 384.0, 0.0, 500.0 / 1113.0, 125.0 / 192.0, -2187.0 / 6784.0, + 11.0 / 84.0, 0.0}; + +// Embedded 4th-order solution weights. +constexpr std::array kB4 = {5179.0 / 57600.0, 0.0, + 7571.0 / 16695.0, 393.0 / 640.0, + -92097.0 / 339200.0, 187.0 / 2100.0, + 1.0 / 40.0}; + +// Step-size controller parameters. +constexpr double kSafetyFactor = 0.9; +constexpr double kMaxScale = 5.0; // Max step-size growth per accepted step. +constexpr double kMinScale = 0.2; // Max step-size shrink per rejected step. +constexpr double kOrder = 5.0; // Method order used for step adaptation. + +/// @brief Compute the scaled RMS error norm between the embedded solutions. +/// +/// Returns a dimensionless error scaled so that a value <= 1 means the step +/// meets the requested (rtol, atol) tolerance. +double computeErrorNorm(const CuDensityMatState &y5, + const CuDensityMatState &y4, double rtol, double atol) { + const std::size_t n = y5.getTensor().get_num_elements(); + std::vector> data5(n), data4(n); + y5.toHost(data5.data(), data5.size()); + y4.toHost(data4.data(), data4.size()); + + double errNormSq = 0.0, y5NormSq = 0.0, y4NormSq = 0.0; + for (std::size_t i = 0; i < n; ++i) { + errNormSq += std::norm(data5[i] - data4[i]); + y5NormSq += std::norm(data5[i]); + y4NormSq += std::norm(data4[i]); + } + const double errNorm = std::sqrt(errNormSq); + const double yNorm = std::sqrt(std::max(y5NormSq, y4NormSq)); + const double scale = atol + rtol * yNorm; + return errNorm / (scale * std::sqrt(static_cast(n))); +} + +/// @brief Select the next step size from the scaled error estimate. +double adaptStepSize(double errorNorm, double dtCurrent, double dtMin, + double dtMax) { + double factor; + if (errorNorm == 0.0) { + factor = kMaxScale; + } else { + factor = kSafetyFactor * std::pow(1.0 / errorNorm, 1.0 / kOrder); + factor = std::max(kMinScale, std::min(kMaxScale, factor)); + } + return std::max(dtMin, std::min(dtMax, dtCurrent * factor)); +} +} // namespace + +dopri5::dopri5(double rtol, double atol, double dt_initial, double dt_min, + double dt_max) + : m_rtol(rtol), m_atol(atol), m_dt(dt_initial), m_dt_min(dt_min), + m_dt_max(dt_max), m_t(0.0) { + if (rtol <= 0.0 || atol <= 0.0) + throw std::invalid_argument( + "dopri5 integrator requires positive rtol and atol."); + if (dt_min <= 0.0 || dt_max <= 0.0 || dt_min > dt_max) + throw std::invalid_argument( + "dopri5 integrator requires 0 < dt_min <= dt_max."); +} + +std::shared_ptr dopri5::clone() { + auto cloned = std::make_shared( + m_rtol, m_atol, m_dt, m_dt_min, m_dt_max); + cloned->m_t = this->m_t; + cloned->m_state = this->m_state; + cloned->m_system = this->m_system; + cloned->m_schedule = this->m_schedule; + cloned->m_stats = this->m_stats; + return cloned; +} + +void dopri5::setState(const cudaq::state &initialState, double t0) { + cudmIntHelp::setState(m_state, m_t, initialState, t0); + resetStats(); +} + +std::pair dopri5::getState() { + return cudmIntHelp::getState(m_state, m_t); +} + +void dopri5::integrate(double targetTime) { + cudaq::dynamics::PerfMetricScopeTimer metricTimer("dopri5::integrate"); + cudmIntHelp::ensureStepper(m_stepper, m_state, m_system, m_schedule); + + // Guard against runaway step rejection (e.g. dt driven to dt_min). + constexpr std::size_t MAX_ITERATIONS = 100000; + std::size_t iterations = 0; + + while (m_t < targetTime) { + if (++iterations > MAX_ITERATIONS) + throw std::runtime_error( + "dopri5 integrator exceeded maximum iterations; possible " + "convergence issue or step size driven below dt_min."); + + const double dt = std::min(m_dt, targetTime - m_t); + auto &y = *cudmIntHelp::asCudmState(*m_state); + + // Evaluate the seven Dormand-Prince stages. Stage j uses the state + // y + dt * sum_{i, 7> kStates; + auto evalStage = [&](int j, CuDensityMatState &stageInput) { + auto params = + cudmIntHelp::scheduleParamsAt(m_schedule, m_t + kNodes[j] * dt); + cudaq::state stageState(CuDensityMatState::clone(stageInput).release()); + auto result = + m_stepper->compute(stageState, m_t + kNodes[j] * dt, params); + kStates[j] = std::make_shared(std::move(result)); + }; + + // k1 = f(t, y) + evalStage(0, y); + auto stageK = [&](int j) -> CuDensityMatState & { + return *cudmIntHelp::asCudmState(*kStates[j]); + }; + + for (int j = 1; j < 7; ++j) { + auto stageInput = CuDensityMatState::clone(y); + for (int i = 0; i < j; ++i) + if (kA[j][i] != 0.0) + stageInput->accumulate_inplace(stageK(i), dt * kA[j][i]); + evalStage(j, *stageInput); + } + + // Embedded 5th- and 4th-order solutions. + auto y5 = CuDensityMatState::clone(y); + auto y4 = CuDensityMatState::clone(y); + for (int j = 0; j < 7; ++j) { + if (kB5[j] != 0.0) + y5->accumulate_inplace(stageK(j), dt * kB5[j]); + if (kB4[j] != 0.0) + y4->accumulate_inplace(stageK(j), dt * kB4[j]); + } + + const double errorNorm = computeErrorNorm(*y5, *y4, m_rtol, m_atol); + const double dtNext = adaptStepSize(errorNorm, dt, m_dt_min, m_dt_max); + const bool accept = (errorNorm <= 1.0); + + if (accept) { + m_state = std::make_shared(y5.release()); + m_t += dt; + m_dt = dtNext; + ++m_stats.accepted_steps; + m_stats.min_dt_used = std::min(m_stats.min_dt_used, dt); + m_stats.max_dt_used = std::max(m_stats.max_dt_used, dt); + m_stats.avg_dt = (m_stats.avg_dt * (m_stats.accepted_steps - 1) + dt) / + m_stats.accepted_steps; + } else { + m_dt = dtNext; + ++m_stats.rejected_steps; + } + } +} + +} // namespace cudaq::integrators diff --git a/pulse/core/runtime/cudm/integrators/MagnusHighOrderIntegrator.cpp b/pulse/core/runtime/cudm/integrators/MagnusHighOrderIntegrator.cpp new file mode 100644 index 00000000000..890c9ad65ba --- /dev/null +++ b/pulse/core/runtime/cudm/integrators/MagnusHighOrderIntegrator.cpp @@ -0,0 +1,272 @@ +/******************************************************************************* + * Copyright (c) 2022 - 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + ******************************************************************************/ + +#include "CuDensityMatContext.h" +#include "CuDensityMatIntegratorBase.h" +#include "CuDensityMatState.h" +#include "CuDensityMatUtils.h" +#include "support/cuda_memory.h" +#include "support/matrix_exp.h" +#include "support/propagator_cache.h" +#include "cudaq/algorithms/integrator.h" +#include "cudaq/runtime/logger/logger.h" + +#include +#include +#include +#include +#include +#include + +namespace cudaq::integrators { + +// High-order commutator-free Magnus integrator (CF4). +// +// For closed-system density-matrix evolution this materialises the dense +// Hamiltonian at the two Gauss-Legendre nodes of a step, forms the two +// commutator-free exponential factors, computes each propagator with a GPU +// matrix exponential (support/matrix_exp.cu), and applies them to the density +// matrix as rho <- U rho U^dagger. Propagators are cached (LRU) keyed by a +// quantized Hamiltonian signature so repeated piecewise-constant slices reuse a +// single matrix exponential. +// +// Reference (CF4, s=2): S. Blanes, P. C. Moan, "Fourth- and sixth-order +// commutator-free Magnus integrators for linear and non-linear dynamical +// systems", Appl. Numer. Math. 56 (2006). +// +// For open systems (collapse operators / super-operator) or state-vector +// evolution, this falls back to `magnus_expansion` for correctness parity. + +using cudmIntHelp = CuDensityMatIntegratorHelper; + +namespace { + +// FNV-1a signature over a quantized dense matrix. Quantization (to ~1e-9) +// absorbs floating-point noise so that identical PWC slices hash identically. +std::size_t hashMatrixBuffer(const std::vector> &buf) { + std::size_t h = 14695981039346656037ULL; + constexpr std::size_t fnvPrime = 1099511628211ULL; + constexpr double invQuantum = 1e9; // 1e-9 resolution. + auto combine = [&](std::int64_t v) { + h ^= static_cast(v); + h *= fnvPrime; + }; + for (const auto &z : buf) { + combine(static_cast(std::llround(z.real() * invQuantum))); + combine(static_cast(std::llround(z.imag() * invQuantum))); + } + return h; +} + +} // namespace + +/// @brief Hidden CUDA/cache state for `magnus_cf4`. +struct magnus_cf4::Impl { + cudaq::detail::PropagatorLRUCache cache; + cudaq::detail::CudaComplexMemory dH; // Scaled Hamiltonian / scratch. + cudaq::detail::CudaComplexMemory applyWork; // Workspace for U rho U^dagger. + void *expWorkspace = nullptr; + std::size_t expWorkspaceBytes = 0; + cusolverDnHandle_t cusolver = nullptr; + int dim = 0; + + explicit Impl(std::size_t capacity) : cache(capacity) {} + + ~Impl() { + if (expWorkspace) + cudaFree(expWorkspace); + if (cusolver) + cusolverDnDestroy(cusolver); + } + + // Lazily allocate device buffers / handles for a given Hilbert dimension. + void ensureResources(int hilbertDim, cublasHandle_t cublas) { + if (!cusolver) { + CUSOLVER_CHECK(cusolverDnCreate(&cusolver)); + cudaStream_t stream = nullptr; + cublasGetStream(cublas, &stream); + CUSOLVER_CHECK(cusolverDnSetStream(cusolver, stream)); + } + if (hilbertDim != dim) { + dim = hilbertDim; + const std::size_t nn = static_cast(dim) * dim; + dH.reallocate(nn); + applyWork.reallocate(nn); + const std::size_t needed = get_matrix_exp_workspace_size(dim); + if (needed > expWorkspaceBytes) { + if (expWorkspace) + cudaFree(expWorkspace); + CUDA_CHECK(cudaMalloc(&expWorkspace, needed)); + expWorkspaceBytes = needed; + } + // Dimension-specific cache entries are stale for a new dimension. + cache.clear(); + } + } +}; + +magnus_cf4::magnus_cf4(const std::optional &max_step_size, + std::size_t cache_capacity) + : m_t(0.0), m_dt(max_step_size), m_cache_capacity(cache_capacity), + m_impl(std::make_shared(cache_capacity)) {} + +magnus_cf4::~magnus_cf4() = default; + +std::shared_ptr magnus_cf4::clone() { + auto cloned = + std::make_shared(m_dt, m_cache_capacity); + cloned->m_t = this->m_t; + cloned->m_state = this->m_state; + cloned->m_system = this->m_system; + cloned->m_schedule = this->m_schedule; + // Note: the propagator cache is intentionally not shared; the clone starts + // with an empty cache so async copies do not race on device buffers. + return cloned; +} + +void magnus_cf4::setState(const cudaq::state &initialState, double t0) { + cudmIntHelp::setState(m_state, m_t, initialState, t0); + resetStats(); +} + +std::pair magnus_cf4::getState() { + return cudmIntHelp::getState(m_state, m_t); +} + +namespace { + +// Whether the closed-system, density-matrix unitary fast path applies. +bool canUseUnitaryFastPath(const SystemDynamics &system, + CuDensityMatState &state) { + if (!state.is_density_matrix()) + return false; + if (state.getBatchSize() != 1) + return false; + if (system.superOp.has_value()) + return false; + for (const auto &ops : system.collapseOps) + if (!ops.empty()) + return false; + return true; +} + +} // namespace + +void magnus_cf4::integrate(double targetTime) { + cudaq::dynamics::PerfMetricScopeTimer metricTimer("magnus_cf4::integrate"); + + auto &state = *cudmIntHelp::asCudmState(*m_state); + + // Fall back to the general (open-system / state-vector) Magnus path when the + // exact unitary propagator does not apply. + if (!canUseUnitaryFastPath(m_system, state)) { + cudaq::integrators::magnus_expansion fallback( + magnus_expansion::default_num_taylor_terms, m_dt); + cudaq::integrator_helper::init_system_dynamics(fallback, m_system, + m_schedule); + auto [t0, currentState] = getState(); + fallback.setState(currentState, m_t); + fallback.integrate(targetTime); + auto [tFinal, finalState] = fallback.getState(); + auto *finalCudm = cudmIntHelp::asCudmState(finalState); + m_state = std::make_shared( + CuDensityMatState::clone(*finalCudm).release()); + m_t = tFinal; + return; + } + + auto *context = cudaq::dynamics::Context::getCurrentContext(); + cublasHandle_t cublas = context->getCublasHandle(); + + const auto tensor = state.getTensor(); + const int dim = static_cast(tensor.extents[0]); + m_impl->ensureResources(dim, cublas); + + // Precompute the dimension map (degree index -> extent) for to_matrix. + std::unordered_map dims; + for (std::size_t i = 0; i < m_system.modeExtents.size(); ++i) + dims[i] = m_system.modeExtents[i]; + + // CF4 (s=2) Gauss-Legendre nodes and commutator-free weights. + const double sqrt3 = std::sqrt(3.0); + const double c1 = 0.5 - sqrt3 / 6.0; + const double c2 = 0.5 + sqrt3 / 6.0; + const double alpha1 = 0.25 + sqrt3 / 6.0; + const double alpha2 = 0.25 - sqrt3 / 6.0; + + const std::size_t nn = static_cast(dim) * dim; + + // Dense Hamiltonian at time t, flattened column-major to match the device + // storage order used by CuDensityMatState. + auto denseHamiltonianColumnMajor = [&](double t) { + auto params = cudmIntHelp::scheduleParamsAt(m_schedule, t); + const auto H = m_system.hamiltonian.front().to_matrix(dims, params); + std::vector> flat; + flat.reserve(nn); + for (std::size_t col = 0; col < H.cols(); ++col) + for (std::size_t row = 0; row < H.rows(); ++row) + flat.push_back(H[{row, col}]); + return flat; + }; + + // Weighted combination w1*H1 + w2*H2 (both already column-major). + auto combine = [&](const std::vector> &H1, + const std::vector> &H2, double w1, + double w2) { + std::vector> out(nn); + for (std::size_t i = 0; i < nn; ++i) + out[i] = w1 * H1[i] + w2 * H2[i]; + return out; + }; + + // Apply exp(-i * step * X) to the density matrix rho (in place), using the + // propagator cache. X is the column-major exponent (a Hermitian matrix). + auto applyExpFactor = [&](const std::vector> &X, + double step, cuDoubleComplex *dRho) { + const std::size_t signature = hashMatrixBuffer(X); + const std::size_t key = m_impl->cache.make_key(signature, step); + auto *entry = m_impl->cache.get(key); + if (!entry) { + ++m_stats.cache_misses; + entry = &m_impl->cache.insert(key, static_cast(dim)); + m_impl->dH.copy_from_host( + reinterpret_cast(X.data()), nn); + // Scale in place: dH <- (-i * step) * X. + const cuDoubleComplex scale = make_cuDoubleComplex(0.0, -step); + CUBLAS_CHECK(cublasZscal(cublas, static_cast(nn), &scale, + m_impl->dH.get(), 1)); + compute_matrix_exp(m_impl->dH.get(), entry->U.get(), dim, + m_impl->expWorkspace, cublas, m_impl->cusolver); + } else { + ++m_stats.cache_hits; + } + apply_unitary_to_density_matrix(entry->U.get(), dRho, dim, + m_impl->applyWork.get(), cublas); + }; + + auto *dRho = static_cast(state.get_device_pointer()); + + while (m_t < targetTime) { + const double step = cudmIntHelp::computeStepSize(m_t, targetTime, m_dt); + + const auto H1 = denseHamiltonianColumnMajor(m_t + c1 * step); + const auto H2 = denseHamiltonianColumnMajor(m_t + c2 * step); + const auto expA = combine(H1, H2, alpha1, alpha2); + const auto expB = combine(H1, H2, alpha2, alpha1); + + // U = exp(-i*step*expA) * exp(-i*step*expB); applying expB first, then + // expA, yields rho <- U rho U^dagger. + applyExpFactor(expB, step, dRho); + applyExpFactor(expA, step, dRho); + + ++m_stats.steps; + m_t += step; + } +} + +} // namespace cudaq::integrators diff --git a/pulse/core/runtime/cudm/integrators/support/cuda_check.h b/pulse/core/runtime/cudm/integrators/support/cuda_check.h new file mode 100644 index 00000000000..7dde0d9460b --- /dev/null +++ b/pulse/core/runtime/cudm/integrators/support/cuda_check.h @@ -0,0 +1,62 @@ +/****************************************************************-*- C++ -*-**** + * Copyright (c) 2022 - 2026 NVIDIA Corporation & Affiliates. + * All rights reserved. + * + * This source code and the accompanying materials are made available under + * the terms of the Apache License 2.0 which accompanies this distribution. + ******************************************************************************/ + +#pragma once + +/// \file cuda_check.h +/// \brief Lightweight CUDA / cuBLAS / cuSOLVER error-checking helpers for the +/// dynamics support code (matrix exponential, propagator / Hamiltonian caches). +/// All of the runtime-API error checks live here in one place. + +#include +#include +#include +#include +#include + +namespace cudaq::detail { + +/// \brief Check CUDA error and throw on failure. +inline void checkCudaError(cudaError_t error, const char *file, int line) { + if (error != cudaSuccess) { + throw std::runtime_error(std::string("CUDA error at ") + file + ":" + + std::to_string(line) + ": " + + cudaGetErrorString(error)); + } +} + +/// \brief Check cuBLAS error and throw on failure. +inline void checkCublasError(cublasStatus_t status, const char *file, + int line) { + if (status != CUBLAS_STATUS_SUCCESS) { + throw std::runtime_error(std::string("cuBLAS error at ") + file + ":" + + std::to_string(line) + " - code " + + std::to_string(status)); + } +} + +/// \brief Check cuSOLVER error and throw on failure. +inline void checkCusolverError(cusolverStatus_t status, const char *file, + int line) { + if (status != CUSOLVER_STATUS_SUCCESS) { + throw std::runtime_error(std::string("cuSOLVER error at ") + file + ":" + + std::to_string(line) + " - code " + + std::to_string(status)); + } +} + +} // namespace cudaq::detail + +#define CUDA_CHECK(call) \ + ::cudaq::detail::checkCudaError((call), __FILE__, __LINE__) + +#define CUBLAS_CHECK(call) \ + ::cudaq::detail::checkCublasError((call), __FILE__, __LINE__) + +#define CUSOLVER_CHECK(call) \ + ::cudaq::detail::checkCusolverError((call), __FILE__, __LINE__) diff --git a/pulse/core/runtime/cudm/integrators/support/cuda_memory.h b/pulse/core/runtime/cudm/integrators/support/cuda_memory.h new file mode 100644 index 00000000000..7c47167c9c5 --- /dev/null +++ b/pulse/core/runtime/cudm/integrators/support/cuda_memory.h @@ -0,0 +1,113 @@ +/****************************************************************-*- C++ -*-**** + * Copyright (c) 2022 - 2026 NVIDIA Corporation & Affiliates. + * All rights reserved. + * + * This source code and the accompanying materials are made available under + * the terms of the Apache License 2.0 which accompanies this distribution. + ******************************************************************************/ + +#pragma once + +/// \file cuda_memory.h +/// \brief Minimal RAII device-memory wrapper used by the dynamics +/// matrix-exponential support and propagator caches. The CUDA / cuBLAS / +/// cuSOLVER error-check helpers live in cuda_check.h. +/// +/// This is a trimmed subset of the experimental pulse runtime's cuda_memory.h, +/// containing only the pieces required by matrix_exp.cu and the propagator / +/// Hamiltonian caches. + +#include "cuda_check.h" + +#include +#include +#include +#include + +namespace cudaq::detail { + +/// \brief RAII wrapper for CUDA device memory (move-only, 2x growth). +/// +/// Existing content is not preserved on reallocate. +template +class CudaDeviceMemory { +public: + CudaDeviceMemory() = default; + explicit CudaDeviceMemory(size_t count) { reallocate(count); } + + ~CudaDeviceMemory() { + if (ptr_) + cudaFree(ptr_); + } + + CudaDeviceMemory(const CudaDeviceMemory &) = delete; + CudaDeviceMemory &operator=(const CudaDeviceMemory &) = delete; + + CudaDeviceMemory(CudaDeviceMemory &&other) noexcept + : ptr_(std::exchange(other.ptr_, nullptr)), + size_(std::exchange(other.size_, 0)), + capacity_(std::exchange(other.capacity_, 0)) {} + + CudaDeviceMemory &operator=(CudaDeviceMemory &&other) noexcept { + if (this != &other) { + if (ptr_) + cudaFree(ptr_); + ptr_ = std::exchange(other.ptr_, nullptr); + size_ = std::exchange(other.size_, 0); + capacity_ = std::exchange(other.capacity_, 0); + } + return *this; + } + + /// \brief Reallocate to hold \p new_count elements (2x growth, no preserve). + void reallocate(size_t new_count) { + if (new_count <= capacity_) { + size_ = new_count; + return; + } + if (ptr_) { + cudaFree(ptr_); + ptr_ = nullptr; + } + capacity_ = std::max(new_count, capacity_ * 2); + size_ = new_count; + if (capacity_ > 0) + CUDA_CHECK(cudaMalloc(&ptr_, capacity_ * sizeof(T))); + } + + [[nodiscard]] T *get() noexcept { return ptr_; } + [[nodiscard]] const T *get() const noexcept { return ptr_; } + [[nodiscard]] size_t size() const noexcept { return size_; } + [[nodiscard]] size_t size_bytes() const noexcept { return size_ * sizeof(T); } + [[nodiscard]] explicit operator bool() const noexcept { + return ptr_ != nullptr; + } + + void copy_from_host(const T *host_ptr, size_t count) { + if (count > size_) + throw std::runtime_error("copy_from_host: count exceeds allocated size"); + CUDA_CHECK( + cudaMemcpy(ptr_, host_ptr, count * sizeof(T), cudaMemcpyHostToDevice)); + } + + void copy_to_host(T *host_ptr, size_t count) const { + if (count > size_) + throw std::runtime_error("copy_to_host: count exceeds allocated size"); + CUDA_CHECK( + cudaMemcpy(host_ptr, ptr_, count * sizeof(T), cudaMemcpyDeviceToHost)); + } + + void zero() { + if (ptr_) + CUDA_CHECK(cudaMemset(ptr_, 0, size_ * sizeof(T))); + } + +private: + T *ptr_ = nullptr; + size_t size_ = 0; + size_t capacity_ = 0; +}; + +using CudaComplexMemory = CudaDeviceMemory; + +} // namespace cudaq::detail diff --git a/pulse/core/runtime/cudm/integrators/support/matrix_exp.cu b/pulse/core/runtime/cudm/integrators/support/matrix_exp.cu new file mode 100644 index 00000000000..d4506c2ed10 --- /dev/null +++ b/pulse/core/runtime/cudm/integrators/support/matrix_exp.cu @@ -0,0 +1,358 @@ +/****************************************************************-*- C++ -*-**** + * Copyright (c) 2022 - 2026 NVIDIA Corporation & Affiliates. + * All rights reserved. + * + * This source code and the accompanying materials are made available under + * the terms of the Apache License 2.0 which accompanies this distribution. + ******************************************************************************/ + +#include "matrix_exp.h" +#include +#include +#include +#include +#include +#include +#include + +// Small-matrix Frobenius norm kernel +__global__ void +compute_frobenius_norm_kernel(const cuDoubleComplex *__restrict__ a, int n, + double *__restrict__ out) { + extern __shared__ double sdata[]; + int tid = threadIdx.x; + double sum = 0.0; + for (int idx = tid; idx < n; idx += blockDim.x) { + double re = a[idx].x; + double im = a[idx].y; + sum += re * re + im * im; + } + sdata[tid] = sum; + __syncthreads(); + // Reduction + for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) { + if (tid < stride) { + sdata[tid] += sdata[tid + stride]; + } + __syncthreads(); + } + if (tid == 0) { + out[0] = sdata[0]; + } +} + +// Use standardized error checking (throws exceptions instead of return codes) +#include "cuda_memory.h" + +#define MATRIX_EXP_CUDA_CHECK(call) CUDA_CHECK(call) +#define MATRIX_EXP_CUBLAS_CHECK(call) CUBLAS_CHECK(call) +#define MATRIX_EXP_CUSOLVER_CHECK(call) CUSOLVER_CHECK(call) + +/// Kernel: Set matrix to identity +__global__ void set_identity_kernel(cuDoubleComplex *__restrict__ d_A, int N) { + const int row = blockIdx.x * blockDim.x + threadIdx.x; + const int col = blockIdx.y * blockDim.y + threadIdx.y; + + if (row < N && col < N) { + d_A[row * N + col] = (row == col) ? make_cuDoubleComplex(1.0, 0.0) + : make_cuDoubleComplex(0.0, 0.0); + } +} + +size_t get_matrix_exp_workspace_size(int N) { + // Need space for: + // - 8 matrices for Padé (A_scaled, A2, A4, A6, U, V, tmp, tmp2) + // - Extra workspace for LU factorization (reserve 2 matrices worth) + // - Pivot + info arrays + const size_t matrix_bytes = + static_cast(N) * N * sizeof(cuDoubleComplex); + const size_t matrix_storage = 8 * matrix_bytes; + const size_t lu_workspace = 2 * matrix_bytes; + const size_t pivots = static_cast(N + 1) * sizeof(int); + const size_t align_pad = alignof(cuDoubleComplex); + return matrix_storage + lu_workspace + pivots + align_pad; +} + +int compute_matrix_exp(cuDoubleComplex *d_A, cuDoubleComplex *d_expA, int N, + void *d_workspace, cublasHandle_t cublasH, + cusolverDnHandle_t cusolverH) { + // Both handles must be provided + if (!cublasH || !cusolverH) { + throw std::runtime_error( + "compute_matrix_exp requires valid cuBLAS and cuSOLVER handles"); + } + + cudaStream_t stream = nullptr; + cublasGetStream(cublasH, &stream); + + // Workspace partitioning + // Padé 13 uses 8 matrices + LU workspace/pivots + cuDoubleComplex *d_A_scaled = (cuDoubleComplex *)d_workspace; + cuDoubleComplex *d_A2 = d_A_scaled + N * N; + cuDoubleComplex *d_A4 = d_A2 + N * N; + cuDoubleComplex *d_A6 = d_A4 + N * N; + cuDoubleComplex *d_U = d_A6 + N * N; + cuDoubleComplex *d_V = d_U + N * N; + cuDoubleComplex *d_tmp = d_V + N * N; + cuDoubleComplex *d_tmp2 = d_tmp + N * N; + char *extra = reinterpret_cast(d_tmp2 + N * N); + int *d_pivots = reinterpret_cast(extra); + extra += static_cast(N) * sizeof(int); + int *d_info = reinterpret_cast(extra); + extra += sizeof(int); + // Align LU workspace to cuDoubleComplex alignment + constexpr std::size_t align = alignof(cuDoubleComplex); + std::uintptr_t extra_addr = reinterpret_cast(extra); + extra_addr = (extra_addr + align - 1) & ~(align - 1); + cuDoubleComplex *d_lu_work = reinterpret_cast(extra_addr); + + // 1. Estimate norm of A (Frobenius norm) + double norm_A = 0.0; + + if (N <= 16) { + // Use custom CUDA norm for small matrices (avoids host-side dependency + // issues or small-vec bugs) Use d_workspace as scratch for output (first + // double) + double *d_norm = reinterpret_cast(d_workspace); + int nElems = N * N; + int block = 256; + int grid = 1; + // Kernel defined above + compute_frobenius_norm_kernel<<>>( + reinterpret_cast(d_A), nElems, d_norm); + + MATRIX_EXP_CUDA_CHECK(cudaGetLastError()); + MATRIX_EXP_CUDA_CHECK( + cudaMemcpy(&norm_A, d_norm, sizeof(double), cudaMemcpyDeviceToHost)); + norm_A = std::sqrt(norm_A); + } else { + // Use cuBLAS for larger matrices + MATRIX_EXP_CUBLAS_CHECK(cublasDznrm2(cublasH, N * N, d_A, 1, &norm_A)); + } + + // 2. Scaling: choose s such that ||A/2^s|| is small + int s = 0; + if (norm_A > 0.0) { + s = std::max(0, static_cast(std::ceil(std::log2(norm_A)))); + } + const double scale = 1.0 / (1 << s); // 2^(-s) + const cuDoubleComplex one = make_cuDoubleComplex(1.0, 0.0); + const cuDoubleComplex zero = make_cuDoubleComplex(0.0, 0.0); + + // A_scaled = A * scale + cuDoubleComplex alpha = make_cuDoubleComplex(scale, 0.0); + MATRIX_EXP_CUDA_CHECK(cudaMemcpy(d_A_scaled, d_A, + N * N * sizeof(cuDoubleComplex), + cudaMemcpyDeviceToDevice)); + MATRIX_EXP_CUBLAS_CHECK(cublasZscal(cublasH, N * N, &alpha, d_A_scaled, 1)); + + if (norm_A == 0.0) { + dim3 block(16, 16); + dim3 grid((N + 15) / 16, (N + 15) / 16); + set_identity_kernel<<>>(d_expA, N); + MATRIX_EXP_CUDA_CHECK(cudaGetLastError()); + return 0; + } + + // 3. Padé [13/13] approximation + // Coefficients for Padé(13) + constexpr double b0 = 64764752532480000.0; + constexpr double b1 = 32382376266240000.0; + constexpr double b2 = 7771770303897600.0; + constexpr double b3 = 1187353796428800.0; + constexpr double b4 = 129060195264000.0; + constexpr double b5 = 10559470521600.0; + constexpr double b6 = 670442572800.0; + constexpr double b7 = 33522128640.0; + constexpr double b8 = 1323241920.0; + constexpr double b9 = 40840800.0; + constexpr double b10 = 960960.0; + constexpr double b11 = 16380.0; + constexpr double b12 = 182.0; + constexpr double b13 = 1.0; + + const int nn = N * N; + cuDoubleComplex beta = zero; + + // A2 = A_scaled^2 + alpha = one; + MATRIX_EXP_CUBLAS_CHECK(cublasZgemm(cublasH, CUBLAS_OP_N, CUBLAS_OP_N, N, N, + N, &alpha, d_A_scaled, N, d_A_scaled, N, + &beta, d_A2, N)); + + // A4 = A2^2 + alpha = one; + MATRIX_EXP_CUBLAS_CHECK(cublasZgemm(cublasH, CUBLAS_OP_N, CUBLAS_OP_N, N, N, + N, &alpha, d_A2, N, d_A2, N, &beta, d_A4, + N)); + + // A6 = A2 * A4 + MATRIX_EXP_CUBLAS_CHECK(cublasZgemm(cublasH, CUBLAS_OP_N, CUBLAS_OP_N, N, N, + N, &alpha, d_A2, N, d_A4, N, &beta, d_A6, + N)); + + // --- Compute U --- + // tmp = b13*A6 + b11*A4 + b9*A2 + MATRIX_EXP_CUDA_CHECK(cudaMemcpy(d_tmp, d_A6, N * N * sizeof(cuDoubleComplex), + cudaMemcpyDeviceToDevice)); + alpha = make_cuDoubleComplex(b13, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZscal(cublasH, nn, &alpha, d_tmp, 1)); + alpha = make_cuDoubleComplex(b11, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZaxpy(cublasH, nn, &alpha, d_A4, 1, d_tmp, 1)); + alpha = make_cuDoubleComplex(b9, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZaxpy(cublasH, nn, &alpha, d_A2, 1, d_tmp, 1)); + + // tmp2 = A6 * tmp + alpha = one; + MATRIX_EXP_CUBLAS_CHECK(cublasZgemm(cublasH, CUBLAS_OP_N, CUBLAS_OP_N, N, N, + N, &alpha, d_A6, N, d_tmp, N, &beta, + d_tmp2, N)); + + // tmp2 += b7*A6 + b5*A4 + b3*A2 + b1*I + alpha = make_cuDoubleComplex(b7, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZaxpy(cublasH, nn, &alpha, d_A6, 1, d_tmp2, 1)); + alpha = make_cuDoubleComplex(b5, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZaxpy(cublasH, nn, &alpha, d_A4, 1, d_tmp2, 1)); + alpha = make_cuDoubleComplex(b3, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZaxpy(cublasH, nn, &alpha, d_A2, 1, d_tmp2, 1)); + + // Add b1 * I + dim3 block(16, 16); + dim3 grid((N + 15) / 16, (N + 15) / 16); + set_identity_kernel<<>>(d_tmp, N); + alpha = make_cuDoubleComplex(b1, 0.0); + MATRIX_EXP_CUBLAS_CHECK( + cublasZaxpy(cublasH, nn, &alpha, d_tmp, 1, d_tmp2, 1)); + + // U = A_scaled * tmp2 + alpha = one; + MATRIX_EXP_CUBLAS_CHECK(cublasZgemm(cublasH, CUBLAS_OP_N, CUBLAS_OP_N, N, N, + N, &alpha, d_A_scaled, N, d_tmp2, N, + &beta, d_U, N)); + + // --- Compute V --- + // tmp = b12*A6 + b10*A4 + b8*A2 + MATRIX_EXP_CUDA_CHECK(cudaMemcpy(d_tmp, d_A6, N * N * sizeof(cuDoubleComplex), + cudaMemcpyDeviceToDevice)); + alpha = make_cuDoubleComplex(b12, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZscal(cublasH, nn, &alpha, d_tmp, 1)); + alpha = make_cuDoubleComplex(b10, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZaxpy(cublasH, nn, &alpha, d_A4, 1, d_tmp, 1)); + alpha = make_cuDoubleComplex(b8, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZaxpy(cublasH, nn, &alpha, d_A2, 1, d_tmp, 1)); + + // tmp2 = A6 * tmp + alpha = one; + MATRIX_EXP_CUBLAS_CHECK(cublasZgemm(cublasH, CUBLAS_OP_N, CUBLAS_OP_N, N, N, + N, &alpha, d_A6, N, d_tmp, N, &beta, + d_tmp2, N)); + + // V = tmp2 + b6*A6 + b4*A4 + b2*A2 + b0*I + MATRIX_EXP_CUDA_CHECK(cudaMemcpy(d_V, d_tmp2, N * N * sizeof(cuDoubleComplex), + cudaMemcpyDeviceToDevice)); + alpha = make_cuDoubleComplex(b6, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZaxpy(cublasH, nn, &alpha, d_A6, 1, d_V, 1)); + alpha = make_cuDoubleComplex(b4, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZaxpy(cublasH, nn, &alpha, d_A4, 1, d_V, 1)); + alpha = make_cuDoubleComplex(b2, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZaxpy(cublasH, nn, &alpha, d_A2, 1, d_V, 1)); + + set_identity_kernel<<>>(d_tmp, N); + alpha = make_cuDoubleComplex(b0, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZaxpy(cublasH, nn, &alpha, d_tmp, 1, d_V, 1)); + + // Solve (V - U) X = (V + U) + // tmp2 = V + U + alpha = one; + beta = one; + MATRIX_EXP_CUBLAS_CHECK(cublasZgeam(cublasH, CUBLAS_OP_N, CUBLAS_OP_N, N, N, + &alpha, d_V, N, &beta, d_U, N, d_tmp2, + N)); + + // V = V - U + beta = make_cuDoubleComplex(-1.0, 0.0); + MATRIX_EXP_CUBLAS_CHECK(cublasZgeam(cublasH, CUBLAS_OP_N, CUBLAS_OP_N, N, N, + &alpha, d_V, N, &beta, d_U, N, d_V, N)); + + // LU factorization of (V - U) + int lwork = 0; + MATRIX_EXP_CUSOLVER_CHECK( + cusolverDnZgetrf_bufferSize(cusolverH, N, N, d_V, N, &lwork)); + const size_t lu_workspace_bytes = + 2 * static_cast(N) * N * sizeof(cuDoubleComplex); + const size_t required_bytes = + static_cast(lwork) * sizeof(cuDoubleComplex); + cuDoubleComplex *lu_work = d_lu_work; + bool lu_allocated = false; + if (required_bytes > lu_workspace_bytes) { + MATRIX_EXP_CUDA_CHECK(cudaMalloc(&lu_work, required_bytes)); + lu_allocated = true; + } + + MATRIX_EXP_CUSOLVER_CHECK( + cusolverDnZgetrf(cusolverH, N, N, d_V, N, lu_work, d_pivots, d_info)); + + MATRIX_EXP_CUSOLVER_CHECK(cusolverDnZgetrs(cusolverH, CUBLAS_OP_N, N, N, d_V, + N, d_pivots, d_tmp2, N, d_info)); + + if (lu_allocated) { + cudaFree(lu_work); + } + + // 4. Squaring: exp(A) = (exp(A_scaled))^(2^s) + // We perform 's' matrix multiplications using ping-pong buffering + + cuDoubleComplex *current = d_tmp2; + cuDoubleComplex *next = d_tmp; // Use tmp as temp buffer + + alpha = make_cuDoubleComplex(1.0, 0.0); + beta = make_cuDoubleComplex(0.0, 0.0); + + for (int i = 0; i < s; ++i) { + // Square: next = current * current + MATRIX_EXP_CUBLAS_CHECK(cublasZgemm(cublasH, CUBLAS_OP_N, CUBLAS_OP_N, N, N, + N, &alpha, current, N, current, N, + &beta, next, N)); + + // Swap pointers + std::swap(current, next); + } + + // Final result is in 'current' + // Copy to d_expA (the output buffer) + if (current != d_expA) { + MATRIX_EXP_CUDA_CHECK(cudaMemcpy(d_expA, current, + N * N * sizeof(cuDoubleComplex), + cudaMemcpyDeviceToDevice)); + } + + return 0; +} + +int apply_unitary_to_density_matrix(const cuDoubleComplex *d_U, + cuDoubleComplex *d_rho, int N, + void *d_workspace, cublasHandle_t cublasH) { + if (!cublasH) { + throw std::runtime_error( + "apply_unitary_to_density_matrix requires valid cuBLAS handle"); + } + + // Workspace for temp = U * rho + cuDoubleComplex *d_temp = static_cast(d_workspace); + + const cuDoubleComplex alpha = make_cuDoubleComplex(1.0, 0.0); + const cuDoubleComplex beta = make_cuDoubleComplex(0.0, 0.0); + + // Step 1: temp = U * rho + MATRIX_EXP_CUBLAS_CHECK(cublasZgemm(cublasH, CUBLAS_OP_N, CUBLAS_OP_N, N, N, + N, &alpha, d_U, N, d_rho, N, &beta, + d_temp, N)); + + // Step 2: rho = temp * U† (U† = conjugate transpose of U) + MATRIX_EXP_CUBLAS_CHECK(cublasZgemm(cublasH, CUBLAS_OP_N, CUBLAS_OP_C, N, N, + N, &alpha, d_temp, N, d_U, N, &beta, + d_rho, N)); + + return 0; +} diff --git a/pulse/core/runtime/cudm/integrators/support/matrix_exp.h b/pulse/core/runtime/cudm/integrators/support/matrix_exp.h new file mode 100644 index 00000000000..0ef8aba850e --- /dev/null +++ b/pulse/core/runtime/cudm/integrators/support/matrix_exp.h @@ -0,0 +1,62 @@ +/****************************************************************-*- C++ -*-**** + * Copyright (c) 2022 - 2026 NVIDIA Corporation & Affiliates. + * All rights reserved. + * + * This source code and the accompanying materials are made available under + * the terms of the Apache License 2.0 which accompanies this distribution. + ******************************************************************************/ + +#pragma once + +#include +#include +#include +#include + +/// \file matrix_exp.h +/// \brief Matrix exponential for exact PWC Hamiltonian evolution. +/// +/// Implements exp(A) for complex matrices using scaling and squaring with +/// Padé [13/13] approximation (Higham 2005). Used by Magnus CF4 integrator. + +/// \brief Compute matrix exponential: B = exp(A) using scaling and squaring. +/// +/// Algorithm: Scaling and squaring with Padé [13/13] approximation. +/// Based on Higham 2005, using the standard Padé coefficients. +/// +/// Steps: +/// 1. Scale: A' = A / 2^s where s is chosen so ||A'|| is small. +/// 2. Padé: Compute U and V polynomials for [13/13]. +/// 3. Solve: exp(A') = (V + U) × (V - U)⁻¹. +/// 4. Square: exp(A) = (exp(A'))^(2^s). +/// +/// \param d_A Input matrix A (N×N, will be modified as workspace). +/// \param d_expA Output matrix exp(A) (N×N). +/// \param N Matrix dimension. +/// \param d_workspace Workspace buffer (see get_matrix_exp_workspace_size). +/// \param cublasH cuBLAS handle (required, must be valid). +/// \param cusolverH cuSOLVER handle (required, must be valid). +/// \return 0 on success, throws exception on error. +int compute_matrix_exp(cuDoubleComplex *d_A, cuDoubleComplex *d_expA, int N, + void *d_workspace, cublasHandle_t cublasH, + cusolverDnHandle_t cusolverH); + +/// \brief Query workspace size needed for matrix exponential. +/// \param N Matrix dimension. +/// \return Workspace size in bytes. +size_t get_matrix_exp_workspace_size(int N); + +/// \brief Apply unitary propagator to density matrix: ρ_new = U ρ U†. +/// +/// For Hamiltonian evolution with PWC Hamiltonians: +/// U = exp(-i H Δt), ρ(t+Δt) = U ρ(t) U†. +/// +/// \param d_U Unitary operator U (N×N, read-only). +/// \param d_rho Density matrix ρ (N×N, modified in-place). +/// \param N Hilbert space dimension. +/// \param d_workspace Workspace buffer (at least N×N complex elements). +/// \param cublasH cuBLAS handle (required, must be valid). +/// \return 0 on success, throws exception on error. +int apply_unitary_to_density_matrix(const cuDoubleComplex *d_U, + cuDoubleComplex *d_rho, int N, + void *d_workspace, cublasHandle_t cublasH); diff --git a/pulse/core/runtime/cudm/integrators/support/propagator_cache.h b/pulse/core/runtime/cudm/integrators/support/propagator_cache.h new file mode 100644 index 00000000000..1a1cc0687a7 --- /dev/null +++ b/pulse/core/runtime/cudm/integrators/support/propagator_cache.h @@ -0,0 +1,163 @@ +/****************************************************************-*- C++ -*-**** + * Copyright (c) 2022 - 2026 NVIDIA Corporation & Affiliates. + * All rights reserved. + * + * This source code and the accompanying materials are made available under + * the terms of the Apache License 2.0 which accompanies this distribution. + ******************************************************************************/ + +#pragma once + +#include "cuda_memory.h" +#include +#include +#include +#include +#include + +namespace cudaq::detail { + +/// \file propagator_cache.h +/// \brief LRU cache for PWC propagator matrices. + +/// \brief Quantize timestep to 1 ps precision for stable hashing. +/// \param dt_ns Timestep in nanoseconds. +/// \return Quantized integer (picoseconds). +inline int64_t quantize_timestep(double dt_ns) { + return static_cast(std::round(dt_ns * 1000.0)); // 0.001 ns = 1 ps +} + +/// \brief Cache entry for PWC propagators. +/// +/// Stores both U = exp(-i·0.5·dt·H) and U² = U × U for Magnus CF4. +struct PropagatorCacheEntry { + CudaComplexMemory U; ///< exp(-i·0.5·dt·H). + CudaComplexMemory U_squared; ///< U². + size_t dim = 0; ///< Matrix dimension. + size_t last_use_time = 0; ///< Timestamp for LRU. + + PropagatorCacheEntry() = default; + PropagatorCacheEntry(PropagatorCacheEntry &&) = default; + PropagatorCacheEntry &operator=(PropagatorCacheEntry &&) = default; +}; + +/// \brief LRU cache for PWC propagators with O(1) lookup and eviction. +/// +/// Eliminates redundant matrix exponentials for piecewise-constant +/// Hamiltonians by caching (H_signature, dt) → propagator mappings. +class PropagatorLRUCache { +public: + /// \brief Construct cache with given capacity. + /// \param max_entries Maximum number of cached propagators. + explicit PropagatorLRUCache(size_t max_entries = 32) + : max_entries_(max_entries) {} + + /// \brief Build cache key from Hamiltonian signature and timestep. + /// \param H_signature Hamiltonian hash. + /// \param dt_ns Timestep in nanoseconds. + /// \return Combined cache key. + [[nodiscard]] size_t make_key(size_t H_signature, double dt_ns) const { + // Combine H_signature and quantized dt using FNV-1a + size_t h = H_signature; + constexpr size_t fnv_prime = 1099511628211ULL; + int64_t dt_quantized = quantize_timestep(dt_ns); + h ^= static_cast(dt_quantized); + h *= fnv_prime; + return h; + } + + /// \brief Lookup entry by key. + /// \param key Cache key from make_key(). + /// \return Pointer to entry, or nullptr if not found. + PropagatorCacheEntry *get(size_t key) { + auto it = cache_map_.find(key); + if (it == cache_map_.end()) { + ++cache_misses_; + return nullptr; + } + + // O(1) LRU update: move to front + lru_list_.splice(lru_list_.begin(), lru_list_, it->second.list_iter); + ++cache_hits_; + + return &it->second.entry; + } + + /// \brief Insert new entry (evicts LRU if at capacity). + /// \param key Cache key. + /// \param dim Matrix dimension. + /// \return Reference to the new cache entry. + PropagatorCacheEntry &insert(size_t key, size_t dim) { + if (cache_map_.size() >= max_entries_) { + evict_lru(); + } + + // Insert at front of LRU list + lru_list_.push_front(key); + + auto &cache_entry = cache_map_[key]; + cache_entry.entry.dim = dim; + cache_entry.entry.U.reallocate(dim * dim); + cache_entry.entry.U_squared.reallocate(dim * dim); + cache_entry.list_iter = lru_list_.begin(); + + return cache_entry.entry; + } + + /// \brief Check if key exists in cache. + [[nodiscard]] bool contains(size_t key) const { + return cache_map_.find(key) != cache_map_.end(); + } + + /// \brief Clear all cached entries and reset statistics. + void clear() { + cache_map_.clear(); + lru_list_.clear(); + cache_hits_ = 0; + cache_misses_ = 0; + } + + /// \brief Get current number of cached entries. + [[nodiscard]] size_t size() const { return cache_map_.size(); } + + /// \brief Get maximum cache capacity. + [[nodiscard]] size_t capacity() const { return max_entries_; } + + /// \brief Get cache hit count. + [[nodiscard]] size_t hits() const { return cache_hits_; } + + /// \brief Get cache miss count. + [[nodiscard]] size_t misses() const { return cache_misses_; } + + /// \brief Get cache hit rate (0.0 to 1.0). + [[nodiscard]] double hit_rate() const { + size_t total = cache_hits_ + cache_misses_; + return (total > 0) ? static_cast(cache_hits_) / total : 0.0; + } + +private: + /// \brief Evict least recently used entry. + void evict_lru() { + if (lru_list_.empty()) + return; + + // O(1) eviction: remove least recently used + size_t lru_key = lru_list_.back(); + lru_list_.pop_back(); + cache_map_.erase(lru_key); + } + + /// Internal map entry with LRU list iterator. + struct MapEntry { + PropagatorCacheEntry entry; + std::list::iterator list_iter; + }; + + std::unordered_map cache_map_; ///< Key → entry map. + std::list lru_list_; ///< LRU order (front = MRU, back = LRU). + size_t max_entries_; ///< Maximum cache size. + size_t cache_hits_ = 0; ///< Hit counter. + size_t cache_misses_ = 0; ///< Miss counter. +}; + +} // namespace cudaq::detail diff --git a/pulse/docker/Dockerfile b/pulse/docker/Dockerfile new file mode 100644 index 00000000000..e0a63b0f695 --- /dev/null +++ b/pulse/docker/Dockerfile @@ -0,0 +1,73 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +# Build from the CUDA-Q repository root: +# docker build -f pulse/docker/Dockerfile -t cudaq-pulse-preview . +# +# Pulse compiles against the CUDA-Q toolchain shipped in the `cudaq-devel` +# wheel plus `libcudaqMLIR` from the `cudaq` runtime wheel, so this image needs +# neither the CUDA-Q development container nor a source build of LLVM. To build +# against locally produced wheels instead of the package index, place them in a +# directory inside the build context and pass, for example, +# --build-arg pip_index_args="--no-index --find-links=dist" +ARG base_image=ubuntu:24.04 +FROM ${base_image} AS pulse-build + +ARG cudaq_version= +ARG pip_index_args= + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + g++-12 \ + python3 \ + python3-pip \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* + +ENV CC=gcc-12 CXX=g++-12 + +WORKDIR /opt/cuda-quantum +COPY . . + +# One virtual environment holds the CUDA-Q toolchain, the build tools, and the +# test/documentation dependencies, so CMake's site-packages probe finds them. +ENV VIRTUAL_ENV=/opt/venv +RUN python3 -m venv "${VIRTUAL_ENV}" +ENV PATH="${VIRTUAL_ENV}/bin:${PATH}" + +RUN pip install --no-cache-dir ${pip_index_args} \ + "cudaq${cudaq_version:+==${cudaq_version}}" \ + "cudaq-devel${cudaq_version:+==${cudaq_version}}" \ + && pip install --no-cache-dir \ + "cmake>=4.0" \ + hypothesis \ + lit \ + "myst-parser>=3.0" \ + "nanobind>=2.12" \ + ninja \ + numpy \ + nvidia-sphinx-theme==0.0.8 \ + pytest==9.0.3 \ + "sphinx>=8.1,<8.3" + +RUN cmake -S pulse -B build-pulse -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCUDAQ_BUILD_TESTS=ON \ + -DCUDAQ_PULSE_BUILD_DOCS=ON \ + && cmake --build build-pulse --parallel 2 \ + --target pulse + +ENV PATH="/opt/cuda-quantum/build-pulse/bin:${PATH}" +ENV PYTHONPATH="/opt/cuda-quantum/build-pulse/python" + +FROM pulse-build AS pulse-ci +RUN cmake --build build-pulse --parallel 2 --target check-pulse pulse-docs + +FROM pulse-build AS pulse +CMD ["bash"] diff --git a/pulse/docs/api/compile.rst b/pulse/docs/api/compile.rst new file mode 100644 index 00000000000..4a2c4bbb359 --- /dev/null +++ b/pulse/docs/api/compile.rst @@ -0,0 +1,21 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +Compilation API +=============== + +.. autofunction:: cudaq_pulse.compile + +.. autoclass:: cudaq_pulse.CompiledKernel + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: cudaq_pulse.CompileMetrics + :members: + :undoc-members: + :show-inheritance: diff --git a/pulse/docs/api/evolve.rst b/pulse/docs/api/evolve.rst new file mode 100644 index 00000000000..368cd4272ee --- /dev/null +++ b/pulse/docs/api/evolve.rst @@ -0,0 +1,22 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +GPU Evolution API +================= + +.. warning:: + + GPU evolution is a research-preview interface with no stability or + numerical-accuracy guarantee. See :doc:`../user_guide/gpu_execution` for + the physical model and unsupported features. + +.. autofunction:: cudaq_pulse.evolve + +.. autoclass:: cudaq_pulse.EvolveResult + :members: + :undoc-members: + :show-inheritance: diff --git a/pulse/docs/api/index.rst b/pulse/docs/api/index.rst new file mode 100644 index 00000000000..42d3dc66baa --- /dev/null +++ b/pulse/docs/api/index.rst @@ -0,0 +1,17 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +API Reference +============= + +.. toctree:: + :maxdepth: 2 + + compile + evolve + kernel + ops diff --git a/pulse/docs/api/kernel.rst b/pulse/docs/api/kernel.rst new file mode 100644 index 00000000000..e3daa64c9b8 --- /dev/null +++ b/pulse/docs/api/kernel.rst @@ -0,0 +1,25 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +Kernel API +========== + +.. autofunction:: cudaq_pulse.kernel + +.. autofunction:: cudaq_pulse.qudit_ref + +.. autofunction:: cudaq_pulse.qvec_ref + +.. autoclass:: cudaq_pulse.kernel.decorator.QuditRef + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: cudaq_pulse.kernel.decorator.QvecRef + :members: + :undoc-members: + :show-inheritance: diff --git a/pulse/docs/api/ops.rst b/pulse/docs/api/ops.rst new file mode 100644 index 00000000000..6b50c2543d8 --- /dev/null +++ b/pulse/docs/api/ops.rst @@ -0,0 +1,70 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +Pulse Operations API +==================== + +Channel Access +-------------- + +.. autofunction:: cudaq_pulse.get_drive_line + +.. autofunction:: cudaq_pulse.get_readout_line + +Scheduling +---------- + +.. autofunction:: cudaq_pulse.drive + +.. autofunction:: cudaq_pulse.readout + +.. autofunction:: cudaq_pulse.wait + +.. autofunction:: cudaq_pulse.sync + +Phase and Frequency +------------------- + +.. autofunction:: cudaq_pulse.shift_phase + +.. autofunction:: cudaq_pulse.set_phase + +.. autofunction:: cudaq_pulse.shift_frequency + +.. autofunction:: cudaq_pulse.set_frequency + +Waveform Constructors +--------------------- + +.. autofunction:: cudaq_pulse.gaussian + +.. autofunction:: cudaq_pulse.square + +.. autofunction:: cudaq_pulse.drag + +.. autofunction:: cudaq_pulse.cosine + +.. autofunction:: cudaq_pulse.tanh_ramp + +.. autofunction:: cudaq_pulse.gaussian_square + +.. autofunction:: cudaq_pulse.custom + +.. autofunction:: cudaq_pulse.custom_samples + +Waveform Arithmetic +------------------- + +.. autofunction:: cudaq_pulse.wf_add + +.. autofunction:: cudaq_pulse.wf_sub + +.. autofunction:: cudaq_pulse.wf_mul + +.. autofunction:: cudaq_pulse.wf_scale + +.. autofunction:: cudaq_pulse.wf_neg diff --git a/pulse/docs/architecture/dialects.rst b/pulse/docs/architecture/dialects.rst new file mode 100644 index 00000000000..bdb46c2909c --- /dev/null +++ b/pulse/docs/architecture/dialects.rst @@ -0,0 +1,114 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +MLIR Dialects +============= + +cudaq-pulse defines three MLIR dialects that form a layered IR stack. +Each dialect has full round-trip fidelity (parse -> print -> parse). + +Pulse Dialect +------------- + +The core dialect for pulse-level quantum programming. Defined in +``core/mlir/include/cudaq-pulse/Dialect/Pulse/PulseOps.td``. + +**Types:** + +- ``!pulse.qudit`` -- quantum degree of freedom +- ``!pulse.line`` -- drive or readout channel (linear resource) +- ``!pulse.tone`` -- frequency/phase reference for a channel +- ``!pulse.waveform`` -- envelope shape + +**Operations:** + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Operation + - Description + * - ``pulse.qudit_alloc`` + - Allocate a qudit resource + * - ``pulse.get_drive_line`` + - Obtain drive line and tone for a qubit + * - ``pulse.get_readout_line`` + - Obtain readout line and tone for a qubit + * - ``pulse.drive`` + - Play a waveform on a drive line + * - ``pulse.readout`` + - Acquire through a readout line + * - ``pulse.wait`` + - Idle delay on a line + * - ``pulse.sync`` + - Synchronize multiple lines + * - ``pulse.shift_phase`` + - Relative phase offset on a tone + * - ``pulse.set_phase`` + - Absolute phase on a tone + * - ``pulse.shift_frequency`` + - Relative frequency offset on a tone + * - ``pulse.set_frequency`` + - Absolute frequency on a tone + * - ``pulse.gaussian`` + - Gaussian envelope waveform + * - ``pulse.square`` + - Flat-top envelope waveform + * - ``pulse.drag`` + - DRAG envelope waveform + * - ``pulse.cosine`` + - Raised-cosine envelope + * - ``pulse.tanh_ramp`` + - Hyperbolic tangent ramp + * - ``pulse.gaussian_square`` + - Gaussian-edge flat-top waveform + * - ``pulse.custom_waveform`` + - User-defined callable envelope + * - ``pulse.custom_samples_waveform`` + - Pre-computed sample array + * - ``pulse.wf_add`` + - Element-wise waveform addition + * - ``pulse.wf_sub`` + - Element-wise waveform subtraction + * - ``pulse.wf_mul`` + - Element-wise waveform multiplication + * - ``pulse.wf_scale`` + - Scalar-waveform multiplication + * - ``pulse.wf_neg`` + - Waveform negation + +QOp Dialect +----------- + +Backend-agnostic quantum operator algebra. Defined in +``core/mlir/include/cudaq-pulse/Dialect/QOp/``. + +The QOp dialect represents the physics of pulse programs as +Hamiltonians, Lindbladians, and time-evolution operators. It serves +as the intermediate representation between pulse-level programming +and simulator-specific APIs. + +**Key operations:** + +- ``qop.hamiltonian`` -- define a Hamiltonian operator +- ``qop.lindbladian`` -- define Lindblad dissipator channels +- ``qop.evolve`` -- time-evolve a quantum state + +CuDensityMat Dialect +-------------------- + +Wrapper for NVIDIA's cuDensityMat GPU-accelerated density matrix +solver. Defined in ``core/mlir/include/cudaq-pulse/Dialect/CuDensityMat/``. + +Maps quantum operator algebra to concrete cuDensityMat API calls. + +**Key operations:** + +- ``cudm.state_create`` -- allocate GPU quantum state +- ``cudm.operator_create`` -- construct operator on GPU +- ``cudm.evolve`` -- GPU-accelerated time evolution +- ``cudm.state_destroy`` -- deallocate GPU state diff --git a/pulse/docs/architecture/mlir_passes.rst b/pulse/docs/architecture/mlir_passes.rst new file mode 100644 index 00000000000..4b6c78265c5 --- /dev/null +++ b/pulse/docs/architecture/mlir_passes.rst @@ -0,0 +1,89 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +MLIR Passes +=========== + +cudaq-pulse implements several C++ MLIR passes that optimize and +validate pulse programs. All passes operate on the in-memory +``mlir::ModuleOp`` and are invoked through ``mlir::PassManager``. + +pulse-verify +------------ + +**Source:** ``core/mlir/transforms/PulseVerify.cpp`` + +Validates structural and semantic correctness of a pulse program: + +- **Waveform validity**: Ensures all waveform durations are positive. +- **Linear-resource validity**: Ensures drive/readout lines have exactly one + continuation at each step and detects multiple uses of a consumed line. +- **Monotone time ordering**: After scheduling, traces each physical line and + verifies that drive/readout intervals do not overlap or move backwards. + +The verify pass runs early in the pipeline and raises a diagnostic +error for any violation, preventing malformed programs from reaching +later stages. + +pulse-canonicalize +------------------ + +**Source:** ``core/mlir/transforms/Canonicalize.cpp`` + +Applies peephole simplifications using MLIR's greedy rewrite infrastructure: + +- **Remove single-operand sync**: A ``pulse.sync`` with only one line + operand is a no-op and is eliminated. +- **Remove redundant sync**: Consecutive ``pulse.sync`` operations on + the same set of lines are deduplicated. + +pulse-virtual-z +--------------- + +**Source:** ``core/mlir/transforms/VirtualZ.cpp`` + +Implements the *virtual-Z gate* optimization. Phase shifts +(``pulse.shift_phase``) that precede drive operations are commuted +forward and absorbed into the drive's waveform phase, eliminating +the need for a physical frame rotation. + +This is a standard optimization in superconducting qubit control that +reduces the number of operations without changing the physical program. + +pulse-fusion +------------ + +**Source:** ``core/mlir/transforms/Fusion.cpp`` + +Merges adjacent ``pulse.drive`` operations on the same line into a +single drive with a combined waveform (using ``pulse.wf_add``). +This reduces operation count and can improve scheduling density. + +Fusion is only applied when both drives use the same tone and have +compatible timing. + +pulse-schedule-alap +------------------- + +**Source:** ``core/mlir/transforms/ScheduleAlap.cpp`` + +Implements As-Late-As-Possible (ALAP) scheduling. Assigns concrete +``pulse.time`` integer attributes to every operation by: + +1. Building a dependency graph from data flow and sync constraints +2. Assigning times in reverse topological order, pushing operations + as late as possible while respecting dependencies +3. Normalizing all times so the earliest operation starts at ``t=0`` + +loop-invariant-code-motion +-------------------------- + +Uses MLIR's built-in ``-loop-invariant-code-motion`` pass to hoist +waveform construction and other loop-invariant operations out of +``scf.for`` loop bodies. Particularly effective for dynamical +decoupling sequences where the same waveform is applied in every +iteration. diff --git a/pulse/docs/architecture/pipeline.rst b/pulse/docs/architecture/pipeline.rst new file mode 100644 index 00000000000..c00a4acbc46 --- /dev/null +++ b/pulse/docs/architecture/pipeline.rst @@ -0,0 +1,91 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +Compilation Pipeline +==================== + +cudaq-pulse compiles ``@kernel`` functions through a multi-stage pipeline +that goes from Python source to GPU-executable code. + +.. :spellcheck-disable: + +.. code-block:: text + + @kernel Python function + | + | bytecode tracing + v + Packed int64 buffer (numpy) + | + | zero-copy FFI (single call) + v + In-memory MLIR ModuleOp (Pulse dialect) + | + | C++ passes: verify, virtual-z, fusion, canonicalize, LICM + v + Optimized Pulse-dialect MLIR + | + | pulse-schedule-alap + v + Scheduled Pulse-dialect MLIR --> CompiledKernel + | + | (optional) dialect lowering + v + Pulse -> QOp -> CuDensityMat -> LLVM + | + | JIT compile + execute + v + GPU simulation via cuDensityMat + +.. :spellcheck-enable: + +Stage 1: Bytecode Tracing +-------------------------- + +The ``@kernel`` decorator captures the decorated function's CPython +bytecode. When ``compile()`` is called, a ``PackedIRBuilder`` traces +the bytecode and writes each operation into a flat ``numpy.ndarray`` +of ``int64`` values. This packed buffer encodes operation types, +arguments, and waveform attributes in a compact binary format. + +Stage 2: FFI to C++ +-------------------- + +The packed buffer is sent to the C++ ``PulseModuleBuilder`` in a single +zero-copy FFI call via nanobind. The builder iterates the buffer and +constructs typed MLIR operations (``pulse.drive``, ``pulse.gaussian``, +etc.) directly into an ``mlir::ModuleOp``. + +Stage 3: MLIR Passes +--------------------- + +The in-memory MLIR module is then run through a configurable set of +C++ optimization passes via ``mlir::PassManager``. See +:doc:`mlir_passes` for details on each pass. + +Stage 4: Scheduling +-------------------- + +The scheduling pass (``pulse-schedule-alap``) assigns concrete +``pulse.time`` attributes to every operation, respecting data +dependencies, sync constraints, and operation durations. + +Stage 5: Lowering (Optional) +----------------------------- + +For GPU execution, three dialect conversion passes lower the IR: + +1. **Pulse -> QOp**: Converts pulse operations to quantum operator + algebra (Hamiltonians, Lindbladians) +2. **QOp -> CuDensityMat**: Maps operators to cuDensityMat API calls +3. **CuDensityMat -> LLVM**: Final lowering to LLVM IR with runtime + library calls + +The execution lowering currently supports two-level transmon models in a +rotating frame. It rejects unsupported measurement, neutral-atom, multilevel, +and unspecialized waveform cases instead of silently approximating them. See +:doc:`../user_guide/gpu_execution` for the complete model and limitations. diff --git a/pulse/docs/conf.py b/pulse/docs/conf.py new file mode 100644 index 00000000000..b382ad950db --- /dev/null +++ b/pulse/docs/conf.py @@ -0,0 +1,48 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Sphinx configuration for cudaq-pulse documentation.""" + +project = "cudaq-pulse" +copyright = "2026, NVIDIA Corporation & Affiliates" +author = "NVIDIA Corporation" +release = "0.1.0" + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "sphinx.ext.mathjax", + "myst_parser", +] + +myst_enable_extensions = [ + "colon_fence", + "deflist", +] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +html_theme = "nvidia_sphinx_theme" + +html_theme_options = { + "show_nav_level": 1, + "navigation_depth": 4, + "show_toc_level": 2, +} + +autodoc_member_order = "bysource" +autodoc_typehints = "description" +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, +} + +napoleon_google_docstring = True +napoleon_numpy_docstring = True diff --git a/pulse/docs/examples.rst b/pulse/docs/examples.rst new file mode 100644 index 00000000000..34d3081bfe5 --- /dev/null +++ b/pulse/docs/examples.rst @@ -0,0 +1,132 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +Examples +======== + +cudaq-pulse includes a comprehensive set of examples demonstrating +pulse-level quantum programming, from single-qubit Rabi oscillations +to multi-qubit QEC circuits. + +All examples are in the ``examples/`` directory and use the canonical +import convention: + +.. :spellcheck-disable: + +.. code-block:: python + + import cudaq_pulse as pulse + +.. :spellcheck-enable: + +Core Examples +------------- + +.. list-table:: + :header-rows: 1 + :widths: 10 25 65 + + * - # + - File + - Description + * - 01 + - ``01_single_qubit_rabi.py`` + - Single-qubit Rabi oscillation with a square pulse of varying amplitude + * - 02 + - ``02_two_qubit_cross_resonance.py`` + - Two-qubit CNOT via echoed cross-resonance driving + * - 03 + - ``03_t1_t2_dissipator.py`` + - T1/T2 measurement with Lindblad dissipator modeling + * - 04 + - ``04_echo_paper.py`` + - Canonical Hahn spin-echo sequence (pi/2 - tau - pi - tau - pi/2) + * - 05 + - ``05_waveform_gallery.py`` + - All 8 waveform constructors and 5 algebraic combinators + * - 06 + - ``06_phase_frequency_control.py`` + - Phase and frequency manipulation primitives on tone channels + * - 07 + - ``07_multi_qubit_sync.py`` + - Multi-qubit programs with sync and ``pulse.qvec_ref()`` allocation + * - 08 + - ``08_readout_and_branching.py`` + - Readout channels and measurement-conditioned branching + * - 09 + - ``09_compilation_pipeline.py`` + - Full ``pulse.compile()`` API walkthrough with pass customization + * - 10 + - ``10_scheduling_comparison.py`` + - Side-by-side ASAP, ALAP, and RCP scheduling on a 4-qubit program + * - 11 + - ``11_loop_optimizations.py`` + - LICM and loop strength reduction on pulse loop bodies + * - 12 + - ``12_error_detection.py`` + - Verification error detection with intentionally malformed programs + * - 13 + - ``13_dynamical_decoupling.py`` + - XY4, Uhrig, and CPMG dynamical decoupling sequences + * - 14 + - ``14_randomized_benchmarking.py`` + - Single-qubit randomized benchmarking with Clifford decomposition + * - 15 + - ``15_pulse_to_operator.py`` + - Experimental Python pulse-to-operator reference lowering + * - 16 + - ``16_visualization.py`` + - Pulse schedule visualization with matplotlib Gantt charts + * - 17 + - ``17_ghz_state_prep.py`` + - N-qubit GHZ state preparation using echoed cross-resonance CNOTs + +Hardware Target Examples +------------------------ + +**Transmon** (``examples/transmon/``): + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - File + - Description + * - ``krinner_rabi.py`` + - Rabi oscillation on the Krinner 17-qubit transmon target + * - ``krinner_full_pipeline.py`` + - Target-aware intermediate lowering and execution-ready Pulse MLIR + * - ``krinner_evolve_gpu.py`` + - Public ``pulse.evolve`` GPU path for the Krinner target + * - ``krinner_surface_code_cycle.py`` + - Surface code stabilizer cycle on the Krinner lattice + +**Neutral Atom** (``examples/neutral_atom/``): + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - File + - Description + * - ``rydberg_blockade_demo.py`` + - Two-atom Rydberg blockade interaction + * - ``rydberg_chain_adiabatic.py`` + - Adiabatic sweep on a 1D Rydberg atom chain + +Running Examples +---------------- + +.. :spellcheck-disable: + +.. code-block:: bash + + # Run from the CUDA-Q repository root after building the pulse targets. + PYTHONPATH=build-pulse/python \ + python pulse/examples/01_single_qubit_rabi.py + +.. :spellcheck-enable: diff --git a/pulse/docs/getting_started.rst b/pulse/docs/getting_started.rst new file mode 100644 index 00000000000..85c3056332e --- /dev/null +++ b/pulse/docs/getting_started.rst @@ -0,0 +1,247 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +Getting Started +=============== + +.. warning:: + + CUDA-Q pulse is **research-preview software**, not production software or a + product-supported CUDA-Q feature. APIs, IR, runtime behavior, build options, + and numerical behavior may change incompatibly or be removed without + notice. No stability or production-readiness guarantee is provided. + +Prerequisites +------------- + +- Python 3.10+ +- numpy + +For building the MLIR bindings from source: + +- The ``cudaq`` and ``cudaq-devel`` wheels, which supply the CUDA-Q and + LLVM/MLIR toolchain +- nanobind 2.12+ +- CMake and Ninja +- pytest, Hypothesis, and LLVM lit for tests + +For GPU simulation (research preview): + +- NVIDIA GPU with compute capability 7.0+ +- cuDensityMat (part of the cuQuantum SDK) + +Build from Source +----------------- + +CUDA-Q pulse lives in the top-level ``pulse`` directory but is a standalone +CMake project: it is not part of the CUDA-Q build. It compiles against the +CUDA-Q toolchain distributed as Python wheels, so there is no LLVM to build and +no submodule to initialize. + +- ``cudaq-devel`` provides the headers, CMake packages, MLIR/LLVM archives and + ``mlir-tblgen``. +- ``cudaq`` (the runtime wheel) provides ``libcudaqMLIR``, the single shared + MLIR/LLVM instance the pulse Python extension resolves its symbols from. + +Both wheels must come from the same CUDA-Q revision. + +.. :spellcheck-disable: + +.. code-block:: bash + + git clone https://github.com/NVIDIA/cuda-quantum.git + cd cuda-quantum + + python3 -m venv .venv-pulse + source .venv-pulse/bin/activate + python -m pip install cudaq cudaq-devel + python -m pip install "nanobind>=2.12" cmake ninja pytest hypothesis lit numpy + + cmake -S pulse -B build-pulse -G Ninja -DCMAKE_BUILD_TYPE=Release + cmake --build build-pulse --parallel + +.. :spellcheck-enable: + +Note the ``-S pulse``: the configure entry point is the ``pulse`` directory, +not the repository root. + +Pulse locates the wheels through ``site.getsitepackages()`` of the interpreter +CMake picks up, so run CMake from the environment the wheels were installed +into, or pass ``-DPython3_EXECUTABLE=/path/to/venv/bin/python``. To build +against a CUDA-Q installation that is not a wheel, point CMake at it with +``-DCMAKE_PREFIX_PATH=/path/to/cudaq/prefix``. nanobind is discovered the same +way and can be overridden with +``-Dnanobind_DIR="$(python -m nanobind --cmake_dir)"``. + +The explicit ``--target pulse`` aggregate target remains available for scripts +that prefer a named target. + +Like CUDA-Q itself, the complete pulse package is staged under +``build-pulse/python``. Put that single directory on your ``PYTHONPATH``: + +.. :spellcheck-disable: + +.. code-block:: bash + + export PATH="$PWD/build-pulse/bin:$PATH" + export PYTHONPATH="$PWD/build-pulse/python${PYTHONPATH:+:$PYTHONPATH}" + +.. :spellcheck-enable: + +Build the cuDensityMat GPU Runtime +---------------------------------- + +The experimental GPU runtime is not needed for compiler-only use. Point +``CUDENSITYMAT_ROOT`` at a cuQuantum SDK containing +``include/cudensitymat.h`` and ``lib/libcudensitymat.so``. CMake then discovers +cuDensityMat and automatically adds the runtime to the ``pulse`` target; there +is no second pulse feature flag: + +.. :spellcheck-disable: + +.. code-block:: bash + + cmake -S pulse -B build-gpu -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCUDAQ_BUILD_TESTS=ON \ + -DCUDENSITYMAT_ROOT=/path/to/cuquantum \ + -DPython3_EXECUTABLE="$PWD/.venv-pulse/bin/python" + cmake --build build-gpu --parallel --target pulse + export CUDAQ_PULSE_BUILD_DIR="$PWD/build-gpu" + export PATH="$PWD/build-gpu/bin:$PATH" + export PYTHONPATH="$PWD/build-gpu/python${PYTHONPATH:+:$PYTHONPATH}" + +.. :spellcheck-enable: + +When ``CUDENSITYMAT_ROOT`` is set, configuration fails if the CUDA Toolkit or +cuDensityMat dependency cannot be found. Without cuDensityMat, pulse remains +usable in compiler-only mode. The runtime links the discovered SDK library and +records its location in the runtime search path. + +Hello World +----------- + +Define a pulse kernel, compile it, and inspect the generated MLIR: + +.. :spellcheck-disable: + +.. code-block:: python + + import cudaq_pulse as pulse + + @pulse.kernel + def rabi_oscillation(qubit): + drive_line, tone = get_drive_line(qubit) + drive(drive_line, gaussian(64, 0.5, 16.0), tone) + + compiled_kernel = pulse.compile(rabi_oscillation, [pulse.qudit_ref()], + qubit_freq_hz={0: 5.0e9}) + print(compiled_kernel.mlir) + print(f"Compiled in {compiled_kernel.metrics.total_ms:.1f} ms") + +.. :spellcheck-enable: + +The Compiler Pipeline +--------------------- + +cudaq-pulse is a Python-first compiler pipeline with four stages: + +1. **Write a kernel in Python** -- the ``@pulse.kernel`` DSL + (``get_drive_line``, ``drive``, ``gaussian``, ``wait``, ``sync``, ...). +2. **Compile to MLIR** -- ``pulse.compile()`` returns a ``CompiledKernel`` + whose ``.mlir`` is the scheduled Pulse dialect. See :doc:`user_guide/compilation`. +3. **Write transform passes in Python and apply them** -- passes are plain + ``Program -> Program`` functions; compose the built-ins or author your own. + See :doc:`user_guide/passes`. +4. **Emit** -- lower the transformed program back to MLIR with + ``program_to_pulse_mlir``. + +GPU simulation via NVIDIA cuDensityMat is a preview capability; see +:doc:`user_guide/gpu_execution`. + +IDE Setup +--------- + +For the best experience, add a ``pyrightconfig.json`` to your project root: + +.. :spellcheck-disable: + +.. code-block:: json + + { + "reportUndefinedVariable": "warning" + } + +.. :spellcheck-enable: + +This downgrades bare-name diagnostics inside ``@pulse.kernel`` functions +from errors to warnings. See :doc:`user_guide/kernels` for details. + +Running Tests +------------- + +.. :spellcheck-disable: + +.. code-block:: bash + + cmake --build build-pulse --target check-pulse + ctest --test-dir build-pulse -L pulse --output-on-failure + +.. :spellcheck-enable: + +For a GPU-enabled build, run the cuDensityMat linkage, descriptor, and +numerical regression tests: + +.. :spellcheck-disable: + +.. code-block:: bash + + cmake --build build-gpu --target check-pulse-gpu + +.. :spellcheck-enable: + +The checks validate SDK linkage and basic descriptors, then exercise +single-qubit drive evolution, T1 decay, two-qubit XX coupling, and the public +compile/JIT path. CMake enables the numerical tests only when ``nvidia-smi`` +reports a GPU and the CUDA runtime can access at least one device. Otherwise +the target reports them disabled and retains the CPU-safe SDK linkage check +when cuDensityMat is installed. These are research regression tests, not a +production numerical-accuracy qualification. + +Docker Environment +------------------ + +Build the turnkey image from the CUDA-Q repository root: + +.. :spellcheck-disable: + +.. code-block:: bash + + docker build -f pulse/docker/Dockerfile -t cudaq-pulse-preview . + docker run --rm -it cudaq-pulse-preview + +.. :spellcheck-enable: + +The Dockerfile installs the ``cudaq`` and ``cudaq-devel`` wheels into a +virtual environment and builds pulse against them; it does not rebuild LLVM. + +Documentation +------------- + +Enable the Sphinx target when configuring pulse, then build it: + +.. :spellcheck-disable: + +.. code-block:: bash + + cmake -S pulse -B build-pulse -G Ninja \ + -DCUDAQ_PULSE_BUILD_DOCS=ON + cmake --build build-pulse --target pulse-docs + +.. :spellcheck-enable: + +Generated HTML is written to ``build-pulse/docs/html``. diff --git a/pulse/docs/index.rst b/pulse/docs/index.rst new file mode 100644 index 00000000000..418f35ce4c9 --- /dev/null +++ b/pulse/docs/index.rst @@ -0,0 +1,75 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +cudaq-pulse +=========== + +**cudaq-pulse** is a pulse-level MLIR dialect and programming model for +quantum control. Its Python DSL drives an MLIR-based compiler pipeline: + +1. Write a pulse kernel with the ``@pulse.kernel`` DSL. +2. Compile it to Pulse-dialect MLIR with ``pulse.compile()``. +3. Write and apply transform passes over the pulse program. +4. Emit MLIR (and lower further) from the transformed program. + +.. warning:: + + CUDA-Q pulse is **research-preview software**, not production software or a + product-supported CUDA-Q feature. APIs, dialects, runtime interfaces, + numerical behavior, build options, and file layout may change incompatibly + or be removed without notice. No stability, compatibility, performance, or + production-readiness guarantee is provided. Expect the work to evolve as + the research matures. + +.. toctree:: + :maxdepth: 2 + :caption: Getting Started + + getting_started + +.. toctree:: + :maxdepth: 2 + :caption: User Guide + + user_guide/kernels + user_guide/operations + user_guide/compilation + user_guide/passes + +.. toctree:: + :maxdepth: 2 + :caption: API Reference + + api/index + +.. toctree:: + :maxdepth: 2 + :caption: Architecture + + architecture/pipeline + architecture/dialects + architecture/mlir_passes + +.. toctree:: + :maxdepth: 1 + :caption: Resources + + examples + +.. toctree:: + :maxdepth: 1 + :caption: Preview / Experimental + + user_guide/gpu_execution + + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/pulse/docs/user_guide/compilation.rst b/pulse/docs/user_guide/compilation.rst new file mode 100644 index 00000000000..5af4de8d21c --- /dev/null +++ b/pulse/docs/user_guide/compilation.rst @@ -0,0 +1,149 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +Compilation +=========== + +``pulse.compile()`` is the single public entry point for compiling +a ``@pulse.kernel`` function into a scheduled, optimized MLIR module. + +Basic Usage +----------- + +.. :spellcheck-disable: + +.. code-block:: python + + import cudaq_pulse as pulse + + @pulse.kernel + def my_kernel(qubit): + drive_line, tone = get_drive_line(qubit) + drive(drive_line, gaussian(40, 0.3, 10.0), tone) + + compiled_kernel = pulse.compile(my_kernel, [pulse.qudit_ref()], + qubit_freq_hz={0: 5.0e9}) + +.. :spellcheck-enable: + +The ``compile()`` Function +-------------------------- + +.. autofunction:: cudaq_pulse.compile + :noindex: + +Parameters +~~~~~~~~~~ + +``kernel_fn`` + A ``@pulse.kernel``-decorated function. + +``args`` + Positional arguments -- typically ``pulse.qudit_ref()`` objects matching + the kernel's parameters. + +``clock_ghz`` *(float, default 2.0)* + System clock frequency in GHz. Determines the physical time + corresponding to one clock cycle. + +``qubit_freq_hz`` *(dict[int, float], optional)* + Mapping from qubit index to qubit frequency in Hz. Used for + rotating-frame calculations and scheduling. + +``schedule`` *(str, default "alap")* + Scheduling policy. Options: ``"asap"``, ``"alap"``, ``"rcp"``, + ``"alap_rcp"``. ASAP and ALAP preserve data and physical-line + dependencies; RCP variants also enforce ``MachineModel`` resource limits. + +``passes`` *(sequence of str, optional)* + Optimization passes to run. Defaults to ``("verify", "virtual_z", "fusion")``. + Pass an empty tuple ``()`` to skip all passes. + + Available passes: ``"verify"``, ``"canonicalize"``, ``"virtual_z"``, + ``"fusion"``, ``"licm"``. + +``machine`` *(MachineModel, optional)* + Machine model for resource-constrained scheduling (RCP). + +CompiledKernel +-------------- + +``pulse.compile()`` returns a ``CompiledKernel`` object: + +.. autoclass:: cudaq_pulse.CompiledKernel + :noindex: + :members: + :undoc-members: + +Key properties and methods: + +``.mlir`` + The Pulse-dialect MLIR text representation (lazily rendered). + +``.module`` + The in-memory ``PulseModule`` (MLIR ``ModuleOp``). + +``.metrics`` + A ``CompileMetrics`` dataclass with per-stage timing. + +``.lower_to_llvm()`` + Run the full MLIR lowering pipeline (Pulse -> QOp -> CuDensityMat -> LLVM). + +``.run()`` + JIT-compile and execute the unitary, two-level lowering on GPU via + cuDensityMat. Use :func:`cudaq_pulse.evolve` for target-aware T1/T2, + coupling, crosstalk, and drive-calibration metadata. + +CompileMetrics +-------------- + +.. autoclass:: cudaq_pulse.CompileMetrics + :noindex: + :members: + :undoc-members: + +Fields (all in milliseconds): + +- ``trace_ms`` -- bytecode tracing and packed buffer construction +- ``ffi_ms`` -- C++ MLIR module construction from packed buffer +- ``passes_ms`` -- optimization pass execution time +- ``schedule_ms`` -- scheduling pass time +- ``total_ms`` -- sum of all stages +- ``op_count`` -- number of MLIR operations in the module + +Customizing the Pass Pipeline +----------------------------- + +To run a custom set of passes: + +.. :spellcheck-disable: + +.. code-block:: python + + compiled_kernel = pulse.compile( + my_kernel, + [pulse.qudit_ref()], + qubit_freq_hz={0: 5e9}, + passes=("verify", "canonicalize", "virtual_z", "fusion", "licm"), + ) + +.. :spellcheck-enable: + +To skip passes entirely (useful for benchmarking): + +.. :spellcheck-disable: + +.. code-block:: python + + compiled_kernel = pulse.compile( + my_kernel, + [pulse.qudit_ref()], + qubit_freq_hz={0: 5e9}, + passes=(), + ) + +.. :spellcheck-enable: diff --git a/pulse/docs/user_guide/gpu_execution.rst b/pulse/docs/user_guide/gpu_execution.rst new file mode 100644 index 00000000000..73fb4755213 --- /dev/null +++ b/pulse/docs/user_guide/gpu_execution.rst @@ -0,0 +1,196 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +GPU Execution +============= + +.. warning:: + + GPU execution is a **research preview**. It is not production software and + carries no API, numerical, compatibility, or stability guarantee. + +cudaq-pulse contains an experimental lowering and runtime path intended to +connect compiled kernels to NVIDIA cuDensityMat. This path remains active +research and should not be treated as a production simulator. + +Build Configuration +------------------- + +With pulse enabled, setting ``CUDENSITYMAT_ROOT`` makes CMake use CUDA-Q's +``FindcuDensityMat.cmake`` module and automatically build the runtime as part +of the ``pulse`` target. CMake fails if the explicitly requested SDK is not +available. There is no separate pulse GPU option. + +.. :spellcheck-disable: + +.. code-block:: bash + + cmake -S pulse -B build-gpu -G Ninja \ + -DCUDAQ_BUILD_TESTS=ON \ + -DCUDENSITYMAT_ROOT=/path/to/cuquantum + cmake --build build-gpu --parallel --target pulse + cmake --build build-gpu --target check-pulse-gpu + export CUDAQ_PULSE_BUILD_DIR="$PWD/build-gpu" + export PATH="$PWD/build-gpu/bin:$PATH" + export PYTHONPATH="$PWD/build-gpu/python${PYTHONPATH:+:$PYTHONPATH}" + +.. :spellcheck-enable: + +Pipeline Overview +----------------- + +The target-aware public execution path is: + +1. Call a ``@pulse.kernel`` to produce traced pulse IR. +2. ``pulse.evolve(..., target=...)`` verifies, optimizes, and schedules it. +3. The native pipeline lowers Pulse -> QOp -> CuDensityMat -> LLVM IR. +4. The JIT compiles the LLVM IR and executes it with cuDensityMat. + +``pulse.compile()`` also returns a ``CompiledKernel`` whose +``lower_to_llvm()`` and ``run()`` methods expose the native pipeline directly. +That path has no ``Target`` parameter and therefore represents unitary, +two-level execution with pulse amplitudes already expressed in radians per +nanosecond. Use ``pulse.evolve`` when target calibration, coupling, or T1/T2 +metadata matters. + +MLIR Lowering +------------- + +To inspect the lowered LLVM IR: + +.. :spellcheck-disable: + +.. code-block:: python + + import cudaq_pulse as pulse + + compiled_kernel = pulse.compile(my_kernel, [pulse.qudit_ref()], + qubit_freq_hz={0: 5e9}) + llvm_ir = compiled_kernel.lower_to_llvm() + print(llvm_ir) + +.. :spellcheck-enable: + +The lowering passes through three dialect conversions: + +**Pulse -> QOp** + Converts supported drive operations and waveforms into backend-agnostic + Hamiltonian and Lindblad operator algebra. Readout and acquisition are + rejected because this preview does not implement measurement simulation. + +**QOp -> CuDensityMat** + Maps operator algebra to cuDensityMat API calls (state creation, + operator construction, time evolution). + +**CuDensityMat -> LLVM** + Lowers cuDensityMat operations to LLVM IR with runtime library calls. + +GPU Simulation +-------------- + +To execute on a GPU (requires cuDensityMat runtime): + +.. :spellcheck-disable: + +.. code-block:: python + + import math + import cudaq_pulse as pulse + from cudaq_pulse.targets import Qubit, Target + + target = Target( + name="one-qubit-demo", + qubits={ + 0: Qubit( + index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=50.0, + t2_star_us=30.0, + drive_params={"amplitude_scale_rad_per_ns": 1.0}, + ) + }, + ) + + @pulse.kernel + def x_gate(qubit): + drive_line, tone = get_drive_line(qubit) + # 40 virtual units at 2 GHz is 20 ns. + drive(drive_line, square(40, math.pi / 20.0), tone) + + ir = x_gate(pulse.qudit_ref()) + result = pulse.evolve( + ir, + target=target, + t_start=0.0, + t_end=20.0, + num_steps=200, + integrator="rk4", + ) + print(result.final_state) + +.. :spellcheck-enable: + +``t_start`` and ``t_end`` are in nanoseconds. The ``integrator`` argument +selects the time-evolution scheme used by the cuDensityMat runtime path: + +- ``rk1``, ``rk2``, ``rk4`` -- fixed-step explicit Runge-Kutta methods. +- ``magnus`` -- a fixed-step Magnus expansion evaluated as a truncated Taylor + series of the propagator; preserves structure well for smoothly varying + drives. +- ``crank_nicolson`` -- a fixed-step implicit predictor-corrector scheme that + is more stable for stiff dynamics. + +All five schemes reuse the same boundary-safe sampling that keeps piecewise +constant pulse segments from being sampled across a discontinuity. + +Requirements: + +- NVIDIA GPU with compute capability 7.0+ +- cuDensityMat library (part of NVIDIA cuQuantum SDK) +- The matching ``llc`` and ``clang`` tools on ``PATH`` or in + ``CUDAQ_PULSE_LLVM_BIN``. A source build normally satisfies this by adding + the CUDA-Q LLVM ``bin`` directory to ``PATH``. + +Physical Model +-------------- + +The current native execution lowering is a two-level transmon model in one +rotating frame per qubit. Drive envelopes are converted to X/Y Hamiltonian +coefficients in radians per nanosecond; tone frequency and phase determine the +rotating-frame detuning and quadrature. A target may supply +``amplitude_scale_rad_per_ns`` explicitly, or pulse infers a scale from its +calibrated Gaussian/DRAG pi-pulse parameters when available. + +Target relaxation and dephasing data produce Lindblad collapse operators. +Coupling edges are modeled as always-on XX terms and residual crosstalk as +always-on ZZ terms. ``anharmonicity_hz`` is retained as calibration metadata +but is not part of this two-level model; leakage requires a future multilevel +lowering. + +Unsupported Execution Features +------------------------------ + +The execution lowering fails explicitly for readout/acquisition, observables, +neutral-atom targets, multilevel models, unspecialized numeric parameters, +arbitrary Python waveform callbacks, and waveform-algebra nodes. Built-in +waveforms and ``custom_samples`` are supported. The callback runtime currently +supports at most 128 drive operations per compiled module. + +CMake registers link/descriptor smoke checks and numerical GPU tests for +single-qubit drives, T1 decay, idle evolution, two-qubit coupling, frame and +I/Q modulation, an eight-qubit ladder register, closed-system physics +validation, quantum-algorithm building blocks, integrator parity across +``rk4``/``magnus``/``crank_nicolson``, and the public compile/JIT path. It +enables the numerical tests only when ``nvidia-smi`` reports a GPU and the +CUDA runtime can access at least one device. Otherwise the ``check-pulse-gpu`` +target reports them disabled and retains the CPU-safe SDK linkage check when +cuDensityMat is installed. These tests provide research regression coverage; +they do not establish production numerical accuracy. + +The ``run()`` method and returned state representation are experimental and +will evolve with the lowering and runtime implementation. diff --git a/pulse/docs/user_guide/kernels.rst b/pulse/docs/user_guide/kernels.rst new file mode 100644 index 00000000000..e6fc10bd0a8 --- /dev/null +++ b/pulse/docs/user_guide/kernels.rst @@ -0,0 +1,156 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +Pulse Kernels +============= + +The ``@pulse.kernel`` decorator is the entry point for writing +pulse-level quantum programs. It captures the decorated function's +Python bytecode and traces it into an intermediate representation +that can be compiled to MLIR. + +Import Convention +----------------- + +A single import gives you everything: + +.. :spellcheck-disable: + +.. code-block:: python + + import cudaq_pulse as pulse + +.. :spellcheck-enable: + +Inside ``@pulse.kernel`` functions, DSL operations (``drive``, +``gaussian``, ``get_drive_line``, etc.) are used as bare names. +Infrastructure stays behind the ``pulse.`` prefix: + +.. :spellcheck-disable: + +.. code-block:: python + + @pulse.kernel + def rabi_oscillation(qubit): + drive_line, tone = get_drive_line(qubit) + drive(drive_line, gaussian(64, 0.5, 16.0), tone) + + compiled_kernel = pulse.compile(rabi_oscillation, [pulse.qudit_ref()], + qubit_freq_hz={0: 5.0e9}) + +.. :spellcheck-enable: + +Defining a Kernel +----------------- + +.. :spellcheck-disable: + +.. code-block:: python + + import cudaq_pulse as pulse + + @pulse.kernel + def my_kernel(qubit): + drive_line, tone = get_drive_line(qubit) + waveform = gaussian(40, 0.3, 10.0) + drive(drive_line, waveform, tone) + +.. :spellcheck-enable: + +Kernel arguments are **qudit references** -- opaque handles representing +quantum degrees of freedom. They are created outside the kernel using +``pulse.qudit_ref()`` or ``pulse.qvec_ref(n)`` and passed in when compiling. + +Qudit Allocation +---------------- + +**Single qudit:** + +.. :spellcheck-disable: + +.. code-block:: python + + qubit = pulse.qudit_ref() + +.. :spellcheck-enable: + +Bind an argument to a specific physical target index when needed: + +.. :spellcheck-disable: + +.. code-block:: python + + target_qubit_4 = pulse.qudit_ref(4) + +.. :spellcheck-enable: + +**Vector of qudits:** + +.. :spellcheck-disable: + +.. code-block:: python + + qubits = pulse.qvec_ref(4) + qubit_0 = qubits[0] + qubit_1 = qubits[1] + +.. :spellcheck-enable: + +Control Flow +------------ + +Kernels support a subset of Python control flow that can be captured +at trace time: + +**For loops** with compile-time integer bounds: + +.. :spellcheck-disable: + +.. code-block:: python + + @pulse.kernel + def echo_sequence(qubit): + drive_line, tone = get_drive_line(qubit) + for i in range(5): + drive(drive_line, gaussian(40, 0.3, 10.0), tone) + wait(drive_line, 20) + +.. :spellcheck-enable: + +Concrete ``range`` loops are unrolled exactly at trace time. Symbolic or +runtime-dependent loop bounds are rejected. + +**If/else** with compile-time conditions: + +.. :spellcheck-disable: + +.. code-block:: python + + @pulse.kernel + def conditional_pulse(qubit, use_drag): + drive_line, tone = get_drive_line(qubit) + if use_drag: + waveform = drag(40, 0.3, 10.0, 0.5) + else: + waveform = gaussian(40, 0.3, 10.0) + drive(drive_line, waveform, tone) + +.. :spellcheck-enable: + +Unsupported Patterns +-------------------- + +The following Python constructs are **not** supported inside kernels +and will raise ``CompilationError``: + +- ``while`` loops +- Nested function definitions or closures +- List comprehensions or generator expressions +- ``try`` / ``except`` blocks +- Calls to arbitrary Python functions (only ``cudaq_pulse`` ops are allowed) +- Dynamic loop bounds (bounds must be known at trace time) +- Runtime-dependent ``if`` conditions and measurement-conditioned control flow diff --git a/pulse/docs/user_guide/operations.rst b/pulse/docs/user_guide/operations.rst new file mode 100644 index 00000000000..6e8862b50e4 --- /dev/null +++ b/pulse/docs/user_guide/operations.rst @@ -0,0 +1,144 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +Pulse Operations +================ + +All operations listed here are used as bare names inside ``@pulse.kernel`` +functions. They are intercepted during bytecode tracing and lowered +to MLIR operations in the Pulse dialect. + +Channel Access +-------------- + +.. function:: get_drive_line(qubit) -> (line, tone) + + Obtain the drive channel for a qubit. Returns a ``(line, tone)`` pair: + the *line* is passed to ``drive()``, ``wait()``, and ``sync()``; the + *tone* is passed to phase and frequency ops. + +.. function:: get_readout_line(qubit) -> (line, tone) + + Obtain the readout channel for a qubit. Same return convention. + +Scheduling Operations +--------------------- + +.. function:: drive(line, waveform, tone) + + Play a waveform envelope on a drive line at the given tone frequency. + +.. function:: readout(line, waveform, tone) + + Acquire a measurement through a readout line. + +.. function:: wait(line, duration) + + Insert an idle delay of *duration* clock cycles on a line. + +.. function:: sync(line1, line2, ...) + + Synchronize two or more lines to a common time point. + All lines are padded to the latest time among them. + +Phase and Frequency Control +--------------------------- + +These operations modify the rotating frame of a tone channel. + +.. function:: shift_phase(tone, phase) + + Add a relative phase offset (radians) to the tone. + +.. function:: set_phase(tone, phase) + + Set the absolute phase (radians) of the tone. + +.. function:: shift_frequency(tone, frequency) + + Add a relative frequency offset (Hz) to the tone. + +.. function:: set_frequency(tone, frequency) + + Set the absolute frequency (Hz) of the tone. + +Waveform Constructors +--------------------- + +Each constructor returns a waveform value that can be passed to +``drive()`` or combined with waveform arithmetic. + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Function + - Description + * - ``gaussian(duration, amplitude, sigma)`` + - Gaussian envelope with standard deviation *sigma* + * - ``square(duration, amplitude)`` + - Constant-amplitude (flat-top) envelope + * - ``drag(duration, amplitude, sigma, beta)`` + - DRAG pulse for leakage suppression + * - ``cosine(duration, amplitude)`` + - Raised-cosine envelope + * - ``tanh_ramp(duration, amplitude, sigma)`` + - Hyperbolic tangent ramp from zero to the requested amplitude + * - ``gaussian_square(duration, amplitude, sigma, width)`` + - Flat-top pulse with Gaussian rise/fall edges + * - ``custom(duration, envelope_fn)`` + - Named user-defined envelope callback for compiler experimentation + * - ``custom_samples(samples)`` + - Waveform from a non-empty sequence of at most 253 real-valued samples + +For ``gaussian_square``, ``width`` is the flat-top width in virtual time units; +``duration - width`` must be a positive even integer so the two edges have the +same integer duration. + +The GPU execution lowering supports the built-in constructors and +``custom_samples``. Arbitrary ``custom`` callbacks must be specialized to +samples by a future pass and are currently rejected before execution. + +Waveform Arithmetic +------------------- + +Waveforms can be combined algebraically inside kernels: + +.. :spellcheck-disable: + +.. code-block:: python + + @pulse.kernel + def combined_waveform(qubit): + drive_line, tone = get_drive_line(qubit) + envelope_a = gaussian(64, 0.5, 16.0) + envelope_b = gaussian(64, 0.3, 10.0) + combined = wf_add(envelope_a, envelope_b) + scaled = wf_scale(0.5, envelope_a) + inverted = wf_neg(envelope_a) + +.. :spellcheck-enable: + +.. list-table:: + :header-rows: 1 + :widths: 30 70 + + * - Function + - Operation + * - ``wf_add(left, right)`` + - ``left + right`` + * - ``wf_sub(left, right)`` + - ``left - right`` + * - ``wf_mul(left, right)`` + - ``left * right`` (element-wise) + * - ``wf_scale(scalar, waveform)`` + - ``scalar * waveform`` + * - ``wf_neg(waveform)`` + - ``-waveform`` + +Waveform algebra is available in the compiler IR, but the GPU execution +lowering does not yet evaluate algebra nodes and rejects them explicitly. diff --git a/pulse/docs/user_guide/passes.rst b/pulse/docs/user_guide/passes.rst new file mode 100644 index 00000000000..9d042f9a304 --- /dev/null +++ b/pulse/docs/user_guide/passes.rst @@ -0,0 +1,124 @@ +.. + Copyright (c) 2026 NVIDIA Corporation & Affiliates. + All rights reserved. + + This source code and the accompanying materials are made available under + the terms of the Apache License 2.0 which accompanies this distribution. + +Writing Transform Passes +======================== + +Beyond the built-in optimization pipeline that ``pulse.compile()`` runs, you +can transform pulse programs directly in Python. Passes operate on a +lightweight ``Program`` / ``Op`` intermediate representation, so authoring and +composing your own transform is just writing a plain Python function. + +.. note:: + + The ``cudaq_pulse.passes`` surface is experimental and may change without + notice. For the standard "compile a kernel end-to-end" path, prefer + :doc:`compilation`. + +The Program IR +-------------- + +A ``Program`` is a flat, ordered list of ``Op`` records over SSA-style +``Value`` handles. You can build one with the fluent ``ProgramBuilder``: + +.. :spellcheck-disable: + +.. code-block:: python + + from cudaq_pulse.passes import ProgramBuilder + + builder = ProgramBuilder("rabi", clock_ghz=2.0) + line, tone = builder.get_drive_line(qubit=0, freq_hz=5.0e9) + envelope = builder.gaussian(duration_vtu=64, amplitude=0.5, sigma=16.0) + builder.drive(line, envelope, tone) + + program = builder.build() + +.. :spellcheck-enable: + +Applying Built-in Passes +------------------------ + +Every optimization pass is a ``Program -> Program`` function, so passes compose +by ordinary function application: + +.. :spellcheck-disable: + +.. code-block:: python + + from cudaq_pulse.passes import ( + run_canonicalize, + run_virtual_z, + run_fusion, + ) + + program = run_canonicalize(program) + program = run_virtual_z(program) + program = run_fusion(program) + +.. :spellcheck-enable: + +Schedulers return a list of timed events plus metrics rather than a new +``Program``: + +.. :spellcheck-disable: + +.. code-block:: python + + from cudaq_pulse.passes import schedule_alap + + events, metrics = schedule_alap(program) + print(f"total {metrics.total_length_vtu:.0f} VTU, " + f"idle {metrics.idle_fraction:.1%}") + +.. :spellcheck-enable: + +Writing Your Own Pass +--------------------- + +A custom transform is just a function that takes a ``Program`` and returns a +new one. This example drops any zero-amplitude drives: + +.. :spellcheck-disable: + +.. code-block:: python + + from cudaq_pulse.passes import Program, OpKind + from dataclasses import replace + + def drop_zero_amplitude(program: Program) -> Program: + kept = [ + op for op in program.ops + if not (op.kind == OpKind.DRIVE and op.attrs.get("amplitude") == 0) + ] + return replace(program, ops=kept) + + program = drop_zero_amplitude(program) + +.. :spellcheck-enable: + +Because passes are pure ``Program -> Program`` functions, they are trivial to +unit test and to interleave with the built-ins in any order. + +Emitting MLIR +------------- + +Once a program has been transformed, lower it to Pulse-dialect MLIR: + +.. :spellcheck-disable: + +.. code-block:: python + + from cudaq_pulse.passes import program_to_pulse_mlir + + mlir = program_to_pulse_mlir(program) + print(mlir) + +.. :spellcheck-enable: + +See ``examples/10_scheduling_comparison.py`` and +``examples/11_loop_optimizations.py`` for complete, runnable pass walkthroughs. diff --git a/pulse/examples/01_single_qubit_rabi.py b/pulse/examples/01_single_qubit_rabi.py new file mode 100644 index 00000000000..172f69bf14d --- /dev/null +++ b/pulse/examples/01_single_qubit_rabi.py @@ -0,0 +1,73 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Single-qubit Rabi oscillation. + +Drives a qubit with a square pulse of varying amplitude to trace out +Rabi oscillations. The simplest end-to-end example, showing both +external and internal qudit allocation styles. + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import cudaq_pulse as pulse + + +@pulse.kernel +def rabi_external(qubit, duration, amplitude): + """Rabi with qudit allocated externally and passed in.""" + drive_line, tone = get_drive_line(qubit) + drive(drive_line, square(duration, amplitude), tone) + + +@pulse.kernel +def rabi_internal(duration, amplitude): + """Rabi with qudit allocated inside the kernel.""" + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + drive(drive_line, square(duration, amplitude), tone) + + +@pulse.kernel +def rabi_with_readout(duration, amplitude): + """Rabi experiment with measurement — the full circuit.""" + qubit = pulse.qudit_ref() + drive_line, drive_tone = get_drive_line(qubit) + readout_line, readout_tone = get_readout_line(qubit) + + drive(drive_line, square(duration, amplitude), drive_tone) + + sync(drive_line, readout_line) + readout(readout_line, square(1000, 0.05), readout_tone) + + +if __name__ == "__main__": + print("=== External allocation ===") + compiled_kernel = pulse.compile(rabi_external, + [pulse.qudit_ref(), 100, 0.5], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") + + print("\n=== Internal allocation ===") + compiled_kernel = pulse.compile(rabi_internal, [100, 0.5], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + + print("\n=== Amplitude sweep (Rabi oscillation) ===") + for amplitude in [0.1, 0.2, 0.3, 0.4, 0.5]: + compiled_kernel = pulse.compile(rabi_internal, [100, amplitude], + qubit_freq_hz={0: 5e9}) + print(f" amplitude={amplitude:.1f}: compiled in " + f"{compiled_kernel.metrics.total_ms:.3f} ms") + + print("\n=== With readout ===") + compiled_kernel = pulse.compile(rabi_with_readout, [100, 0.5], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") diff --git a/pulse/examples/02_two_qubit_cross_resonance.py b/pulse/examples/02_two_qubit_cross_resonance.py new file mode 100644 index 00000000000..d95a3acb0bc --- /dev/null +++ b/pulse/examples/02_two_qubit_cross_resonance.py @@ -0,0 +1,83 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Two-qubit CNOT via echoed cross-resonance. + +Demonstrates cross-resonance driving: the control qubit's drive line +is modulated at the target qubit's frequency (via the target's tone). +Shows both external and internal qudit allocation. + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import cudaq_pulse as pulse + + +@pulse.kernel +def echoed_cr_external(qubit_ctrl, qubit_tgt): + """Echoed cross-resonance CNOT with externally allocated qudits.""" + drive_line_ctrl, tone_ctrl = get_drive_line(qubit_ctrl) + drive_line_tgt, tone_tgt = get_drive_line(qubit_tgt) + + sync(drive_line_ctrl, drive_line_tgt) + + sx_pulse = drag(40, 0.025, 10.0, 0.5) + x_ctrl = drag(40, 0.047, 10.0, 0.5) + cr = gaussian_square(200, 0.10, 10.0, 160) + cr_neg = gaussian_square(200, -0.10, 10.0, 160) + + drive(drive_line_tgt, sx_pulse, tone_tgt) + drive(drive_line_ctrl, cr, tone_tgt) + drive(drive_line_ctrl, x_ctrl, tone_ctrl) + drive(drive_line_ctrl, cr_neg, tone_tgt) + drive(drive_line_tgt, sx_pulse, tone_tgt) + + +@pulse.kernel +def echoed_cr_internal(): + """Echoed cross-resonance CNOT with internally allocated qudits.""" + qubit_ctrl = pulse.qudit_ref() + qubit_tgt = pulse.qudit_ref() + + drive_line_ctrl, tone_ctrl = get_drive_line(qubit_ctrl) + drive_line_tgt, tone_tgt = get_drive_line(qubit_tgt) + + sync(drive_line_ctrl, drive_line_tgt) + + sx_pulse = drag(40, 0.025, 10.0, 0.5) + x_ctrl = drag(40, 0.047, 10.0, 0.5) + cr = gaussian_square(200, 0.10, 10.0, 160) + cr_neg = gaussian_square(200, -0.10, 10.0, 160) + + drive(drive_line_tgt, sx_pulse, tone_tgt) + drive(drive_line_ctrl, cr, tone_tgt) + drive(drive_line_ctrl, x_ctrl, tone_ctrl) + drive(drive_line_ctrl, cr_neg, tone_tgt) + drive(drive_line_tgt, sx_pulse, tone_tgt) + + +if __name__ == "__main__": + print("=== External allocation ===") + compiled_kernel = pulse.compile( + echoed_cr_external, + [pulse.qudit_ref(), pulse.qudit_ref()], + qubit_freq_hz={ + 0: 5e9, + 1: 5.1e9 + }) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") + + print("\n=== Internal allocation ===") + compiled_kernel = pulse.compile(echoed_cr_internal, [], + qubit_freq_hz={ + 0: 5e9, + 1: 5.1e9 + }) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") diff --git a/pulse/examples/03_t1_t2_dissipator.py b/pulse/examples/03_t1_t2_dissipator.py new file mode 100644 index 00000000000..85eaf07d1a2 --- /dev/null +++ b/pulse/examples/03_t1_t2_dissipator.py @@ -0,0 +1,149 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""T1/T2 measurement with Lindblad dissipator. + +Prepares a qubit, applies a wait period, and measures. Demonstrates +both the kernel frontend (via ``pulse.compile()``) and the direct IR builder +for pulse-to-operator lowering that adds Lindblad dissipators from +T1/T2 calibration data. + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import cudaq_pulse as pulse + + +@pulse.kernel +def t1_experiment(wait_time): + """T1 measurement: X pulse -> wait -> readout. + + The wait duration is parameterized to sweep T1 decay. + """ + qubit = pulse.qudit_ref() + drive_line, drive_tone = get_drive_line(qubit) + readout_line, readout_tone = get_readout_line(qubit) + + x_pulse = drag(40, 0.5, 10.0, 0.5) + drive(drive_line, x_pulse, drive_tone) + + wait(drive_line, wait_time) + + sync(drive_line, readout_line) + readout(readout_line, square(1000, 0.05), readout_tone) + + +@pulse.kernel +def t2_ramsey(tau): + """T2* (Ramsey) measurement: pi/2 -> tau -> pi/2 -> readout.""" + qubit = pulse.qudit_ref() + drive_line, drive_tone = get_drive_line(qubit) + readout_line, readout_tone = get_readout_line(qubit) + + half_pi = drag(40, 0.25, 10.0, 0.5) + + drive(drive_line, half_pi, drive_tone) + wait(drive_line, tau) + drive(drive_line, half_pi, drive_tone) + + sync(drive_line, readout_line) + readout(readout_line, square(1000, 0.05), readout_tone) + + +@pulse.kernel +def t2_echo(tau): + """T2 (Hahn echo) measurement: pi/2 -> tau/2 -> pi -> tau/2 -> pi/2 -> readout.""" + qubit = pulse.qudit_ref() + drive_line, drive_tone = get_drive_line(qubit) + readout_line, readout_tone = get_readout_line(qubit) + + half_pi = drag(40, 0.25, 10.0, 0.5) + pi_pulse = drag(40, 0.50, 10.0, 0.5) + half_tau = tau / 2 + + drive(drive_line, half_pi, drive_tone) + wait(drive_line, half_tau) + drive(drive_line, pi_pulse, drive_tone) + wait(drive_line, half_tau) + drive(drive_line, half_pi, drive_tone) + + sync(drive_line, readout_line) + readout(readout_line, square(1000, 0.05), readout_tone) + + +if __name__ == "__main__": + print("=== T1 experiment (sweep wait times) ===") + for tau in [100, 500, 1000, 5000, 10000]: + compiled_kernel = pulse.compile(t1_experiment, [tau], + qubit_freq_hz={0: 5e9}) + print(f" tau={tau:6d} VTU: compiled in " + f"{compiled_kernel.metrics.total_ms:.3f} ms") + + print("\n=== T2* Ramsey (tau=500) ===") + compiled_kernel = pulse.compile(t2_ramsey, [500], qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + + print("\n=== T2 Echo (tau=1000) ===") + compiled_kernel = pulse.compile(t2_echo, [1000], qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + + print("\n=== Pulse-to-operator lowering with dissipators ===") + from cudaq_pulse.passes.ir_types import ( + Program, + Value, + ValueType, + Op, + OpKind, + _mk, + _reset_vid_counter, + ) + from cudaq_pulse.passes.pulse_to_operator import run_pulse_to_operator + + _reset_vid_counter() + drive_line_0 = _mk(ValueType.DRIVE_LINE, "d0") + tone_0 = _mk(ValueType.TONE, "t0") + waveform = _mk(ValueType.WAVEFORM, "x_pulse") + drive_line_1 = _mk(ValueType.DRIVE_LINE) + tone_1 = _mk(ValueType.TONE) + drive_line_2 = _mk(ValueType.DRIVE_LINE) + + program = Program( + name="t1_lowering", + clock_ghz=1.0, + ops=[ + Op(OpKind.ALLOC_DRIVE, (), (drive_line_0, tone_0), { + "qubit": 0, + "freq_hz": 5.0e9 + }), + Op(OpKind.MAKE_WAVEFORM, (), (waveform,), { + "waveform_type": "drag", + "duration_vtu": 40, + "amplitude": 0.5 + }), + Op(OpKind.DRIVE, (drive_line_0, waveform, tone_0), + (drive_line_1, tone_1), { + "duration_vtu": 40, + "amplitude": 0.5, + "qubit_index": 0 + }), + Op(OpKind.WAIT, (drive_line_1,), (drive_line_2,), + {"duration_vtu": 500}), + ], + values=[drive_line_0, tone_0, waveform], + qubit_freq_hz={0: 5.0e9}, + ) + + op_prog = run_pulse_to_operator(program, + t1_times={0: 50e3}, + t2_times={0: 30e3}) + print(f" Hamiltonian terms: {len(op_prog.hamiltonian_terms)}") + print(f" Dissipator terms: {len(op_prog.dissipator_terms)}") + for term in op_prog.dissipator_terms: + print( + f" {term.kind}: qubit={term.qubit_indices}, gamma={term.coefficient:.4g}" + ) diff --git a/pulse/examples/04_echo_paper.py b/pulse/examples/04_echo_paper.py new file mode 100644 index 00000000000..cc5cc533993 --- /dev/null +++ b/pulse/examples/04_echo_paper.py @@ -0,0 +1,107 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Canonical spin-echo example. + +Implements the Hahn echo sequence: pi/2 - tau - pi - tau - pi/2, +which refocuses dephasing due to static frequency detuning and +low-frequency noise. + +This is the canonical echo example from the cudaq-pulse design plan. + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import numpy as np + +import cudaq_pulse as pulse + + +@pulse.kernel +def hahn_echo(sigma, amp_half_pi, amp_pi, tau): + """Hahn echo: pi/2_X - tau - pi_X - tau - pi/2_X.""" + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + + dur_half_pi = int(4 * sigma) + dur_pi = int(4 * sigma) + + half_pi_envelope = gaussian(dur_half_pi, amp_half_pi, sigma) + pi_envelope = gaussian(dur_pi, amp_pi, sigma) + + # pi/2 pulse about X + drive(drive_line, half_pi_envelope, tone) + + # Free evolution + wait(drive_line, tau) + + # pi refocusing pulse about X + drive(drive_line, pi_envelope, tone) + + # Free evolution + wait(drive_line, tau) + + # Final pi/2 pulse about X + drive(drive_line, half_pi_envelope, tone) + + +@pulse.kernel +def cpmg_echo(sigma, amp_half_pi, amp_pi, tau, n_refocus): + """CPMG dynamical decoupling: pi/2_X - [tau - pi_Y - tau]^n - pi/2_X.""" + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + + dur_half_pi = int(4 * sigma) + dur_pi = int(4 * sigma) + + half_pi_envelope = gaussian(dur_half_pi, amp_half_pi, sigma) + pi_envelope = gaussian(dur_pi, amp_pi, sigma) + + # Initial pi/2 about X + drive(drive_line, half_pi_envelope, tone) + + for _i in range(n_refocus): + wait(drive_line, tau) + # pi about Y = phase shift of pi/2 then pi_X + shift_phase(tone, np.pi / 2) + drive(drive_line, pi_envelope, tone) + shift_phase(tone, -np.pi / 2) + wait(drive_line, tau) + + # Final pi/2 about X + drive(drive_line, half_pi_envelope, tone) + + +def main(): + sigma = 10.0 # Gaussian sigma in VTU + amp_half_pi = 0.25 + amp_pi = 0.50 + tau = 500 # VTU + + print("=== Hahn Echo ===") + compiled_kernel = pulse.compile(hahn_echo, + [sigma, amp_half_pi, amp_pi, tau], + qubit_freq_hz={0: 5e9}) + total_hahn = 2 * int(4 * sigma) + int(4 * sigma) + 2 * tau + print(f" Expected duration: {total_hahn} VTU") + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") + + print("\n=== CPMG-4 Echo ===") + n_refocus = 4 + compiled_kernel = pulse.compile( + cpmg_echo, [sigma, amp_half_pi, amp_pi, tau, n_refocus], + qubit_freq_hz={0: 5e9}) + total_cpmg = 2 * int(4 * sigma) + n_refocus * (int(4 * sigma) + 2 * tau) + print(f" Expected duration: {total_cpmg} VTU") + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") + + +if __name__ == "__main__": + main() diff --git a/pulse/examples/05_waveform_gallery.py b/pulse/examples/05_waveform_gallery.py new file mode 100644 index 00000000000..0761233c4e7 --- /dev/null +++ b/pulse/examples/05_waveform_gallery.py @@ -0,0 +1,126 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Waveform gallery: every envelope type and waveform algebra. + +Demonstrates all 8 built-in waveform constructors and the 5 algebraic +combinators (add, sub, mul, scale, neg) available inside a pulse kernel. + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import cudaq_pulse as pulse + +# ── 1. Every waveform constructor ──────────────────────────────────────────── + + +@pulse.kernel +def waveform_showcase(): + """Drive a qubit with each built-in waveform type sequentially.""" + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + + # Gaussian — the workhorse of single-qubit gates + drive(drive_line, gaussian(40, 0.3, 10.0), tone) + + # Square — constant amplitude, simplest envelope + drive(drive_line, square(100, 0.5), tone) + + # DRAG — derivative removal by adiabatic gate, reduces leakage + drive(drive_line, drag(40, 0.25, 10.0, 0.5), tone) + + # Cosine — smooth rise/fall for flux pulses + drive(drive_line, cosine(60, 0.4), tone) + + # Tanh ramp — sigmoidal edges for adiabatic state transfer + drive(drive_line, tanh_ramp(80, 0.35, 5.0), tone) + + # Gaussian square — flat top with Gaussian rise/fall, used in echoed-CR + drive(drive_line, gaussian_square(200, 0.1, 10.0, 160), tone) + + # Custom — named envelope resolved at runtime from calibration DB + drive(drive_line, custom(40, "my_optimal_pulse"), tone) + + # Custom samples — arbitrary IQ envelope from pre-computed data + drive(drive_line, custom_samples([0.1, 0.3, 0.5, 0.3, 0.1]), tone) + + +# ── 2. Waveform algebra ────────────────────────────────────────────────────── + + +@pulse.kernel +def waveform_algebra(): + """Combine waveforms using the built-in algebraic operations.""" + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + + gaussian_envelope = gaussian(40, 0.3, 10.0) + square_envelope = square(40, 0.1) + + # Addition — superpose two envelopes (e.g. DRAG = Gaussian + derivative) + combined = wf_add(gaussian_envelope, square_envelope) + drive(drive_line, combined, tone) + + # Subtraction — difference of envelopes + diff = wf_sub(gaussian_envelope, square_envelope) + drive(drive_line, diff, tone) + + # Multiplication — element-wise product (amplitude modulation) + modulated = wf_mul(gaussian_envelope, square_envelope) + drive(drive_line, modulated, tone) + + # Scale — multiply envelope by a scalar + boosted = wf_scale(gaussian_envelope, 2.0) + drive(drive_line, boosted, tone) + + # Negation — flip sign (180° phase flip in IQ) + flipped = wf_neg(gaussian_envelope) + drive(drive_line, flipped, tone) + + +# ── 3. Composing complex envelopes ─────────────────────────────────────────── + + +@pulse.kernel +def derivative_pulse(sigma, amplitude, beta): + """DRAG-like pulse built manually: Gaussian + beta * d/dt(Gaussian). + + Shows how waveform algebra composes arbitrary envelopes from primitives. + """ + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + dur = 40 + + base = gaussian(dur, amplitude, sigma) + derivative_approx = wf_sub( + gaussian(dur, amplitude, sigma), + gaussian(dur, amplitude, sigma), + ) + correction = wf_scale(derivative_approx, beta) + final_envelope = wf_add(base, correction) + + drive(drive_line, final_envelope, tone) + + +if __name__ == "__main__": + print("=== Waveform Showcase ===") + compiled_kernel = pulse.compile(waveform_showcase, [], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") + + print("\n=== Waveform Algebra ===") + compiled_kernel = pulse.compile(waveform_algebra, [], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + + print("\n=== Derivative Pulse ===") + compiled_kernel = pulse.compile(derivative_pulse, [10.0, 0.3, 0.5], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") diff --git a/pulse/examples/06_phase_frequency_control.py b/pulse/examples/06_phase_frequency_control.py new file mode 100644 index 00000000000..5da857e79ae --- /dev/null +++ b/pulse/examples/06_phase_frequency_control.py @@ -0,0 +1,121 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Phase and frequency manipulation on tone channels. + +Demonstrates shift_phase, set_phase, shift_frequency, set_frequency — +the four tone-modification primitives that control the rotating frame +of a drive or readout channel. + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import math + +import cudaq_pulse as pulse + + +@pulse.kernel +def phase_rotation_demo(): + """Drive X, then shift tone by pi/2 to drive Y, then by pi to drive -X. + + Illustrates how phase shifts rotate the drive axis on the Bloch sphere. + """ + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + + pi2_pulse = gaussian(40, 0.25, 10.0) + + drive(drive_line, pi2_pulse, tone) + + shift_phase(tone, math.pi / 2) + drive(drive_line, pi2_pulse, tone) + + shift_phase(tone, math.pi / 2) + drive(drive_line, pi2_pulse, tone) + + shift_phase(tone, math.pi / 2) + drive(drive_line, pi2_pulse, tone) + + +@pulse.kernel +def set_phase_vs_shift_phase(): + """Contrast between set_phase (absolute) and shift_phase (relative). + + set_phase resets the tone to a fixed angle regardless of history. + shift_phase accumulates relative to the current phase. + """ + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + envelope = gaussian(40, 0.3, 10.0) + + shift_phase(tone, math.pi / 4) + drive(drive_line, envelope, tone) + + shift_phase(tone, math.pi / 4) + drive(drive_line, envelope, tone) + + set_phase(tone, 0.0) + drive(drive_line, envelope, tone) + + +@pulse.kernel +def chirped_drive(n_steps, total_shift_hz): + """Frequency-chirped pulse: step the drive frequency across the pulse. + + Useful for spectroscopy sweeps compiled as a single kernel. + """ + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + step_size = total_shift_hz / n_steps + + for _i in range(n_steps): + drive(drive_line, square(20, 0.1), tone) + shift_frequency(tone, step_size) + + +@pulse.kernel +def sideband_modulation(): + """Use set_frequency to park the drive tone at a sideband. + + Common in flux-tunable transmon architectures where the drive + line needs to address different transition frequencies. + """ + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + + set_frequency(tone, 5.0e9) + drive(drive_line, gaussian(40, 0.25, 10.0), tone) + wait(drive_line, 100) + + set_frequency(tone, 5.15e9) + drive(drive_line, gaussian(40, 0.25, 10.0), tone) + + +if __name__ == "__main__": + print("=== Phase Rotation (4 x pi/2 = full circle) ===") + compiled_kernel = pulse.compile(phase_rotation_demo, [], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + + print("\n=== set_phase vs shift_phase ===") + compiled_kernel = pulse.compile(set_phase_vs_shift_phase, [], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + + print("\n=== Chirped Drive (10 steps, 50 MHz sweep) ===") + compiled_kernel = pulse.compile(chirped_drive, [10, 50e6], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") + + print("\n=== Sideband Modulation ===") + compiled_kernel = pulse.compile(sideband_modulation, [], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") diff --git a/pulse/examples/07_multi_qubit_sync.py b/pulse/examples/07_multi_qubit_sync.py new file mode 100644 index 00000000000..479e3eed18c --- /dev/null +++ b/pulse/examples/07_multi_qubit_sync.py @@ -0,0 +1,163 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Multi-qubit programs with sync barriers and qvec_ref allocation. + +Shows the two allocation styles (single qudit_ref vs vectorized qvec_ref), +the sync primitive for cross-line coordination, and patterns for building +multi-qubit pulse programs. + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import cudaq_pulse as pulse + +# ── 1. Explicit multi-qudit allocation ─────────────────────────────────────── + + +@pulse.kernel +def two_qubit_simultaneous(): + """Drive two qubits simultaneously, then sync them. + + Each qudit is allocated individually inside the kernel. + """ + qubit_0 = pulse.qudit_ref() + qubit_1 = pulse.qudit_ref() + + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + + drive(drive_line_0, gaussian(40, 0.25, 10.0), tone_0) + drive(drive_line_1, gaussian(40, 0.30, 10.0), tone_1) + + sync(drive_line_0, drive_line_1) + + drive(drive_line_0, gaussian(40, 0.25, 10.0), tone_0) + drive(drive_line_1, gaussian(40, 0.30, 10.0), tone_1) + + +# ── 2. External allocation — qudits passed as arguments ────────────────────── + + +@pulse.kernel +def parametric_pi_half(qubit_0, qubit_1, amplitude_0, amplitude_1): + """Calibration-friendly: pass external qudit refs and amplitudes in.""" + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + + drive(drive_line_0, drag(40, amplitude_0, 10.0, 0.5), tone_0) + drive(drive_line_1, drag(40, amplitude_1, 10.0, 0.5), tone_1) + + +# ── 3. qvec_ref — vectorized qudit allocation ──────────────────────────────── + + +@pulse.kernel +def qvec_simultaneous_drive(qubit_count, amplitude): + """Drive N qubits in parallel using a qvec_ref. + + pulse.qvec_ref(qubit_count) allocates a contiguous vector; individual + qudits are accessed by indexing. + """ + qubit_vector = pulse.qvec_ref(qubit_count) + drive_line_0, tone_0 = get_drive_line(qubit_vector[0]) + drive_line_1, tone_1 = get_drive_line(qubit_vector[1]) + + drive(drive_line_0, gaussian(40, amplitude, 10.0), tone_0) + drive(drive_line_1, gaussian(40, amplitude, 10.0), tone_1) + sync(drive_line_0, drive_line_1) + + +# ── 4. Sync patterns ───────────────────────────────────────────────────────── + + +@pulse.kernel +def sync_after_asymmetric_ops(): + """Sync after different-duration ops to re-align timelines. + + Without sync, the two lines would diverge in time. + """ + qubit_0 = pulse.qudit_ref() + qubit_1 = pulse.qudit_ref() + + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + + drive(drive_line_0, square(200, 0.1), tone_0) + drive(drive_line_1, gaussian(40, 0.25, 10.0), tone_1) + + sync(drive_line_0, drive_line_1) + + drive(drive_line_0, gaussian(40, 0.25, 10.0), tone_0) + drive(drive_line_1, gaussian(40, 0.25, 10.0), tone_1) + + +@pulse.kernel +def staggered_readout(): + """Stagger operations across qubits with wait + sync. + + Drive qubit_0, wait, then sync with qubit_1 before a joint operation. + """ + qubit_0 = pulse.qudit_ref() + qubit_1 = pulse.qudit_ref() + + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + + drive(drive_line_0, gaussian(40, 0.5, 10.0), tone_0) + wait(drive_line_0, 500) + + drive(drive_line_1, gaussian(40, 0.5, 10.0), tone_1) + + sync(drive_line_0, drive_line_1) + + drive(drive_line_0, gaussian(40, 0.25, 10.0), tone_0) + drive(drive_line_1, gaussian(40, 0.25, 10.0), tone_1) + + +if __name__ == "__main__": + print("=== Two-qubit simultaneous drive (internal alloc) ===") + compiled_kernel = pulse.compile(two_qubit_simultaneous, [], + qubit_freq_hz={ + 0: 5e9, + 1: 5.1e9 + }) + print(compiled_kernel.mlir) + + print("\n=== Parametric pi/2 (external alloc) ===") + compiled_kernel = pulse.compile( + parametric_pi_half, + [pulse.qudit_ref(), pulse.qudit_ref(), 0.25, 0.28], + qubit_freq_hz={ + 0: 5e9, + 1: 5.1e9 + }) + print(compiled_kernel.mlir) + + print("\n=== qvec simultaneous drive ===") + compiled_kernel = pulse.compile( + qvec_simultaneous_drive, [4, 0.3], + qubit_freq_hz={i: 5e9 + i * 0.1e9 for i in range(4)}) + print(compiled_kernel.mlir) + + print("\n=== Sync after asymmetric ops ===") + compiled_kernel = pulse.compile(sync_after_asymmetric_ops, [], + qubit_freq_hz={ + 0: 5e9, + 1: 5.1e9 + }) + print(compiled_kernel.mlir) + + print("\n=== Staggered readout ===") + compiled_kernel = pulse.compile(staggered_readout, [], + qubit_freq_hz={ + 0: 5e9, + 1: 5.1e9 + }) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") diff --git a/pulse/examples/08_readout_and_branching.py b/pulse/examples/08_readout_and_branching.py new file mode 100644 index 00000000000..ea2de336a7f --- /dev/null +++ b/pulse/examples/08_readout_and_branching.py @@ -0,0 +1,127 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Readout, measurement, and measurement limitations. + +Shows how to use readout channels and acquire IQ data. Runtime-dependent +branching is demonstrated as an intentionally rejected preview limitation. + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import cudaq_pulse as pulse + + +@pulse.kernel +def measure_single_qubit(): + """Prepare |1> and measure. + + Demonstrates the readout channel: get_readout_line returns a + (readout_line, tone) pair, and readout() acquires IQ data. + """ + qubit = pulse.qudit_ref() + drive_line, drive_tone = get_drive_line(qubit) + readout_line, readout_tone = get_readout_line(qubit) + + x_pulse = drag(40, 0.5, 10.0, 0.5) + drive(drive_line, x_pulse, drive_tone) + + sync(drive_line, readout_line) + + readout_envelope = square(1000, 0.05) + readout(readout_line, readout_envelope, readout_tone) + + +@pulse.kernel +def active_reset(): + """Active reset: measure, and if excited, apply an X pulse. + + The first research preview rejects this construct because execution does + not yet implement feedback control. It is retained to demonstrate the + compile-time diagnostic. + """ + qubit = pulse.qudit_ref() + drive_line, drive_tone = get_drive_line(qubit) + readout_line, readout_tone = get_readout_line(qubit) + + readout_envelope = square(1000, 0.05) + result = readout(readout_line, readout_envelope, readout_tone) + + sync(drive_line, readout_line) + + if result: + x_pulse = drag(40, 0.5, 10.0, 0.5) + drive(drive_line, x_pulse, drive_tone) + + +@pulse.kernel +def repeated_measurement(n_shots): + """Repeated preparation and measurement in a single kernel. + + A for loop wrapping prepare-measure cycles. + """ + qubit = pulse.qudit_ref() + drive_line, drive_tone = get_drive_line(qubit) + readout_line, readout_tone = get_readout_line(qubit) + + x_half = drag(40, 0.25, 10.0, 0.5) + + for _shot in range(n_shots): + drive(drive_line, x_half, drive_tone) + sync(drive_line, readout_line) + + readout(readout_line, square(1000, 0.05), readout_tone) + + sync(drive_line, readout_line) + wait(drive_line, 500) + + +@pulse.kernel +def ramsey_with_readout(tau): + """Ramsey experiment: pi/2 - tau - pi/2 - measure. + + Demonstrates the full single-qubit characterization loop with readout. + """ + qubit = pulse.qudit_ref() + drive_line, drive_tone = get_drive_line(qubit) + readout_line, readout_tone = get_readout_line(qubit) + + half_pi = drag(40, 0.25, 10.0, 0.5) + + drive(drive_line, half_pi, drive_tone) + + wait(drive_line, tau) + + drive(drive_line, half_pi, drive_tone) + + sync(drive_line, readout_line) + readout(readout_line, square(1000, 0.05), readout_tone) + + +if __name__ == "__main__": + print("=== Measure single qubit ===") + compiled_kernel = pulse.compile(measure_single_qubit, [], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + + print("\n=== Active reset (measurement-conditioned branch) ===") + try: + pulse.compile(active_reset, [], qubit_freq_hz={0: 5e9}) + except pulse.CompilationError as error: + print(f" Correctly rejected in the research preview: {error}") + + print("\n=== Repeated measurement (5 shots) ===") + compiled_kernel = pulse.compile(repeated_measurement, [5], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + + print("\n=== Ramsey with readout (tau=200) ===") + compiled_kernel = pulse.compile(ramsey_with_readout, [200], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") diff --git a/pulse/examples/09_compilation_pipeline.py b/pulse/examples/09_compilation_pipeline.py new file mode 100644 index 00000000000..4e22a12ee2e --- /dev/null +++ b/pulse/examples/09_compilation_pipeline.py @@ -0,0 +1,84 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Full compilation pipeline walkthrough. + +Demonstrates the ``pulse.compile()`` API — the single entry point that +captures a kernel, runs all optimization passes, and produces a +``pulse.CompiledKernel`` containing lowered MLIR. +""" + +import math + +import cudaq_pulse as pulse + + +@pulse.kernel +def pipeline_demo(qubit_0, qubit_1): + """A 2-qubit program with redundancies for the pipeline to clean up. + + Includes: + - A shift_phase before a drive (virtual-Z folds it) + - Adjacent waits (canonicalize merges them) + - Adjacent same-amplitude square pulses (fusion merges them) + """ + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + + shift_phase(tone_0, math.pi / 4) + gaussian_envelope = gaussian(40, 0.3, 10.0) + drive(drive_line_0, gaussian_envelope, tone_0) + + wait(drive_line_0, 100) + wait(drive_line_0, 100) + + square_pulse_1 = square(50, 0.2) + drive(drive_line_0, square_pulse_1, tone_0) + square_pulse_2 = square(50, 0.2) + drive(drive_line_0, square_pulse_2, tone_0) + + gaussian_envelope_2 = gaussian(40, 0.3, 10.0) + drive(drive_line_1, gaussian_envelope_2, tone_1) + + +def main(): + compiled_kernel = pulse.compile( + pipeline_demo, + [pulse.qudit_ref(), pulse.qudit_ref()], + clock_ghz=1.0, + qubit_freq_hz={ + 0: 5.0e9, + 1: 5.1e9 + }, + schedule="alap", + ) + + print("=== Compiled MLIR (first 30 lines) ===") + for line in compiled_kernel.mlir.splitlines()[:30]: + print(f" {line}") + + metrics = compiled_kernel.metrics + print(f"\n=== Compile metrics ===") + print(f" Capture : {metrics.capture_ms:.3f} ms") + print(f" Lower : {metrics.lower_ms:.3f} ms") + print(f" Passes : {metrics.passes_ms:.3f} ms") + print(f" Schedule : {metrics.schedule_ms:.3f} ms") + print(f" MLIR emit : {metrics.mlir_emit_ms:.3f} ms") + print(f" Total : {metrics.total_ms:.3f} ms") + + # Lower to LLVM IR in process with the native MLIR binding. + try: + llvm_ir = compiled_kernel.lower_to_llvm() + print(f"\n=== LLVM IR ({len(llvm_ir)} chars, first 10 lines) ===") + for line in llvm_ir.splitlines()[:10]: + print(f" {line}") + except RuntimeError as error: + print(f"\n(LLVM lowering failed: {error})") + + +if __name__ == "__main__": + main() diff --git a/pulse/examples/10_scheduling_comparison.py b/pulse/examples/10_scheduling_comparison.py new file mode 100644 index 00000000000..5b01666bb39 --- /dev/null +++ b/pulse/examples/10_scheduling_comparison.py @@ -0,0 +1,155 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Side-by-side comparison of ASAP, ALAP, and RCP scheduling policies. + +Builds a realistic 4-qubit program with varying op durations and resource +contention, then schedules it under all three policies and prints the +resulting timelines and metrics. +""" + +from cudaq_pulse.passes.ir_types import ( + Program, + Value, + ValueType, + Op, + OpKind, + _mk, + _reset_vid_counter, +) +from cudaq_pulse.passes.scheduling import ( + schedule_asap, + schedule_alap, + schedule_rcp, + MachineModel, + ScheduledEvent, +) + + +def build_four_qubit_program() -> Program: + """A 4-qubit program with mixed durations and syncs.""" + _reset_vid_counter() + + lines, tones, waveforms = [], [], [] + for i in range(4): + lines.append(_mk(ValueType.DRIVE_LINE, f"d{i}")) + tones.append(_mk(ValueType.TONE, f"t{i}")) + + for i in range(4): + waveforms.append(_mk(ValueType.WAVEFORM, f"wf{i}")) + + ops = [] + for i in range(4): + ops.append( + Op(OpKind.ALLOC_DRIVE, (), (lines[i], tones[i]), { + "qubit": i, + "freq_hz": (5.0 + 0.1 * i) * 1e9 + })) + + for i in range(4): + ops.append( + Op( + OpKind.MAKE_WAVEFORM, (), (waveforms[i],), { + "waveform_type": "gaussian", + "duration_vtu": 40 + 20 * i, + "amplitude": 0.25 + })) + + out_lines = [] + out_tones = [] + for i in range(4): + drive_out = _mk(ValueType.DRIVE_LINE, f"d{i}'") + tone_out = _mk(ValueType.TONE, f"t{i}'") + ops.append( + Op(OpKind.DRIVE, (lines[i], waveforms[i], tones[i]), + (drive_out, tone_out), { + "duration_vtu": 40 + 20 * i, + "amplitude": 0.25 + })) + out_lines.append(drive_out) + out_tones.append(tone_out) + + ops.append(Op(OpKind.SYNC, tuple(out_lines), (), {})) + + out_lines2 = [] + for i in range(4): + drive_out_2 = _mk(ValueType.DRIVE_LINE, f"d{i}''") + tone_out_2 = _mk(ValueType.TONE, f"t{i}''") + ops.append( + Op(OpKind.DRIVE, (out_lines[i], waveforms[i], out_tones[i]), + (drive_out_2, tone_out_2), { + "duration_vtu": 40 + 20 * i, + "amplitude": 0.25 + })) + out_lines2.append(drive_out_2) + + return Program( + name="four_qubit", + clock_ghz=1.0, + ops=ops, + values=lines + tones + waveforms, + qubit_freq_hz={i: (5.0 + 0.1 * i) * 1e9 for i in range(4)}, + ) + + +def print_timeline(events: list[ScheduledEvent], label: str) -> None: + """Pretty-print a timeline of scheduled events.""" + print(f"\n --- {label} ---") + active = [ev for ev in events if ev.duration_vtu > 0] + for ev in sorted(active, key=lambda e: (e.start_vtu, e.line_id or 0)): + lid = f"line {ev.line_id}" if ev.line_id is not None else "global" + print( + f" [{ev.start_vtu:7.1f} - {ev.end_vtu:7.1f}] {lid:>8} {ev.kind}" + ) + + +def main(): + prog = build_four_qubit_program() + print(f"Program: {prog.name}, {len(prog.ops)} ops, 4 qubits") + + # ASAP + events_asap, metrics_asap = schedule_asap(prog) + print_timeline(events_asap, "ASAP") + print(f"\n Total: {metrics_asap.total_length_vtu:.0f} VTU, " + f"idle: {metrics_asap.idle_fraction:.1%}, " + f"compile: {metrics_asap.compile_time_ms:.3f} ms") + + # ALAP + events_alap, metrics_alap = schedule_alap(prog) + print_timeline(events_alap, "ALAP") + print(f"\n Total: {metrics_alap.total_length_vtu:.0f} VTU, " + f"idle: {metrics_alap.idle_fraction:.1%}, " + f"compile: {metrics_alap.compile_time_ms:.3f} ms") + + # RCP with hardware constraints + machine = MachineModel( + max_concurrent_drives=2, + max_concurrent_readouts=1, + line_switch_penalty_vtu=5.0, + ) + events_rcp, metrics_rcp = schedule_rcp(prog, machine) + print_timeline( + events_rcp, + f"RCP (max {machine.max_concurrent_drives} concurrent drives)") + print(f"\n Total: {metrics_rcp.total_length_vtu:.0f} VTU, " + f"idle: {metrics_rcp.idle_fraction:.1%}, " + f"compile: {metrics_rcp.compile_time_ms:.3f} ms") + + # Summary table + print("\n ┌──────────┬───────────┬──────────┬────────────┐") + print(" │ Policy │ Total VTU │ Idle % │ Compile ms │") + print(" ├──────────┼───────────┼──────────┼────────────┤") + for name, metrics in [("ASAP", metrics_asap), ("ALAP", metrics_alap), + ("RCP", metrics_rcp)]: + print( + f" │ {name:<8} │ {metrics.total_length_vtu:>9.0f} │ {metrics.idle_fraction:>7.1%} │ {metrics.compile_time_ms:>10.3f} │" + ) + print(" └──────────┴───────────┴──────────┴────────────┘") + + +if __name__ == "__main__": + main() diff --git a/pulse/examples/11_loop_optimizations.py b/pulse/examples/11_loop_optimizations.py new file mode 100644 index 00000000000..804cc6087bc --- /dev/null +++ b/pulse/examples/11_loop_optimizations.py @@ -0,0 +1,139 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Loop optimization passes: LICM and loop strength reduction. + +Shows how loop-invariant code motion hoists waveform construction out of +for-loops, and how strength reduction annotates linear phase progressions +for more efficient downstream lowering. +""" + +import math + +from cudaq_pulse.passes.ir_types import ( + Program, + Value, + ValueType, + Op, + OpKind, + _mk, + _reset_vid_counter, +) +from cudaq_pulse.passes.loop_passes import run_licm, run_loop_strength_reduction + + +def build_loop_program() -> Program: + """A for-loop that constructs the same waveform every iteration. + + LICM should hoist the waveform construction before the loop. + Strength reduction should annotate the constant shift_phase. + """ + _reset_vid_counter() + + drive_line_0 = _mk(ValueType.DRIVE_LINE, "d0") + tone_0 = _mk(ValueType.TONE, "t0") + + ops = [ + Op(OpKind.ALLOC_DRIVE, (), (drive_line_0, tone_0), { + "qubit": 0, + "freq_hz": 5.0e9 + }), + Op(OpKind.FOR_LOOP, (), (), { + "lb": 0, + "ub": 10, + "step": 1, + "var": "i", + "count": 10 + }), + ] + + prev_drive, prev_tone = drive_line_0, tone_0 + for _ in range(1): + waveform = _mk(ValueType.WAVEFORM, "wf_loop") + drive_out = _mk(ValueType.DRIVE_LINE) + tone_out = _mk(ValueType.TONE) + + ops.append( + Op( + OpKind.MAKE_WAVEFORM, (), (waveform,), { + "waveform_type": "gaussian", + "duration_vtu": 40, + "amplitude": 0.3, + "sigma": 10.0 + })) + ops.append( + Op(OpKind.DRIVE, (prev_drive, waveform, prev_tone), + (drive_out, tone_out), { + "duration_vtu": 40, + "amplitude": 0.3 + })) + ops.append( + Op(OpKind.SHIFT_PHASE, (tone_out,), (), + {"phase_rad": math.pi / 10})) + + prev_drive, prev_tone = drive_out, tone_out + + ops.append(Op(OpKind.END_FOR, (), (), {})) + + return Program( + name="loop_opt_demo", + clock_ghz=1.0, + ops=ops, + values=[drive_line_0, tone_0], + qubit_freq_hz={0: 5.0e9}, + ) + + +def find_op_index(ops: list[Op], kind: str) -> int: + for i, op in enumerate(ops): + if op.kind == kind: + return i + return -1 + + +def main(): + prog = build_loop_program() + + print("=== Original program ===") + for i, op in enumerate(prog.ops): + print(f" [{i:2d}] {op.kind}" + + (f" attrs={op.attrs}" if op.attrs else "")) + + # LICM: hoist waveform construction + hoisted = run_licm(prog) + print(f"\n=== After LICM ({len(hoisted.ops)} ops) ===") + for_idx = find_op_index(hoisted.ops, OpKind.FOR_LOOP) + wf_idx = find_op_index(hoisted.ops, OpKind.MAKE_WAVEFORM) + for i, op in enumerate(hoisted.ops): + marker = "" + if op.kind == OpKind.MAKE_WAVEFORM and i < for_idx: + marker = " <-- HOISTED before loop" + print(f" [{i:2d}] {op.kind}{marker}") + + if wf_idx < for_idx: + print( + "\n Waveform construction successfully hoisted above the for-loop." + ) + else: + print( + "\n (No hoisting occurred — waveform may depend on loop variables.)" + ) + + # Strength reduction: annotate constant shift_phase + reduced = run_loop_strength_reduction(hoisted) + print(f"\n=== After strength reduction ({len(reduced.ops)} ops) ===") + for i, op in enumerate(reduced.ops): + if op.attrs.get("strength_reduced"): + print(f" [{i:2d}] {op.kind} ** strength-reduced: " + f"delta={op.attrs['increment_delta']:.4f}, " + f"loop_count={op.attrs.get('loop_count', '?')}") + else: + print(f" [{i:2d}] {op.kind}") + + +if __name__ == "__main__": + main() diff --git a/pulse/examples/12_error_detection.py b/pulse/examples/12_error_detection.py new file mode 100644 index 00000000000..8a39f289b1f --- /dev/null +++ b/pulse/examples/12_error_detection.py @@ -0,0 +1,230 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Verification error detection: intentionally bad programs. + +Demonstrates every category of error the pulse-verify pass can catch: + - Linearity violations (double consumption, unconsumed values) + - Backward time travel + - Drive exclusivity overlap + - Cross-resonance miscalibration heuristic +""" + +from cudaq_pulse.passes.ir_types import ( + Program, + Value, + ValueType, + Op, + OpKind, + _mk, + _reset_vid_counter, +) +from cudaq_pulse.passes.verify import ( + verify, + check_linearity, + check_monotone_time, + check_drive_exclusivity, + check_cr_miscalibration, + LinearityViolation, + BackwardTimeTravelError, + UnintentionalOverlapError, + CrossResonanceMiscalibrationError, +) + +# ── 1. Linearity violation: double consumption ────────────────────────────── + + +def linearity_double_consume() -> Program: + """Use the same linear drive_line value in two drive ops.""" + _reset_vid_counter() + + drive_line_0 = _mk(ValueType.DRIVE_LINE, "d0") + tone_0 = _mk(ValueType.TONE, "t0") + waveform = _mk(ValueType.WAVEFORM, "wf") + drive_out_1 = _mk(ValueType.DRIVE_LINE) + tone_out_1 = _mk(ValueType.TONE) + drive_out_2 = _mk(ValueType.DRIVE_LINE) + tone_out_2 = _mk(ValueType.TONE) + + ops = [ + Op(OpKind.ALLOC_DRIVE, (), (drive_line_0, tone_0), {"qubit": 0}), + Op(OpKind.MAKE_WAVEFORM, (), (waveform,), {"duration_vtu": 40}), + Op(OpKind.DRIVE, (drive_line_0, waveform, tone_0), + (drive_out_1, tone_out_1), {"duration_vtu": 40}), + Op(OpKind.DRIVE, (drive_line_0, waveform, tone_0), + (drive_out_2, tone_out_2), {"duration_vtu": 40}), + ] + + return Program(name="bad_linearity", + clock_ghz=1.0, + ops=ops, + values=[drive_line_0, tone_0, waveform]) + + +# ── 2. Backward time travel ───────────────────────────────────────────────── + + +def backward_time() -> Program: + """An op references a start time earlier than its predecessor's end.""" + _reset_vid_counter() + + drive_line_0 = _mk(ValueType.DRIVE_LINE, "d0") + tone_0 = _mk(ValueType.TONE, "t0") + waveform = _mk(ValueType.WAVEFORM, "wf") + drive_line_1 = _mk(ValueType.DRIVE_LINE) + tone_1 = _mk(ValueType.TONE) + drive_line_2 = _mk(ValueType.DRIVE_LINE) + tone_2 = _mk(ValueType.TONE) + + ops = [ + Op(OpKind.ALLOC_DRIVE, (), (drive_line_0, tone_0), {"qubit": 0}), + Op(OpKind.MAKE_WAVEFORM, (), (waveform,), {"duration_vtu": 100}), + Op(OpKind.DRIVE, (drive_line_0, waveform, tone_0), + (drive_line_1, tone_1), { + "duration_vtu": 100, + "start_vtu": 0 + }), + Op(OpKind.DRIVE, (drive_line_1, waveform, tone_1), + (drive_line_2, tone_2), { + "duration_vtu": 40, + "start_vtu": 50 + }), + ] + + return Program(name="bad_time", + clock_ghz=1.0, + ops=ops, + values=[drive_line_0, tone_0, waveform]) + + +# ── 3. Drive exclusivity overlap ──────────────────────────────────────────── + + +def overlapping_drives() -> Program: + """Two drive ops on the same line with overlapping time intervals.""" + _reset_vid_counter() + + drive_line_0 = _mk(ValueType.DRIVE_LINE, "d0") + tone_0 = _mk(ValueType.TONE, "t0") + waveform = _mk(ValueType.WAVEFORM, "wf") + drive_line_1 = _mk(ValueType.DRIVE_LINE) + tone_1 = _mk(ValueType.TONE) + drive_line_2 = _mk(ValueType.DRIVE_LINE) + tone_2 = _mk(ValueType.TONE) + + ops = [ + Op(OpKind.ALLOC_DRIVE, (), (drive_line_0, tone_0), {"qubit": 0}), + Op(OpKind.MAKE_WAVEFORM, (), (waveform,), {"duration_vtu": 100}), + Op(OpKind.DRIVE, (drive_line_0, waveform, tone_0), + (drive_line_1, tone_1), { + "duration_vtu": 100, + "start_vtu": 0 + }), + Op(OpKind.DRIVE, (drive_line_0, waveform, tone_0), + (drive_line_2, tone_2), { + "duration_vtu": 100, + "start_vtu": 50 + }), + ] + + return Program(name="bad_overlap", + clock_ghz=1.0, + ops=ops, + values=[drive_line_0, tone_0, waveform]) + + +# ── 4. Cross-resonance miscalibration ─────────────────────────────────────── + + +def cr_miscalibration() -> Program: + """A CR drive tagged for qubit 1 but using a tone labeled for qubit 2.""" + _reset_vid_counter() + + drive_line_0 = _mk(ValueType.DRIVE_LINE, "d0") + tone_0 = _mk(ValueType.TONE, "t0") + waveform = _mk(ValueType.WAVEFORM, "wf") + drive_line_1 = _mk(ValueType.DRIVE_LINE) + tone_1 = _mk(ValueType.TONE) + + ops = [ + Op(OpKind.ALLOC_DRIVE, (), (drive_line_0, tone_0), {"qubit": 0}), + Op(OpKind.MAKE_WAVEFORM, (), (waveform,), {"duration_vtu": 200}), + Op(OpKind.DRIVE, (drive_line_0, waveform, tone_0), + (drive_line_1, tone_1), { + "duration_vtu": 200, + "cr_target": 1, + "tone_tag": "q2_tone" + }), + ] + + return Program(name="bad_cr", + clock_ghz=1.0, + ops=ops, + values=[drive_line_0, tone_0, waveform]) + + +# ── 5. Clean program (control) ────────────────────────────────────────────── + + +def clean_program() -> Program: + """A well-formed program that should pass all checks.""" + _reset_vid_counter() + + drive_line_0 = _mk(ValueType.DRIVE_LINE, "d0") + tone_0 = _mk(ValueType.TONE, "t0") + waveform = _mk(ValueType.WAVEFORM, "wf") + drive_line_1 = _mk(ValueType.DRIVE_LINE) + tone_1 = _mk(ValueType.TONE) + + ops = [ + Op(OpKind.ALLOC_DRIVE, (), (drive_line_0, tone_0), {"qubit": 0}), + Op(OpKind.MAKE_WAVEFORM, (), (waveform,), {"duration_vtu": 40}), + Op(OpKind.DRIVE, (drive_line_0, waveform, tone_0), + (drive_line_1, tone_1), {"duration_vtu": 40}), + ] + + return Program(name="clean", + clock_ghz=1.0, + ops=ops, + values=[drive_line_0, tone_0, waveform]) + + +def main(): + test_cases = [ + ("Linearity violation (double consume)", linearity_double_consume, + LinearityViolation), + ("Backward time travel", backward_time, BackwardTimeTravelError), + ("Overlapping drives", overlapping_drives, UnintentionalOverlapError), + ("CR miscalibration", cr_miscalibration, + CrossResonanceMiscalibrationError), + ("Clean program (should pass)", clean_program, None), + ] + + for label, builder, expected_type in test_cases: + prog = builder() + issues = verify(prog) + status = "PASS" if not issues else f"CAUGHT {len(issues)} issue(s)" + print(f"\n{'='*60}") + print(f" {label}: {status}") + print(f"{'='*60}") + + for issue in issues: + matched = expected_type and isinstance(issue, expected_type) + tag = "EXPECTED" if matched else "OTHER" + print(f" [{tag}] {issue}") + + if not issues: + if expected_type is None: + print(" All checks passed (as expected).") + else: + print( + f" WARNING: Expected {expected_type.__name__} but no issues found." + ) + + +if __name__ == "__main__": + main() diff --git a/pulse/examples/13_dynamical_decoupling.py b/pulse/examples/13_dynamical_decoupling.py new file mode 100644 index 00000000000..e179a50d057 --- /dev/null +++ b/pulse/examples/13_dynamical_decoupling.py @@ -0,0 +1,164 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Dynamical decoupling pulse sequences: XY4, Uhrig, and CPMG. + +Each sequence uses a different refocusing pattern to suppress different +noise spectra. All three use for-loops that capture as rolled scf.for ops +in the IR — demonstrating the kernel's loop-capture capability. + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import math + +import cudaq_pulse as pulse + + +@pulse.kernel +def xy4(sigma, amplitude_pi, tau, n_cycles): + """XY4 dynamical decoupling: [X - tau - Y - tau - X - tau - Y - tau]^n. + + Suppresses both dephasing and amplitude noise by alternating X and Y + refocusing axes. + """ + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + + dur_pi = int(4 * sigma) + pi_x = gaussian(dur_pi, amplitude_pi, sigma) + + for _cycle in range(n_cycles): + # X refocus + drive(drive_line, pi_x, tone) + wait(drive_line, tau) + + # Y refocus = phase shift pi/2, then pi_X, then undo phase + shift_phase(tone, math.pi / 2) + drive(drive_line, pi_x, tone) + shift_phase(tone, -math.pi / 2) + wait(drive_line, tau) + + # X refocus + drive(drive_line, pi_x, tone) + wait(drive_line, tau) + + # Y refocus + shift_phase(tone, math.pi / 2) + drive(drive_line, pi_x, tone) + shift_phase(tone, -math.pi / 2) + wait(drive_line, tau) + + +@pulse.kernel +def uhrig_dd(sigma, amplitude_pi, total_time, n_pulses): + """Uhrig dynamical decoupling (UDD). + + Places n refocusing pulses at non-uniform intervals: + t_j = T * sin^2(pi * j / (2n + 2)) + + Optimal for pure dephasing from a soft-cutoff noise spectrum. + The intervals are computed at compile time from n_pulses. + """ + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + + dur_pi = int(4 * sigma) + pi_pulse = gaussian(dur_pi, amplitude_pi, sigma) + + prev_time = 0 + for j in range(n_pulses): + frac = math.sin(math.pi * (j + 1) / (2 * n_pulses + 2))**2 + tj = int(total_time * frac) + gap = tj - prev_time - dur_pi + if gap > 0: + wait(drive_line, gap) + drive(drive_line, pi_pulse, tone) + prev_time = tj + + remaining = total_time - prev_time + if remaining > 0: + wait(drive_line, remaining) + + +@pulse.kernel +def cpmg(sigma, amplitude_half_pi, amplitude_pi, tau, n_refocus): + """Carr-Purcell-Meiboom-Gill: pi/2_X - [tau - pi_Y - tau]^n - pi/2_X. + + The standard multi-pulse echo for dephasing suppression. + """ + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + + dur = int(4 * sigma) + half_pi_pulse = gaussian(dur, amplitude_half_pi, sigma) + pi_pulse = gaussian(dur, amplitude_pi, sigma) + + drive(drive_line, half_pi_pulse, tone) + + for _i in range(n_refocus): + wait(drive_line, tau) + shift_phase(tone, math.pi / 2) + drive(drive_line, pi_pulse, tone) + shift_phase(tone, -math.pi / 2) + wait(drive_line, tau) + + drive(drive_line, half_pi_pulse, tone) + + +@pulse.kernel +def knill_dd(sigma, amplitude_pi, tau): + """Knill dynamical decoupling (KDD): a composite-pulse sequence. + + Uses 5 pi-pulses with carefully chosen phases to suppress both + systematic over/under-rotation and dephasing. + Phases: [pi/6, 0, pi/2, 0, pi/6] + """ + qubit = pulse.qudit_ref() + drive_line, tone = get_drive_line(qubit) + + dur_pi = int(4 * sigma) + pi_pulse = gaussian(dur_pi, amplitude_pi, sigma) + + phases = [math.pi / 6, 0.0, math.pi / 2, 0.0, math.pi / 6] + + for idx in range(5): + wait(drive_line, tau) + shift_phase(tone, phases[idx]) + drive(drive_line, pi_pulse, tone) + shift_phase(tone, -phases[idx]) + + +if __name__ == "__main__": + sigma = 10.0 + amplitude_pi = 0.50 + amplitude_half_pi = 0.25 + tau = 200 + + print("=== XY4 (4 cycles) ===") + compiled_kernel = pulse.compile(xy4, [sigma, amplitude_pi, tau, 4], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") + + print("\n=== Uhrig DD (8 pulses, T=5000 VTU) ===") + compiled_kernel = pulse.compile(uhrig_dd, [sigma, amplitude_pi, 5000, 8], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + + print("\n=== CPMG (8 refocusing pulses) ===") + compiled_kernel = pulse.compile( + cpmg, [sigma, amplitude_half_pi, amplitude_pi, tau, 8], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + + print("\n=== KDD (Knill DD) ===") + compiled_kernel = pulse.compile(knill_dd, [sigma, amplitude_pi, tau], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") diff --git a/pulse/examples/14_randomized_benchmarking.py b/pulse/examples/14_randomized_benchmarking.py new file mode 100644 index 00000000000..b6db63bfa60 --- /dev/null +++ b/pulse/examples/14_randomized_benchmarking.py @@ -0,0 +1,109 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Single-qubit randomized benchmarking (RB) at the pulse level. + +Generates a sequence of random Clifford gates decomposed into native +pi/2 and pi pulses with phase shifts, followed by the inverse Clifford. +Demonstrates how pulse-level programming captures complex gate sequences +as flat, high-performance IR. + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import math + +import cudaq_pulse as pulse + +CLIFFORD_DECOMPOSITIONS = [ + [], + [("X90",)], + [("X90",), ("X90",)], + [("X90",), ("X90",), ("X90",)], + [("Y90",)], + [("Y90",), ("Y90",)], + [("Y90",), ("Y90",), ("Y90",)], + [("X90",), ("Y90",)], + [("X90",), ("Y90",), ("Y90",), ("Y90",)], + [("X90",), ("X90",), ("X90",), ("Y90",)], + [("Y90",), ("X90",)], + [("Y90",), ("X90",), ("X90",), ("X90",)], +] + + +@pulse.kernel +def clifford_sequence(sequence_length, seed): + """Apply a sequence of single-qubit Cliffords + inversion + readout. + + The specific Cliffords are determined by `seed` at compile time. + """ + qubit = pulse.qudit_ref() + drive_line, drive_tone = get_drive_line(qubit) + readout_line, readout_tone = get_readout_line(qubit) + + half_pi = drag(40, 0.25, 10.0, 0.5) + + for _step in range(sequence_length): + drive(drive_line, half_pi, drive_tone) + shift_phase(drive_tone, math.pi / 2) + drive(drive_line, half_pi, drive_tone) + shift_phase(drive_tone, -math.pi / 2) + + shift_phase(drive_tone, math.pi) + for _step in range(sequence_length): + shift_phase(drive_tone, math.pi / 2) + drive(drive_line, half_pi, drive_tone) + shift_phase(drive_tone, -math.pi / 2) + drive(drive_line, half_pi, drive_tone) + shift_phase(drive_tone, -math.pi) + + sync(drive_line, readout_line) + readout(readout_line, square(1000, 0.05), readout_tone) + + +@pulse.kernel +def rb_depth_sweep(): + """Compile RB sequences at multiple depths in a single kernel. + + The outer loop over depths is unrolled at compile time; the inner + Clifford loops use for-loop capture. + """ + qubit = pulse.qudit_ref() + drive_line, drive_tone = get_drive_line(qubit) + readout_line, readout_tone = get_readout_line(qubit) + + half_pi = drag(40, 0.25, 10.0, 0.5) + + for depth in range(5): + for _step in range(depth): + drive(drive_line, half_pi, drive_tone) + shift_phase(drive_tone, math.pi / 2) + + sync(drive_line, readout_line) + readout(readout_line, square(1000, 0.05), readout_tone) + sync(drive_line, readout_line) + + +if __name__ == "__main__": + print("=== RB Clifford sequence (depth=10) ===") + compiled_kernel = pulse.compile(clifford_sequence, [10, 42], + qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") + + print("\n=== Depth sweep ===") + for depth in [1, 5, 10, 20]: + compiled_kernel = pulse.compile(clifford_sequence, [depth, 0], + qubit_freq_hz={0: 5e9}) + print(f" depth={depth:3d}: compiled in " + f"{compiled_kernel.metrics.total_ms:.3f} ms") + + print("\n=== RB depth sweep (depths 0-4 in one kernel) ===") + compiled_kernel = pulse.compile(rb_depth_sweep, [], qubit_freq_hz={0: 5e9}) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") diff --git a/pulse/examples/15_pulse_to_operator.py b/pulse/examples/15_pulse_to_operator.py new file mode 100644 index 00000000000..b30733952c3 --- /dev/null +++ b/pulse/examples/15_pulse_to_operator.py @@ -0,0 +1,130 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Experimental Python pulse-to-operator reference lowering. + +Demonstrates the lightweight Python lowering from pulse IR to operator data, +including: + - Static Hamiltonian terms (qubit frequencies -> sigma_z) + - Time-dependent drive controls (drive ops -> callbacks) + - Lindblad dissipators from T1/T2 calibration data + +NOTE: This example uses the advanced internal API (``_to_program``) because +``run_pulse_to_operator`` operates on ``Program`` objects — a specialized +pass not available in the standard C++ compilation pipeline. +""" + +import math + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program +from cudaq_pulse.passes.pulse_to_operator import ( + run_pulse_to_operator, + OperatorProgram, + OperatorTerm, +) + + +@pulse.kernel +def single_qubit_drive(qubit_0): + """Simple single-qubit X drive for lowering.""" + drive_line_0, tone_0 = get_drive_line(qubit_0) + waveform = gaussian(40, 0.3, 10.0) + drive(drive_line_0, waveform, tone_0) + + +@pulse.kernel +def two_qubit_cr(qubit_0, qubit_1): + """Two-qubit program with cross-resonance for lowering.""" + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + + waveform_x = drag(40, 0.25, 10.0, 0.5) + waveform_cr = gaussian_square(300, 0.05, 10.0, 200.0) + + drive(drive_line_0, waveform_x, tone_0) + drive(drive_line_0, waveform_cr, tone_1) + drive(drive_line_1, waveform_x, tone_1) + + +def print_operator_program(op_prog: OperatorProgram) -> None: + """Pretty-print an operator program.""" + print(f" Name: {op_prog.name}") + print(f" Qubits: {op_prog.n_qubits}") + print(f" Total time: {op_prog.total_time_ns:.1f} ns") + print(f" Ops emitted: {len(op_prog.ops)}") + + print(f"\n Hamiltonian terms ({len(op_prog.hamiltonian_terms)}):") + for term in op_prog.hamiltonian_terms: + td = "time-dep" if term.time_dependent else "static" + print(f" {term.kind:20s} qubits={term.qubit_indices} " + f"coeff={term.coefficient:.6g} [{td}]") + + if op_prog.dissipator_terms: + print(f"\n Dissipator terms ({len(op_prog.dissipator_terms)}):") + for term in op_prog.dissipator_terms: + print( + f" {term.kind:20s} qubits={term.qubit_indices} gamma={term.coefficient:.6g}" + ) + + +def main(): + # 1. Single-qubit drive -> operator (no dissipators) + print("=" * 60) + print(" Single-qubit drive -> Hamiltonian") + print("=" * 60) + ir = single_qubit_drive(pulse.qudit_ref()) + prog = _to_program(ir, clock_ghz=1.0, qubit_freq_hz={0: 5.0e9}) + op_prog = run_pulse_to_operator(prog) + print_operator_program(op_prog) + + # 2. Same program with T1/T2 dissipators + print(f"\n{'=' * 60}") + print(" Single-qubit with T1=50us, T2=30us dissipators") + print("=" * 60) + op_prog_diss = run_pulse_to_operator( + prog, + t1_times={0: 50e3}, + t2_times={0: 30e3}, + ) + print_operator_program(op_prog_diss) + + # 3. Two-qubit cross-resonance -> operator + print(f"\n{'=' * 60}") + print(" Two-qubit CR -> Hamiltonian + dissipators") + print("=" * 60) + ir_cr = two_qubit_cr(pulse.qudit_ref(), pulse.qudit_ref()) + prog_cr = _to_program(ir_cr, + clock_ghz=1.0, + qubit_freq_hz={ + 0: 5.0e9, + 1: 5.15e9 + }) + op_prog_cr = run_pulse_to_operator( + prog_cr, + t1_times={ + 0: 80e3, + 1: 60e3 + }, + t2_times={ + 0: 50e3, + 1: 40e3 + }, + ) + print_operator_program(op_prog_cr) + + # 4. Show the op-level IR for inspection + print(f"\n{'=' * 60}") + print(" Generated qop IR (two-qubit)") + print("=" * 60) + for i, op in enumerate(op_prog_cr.ops): + res_names = [v.name for v in op.results if v.name] + print(f" [{i:2d}] {op.kind:30s} -> {res_names}") + + +if __name__ == "__main__": + main() diff --git a/pulse/examples/16_visualization.py b/pulse/examples/16_visualization.py new file mode 100644 index 00000000000..29a1243732d --- /dev/null +++ b/pulse/examples/16_visualization.py @@ -0,0 +1,194 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Pulse schedule visualization with matplotlib. + +Shows how to use the viz module to plot per-line Gantt charts of +scheduled pulse programs — useful for debugging timing, identifying +idle gaps, and verifying sync alignment. + +Requires matplotlib: pip install matplotlib +""" + +from cudaq_pulse.passes.ir_types import ( + Program, + Value, + ValueType, + Op, + OpKind, + _mk, + _reset_vid_counter, +) +from cudaq_pulse.passes.scheduling import schedule_asap, schedule_alap, ScheduledEvent + + +def build_vis_program() -> Program: + """A 3-qubit program with varied timing for interesting visualization.""" + _reset_vid_counter() + + lines, tones = [], [] + for i in range(3): + lines.append(_mk(ValueType.DRIVE_LINE, f"d{i}")) + tones.append(_mk(ValueType.TONE, f"t{i}")) + + waveform_short = _mk(ValueType.WAVEFORM, "wf_short") + waveform_long = _mk(ValueType.WAVEFORM, "wf_long") + waveform_readout = _mk(ValueType.WAVEFORM, "wf_ro") + + readout_line = _mk(ValueType.READOUT_LINE, "r0") + readout_tone = _mk(ValueType.TONE, "rt0") + + ops = [ + Op(OpKind.ALLOC_DRIVE, (), (lines[0], tones[0]), { + "qubit": 0, + "freq_hz": 5.0e9 + }), + Op(OpKind.ALLOC_DRIVE, (), (lines[1], tones[1]), { + "qubit": 1, + "freq_hz": 5.1e9 + }), + Op(OpKind.ALLOC_DRIVE, (), (lines[2], tones[2]), { + "qubit": 2, + "freq_hz": 5.2e9 + }), + Op(OpKind.ALLOC_READOUT, (), (readout_line, readout_tone), + {"qubit": 0}), + Op(OpKind.MAKE_WAVEFORM, (), (waveform_short,), { + "waveform_type": "drag", + "duration_vtu": 40 + }), + Op(OpKind.MAKE_WAVEFORM, (), (waveform_long,), { + "waveform_type": "gaussian_square", + "duration_vtu": 300 + }), + Op(OpKind.MAKE_WAVEFORM, (), (waveform_readout,), { + "waveform_type": "square", + "duration_vtu": 1000 + }), + ] + + out = [[], [], []] + for i in range(3): + drive_out = _mk(ValueType.DRIVE_LINE) + tone_out = _mk(ValueType.TONE) + ops.append( + Op(OpKind.DRIVE, (lines[i], waveform_short, tones[i]), + (drive_out, tone_out), { + "duration_vtu": 40, + "amplitude": 0.25 + })) + out[i] = [drive_out, tone_out] + + ops.append(Op(OpKind.SYNC, (out[0][0], out[1][0], out[2][0]), (), {})) + + drive_out_0_cr = _mk(ValueType.DRIVE_LINE) + tone_out_0_cr = _mk(ValueType.TONE) + ops.append( + Op(OpKind.DRIVE, (out[0][0], waveform_long, out[1][1]), + (drive_out_0_cr, tone_out_0_cr), { + "duration_vtu": 300, + "amplitude": 0.05 + })) + + drive_wait_2 = _mk(ValueType.DRIVE_LINE) + ops.append( + Op(OpKind.WAIT, (out[2][0],), (drive_wait_2,), {"duration_vtu": 200})) + + drive_out_1_2 = _mk(ValueType.DRIVE_LINE) + tone_out_1_2 = _mk(ValueType.TONE) + ops.append( + Op(OpKind.DRIVE, (out[1][0], waveform_short, out[1][1]), + (drive_out_1_2, tone_out_1_2), { + "duration_vtu": 40, + "amplitude": 0.25 + })) + + ops.append( + Op(OpKind.SYNC, (drive_out_0_cr, drive_out_1_2, drive_wait_2), (), {})) + + readout_out = _mk(ValueType.READOUT_LINE) + readout_tone_out = _mk(ValueType.TONE) + ops.append( + Op(OpKind.READOUT, (readout_line, waveform_readout, readout_tone), + (readout_out, readout_tone_out), { + "duration_vtu": 1000, + "amplitude": 0.05 + })) + + return Program( + name="vis_demo", + clock_ghz=1.0, + ops=ops, + values=lines + tones + [ + waveform_short, waveform_long, waveform_readout, readout_line, + readout_tone + ], + qubit_freq_hz={ + 0: 5.0e9, + 1: 5.1e9, + 2: 5.2e9 + }, + ) + + +def print_ascii_timeline(events: list[ScheduledEvent], width: int = 70) -> None: + """Render a simple ASCII timeline for terminals without matplotlib.""" + active = [ + ev for ev in events if ev.duration_vtu > 0 and ev.line_id is not None + ] + if not active: + print(" (no timed events)") + return + + max_t = max(ev.end_vtu for ev in active) + line_ids = sorted({ev.line_id for ev in active}) + + for lid in line_ids: + line_evs = [ev for ev in active if ev.line_id == lid] + row = [" "] * width + for ev in line_evs: + start = int(ev.start_vtu / max_t * (width - 1)) + end = int(ev.end_vtu / max_t * (width - 1)) + char = "D" if ev.kind == "drive" else ( + "R" if ev.kind == "readout" else ".") + for c in range(start, min(end + 1, width)): + row[c] = char + print(f" line {lid}: |{''.join(row)}|") + + print(f" {'':>8} 0{' ' * (width - 8)}{max_t:.0f} VTU") + + +def main(): + prog = build_vis_program() + print(f"Program: {prog.name}, {len(prog.ops)} ops, 3 qubits + readout\n") + + # ASAP schedule + events_asap, metrics_asap = schedule_asap(prog) + print(f"=== ASAP Schedule ({metrics_asap.total_length_vtu:.0f} VTU) ===") + print_ascii_timeline(events_asap) + + # ALAP schedule + events_alap, metrics_alap = schedule_alap(prog) + print(f"\n=== ALAP Schedule ({metrics_alap.total_length_vtu:.0f} VTU) ===") + print_ascii_timeline(events_alap) + + # Try matplotlib plot + try: + from cudaq_pulse.viz.timeline import plot_schedule, save_schedule + fig = plot_schedule(events_asap, + program=prog, + title="ASAP Schedule (3 qubits)") + print("\n matplotlib figure created successfully.") + print(" Call save_schedule(events, 'output.png') to save to file.") + except ImportError: + print( + "\n matplotlib not installed. Install with: pip install matplotlib" + ) + + +if __name__ == "__main__": + main() diff --git a/pulse/examples/17_ghz_state_prep.py b/pulse/examples/17_ghz_state_prep.py new file mode 100644 index 00000000000..1c1f53fb177 --- /dev/null +++ b/pulse/examples/17_ghz_state_prep.py @@ -0,0 +1,121 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GHZ state preparation at the pulse level. + +Creates the N-qubit GHZ state |000...0> + |111...1> using: + 1. A pi/2 pulse on qubit 0 + 2. A chain of echoed cross-resonance CNOT gates: (0,1), (1,2), ..., (N-2,N-1) + +Demonstrates multi-qubit pulse programming with internal allocation, +sync barriers, and the cross-resonance gate primitive. + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import cudaq_pulse as pulse + + +@pulse.kernel +def ghz_3(): + """3-qubit GHZ state: H(qubit_0) - CNOT(0,1) - CNOT(1,2). + + Each CNOT is an echoed cross-resonance gate: + SX(target) - CR(ctrl->tgt) - X(ctrl) - CR_neg(ctrl->tgt) - SX(target) + """ + qubit_0 = pulse.qudit_ref() + qubit_1 = pulse.qudit_ref() + qubit_2 = pulse.qudit_ref() + + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + drive_line_2, tone_2 = get_drive_line(qubit_2) + + sx_pulse = drag(40, 0.25, 10.0, 0.5) + x_pi = drag(40, 0.50, 10.0, 0.5) + cr_pos = gaussian_square(200, 0.05, 10.0, 160) + cr_neg = gaussian_square(200, -0.05, 10.0, 160) + + # H(qubit_0) = Rz(pi) SX Rz(pi/2) + shift_phase(tone_0, 3.14159) + drive(drive_line_0, sx_pulse, tone_0) + shift_phase(tone_0, 1.5708) + + sync(drive_line_0, drive_line_1) + + # CNOT(0, 1): echoed CR + drive(drive_line_1, sx_pulse, tone_1) # SX on target + drive(drive_line_0, cr_pos, tone_1) # CR on ctrl at target freq + drive(drive_line_0, x_pi, tone_0) # X on ctrl + drive(drive_line_0, cr_neg, tone_1) # CR_neg on ctrl at target freq + drive(drive_line_1, sx_pulse, tone_1) # SX on target + + sync(drive_line_0, drive_line_1, drive_line_2) + + # CNOT(1, 2): echoed CR + drive(drive_line_2, sx_pulse, tone_2) + drive(drive_line_1, cr_pos, tone_2) + drive(drive_line_1, x_pi, tone_1) + drive(drive_line_1, cr_neg, tone_2) + drive(drive_line_2, sx_pulse, tone_2) + + +@pulse.kernel +def ghz_n(qubit_count): + """N-qubit GHZ state using a loop of CNOT gates. + + The outer structure: H on qubit_0, then CNOT chain. + The for-loop demonstrates loop capture for repeated gate patterns. + Qubit allocation is inside the kernel. + """ + qubit_0 = pulse.qudit_ref() + qubit_1 = pulse.qudit_ref() + + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + + sx_pulse = drag(40, 0.25, 10.0, 0.5) + x_pi = drag(40, 0.50, 10.0, 0.5) + cr_pos = gaussian_square(200, 0.05, 10.0, 160) + cr_neg = gaussian_square(200, -0.05, 10.0, 160) + + # H(qubit_0) + shift_phase(tone_0, 3.14159) + drive(drive_line_0, sx_pulse, tone_0) + shift_phase(tone_0, 1.5708) + + # CNOT chain: for a 2-qubit kernel we do one CNOT(0,1) + # In a real N-qubit system, this loop would iterate over qubit pairs + for _i in range(qubit_count - 1): + sync(drive_line_0, drive_line_1) + drive(drive_line_1, sx_pulse, tone_1) + drive(drive_line_0, cr_pos, tone_1) + drive(drive_line_0, x_pi, tone_0) + drive(drive_line_0, cr_neg, tone_1) + drive(drive_line_1, sx_pulse, tone_1) + + +if __name__ == "__main__": + print("=== 3-qubit GHZ (explicit) ===") + compiled_kernel = pulse.compile(ghz_3, [], + qubit_freq_hz={ + 0: 5e9, + 1: 5.1e9, + 2: 5.2e9 + }) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") + + print("\n=== N-qubit GHZ (loop-based, N=5) ===") + compiled_kernel = pulse.compile(ghz_n, [5], + qubit_freq_hz={ + 0: 5e9, + 1: 5.1e9 + }) + print(compiled_kernel.mlir) + print(f" Compile metrics: {compiled_kernel.metrics}") diff --git a/pulse/examples/neutral_atom/rydberg_blockade_demo.py b/pulse/examples/neutral_atom/rydberg_blockade_demo.py new file mode 100644 index 00000000000..5c636025fb4 --- /dev/null +++ b/pulse/examples/neutral_atom/rydberg_blockade_demo.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Two-atom Rydberg blockade demonstration. + +Demonstrates: + - Two atoms within the blockade radius + - The Rydberg interaction prevents double excitation + - Comparison of interaction strength at different spacings + +When atoms are within the blockade radius (R_b), the interaction energy +V = C6/r^6 >> Omega, preventing both atoms from being excited to |r>. +This is the basis for Rydberg quantum gates. + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import math + +import cudaq_pulse as pulse +from cudaq_pulse.targets.rydberg import rydberg_chain, RydbergAtom, RydbergTarget + +RABI_MHZ = 4.0 + +print("=== Rydberg Blockade Physics ===\n") + +# Compute blockade radius +reference_chain = rydberg_chain(2, spacing_um=6.0, global_rabi_mhz=RABI_MHZ) +R_b = reference_chain.blockade_radius() +print(f"Blockade radius R_b = (C6/Omega)^(1/6) = {R_b:.2f} um") +print(f" C6 = {reference_chain.c6:.0f} * 2pi MHz um^6 (Rb-87, 70S_1/2)") +print(f" Omega = {RABI_MHZ} MHz") + +print("\n--- Interaction strength vs spacing ---") +for spacing in [3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 15.0]: + chain = rydberg_chain(2, spacing_um=spacing, global_rabi_mhz=RABI_MHZ) + V = chain.interaction_strength(chain.atoms[0], chain.atoms[1]) + ratio = V / RABI_MHZ + regime = "BLOCKADE" if spacing < R_b else "weak" + print( + f" r = {spacing:5.1f} um | V = {V:10.2f} MHz | V/Omega = {ratio:8.1f} | {regime}" + ) + +# Build a pulse program for blockade demo +print("\n--- Two-atom blockade pulse program ---") + +close_chain = rydberg_chain(2, spacing_um=4.0, global_rabi_mhz=RABI_MHZ) +V_close = close_chain.interaction_strength(close_chain.atoms[0], + close_chain.atoms[1]) +target = close_chain.to_target() + +print(f"\nSpacing = 4.0 um (within blockade radius)") +print(f"V = {V_close:.1f} MHz >> Omega = {RABI_MHZ} MHz") +print(f"Blockade regime: double excitation |rr> strongly suppressed") + +pi_duration = int(1000.0 / (2.0 * RABI_MHZ)) +pi_amplitude = complex(RABI_MHZ / 10.0, 0) + + +@pulse.kernel +def blockade_2atom(qubit_0, qubit_1): + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + + # Global pi pulse: creates |W> = (|gr> + |rg>)/sqrt(2) in blockade regime + pi_pulse = square(pi_duration, pi_amplitude) + drive(drive_line_0, pi_pulse, tone_0) + drive(drive_line_1, pi_pulse, tone_1) + + sync(drive_line_0, drive_line_1) + + wait(drive_line_0, 100) + wait(drive_line_1, 100) + + +compiled_kernel = pulse.compile( + blockade_2atom, + [pulse.qudit_ref(), pulse.qudit_ref()], + clock_ghz=1.0, + qubit_freq_hz={ + 0: RABI_MHZ * 1e6, + 1: RABI_MHZ * 1e6 + }, +) + +print(compiled_kernel.mlir) + +metrics = compiled_kernel.metrics +print(f"\n=== Compile metrics ===") +print(f" Trace : {metrics.trace_ms:.3f} ms") +print(f" Passes : {metrics.passes_ms:.3f} ms") +print(f" Schedule: {metrics.schedule_ms:.3f} ms") +print(f" Total : {metrics.total_ms:.3f} ms") diff --git a/pulse/examples/neutral_atom/rydberg_chain_adiabatic.py b/pulse/examples/neutral_atom/rydberg_chain_adiabatic.py new file mode 100644 index 00000000000..872bd0b25e4 --- /dev/null +++ b/pulse/examples/neutral_atom/rydberg_chain_adiabatic.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Adiabatic sweep on a 1D Rydberg chain. + +Demonstrates: + - Creating a Rydberg target with a 1D chain geometry + - Computing blockade radius and interaction strengths + - Building a pulse program for global adiabatic detuning sweep + - Compiling via ``pulse.compile()`` + +The adiabatic protocol starts with large negative detuning (all atoms in |g>), +ramps through resonance, and ends at large positive detuning to prepare a +many-body ordered state (Z2 antiferromagnet for appropriate spacing). + +NOTE: Requires cudaq-pulse native C++ bindings (see README for build +instructions). +""" + +import math + +import cudaq_pulse as pulse +from cudaq_pulse.targets.rydberg import rydberg_chain + +N_ATOMS = 7 +SPACING_UM = 6.0 + +chain = rydberg_chain(N_ATOMS, spacing_um=SPACING_UM, global_rabi_mhz=4.0) +target = chain.to_target() + +print(f"Rydberg chain: {chain.n_atoms} atoms, spacing = {SPACING_UM} um") +print(f"Blockade radius: {chain.blockade_radius():.2f} um") +print( + f"Nearest-neighbor V = {chain.interaction_strength(chain.atoms[0], chain.atoms[1]):.2f} MHz" +) +print( + f"Next-nearest V = {chain.interaction_strength(chain.atoms[0], chain.atoms[2]):.4f} MHz" +) + +ham_terms = chain.hamiltonian_terms() +diss_terms = chain.dissipator_terms() +print(f"\nHamiltonian: {len(ham_terms)} terms") +for term in ham_terms[:5]: + print(f" {term['kind']:25s} qubits={term['qubit_indices']} " + f"coeff={term['coefficient'].real:.4e}") +if len(ham_terms) > 5: + print(f" ... ({len(ham_terms) - 5} more)") +print(f"Dissipators: {len(diss_terms)} terms") + +RAMP_STEPS = 20 +STEP_DURATION = 50 +amplitude = chain.global_rabi_mhz / 10.0 + + +@pulse.kernel +def rydberg_adiabatic(qubit_0, qubit_1, qubit_2, qubit_3, qubit_4, qubit_5, + qubit_6): + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + drive_line_2, tone_2 = get_drive_line(qubit_2) + drive_line_3, tone_3 = get_drive_line(qubit_3) + drive_line_4, tone_4 = get_drive_line(qubit_4) + drive_line_5, tone_5 = get_drive_line(qubit_5) + drive_line_6, tone_6 = get_drive_line(qubit_6) + + for step in range(RAMP_STEPS): + pulse_0 = square(STEP_DURATION, complex(amplitude, 0)) + drive(drive_line_0, pulse_0, tone_0) + pulse_1 = square(STEP_DURATION, complex(amplitude, 0)) + drive(drive_line_1, pulse_1, tone_1) + pulse_2 = square(STEP_DURATION, complex(amplitude, 0)) + drive(drive_line_2, pulse_2, tone_2) + pulse_3 = square(STEP_DURATION, complex(amplitude, 0)) + drive(drive_line_3, pulse_3, tone_3) + pulse_4 = square(STEP_DURATION, complex(amplitude, 0)) + drive(drive_line_4, pulse_4, tone_4) + pulse_5 = square(STEP_DURATION, complex(amplitude, 0)) + drive(drive_line_5, pulse_5, tone_5) + pulse_6 = square(STEP_DURATION, complex(amplitude, 0)) + drive(drive_line_6, pulse_6, tone_6) + + if step < RAMP_STEPS - 1: + sync(drive_line_0, drive_line_1, drive_line_2, drive_line_3, + drive_line_4, drive_line_5, drive_line_6) + + +frequency = chain.global_rabi_mhz * 1e6 +compiled_kernel = pulse.compile( + rydberg_adiabatic, + [pulse.qudit_ref() for _ in range(N_ATOMS)], + clock_ghz=1.0, + qubit_freq_hz={i: frequency for i in range(N_ATOMS)}, +) + +print(f"\nSweep time: {RAMP_STEPS * STEP_DURATION} VTU") +print(compiled_kernel.mlir) + +metrics = compiled_kernel.metrics +print(f"\n=== Compile metrics ===") +print(f" Trace : {metrics.trace_ms:.3f} ms") +print(f" Passes : {metrics.passes_ms:.3f} ms") +print(f" Schedule: {metrics.schedule_ms:.3f} ms") +print(f" Total : {metrics.total_ms:.3f} ms") diff --git a/pulse/examples/transmon/krinner_evolve_gpu.py b/pulse/examples/transmon/krinner_evolve_gpu.py new file mode 100644 index 00000000000..08ee4ca42ec --- /dev/null +++ b/pulse/examples/transmon/krinner_evolve_gpu.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""End-to-end GPU time evolution on the Krinner 17-qubit target. + +HARDWARE REQUIREMENT: This example requires an NVIDIA GPU with CUDA +runtime and cuDensityMat libraries installed. If unavailable, it will +raise an error with an actionable message -- no silent fallback. + +Pipeline: + @pulse.kernel -> pulse.evolve(target=...) -> native MLIR lowering + -> JIT compile -> cuDensityMat GPU execution +""" + +import math +import sys + +import numpy as np + +import cudaq_pulse as pulse +from cudaq_pulse.runtime.jit import _check_gpu_available +from cudaq_pulse.targets import transmon_krinner_17q + +# -- Pre-flight: verify GPU is available -- +if not _check_gpu_available(): + print("ERROR: No NVIDIA GPU detected.", file=sys.stderr) + print("This example requires:", file=sys.stderr) + print(" - NVIDIA GPU (compute capability >= 7.0)", file=sys.stderr) + print(" - CUDA runtime (set CUDA_HOME if needed)", file=sys.stderr) + print(" - cuDensityMat runtime (set CUDM_RUNTIME_LIB if needed)", + file=sys.stderr) + raise RuntimeError( + "GPU required for evolve(). Install CUDA toolkit and cuDensityMat, " + "then set CUDA_HOME and CUDM_RUNTIME_LIB environment variables.") + +target = transmon_krinner_17q() + +qubit_0_info = target.qubits[0] +qubit_1_info = target.qubits[1] +drive_params_0 = target.get_drive_params(0) +drive_params_1 = target.get_drive_params(1) + + +@pulse.kernel +def krinner_evolve(qubit_0, qubit_1): + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + + # Hadamard on Q0 + shift_phase(tone_0, math.pi / 2) + sx_pulse = drag(20, drive_params_0["x_amp"], drive_params_0["x_sigma"], + drive_params_0["x_beta"]) + drive(drive_line_0, sx_pulse, tone_0) + shift_phase(tone_0, math.pi / 2) + + # Sync + sync(drive_line_0, drive_line_1) + + # Echoed CR (CZ-like) + cr = gaussian(98, 0.32, 24.0) + drive(drive_line_0, cr, tone_1) + x_echo = drag(20, drive_params_1["x_amp"], drive_params_1["x_sigma"], + drive_params_1["x_beta"]) + drive(drive_line_1, x_echo, tone_1) + cr_neg = gaussian(98, -0.32, 24.0) + drive(drive_line_0, cr_neg, tone_1) + drive(drive_line_1, x_echo, tone_1) + + +ir = krinner_evolve(pulse.qudit_ref(), pulse.qudit_ref()) + +# This sequence is shorter than 128 ns. The remaining interval evolves under +# the target's always-on coupling and T1/T2 model. +result = pulse.evolve( + ir, + target=target, + t_start=0.0, + t_end=128.0, + num_steps=512, + integrator="rk4", +) + +state = result.final_state +print(f"=== Final state shape: {state.shape} ===") +if state.ndim == 2: + populations = np.real(np.diag(state)) + print(f"Density-matrix trace: {np.trace(state):.8f}") +else: + populations = np.abs(state)**2 + +for basis_index, probability in enumerate(populations): + if probability > 1.0e-6: + print(f" |{basis_index:02b}> : P = {probability:.6f}") diff --git a/pulse/examples/transmon/krinner_full_pipeline.py b/pulse/examples/transmon/krinner_full_pipeline.py new file mode 100644 index 00000000000..012b809aa55 --- /dev/null +++ b/pulse/examples/transmon/krinner_full_pipeline.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Target-aware lowering demo on the Krinner target. + + kernel -> verify -> schedule -> pulse_to_operator(target) -> Pulse MLIR + +Prints execution-ready Pulse MLIR with target and evolution attributes. The +native lowering stack consumes this IR as +Pulse -> QOp -> CuDensityMat -> LLVM. + +NOTE: This example uses the advanced internal API (``_to_program``) because +it demonstrates intermediate compiler representations. Application code that +wants to execute a kernel should use ``pulse.evolve`` instead. +""" + +import math + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program +from cudaq_pulse.passes.verify import verify +from cudaq_pulse.passes.scheduling import schedule_alap +from cudaq_pulse.passes.pulse_to_operator import run_pulse_to_operator +from cudaq_pulse.passes.to_pulse_mlir import program_to_pulse_mlir +from cudaq_pulse.targets import transmon_krinner_17q + +target = transmon_krinner_17q() + +qubit_0_info = target.qubits[0] +qubit_1_info = target.qubits[1] +drive_params_0 = target.get_drive_params(0) +drive_params_1 = target.get_drive_params(1) + + +@pulse.kernel +def krinner_bell(qubit_0, qubit_1): + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + + # Hadamard on Q0 + shift_phase(tone_0, math.pi / 2) + sx_pulse = drag(20, drive_params_0["x_amp"], drive_params_0["x_sigma"], + drive_params_0["x_beta"]) + drive(drive_line_0, sx_pulse, tone_0) + shift_phase(tone_0, math.pi / 2) + + # Sync + sync(drive_line_0, drive_line_1) + + # Echoed CR (CZ-like) + cr = gaussian(98, 0.32, 24.0) + drive(drive_line_0, cr, tone_1) + x_echo = drag(20, drive_params_1["x_amp"], drive_params_1["x_sigma"], + drive_params_1["x_beta"]) + drive(drive_line_1, x_echo, tone_1) + cr_neg = gaussian(98, -0.32, 24.0) + drive(drive_line_0, cr_neg, tone_1) + drive(drive_line_1, x_echo, tone_1) + + +ir = krinner_bell(pulse.qudit_ref(), pulse.qudit_ref()) +program = _to_program( + ir, + clock_ghz=2.0, + qubit_freq_hz={ + 0: qubit_0_info.frequency_hz, + 1: qubit_1_info.frequency_hz + }, +) + +# Stage 1: Verify +print("=== Stage 1: Verify ===") +issues = verify(program) +print(f" {len(issues)} issue(s)") + +# Stage 2: Schedule +print("\n=== Stage 2: Schedule (ALAP) ===") +events, metrics = schedule_alap(program) +print(f" {metrics.op_count} ops, {metrics.total_length_ns:.0f} ns total") + +# Stage 3: Pulse-to-operator +print("\n=== Stage 3: Pulse-to-Operator ===") +op_ir = run_pulse_to_operator(program, target=target) +print(f" {len(op_ir.hamiltonian_terms)} Hamiltonian terms") +print(f" {len(op_ir.dissipator_terms)} dissipator terms") +print(f" {op_ir.n_qubits} qubits, {op_ir.total_time_ns:.1f} ns total time") + +# Stage 4: target-aware Pulse MLIR emission. The target metadata is consumed +# by the native Pulse -> QOp lowering. +print("\n=== Stage 4: Execution-ready Pulse MLIR ===") +pulse_mlir = program_to_pulse_mlir( + program, + target=target, + t_start=0.0, + t_end=op_ir.total_time_ns, + num_steps=100, + integrator="rk4", +) +print(pulse_mlir) diff --git a/pulse/examples/transmon/krinner_rabi.py b/pulse/examples/transmon/krinner_rabi.py new file mode 100644 index 00000000000..b2b5dda1675 --- /dev/null +++ b/pulse/examples/transmon/krinner_rabi.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Rabi oscillation on qubit D5 using the Krinner 17-qubit target. + +Demonstrates: + - Loading the pre-defined transmon target + - Using per-qubit drive parameters from the target + - Running verify + schedule on a single-qubit pulse program + - Lowering to an operator program with the target Hamiltonian + +NOTE: This example uses the advanced internal API (``_to_program``) because +``run_pulse_to_operator`` operates on ``Program`` objects — a specialized +pass not available in the standard C++ compilation pipeline. +""" + +import math + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program +from cudaq_pulse.passes.verify import verify +from cudaq_pulse.passes.scheduling import schedule_alap +from cudaq_pulse.passes.pulse_to_operator import run_pulse_to_operator +from cudaq_pulse.targets import transmon_krinner_17q + +target = transmon_krinner_17q() +qubit_idx = 4 # D5 -- central data qubit + +qubit_d5 = target.qubits[qubit_idx] +drive_params = target.get_drive_params(qubit_idx) + +print(f"Target: {target.name} ({target.n_qubits} qubits)") +print(f"Qubit D5 (idx {qubit_idx}):") +print(f" frequency = {qubit_d5.frequency_hz / 1e9:.3f} GHz") +print(f" anharmonicity = {qubit_d5.anharmonicity_hz / 1e6:.1f} MHz") +print(f" T1 = {qubit_d5.t1_us:.1f} us, T2* = {qubit_d5.t2_star_us:.1f} us") +print(f" DRAG params: amp={drive_params['x_amp']:.3f}, " + f"sigma={drive_params['x_sigma']:.1f}, " + f"beta={drive_params['x_beta']:.2f}") + +amplitude = drive_params["x_amp"] +sigma = drive_params["x_sigma"] +beta = drive_params["x_beta"] +duration = drive_params["x_dur"] +n_rabi_points = 21 + + +@pulse.kernel +def rabi_d5(qubit): + drive_line, tone = get_drive_line(qubit) + for i in range(n_rabi_points): + scale = i / (n_rabi_points - 1) + rabi_pulse = drag(duration, amplitude * scale, sigma, beta) + drive(drive_line, rabi_pulse, tone) + wait(drive_line, 100) + + +ir = rabi_d5(pulse.qudit_ref(qubit_idx)) +program = _to_program(ir, + clock_ghz=2.0, + qubit_freq_hz={qubit_idx: qubit_d5.frequency_hz}) + +issues = verify(program) +print(f"\nVerification: {len(issues)} issue(s)") +for issue in issues: + print(f" {issue}") + +events, metrics = schedule_alap(program) +print( + f"\nScheduled {metrics.op_count} ops, total length = {metrics.total_length_ns:.1f} ns" +) + +op_ir = run_pulse_to_operator(program, target=target) +print( + f"\nOperator program: {len(op_ir.hamiltonian_terms)} Hamiltonian terms, " + f"{len(op_ir.dissipator_terms)} dissipator terms, {op_ir.n_qubits} qubits") +print(f"Total simulated time: {op_ir.total_time_ns:.1f} ns") diff --git a/pulse/examples/transmon/krinner_surface_code_cycle.py b/pulse/examples/transmon/krinner_surface_code_cycle.py new file mode 100644 index 00000000000..e33c2541c22 --- /dev/null +++ b/pulse/examples/transmon/krinner_surface_code_cycle.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""One QEC syndrome extraction cycle on the Krinner d=3 surface code. + +Demonstrates: + - Building a multi-qubit pulse program on the 17-qubit target + - Z-stabilizer and X-stabilizer measurement rounds + - Sync barriers between stabilizer groups + - Full pipeline: verify -> schedule -> pulse_to_operator + +The 17-qubit layout (Krinner et al.): + Data qubits: D1-D9 (indices 0-8) + Z-ancillas: Z1-Z4 (indices 9-12) + X-ancillas: X1-X4 (indices 13-16) + +NOTE: This example uses the advanced internal API (``_to_program``) because +``run_pulse_to_operator`` operates on ``Program`` objects — a specialized +pass not available in the standard C++ compilation pipeline. +""" + +import math + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program +from cudaq_pulse.passes.verify import verify +from cudaq_pulse.passes.scheduling import schedule_alap +from cudaq_pulse.passes.pulse_to_operator import run_pulse_to_operator +from cudaq_pulse.targets import transmon_krinner_17q + +target = transmon_krinner_17q() + +# Stabilizer definitions (ancilla -> data qubit pairs) +Z_STABILIZERS = { + 9: [0, 1], # Z1 (weight-2) + 10: [0, 1, 3, 4], # Z2 (weight-4) + 11: [4, 5, 7, 8], # Z3 (weight-4) + 12: [7, 8], # Z4 (weight-2) +} +X_STABILIZERS = { + 13: [0, 3], # X1 (weight-2) + 14: [1, 2, 4, 5], # X2 (weight-4) + 15: [3, 4, 6, 7], # X3 (weight-4) + 16: [5, 8], # X4 (weight-2) +} + +print(f"Target: {target.name}") +print( + f"Z-stabilizers: {len(Z_STABILIZERS)}, X-stabilizers: {len(X_STABILIZERS)}") + +# Collect drive params for all qubits (captured as closures) +all_qubits = sorted(target.qubits.keys()) +all_drive_params = {qi: target.get_drive_params(qi) for qi in all_qubits} + + +@pulse.kernel +def surface_code_cycle( + qubit_0, + qubit_1, + qubit_2, + qubit_3, + qubit_4, + qubit_5, + qubit_6, + qubit_7, + qubit_8, + qubit_9, + qubit_10, + qubit_11, + qubit_12, + qubit_13, + qubit_14, + qubit_15, + qubit_16, +): + drive_line_0, tone_0 = get_drive_line(qubit_0) + drive_line_1, tone_1 = get_drive_line(qubit_1) + drive_line_2, tone_2 = get_drive_line(qubit_2) + drive_line_3, tone_3 = get_drive_line(qubit_3) + drive_line_4, tone_4 = get_drive_line(qubit_4) + drive_line_5, tone_5 = get_drive_line(qubit_5) + drive_line_6, tone_6 = get_drive_line(qubit_6) + drive_line_7, tone_7 = get_drive_line(qubit_7) + drive_line_8, tone_8 = get_drive_line(qubit_8) + drive_line_9, tone_9 = get_drive_line(qubit_9) + drive_line_10, tone_10 = get_drive_line(qubit_10) + drive_line_11, tone_11 = get_drive_line(qubit_11) + drive_line_12, tone_12 = get_drive_line(qubit_12) + drive_line_13, tone_13 = get_drive_line(qubit_13) + drive_line_14, tone_14 = get_drive_line(qubit_14) + drive_line_15, tone_15 = get_drive_line(qubit_15) + drive_line_16, tone_16 = get_drive_line(qubit_16) + + # -- Z-stabilizer round -- + + # Z1 (ancilla 9): Hadamard, CZ with data [0, 1], Hadamard + shift_phase(tone_9, math.pi / 2) + sx_pulse = drag(20, all_drive_params[9].get("x_amp", 0.44), + all_drive_params[9].get("x_sigma", 5.0), + all_drive_params[9].get("x_beta", 0.7)) + drive(drive_line_9, sx_pulse, tone_9) + shift_phase(tone_9, math.pi / 2) + + sync(drive_line_9, drive_line_0) + cr = gaussian(98, 0.32, 24.0) + drive(drive_line_0, cr, tone_0) + x_echo = drag(20, all_drive_params[0].get("x_amp", 0.44), + all_drive_params[0].get("x_sigma", 5.0), + all_drive_params[0].get("x_beta", 0.7)) + drive(drive_line_9, x_echo, tone_9) + cr_neg = gaussian(98, -0.32, 24.0) + drive(drive_line_0, cr_neg, tone_0) + drive(drive_line_9, x_echo, tone_9) + + sync(drive_line_9, drive_line_1) + cr = gaussian(98, 0.32, 24.0) + drive(drive_line_1, cr, tone_1) + x_echo = drag(20, all_drive_params[1].get("x_amp", 0.44), + all_drive_params[1].get("x_sigma", 5.0), + all_drive_params[1].get("x_beta", 0.7)) + drive(drive_line_9, x_echo, tone_9) + cr_neg = gaussian(98, -0.32, 24.0) + drive(drive_line_1, cr_neg, tone_1) + drive(drive_line_9, x_echo, tone_9) + + shift_phase(tone_9, math.pi / 2) + sx_pulse = drag(20, all_drive_params[9].get("x_amp", 0.44), + all_drive_params[9].get("x_sigma", 5.0), + all_drive_params[9].get("x_beta", 0.7)) + drive(drive_line_9, sx_pulse, tone_9) + shift_phase(tone_9, math.pi / 2) + + # Z2 (ancilla 10): Hadamard, CZ with data [0, 1, 3, 4], Hadamard + shift_phase(tone_10, math.pi / 2) + sx_pulse = drag(20, all_drive_params[10].get("x_amp", 0.44), + all_drive_params[10].get("x_sigma", 5.0), + all_drive_params[10].get("x_beta", 0.7)) + drive(drive_line_10, sx_pulse, tone_10) + shift_phase(tone_10, math.pi / 2) + + sync(drive_line_10, drive_line_0) + cr = gaussian(98, 0.32, 24.0) + drive(drive_line_0, cr, tone_0) + x_echo = drag(20, all_drive_params[0].get("x_amp", 0.44), + all_drive_params[0].get("x_sigma", 5.0), + all_drive_params[0].get("x_beta", 0.7)) + drive(drive_line_10, x_echo, tone_10) + cr_neg = gaussian(98, -0.32, 24.0) + drive(drive_line_0, cr_neg, tone_0) + drive(drive_line_10, x_echo, tone_10) + + sync(drive_line_10, drive_line_1) + cr = gaussian(98, 0.32, 24.0) + drive(drive_line_1, cr, tone_1) + x_echo = drag(20, all_drive_params[1].get("x_amp", 0.44), + all_drive_params[1].get("x_sigma", 5.0), + all_drive_params[1].get("x_beta", 0.7)) + drive(drive_line_10, x_echo, tone_10) + cr_neg = gaussian(98, -0.32, 24.0) + drive(drive_line_1, cr_neg, tone_1) + drive(drive_line_10, x_echo, tone_10) + + sync(drive_line_10, drive_line_3) + cr = gaussian(98, 0.32, 24.0) + drive(drive_line_3, cr, tone_3) + x_echo = drag(20, all_drive_params[3].get("x_amp", 0.44), + all_drive_params[3].get("x_sigma", 5.0), + all_drive_params[3].get("x_beta", 0.7)) + drive(drive_line_10, x_echo, tone_10) + cr_neg = gaussian(98, -0.32, 24.0) + drive(drive_line_3, cr_neg, tone_3) + drive(drive_line_10, x_echo, tone_10) + + sync(drive_line_10, drive_line_4) + cr = gaussian(98, 0.32, 24.0) + drive(drive_line_4, cr, tone_4) + x_echo = drag(20, all_drive_params[4].get("x_amp", 0.44), + all_drive_params[4].get("x_sigma", 5.0), + all_drive_params[4].get("x_beta", 0.7)) + drive(drive_line_10, x_echo, tone_10) + cr_neg = gaussian(98, -0.32, 24.0) + drive(drive_line_4, cr_neg, tone_4) + drive(drive_line_10, x_echo, tone_10) + + shift_phase(tone_10, math.pi / 2) + sx_pulse = drag(20, all_drive_params[10].get("x_amp", 0.44), + all_drive_params[10].get("x_sigma", 5.0), + all_drive_params[10].get("x_beta", 0.7)) + drive(drive_line_10, sx_pulse, tone_10) + shift_phase(tone_10, math.pi / 2) + + # Global sync before X round + sync(drive_line_0, drive_line_1, drive_line_2, drive_line_3, drive_line_4, + drive_line_5, drive_line_6, drive_line_7, drive_line_8, drive_line_9, + drive_line_10, drive_line_11, drive_line_12, drive_line_13, + drive_line_14, drive_line_15, drive_line_16) + + # -- X-stabilizer round (first two for brevity) -- + + # X1 (ancilla 13): CZ with data [0, 3] + sync(drive_line_13, drive_line_0) + cr = gaussian(98, 0.32, 24.0) + drive(drive_line_13, cr, tone_13) + x_echo = drag(20, all_drive_params[0].get("x_amp", 0.44), + all_drive_params[0].get("x_sigma", 5.0), + all_drive_params[0].get("x_beta", 0.7)) + drive(drive_line_0, x_echo, tone_0) + cr_neg = gaussian(98, -0.32, 24.0) + drive(drive_line_13, cr_neg, tone_13) + drive(drive_line_0, x_echo, tone_0) + + sync(drive_line_13, drive_line_3) + cr = gaussian(98, 0.32, 24.0) + drive(drive_line_13, cr, tone_13) + x_echo = drag(20, all_drive_params[3].get("x_amp", 0.44), + all_drive_params[3].get("x_sigma", 5.0), + all_drive_params[3].get("x_beta", 0.7)) + drive(drive_line_3, x_echo, tone_3) + cr_neg = gaussian(98, -0.32, 24.0) + drive(drive_line_13, cr_neg, tone_13) + drive(drive_line_3, x_echo, tone_3) + + # X4 (ancilla 16): CZ with data [5, 8] + sync(drive_line_16, drive_line_5) + cr = gaussian(98, 0.32, 24.0) + drive(drive_line_16, cr, tone_16) + x_echo = drag(20, all_drive_params[5].get("x_amp", 0.44), + all_drive_params[5].get("x_sigma", 5.0), + all_drive_params[5].get("x_beta", 0.7)) + drive(drive_line_5, x_echo, tone_5) + cr_neg = gaussian(98, -0.32, 24.0) + drive(drive_line_16, cr_neg, tone_16) + drive(drive_line_5, x_echo, tone_5) + + sync(drive_line_16, drive_line_8) + cr = gaussian(98, 0.32, 24.0) + drive(drive_line_16, cr, tone_16) + x_echo = drag(20, all_drive_params[8].get("x_amp", 0.44), + all_drive_params[8].get("x_sigma", 5.0), + all_drive_params[8].get("x_beta", 0.7)) + drive(drive_line_8, x_echo, tone_8) + cr_neg = gaussian(98, -0.32, 24.0) + drive(drive_line_16, cr_neg, tone_16) + drive(drive_line_8, x_echo, tone_8) + + +ir = surface_code_cycle(*(pulse.qudit_ref() for _ in range(17))) +program = _to_program( + ir, + clock_ghz=2.0, + qubit_freq_hz={qi: target.qubits[qi].frequency_hz for qi in all_qubits}, +) + +issues = verify(program) +errors = [i for i in issues if i.severity == "error"] +print(f"\nVerification: {len(issues)} total issues, {len(errors)} errors") +for issue in issues[:5]: + print(f" {issue}") + +events, metrics = schedule_alap(program) +print( + f"\nScheduled: {metrics.op_count} ops, " + f"total = {metrics.total_length_ns:.0f} ns ({metrics.total_length_ns / 1000:.1f} us)" +) + +op_ir = run_pulse_to_operator(program, target=target) +print(f"\nOperator program: {len(op_ir.hamiltonian_terms)} Hamiltonian terms, " + f"{len(op_ir.dissipator_terms)} dissipator terms") diff --git a/pulse/pyproject.toml b/pulse/pyproject.toml new file mode 100644 index 00000000000..681726f0c2f --- /dev/null +++ b/pulse/pyproject.toml @@ -0,0 +1,76 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +# `pip install ./pulse` builds the standalone CMake project in this directory. +# It requires the CUDA-Q toolchain (`cudaq` + `cudaq-devel`) to be installed in +# the target environment already; those wheels are not build requirements +# because the build backend resolves them into an isolated environment where +# CMake would not find them. See pulse/README.md. +[build-system] +requires = [ + "scikit-build-core>=0.10", + "nanobind>=2.12", +] +build-backend = "scikit_build_core.build" + +[project] +name = "cudaq-pulse" +version = "0.1.0" +description = "Pulse-level MLIR dialect and programming model for quantum control" +authors = [{name = "NVIDIA Corporation"}] +license = {text = "Apache-2.0"} +requires-python = ">=3.10" +dependencies = [ + "numpy>=1.24", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Physics", +] + +[project.optional-dependencies] +viz = ["matplotlib>=3.7"] +dev = [ + "pytest>=7.0", + "pytest-cov", + "ruff", + "mypy", + "hypothesis", + "matplotlib>=3.7", +] + +[tool.scikit-build] +cmake.build-type = "Release" +install.components = ["CudaqPulse"] +wheel.packages = ["core/frontend/cudaq_pulse"] +wheel.install-dir = "cudaq_pulse" + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP"] + +[tool.mypy] +strict = true +python_version = "3.10" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = ["-ra", "--strict-markers"] +markers = [ + "gpu: requires GPU runtime", + "slow: long-running tests", +] diff --git a/pulse/pyrightconfig.json b/pulse/pyrightconfig.json new file mode 100644 index 00000000000..aeb5295c88a --- /dev/null +++ b/pulse/pyrightconfig.json @@ -0,0 +1,16 @@ +{ + "reportUndefinedVariable": "warning", + "pythonVersion": "3.10", + "pythonPlatform": "Linux", + "executionEnvironments": [ + { + "root": "core/frontend" + }, + { + "root": "examples" + }, + { + "root": "tests" + } + ] +} diff --git a/pulse/test/Conversion/cudm-to-llvm.mlir b/pulse/test/Conversion/cudm-to-llvm.mlir new file mode 100644 index 00000000000..10f2b26a42e --- /dev/null +++ b/pulse/test/Conversion/cudm-to-llvm.mlir @@ -0,0 +1,78 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt --cudm-to-llvm %s | FileCheck %s + +// CHECK-LABEL: func @main +func.func @main() { + // CHECK: llvm.call @cudm_init + %h = cudm.init_handle : !cudm.handle + + // CHECK: llvm.call @cudm_state_alloc + %s_in = cudm.create_state %h {purity = #cudm, data_type = #cudm, mode_extents = array} : (!cudm.handle) -> !cudm.state + %s_out = cudm.create_state %h {purity = #cudm, data_type = #cudm, mode_extents = array} : (!cudm.handle) -> !cudm.state + + // CHECK: llvm.call @cudm_workspace_create + %ws = cudm.create_workspace %h : (!cudm.handle) -> !cudm.workspace + + // CHECK: llvm.call @cudm_operator_create + %op = cudm.create_operator %h {mode_extents = array} : (!cudm.handle) -> !cudm.operator + + // CHECK: llvm.call @cudm_evolve + // CHECK: llvm.call @cudm_state_capture + %result = cudm.evolve %h, %op, %s_in, %s_out, %ws {integrator = #cudm, t_start = 0.0 : f64, t_end = 10.0 : f64, num_steps = 10 : i64} : !cudm.handle, !cudm.operator, !cudm.state, !cudm.state, !cudm.workspace -> !cudm.state + + // CHECK: llvm.call @cudm_operator_destroy + cudm.destroy_operator %op : !cudm.operator + // CHECK: llvm.call @cudm_workspace_destroy + cudm.destroy_workspace %ws : !cudm.workspace + // CHECK: llvm.call @cudm_state_destroy + cudm.destroy_state %s_out : !cudm.state + cudm.destroy_state %s_in : !cudm.state + // CHECK: llvm.call @cudm_destroy + cudm.destroy_handle %h : !cudm.handle + return +} + +// CHECK-LABEL: func @main_magnus +func.func @main_magnus() { + %h = cudm.init_handle : !cudm.handle + %s_in = cudm.create_state %h {purity = #cudm, data_type = #cudm, mode_extents = array} : (!cudm.handle) -> !cudm.state + %s_out = cudm.create_state %h {purity = #cudm, data_type = #cudm, mode_extents = array} : (!cudm.handle) -> !cudm.state + %ws = cudm.create_workspace %h : (!cudm.handle) -> !cudm.workspace + %op = cudm.create_operator %h {mode_extents = array} : (!cudm.handle) -> !cudm.operator + // The magnus integrator lowers to the dialect enum value 5. + // CHECK: llvm.mlir.constant(5 : i32) + // CHECK: llvm.call @cudm_evolve + %result = cudm.evolve %h, %op, %s_in, %s_out, %ws {integrator = #cudm, t_start = 0.0 : f64, t_end = 10.0 : f64, num_steps = 10 : i64} : !cudm.handle, !cudm.operator, !cudm.state, !cudm.state, !cudm.workspace -> !cudm.state + cudm.destroy_operator %op : !cudm.operator + cudm.destroy_workspace %ws : !cudm.workspace + cudm.destroy_state %s_out : !cudm.state + cudm.destroy_state %s_in : !cudm.state + cudm.destroy_handle %h : !cudm.handle + return +} + +// CHECK-LABEL: func @main_crank_nicolson +func.func @main_crank_nicolson() { + %h = cudm.init_handle : !cudm.handle + %s_in = cudm.create_state %h {purity = #cudm, data_type = #cudm, mode_extents = array} : (!cudm.handle) -> !cudm.state + %s_out = cudm.create_state %h {purity = #cudm, data_type = #cudm, mode_extents = array} : (!cudm.handle) -> !cudm.state + %ws = cudm.create_workspace %h : (!cudm.handle) -> !cudm.workspace + %op = cudm.create_operator %h {mode_extents = array} : (!cudm.handle) -> !cudm.operator + // The crank_nicolson integrator lowers to the dialect enum value 6. + // CHECK: llvm.mlir.constant(6 : i32) + // CHECK: llvm.call @cudm_evolve + %result = cudm.evolve %h, %op, %s_in, %s_out, %ws {integrator = #cudm, t_start = 0.0 : f64, t_end = 10.0 : f64, num_steps = 10 : i64} : !cudm.handle, !cudm.operator, !cudm.state, !cudm.state, !cudm.workspace -> !cudm.state + cudm.destroy_operator %op : !cudm.operator + cudm.destroy_workspace %ws : !cudm.workspace + cudm.destroy_state %s_out : !cudm.state + cudm.destroy_state %s_in : !cudm.state + cudm.destroy_handle %h : !cudm.handle + return +} diff --git a/pulse/test/Conversion/pulse-to-qop-dissipator.mlir b/pulse/test/Conversion/pulse-to-qop-dissipator.mlir new file mode 100644 index 00000000000..80146754e51 --- /dev/null +++ b/pulse/test/Conversion/pulse-to-qop-dissipator.mlir @@ -0,0 +1,37 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt --pulse-to-qop %s | FileCheck %s + +module @dissipator_test attributes { + pulse.t1_times = [50.0e3 : f64], + pulse.t2_times = [30.0e3 : f64] +} { + +// CHECK-LABEL: func @main +func.func @main() { + %q0 = pulse.qudit_alloc : !pulse.qref + %d0, %t0 = pulse.get_drive_line %q0 {qubit = 0 : i64, frequency_hz = 5.0e9 : f64} + : (!pulse.qref) -> (!pulse.drive_line, !pulse.tone) + %duration = arith.constant 40 : i64 + %amplitude = arith.constant 0.3 : f64 + %sigma = arith.constant 10.0 : f64 + %wf = pulse.gaussian %duration, %amplitude, %sigma + : i64, f64, f64 -> !pulse.waveform + %d1, %t1 = pulse.drive %d0, %wf, %t0 + {start_vtu = 0 : i64, duration_vtu = 40 : i64} + : !pulse.drive_line, !pulse.waveform, !pulse.tone + -> !pulse.drive_line, !pulse.tone + + // CHECK: qop.spin{{.*}}spin_lowering + // CHECK: qop.spin{{.*}}spin_z + // CHECK: qop.lindblad + return +} + +} diff --git a/pulse/test/Conversion/pulse-to-qop.mlir b/pulse/test/Conversion/pulse-to-qop.mlir new file mode 100644 index 00000000000..ccf960e087e --- /dev/null +++ b/pulse/test/Conversion/pulse-to-qop.mlir @@ -0,0 +1,43 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt --pulse-to-qop %s | FileCheck %s +// RUN: cudaq-pulse-opt --pulse-to-qop --qop-to-cudm --cudm-to-llvm \ +// RUN: --canonicalize --convert-arith-to-llvm --convert-func-to-llvm \ +// RUN: --reconcile-unrealized-casts %s | FileCheck %s --check-prefix=FULL + +// FULL-LABEL: llvm.func @main +// FULL: llvm.call @cudm_init +// FULL: llvm.call @cudm_evolve +// FULL: llvm.call @cudm_state_capture +// FULL-NOT: pulse.drive +// FULL-NOT: qop.lindblad +// FULL-NOT: cudm.evolve + +// CHECK-LABEL: func @main +func.func @main() { + %q0 = pulse.qudit_alloc : !pulse.qref + %d0, %t0 = pulse.get_drive_line %q0 {qubit = 0 : i64, frequency_hz = 5.0e9 : f64} + : (!pulse.qref) -> (!pulse.drive_line, !pulse.tone) + %duration = arith.constant 40 : i64 + %amplitude = arith.constant 0.3 : f64 + %sigma = arith.constant 10.0 : f64 + %wf = pulse.gaussian %duration, %amplitude, %sigma + : i64, f64, f64 -> !pulse.waveform + %d1, %t1 = pulse.drive %d0, %wf, %t0 + {start_vtu = 0 : i64, duration_vtu = 40 : i64} + : !pulse.drive_line, !pulse.waveform, !pulse.tone + -> !pulse.drive_line, !pulse.tone + + // CHECK: qop.spin + // CHECK: qop.make_product + // CHECK: qop.callback_scalar + // CHECK: qop.make_sum + // CHECK: qop.lindblad + return +} diff --git a/pulse/test/Conversion/qop-to-cudm.mlir b/pulse/test/Conversion/qop-to-cudm.mlir new file mode 100644 index 00000000000..a6df03c6f07 --- /dev/null +++ b/pulse/test/Conversion/qop-to-cudm.mlir @@ -0,0 +1,57 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt --qop-to-cudm %s | FileCheck %s + +module @qop_to_cudm_test attributes {qop.n_qubits = 1 : i64, qop.t_start = 0.0 : f64, qop.t_end = 100.0 : f64, qop.num_steps = 100 : i64} { + +// CHECK-LABEL: func @main +func.func @main() { + %c0 = arith.constant 0 : i64 + + // Static Z term + %sz = qop.spin(%c0) {kind = #qop} : !qop.handler + %coeff_z = qop.const_scalar {real = 15.707963 : f64, imag = 0.0 : f64} : !qop.scalar + %term_z = qop.make_product(%coeff_z, %sz) : !qop.product + + // Drive X term + %sx = qop.spin(%c0) {kind = #qop} : !qop.handler + %cb_x = qop.callback_scalar @drive_envelope_0_x : !qop.scalar + %term_x = qop.make_product(%cb_x, %sx) : !qop.product + + // Non-Hermitian amplitude-damping collapse operator. + %lowering = qop.spin(%c0) {kind = #qop} : !qop.handler + %collapse_coeff = qop.const_scalar {real = 0.1 : f64, imag = 0.0 : f64} : !qop.scalar + %collapse_product = qop.make_product(%collapse_coeff, %lowering) : !qop.product + %collapse = qop.make_sum(%collapse_product) : !qop.op + + %H = qop.make_sum(%term_z, %term_x) : !qop.op + %L = qop.lindblad(%H, %collapse) : !qop.super_op + + // CHECK: cudm.init_handle + // CHECK: cudm.create_state {{.*}}purity = #cudm + // CHECK: cudm.create_workspace + // CHECK: cudm.create_operator + // CHECK: %[[Z:[0-9]+]] = cudm.create_elementary_op + // CHECK: %[[X:[0-9]+]] = cudm.create_elementary_op + // CHECK: %[[LOWERING:[0-9]+]] = cudm.create_elementary_op + // CHECK: %[[RAISING:[0-9]+]] = cudm.create_elementary_op + // CHECK: cudm.create_op_term + // CHECK: cudm.append_elementary_product + // CHECK: cudm.append_elementary_product {{.*}} %[[LOWERING]], %[[RAISING]] {{.*}}duality = array + // CHECK: cudm.append_elementary_product {{.*}} %[[LOWERING]], %[[RAISING]] {{.*}}duality = array + // CHECK: cudm.operator_append_term + // CHECK: cudm.evolve + // CHECK: cudm.destroy_operator + // CHECK: cudm.destroy_workspace + // CHECK: cudm.destroy_state + // CHECK: cudm.destroy_handle + return +} + +} diff --git a/pulse/test/Dialect/CuDensityMat/roundtrip.mlir b/pulse/test/Dialect/CuDensityMat/roundtrip.mlir new file mode 100644 index 00000000000..14d1ffe8823 --- /dev/null +++ b/pulse/test/Dialect/CuDensityMat/roundtrip.mlir @@ -0,0 +1,29 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt %s | cudaq-pulse-opt | FileCheck %s + +// CHECK-LABEL: func @test_cudm_basic +func.func @test_cudm_basic() { + // CHECK: cudm.init_handle + %h = cudm.init_handle : !cudm.handle + + // CHECK: cudm.create_state + %s = cudm.create_state %h {purity = #cudm, data_type = #cudm, mode_extents = array} : (!cudm.handle) -> !cudm.state + + // CHECK: cudm.create_workspace + %ws = cudm.create_workspace %h : (!cudm.handle) -> !cudm.workspace + + // CHECK: cudm.destroy_state + cudm.destroy_state %s : !cudm.state + // CHECK: cudm.destroy_workspace + cudm.destroy_workspace %ws : !cudm.workspace + // CHECK: cudm.destroy_handle + cudm.destroy_handle %h : !cudm.handle + return +} diff --git a/pulse/test/Dialect/Pulse/algebra.mlir b/pulse/test/Dialect/Pulse/algebra.mlir new file mode 100644 index 00000000000..8f10295462c --- /dev/null +++ b/pulse/test/Dialect/Pulse/algebra.mlir @@ -0,0 +1,35 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt %s | cudaq-pulse-opt | FileCheck %s + +// CHECK-LABEL: func @test_waveform_algebra +func.func @test_waveform_algebra() { + %duration = arith.constant 40 : i64 + %amp_a = arith.constant 0.3 : f64 + %amp_b = arith.constant 0.1 : f64 + %zero = arith.constant 0.0 : f64 + %a = pulse.square %duration, %amp_a, %zero + : i64, f64, f64 -> !pulse.waveform + %b = pulse.square %duration, %amp_b, %zero + : i64, f64, f64 -> !pulse.waveform + + // CHECK: pulse.add + %sum = pulse.add %a, %b : !pulse.waveform + // CHECK: pulse.sub + %diff = pulse.sub %a, %b : !pulse.waveform + // CHECK: pulse.mul + %prod = pulse.mul %a, %b : !pulse.waveform + + %c = arith.constant 2.0 : f64 + // CHECK: pulse.scale + %scaled = pulse.scale %a, %c : !pulse.waveform, f64 -> !pulse.waveform + // CHECK: pulse.neg + %negated = pulse.neg %a : !pulse.waveform + return +} diff --git a/pulse/test/Dialect/Pulse/fusion.mlir b/pulse/test/Dialect/Pulse/fusion.mlir new file mode 100644 index 00000000000..8f78885852f --- /dev/null +++ b/pulse/test/Dialect/Pulse/fusion.mlir @@ -0,0 +1,29 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt --pulse-fusion %s | FileCheck %s + +// CHECK-LABEL: func.func @fuse_adjacent_squares +func.func @fuse_adjacent_squares() { + %q = pulse.qudit_alloc : !pulse.qref + %d, %t = pulse.get_drive_line %q : (!pulse.qref) -> (!pulse.drive_line, !pulse.tone) + // Two adjacent square pulses with same amplitude should fuse + %duration = arith.constant 50 : i64 + %amplitude = arith.constant 2.000000e-01 : f64 + %zero = arith.constant 0.000000e+00 : f64 + %wf1 = pulse.square %duration, %amplitude, %zero + : i64, f64, f64 -> !pulse.waveform + %d2, %t2 = pulse.drive %d, %wf1, %t : !pulse.drive_line, !pulse.waveform, !pulse.tone -> !pulse.drive_line, !pulse.tone + %wf2 = pulse.square %duration, %amplitude, %zero + : i64, f64, f64 -> !pulse.waveform + %d3, %t3 = pulse.drive %d2, %wf2, %t2 : !pulse.drive_line, !pulse.waveform, !pulse.tone -> !pulse.drive_line, !pulse.tone + // CHECK: arith.constant 100 + // CHECK: pulse.square + // CHECK: fused + return +} diff --git a/pulse/test/Dialect/Pulse/roundtrip.mlir b/pulse/test/Dialect/Pulse/roundtrip.mlir new file mode 100644 index 00000000000..163073941a2 --- /dev/null +++ b/pulse/test/Dialect/Pulse/roundtrip.mlir @@ -0,0 +1,37 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt %s | cudaq-pulse-opt | FileCheck %s + +// CHECK-LABEL: func @test_qudit_and_drive +func.func @test_qudit_and_drive() { + %q0 = pulse.qudit_alloc : !pulse.qref + %d0, %t0 = pulse.get_drive_line %q0 : (!pulse.qref) -> (!pulse.drive_line, !pulse.tone) + %duration = arith.constant 40 : i64 + %amplitude = arith.constant 0.3 : f64 + %sigma = arith.constant 10.0 : f64 + %wf = pulse.gaussian %duration, %amplitude, %sigma + : i64, f64, f64 -> !pulse.waveform + // CHECK: pulse.drive + %d1, %t1 = pulse.drive %d0, %wf, %t0 : !pulse.drive_line, !pulse.waveform, !pulse.tone -> !pulse.drive_line, !pulse.tone + return +} + +// CHECK-LABEL: func @test_readout +func.func @test_readout() { + %q0 = pulse.qudit_alloc : !pulse.qref + %r0, %rt0 = pulse.get_readout_line %q0 : (!pulse.qref) -> (!pulse.readout_line, !pulse.tone) + %duration = arith.constant 1000 : i64 + %amplitude = arith.constant 0.05 : f64 + %zero = arith.constant 0.0 : f64 + %wf = pulse.square %duration, %amplitude, %zero + : i64, f64, f64 -> !pulse.waveform + // CHECK: pulse.readout + %r1, %rt1, %m = pulse.readout %r0, %wf, %rt0, "iq" : !pulse.readout_line, !pulse.waveform, !pulse.tone -> !pulse.readout_line, !pulse.tone, !pulse.measurement + return +} diff --git a/pulse/test/Dialect/Pulse/schedule_alap.mlir b/pulse/test/Dialect/Pulse/schedule_alap.mlir new file mode 100644 index 00000000000..e25556e8634 --- /dev/null +++ b/pulse/test/Dialect/Pulse/schedule_alap.mlir @@ -0,0 +1,25 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt --pulse-schedule-alap %s | FileCheck %s + +// CHECK-LABEL: func.func @simple_schedule +func.func @simple_schedule() { + %q = pulse.qudit_alloc : !pulse.qref + %d, %t = pulse.get_drive_line %q : (!pulse.qref) -> (!pulse.drive_line, !pulse.tone) + %duration = arith.constant 40 : i64 + %amplitude = arith.constant 3.000000e-01 : f64 + %sigma = arith.constant 1.000000e+01 : f64 + %wf = pulse.gaussian %duration, %amplitude, %sigma + : i64, f64, f64 -> !pulse.waveform + // CHECK: = pulse.drive + // CHECK-SAME: duration_vtu = 40 + // CHECK-SAME: start_vtu = 0 + %d2, %t2 = pulse.drive %d, %wf, %t : !pulse.drive_line, !pulse.waveform, !pulse.tone -> !pulse.drive_line, !pulse.tone + return +} diff --git a/pulse/test/Dialect/Pulse/timing.mlir b/pulse/test/Dialect/Pulse/timing.mlir new file mode 100644 index 00000000000..8e6da3d7666 --- /dev/null +++ b/pulse/test/Dialect/Pulse/timing.mlir @@ -0,0 +1,26 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt %s | cudaq-pulse-opt | FileCheck %s + +// CHECK-LABEL: func @test_timing +func.func @test_timing() { + %q0 = pulse.qudit_alloc : !pulse.qref + %q1 = pulse.qudit_alloc : !pulse.qref + %d0, %t0 = pulse.get_drive_line %q0 : (!pulse.qref) -> (!pulse.drive_line, !pulse.tone) + %d1, %t1 = pulse.get_drive_line %q1 : (!pulse.qref) -> (!pulse.drive_line, !pulse.tone) + + %c20 = arith.constant 20 : i64 + %dur = pulse.duration_from_int %c20 : (i64) -> !pulse.duration + // CHECK: pulse.wait + %d0a = pulse.wait %d0, %dur : (!pulse.drive_line, !pulse.duration) -> !pulse.drive_line + + // CHECK: pulse.sync + %d0b, %d1a = pulse.sync %d0a, %d1 : !pulse.drive_line, !pulse.drive_line -> !pulse.drive_line, !pulse.drive_line + return +} diff --git a/pulse/test/Dialect/Pulse/verify.mlir b/pulse/test/Dialect/Pulse/verify.mlir new file mode 100644 index 00000000000..68e35b0d355 --- /dev/null +++ b/pulse/test/Dialect/Pulse/verify.mlir @@ -0,0 +1,46 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: not cudaq-pulse-opt --pulse-verify %s 2>&1 | FileCheck %s + +func.func @invalid_linearity_and_timing() { + %q = pulse.qudit_alloc : !pulse.qref + %d0, %t0 = pulse.get_drive_line %q + {qubit = 0 : i64, frequency_hz = 5.0e9 : f64} + : (!pulse.qref) -> (!pulse.drive_line, !pulse.tone) + %duration = arith.constant 40 : i64 + %real = arith.constant 0.3 : f64 + %imag = arith.constant 0.0 : f64 + %wf = pulse.square %duration, %real, %imag + : i64, f64, f64 -> !pulse.waveform + %d1, %t1 = pulse.drive %d0, %wf, %t0 + {start_vtu = 0 : i64, duration_vtu = 40 : i64} + : !pulse.drive_line, !pulse.waveform, !pulse.tone + -> !pulse.drive_line, !pulse.tone + %d2, %t2 = pulse.drive %d0, %wf, %t0 + {start_vtu = 20 : i64, duration_vtu = 40 : i64} + : !pulse.drive_line, !pulse.waveform, !pulse.tone + -> !pulse.drive_line, !pulse.tone + + %q_wait = pulse.qudit_alloc : !pulse.qref + %wait_line, %wait_tone = pulse.get_drive_line %q_wait + : (!pulse.qref) -> (!pulse.drive_line, !pulse.tone) + %wait_cycles = arith.constant 40 : i64 + %wait_duration = pulse.duration_from_int %wait_cycles + : (i64) -> !pulse.duration + %wait_line_1 = pulse.wait %wait_line, %wait_duration + {start_vtu = 0 : i64, duration_vtu = 40 : i64} + : (!pulse.drive_line, !pulse.duration) -> !pulse.drive_line + %wait_line_2 = pulse.wait %wait_line_1, %wait_duration + {start_vtu = 20 : i64, duration_vtu = 40 : i64} + : (!pulse.drive_line, !pulse.duration) -> !pulse.drive_line + return +} + +// CHECK: error: linear pulse value has 2 uses; expected at most one +// CHECK-COUNT-2: error: operation overlaps or precedes its physical-line predecessor diff --git a/pulse/test/Dialect/Pulse/virtual_z.mlir b/pulse/test/Dialect/Pulse/virtual_z.mlir new file mode 100644 index 00000000000..679fd708aaa --- /dev/null +++ b/pulse/test/Dialect/Pulse/virtual_z.mlir @@ -0,0 +1,28 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt --pulse-virtual-z %s | FileCheck %s + +// CHECK-LABEL: func.func @fold_shift_into_drive +func.func @fold_shift_into_drive() { + %q = pulse.qudit_alloc : !pulse.qref + %d, %t = pulse.get_drive_line %q : (!pulse.qref) -> (!pulse.drive_line, !pulse.tone) + %ph = arith.constant 0.785398163397448 : f64 + // The shift_phase should be folded into the drive as a persistent frame attr + %t2 = pulse.shift_phase %t, %ph : !pulse.tone, f64 -> !pulse.tone + %duration = arith.constant 40 : i64 + %amplitude = arith.constant 3.000000e-01 : f64 + %sigma = arith.constant 1.000000e+01 : f64 + %wf = pulse.gaussian %duration, %amplitude, %sigma + : i64, f64, f64 -> !pulse.waveform + // CHECK: = pulse.drive + // CHECK-SAME: frame_phase_offset + %d2, %t3 = pulse.drive %d, %wf, %t2 : !pulse.drive_line, !pulse.waveform, !pulse.tone -> !pulse.drive_line, !pulse.tone + // CHECK-NOT: pulse.shift_phase + return +} diff --git a/pulse/test/Dialect/Pulse/waveforms.mlir b/pulse/test/Dialect/Pulse/waveforms.mlir new file mode 100644 index 00000000000..19fc9ef5312 --- /dev/null +++ b/pulse/test/Dialect/Pulse/waveforms.mlir @@ -0,0 +1,42 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt %s | cudaq-pulse-opt | FileCheck %s + +// CHECK-LABEL: func @test_waveforms +func.func @test_waveforms() { + %c0 = arith.constant 0.0 : f64 + %c025 = arith.constant 0.25 : f64 + %c03 = arith.constant 0.3 : f64 + %c04 = arith.constant 0.4 : f64 + %c05 = arith.constant 0.5 : f64 + %c5 = arith.constant 5.0 : f64 + %c8 = arith.constant 8.0 : f64 + %c10 = arith.constant 10.0 : f64 + %c20 = arith.constant 20 : i64 + %c40 = arith.constant 40 : i64 + %c80 = arith.constant 80 : i64 + %c100 = arith.constant 100 : i64 + %c200 = arith.constant 200 : i64 + // CHECK: pulse.square + %sq = pulse.square %c100, %c05, %c0 : i64, f64, f64 -> !pulse.waveform + // CHECK: pulse.gaussian + %g = pulse.gaussian %c40, %c03, %c10 : i64, f64, f64 -> !pulse.waveform + // CHECK: pulse.gaussian_square + %gs = pulse.gaussian_square %c200, %c04, %c8, %c20 + : i64, f64, f64, i64 -> !pulse.waveform + // CHECK: pulse.drag + %dr = pulse.drag %c40, %c025, %c10, %c05 + : i64, f64, f64, f64 -> !pulse.waveform + // CHECK: pulse.cosine + %cos = pulse.cosine %c100, %c03 : i64, f64 -> !pulse.waveform + // CHECK: pulse.tanh_ramp + %ramp = pulse.tanh_ramp %c80, %c05, %c5 + : i64, f64, f64 -> !pulse.waveform + return +} diff --git a/pulse/test/Dialect/QOp/roundtrip.mlir b/pulse/test/Dialect/QOp/roundtrip.mlir new file mode 100644 index 00000000000..5715c3fed61 --- /dev/null +++ b/pulse/test/Dialect/QOp/roundtrip.mlir @@ -0,0 +1,32 @@ +// ============================================================================ // +// Copyright (c) 2026 NVIDIA Corporation & Affiliates. // +// All rights reserved. // +// // +// This source code and the accompanying materials are made available under // +// the terms of the Apache License 2.0 which accompanies this distribution. // +// ============================================================================ // + +// RUN: cudaq-pulse-opt %s | cudaq-pulse-opt | FileCheck %s + +// CHECK-LABEL: func @test_qop_basic +func.func @test_qop_basic() { + %c0 = arith.constant 0 : i64 + %c1 = arith.constant 1 : i64 + + // CHECK: qop.spin + %sx = qop.spin(%c0) {kind = #qop} : !qop.handler + %sz = qop.spin(%c1) {kind = #qop} : !qop.handler + + // CHECK: qop.const_scalar + %coeff = qop.const_scalar {real = 0.3 : f64, imag = 0.0 : f64} : !qop.scalar + + // CHECK: qop.make_product + %term = qop.make_product(%coeff, %sx) : !qop.product + + // CHECK: qop.make_sum + %H = qop.make_sum(%term) : !qop.op + + // CHECK: qop.dagger + %Hd = qop.dagger %H : !qop.op + return +} diff --git a/pulse/test/lit.cfg.py b/pulse/test/lit.cfg.py new file mode 100644 index 00000000000..450b23f93b8 --- /dev/null +++ b/pulse/test/lit.cfg.py @@ -0,0 +1,21 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import os +import lit.formats + +config.name = "cudaq-pulse" +config.test_format = lit.formats.ShTest(True) +config.suffixes = [".mlir"] +config.test_source_root = os.path.dirname(__file__) + +tools_dir = getattr(config, "cudaq_pulse_tools_dir", "") +llvm_tools = getattr(config, "llvm_tools_dir", "") +config.environment["PATH"] = os.pathsep.join( + filter(None, [tools_dir, llvm_tools, + os.environ.get("PATH", "")])) diff --git a/pulse/test/lit.site.cfg.py.in b/pulse/test/lit.site.cfg.py.in new file mode 100644 index 00000000000..9776d172159 --- /dev/null +++ b/pulse/test/lit.site.cfg.py.in @@ -0,0 +1,20 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +@LIT_SITE_CFG_IN_HEADER@ + +config.cudaq_pulse_tools_dir = "@CUDAQ_PULSE_TOOLS_DIR@" +config.cudaq_pulse_src_root = "@CUDAQ_PULSE_SOURCE_DIR@" +config.llvm_tools_dir = "@LLVM_TOOLS_BINARY_DIR@" + +import lit.llvm +lit.llvm.initialize(lit_config, config) + +import os +config.test_exec_root = os.path.dirname(__file__) +lit_config.load_config(config, os.path.join(config.cudaq_pulse_src_root, "test", "lit.cfg.py")) diff --git a/pulse/tests/__init__.py b/pulse/tests/__init__.py new file mode 100644 index 00000000000..c6c6f4d157c --- /dev/null +++ b/pulse/tests/__init__.py @@ -0,0 +1,7 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # diff --git a/pulse/tests/conftest.py b/pulse/tests/conftest.py new file mode 100644 index 00000000000..a7a6d8899cd --- /dev/null +++ b/pulse/tests/conftest.py @@ -0,0 +1,61 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program + + +@pulse.kernel +def _simple_kernel(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + +@pulse.kernel +def _two_qubit_kernel(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + sync(d0, d1) + drive(d1, wf, t1) + + +@pulse.kernel +def _echo_kernel(q0): + d0, t0 = get_drive_line(q0) + for i in range(5): + wf_pos = gaussian(40, 0.3, 10.0) + drive(d0, wf_pos, t0) + wait(d0, 20) + wf_neg = gaussian(40, -0.3, 10.0) + drive(d0, wf_neg, t0) + + +@pytest.fixture +def simple_program(): + """A minimal single-qubit drive program for testing.""" + ir = _simple_kernel(pulse.qudit_ref()) + return _to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + + +@pytest.fixture +def two_qubit_program(): + """A two-qubit program with sync for testing.""" + ir = _two_qubit_kernel(pulse.qudit_ref(), pulse.qudit_ref()) + return _to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9, 1: 5.1e9}) + + +@pytest.fixture +def echo_program(): + """The canonical echo program from the paper.""" + ir = _echo_kernel(pulse.qudit_ref()) + return _to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) diff --git a/pulse/tests/kernel/__init__.py b/pulse/tests/kernel/__init__.py new file mode 100644 index 00000000000..c6c6f4d157c --- /dev/null +++ b/pulse/tests/kernel/__init__.py @@ -0,0 +1,7 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # diff --git a/pulse/tests/kernel/test_bytecode_bridge.py b/pulse/tests/kernel/test_bytecode_bridge.py new file mode 100644 index 00000000000..27c4d05565f --- /dev/null +++ b/pulse/tests/kernel/test_bytecode_bridge.py @@ -0,0 +1,555 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Comprehensive tests for the bytecode kernel capture backend. + +Covers all pulse operations, control flow, linear-type rebinding, +constant folding, qudit allocation patterns, and error cases. +""" + +import math +import pytest + +from cudaq_pulse.kernel.bytecode_bridge import compile_kernel_bytecode +from cudaq_pulse.kernel.ir_builder import CompilationError +from cudaq_pulse.kernel.decorator import kernel, qudit_ref, qvec_ref +from cudaq_pulse.ops import ( + get_drive_line, + get_readout_line, + gaussian, + square, + drag, + cosine, + tanh_ramp, + gaussian_square, + custom, + drive, + readout, + wait, + sync, + shift_phase, + set_phase, + shift_frequency, + set_frequency, +) + + +def _helper_waveform(): + return gaussian(32, 0.2, 8.0) + + +def _helper_drive(line, tone, waveform): + drive(line, waveform, tone) + + +# ── Basic compilation ──────────────────────────────────────────────── + + +class TestBasicCompilation: + + def test_empty_kernel(self): + + def empty(q0): + pass + + ir = compile_kernel_bytecode(empty)(qudit_ref()) + assert ir.name == "empty" + assert len(ir.ops) == 1 # just pulse.qudit_arg + + def test_kernel_name_preserved(self): + + def my_special_name(q0): + pass + + ir = compile_kernel_bytecode(my_special_name)(qudit_ref()) + assert ir.name == "my_special_name" + + def test_drive_kernel(self): + + def drive_test(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(drive_test)(qudit_ref()) + kinds = [op.kind for op in ir.ops] + assert kinds == [ + "pulse.qudit_arg", + "pulse.get_drive_line", + "pulse.gaussian", + "pulse.drive", + ] + + def test_multiple_args(self): + + def two_qubit(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + + ir = compile_kernel_bytecode(two_qubit)(qudit_ref(), qudit_ref()) + arg_ops = [op for op in ir.ops if op.kind == "pulse.qudit_arg"] + assert len(arg_ops) == 2 + + def test_pulse_helpers_are_inlined(self): + + def helper_kernel(q0): + d0, t0 = get_drive_line(q0) + wf = _helper_waveform() + _helper_drive(d0, t0, wf) + + ir = compile_kernel_bytecode(helper_kernel)(qudit_ref()) + kinds = [op.kind for op in ir.ops] + assert kinds == [ + "pulse.qudit_arg", + "pulse.get_drive_line", + "pulse.gaussian", + "pulse.drive", + ] + + def test_wrong_arg_count_raises(self): + + def one_arg(q0): + pass + + emitter = compile_kernel_bytecode(one_arg) + with pytest.raises(CompilationError, match="expected 1 args, got 0"): + emitter() + + def test_kwargs_rejected(self): + + def one_arg(q0): + pass + + emitter = compile_kernel_bytecode(one_arg) + with pytest.raises(CompilationError, match="keyword arguments"): + emitter(q0=qudit_ref()) + + +# ── Waveform creation ──────────────────────────────────────────────── + + +class TestWaveforms: + + def test_gaussian(self): + + def wf_test(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(wf_test)(qudit_ref()) + wf_op = next(op for op in ir.ops if op.kind == "pulse.gaussian") + assert wf_op.attrs == {"duration": 40, "amplitude": 0.3, "sigma": 10.0} + + def test_square(self): + + def wf_test(q0): + d0, t0 = get_drive_line(q0) + wf = square(20, 0.5) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(wf_test)(qudit_ref()) + wf_op = next(op for op in ir.ops if op.kind == "pulse.square") + assert wf_op.attrs == {"duration": 20, "amplitude": 0.5} + + def test_drag(self): + + def wf_test(q0): + d0, t0 = get_drive_line(q0) + wf = drag(40, 0.435, 5.0, 0.75) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(wf_test)(qudit_ref()) + wf_op = next(op for op in ir.ops if op.kind == "pulse.drag") + assert wf_op.attrs == { + "duration": 40, + "amplitude": 0.435, + "sigma": 5.0, + "beta": 0.75, + } + + def test_cosine(self): + + def wf_test(q0): + d0, t0 = get_drive_line(q0) + wf = cosine(100, 0.5) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(wf_test)(qudit_ref()) + assert any(op.kind == "pulse.cosine" for op in ir.ops) + + def test_gaussian_square(self): + + def wf_test(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian_square(200, 0.3, 10.0, 150.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(wf_test)(qudit_ref()) + wf_op = next(op for op in ir.ops if op.kind == "pulse.gaussian_square") + assert wf_op.attrs["width"] == 150.0 + + def test_tanh_ramp(self): + + def wf_test(q0): + d0, t0 = get_drive_line(q0) + wf = tanh_ramp(50, 0.4, 5.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(wf_test)(qudit_ref()) + assert any(op.kind == "pulse.tanh_ramp" for op in ir.ops) + + +# ── Readout and measurement ────────────────────────────────────────── + + +class TestReadout: + + def test_readout_produces_measurement(self): + + def ro_test(q0): + r0, t0 = get_readout_line(q0) + wf = square(600, 0.1) + readout(r0, wf, t0) + + ir = compile_kernel_bytecode(ro_test)(qudit_ref()) + ro_ops = [op for op in ir.ops if op.kind == "pulse.readout"] + assert len(ro_ops) == 1 + assert len(ro_ops[0].results) == 3 + assert ro_ops[0].results[2].vtype == "measurement" + + +# ── Tone manipulation ops ──────────────────────────────────────────── + + +class TestToneOps: + + def test_shift_phase(self): + + def phase_test(q0): + d0, t0 = get_drive_line(q0) + t0 = shift_phase(t0, 1.5707) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(phase_test)(qudit_ref()) + sp = next(op for op in ir.ops if op.kind == "pulse.shift_phase") + assert abs(sp.attrs["phase_rad"] - 1.5707) < 1e-10 + + def test_set_phase(self): + + def phase_test(q0): + d0, t0 = get_drive_line(q0) + t0 = set_phase(t0, 0.0) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(phase_test)(qudit_ref()) + assert any(op.kind == "pulse.set_phase" for op in ir.ops) + + def test_shift_frequency(self): + + def freq_test(q0): + d0, t0 = get_drive_line(q0) + t0 = shift_frequency(t0, 1e6) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(freq_test)(qudit_ref()) + sf = next(op for op in ir.ops if op.kind == "pulse.shift_frequency") + assert sf.attrs["freq_hz"] == 1e6 + + def test_set_frequency(self): + + def freq_test(q0): + d0, t0 = get_drive_line(q0) + t0 = set_frequency(t0, 5.1e9) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(freq_test)(qudit_ref()) + assert any(op.kind == "pulse.set_frequency" for op in ir.ops) + + +# ── Wait and sync ──────────────────────────────────────────────────── + + +class TestWaitSync: + + def test_wait(self): + + def wait_test(q0): + d0, t0 = get_drive_line(q0) + wait(d0, 100) + + ir = compile_kernel_bytecode(wait_test)(qudit_ref()) + w = next(op for op in ir.ops if op.kind == "pulse.wait") + assert w.attrs["duration"] == 100 + + def test_sync_variadic(self): + + def sync_test(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + + ir = compile_kernel_bytecode(sync_test)(qudit_ref(), qudit_ref()) + s = next(op for op in ir.ops if op.kind == "pulse.sync") + assert len(s.operands) == 2 + assert all(o.vtype == "drive_line" for o in s.operands) + + +# ── Linear-type rebinding ──────────────────────────────────────────── + + +class TestLinearRebinding: + + def test_drive_rebinds_line_and_tone(self): + + def rebind_test(q0): + d0, t0 = get_drive_line(q0) + wf1 = gaussian(40, 0.3, 10.0) + wf2 = gaussian(40, 0.5, 10.0) + drive(d0, wf1, t0) + drive(d0, wf2, t0) + + ir = compile_kernel_bytecode(rebind_test)(qudit_ref()) + drives = [op for op in ir.ops if op.kind == "pulse.drive"] + assert len(drives) == 2 + assert drives[0].operands[0] is not drives[1].operands[0] + assert drives[0].operands[2] is not drives[1].operands[2] + + def test_wait_rebinds_line(self): + + def rebind_test(q0): + d0, t0 = get_drive_line(q0) + wait(d0, 50) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(rebind_test)(qudit_ref()) + w = next(op for op in ir.ops if op.kind == "pulse.wait") + d = next(op for op in ir.ops if op.kind == "pulse.drive") + assert w.operands[0] is not d.operands[0] + + def test_shift_phase_rebinds_tone(self): + + def phase_test(q0): + d0, t0 = get_drive_line(q0) + t0 = shift_phase(t0, 0.5) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(phase_test)(qudit_ref()) + sp = next(op for op in ir.ops if op.kind == "pulse.shift_phase") + d = next(op for op in ir.ops if op.kind == "pulse.drive") + assert sp.results[0] is d.operands[2] + + +# ── For loops ──────────────────────────────────────────────────────── + + +class TestForLoop: + + def test_for_range_is_fully_unrolled(self): + + def loop_test(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 10.0) + for i in range(5): + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(loop_test)(qudit_ref()) + kinds = [op.kind for op in ir.ops] + assert kinds.count("pulse.drive") == 5 + assert "scf.for" not in kinds + + def test_for_loop_does_not_leave_region_markers(self): + + def loop_yield(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 10.0) + for i in range(3): + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(loop_yield)(qudit_ref()) + kinds = [op.kind for op in ir.ops] + assert kinds.count("pulse.drive") == 3 + assert "scf.yield" not in kinds + + +# ── Qudit allocation patterns ──────────────────────────────────────── + + +class TestQuditAlloc: + + def test_internal_qudit_ref(self): + + def alloc_test(): + q = qudit_ref() + d0, t0 = get_drive_line(q) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(alloc_test)() + kinds = [op.kind for op in ir.ops] + assert "pulse.qudit_alloc" in kinds + alloc = next(op for op in ir.ops if op.kind == "pulse.qudit_alloc") + assert alloc.results[0].vtype == "qref" + + def test_attribute_style_alloc(self): + import cudaq_pulse + + def attr_alloc(): + q = cudaq_pulse.qudit_ref() + d0, t0 = cudaq_pulse.get_drive_line(q) + + ir = compile_kernel_bytecode(attr_alloc)() + kinds = [op.kind for op in ir.ops] + assert "pulse.qudit_alloc" in kinds + assert "pulse.get_drive_line" in kinds + + +# ── Constant folding / arithmetic ──────────────────────────────────── + + +class TestConstantFolding: + + def test_binary_add(self): + + def arith_test(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(20 + 20, 0.3, 10.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(arith_test)(qudit_ref()) + wf = next(op for op in ir.ops if op.kind == "pulse.gaussian") + assert wf.attrs["duration"] == 40 + + def test_binary_mul(self): + + def arith_test(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3 * 2, 10.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(arith_test)(qudit_ref()) + wf = next(op for op in ir.ops if op.kind == "pulse.gaussian") + assert abs(wf.attrs["amplitude"] - 0.6) < 1e-10 + + def test_binary_div(self): + + def arith_test(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 20.0 / 2) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(arith_test)(qudit_ref()) + wf = next(op for op in ir.ops if op.kind == "pulse.gaussian") + assert abs(wf.attrs["sigma"] - 10.0) < 1e-10 + + def test_unary_negation(self): + + def neg_test(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, -0.3, 10.0) + drive(d0, wf, t0) + + ir = compile_kernel_bytecode(neg_test)(qudit_ref()) + wf = next(op for op in ir.ops if op.kind == "pulse.gaussian") + assert abs(wf.attrs["amplitude"] - (-0.3)) < 1e-10 + + +# ── Source-less compilation ────────────────────────────────────────── + + +class TestSourceless: + + def test_exec_compiled_function(self): + code = compile( + "def f(q0):\n d0, t0 = get_drive_line(q0)\n", + "", + "exec", + ) + ns = {"get_drive_line": get_drive_line} + exec(code, ns) + fn = ns["f"] + + ir = compile_kernel_bytecode(fn)(qudit_ref()) + assert any(op.kind == "pulse.get_drive_line" for op in ir.ops) + + +# ── Error cases ────────────────────────────────────────────────────── + + +class TestErrors: + + def test_unknown_op_raises(self): + """Calling a function that isn't a pulse op or known builtin raises.""" + + def bad_kernel(q0): + d0, t0 = get_drive_line(q0) + unknown_function_xyz(d0) + + emitter = compile_kernel_bytecode(bad_kernel) + with pytest.raises(CompilationError): + emitter(qudit_ref()) + + def test_for_list_not_supported(self): + """for i in [1,2,3] is not range-based, should fail gracefully.""" + + def bad_loop(q0): + d0, t0 = get_drive_line(q0) + for i in [1, 2, 3]: + wait(d0, 10) + + emitter = compile_kernel_bytecode(bad_loop) + q = qudit_ref() + with pytest.raises((CompilationError, TypeError)): + emitter(q) + + +# ── Decorator integration ──────────────────────────────────────────── + + +class TestDecoratorIntegration: + + def test_kernel_decorator_uses_bytecode(self): + + @kernel + def my_kernel(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = my_kernel(qudit_ref()) + assert ir is not None + kinds = [op.kind for op in ir.ops] + assert "pulse.drive" in kinds + + def test_kernel_caching(self): + + @kernel + def cached(q0): + pass + + cached(qudit_ref()) + e1 = cached.__cudaq_pulse_emitter__ + cached(qudit_ref()) + e2 = cached.__cudaq_pulse_emitter__ + assert e1 is e2 + + def test_kernel_internal_alloc(self): + + @kernel + def internal(): + q = qudit_ref() + d0, t0 = get_drive_line(q) + + ir = internal() + kinds = [op.kind for op in ir.ops] + assert "pulse.qudit_alloc" in kinds diff --git a/pulse/tests/kernel/test_bytecode_normalize.py b/pulse/tests/kernel/test_bytecode_normalize.py new file mode 100644 index 00000000000..e245046b701 --- /dev/null +++ b/pulse/tests/kernel/test_bytecode_normalize.py @@ -0,0 +1,84 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Tests for the version-isolated bytecode normalizer. + +CI exercises a single interpreter, so the per-version opname maps are +checked directly here: a map that is missing an opcode the running +version emits only shows up as a ``CompilationError`` on that version. +""" + +import pytest + +from cudaq_pulse.kernel._bytecode_normalize import ( + _OPNAME_MAP_39, + _OPNAME_MAP_311, + _OPNAME_MAP_312, + _select_map, + normalize, +) + +# Versions the bridge claims to support. +_SUPPORTED = [(3, 9), (3, 10), (3, 11), (3, 12), (3, 13), (3, 14)] + + +@pytest.mark.parametrize("major,minor", _SUPPORTED) +def test_every_supported_version_has_a_map(major, minor): + assert _select_map(major, minor) + + +def test_unsupported_version_raises(): + with pytest.raises(NotImplementedError): + _select_map(3, 8) + with pytest.raises(NotImplementedError): + _select_map(2, 7) + + +@pytest.mark.parametrize("major,minor", [(3, 9), (3, 10), (3, 11)]) +def test_pre_312_versions_canonicalize_load_method(major, minor): + """LOAD_METHOD was folded into LOAD_ATTR in 3.12; earlier versions + still emit it for ``obj.method()`` and must map it explicitly.""" + assert _select_map(major, minor)["LOAD_METHOD"] == "LOAD_ATTR" + + +def test_311_map_extends_the_312_map(): + for opname, canonical in _OPNAME_MAP_312.items(): + assert _OPNAME_MAP_311[opname] == canonical + + +def test_39_map_canonicalizes_calls(): + assert _OPNAME_MAP_39["CALL_METHOD"] == "CALL" + assert _OPNAME_MAP_39["CALL_FUNCTION"] == "CALL" + + +def test_attribute_call_normalizes_to_load_attr_and_call(): + """Runs on whatever interpreter CI uses: a module-attribute call must + canonicalize identically on every supported version.""" + + class _Mod: + + @staticmethod + def helper(): + return 1 + + def attr_call(): + return _Mod.helper() + + ops = [ci.op for ci in normalize(attr_call.__code__)] + assert "LOAD_ATTR" in ops + assert "CALL" in ops + assert not any(op.startswith("LOAD_METHOD") for op in ops) + + +def test_bare_call_normalizes_to_call(): + + def bare_call(): + return len([]) + + ops = [ci.op for ci in normalize(bare_call.__code__)] + assert "CALL" in ops + assert ops[-1] == "RETURN" diff --git a/pulse/tests/kernel/test_decorator.py b/pulse/tests/kernel/test_decorator.py new file mode 100644 index 00000000000..0ce78583f3e --- /dev/null +++ b/pulse/tests/kernel/test_decorator.py @@ -0,0 +1,98 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import pytest + +import cudaq_pulse as pulse + + +def test_kernel_basic_decoration(): + + @pulse.kernel + def my_kernel(q0): + pass + + assert hasattr(my_kernel, "__cudaq_pulse_emitter__") + + +def test_kernel_caching(): + + @pulse.kernel + def my_kernel(q0): + pass + + q = pulse.qudit_ref() + my_kernel(q) + emitter1 = my_kernel.__cudaq_pulse_emitter__ + my_kernel(q) + emitter2 = my_kernel.__cudaq_pulse_emitter__ + assert emitter1 is not None + assert emitter1 is emitter2 + + +def test_qudit_ref_creation(): + q = pulse.qudit_ref() + assert q is not None + from cudaq_pulse.kernel.decorator import QuditRef + assert isinstance(q, QuditRef) + assert hasattr(q, "_vid") + + +def test_qvec_ref_creation(): + qv = pulse.qvec_ref(4) + assert len(qv) == 4 + from cudaq_pulse.kernel.decorator import QuditRef + q0 = qv[0] + assert isinstance(q0, QuditRef) + assert hasattr(q0, "_vid") + + +def test_qvec_ref_indexing(): + qv = pulse.qvec_ref(3) + for i in range(3): + assert qv[i] is not None + + +def test_kernel_invocation(): + + @pulse.kernel + def echo(q0): + pass + + q = pulse.qudit_ref() + result = echo(q) + assert result is not None + + +def test_kernel_internal_qudit_alloc(): + """qudit_ref() inside the kernel emits a pulse.qudit_alloc op.""" + import cudaq_pulse + + @pulse.kernel + def internal(): + q = cudaq_pulse.qudit_ref() + d0, t0 = cudaq_pulse.get_drive_line(q) + + program = internal() + assert program is not None + op_kinds = [op.kind for op in program.ops] + assert "pulse.qudit_alloc" in op_kinds + assert "pulse.get_drive_line" in op_kinds + + +def test_kernel_internal_bare_qudit_ref(): + """qudit_ref() as a bare name inside the kernel also works.""" + + @pulse.kernel + def internal(): + q = qudit_ref() + d0, t0 = get_drive_line(q) + + program = internal() + op_kinds = [op.kind for op in program.ops] + assert "pulse.qudit_alloc" in op_kinds diff --git a/pulse/tests/passes/__init__.py b/pulse/tests/passes/__init__.py new file mode 100644 index 00000000000..c6c6f4d157c --- /dev/null +++ b/pulse/tests/passes/__init__.py @@ -0,0 +1,7 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # diff --git a/pulse/tests/passes/test_canonicalize.py b/pulse/tests/passes/test_canonicalize.py new file mode 100644 index 00000000000..c6303716aa5 --- /dev/null +++ b/pulse/tests/passes/test_canonicalize.py @@ -0,0 +1,93 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import pytest + +from cudaq_pulse.passes.canonicalize import run_canonicalize +from cudaq_pulse.passes.ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + _reset_vid_counter, +) + + +def _simple_program_with_dead_alloc(): + """Program with a dead waveform alloc that canonicalize should remove.""" + _reset_vid_counter(400) + d = Value(vid=400, vtype=ValueType.DRIVE_LINE, name="d0") + t = Value(vid=401, vtype=ValueType.TONE, name="t0") + wf_used = Value(vid=402, vtype=ValueType.WAVEFORM, name="wf_used") + wf_dead = Value(vid=403, vtype=ValueType.WAVEFORM, name="wf_dead") + d2 = Value(vid=404, vtype=ValueType.DRIVE_LINE, name="d0") + t2 = Value(vid=405, vtype=ValueType.TONE, name="t0") + + return Program( + name="dead_alloc", + clock_ghz=2.0, + ops=[ + Op(kind=OpKind.ALLOC_DRIVE, + operands=(), + results=(d, t), + attrs={ + "qubit": 0, + "frequency_hz": 5e9 + }), + Op(kind=OpKind.MAKE_WAVEFORM, + operands=(), + results=(wf_used,), + attrs={ + "waveform_type": "gaussian", + "duration_vtu": 40, + "amplitude": 0.3, + "sigma": 10.0 + }), + Op(kind=OpKind.MAKE_WAVEFORM, + operands=(), + results=(wf_dead,), + attrs={ + "waveform_type": "gaussian", + "duration_vtu": 40, + "amplitude": 0.1, + "sigma": 10.0 + }), + Op(kind=OpKind.DRIVE, + operands=(d, wf_used, t), + results=(d2, t2), + attrs={"duration_vtu": 40}), + ], + values=[d, t, wf_used, wf_dead, d2, t2], + qubit_freq_hz={0: 5e9}, + ) + + +def test_canonicalize_preserves_ops(simple_program): + """Canonical program keeps all ops when nothing to optimize.""" + result = run_canonicalize(simple_program) + assert result.op_count() >= simple_program.op_count() - 1 + + +def test_canonicalize_removes_dead_alloc(): + prog = _simple_program_with_dead_alloc() + original_count = prog.op_count() + result = run_canonicalize(prog) + make_wfs = [op for op in result.ops if op.kind == OpKind.MAKE_WAVEFORM] + assert len(make_wfs) <= 2 + + +def test_canonicalize_two_qubit(two_qubit_program): + result = run_canonicalize(two_qubit_program) + assert result.op_count() > 0 + + +def test_canonicalize_echo(echo_program): + result = run_canonicalize(echo_program) + assert result.op_count() > 0 + assert result.clock_ghz == echo_program.clock_ghz diff --git a/pulse/tests/passes/test_fusion.py b/pulse/tests/passes/test_fusion.py new file mode 100644 index 00000000000..48d27ba51c7 --- /dev/null +++ b/pulse/tests/passes/test_fusion.py @@ -0,0 +1,71 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.ir_types import OpKind +from cudaq_pulse.passes.fusion import run_fusion + + +@pulse.kernel +def _no_fuse_kernel(q0): + d0, t0 = get_drive_line(q0) + sq1 = square(40, 0.3 + 0j) + drive(d0, sq1, t0) + sq2 = square(40, 0.5 + 0j) + drive(d0, sq2, t0) + + +@pulse.kernel +def _fuse_kernel(q0): + d0, t0 = get_drive_line(q0) + sq1 = square(40, 0.3 + 0j) + drive(d0, sq1, t0) + sq2 = square(40, 0.3 + 0j) + drive(d0, sq2, t0) + + +@pulse.kernel +def _mixed_kernel(q0): + d0, t0 = get_drive_line(q0) + g = gaussian(40, 0.3, 10.0) + drive(d0, g, t0) + + +def test_no_fusion_different_amplitude(): + ir = _no_fuse_kernel(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + result = run_fusion(prog) + drive_count = sum(1 for op in result.ops if op.kind == OpKind.DRIVE) + assert drive_count == 2 + + +def test_fusion_same_amplitude(): + ir = _fuse_kernel(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + result = run_fusion(prog) + drives = [op for op in result.ops if op.kind == OpKind.DRIVE] + assert len(drives) == 1 + assert drives[0].attrs["duration_vtu"] == 80 + assert drives[0].attrs["fused"] is True + waveform_vid = drives[0].operands[1].vid + fused_waveforms = [ + op for op in result.ops + if op.kind == OpKind.MAKE_WAVEFORM and op.results[0].vid == waveform_vid + ] + assert len(fused_waveforms) == 1 + assert fused_waveforms[0].attrs["duration_vtu"] == 80 + + +def test_fusion_preserves_non_square(): + ir = _mixed_kernel(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + result = run_fusion(prog) + waveform_ops = [op for op in result.ops if op.kind == OpKind.MAKE_WAVEFORM] + assert any( + op.attrs.get("waveform_type") == "gaussian" for op in waveform_ops) diff --git a/pulse/tests/passes/test_ir_types.py b/pulse/tests/passes/test_ir_types.py new file mode 100644 index 00000000000..2cec32e1dad --- /dev/null +++ b/pulse/tests/passes/test_ir_types.py @@ -0,0 +1,106 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import pytest + +from cudaq_pulse.passes.ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + _mk, + _reset_vid_counter, + clone_program, + duration_of, + line_id_of, + tone_id_of, + waveform_of, + is_loop_or_barrier, +) + + +def test_value_creation(): + v = Value(vid=0, vtype=ValueType.DRIVE_LINE, name="d0") + assert v.vid == 0 + assert v.vtype == ValueType.DRIVE_LINE + assert v.name == "d0" + + +def test_op_creation(): + v_in = Value(vid=0, vtype=ValueType.DRIVE_LINE, name="d0") + v_out = Value(vid=1, vtype=ValueType.DRIVE_LINE, name="d0") + op = Op(kind=OpKind.DRIVE, + operands=(v_in,), + results=(v_out,), + attrs={"duration_vtu": 40}) + assert op.kind == OpKind.DRIVE + assert duration_of(op) == 40 + + +def test_program_vtu_to_ns(): + prog = Program(name="test", + clock_ghz=2.0, + ops=[], + values=[], + qubit_freq_hz={0: 5e9}) + assert prog.vtu_to_ns == 0.5 + + +def test_program_vtu_to_ns_zero_clock(): + prog = Program(name="test", + clock_ghz=0.0, + ops=[], + values=[], + qubit_freq_hz={0: 5e9}) + with pytest.raises(ValueError, match="clock_ghz must be positive"): + _ = prog.vtu_to_ns + + +def test_clone_program(simple_program): + cloned = clone_program(simple_program) + assert cloned.name == simple_program.name + assert cloned.op_count() == simple_program.op_count() + assert cloned is not simple_program + assert cloned.ops is not simple_program.ops + + +def test_line_id_of(): + d = Value(vid=0, vtype=ValueType.DRIVE_LINE, name="d0") + op = Op(kind=OpKind.DRIVE, operands=(d,), results=(), attrs={}) + assert line_id_of(op) == 0 + + +def test_tone_id_of(): + t = Value(vid=5, vtype=ValueType.TONE, name="t0") + op = Op(kind=OpKind.SHIFT_PHASE, operands=(t,), results=(), attrs={}) + assert tone_id_of(op) == 5 + + +def test_is_loop_or_barrier(): + op_for = Op(kind=OpKind.FOR_LOOP, operands=(), results=(), attrs={}) + op_end = Op(kind=OpKind.END_FOR, operands=(), results=(), attrs={}) + op_sync = Op(kind=OpKind.SYNC, operands=(), results=(), attrs={}) + op_drv = Op(kind=OpKind.DRIVE, operands=(), results=(), attrs={}) + + assert is_loop_or_barrier(op_for) + assert is_loop_or_barrier(op_end) + assert is_loop_or_barrier(op_sync) + assert not is_loop_or_barrier(op_drv) + + +def test_mk_helper(): + v = _mk(ValueType.WAVEFORM, "wf") + assert v.vtype == ValueType.WAVEFORM + assert v.name == "wf" + + +def test_reset_vid_counter(): + _reset_vid_counter(999) + v = _mk(ValueType.TONE, "t") + assert v.vid == 999 diff --git a/pulse/tests/passes/test_loop_passes.py b/pulse/tests/passes/test_loop_passes.py new file mode 100644 index 00000000000..56e6c8e9524 --- /dev/null +++ b/pulse/tests/passes/test_loop_passes.py @@ -0,0 +1,165 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import pytest + +from cudaq_pulse.passes.loop_passes import run_licm, run_loop_strength_reduction +from cudaq_pulse.passes.ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + _reset_vid_counter, +) + + +def _build_loop_program(): + """Program with a for-loop containing a hoistable MAKE_WAVEFORM.""" + _reset_vid_counter(500) + d = Value(vid=500, vtype=ValueType.DRIVE_LINE, name="d0") + t = Value(vid=501, vtype=ValueType.TONE, name="t0") + wf = Value(vid=502, vtype=ValueType.WAVEFORM, name="wf") + d2 = Value(vid=503, vtype=ValueType.DRIVE_LINE, name="d0") + t2 = Value(vid=504, vtype=ValueType.TONE, name="t0") + + return Program( + name="loop_test", + clock_ghz=2.0, + ops=[ + Op(kind=OpKind.ALLOC_DRIVE, + operands=(), + results=(d, t), + attrs={ + "qubit": 0, + "frequency_hz": 5e9 + }), + Op(kind=OpKind.FOR_LOOP, + operands=(), + results=(), + attrs={ + "lb": 0, + "ub": 5, + "step": 1 + }), + Op(kind=OpKind.MAKE_WAVEFORM, + operands=(), + results=(wf,), + attrs={ + "waveform_type": "gaussian", + "duration_vtu": 40, + "amplitude": 0.3, + "sigma": 10.0 + }), + Op(kind=OpKind.DRIVE, + operands=(d, wf, t), + results=(d2, t2), + attrs={"duration_vtu": 40}), + Op(kind=OpKind.END_FOR, operands=(), results=(), attrs={}), + ], + values=[d, t, wf, d2, t2], + qubit_freq_hz={0: 5e9}, + ) + + +def _build_shift_phase_loop(): + """Program with a linear shift_phase progression in a loop.""" + _reset_vid_counter(600) + d = Value(vid=600, vtype=ValueType.DRIVE_LINE, name="d0") + t = Value(vid=601, vtype=ValueType.TONE, name="t0") + t2 = Value(vid=602, vtype=ValueType.TONE, name="t0") + + return Program( + name="shift_loop", + clock_ghz=2.0, + ops=[ + Op(kind=OpKind.ALLOC_DRIVE, + operands=(), + results=(d, t), + attrs={ + "qubit": 0, + "frequency_hz": 5e9 + }), + Op(kind=OpKind.FOR_LOOP, + operands=(), + results=(), + attrs={ + "lb": 0, + "ub": 10, + "step": 1 + }), + Op(kind=OpKind.SHIFT_PHASE, + operands=(t,), + results=(t2,), + attrs={"delta_rad": 0.1}), + Op(kind=OpKind.END_FOR, operands=(), results=(), attrs={}), + ], + values=[d, t, t2], + qubit_freq_hz={0: 5e9}, + ) + + +def test_licm_hoists_waveform(): + prog = _build_loop_program() + result = run_licm(prog) + for_idx = next( + i for i, op in enumerate(result.ops) if op.kind == OpKind.FOR_LOOP) + make_wf_before = [ + i for i, op in enumerate(result.ops) + if op.kind == OpKind.MAKE_WAVEFORM and i < for_idx + ] + assert len( + make_wf_before) >= 1, "LICM should hoist MAKE_WAVEFORM before loop" + + +def test_licm_preserves_echo(echo_program): + result = run_licm(echo_program) + assert result.op_count() > 0 + + +def test_loop_strength_reduction_runs(): + prog = _build_shift_phase_loop() + result = run_loop_strength_reduction(prog) + assert result.op_count() > 0 + + +def test_loop_strength_reduction_on_echo(echo_program): + result = run_loop_strength_reduction(echo_program) + assert result.op_count() > 0 + + +def test_unbalanced_loop_raises(): + _reset_vid_counter(700) + d = Value(vid=700, vtype=ValueType.DRIVE_LINE, name="d0") + t = Value(vid=701, vtype=ValueType.TONE, name="t0") + + prog = Program( + name="unbalanced", + clock_ghz=2.0, + ops=[ + Op(kind=OpKind.ALLOC_DRIVE, + operands=(), + results=(d, t), + attrs={ + "qubit": 0, + "frequency_hz": 5e9 + }), + Op(kind=OpKind.FOR_LOOP, + operands=(), + results=(), + attrs={ + "lb": 0, + "ub": 5, + "step": 1 + }), + ], + values=[d, t], + qubit_freq_hz={0: 5e9}, + ) + with pytest.raises(ValueError, match="FOR_LOOP.*without matching END_FOR"): + run_licm(prog) diff --git a/pulse/tests/passes/test_lower.py b/pulse/tests/passes/test_lower.py new file mode 100644 index 00000000000..cbb80a8a2c5 --- /dev/null +++ b/pulse/tests/passes/test_lower.py @@ -0,0 +1,72 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.ir_types import OpKind + + +def test_lower_basic_kernel(): + + @pulse.kernel + def k(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = k(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + kinds = [op.kind for op in prog.ops] + assert OpKind.ALLOC_DRIVE in kinds + assert OpKind.MAKE_WAVEFORM in kinds + assert OpKind.DRIVE in kinds + + +def test_lower_preserves_frequency(): + + @pulse.kernel + def k(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = k(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 4.8e9}) + alloc_ops = [op for op in prog.ops if op.kind == OpKind.ALLOC_DRIVE] + assert len(alloc_ops) >= 1 + assert alloc_ops[0].attrs.get("frequency_hz") == 4.8e9 + + +def test_lower_missing_frequency_raises(): + + @pulse.kernel + def k(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = k(pulse.qudit_ref()) + from cudaq_pulse.kernel.ir_builder import CompilationError + with pytest.raises(CompilationError, match="no frequency provided"): + to_program(ir, clock_ghz=2.0, qubit_freq_hz={}) + + +def test_lower_wait_duration(): + + @pulse.kernel + def k(q0): + d0, t0 = get_drive_line(q0) + wait(d0, 100) + + ir = k(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5e9}) + wait_ops = [op for op in prog.ops if op.kind == OpKind.WAIT] + assert len(wait_ops) >= 1 + assert wait_ops[0].attrs.get("duration_vtu") == 100 diff --git a/pulse/tests/passes/test_pulse_to_operator.py b/pulse/tests/passes/test_pulse_to_operator.py new file mode 100644 index 00000000000..a32f1310e20 --- /dev/null +++ b/pulse/tests/passes/test_pulse_to_operator.py @@ -0,0 +1,131 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import math + +import pytest + +from cudaq_pulse.passes.ir_types import OpKind +from cudaq_pulse.passes.pulse_to_operator import run_pulse_to_operator +from cudaq_pulse.targets.base import Qubit, Target + + +def test_basic_operator_program(simple_program): + result = run_pulse_to_operator(simple_program) + assert result is not None + assert result.n_qubits >= 1 + # The waveform definition and its drive both carry a duration, but only + # the physical drive contributes to the makespan. + assert result.total_time_ns == pytest.approx(20.0) + + +def test_operator_program_has_hamiltonian(simple_program): + result = run_pulse_to_operator(simple_program) + assert len(result.hamiltonian_terms) > 0 + + +def test_two_qubit_operator(two_qubit_program): + result = run_pulse_to_operator(two_qubit_program) + assert result.n_qubits >= 2 + assert result.total_time_ns > 0 + + +def test_with_dissipators(simple_program): + result = run_pulse_to_operator( + simple_program, + t1_times={0: 50.0}, + t2_times={0: 30.0}, + ) + assert len(result.dissipator_terms) > 0 + + +def test_dissipator_gamma_correctness(simple_program): + """T2 dissipator should use pure dephasing rate: 1/T2 - 1/(2*T1).""" + result = run_pulse_to_operator( + simple_program, + t1_times={0: 50.0}, + t2_times={0: 30.0}, + ) + assert len(result.dissipator_terms) >= 2 + + +def test_target_coefficients_use_nanoseconds_without_duplicate_dissipators( + simple_program): + target = Target( + name="unit-test", + qubits={ + 0: + Qubit( + index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=50.0, + t2_star_us=30.0, + ) + }, + ) + + result = run_pulse_to_operator(simple_program, target=target) + static = next(t for t in result.hamiltonian_terms if t.kind == "static_z") + assert static.coefficient.real == pytest.approx(math.pi * 5.0) + assert not any(t.kind == "anharmonicity" for t in result.hamiltonian_terms) + + assert [t.kind for t in result.dissipator_terms + ] == ["dissipator_t1", "dissipator_t2"] + gamma1 = 1.0 / 50_000.0 + gamma_phi = 1.0 / 30_000.0 - 1.0 / (2.0 * 50_000.0) + assert result.dissipator_terms[0].coefficient.real == pytest.approx( + math.sqrt(gamma1)) + assert result.dissipator_terms[1].coefficient.real == pytest.approx( + math.sqrt(gamma_phi / 2.0)) + + +def test_target_dephasing_without_t1_is_finite(): + target = Target( + name="dephasing-only", + qubits={ + 0: + Qubit( + index=0, + frequency_hz=5.0e9, + anharmonicity_hz=0.0, + t1_us=0.0, + t2_star_us=20.0, + ) + }, + ) + + terms = target.dissipator_terms() + assert len(terms) == 1 + assert terms[0]["coefficient"].real == pytest.approx( + math.sqrt(1.0 / (2.0 * 20_000.0))) + + +def test_target_drive_amplitude_scale(): + calibrated = Target( + name="calibrated", + qubits={ + 0: + Qubit( + index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=0.0, + t2_star_us=0.0, + drive_params={ + "x_amp": 0.5, + "x_dur": 20.0, + "x_sigma": 5.0, + }, + ) + }, + ) + area = 5.0 * math.sqrt(2.0 * math.pi) * math.erf( + 20.0 / (2.0 * math.sqrt(2.0) * 5.0)) + assert calibrated.drive_amplitude_scale(0) == pytest.approx(math.pi / + (0.5 * area)) diff --git a/pulse/tests/passes/test_scheduling.py b/pulse/tests/passes/test_scheduling.py new file mode 100644 index 00000000000..0d3e3cf3344 --- /dev/null +++ b/pulse/tests/passes/test_scheduling.py @@ -0,0 +1,56 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import pytest + +from cudaq_pulse.passes.ir_types import Program +from cudaq_pulse.passes.scheduling import ( + schedule_asap, + schedule_alap, + schedule_rcp, + MachineModel, +) + + +def test_asap_simple(simple_program): + events, metrics = schedule_asap(simple_program) + assert metrics.total_length_vtu > 0 + assert metrics.op_count > 0 + + +def test_alap_simple(simple_program): + events, metrics = schedule_alap(simple_program) + assert metrics.total_length_vtu > 0 + + +def test_asap_two_qubit(two_qubit_program): + events, metrics = schedule_asap(two_qubit_program) + assert metrics.total_length_vtu > 0 + + +def test_alap_two_qubit(two_qubit_program): + events, metrics = schedule_alap(two_qubit_program) + assert metrics.total_length_vtu > 0 + + +def test_rcp_two_qubit(two_qubit_program): + machine = MachineModel(max_concurrent_drives=2, max_concurrent_readouts=2) + events, metrics = schedule_rcp(two_qubit_program, machine) + assert metrics.total_length_vtu > 0 + + +def test_asap_alap_same_makespan(simple_program): + _, asap_m = schedule_asap(simple_program) + _, alap_m = schedule_alap(simple_program) + assert asap_m.total_length_vtu == alap_m.total_length_vtu + + +def test_echo_scheduling(echo_program): + events, metrics = schedule_asap(echo_program) + assert metrics.total_length_vtu > 0 + assert metrics.op_count >= 6 # loop body ops + loop structure diff --git a/pulse/tests/passes/test_to_pulse_mlir.py b/pulse/tests/passes/test_to_pulse_mlir.py new file mode 100644 index 00000000000..2b8932a7333 --- /dev/null +++ b/pulse/tests/passes/test_to_pulse_mlir.py @@ -0,0 +1,241 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Tests for the program_to_pulse_mlir emitter.""" + +import math +import re + +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.to_pulse_mlir import program_to_pulse_mlir +from cudaq_pulse.targets.base import Qubit, Target + + +def test_simple_drive(simple_program): + mlir = program_to_pulse_mlir(simple_program) + assert "module @" in mlir + assert "func.func @main()" in mlir + assert "pulse.qudit_alloc" in mlir + assert "pulse.get_drive_line" in mlir + assert "pulse.gaussian" in mlir + assert "pulse.drive" in mlir + assert "return" in mlir + + +def test_target_and_evolution_metadata(simple_program): + target = Target( + name="sim", + qubits={ + 0: + Qubit( + index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=50.0, + t2_star_us=30.0, + drive_params={"amplitude_scale_rad_per_ns": 0.25}, + ) + }, + ) + mlir = program_to_pulse_mlir(simple_program, + target=target, + t_start=1.0, + t_end=20.0, + num_steps=64, + integrator="rk4") + assert "qop.t_start = 1.000000000000000e+00 : f64" in mlir + assert "qop.t_end = 2.000000000000000e+01 : f64" in mlir + assert "qop.num_steps = 64 : i64" in mlir + assert 'qop.integrator = "rk4"' in mlir + assert "pulse.t1_times = [5.000000000000000e+04 : f64]" in mlir + assert "pulse.drive_scale_rad_per_ns = array !pulse.waveform" in mlir + + +def test_drag_waveform(): + + @pulse.kernel + def k(q0): + d0, t0 = get_drive_line(q0) + wf = drag(40, 0.3, 10.0, 0.5) + drive(d0, wf, t0) + + ir = k(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + mlir = program_to_pulse_mlir(prog) + assert "pulse.drag" in mlir + + +def test_gaussian_square_flat_width_becomes_edge_duration(): + + @pulse.kernel + def k(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian_square(100, 0.3, 10.0, 20) + drive(d0, wf, t0) + + ir = k(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + mlir = program_to_pulse_mlir(prog) + assert "arith.constant 40 : i64" in mlir + assert "pulse.gaussian_square" in mlir + + +def test_gaussian_square_rejects_invalid_flat_width(): + + @pulse.kernel + def k(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian_square(100, 0.3, 10.0, 100) + drive(d0, wf, t0) + + ir = k(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + with pytest.raises(ValueError, match="width must satisfy"): + program_to_pulse_mlir(prog) + + +def test_ssa_threading_correctness(two_qubit_program): + """SSA values must never be used before they're defined.""" + mlir = program_to_pulse_mlir(two_qubit_program) + defined = set() + for line in mlir.splitlines(): + line = line.strip() + if not line or line.startswith("//") or line in ("{", "}"): + continue + lhs_match = re.match(r"^((?:%\w+,?\s*)+)\s*=", line) + if lhs_match: + for m in re.finditer(r"%(\w+)", lhs_match.group(1)): + defined.add(m.group(0)) + if "=" in line: + rhs = line.split("=", 1)[1] + else: + rhs = line + for m in re.finditer(r"%(\w+)", rhs): + ssa_name = m.group(0) + if ssa_name.startswith("%arg") or ssa_name.startswith("%iv"): + continue + assert ssa_name in defined, f"SSA value {ssa_name} used before definition in: {line}" + + +def test_module_name(simple_program): + """Module name should come from the program name.""" + mlir = program_to_pulse_mlir(simple_program) + assert f"module @{simple_program.name}" in mlir + + +def test_scheduling_attrs_preserved(): + + @pulse.kernel + def k(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + ir = k(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + mlir = program_to_pulse_mlir(prog) + assert "duration_vtu = 40" in mlir diff --git a/pulse/tests/passes/test_verify.py b/pulse/tests/passes/test_verify.py new file mode 100644 index 00000000000..d5e98e3711f --- /dev/null +++ b/pulse/tests/passes/test_verify.py @@ -0,0 +1,162 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import pytest + +from cudaq_pulse.passes.ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + _mk, + _reset_vid_counter, +) +from cudaq_pulse.passes.verify import ( + verify, + LinearityViolation, + BackwardTimeTravelError, + PhaseBookkeepingError, +) + + +def test_valid_program(simple_program): + issues = verify(simple_program) + errors = [i for i in issues if i.severity == "error"] + linearity_unconsumed = [i for i in errors if "never consumed" in i.message] + non_linearity = [i for i in errors if "never consumed" not in i.message] + assert len(non_linearity) == 0 + + +def test_valid_two_qubit(two_qubit_program): + issues = verify(two_qubit_program) + errors = [i for i in issues if i.severity == "error"] + non_linearity = [i for i in errors if "never consumed" not in i.message] + assert len(non_linearity) == 0 + + +def test_valid_echo(echo_program): + issues = verify(echo_program) + errors = [i for i in issues if i.severity == "error"] + non_linearity = [i for i in errors if "never consumed" not in i.message] + assert len(non_linearity) == 0 + + +def test_double_use_line(): + """Two drives consuming the same line value -> linearity violation.""" + _reset_vid_counter(100) + d0 = Value(vid=100, vtype=ValueType.DRIVE_LINE, name="d0") + t0 = Value(vid=101, vtype=ValueType.TONE, name="t0") + wf = Value(vid=102, vtype=ValueType.WAVEFORM, name="wf") + d0_out1 = Value(vid=103, vtype=ValueType.DRIVE_LINE, name="d0") + t0_out1 = Value(vid=104, vtype=ValueType.TONE, name="t0") + d0_out2 = Value(vid=105, vtype=ValueType.DRIVE_LINE, name="d0") + t0_out2 = Value(vid=106, vtype=ValueType.TONE, name="t0") + + p = Program( + name="bad_linearity", + clock_ghz=2.0, + ops=[ + Op( + kind=OpKind.ALLOC_DRIVE, + operands=(), + results=(d0, t0), + attrs={ + "qubit": 0, + "frequency_hz": 5e9 + }, + ), + Op( + kind=OpKind.MAKE_WAVEFORM, + operands=(), + results=(wf,), + attrs={ + "waveform_type": "gaussian", + "duration_vtu": 40, + "amplitude": 0.3 + }, + ), + Op( + kind=OpKind.DRIVE, + operands=(d0, wf, t0), + results=(d0_out1, t0_out1), + attrs={"duration_vtu": 40}, + ), + Op( + kind=OpKind.DRIVE, + operands=(d0, wf, t0), + results=(d0_out2, t0_out2), + attrs={"duration_vtu": 40}, + ), + ], + values=[d0, t0, wf, d0_out1, t0_out1, d0_out2, t0_out2], + qubit_freq_hz={0: 5e9}, + ) + issues = verify(p) + assert any( + isinstance(i, LinearityViolation) and i.severity == "error" + for i in issues) + + +def test_negative_wait(): + """Scheduled ops with backward start_vtu trigger BackwardTimeTravelError.""" + d0 = Value(vid=300, vtype=ValueType.DRIVE_LINE, name="d0") + t0 = Value(vid=301, vtype=ValueType.TONE, name="t0") + wf = Value(vid=302, vtype=ValueType.WAVEFORM, name="wf") + d0_a = Value(vid=303, vtype=ValueType.DRIVE_LINE, name="d0") + t0_a = Value(vid=304, vtype=ValueType.TONE, name="t0") + d0_b = Value(vid=305, vtype=ValueType.DRIVE_LINE, name="d0") + t0_b = Value(vid=306, vtype=ValueType.TONE, name="t0") + + p = Program( + name="bad_time", + clock_ghz=2.0, + ops=[ + Op( + kind=OpKind.ALLOC_DRIVE, + operands=(), + results=(d0, t0), + attrs={ + "qubit": 0, + "frequency_hz": 5e9 + }, + ), + Op( + kind=OpKind.MAKE_WAVEFORM, + operands=(), + results=(wf,), + attrs={ + "waveform_type": "gaussian", + "duration_vtu": 40, + "amplitude": 0.3 + }, + ), + Op( + kind=OpKind.DRIVE, + operands=(d0, wf, t0), + results=(d0_a, t0_a), + attrs={ + "duration_vtu": 40, + "start_vtu": 0 + }, + ), + Op( + kind=OpKind.DRIVE, + operands=(d0_a, wf, t0_a), + results=(d0_b, t0_b), + attrs={ + "duration_vtu": 40, + "start_vtu": 10 + }, + ), + ], + values=[d0, t0, wf, d0_a, t0_a, d0_b, t0_b], + qubit_freq_hz={0: 5e9}, + ) + issues = verify(p) + assert any(isinstance(i, BackwardTimeTravelError) for i in issues) diff --git a/pulse/tests/passes/test_virtual_z.py b/pulse/tests/passes/test_virtual_z.py new file mode 100644 index 00000000000..b2584c1910b --- /dev/null +++ b/pulse/tests/passes/test_virtual_z.py @@ -0,0 +1,91 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import math + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.ir_types import OpKind +from cudaq_pulse.passes.virtual_z import run_virtual_z + + +@pulse.kernel +def _vz_absorb(q0): + d0, t0 = get_drive_line(q0) + shift_phase(t0, math.pi / 2) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + +@pulse.kernel +def _vz_merge(q0): + d0, t0 = get_drive_line(q0) + shift_phase(t0, 0.1) + shift_phase(t0, 0.2) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + +@pulse.kernel +def _vz_noop(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + +@pulse.kernel +def _vz_persists(q0): + d0, t0 = get_drive_line(q0) + shift_phase(t0, 0.4) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + drive(d0, wf, t0) + + +def test_shift_phase_absorbed(): + ir = _vz_absorb(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + result = run_virtual_z(prog) + shift_count = sum(1 for op in result.ops if op.kind == OpKind.SHIFT_PHASE) + drive_ops = [op for op in result.ops if op.kind == OpKind.DRIVE] + assert shift_count == 0, f"shift_phase should be absorbed, got {shift_count}" + assert len(drive_ops) == 1 + assert drive_ops[0].attrs.get("virtual_z_applied") is True + assert abs(drive_ops[0].attrs["frame_phase_offset"] - math.pi / 2) < 1e-10 + + +def test_consecutive_shifts_merge(): + ir = _vz_merge(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + result = run_virtual_z(prog) + shift_count = sum(1 for op in result.ops if op.kind == OpKind.SHIFT_PHASE) + drive_ops = [op for op in result.ops if op.kind == OpKind.DRIVE] + assert shift_count == 0, "both shifts should merge and absorb into drive" + assert len(drive_ops) == 1 + assert abs(drive_ops[0].attrs["frame_phase_offset"] - 0.3) < 1e-10 + + +def test_no_phase_no_change(): + ir = _vz_noop(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + result = run_virtual_z(prog) + drive_ops = [op for op in result.ops if op.kind == OpKind.DRIVE] + assert all("virtual_z_applied" not in op.attrs for op in drive_ops) + + +def test_virtual_z_persists_across_drives(): + ir = _vz_persists(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + result = run_virtual_z(prog) + drive_ops = [op for op in result.ops if op.kind == OpKind.DRIVE] + assert len(drive_ops) == 2 + assert all( + abs(op.attrs["frame_phase_offset"] - 0.4) < 1.0e-10 for op in drive_ops) + # The first drive consumes the allocation tone; the second consumes the + # first drive's updated tone, preserving linear SSA. + assert drive_ops[1].operands[2].vid == drive_ops[0].results[1].vid diff --git a/pulse/tests/runtime/__init__.py b/pulse/tests/runtime/__init__.py new file mode 100644 index 00000000000..c6c6f4d157c --- /dev/null +++ b/pulse/tests/runtime/__init__.py @@ -0,0 +1,7 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # diff --git a/pulse/tests/runtime/cuda_device_probe.cpp b/pulse/tests/runtime/cuda_device_probe.cpp new file mode 100644 index 00000000000..35b88acf1a5 --- /dev/null +++ b/pulse/tests/runtime/cuda_device_probe.cpp @@ -0,0 +1,15 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +#include + +int main() { + int deviceCount = 0; + const auto status = cudaGetDeviceCount(&deviceCount); + return status == cudaSuccess && deviceCount > 0 ? 0 : 1; +} diff --git a/pulse/tests/runtime/cudm_runtime_smoke.cpp b/pulse/tests/runtime/cudm_runtime_smoke.cpp new file mode 100644 index 00000000000..72c5396dfd2 --- /dev/null +++ b/pulse/tests/runtime/cudm_runtime_smoke.cpp @@ -0,0 +1,67 @@ +/******************************************************************************* + * Copyright (c) 2026 NVIDIA Corporation & Affiliates. * + * All rights reserved. * + * * + * This source code and the accompanying materials are made available under * + * the terms of the Apache License 2.0 which accompanies this distribution. * + *******************************************************************************/ + +#include "cudm_runtime.h" + +#include + +#include +#include +#include + +int main(int argc, char **argv) { + const auto version = cudm_runtime_version(); + if (version <= 0) { + std::fprintf(stderr, "cuDensityMat returned an invalid version\n"); + return 1; + } + + std::printf("cuDensityMat runtime version: %lld\n", + static_cast(version)); + if (argc == 1 || std::strcmp(argv[1], "--gpu") != 0) + return 0; + + int deviceCount = 0; + const auto cudaStatus = cudaGetDeviceCount(&deviceCount); + if (cudaStatus != cudaSuccess || deviceCount == 0) { + std::fprintf(stderr, "No accessible NVIDIA GPU; skipping GPU smoke test\n"); + return 77; + } + + CudmHandle handle = nullptr; + if (cudm_init(&handle) != CUDM_SUCCESS) + return 2; + + constexpr std::array modeExtents = {2}; + CudmState state = nullptr; + CudmWorkspace workspace = nullptr; + CudmOperator op = nullptr; + + const bool created = + cudm_state_alloc(handle, &state, modeExtents.data(), modeExtents.size(), + 0, 16) == CUDM_SUCCESS && + cudm_workspace_create(handle, &workspace) == CUDM_SUCCESS && + cudm_operator_create(handle, &op, modeExtents.data(), + modeExtents.size()) == CUDM_SUCCESS; + + if (op) + cudm_operator_destroy(op); + if (workspace) + cudm_workspace_destroy(workspace); + if (state) + cudm_state_destroy(state); + cudm_destroy(handle); + + if (!created) { + std::fprintf(stderr, "Failed to construct cuDensityMat GPU descriptors\n"); + return 3; + } + + std::puts("cuDensityMat GPU descriptor smoke test passed"); + return 0; +} diff --git a/pulse/tests/runtime/test_8qubit_gpu.py b/pulse/tests/runtime/test_8qubit_gpu.py new file mode 100644 index 00000000000..00bb6a18d07 --- /dev/null +++ b/pulse/tests/runtime/test_8qubit_gpu.py @@ -0,0 +1,127 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GPU integration tests: 8-qubit ladder system. + +Ported from ``qpu/physics/test_8qubit_system.cpp``. The engine-specific +calibration/dispersive-readout machinery (``SystemCalibration``, +``TransmonConfig::create_ladder_8q``, MLIR calibration files) is dropped; +what survives is the ladder connectivity as a ``Target`` and a scalable +256-dimensional GPU evolution. + + q0 -- q1 -- q2 -- q3 + | | | | + q4 -- q5 -- q6 -- q7 +""" + +import math + +import numpy as np +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.targets import Coupling, Qubit, Target + +_HORIZONTAL = [(0, 1), (1, 2), (2, 3), (4, 5), (5, 6), (6, 7)] +_VERTICAL = [(0, 4), (1, 5), (2, 6), (3, 7)] +_LADDER_EDGES = _HORIZONTAL + _VERTICAL + + +def _gpu_available(): + try: + from cudaq_pulse.runtime.jit import _check_gpu_available + + return _check_gpu_available() + except Exception: + return False + + +gpu = pytest.mark.gpu +requires_gpu = pytest.mark.skipif(not _gpu_available(), + reason="No GPU/cuDensityMat") + + +def _ladder_8q_target(coupling_hz=1.0e6): + qubits = { + i: + Qubit(index=i, + frequency_hz=(5.0 + 0.02 * i) * 1.0e9, + anharmonicity_hz=-200.0e6, + t1_us=0.0, + t2_star_us=0.0, + drive_params={"amplitude_scale_rad_per_ns": 1.0}) + for i in range(8) + } + couplings = [ + Coupling(a, b, coupling_strength_hz=coupling_hz) + for a, b in _LADDER_EDGES + ] + return Target(name="ladder-8q", qubits=qubits, couplings=couplings) + + +def test_ladder_topology_structure(): + """The ladder target encodes the expected 10-edge connectivity.""" + target = _ladder_8q_target() + assert len(target.couplings) == 10 + + edges = {tuple(sorted(pair)) for pair in target.coupling_map} + assert (0, 1) in edges + assert (0, 4) in edges + assert (5, 6) in edges + assert (3, 7) in edges + # Non-adjacent qubits are not directly coupled. + assert (0, 2) not in edges + assert (0, 7) not in edges + assert (4, 7) not in edges + + +def test_ladder_neighbor_degrees(): + """Corner qubits have degree 2; edge/center qubits have degree 3.""" + graph = _ladder_8q_target().connectivity_graph() + corners = [0, 3, 4, 7] + inner = [1, 2, 5, 6] + for q in corners: + assert len(graph[q]) == 2 + for q in inner: + assert len(graph[q]) == 3 + + +@gpu +@requires_gpu +def test_8qubit_single_qubit_excitation(): + """A pi pulse on q0 excites it within the full 256-dimensional register.""" + target = _ladder_8q_target() + + # The simulated register is sized by the qudits the kernel *allocates* + # (its 8 arguments), not by the ones it drives. So q0 alone gets a pi + # pulse while q1..q7 idle in |0>, and the full 8-qubit (256-dim) register + # still evolves. + @pulse.kernel + def excite_q0(q0, q1, q2, q3, q4, q5, q6, q7): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 20.0), t0) + + refs = [pulse.qudit_ref() for _ in range(8)] + result = pulse.evolve(excite_q0(*refs), + target=target, + t_start=0.0, + t_end=20.0, + num_steps=200, + integrator="rk4") + + state = result.final_state + assert state.shape == (256,) + assert np.vdot(state, state).real == pytest.approx(1.0, abs=1.0e-5) + + # A single pi pulse drives exactly one qubit, so population leaves the + # all-ground state and lands in the single-excitation manifold (basis + # states whose index is a power of two). This holds regardless of the + # MSB/LSB ordering convention. + probs = np.abs(state)**2 + single_excitation = float(sum(probs[1 << b] for b in range(8))) + assert probs[0] < 0.1 # left the all-ground state + assert single_excitation > 0.5 # one qubit excited diff --git a/pulse/tests/runtime/test_bell_gpu.py b/pulse/tests/runtime/test_bell_gpu.py new file mode 100644 index 00000000000..f657abf5ac8 --- /dev/null +++ b/pulse/tests/runtime/test_bell_gpu.py @@ -0,0 +1,106 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GPU integration test: 2-qubit Bell state from XX coupling.""" + +import math + +import numpy as np +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.to_pulse_mlir import program_to_pulse_mlir +from cudaq_pulse.passes import run_canonicalize, run_virtual_z, run_fusion, schedule_alap +from cudaq_pulse.targets import Coupling, Qubit, Target + + +def _gpu_available(): + try: + from cudaq_pulse.runtime.jit import _check_gpu_available + + return _check_gpu_available() + except Exception: + return False + + +gpu = pytest.mark.gpu +requires_gpu = pytest.mark.skipif(not _gpu_available(), + reason="No GPU/cuDensityMat") + + +def test_bell_mlir_structure(): + """Verify 2-qubit CR Bell MLIR has correct structure.""" + + @pulse.kernel + def bell_cr(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + # pi/2 X on q0 + x90 = gaussian(40, 0.25, 10.0) + drive(d0, x90, t0) + sync(d0, d1) + # CR drive on q0 at q1's frequency (simplified) + cr = square(160, 0.05) + drive(d0, cr, t0) + sync(d0, d1) + # pi/2 X on q1 + drive(d1, x90, t1) + + ir = bell_cr(pulse.qudit_ref(), pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9, 1: 5.1e9}) + mlir = program_to_pulse_mlir(prog) + + assert mlir.count("pulse.qudit_alloc") == 2 + assert mlir.count("pulse.get_drive_line") == 2 + assert "pulse.sync" in mlir + assert "pulse.square" in mlir + assert "pulse.gaussian" in mlir + + +@gpu +@requires_gpu +def test_bell_gpu_execution(): + """XX evolution produces (|00> - i|11>) / sqrt(2).""" + + qubits = { + index: + Qubit( + index=index, + frequency_hz=(5.0 + 0.1 * index) * 1.0e9, + anharmonicity_hz=-200.0e6, + t1_us=0.0, + t2_star_us=0.0, + ) for index in range(2) + } + target = Target( + name="xx-bell-test", + qubits=qubits, + couplings=[Coupling(0, 1, coupling_strength_hz=5.0e6)], + ) + + @pulse.kernel + def bell_xx(q0, q1): + d0, _t0 = get_drive_line(q0) + d1, _t1 = get_drive_line(q1) + wait(d0, 50) + wait(d1, 50) + sync(d0, d1) + + ir = bell_xx(pulse.qudit_ref(), pulse.qudit_ref()) + result = pulse.evolve(ir, + target=target, + t_start=0.0, + t_end=25.0, + num_steps=250, + integrator="rk4") + + state = result.final_state + expected = np.array([1.0, 0.0, 0.0, -1.0j]) / math.sqrt(2.0) + fidelity = abs(np.vdot(expected, state))**2 + assert state.shape == (4,) + assert fidelity > 0.999 diff --git a/pulse/tests/runtime/test_decoherence_gpu.py b/pulse/tests/runtime/test_decoherence_gpu.py new file mode 100644 index 00000000000..78bbbe7e505 --- /dev/null +++ b/pulse/tests/runtime/test_decoherence_gpu.py @@ -0,0 +1,222 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GPU integration tests: T1 amplitude damping and multi-qubit decoherence. + +Ported from the research-preview ``qpu/physics/test_decoherence.cpp`` engine +tests to the dialect-routed cuDensityMat runtime. The old parallel-engine +config-sampling cases (``TransmonConfig::generate`` / ``create_ladder_8q`` +seeding) are intentionally dropped: parameter sampling is not part of the +pulse frontend. The physics -- T1 decay, monotonicity, ground-state stability, +trace preservation, and independent multi-qubit decay -- is preserved here +through ``Target`` T1 Lindblad terms. +""" + +import math + +import numpy as np +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.to_pulse_mlir import program_to_pulse_mlir +from cudaq_pulse.passes import run_canonicalize, run_virtual_z, run_fusion, schedule_alap +from cudaq_pulse.targets import Qubit, Target + + +def _gpu_available(): + try: + from cudaq_pulse.runtime.jit import _check_gpu_available + + return _check_gpu_available() + except Exception: + return False + + +gpu = pytest.mark.gpu +requires_gpu = pytest.mark.skipif(not _gpu_available(), + reason="No GPU/cuDensityMat") + + +def _single_qubit_target(*, t1_us, t2_star_us=0.0, frequency_hz=5.0e9): + return Target( + name="decoherence-test", + qubits={ + 0: + Qubit( + index=0, + frequency_hz=frequency_hz, + anharmonicity_hz=-200.0e6, + t1_us=t1_us, + t2_star_us=t2_star_us, + drive_params={"amplitude_scale_rad_per_ns": 1.0}, + ) + }, + ) + + +# A calibrated pi pulse: 40 virtual units at 2 GHz is 20 ns; with H = amp*X/2 +# an amplitude of pi/20 rad/ns applies a pi rotation |0> -> |1>. +def _pi_pulse_then_wait(wait_vtu): + + @pulse.kernel + def kernel(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 20.0), t0) + wait(d0, wait_vtu) + + return kernel + + +def test_decoherence_mlir_structure(): + """A pi-pulse-plus-wait kernel lowers to structurally valid MLIR.""" + ir = _pi_pulse_then_wait(1000)(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + prog = run_canonicalize(prog) + prog = run_virtual_z(prog) + prog = run_fusion(prog) + schedule_alap(prog) + mlir = program_to_pulse_mlir(prog) + + assert "pulse.square" in mlir + assert "pulse.wait" in mlir + + +@gpu +@requires_gpu +def test_t1_excited_state_decays(): + """After a pi pulse and a wait, the excited population damps toward |0>.""" + target = _single_qubit_target(t1_us=0.2) + ir = _pi_pulse_then_wait(1000)(pulse.qudit_ref()) # 500 ns of free decay + result = pulse.evolve(ir, + target=target, + t_start=0.0, + t_end=520.0, + num_steps=520, + integrator="rk4") + + rho = result.final_state + assert rho.shape == (2, 2) + assert np.trace(rho).real == pytest.approx(1.0, abs=1.0e-6) + p1 = rho[1, 1].real + # exp(-500/200) ~ 0.082; the 20 ns drive adds a little in-pulse decay. + assert p1 < 0.5 + assert rho[0, 0].real > p1 + + +@gpu +@requires_gpu +def test_t1_longer_wait_more_decay(): + """Longer idle time produces strictly more T1 decay.""" + target = _single_qubit_target(t1_us=0.2) + + def _run(wait_vtu, t_end): + ir = _pi_pulse_then_wait(wait_vtu)(pulse.qudit_ref()) + result = pulse.evolve(ir, + target=target, + t_start=0.0, + t_end=t_end, + num_steps=int(t_end), + integrator="rk4") + return result.final_state[1, 1].real + + p1_short = _run(200, 120.0) # 100 ns wait + p1_long = _run(2000, 1020.0) # 1000 ns wait + assert p1_long < p1_short + assert p1_short < 1.0 + + +@gpu +@requires_gpu +def test_ground_state_stable_under_decoherence(): + """The ground state is the fixed point of T1 damping.""" + target = _single_qubit_target(t1_us=0.2) + + @pulse.kernel + def idle(q0): + d0, _t0 = get_drive_line(q0) + wait(d0, 2000) # 1000 ns of free evolution, no drive + + ir = idle(pulse.qudit_ref()) + result = pulse.evolve(ir, + target=target, + t_start=0.0, + t_end=1000.0, + num_steps=1000, + integrator="rk4") + rho = result.final_state + assert rho.shape == (2, 2) + assert rho[0, 0].real > 0.98 + + +@gpu +@requires_gpu +def test_decoherence_preserves_trace(): + """Lindblad evolution conserves the density-matrix trace.""" + target = _single_qubit_target(t1_us=0.2) + ir = _pi_pulse_then_wait(4000)(pulse.qudit_ref()) # 2000 ns + result = pulse.evolve(ir, + target=target, + t_start=0.0, + t_end=2020.0, + num_steps=2020, + integrator="rk4") + rho = result.final_state + assert np.trace(rho).real == pytest.approx(1.0, abs=1.0e-3) + + +@gpu +@requires_gpu +def test_multiqubit_independent_decay(): + """Two qubits with distinct T1 both damp; the whole state stays physical.""" + target = Target( + name="two-qubit-decoherence", + qubits={ + 0: + Qubit(index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=0.2, + t2_star_us=0.0, + drive_params={"amplitude_scale_rad_per_ns": 1.0}), + 1: + Qubit(index=1, + frequency_hz=5.2e9, + anharmonicity_hz=-200.0e6, + t1_us=0.1, + t2_star_us=0.0, + drive_params={"amplitude_scale_rad_per_ns": 1.0}), + }, + ) + + @pulse.kernel + def excite_both(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + drive(d0, square(40, math.pi / 20.0), t0) + drive(d1, square(40, math.pi / 20.0), t1) + sync(d0, d1) + wait(d0, 1000) + wait(d1, 1000) + + ir = excite_both(pulse.qudit_ref(), pulse.qudit_ref()) + result = pulse.evolve(ir, + target=target, + t_start=0.0, + t_end=520.0, + num_steps=520, + integrator="rk4") + + rho = result.final_state + assert rho.shape == (4, 4) + assert np.trace(rho).real == pytest.approx(1.0, abs=1.0e-3) + # Reduced excited populations: q0 = |01>+|11| diag, q1 = |10>+|11| diag. + # Basis order is |q0 q1>: indices 0=00,1=01,2=10,3=11. + p1_q0 = rho[2, 2].real + rho[3, 3].real + p1_q1 = rho[1, 1].real + rho[3, 3].real + assert p1_q0 < 0.9 + assert p1_q1 < 0.9 diff --git a/pulse/tests/runtime/test_frame_operations_gpu.py b/pulse/tests/runtime/test_frame_operations_gpu.py new file mode 100644 index 00000000000..5da0ce9abcb --- /dev/null +++ b/pulse/tests/runtime/test_frame_operations_gpu.py @@ -0,0 +1,132 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GPU integration tests: frame (phase) operations. + +Ported (partially) from ``qpu/physics/test_frame_operations.cpp``. Only the +pieces that map to ops supported by the dialect-routed runtime are kept: +``shift_phase`` rotates the drive axis, which the pulse->operator lowering +models as X/Y quadratures (amplitude*cos(phase) and amplitude*sin(phase)). + +Frequency-detuning cases from the source are intentionally omitted: the +research-preview runtime applies drives resonantly and does not model an +independent drive-frequency detuning term. +""" + +import math + +import numpy as np +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.to_pulse_mlir import program_to_pulse_mlir +from cudaq_pulse.passes import run_canonicalize, run_virtual_z, run_fusion, schedule_alap +from cudaq_pulse.targets import Qubit, Target + + +def _gpu_available(): + try: + from cudaq_pulse.runtime.jit import _check_gpu_available + + return _check_gpu_available() + except Exception: + return False + + +gpu = pytest.mark.gpu +requires_gpu = pytest.mark.skipif(not _gpu_available(), + reason="No GPU/cuDensityMat") + + +def _target(): + return Target( + name="frame-ops", + qubits={ + 0: + Qubit(index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=0.0, + t2_star_us=0.0, + drive_params={"amplitude_scale_rad_per_ns": 1.0}) + }, + ) + + +def _evolve(kernel): + return pulse.evolve(kernel(pulse.qudit_ref()), + target=_target(), + t_start=0.0, + t_end=40.0, + num_steps=400, + integrator="rk4").final_state + + +def test_frame_operations_mlir_structure(): + """A kernel using shift_phase lowers with a pulse.shift_phase op.""" + + @pulse.kernel + def phased(q0): + d0, t0 = get_drive_line(q0) + shift_phase(t0, math.pi / 2) + drive(d0, square(40, math.pi / 20.0), t0) + + prog = to_program(phased(pulse.qudit_ref()), + clock_ghz=2.0, + qubit_freq_hz={0: 5.0e9}) + prog = run_canonicalize(prog) + prog = run_fusion(prog) + schedule_alap(prog) + mlir = program_to_pulse_mlir(prog) + assert "pulse.shift_phase" in mlir or "phase" in mlir + + +@gpu +@requires_gpu +def test_phase_shift_on_ground_state_no_population_change(): + """A frame phase shift alone does not move population out of |0>.""" + + @pulse.kernel + def phase_only(q0): + d0, t0 = get_drive_line(q0) + shift_phase(t0, math.pi / 2) + wait(d0, 20) + + state = _evolve(phase_only) + assert abs(state[0])**2 > 0.99 + + +@gpu +@requires_gpu +def test_opposite_phase_pulses_cancel(): + """Two pi/2 pulses about opposite axes (phase pi apart) return to |0>.""" + + @pulse.kernel + def cancel(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 40.0), t0) # +X pi/2 + shift_phase(t0, math.pi) + drive(d0, square(40, math.pi / 40.0), t0) # -X pi/2 + + state = _evolve(cancel) + assert abs(state[0])**2 > 0.95 + + +@gpu +@requires_gpu +def test_same_phase_pulses_add(): + """Two same-axis pi/2 pulses compose into a pi rotation to |1>.""" + + @pulse.kernel + def add(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 40.0), t0) + drive(d0, square(40, math.pi / 40.0), t0) + + state = _evolve(add) + assert abs(state[1])**2 > 0.95 diff --git a/pulse/tests/runtime/test_full_pipeline_gpu.py b/pulse/tests/runtime/test_full_pipeline_gpu.py new file mode 100644 index 00000000000..1b99014ba94 --- /dev/null +++ b/pulse/tests/runtime/test_full_pipeline_gpu.py @@ -0,0 +1,91 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GPU integration test: full pipeline from @pulse.kernel to GPU state.""" + +import math + +import numpy as np +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.to_pulse_mlir import program_to_pulse_mlir +from cudaq_pulse.passes import ( + verify, + run_canonicalize, + run_virtual_z, + run_fusion, + run_licm, + schedule_alap, +) + + +def _gpu_available(): + try: + from cudaq_pulse.runtime.jit import _check_gpu_available + + return _check_gpu_available() + except Exception: + return False + + +gpu = pytest.mark.gpu +requires_gpu = pytest.mark.skipif(not _gpu_available(), + reason="No GPU/cuDensityMat") + + +def test_full_pipeline_mlir(): + """Verify the full pipeline produces valid MLIR from @pulse.kernel.""" + + @pulse.kernel + def my_kernel(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + sync(d0, d1) + drive(d1, wf, t1) + + ir = my_kernel(pulse.qudit_ref(), pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9, 1: 5.1e9}) + + verify(prog, strict=False) + + prog = run_canonicalize(prog) + prog = run_virtual_z(prog) + prog = run_fusion(prog) + prog = run_licm(prog) + schedule_alap(prog) + + mlir = program_to_pulse_mlir(prog) + + assert "module @" in mlir + assert "func.func @main()" in mlir + assert "pulse.qudit_alloc" in mlir + assert "pulse.get_drive_line" in mlir + assert "return" in mlir + + +@gpu +@requires_gpu +def test_full_pipeline_gpu(): + """Public compile -> MLIR lowering -> JIT -> GPU path.""" + + @pulse.kernel + def my_kernel(q0): + d0, t0 = get_drive_line(q0) + wf = square(40, math.pi / 20.0) + drive(d0, wf, t0) + + compiled = pulse.compile(my_kernel, [pulse.qudit_ref()], + qubit_freq_hz={0: 5.0e9}) + results = compiled.run() + state = results[0].to_numpy() + assert state.shape == (2,) + assert np.vdot(state, state).real == pytest.approx(1.0, abs=1.0e-6) + assert abs(state[1])**2 > 0.999 diff --git a/pulse/tests/runtime/test_idle_evolution_gpu.py b/pulse/tests/runtime/test_idle_evolution_gpu.py new file mode 100644 index 00000000000..752ce96dfbe --- /dev/null +++ b/pulse/tests/runtime/test_idle_evolution_gpu.py @@ -0,0 +1,166 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GPU integration tests: T1 decoherence during idle (wait) periods. + +Ported from ``qpu/physics/test_idle_evolution.cpp``. Verifies that free +evolution under a T1 Lindblad term follows P(|1>, t) = P0 * exp(-t/T1), that +short idles cause negligible decay, and that splitting an idle into several +shorter waits yields the same decay (schedule/integrator consistency). +""" + +import math + +import numpy as np +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.to_pulse_mlir import program_to_pulse_mlir +from cudaq_pulse.passes import run_canonicalize, run_virtual_z, run_fusion, schedule_alap +from cudaq_pulse.targets import Qubit, Target + + +def _gpu_available(): + try: + from cudaq_pulse.runtime.jit import _check_gpu_available + + return _check_gpu_available() + except Exception: + return False + + +gpu = pytest.mark.gpu +requires_gpu = pytest.mark.skipif(not _gpu_available(), + reason="No GPU/cuDensityMat") + +# T1 chosen large relative to the 20 ns preparation pulse so in-pulse decay is +# negligible and the idle-period decay cleanly follows exp(-t/T1). +_T1_US = 2.0 +_T1_NS = _T1_US * 1.0e3 +_CLOCK_GHZ = 2.0 # 2 virtual time units per nanosecond + + +def _target(): + return Target( + name="idle-evolution-test", + qubits={ + 0: + Qubit(index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=_T1_US, + t2_star_us=0.0, + drive_params={"amplitude_scale_rad_per_ns": 1.0}) + }, + ) + + +def _p1_after_wait(wait_ns): + """Prepare |1> with a pi pulse, idle for wait_ns, return P(|1>).""" + wait_vtu = int(round(wait_ns * _CLOCK_GHZ)) + + if wait_vtu > 0: + + @pulse.kernel + def kernel(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 20.0), t0) + wait(d0, wait_vtu) + else: + + @pulse.kernel + def kernel(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 20.0), t0) + + t_end = 20.0 + wait_ns + result = pulse.evolve(kernel(pulse.qudit_ref()), + target=_target(), + t_start=0.0, + t_end=t_end, + num_steps=max(1, int(round(t_end))), + integrator="rk4") + return result.final_state[1, 1].real + + +def test_idle_evolution_mlir_structure(): + """A pi-pulse-plus-idle kernel lowers to valid MLIR with a wait op.""" + + @pulse.kernel + def kernel(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 20.0), t0) + wait(d0, 1000) + + prog = to_program(kernel(pulse.qudit_ref()), + clock_ghz=_CLOCK_GHZ, + qubit_freq_hz={0: 5.0e9}) + prog = run_canonicalize(prog) + prog = run_virtual_z(prog) + prog = run_fusion(prog) + schedule_alap(prog) + mlir = program_to_pulse_mlir(prog) + assert "pulse.wait" in mlir + + +@gpu +@requires_gpu +def test_t1_decay_follows_exponential(): + """P(|1>) at t = frac * T1 matches P0 * exp(-frac) across a sweep.""" + fractions = [0.0, 0.25, 0.5, 1.0] + p1 = [_p1_after_wait(f * _T1_NS) for f in fractions] + + p0 = p1[0] + assert p0 > 0.95 # pi pulse leaves the qubit excited + for i in range(1, len(fractions)): + assert p1[i] < p1[i - 1] # monotonic decay + expected = p0 * math.exp(-fractions[i]) + assert p1[i] == pytest.approx(expected, abs=0.1) + assert p1[-1] < 0.5 # after one T1, well below half + + +@gpu +@requires_gpu +def test_short_idle_minimal_decay(): + """A very short idle (~1% of T1) barely perturbs the excited population.""" + p1_ref = _p1_after_wait(0.0) + p1_short = _p1_after_wait(0.01 * _T1_NS) + assert p1_short == pytest.approx(p1_ref, abs=0.03) + + +@gpu +@requires_gpu +def test_split_idle_matches_single_idle(): + """Splitting one idle into several shorter waits gives the same decay.""" + + @pulse.kernel + def single(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 20.0), t0) + wait(d0, 2000) # 1000 ns in one wait + + @pulse.kernel + def split(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 20.0), t0) + wait(d0, 400) + wait(d0, 400) + wait(d0, 400) + wait(d0, 400) + wait(d0, 400) # 5 x 200 ns == 1000 ns total + + def _run(kernel): + result = pulse.evolve(kernel(pulse.qudit_ref()), + target=_target(), + t_start=0.0, + t_end=1020.0, + num_steps=1020, + integrator="rk4") + return result.final_state[1, 1].real + + assert _run(single) == pytest.approx(_run(split), abs=0.02) diff --git a/pulse/tests/runtime/test_iq_modulation_gpu.py b/pulse/tests/runtime/test_iq_modulation_gpu.py new file mode 100644 index 00000000000..ec19c995439 --- /dev/null +++ b/pulse/tests/runtime/test_iq_modulation_gpu.py @@ -0,0 +1,155 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GPU integration tests: I/Q quadrature modulation. + +Ported (partially) from ``qpu/physics/test_iq_modulation.cpp``. The in-phase +(I) quadrature drives an X rotation; the quadrature (Q) component -- realized +here as a pi/2 frame phase shift before the drive -- drives a Y rotation. The +pulse->operator lowering models these as amplitude*cos(phase) (X) and +amplitude*sin(phase) (Y) control terms. +""" + +import math + +import numpy as np +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.to_pulse_mlir import program_to_pulse_mlir +from cudaq_pulse.passes import run_canonicalize, run_fusion, schedule_alap +from cudaq_pulse.targets import Qubit, Target + + +def _gpu_available(): + try: + from cudaq_pulse.runtime.jit import _check_gpu_available + + return _check_gpu_available() + except Exception: + return False + + +gpu = pytest.mark.gpu +requires_gpu = pytest.mark.skipif(not _gpu_available(), + reason="No GPU/cuDensityMat") + + +def _target(): + return Target( + name="iq-modulation", + qubits={ + 0: + Qubit(index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=0.0, + t2_star_us=0.0, + drive_params={"amplitude_scale_rad_per_ns": 1.0}) + }, + ) + + +def _evolve(kernel): + return pulse.evolve(kernel(pulse.qudit_ref()), + target=_target(), + t_start=0.0, + t_end=20.0, + num_steps=200, + integrator="rk4").final_state + + +def test_iq_modulation_mlir_structure(): + """A quadrature (phase-shifted) drive lowers to valid MLIR.""" + + @pulse.kernel + def q_drive(q0): + d0, t0 = get_drive_line(q0) + shift_phase(t0, math.pi / 2) + drive(d0, square(40, math.pi / 20.0), t0) + + prog = to_program(q_drive(pulse.qudit_ref()), + clock_ghz=2.0, + qubit_freq_hz={0: 5.0e9}) + prog = run_canonicalize(prog) + prog = run_fusion(prog) + schedule_alap(prog) + mlir = program_to_pulse_mlir(prog) + assert "pulse.square" in mlir + + +@gpu +@requires_gpu +def test_pure_i_drives_x_rotation(): + """In-phase (phase 0) pi drive flips |0> -> |1>.""" + + @pulse.kernel + def x_pi(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 20.0), t0) + + assert abs(_evolve(x_pi)[1])**2 > 0.99 + + +@gpu +@requires_gpu +def test_pure_q_drives_y_rotation(): + """Quadrature (phase pi/2) pi drive also flips |0> -> |1>.""" + + @pulse.kernel + def y_pi(q0): + d0, t0 = get_drive_line(q0) + shift_phase(t0, math.pi / 2) + drive(d0, square(40, math.pi / 20.0), t0) + + assert abs(_evolve(y_pi)[1])**2 > 0.99 + + +@gpu +@requires_gpu +def test_iq_symmetry_equal_populations(): + """Equal-magnitude X and Y pi/2 drives yield equal |1> population.""" + + @pulse.kernel + def x90(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 40.0), t0) + + @pulse.kernel + def y90(q0): + d0, t0 = get_drive_line(q0) + shift_phase(t0, math.pi / 2) + drive(d0, square(40, math.pi / 40.0), t0) + + p1_x = abs(_evolve(x90)[1])**2 + p1_y = abs(_evolve(y90)[1])**2 + assert p1_x == pytest.approx(0.5, abs=0.05) + assert p1_y == pytest.approx(p1_x, abs=0.05) + + +@gpu +@requires_gpu +def test_xy_orthogonality_distinct_states(): + """X(pi/2) and Y(pi/2) produce distinguishable states (different phases).""" + + @pulse.kernel + def x90(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 40.0), t0) + + @pulse.kernel + def y90(q0): + d0, t0 = get_drive_line(q0) + shift_phase(t0, math.pi / 2) + drive(d0, square(40, math.pi / 40.0), t0) + + x_state = _evolve(x90) + y_state = _evolve(y90) + overlap = abs(np.vdot(x_state, y_state))**2 + # Same populations but different relative phase => not the same state. + assert overlap < 0.9 diff --git a/pulse/tests/runtime/test_jit.py b/pulse/tests/runtime/test_jit.py new file mode 100644 index 00000000000..d842f9a17c9 --- /dev/null +++ b/pulse/tests/runtime/test_jit.py @@ -0,0 +1,163 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +import pytest +import numpy as np + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.runtime.jit import _check_gpu_available +from cudaq_pulse.targets.base import Qubit, Target + + +@pulse.kernel +def _jit_test_kernel(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + + +@pytest.mark.gpu +@pytest.mark.skipif(not _check_gpu_available(), reason="No GPU/cuDensityMat") +def test_jit_executes_target_aware_program(): + """The direct JIT path executes scheduled, target-aware pulse MLIR.""" + ir = _jit_test_kernel(pulse.qudit_ref()) + target = Target( + name="jit-test", + qubits={ + 0: + Qubit(index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=0.0, + t2_star_us=0.0) + }, + ) + result = pulse.evolve(ir, + target=target, + t_start=0.0, + t_end=20.0, + num_steps=200) + state = result.final_state + assert state.shape == (2,) + assert np.vdot(state, state).real == pytest.approx(1.0, abs=1.0e-8) + + +def test_jit_import(): + """JIT module should be importable.""" + from cudaq_pulse.runtime import jit + + assert hasattr(jit, "JITCompiler") + + +def test_evolve_import(): + """Evolve module should be importable.""" + from cudaq_pulse.runtime import evolve + + assert hasattr(evolve, "evolve") + + +def test_evolve_builds_target_aware_mlir(monkeypatch): + from cudaq_pulse.runtime import evolve as evolve_module + + target = Target( + name="test", + qubits={ + 0: + Qubit(index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=50.0, + t2_star_us=30.0) + }, + ) + captured = {} + + class Result: + + @staticmethod + def to_numpy(): + return np.eye(2, dtype=np.complex128) + + def fake_run(mlir, *, entry, n_qubits): + captured["mlir"] = mlir + captured["n_qubits"] = n_qubits + return [Result()] + + monkeypatch.setattr(evolve_module, "compile_and_run_pulse", fake_run) + ir = _jit_test_kernel(pulse.qudit_ref()) + result = pulse.evolve(ir, + target=target, + t_start=0.0, + t_end=20.0, + num_steps=100) + assert result.final_state.shape == (2, 2) + assert result.times.shape == (101,) + assert captured["n_qubits"] == 1 + assert "pulse.t1_times" in captured["mlir"] + assert 'qop.integrator = "rk4"' in captured["mlir"] + + +@pytest.mark.parametrize("integrator", + ["rk1", "rk2", "rk4", "magnus", "crank_nicolson"]) +def test_evolve_emits_selected_integrator(monkeypatch, integrator): + """Every supported integrator name lowers to its dialect attribute.""" + from cudaq_pulse.runtime import evolve as evolve_module + + target = Target( + name="test", + qubits={ + 0: + Qubit(index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=50.0, + t2_star_us=30.0) + }, + ) + captured = {} + + class Result: + + @staticmethod + def to_numpy(): + return np.eye(2, dtype=np.complex128) + + def fake_run(mlir, *, entry, n_qubits): + captured["mlir"] = mlir + return [Result()] + + monkeypatch.setattr(evolve_module, "compile_and_run_pulse", fake_run) + ir = _jit_test_kernel(pulse.qudit_ref()) + pulse.evolve(ir, + target=target, + t_start=0.0, + t_end=20.0, + num_steps=100, + integrator=integrator) + assert f'qop.integrator = "{integrator}"' in captured["mlir"] + + +def test_evolve_rejects_unimplemented_options(): + target = Target(name="test", qubits={}) + with pytest.raises(ValueError, match="Unknown integrator"): + pulse.evolve(object(), + target=target, + t_start=0.0, + t_end=1.0, + num_steps=1, + integrator="magnus_cf4") + with pytest.raises(NotImplementedError, match="observable"): + pulse.evolve( + object(), + target=target, + t_start=0.0, + t_end=1.0, + num_steps=1, + observables={"z": object()}, + ) diff --git a/pulse/tests/runtime/test_physics_validation_gpu.py b/pulse/tests/runtime/test_physics_validation_gpu.py new file mode 100644 index 00000000000..6a7c2216dc1 --- /dev/null +++ b/pulse/tests/runtime/test_physics_validation_gpu.py @@ -0,0 +1,204 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GPU integration tests: closed-system physics validation. + +Ported from ``qpu/physics/test_physics_validation.cpp``. Covers ground-state +initialization, free drift, single-qubit Rabi rotation angles (pi/2 and pi), +selective addressing of one qubit in a register, and XX-coupling excitation +exchange. +""" + +import math + +import numpy as np +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.to_pulse_mlir import program_to_pulse_mlir +from cudaq_pulse.passes import run_canonicalize, run_virtual_z, run_fusion, schedule_alap +from cudaq_pulse.targets import Coupling, Qubit, Target + + +def _gpu_available(): + try: + from cudaq_pulse.runtime.jit import _check_gpu_available + + return _check_gpu_available() + except Exception: + return False + + +gpu = pytest.mark.gpu +requires_gpu = pytest.mark.skipif(not _gpu_available(), + reason="No GPU/cuDensityMat") + + +def _unitary_qubit(index, frequency_hz=5.0e9): + return Qubit(index=index, + frequency_hz=frequency_hz, + anharmonicity_hz=-200.0e6, + t1_us=0.0, + t2_star_us=0.0, + drive_params={"amplitude_scale_rad_per_ns": 1.0}) + + +def _single_qubit_target(): + return Target(name="physics-1q", qubits={0: _unitary_qubit(0)}) + + +def test_physics_validation_mlir_structure(): + """A resonant-drive kernel lowers to valid MLIR.""" + + @pulse.kernel + def rabi(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 20.0), t0) + + prog = to_program(rabi(pulse.qudit_ref()), + clock_ghz=2.0, + qubit_freq_hz={0: 5.0e9}) + prog = run_canonicalize(prog) + prog = run_virtual_z(prog) + prog = run_fusion(prog) + schedule_alap(prog) + mlir = program_to_pulse_mlir(prog) + assert "pulse.square" in mlir + + +@gpu +@requires_gpu +def test_free_drift_stays_ground(): + """With no drive, the ground state is a static-Hamiltonian eigenstate.""" + + @pulse.kernel + def idle(q0): + d0, _t0 = get_drive_line(q0) + wait(d0, 40) # 20 ns free evolution + + result = pulse.evolve(idle(pulse.qudit_ref()), + target=_single_qubit_target(), + t_start=0.0, + t_end=20.0, + num_steps=200, + integrator="rk4") + state = result.final_state + assert state.shape == (2,) + assert abs(state[0])**2 > 0.99 + + +@gpu +@requires_gpu +def test_rabi_half_pi_pulse(): + """A pi/2 rotation leaves equal populations in |0> and |1>.""" + + @pulse.kernel + def half_pi(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 40.0), t0) # angle = pi/2 + + result = pulse.evolve(half_pi(pulse.qudit_ref()), + target=_single_qubit_target(), + t_start=0.0, + t_end=20.0, + num_steps=200, + integrator="rk4") + state = result.final_state + assert abs(state[1])**2 == pytest.approx(0.5, abs=0.05) + + +@gpu +@requires_gpu +def test_rabi_pi_pulse(): + """A pi rotation fully transfers population to |1>.""" + + @pulse.kernel + def pi_pulse(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 20.0), t0) # angle = pi + + result = pulse.evolve(pi_pulse(pulse.qudit_ref()), + target=_single_qubit_target(), + t_start=0.0, + t_end=20.0, + num_steps=200, + integrator="rk4") + state = result.final_state + assert abs(state[1])**2 > 0.99 + + +@gpu +@requires_gpu +def test_two_qubit_selective_drive(): + """Driving q0 excites only q0; q1 remains in its ground state.""" + target = Target( + name="physics-2q", + qubits={ + 0: _unitary_qubit(0), + 1: _unitary_qubit(1, 5.2e9) + }, + ) + + @pulse.kernel + def drive_q0(q0, q1): + d0, t0 = get_drive_line(q0) + d1, _t1 = get_drive_line(q1) + drive(d0, square(40, math.pi / 20.0), t0) + wait(d1, 40) + sync(d0, d1) + + result = pulse.evolve(drive_q0(pulse.qudit_ref(), pulse.qudit_ref()), + target=target, + t_start=0.0, + t_end=20.0, + num_steps=200, + integrator="rk4") + state = result.final_state + assert state.shape == (4,) + probs = np.abs(state)**2 + # Exactly one qubit is excited (the driven one), independent of the + # basis-ordering convention: population sits in the single-excitation + # manifold, not in |00> and not in |11>. + assert probs[0] < 0.1 # left the ground state + assert probs[1] + probs[2] > 0.9 # one qubit excited + assert probs[3] < 0.05 # both-excited is negligible + + +@gpu +@requires_gpu +def test_xx_coupling_transfers_excitation(): + """An XX coupling exchanges excitation between neighboring qubits.""" + target = Target( + name="physics-xx", + qubits={ + 0: _unitary_qubit(0), + 1: _unitary_qubit(1, 5.0e9) + }, + couplings=[Coupling(0, 1, coupling_strength_hz=25.0e6)], + ) + + @pulse.kernel + def excite_and_exchange(q0, q1): + d0, t0 = get_drive_line(q0) + d1, _t1 = get_drive_line(q1) + drive(d0, square(40, math.pi / 20.0), t0) # prepare ~|10> + sync(d0, d1) + wait(d0, 40) # let XX coupling swap population + wait(d1, 40) + + result = pulse.evolve(excite_and_exchange(pulse.qudit_ref(), + pulse.qudit_ref()), + target=target, + t_start=0.0, + t_end=40.0, + num_steps=400, + integrator="rk4") + state = result.final_state + probs = np.abs(state)**2 + # Population should have partly transferred q0 -> q1 (|10> -> |01>). + assert probs[1] > 0.1 diff --git a/pulse/tests/runtime/test_quantum_algorithms_gpu.py b/pulse/tests/runtime/test_quantum_algorithms_gpu.py new file mode 100644 index 00000000000..2ff5b134b6e --- /dev/null +++ b/pulse/tests/runtime/test_quantum_algorithms_gpu.py @@ -0,0 +1,168 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GPU integration tests: quantum-algorithm building blocks. + +Ported from ``qpu/physics/test_quantum_algorithms.cpp`` (whose entangling-gate +cases were all ``DISABLED_`` upstream because pulse-calibrated Bell/GHZ/CNOT +fidelity was not achievable on the parallel engine). Here the robust +single-qubit rotations and gate-sequence identities run on GPU, while the +multi-qubit entangling schedules are exercised as compile/lowering structure +tests rather than fidelity assertions. +""" + +import math + +import numpy as np +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.to_pulse_mlir import program_to_pulse_mlir +from cudaq_pulse.passes import run_canonicalize, run_virtual_z, run_fusion, schedule_alap +from cudaq_pulse.targets import Coupling, Qubit, Target + + +def _gpu_available(): + try: + from cudaq_pulse.runtime.jit import _check_gpu_available + + return _check_gpu_available() + except Exception: + return False + + +gpu = pytest.mark.gpu +requires_gpu = pytest.mark.skipif(not _gpu_available(), + reason="No GPU/cuDensityMat") + + +def _unitary_qubit(index, frequency_hz): + return Qubit(index=index, + frequency_hz=frequency_hz, + anharmonicity_hz=-200.0e6, + t1_us=0.0, + t2_star_us=0.0, + drive_params={"amplitude_scale_rad_per_ns": 1.0}) + + +def _single_qubit_target(): + return Target(name="algo-1q", qubits={0: _unitary_qubit(0, 5.0e9)}) + + +def _evolve_1q(kernel): + return pulse.evolve(kernel(pulse.qudit_ref()), + target=_single_qubit_target(), + t_start=0.0, + t_end=40.0, + num_steps=400, + integrator="rk4").final_state + + +def test_bell_schedule_mlir_structure(): + """A CR-based Bell schedule lowers to valid two-qubit MLIR.""" + + @pulse.kernel + def bell(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + shift_phase(t0, math.pi / 2) + drive(d0, drag(40, 0.25, 10.0, 0.5), t0) + shift_phase(t0, math.pi / 2) + sync(d0, d1) + drive(d0, gaussian(160, 0.05, 40.0), t1) # cross-resonance + sync(d0, d1) + + prog = to_program(bell(pulse.qudit_ref(), pulse.qudit_ref()), + clock_ghz=2.0, + qubit_freq_hz={ + 0: 5.0e9, + 1: 5.1e9 + }) + mlir = program_to_pulse_mlir(prog) + assert mlir.count("pulse.qudit_alloc") == 2 + assert "pulse.sync" in mlir + + +def test_ghz_schedule_mlir_structure(): + """A 3-qubit GHZ-style chain schedule lowers to valid MLIR.""" + + @pulse.kernel + def ghz(q0, q1, q2): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + d2, t2 = get_drive_line(q2) + drive(d0, square(40, math.pi / 40.0), t0) + sync(d0, d1, d2) + drive(d0, gaussian(160, 0.05, 40.0), t1) + drive(d1, gaussian(160, 0.05, 40.0), t2) + sync(d0, d1, d2) + + prog = to_program(ghz(pulse.qudit_ref(), pulse.qudit_ref(), + pulse.qudit_ref()), + clock_ghz=2.0, + qubit_freq_hz={ + 0: 5.0e9, + 1: 5.1e9, + 2: 5.2e9 + }) + mlir = program_to_pulse_mlir(prog) + assert mlir.count("pulse.qudit_alloc") == 3 + + +@gpu +@requires_gpu +def test_single_qubit_x_rotation(): + """An X(pi) rotation flips |0> -> |1>.""" + + @pulse.kernel + def x_pi(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 20.0), t0) + + assert abs(_evolve_1q(x_pi)[1])**2 > 0.99 + + +@gpu +@requires_gpu +def test_single_qubit_y_rotation(): + """A Y(pi) rotation (phase pi/2) flips |0> -> |1>.""" + + @pulse.kernel + def y_pi(q0): + d0, t0 = get_drive_line(q0) + shift_phase(t0, math.pi / 2) + drive(d0, square(40, math.pi / 20.0), t0) + + assert abs(_evolve_1q(y_pi)[1])**2 > 0.99 + + +@gpu +@requires_gpu +def test_bloch_half_rotation(): + """An X(pi/2) rotation produces an equal superposition.""" + + @pulse.kernel + def x90(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 40.0), t0) + + assert abs(_evolve_1q(x90)[1])**2 == pytest.approx(0.5, abs=0.05) + + +@gpu +@requires_gpu +def test_gate_sequence_identity(): + """Two X(pi) rotations compose to the identity, returning to |0>.""" + + @pulse.kernel + def xx(q0): + d0, t0 = get_drive_line(q0) + drive(d0, square(40, math.pi / 20.0), t0) + drive(d0, square(40, math.pi / 20.0), t0) + + assert abs(_evolve_1q(xx)[0])**2 > 0.95 diff --git a/pulse/tests/runtime/test_rabi_gpu.py b/pulse/tests/runtime/test_rabi_gpu.py new file mode 100644 index 00000000000..6d906c8d71e --- /dev/null +++ b/pulse/tests/runtime/test_rabi_gpu.py @@ -0,0 +1,147 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GPU integration test: 1-qubit Rabi oscillation. + +Verifies the MLIR emission for a Rabi experiment is structurally correct. +Full GPU execution test is marked with @pytest.mark.gpu. +""" + +import math + +import numpy as np +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.to_pulse_mlir import program_to_pulse_mlir +from cudaq_pulse.passes import run_canonicalize, run_virtual_z, run_fusion, schedule_alap +from cudaq_pulse.targets import Qubit, Target + + +def _gpu_available(): + try: + from cudaq_pulse.runtime.jit import _check_gpu_available + + return _check_gpu_available() + except Exception: + return False + + +gpu = pytest.mark.gpu +requires_gpu = pytest.mark.skipif(not _gpu_available(), + reason="No GPU/cuDensityMat") + + +def test_rabi_mlir_structure(): + """Verify the MLIR text structure for a Rabi simulation.""" + + @pulse.kernel + def rabi(q0): + d0, t0 = get_drive_line(q0) + wf = gaussian(100, 0.1, 25.0) + drive(d0, wf, t0) + + ir = rabi(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + prog = run_canonicalize(prog) + prog = run_virtual_z(prog) + prog = run_fusion(prog) + schedule_alap(prog) + mlir = program_to_pulse_mlir(prog) + + assert "module @rabi" in mlir + assert "pulse.qudit_alloc" in mlir + assert "pulse.get_drive_line" in mlir + assert "pulse.gaussian" in mlir + assert "!pulse.waveform" in mlir + + +@gpu +@requires_gpu +def test_rabi_gpu_execution(): + """Single-qubit Rabi oscillation on GPU.""" + + target = Target( + name="unitary-rabi-test", + qubits={ + 0: + Qubit( + index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=0.0, + t2_star_us=0.0, + drive_params={"amplitude_scale_rad_per_ns": 1.0}, + ) + }, + ) + + @pulse.kernel + def rabi(q0): + d0, t0 = get_drive_line(q0) + wf = square(40, math.pi / 20.0) + drive(d0, wf, t0) + + ir = rabi(pulse.qudit_ref()) + result = pulse.evolve(ir, + target=target, + t_start=0.0, + t_end=20.0, + num_steps=200, + integrator="rk4") + + state = result.final_state + assert state.shape == (2,) + assert np.vdot(state, state).real == pytest.approx(1.0, abs=1.0e-6) + assert abs(state[1])**2 > 0.999 + + +@gpu +@requires_gpu +@pytest.mark.parametrize("integrator", ["rk4", "magnus", "crank_nicolson"]) +def test_rabi_gpu_integrator_parity(integrator): + """All cuDensityMat integrators drive the same pi-pulse to |1>. + + Exercises the magnus (Taylor-series midpoint) and crank_nicolson + (predictor-corrector) paths added to cudm-runtime alongside rk4, and + confirms they agree on a closed-system Rabi flip while preserving norm. + """ + + target = Target( + name="unitary-rabi-parity", + qubits={ + 0: + Qubit( + index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=0.0, + t2_star_us=0.0, + drive_params={"amplitude_scale_rad_per_ns": 1.0}, + ) + }, + ) + + @pulse.kernel + def rabi(q0): + d0, t0 = get_drive_line(q0) + wf = square(40, math.pi / 20.0) + drive(d0, wf, t0) + + ir = rabi(pulse.qudit_ref()) + result = pulse.evolve(ir, + target=target, + t_start=0.0, + t_end=20.0, + num_steps=200, + integrator=integrator) + + state = result.final_state + assert state.shape == (2,) + assert np.vdot(state, state).real == pytest.approx(1.0, abs=1.0e-6) + assert abs(state[1])**2 > 0.999 diff --git a/pulse/tests/runtime/test_t1_decay_gpu.py b/pulse/tests/runtime/test_t1_decay_gpu.py new file mode 100644 index 00000000000..33c120f4e81 --- /dev/null +++ b/pulse/tests/runtime/test_t1_decay_gpu.py @@ -0,0 +1,101 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""GPU integration test: T1 exponential decay.""" + +import math + +import numpy as np +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program as to_program +from cudaq_pulse.passes.to_pulse_mlir import program_to_pulse_mlir +from cudaq_pulse.passes import run_canonicalize, run_virtual_z, run_fusion, schedule_alap +from cudaq_pulse.targets import Qubit, Target + + +def _gpu_available(): + try: + from cudaq_pulse.runtime.jit import _check_gpu_available + + return _check_gpu_available() + except Exception: + return False + + +gpu = pytest.mark.gpu +requires_gpu = pytest.mark.skipif(not _gpu_available(), + reason="No GPU/cuDensityMat") + + +def test_t1_mlir_structure(): + """Verify T1 decay kernel produces valid MLIR.""" + + @pulse.kernel + def t1_decay(q0): + d0, t0 = get_drive_line(q0) + # pi pulse to |1> + pi_pulse = gaussian(40, 0.5, 10.0) + drive(d0, pi_pulse, t0) + # wait for T1 decay + wait(d0, 1000) + + ir = t1_decay(pulse.qudit_ref()) + prog = to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + prog = run_canonicalize(prog) + prog = run_virtual_z(prog) + prog = run_fusion(prog) + schedule_alap(prog) + mlir = program_to_pulse_mlir(prog) + + assert "pulse.gaussian" in mlir + assert "pulse.wait" in mlir + assert "arith.constant 1000 : i64" in mlir + + +@gpu +@requires_gpu +def test_t1_decay_gpu(): + """T1 decay: after exciting to |1> and waiting, population decays.""" + + target = Target( + name="fast-decay-test", + qubits={ + 0: + Qubit( + index=0, + frequency_hz=5.0e9, + anharmonicity_hz=-200.0e6, + t1_us=0.05, + t2_star_us=0.0, + drive_params={"amplitude_scale_rad_per_ns": 1.0}, + ) + }, + ) + + @pulse.kernel + def t1_decay(q0): + d0, t0 = get_drive_line(q0) + # 40 virtual units at 2 GHz is 20 ns. With H = amplitude * X / 2, + # amplitude pi/20 applies a pi rotation. + pi_pulse = square(40, math.pi / 20.0) + drive(d0, pi_pulse, t0) + wait(d0, 1000) + + ir = t1_decay(pulse.qudit_ref()) + result = pulse.evolve(ir, + target=target, + t_start=0.0, + t_end=520.0, + num_steps=520, + integrator="rk4") + + state = result.final_state + assert state.shape == (2, 2) + assert np.trace(state) == pytest.approx(1.0, abs=1.0e-6) + assert state[1, 1].real < 1.0e-3 diff --git a/pulse/tests/test_compile.py b/pulse/tests/test_compile.py new file mode 100644 index 00000000000..a774f91d561 --- /dev/null +++ b/pulse/tests/test_compile.py @@ -0,0 +1,209 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Tests for cudaq_pulse.compile() public API.""" + +from __future__ import annotations + +import math + +import pytest + +import cudaq_pulse as pulse + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@pulse.kernel +def _bell(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + sync(d0, d1) + drive(d1, wf, t1) + + +_FREQ_2Q = {0: 5.0e9, 1: 5.1e9} + +# --------------------------------------------------------------------------- +# Basic compile() usage +# --------------------------------------------------------------------------- + + +def test_compile_returns_compiled_kernel(): + ck = pulse.compile( + _bell, + [pulse.qudit_ref(), pulse.qudit_ref()], + qubit_freq_hz=_FREQ_2Q, + ) + assert isinstance(ck, pulse.CompiledKernel) + + +def test_compile_produces_mlir(): + ck = pulse.compile( + _bell, + [pulse.qudit_ref(), pulse.qudit_ref()], + qubit_freq_hz=_FREQ_2Q, + ) + mlir = ck.mlir + assert "module @" in mlir + assert "func.func @main()" in mlir + assert "pulse.gaussian" in mlir + assert "pulse.drive" in mlir + + +def test_compile_metrics(): + ck = pulse.compile( + _bell, + [pulse.qudit_ref(), pulse.qudit_ref()], + qubit_freq_hz=_FREQ_2Q, + ) + m = ck.metrics + assert isinstance(m, pulse.CompileMetrics) + assert m.total_ms > 0 + assert m.trace_ms > 0 + assert m.ffi_ms > 0 + + +def test_compile_no_passes(): + ck = pulse.compile( + _bell, + [pulse.qudit_ref(), pulse.qudit_ref()], + qubit_freq_hz=_FREQ_2Q, + passes=(), + ) + assert ck.mlir is not None + assert "pulse.drive" in ck.mlir + + +def test_compile_single_qubit(): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz={0: 5.0e9}) + assert "pulse.drive" in ck.mlir + + +def test_compile_with_virtual_z(): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + shift_phase(t, math.pi / 4) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz={0: 5.0e9}) + assert ck.mlir is not None + assert ck.metrics.total_ms > 0 + + +def test_compile_with_fusion(): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + sq1 = square(50, 0.2) + drive(d, sq1, t) + sq2 = square(50, 0.2) + drive(d, sq2, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz={0: 5.0e9}) + assert ck.mlir is not None + + +def test_compile_bad_schedule_raises(): + with pytest.raises(ValueError, match="Unknown schedule"): + pulse.compile(_bell, + [pulse.qudit_ref(), pulse.qudit_ref()], + qubit_freq_hz=_FREQ_2Q, + schedule="bogus") + + +def test_compile_no_args_raises(): + with pytest.raises(TypeError, match="requires args"): + pulse.compile(_bell, qubit_freq_hz=_FREQ_2Q) + + +# --------------------------------------------------------------------------- +# PackedIRBuilder tests +# --------------------------------------------------------------------------- + + +def test_packed_ir_builder_basic(): + """PackedIRBuilder produces valid int64 buffer.""" + import numpy as np + from cudaq_pulse.kernel.packed_ir_builder import PackedIRBuilder + + b = PackedIRBuilder(clock_ghz=2.0, qubit_freq_hz={0: 5e9}) + (q,) = b.emit("pulse.qudit_alloc", (), ("qref",)) + (dl, t) = b.emit("pulse.get_drive_line", (q,), ("drive_line", "tone")) + (wf,) = b.emit("pulse.gaussian", (), ("waveform",), { + "duration": 40, + "amplitude": 0.5, + "sigma": 10.0 + }) + b.emit("pulse.drive", (dl, wf, t), ("drive_line", "tone")) + + buf = b.get_buffer() + assert isinstance(buf, np.ndarray) + assert buf.dtype == np.int64 + assert len(buf) > 0 + assert b.n_qubits == 1 + + +def test_packed_ir_builder_readout(): + """PackedIRBuilder encodes readout ops.""" + from cudaq_pulse.kernel.packed_ir_builder import PackedIRBuilder + + b = PackedIRBuilder(clock_ghz=2.0, qubit_freq_hz={0: 5e9}) + (q,) = b.emit("pulse.qudit_alloc", (), ("qref",)) + (rl, t) = b.emit("pulse.get_readout_line", (q,), ("readout_line", "tone")) + (wf,) = b.emit("pulse.square", (), ("waveform",), { + "duration": 400, + "amplitude": 0.1 + }) + b.emit("pulse.readout", (rl, wf, t), + ("readout_line", "tone", "measurement")) + + buf = b.get_buffer() + assert (buf[0] & 0xFF) == 1 # ALLOC_READOUT + assert len(buf) > 8 + + +def test_packed_ir_builder_sync(): + """PackedIRBuilder encodes variable-length sync ops.""" + from cudaq_pulse.kernel.packed_ir_builder import PackedIRBuilder + from cudaq_pulse.kernel.ir_builder import IRValue + + b = PackedIRBuilder(clock_ghz=2.0, qubit_freq_hz={0: 5e9, 1: 5.1e9}) + (q0,) = b.emit("pulse.qudit_alloc", (), ("qref",)) + (q1,) = b.emit("pulse.qudit_alloc", (), ("qref",)) + (d0, t0) = b.emit("pulse.get_drive_line", (q0,), ("drive_line", "tone")) + (d1, t1) = b.emit("pulse.get_drive_line", (q1,), ("drive_line", "tone")) + b.emit("pulse.sync", (d0, d1), ("drive_line", "drive_line")) + + buf = b.get_buffer() + assert len(buf) > 0 + + +def test_compile_module_available(): + """compile() produces an in-memory PulseModule.""" + ck = pulse.compile( + _bell, + [pulse.qudit_ref(), pulse.qudit_ref()], + qubit_freq_hz=_FREQ_2Q, + ) + assert ck.module is not None + assert ck.mlir is not None diff --git a/pulse/tests/test_e2e.py b/pulse/tests/test_e2e.py new file mode 100644 index 00000000000..96ff6eb5612 --- /dev/null +++ b/pulse/tests/test_e2e.py @@ -0,0 +1,128 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""End-to-end roundtrip tests: kernel -> compile() -> CompiledKernel.""" + +from __future__ import annotations + +import math + +import cudaq_pulse as pulse + + +def test_single_qubit_full_pipeline(): + """Single qubit: kernel -> compile -> verify scheduled MLIR output.""" + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + shift_phase(t, math.pi / 4) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + shift_phase(t, math.pi / 4) + wf2 = gaussian(40, 0.3, 10.0) + drive(d, wf2, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz={0: 5.0e9}) + assert isinstance(ck, pulse.CompiledKernel) + assert "pulse.drive" in ck.mlir + assert ck.metrics.total_ms > 0 + assert ck.metrics.trace_ms > 0 + + +def test_full_lowering_reaches_llvm_dialect(): + """The experimental GPU path produces LLVM-dialect IR.""" + + @pulse.kernel + def k(q): + drive_line, tone = get_drive_line(q) + drive(drive_line, gaussian(8, 0.2, 2.0), tone) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz={0: 5.0e9}) + llvm_ir = ck.lower_to_llvm() + + assert "llvm.func @main" in llvm_ir + assert "llvm.call @cudm_init" in llvm_ir + assert "llvm.call @cudm_evolve" in llvm_ir + assert "llvm.call @cudm_state_capture" in llvm_ir + assert "\n pulse." not in llvm_ir + assert "\n qop." not in llvm_ir + assert "\n cudm." not in llvm_ir + assert "pulse.drive" in ck.mlir + assert "llvm.call" not in ck.mlir + + +def test_two_qubit_full_pipeline(): + """Two qubits with sync: full compilation pipeline via compile().""" + + @pulse.kernel + def k(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + sync(d0, d1) + drive(d1, wf, t1) + + ck = pulse.compile( + k, + [pulse.qudit_ref(), pulse.qudit_ref()], + qubit_freq_hz={ + 0: 5e9, + 1: 5.1e9 + }, + ) + assert isinstance(ck, pulse.CompiledKernel) + assert "pulse.drive" in ck.mlir + assert ck.metrics.total_ms > 0 + + +def test_loop_full_pipeline(): + """Loop kernel: compile() handles loops end-to-end.""" + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + for _ in range(5): + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + wait(d, 20) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz={0: 5e9}) + assert isinstance(ck, pulse.CompiledKernel) + assert "pulse.drive" in ck.mlir + assert ck.metrics.total_ms > 0 + + +def test_scheduling_pipeline(): + """Scheduling via compile() with ALAP policy. + + Note: RCP scheduling is not yet available in the C++ pass pipeline + (_SCHEDULE_MAP only maps "alap"). This test uses schedule="alap". + """ + + @pulse.kernel + def k(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + drive(d1, wf, t1) + + ck = pulse.compile( + k, + [pulse.qudit_ref(), pulse.qudit_ref()], + qubit_freq_hz={ + 0: 5e9, + 1: 5.1e9 + }, + schedule="alap", + ) + assert isinstance(ck, pulse.CompiledKernel) + assert "pulse.drive" in ck.mlir + assert ck.metrics.total_ms > 0 + assert ck.metrics.schedule_ms >= 0 diff --git a/pulse/tests/test_parametric.py b/pulse/tests/test_parametric.py new file mode 100644 index 00000000000..f168b973b0d --- /dev/null +++ b/pulse/tests/test_parametric.py @@ -0,0 +1,760 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Comprehensive tests for parameterized pulse kernels. + +Covers: MLIR roundtrip, parameterized compilation, __call__ evaluation, +strict scheduling correctness, E2E sweeps, and backward compatibility. +""" + +from __future__ import annotations + +import math +import re +import time + +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.kernel.ir_builder import Parameter + +# =========================================================================== +# Helper frequencies +# =========================================================================== + +_F1Q = {0: 5.0e9} +_F2Q = {0: 5.0e9, 1: 5.1e9} + +# =========================================================================== +# MLIR dialect roundtrip and verifier tests +# =========================================================================== + + +class TestDialectRoundtrip: + """Verify SSA-value-based waveform ops produce valid MLIR.""" + + def test_gaussian_concrete_roundtrip(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + mlir = ck.mlir + assert "pulse.gaussian" in mlir + assert "arith.constant 40 : i64" in mlir + assert re.search(r"arith\.constant\s+3\.0+e-01\s*:\s*f64", mlir) + assert re.search(r"arith\.constant\s+1\.0+e\+01\s*:\s*f64", mlir) + + def test_square_concrete_roundtrip(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = square(20, 0.1) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + mlir = ck.mlir + assert "pulse.square" in mlir + assert "arith.constant 20 : i64" in mlir + + def test_drag_concrete_roundtrip(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = drag(40, 0.3, 10.0, 0.5) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + mlir = ck.mlir + assert "pulse.drag" in mlir + assert "arith.constant 40 : i64" in mlir + + def test_cosine_concrete_roundtrip(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = cosine(40, 0.5) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + mlir = ck.mlir + assert "pulse.cosine" in mlir + + def test_tanh_ramp_concrete_roundtrip(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = tanh_ramp(40, 0.5, 5.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + mlir = ck.mlir + assert "pulse.tanh_ramp" in mlir + + def test_gaussian_square_concrete_roundtrip(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = gaussian_square(100, 0.5, 10.0, 20) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + mlir = ck.mlir + assert "pulse.gaussian_square" in mlir + + def test_parametric_gaussian_has_block_arg(self): + + @pulse.kernel + def k(q, amplitude): + d, t = get_drive_line(q) + wf = gaussian(64, amplitude, 16.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + mlir = ck.mlir + assert "%arg0: f64" in mlir + assert "pulse.gaussian" in mlir + assert "%arg0" in mlir + + def test_parametric_has_param_names_attr(self): + + @pulse.kernel + def k(q, amplitude): + d, t = get_drive_line(q) + wf = gaussian(64, amplitude, 16.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert 'pulse.param_names = ["amplitude"]' in ck.mlir + + def test_concrete_no_block_args(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert "func.func @main()" in ck.mlir + assert "%arg" not in ck.mlir + + def test_verifier_passes_concrete_ops(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], + qubit_freq_hz=_F1Q, + passes=("verify",)) + assert ck.mlir is not None + + def test_scheduling_produces_timing_attrs(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert "start_vtu = 0" in ck.mlir + assert "duration_vtu = 40" in ck.mlir + + +# =========================================================================== +# Parameterized compilation tests +# =========================================================================== + + +class TestParametricCompilation: + """Test that compile() detects parameters and builds parametric MLIR.""" + + def test_single_param_amplitude(self): + + @pulse.kernel + def k(q, amp): + d, t = get_drive_line(q) + wf = gaussian(64, amp, 16.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert ck.is_parametric + assert ck.parameters == ["amp"] + assert "%arg0" in ck.mlir + + def test_multiple_params(self): + + @pulse.kernel + def k(q, amp, duration): + d, t = get_drive_line(q) + wf = gaussian(duration, amp, 16.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert ck.is_parametric + assert set(ck.parameters) == {"amp", "duration"} + assert "%arg0" in ck.mlir + assert "%arg1" in ck.mlir + + def test_mixed_concrete_and_param(self): + """Duration is literal 64, amplitude is parameterized.""" + + @pulse.kernel + def k(q, amp): + d, t = get_drive_line(q) + wf = gaussian(64, amp, 16.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + mlir = ck.mlir + assert "arith.constant 64 : i64" in mlir + assert "%arg0" in mlir # amplitude is block arg + + def test_all_concrete_backward_compat(self): + """Kernel with only qubit args compiles as before (no block args).""" + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = gaussian(64, 0.5, 16.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert not ck.is_parametric + assert ck.parameters == [] + assert "func.func @main()" in ck.mlir + assert "start_vtu" in ck.mlir # scheduled + + def test_phase_parameter(self): + + @pulse.kernel + def k(q, phi): + d, t = get_drive_line(q) + shift_phase(t, phi) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert ck.is_parametric + assert ck.parameters == ["phi"] + assert "pulse.shift_phase" in ck.mlir + + def test_wait_parameter(self): + + @pulse.kernel + def k(q, delay): + d, t = get_drive_line(q) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + wait(d, delay) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert ck.is_parametric + assert ck.parameters == ["delay"] + assert "pulse.wait" in ck.mlir + + def test_param_used_in_multiple_ops(self): + + @pulse.kernel + def k(q, amp): + d, t = get_drive_line(q) + wf1 = gaussian(40, amp, 10.0) + drive(d, wf1, t) + wf2 = gaussian(60, amp, 15.0) + drive(d, wf2, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert ck.is_parametric + # amp block arg used in two gaussian ops + mlir = ck.mlir + assert mlir.count("pulse.gaussian") == 2 + assert len(re.findall(r"= pulse\.drive ", mlir)) == 2 + + +# =========================================================================== +# __call__ evaluation tests +# =========================================================================== + + +class TestEvaluation: + """Test compiled(amplitude=0.5) evaluation via specialize().""" + + @pytest.fixture + def parametric_amp(self): + + @pulse.kernel + def k(q, amp): + d, t = get_drive_line(q) + wf = gaussian(64, amp, 16.0) + drive(d, wf, t) + + return pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + + def test_eval_kwargs(self, parametric_amp): + result = parametric_amp(amp=0.5) + assert not result.is_parametric + assert "start_vtu" in result.mlir + assert "duration_vtu" in result.mlir + + def test_eval_positional(self, parametric_amp): + result = parametric_amp(0.5) + assert "start_vtu" in result.mlir + + def test_eval_produces_correct_amplitude(self, parametric_amp): + result = parametric_amp(amp=0.5) + assert re.search(r"arith\.constant\s+5\.0+e-01\s*:\s*f64", result.mlir) + + def test_eval_different_values(self, parametric_amp): + r1 = parametric_amp(amp=0.25) + r2 = parametric_amp(amp=0.75) + assert "2.500000e-01" in r1.mlir + assert "7.500000e-01" in r2.mlir + + def test_re_evaluation_independence(self, parametric_amp): + """Re-evaluation with different values returns distinct results.""" + r1 = parametric_amp(amp=0.1) + r2 = parametric_amp(amp=0.9) + assert "1.000000e-01" in r1.mlir + assert "9.000000e-01" in r2.mlir + # Original kernel is still parametric + assert parametric_amp.is_parametric + + def test_multi_param_eval(self): + + @pulse.kernel + def k(q, amp, duration): + d, t = get_drive_line(q) + wf = gaussian(duration, amp, 16.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + result = ck(amp=0.5, duration=80) + assert "start_vtu" in result.mlir + assert "duration_vtu = 80" in result.mlir + + def test_eval_wrong_param_count_raises(self, parametric_amp): + with pytest.raises(TypeError, match="Expected 1"): + parametric_amp(0.5, 0.6) + + def test_eval_missing_kwarg_raises(self, parametric_amp): + with pytest.raises(TypeError, match="Missing"): + parametric_amp(wrong_name=0.5) + + def test_eval_unknown_kwarg_raises(self, parametric_amp): + with pytest.raises(TypeError, match="Unknown"): + parametric_amp(amp=0.5, bogus=1.0) + + def test_eval_non_parametric_raises(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + with pytest.raises(TypeError, match="no parameters"): + ck(0.5) + + def test_eval_mixed_positional_kwargs_raises(self, parametric_amp): + with pytest.raises(TypeError, match="Cannot mix"): + parametric_amp(0.5, amp=0.3) + + def test_scheduling_correct_after_eval(self, parametric_amp): + result = parametric_amp(amp=0.5) + mlir = result.mlir + assert "start_vtu = 0 : i64" in mlir + assert "duration_vtu = 64 : i64" in mlir + + +# =========================================================================== +# Strict scheduling correctness tests +# =========================================================================== + + +class TestStrictScheduling: + """Verify exact numeric timing values after evaluate().""" + + @staticmethod + def _extract_drive_attrs(mlir: str): + """Extract (start_vtu, duration_vtu) for each pulse.drive op.""" + drives = [] + for m in re.finditer(r"pulse\.drive.*?\{([^}]*)\}", mlir): + attrs_str = m.group(1) + start = int(re.search(r"start_vtu\s*=\s*(\d+)", attrs_str).group(1)) + dur = int( + re.search(r"duration_vtu\s*=\s*(\d+)", attrs_str).group(1)) + drives.append((start, dur)) + return drives + + def test_single_drive_duration_param(self): + + @pulse.kernel + def k(q, dur): + d, t = get_drive_line(q) + wf = gaussian(dur, 0.5, 16.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + + result = ck(dur=40) + drives = self._extract_drive_attrs(result.mlir) + assert drives == [(0, 40)] + + result = ck(dur=80) + drives = self._extract_drive_attrs(result.mlir) + assert drives == [(0, 80)] + + def test_two_sequential_drives_amplitude_param(self): + + @pulse.kernel + def k(q, amp): + d, t = get_drive_line(q) + wf1 = gaussian(40, amp, 10.0) + drive(d, wf1, t) + wf2 = gaussian(60, amp, 15.0) + drive(d, wf2, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + result = ck(amp=0.5) + drives = self._extract_drive_attrs(result.mlir) + assert len(drives) == 2 + assert drives[0] == (0, 40) + assert drives[1] == (40, 60) + + def test_two_qubit_sync_param_duration(self): + + @pulse.kernel + def k(q0, q1, dur): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + wf0 = gaussian(dur, 0.5, 16.0) + drive(d0, wf0, t0) + sync(d0, d1) + wf1 = gaussian(40, 0.5, 10.0) + drive(d1, wf1, t1) + + ck = pulse.compile( + k, [pulse.qudit_ref(), pulse.qudit_ref()], qubit_freq_hz=_F2Q) + result = ck(dur=100) + drives = self._extract_drive_attrs(result.mlir) + assert len(drives) == 2 + assert drives[0] == (0, 100) + assert drives[1][0] == 100 # second drive starts after sync + assert drives[1][1] == 40 + + result = ck(dur=20) + drives = self._extract_drive_attrs(result.mlir) + assert drives[0] == (0, 20) + assert drives[1][0] == 20 + + def test_wait_param_duration(self): + + @pulse.kernel + def k(q, delay): + d, t = get_drive_line(q) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + wait(d, delay) + wf2 = gaussian(40, 0.3, 10.0) + drive(d, wf2, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + result = ck(delay=50) + drives = self._extract_drive_attrs(result.mlir) + assert len(drives) == 2 + assert drives[0] == (0, 40) + assert drives[1] == (40 + 50, 40) + + def test_deterministic_re_evaluation(self): + + @pulse.kernel + def k(q, amp): + d, t = get_drive_line(q) + wf = gaussian(64, amp, 16.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + mlirs = [ck(amp=0.5).mlir for _ in range(50)] + assert all( + m == mlirs[0] + for m in mlirs), "Re-evaluation must produce identical MLIR text" + + def test_parameter_isolation(self): + """Changing only amplitude must not affect timing.""" + + @pulse.kernel + def k(q, amp, dur): + d, t = get_drive_line(q) + wf = gaussian(dur, amp, 16.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + + r1 = ck(amp=0.3, dur=64) + r2 = ck(amp=0.7, dur=64) + d1 = self._extract_drive_attrs(r1.mlir) + d2 = self._extract_drive_attrs(r2.mlir) + assert d1 == d2, "Amplitude changes must not affect timing" + + r3 = ck(amp=0.3, dur=100) + d3 = self._extract_drive_attrs(r3.mlir) + assert d3[0][1] == 100, "Duration change should update timing" + assert d1[0][1] == 64 + + def test_concrete_vs_evaluate_equivalence(self): + """Concrete compile and parametric evaluate at same values must match timing.""" + + @pulse.kernel + def concrete(q): + d, t = get_drive_line(q) + wf = gaussian(64, 0.5, 16.0) + drive(d, wf, t) + + @pulse.kernel + def parametric(q, amp): + d, t = get_drive_line(q) + wf = gaussian(64, amp, 16.0) + drive(d, wf, t) + + ck_concrete = pulse.compile(concrete, [pulse.qudit_ref()], + qubit_freq_hz=_F1Q) + ck_param = pulse.compile(parametric, [pulse.qudit_ref()], + qubit_freq_hz=_F1Q) + ck_eval = ck_param(amp=0.5) + + d_concrete = self._extract_drive_attrs(ck_concrete.mlir) + d_eval = self._extract_drive_attrs(ck_eval.mlir) + assert d_concrete == d_eval, ( + "Concrete and parametric-evaluated must produce identical timing") + + +# =========================================================================== +# End-to-end integration tests +# =========================================================================== + + +class TestE2ESweeps: + """End-to-end tests: amplitude sweep, duration sweep, QEC parameterized.""" + + def test_amplitude_sweep(self): + + @pulse.kernel + def k(q, amp): + d, t = get_drive_line(q) + wf = gaussian(64, amp, 16.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + for amp_val in [0.1 * i for i in range(1, 11)]: + result = ck(amp=amp_val) + assert "start_vtu" in result.mlir + assert "duration_vtu = 64" in result.mlir + + def test_duration_sweep(self): + + @pulse.kernel + def k(q, dur): + d, t = get_drive_line(q) + wf = gaussian(dur, 0.5, 16.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + for dur_val in [20, 40, 60, 80, 100, 200]: + result = ck(dur=dur_val) + assert f"duration_vtu = {dur_val}" in result.mlir + + def test_phase_sweep(self): + + @pulse.kernel + def k(q, phi): + d, t = get_drive_line(q) + shift_phase(t, phi) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + for angle in [0.0, math.pi / 4, math.pi / 2, math.pi, 2 * math.pi]: + result = ck(phi=angle) + assert "start_vtu" in result.mlir + + def test_qec_parameterized(self): + """Surface-code-like kernel with parameterized amplitudes.""" + + @pulse.kernel + def k(q0, q1, amp): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + wf0 = gaussian(40, amp, 10.0) + drive(d0, wf0, t0) + sync(d0, d1) + wf1 = gaussian(40, amp, 10.0) + drive(d1, wf1, t1) + + ck = pulse.compile( + k, [pulse.qudit_ref(), pulse.qudit_ref()], qubit_freq_hz=_F2Q) + assert ck.is_parametric + + result = ck(amp=0.25) + assert "start_vtu" in result.mlir + assert len(re.findall(r"= pulse\.drive ", result.mlir)) == 2 + + def test_performance_specialize_vs_recompile(self): + """Specialize must be faster than full recompile.""" + + @pulse.kernel + def k(q, amp): + d, t = get_drive_line(q) + wf = gaussian(64, amp, 16.0) + drive(d, wf, t) + + # Full compile + t0 = time.perf_counter() + for _ in range(20): + pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + compile_ms = (time.perf_counter() - t0) * 1000 / 20 + + # Parametric: compile once, evaluate many + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + t0 = time.perf_counter() + for i in range(20): + ck(amp=0.1 * (i + 1)) + specialize_ms = (time.perf_counter() - t0) * 1000 / 20 + + assert specialize_ms < compile_ms, ( + f"specialize ({specialize_ms:.2f}ms) should be faster than compile ({compile_ms:.2f}ms)" + ) + + +# =========================================================================== +# Backward compatibility +# =========================================================================== + + +class TestBackwardCompat: + """Existing concrete kernels must work identically.""" + + def test_existing_single_qubit(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert "func.func @main()" in ck.mlir + assert "start_vtu = 0" in ck.mlir + assert "duration_vtu = 40" in ck.mlir + + def test_existing_two_qubit_sync(self): + + @pulse.kernel + def k(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + wf = gaussian(40, 0.3, 10.0) + drive(d0, wf, t0) + sync(d0, d1) + drive(d1, wf, t1) + + ck = pulse.compile( + k, [pulse.qudit_ref(), pulse.qudit_ref()], qubit_freq_hz=_F2Q) + assert "pulse.sync" in ck.mlir + assert len(re.findall(r"= pulse\.drive ", ck.mlir)) == 2 + assert "start_vtu" in ck.mlir + + def test_existing_echo_with_wait(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + wait(d, 20) + wf2 = gaussian(40, 0.3, 10.0) + drive(d, wf2, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert "pulse.wait" in ck.mlir + assert "start_vtu" in ck.mlir + + def test_existing_with_shift_phase(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + shift_phase(t, math.pi / 4) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert ck.mlir is not None + assert "start_vtu" in ck.mlir + + def test_existing_with_all_passes(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + sq1 = square(50, 0.2) + drive(d, sq1, t) + sq2 = square(50, 0.2) + drive(d, sq2, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert ck.mlir is not None + + def test_compile_metrics_populated(self): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wf = gaussian(40, 0.3, 10.0) + drive(d, wf, t) + + ck = pulse.compile(k, [pulse.qudit_ref()], qubit_freq_hz=_F1Q) + assert ck.metrics.total_ms > 0 + assert ck.metrics.trace_ms > 0 + assert ck.metrics.ffi_ms > 0 + + +# =========================================================================== +# Parameter sentinel type tests +# =========================================================================== + + +class TestParameterType: + """Test the Parameter sentinel class itself.""" + + def test_parameter_creation(self): + p = Parameter("amp", 0, "f64") + assert p.name == "amp" + assert p.index == 0 + assert p.dtype == "f64" + + def test_parameter_repr(self): + p = Parameter("amp", 0, "f64") + assert "amp" in repr(p) + + def test_parameter_arithmetic_builds_expression(self): + p = Parameter("amp", 0, "f64") + assert (p + 1).op == "add" + assert (p * 2).op == "mul" + assert (p - 0.5).op == "sub" diff --git a/pulse/tests/test_property.py b/pulse/tests/test_property.py new file mode 100644 index 00000000000..99ed72bcf54 --- /dev/null +++ b/pulse/tests/test_property.py @@ -0,0 +1,187 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Property-based tests using Hypothesis for the pulse IR.""" + +from __future__ import annotations + +import math + +import pytest +from hypothesis import given, settings, assume +from hypothesis import strategies as st + +from cudaq_pulse.passes.ir_types import ( + Op, + OpKind, + Program, + Value, + ValueType, + _mk, + _reset_vid_counter, +) +from cudaq_pulse.passes.scheduling import schedule_asap, schedule_alap +from cudaq_pulse.passes.verify import verify +from cudaq_pulse.passes.canonicalize import run_canonicalize +from cudaq_pulse.passes.virtual_z import run_virtual_z +from cudaq_pulse.passes.fusion import run_fusion + + +def _build_random_program(n_drives: int, n_waits: int, clock_ghz: float, + amplitudes: list, durations: list, wait_durs: list): + """Build a synthetic program with n_drives and n_waits on one line.""" + _reset_vid_counter(10000) + vid = [10000] + + def nv(vt, nm): + v = Value(vid=vid[0], vtype=vt, name=nm) + vid[0] += 1 + return v + + d = nv(ValueType.DRIVE_LINE, "d0") + t = nv(ValueType.TONE, "t0") + vals = [d, t] + ops = [ + Op(kind=OpKind.ALLOC_DRIVE, + operands=(), + results=(d, t), + attrs={ + "qubit": 0, + "frequency_hz": 5e9 + }), + ] + + cur_d, cur_t = d, t + + for i in range(n_drives): + wf = nv(ValueType.WAVEFORM, f"wf{i}") + d_out = nv(ValueType.DRIVE_LINE, "d0") + t_out = nv(ValueType.TONE, "t0") + vals.extend([wf, d_out, t_out]) + + dur = durations[i % len(durations)] + amp = amplitudes[i % len(amplitudes)] + ops.append( + Op(kind=OpKind.MAKE_WAVEFORM, + operands=(), + results=(wf,), + attrs={ + "waveform_type": "gaussian", + "duration_vtu": dur, + "amplitude": amp, + "sigma": max(dur / 4.0, 1.0) + })) + ops.append( + Op(kind=OpKind.DRIVE, + operands=(cur_d, wf, cur_t), + results=(d_out, t_out), + attrs={"duration_vtu": dur})) + cur_d, cur_t = d_out, t_out + + for i in range(n_waits): + d_out = nv(ValueType.DRIVE_LINE, "d0") + vals.append(d_out) + w = wait_durs[i % len(wait_durs)] + ops.append( + Op(kind=OpKind.WAIT, + operands=(cur_d,), + results=(d_out,), + attrs={"duration_vtu": w})) + cur_d = d_out + + return Program(name="fuzz", + clock_ghz=clock_ghz, + ops=ops, + values=vals, + qubit_freq_hz={0: 5e9}) + + +@given( + n_drives=st.integers(min_value=1, max_value=10), + n_waits=st.integers(min_value=0, max_value=3), + clock_ghz=st.floats(min_value=0.1, max_value=10.0), + amplitudes=st.lists(st.floats(min_value=-1.0, + max_value=1.0, + allow_nan=False, + allow_infinity=False), + min_size=1, + max_size=5), + durations=st.lists(st.integers(min_value=4, max_value=1000), + min_size=1, + max_size=5), + wait_durs=st.lists(st.integers(min_value=0, max_value=500), + min_size=1, + max_size=3), +) +@settings(max_examples=50, deadline=5000) +def test_schedule_never_crashes(n_drives, n_waits, clock_ghz, amplitudes, + durations, wait_durs): + """ASAP scheduling should never crash on any valid program.""" + prog = _build_random_program(n_drives, n_waits, clock_ghz, amplitudes, + durations, wait_durs) + events, metrics = schedule_asap(prog) + assert metrics.total_length_vtu >= 0 + + +@given( + n_drives=st.integers(min_value=1, max_value=8), + clock_ghz=st.floats(min_value=0.5, max_value=5.0), + amplitudes=st.lists(st.floats(min_value=-1.0, + max_value=1.0, + allow_nan=False, + allow_infinity=False), + min_size=1, + max_size=4), + durations=st.lists(st.integers(min_value=4, max_value=500), + min_size=1, + max_size=4), +) +@settings(max_examples=50, deadline=5000) +def test_asap_alap_same_makespan(n_drives, clock_ghz, amplitudes, durations): + """ASAP and ALAP should produce the same total length on single-line programs.""" + prog = _build_random_program(n_drives, 0, clock_ghz, amplitudes, durations, + []) + _, asap_m = schedule_asap(prog) + _, alap_m = schedule_alap(prog) + assert abs(asap_m.total_length_vtu - alap_m.total_length_vtu) < 1e-6 + + +@given( + n_drives=st.integers(min_value=1, max_value=6), + amplitudes=st.lists(st.floats(min_value=-1.0, + max_value=1.0, + allow_nan=False, + allow_infinity=False), + min_size=1, + max_size=3), + durations=st.lists(st.integers(min_value=4, max_value=200), + min_size=1, + max_size=3), +) +@settings(max_examples=30, deadline=5000) +def test_canonicalize_preserves_drive_count(n_drives, amplitudes, durations): + """Canonicalize should not drop any DRIVE ops.""" + prog = _build_random_program(n_drives, 0, 2.0, amplitudes, durations, []) + result = run_canonicalize(prog) + orig_drives = sum(1 for op in prog.ops if op.kind == OpKind.DRIVE) + new_drives = sum(1 for op in result.ops if op.kind == OpKind.DRIVE) + assert new_drives == orig_drives + + +@given( + n_drives=st.integers(min_value=1, max_value=5), + durations=st.lists(st.integers(min_value=4, max_value=200), + min_size=1, + max_size=3), +) +@settings(max_examples=30, deadline=5000) +def test_virtual_z_idempotent(n_drives, durations): + """Running virtual_z twice should be the same as running it once.""" + prog = _build_random_program(n_drives, 0, 2.0, [0.3], durations, []) + once = run_virtual_z(prog) + twice = run_virtual_z(once) + assert once.op_count() == twice.op_count() diff --git a/pulse/tests/test_semantic_regressions.py b/pulse/tests/test_semantic_regressions.py new file mode 100644 index 00000000000..2ac885aad9b --- /dev/null +++ b/pulse/tests/test_semantic_regressions.py @@ -0,0 +1,292 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Regression tests for semantic correctness at public API boundaries.""" + +from __future__ import annotations + +import re + +import numpy as np +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse._native._cudaq_pulse_native import PulseModuleBuilder +from cudaq_pulse.kernel.ir_builder import CompilationError +from cudaq_pulse.passes._builder import ProgramBuilder +from cudaq_pulse.passes.scheduling import (MachineModel, schedule_alap, + schedule_asap, schedule_rcp) + + +def _drive_timings(mlir: str) -> list[tuple[int, int]]: + timings = [] + for match in re.finditer(r"pulse\.drive.*?\{([^}]*)\}", mlir): + attrs = match.group(1) + start = re.search(r"start_vtu\s*=\s*(-?\d+)", attrs) + duration = re.search(r"duration_vtu\s*=\s*(-?\d+)", attrs) + assert start and duration + timings.append((int(start.group(1)), int(duration.group(1)))) + return timings + + +def test_constant_loop_is_fully_unrolled(): + + @pulse.kernel + def kernel(q): + line, tone = get_drive_line(q) + waveform = square(10, 0.2) + for _ in range(5): + drive(line, waveform, tone) + + compiled = pulse.compile(kernel, [pulse.qudit_ref()], + qubit_freq_hz={0: 5.0e9}, + schedule="asap", + passes=()) + assert compiled.mlir.count("= pulse.drive ") == 5 + assert _drive_timings(compiled.mlir) == [(0, 10), (10, 10), (20, 10), + (30, 10), (40, 10)] + + +def test_range_start_stop_step_values_are_preserved(): + + @pulse.kernel + def kernel(q): + _, tone = get_drive_line(q) + for index in range(2, 9, 3): + shift_phase(tone, float(index)) + + compiled = pulse.compile(kernel, [pulse.qudit_ref()], + qubit_freq_hz={0: 5.0e9}, + passes=()) + assert compiled.mlir.count("pulse.shift_phase") == 3 + for value in (2.0, 5.0, 8.0): + assert f"{value:.6e}" in compiled.mlir + + +def test_measurement_dependent_branch_is_rejected(): + + @pulse.kernel + def kernel(q): + line, tone = get_readout_line(q) + result = readout(line, square(10, 0.2), tone) + if result: + wait(line, 10) + + with pytest.raises(CompilationError, match="runtime-dependent branches"): + pulse.compile(kernel, [pulse.qudit_ref()], qubit_freq_hz={0: 5.0e9}) + + +def test_documented_waveforms_and_algebra_lower_to_typed_ops(): + + @pulse.kernel + def kernel(): + q = pulse.qudit_ref() + line, tone = get_drive_line(q) + left = cosine(4, 0.2) + right = custom_samples([0.1, 0.2, 0.3, 0.4]) + combined = wf_add(left, right) + scaled = wf_scale(combined, 2.0) + negated = wf_neg(scaled) + drive(line, negated, tone) + + compiled = pulse.compile(kernel, [], + qubit_freq_hz={0: 5.0e9}, + passes=(), + schedule="asap") + assert "pulse.cosine" in compiled.mlir + assert "pulse.custom_samples" in compiled.mlir + assert "pulse.add" in compiled.mlir + assert "pulse.scale" in compiled.mlir + assert "pulse.neg" in compiled.mlir + assert _drive_timings(compiled.mlir) == [(0, 4)] + + +def test_custom_waveform_preserves_callback_symbol(): + + @pulse.kernel + def kernel(): + q = pulse.qudit_ref() + line, tone = get_drive_line(q) + drive(line, custom(8, "calibrated_envelope"), tone) + + compiled = pulse.compile(kernel, [], qubit_freq_hz={0: 5.0e9}, passes=()) + assert "@calibrated_envelope" in compiled.mlir + + +def test_concrete_numeric_arguments_are_not_made_symbolic(): + + @pulse.kernel + def kernel(duration, amplitude): + q = pulse.qudit_ref() + line, tone = get_drive_line(q) + drive(line, gaussian(duration, amplitude, 5.0), tone) + + compiled = pulse.compile(kernel, [20, 0.4], qubit_freq_hz={0: 5.0e9}) + assert not compiled.is_parametric + assert _drive_timings(compiled.mlir) == [(0, 20)] + + +def test_symbolic_type_is_inferred_from_use_not_name(): + + @pulse.kernel + def kernel(q, tau): + line, _ = get_drive_line(q) + wait(line, tau) + + compiled = pulse.compile(kernel, [pulse.qudit_ref()], + qubit_freq_hz={0: 5.0e9}) + assert "%arg0: i64" in compiled.mlir + assert compiled(tau=17).mlir.count("duration_vtu = 17") == 1 + + +def test_symbolic_arithmetic_and_explicit_cast_are_lowered(): + + @pulse.kernel + def kernel(q, sigma: float, delay: int): + line, tone = get_drive_line(q) + waveform = gaussian(int(4 * sigma), 0.2, sigma) + drive(line, waveform, tone) + wait(line, delay * 2 + 1) + + compiled = pulse.compile(kernel, [pulse.qudit_ref()], + qubit_freq_hz={0: 5.0e9}, + schedule="asap") + assert "arith.mulf" in compiled.mlir + assert "arith.fptosi" in compiled.mlir + specialized = compiled(sigma=5.0, delay=7) + assert _drive_timings(specialized.mlir) == [(0, 20)] + assert "duration_vtu = 15" in specialized.mlir + + +def test_unknown_pass_is_rejected(): + + @pulse.kernel + def kernel(): + pass + + with pytest.raises(ValueError, match="Unknown pulse passes"): + pulse.compile(kernel, [], passes=("typo",)) + + +def test_asap_and_alap_have_distinct_correct_placement(): + + @pulse.kernel + def kernel(q0, q1): + line0, tone0 = get_drive_line(q0) + line1, tone1 = get_drive_line(q1) + drive(line0, square(40, 0.2), tone0) + drive(line1, square(100, 0.2), tone1) + + args = [pulse.qudit_ref(), pulse.qudit_ref()] + frequencies = {0: 5.0e9, 1: 5.1e9} + asap = pulse.compile(kernel, + args, + qubit_freq_hz=frequencies, + schedule="asap", + passes=()) + alap = pulse.compile(kernel, + args, + qubit_freq_hz=frequencies, + schedule="alap", + passes=()) + assert _drive_timings(asap.mlir) == [(0, 40), (0, 100)] + assert _drive_timings(alap.mlir) == [(60, 40), (0, 100)] + + +def test_compile_uses_resource_machine_for_overlapping_intervals(): + + @pulse.kernel + def kernel(q0, q1): + line0, tone0 = get_drive_line(q0) + line1, tone1 = get_drive_line(q1) + drive(line0, square(100, 0.2), tone0) + drive(line1, square(40, 0.2), tone1) + + compiled = pulse.compile( + kernel, [pulse.qudit_ref(), pulse.qudit_ref()], + qubit_freq_hz={ + 0: 5.0e9, + 1: 5.1e9 + }, + schedule="rcp", + passes=(), + machine=MachineModel(max_concurrent_drives=1)) + assert _drive_timings(compiled.mlir) == [(0, 100), (100, 40)] + + +def test_python_scheduler_tracks_line_lineage_and_resource_intervals(): + builder = ProgramBuilder("lineage") + line, tone = builder.get_drive_line(0, 5.0e9) + for duration in (10, 20, 30): + waveform = builder.square(duration, 0.2) + line, tone = builder.drive(line, waveform, tone) + program = builder.build() + events, metrics = schedule_asap(program) + drives = [event for event in events if event.kind == "drive"] + assert [event.start_vtu for event in drives] == [0, 10, 30] + assert metrics.total_length_vtu == 60 + + independent = ProgramBuilder("resources") + for qubit in range(3): + line, tone = independent.get_drive_line(qubit, 5.0e9 + qubit * 1.0e8) + waveform = independent.square(100, 0.2) + independent.drive(line, waveform, tone) + events, _ = schedule_rcp(independent.build(), + MachineModel(max_concurrent_drives=2)) + starts = [event.start_vtu for event in events if event.kind == "drive"] + assert starts == [0, 0, 100] + + +def test_python_alap_delays_independent_shorter_operation(): + builder = ProgramBuilder("alap") + line0, tone0 = builder.get_drive_line(0, 5.0e9) + line1, tone1 = builder.get_drive_line(1, 5.1e9) + builder.drive(line0, builder.square(40, 0.2), tone0) + builder.drive(line1, builder.square(100, 0.2), tone1) + events, metrics = schedule_alap(builder.build()) + drives = [event for event in events if event.kind == "drive"] + assert [event.start_vtu for event in drives] == [60, 0] + assert metrics.total_length_vtu == 100 + + +def test_packed_decoder_rejects_truncated_and_unknown_records(): + builder = PulseModuleBuilder() + frequencies = np.array([], dtype=np.float64) + with pytest.raises(Exception, match="truncated record"): + builder.build_from_packed(np.array([3 | (4 << 8)], dtype=np.int64), 2.0, + 0, frequencies) + with pytest.raises(Exception, match="unknown opcode"): + builder.build_from_packed(np.array([255], dtype=np.int64), 2.0, 0, + frequencies) + + +def test_qudit_ref_can_bind_a_physical_target_index(): + + @pulse.kernel + def kernel(qubit): + line, _tone = get_drive_line(qubit) + wait(line, 10) + + compiled = pulse.compile(kernel, [pulse.qudit_ref(4)], + qubit_freq_hz={4: 5.2e9}, + passes=()) + assert "qubit = 4 : i64" in compiled.mlir + assert "frequency_hz = 5.200000e+09 : f64" in compiled.mlir + + with pytest.raises(ValueError, match="non-negative"): + pulse.qudit_ref(-1) + + +def test_virtual_time_arguments_are_not_silently_truncated(): + + @pulse.kernel + def kernel(qubit): + line, tone = get_drive_line(qubit) + drive(line, square(10.5, 0.2), tone) + + with pytest.raises(CompilationError, match="must be an integer"): + pulse.compile(kernel, [pulse.qudit_ref()], qubit_freq_hz={0: 5.0e9}) diff --git a/pulse/tests/test_workloads.py b/pulse/tests/test_workloads.py new file mode 100644 index 00000000000..7031de38dc9 --- /dev/null +++ b/pulse/tests/test_workloads.py @@ -0,0 +1,129 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Pytest harness for the workload programs (W1–W6) and bug corpus.""" + +from __future__ import annotations + +import pytest + +import cudaq_pulse as pulse +from cudaq_pulse.lower import _to_program +from cudaq_pulse.passes import verify, schedule_asap + + +def _make_qubits(n): + return [pulse.qudit_ref() for _ in range(n)] + + +def _freq(n): + return {i: 5.0e9 + i * 0.1e9 for i in range(n)} + + +_FREQ_2Q = {0: 5.0e9, 1: 5.1e9} + +# ── W2: CNOT-CR (simplest 2-qubit, no helpers outside kernel) ───────── + + +def test_w2_cnot_cr_compile(): + from tests.workloads.w2_cnot_cr import build + q0, q1 = _make_qubits(2) + ir = build(q0, q1) + prog = _to_program(ir, clock_ghz=2.0, qubit_freq_hz=_FREQ_2Q) + events, metrics = schedule_asap(prog) + assert metrics.total_length_vtu > 0 + assert metrics.op_count > 0 + + +# ── W5: DD-CPMG (single qubit, loop-heavy) ─────────────────────────── + + +def test_w5_dd_cpmg_compile(): + from tests.workloads.w5_dd_cpmg8 import build + q0 = pulse.qudit_ref() + ir = build(q0) + prog = _to_program(ir, clock_ghz=2.0, qubit_freq_hz={0: 5.0e9}) + events, metrics = schedule_asap(prog) + assert metrics.total_length_vtu > 0 + assert metrics.op_count > 0 + + +# ── W1: Bell ───────────────────────────────────────────────────────── + + +def test_w1_bell_compile(): + from tests.workloads.w1_bell import build + q0, q1 = _make_qubits(2) + ir = build(q0, q1) + prog = _to_program(ir, clock_ghz=2.0, qubit_freq_hz=_FREQ_2Q) + assert prog.op_count() > 0 + + +# ── W3: QAOA-4 ─────────────────────────────────────────────────────── + + +def test_w3_qaoa4_compile(): + from tests.workloads.w3_qaoa4 import build + qs = _make_qubits(4) + ir = build(*qs) + prog = _to_program(ir, + clock_ghz=2.0, + qubit_freq_hz={i: 5e9 + i * 0.1e9 for i in range(4)}) + assert prog.op_count() > 0 + + +# ── W4: Syndrome ───────────────────────────────────────────────────── + + +def test_w4_syndrome_compile(): + from tests.workloads.w4_syndrome import build, NUM_QUBITS + qs = _make_qubits(NUM_QUBITS) + ir = build(*qs) + prog = _to_program(ir, clock_ghz=2.0, qubit_freq_hz=_freq(NUM_QUBITS)) + assert prog.op_count() > 0 + + +# ── W6: VQE-HEA ───────────────────────────────────────────────────── + + +def test_w6_vqe_hea_compile(): + from tests.workloads.w6_vqe_hea import build, NUM_QUBITS + qs = _make_qubits(NUM_QUBITS) + ir = build(*qs) + prog = _to_program(ir, clock_ghz=2.0, qubit_freq_hz=_freq(NUM_QUBITS)) + assert prog.op_count() > 0 + + +# ── Bug corpus: module imports and stats are sane ───────────────────── + + +def test_bug_corpus_loads(): + from tests.workloads.bug_corpus import ALL_CASES, CORPUS_STATS + assert len(ALL_CASES) >= 80 + assert CORPUS_STATS["total"] == len(ALL_CASES) + for cat in ("correct", "unintentional_overlap", "backward_time_travel", + "phase_bookkeeping", "cross_resonance_miscalibration"): + assert CORPUS_STATS[cat] > 0, f"missing category: {cat}" + + +from tests.workloads.bug_corpus import CORRECT, BugCase + + +@pytest.mark.parametrize("case", CORRECT, ids=[c.name for c in CORRECT]) +def test_correct_corpus_compiles(case: BugCase): + """Correct cases should compile without crash (kernel may be a factory).""" + fn_or_kern = case.build_fn() + if not callable(fn_or_kern): + pytest.skip("build_fn returned non-callable") + if not hasattr(fn_or_kern, "__wrapped__"): + pytest.skip("not a kernel") + + nargs = fn_or_kern.__wrapped__.__code__.co_argcount + qs = _make_qubits(nargs) + ir = fn_or_kern(*qs) + prog = _to_program(ir, clock_ghz=2.0, qubit_freq_hz=_freq(nargs)) + assert prog.op_count() > 0 diff --git a/pulse/tests/workloads/__init__.py b/pulse/tests/workloads/__init__.py new file mode 100644 index 00000000000..a1fd34d10f6 --- /dev/null +++ b/pulse/tests/workloads/__init__.py @@ -0,0 +1,25 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # + +from . import ( + w1_bell, + w2_cnot_cr, + w3_qaoa4, + w4_syndrome, + w5_dd_cpmg8, + w6_vqe_hea, +) + +ALL_WORKLOADS = { + "bell": w1_bell, + "cnot_cr": w2_cnot_cr, + "qaoa4": w3_qaoa4, + "syndrome": w4_syndrome, + "dd_cpmg8": w5_dd_cpmg8, + "vqe_hea": w6_vqe_hea, +} diff --git a/pulse/tests/workloads/bug_corpus.py b/pulse/tests/workloads/bug_corpus.py new file mode 100644 index 00000000000..2c3d7afe8a3 --- /dev/null +++ b/pulse/tests/workloads/bug_corpus.py @@ -0,0 +1,545 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""Synthetic bug corpus: 100 programs across 4 bug classes + positive controls. + +Bug classes (paper Section 5): unintentional_overlap, backward_time_travel, +phase_bookkeeping, cross_resonance_miscalibration. +""" +from __future__ import annotations +import math +from dataclasses import dataclass +from typing import Callable, Any +import cudaq_pulse as pulse + + +@dataclass +class BugCase: + name: str + expected: str + build_fn: Callable[[], Any] + note: str = "" + + +# -- shared helpers used inside kernels ------------------------------------- +def _g(): + return gaussian(40, 0.1, 10.0) + + +def _x(): + return square(40, [0.047, 0.0]) + + +def _cr(): + return gaussian(200, 0.10, 50.0) + + +def _crn(): + return gaussian(200, -0.10, 50.0) + + +def _sx(): + return drag(40, 0.025, 10.0, 0.5) + + +def _ro(): + return square(1000, [0.05, 0.0]) + + +def _C(n, fn, note=""): + return BugCase(n, "correct", fn, note) + + +def _O(n, fn, note=""): + return BugCase(n, "unintentional_overlap", fn, note) + + +def _T(n, fn, note=""): + return BugCase(n, "backward_time_travel", fn, note) + + +def _P(n, fn, note=""): + return BugCase(n, "phase_bookkeeping", fn, note) + + +def _R(n, fn, note=""): + return BugCase(n, "cross_resonance_miscalibration", fn, note) + + +# ═══ Correct (25) ═════════════════════════════════════════════════════════ +def _c_dd(n): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + x = _x() + for _ in range(n): + drive(d, x, t) + wait(d, 200) + + return k + + +def _c_1q(body_tag): + """Factory for 1-qubit correct patterns.""" + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + if body_tag == "drive": + drive(d, _g(), t) + elif body_tag == "seq4": + for _ in range(4): + drive(d, _g(), t) + elif body_tag == "wait_d": + drive(d, _g(), t) + wait(d, 100) + drive(d, _g(), t) + elif body_tag == "phase": + shift_phase(t, 0.5) + drive(d, _g(), t) + elif body_tag == "vz": + drive(d, _g(), t) + shift_phase(t, math.pi) + drive(d, _g(), t) + elif body_tag == "zph": + shift_phase(t, 0.0) + drive(d, _g(), t) + elif body_tag == "fdet": + shift_frequency(t, 1e6) + drive(d, _g(), t) + shift_frequency(t, -1e6) + elif body_tag == "wait": + wait(d, 500) + elif body_tag == "lwait": + wait(d, 10000) + drive(d, _g(), t) + elif body_tag == "sx4": + sx = drag(40, 0.25, 10.0, 0.5) + for _ in range(4): + shift_phase(t, math.pi / 2) + drive(d, sx, t) + + return k + + +CORRECT = [ + _C(f"c_{t}", lambda t=t: _c_1q(t), t) for t in [ + "drive", "seq4", "wait_d", "phase", "vz", "zph", "fdet", "wait", + "lwait", "sx4" + ] +] +CORRECT += [ + _C(f"c_dd{n}", lambda n=n: _c_dd(n), f"DD-{n}") + for n in [1, 2, 3, 5, 8, 10, 16, 20, 50, 100] +] + + +@pulse.kernel +def _c2q(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + wf = _g() + drive(d0, wf, t0) + drive(d1, wf, t1) + + +@pulse.kernel +def _cscr(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d0, _cr(), t1) + + +@pulse.kernel +def _cro2(q0, q1): + r0, rt0 = get_readout_line(q0) + r1, rt1 = get_readout_line(q1) + ro = _ro() + readout(r0, ro, rt0) + readout(r1, ro, rt1) + + +@pulse.kernel +def _cmux(q): + d, t = get_drive_line(q) + drive(d, wf_add(gaussian(80, 0.05, 20.), gaussian(80, 0.04, 15.)), t) + + +@pulse.kernel +def _c3s(q0, q1, q2): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + d2, t2 = get_drive_line(q2) + drive(d0, _g(), t0) + sync(d0, d1, d2) + drive(d1, _g(), t1) + + +CORRECT += [ + _C("c_2qpar", lambda: _c2q), + _C("c_syncr", lambda: _cscr), + _C("c_ro2q", lambda: _cro2), + _C("c_mux", lambda: _cmux), + _C("c_3sync", lambda: _c3s) +] + + +# ═══ Overlap (25) ═════════════════════════════════════════════════════════ +def _b_ovk(k): + + @pulse.kernel + def kern(q): + d, t = get_drive_line(q) + wf = _g() + for _ in range(k): + drive(d, wf, t) # BUG: line not rebound + + return kern + + +@pulse.kernel +def _b_ovcr(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d0, _cr(), t1) + drive(d0, _x(), t0) # BUG + + +@pulse.kernel +def _b_ovro(q): + d, t = get_drive_line(q) + r, rt = get_readout_line(q) + drive(d, gaussian(200, 0.1, 50.), t) + readout(r, _ro(), rt) # BUG: no sync + + +OVERLAP = [ + _O(f"b_ov{n}", lambda n=n: _b_ovk(n), f"{n}x") for n in [ + 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32, 48, 64, 80, 96, 100, 128, 150, + 200, 250, 300, 512 + ] +] +OVERLAP += [_O("b_ovcr", lambda: _b_ovcr), _O("b_ovro", lambda: _b_ovro)] + + +# ═══ Backward time-travel (20) ════════════════════════════════════════════ +def _b_neg(dur): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + drive(d, _g(), t) + wait(d, dur) + + return k + + +def _b_negpre(dur): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + wait(d, dur) + drive(d, _g(), t) + + return k + + +def _b_negro(dur): + + @pulse.kernel + def k(q): + r, rt = get_readout_line(q) + wait(r, dur) + readout(r, _ro(), rt) + + return k + + +def _b_negbtw(dur): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + drive(d, _g(), t) + wait(d, dur) + drive(d, _g(), t) + + return k + + +BTT = [ + _T(f"b_neg{v}", lambda v=v: _b_neg(-v), f"wait {-v}") + for v in [1, 5, 10, 40, 100, 500, 1000] +] +BTT += [ + _T(f"b_negp{v}", lambda v=v: _b_negpre(-v), f"pre {-v}") + for v in [1, 10, 100, 1000] +] +BTT += [ + _T(f"b_negr{v}", lambda v=v: _b_negro(-v), f"ro {-v}") + for v in [1, 10, 100, 500] +] +BTT += [ + _T(f"b_negb{v}", lambda v=v: _b_negbtw(-v), f"btw {-v}") + for v in [10, 40, 100, 200, 1000] +] + + +# ═══ Phase bookkeeping (15) ═══════════════════════════════════════════════ +@pulse.kernel +def _b_phst(q): + d, t = get_drive_line(q) + drive(d, _g(), t) + shift_phase(t, 1.0) + + +@pulse.kernel +def _b_phdbl(q): + d, t = get_drive_line(q) + shift_phase(t, 0.5) + shift_phase(t, 0.3) + + +@pulse.kernel +def _b_phcr(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d0, _cr(), t1) + shift_phase(t1, math.pi / 2) + + +@pulse.kernel +def _b_phfr(q): + d, t = get_drive_line(q) + drive(d, _g(), t) + shift_frequency(t, 1e6) + + +def _b_phk(n): + + @pulse.kernel + def k(q): + d, t = get_drive_line(q) + shift_phase(t, 0.1) + for _ in range(n): + shift_phase(t, 0.1) + + return k + + +PHASE = [ + _P("b_phst", lambda: _b_phst), + _P("b_phdbl", lambda: _b_phdbl), + _P("b_phcr", lambda: _b_phcr), + _P("b_phfr", lambda: _b_phfr) +] +PHASE += [ + _P(f"b_phk{n}", lambda n=n: _b_phk(n), f"{n} stale") + for n in [2, 3, 4, 5, 6, 8, 10, 16, 20, 32, 64] +] + + +# ═══ CR miscalibration (15) ═══════════════════════════════════════════════ +@pulse.kernel +def _b_crt(q0, q1): # wrong tone + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d0, _cr(), t0) + + +@pulse.kernel +def _b_crne(q0, q1): # no echo + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d1, _sx(), t1) + drive(d0, _cr(), t1) + drive(d0, _crn(), t1) + drive(d1, _sx(), t1) + + +@pulse.kernel +def _b_crsw(q0, q1): # swapped line + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d1, _cr(), t1) + + +@pulse.kernel +def _b_crns(q0, q1): # no sync + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + drive(d1, _sx(), t1) + drive(d0, _cr(), t1) + drive(d0, _x(), t0) + drive(d0, _crn(), t1) + drive(d1, _sx(), t1) + + +@pulse.kernel +def _b_crde(q0, q1): # double echo + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d1, _sx(), t1) + drive(d0, _cr(), t1) + drive(d0, _x(), t0) + drive(d0, _x(), t0) + drive(d0, _crn(), t1) + drive(d1, _sx(), t1) + + +@pulse.kernel +def _b_cram(q0, q1): # asymmetric amp + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d1, _sx(), t1) + drive(d0, _cr(), t1) + drive(d0, _x(), t0) + drive(d0, gaussian(200, -0.08, 50.), t1) + drive(d1, _sx(), t1) + + +@pulse.kernel +def _b_crboth(q0, q1): # both wrong tone + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d1, _sx(), t1) + drive(d0, _cr(), t0) + drive(d0, _x(), t0) + drive(d0, _crn(), t0) + drive(d1, _sx(), t1) + + +@pulse.kernel +def _b_crrev(q0, q1): # reversed order + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d1, _sx(), t1) + drive(d0, _crn(), t1) + drive(d0, _x(), t0) + drive(d0, _cr(), t1) + drive(d1, _sx(), t1) + + +@pulse.kernel +def _b_crnsx(q0, q1): # no SX on target + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d0, _cr(), t1) + drive(d0, _x(), t0) + drive(d0, _crn(), t1) + + +@pulse.kernel +def _b_crself(q0, q1): # self-drive + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d0, _cr(), t0) + drive(d1, _cr(), t1) + + +@pulse.kernel +def _b_cr3q(q0, q1, q2): # 3-qubit mixup + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + d2, t2 = get_drive_line(q2) + sync(d0, d1, d2) + drive(d0, _cr(), t2) + drive(d1, _cr(), t0) + + +@pulse.kernel +def _b_crex(q0, q1): # extra spurious + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d1, _sx(), t1) + drive(d0, _cr(), t1) + drive(d1, gaussian(40, 0.01, 10.), t1) + drive(d0, _x(), t0) + drive(d0, _crn(), t1) + drive(d1, _sx(), t1) + + +@pulse.kernel +def _b_crsig(q0, q1): # sigma too small + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d0, gaussian(200, 0.10, 5.0), t1) + + +@pulse.kernel +def _b_crlong(q0, q1): # too long + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d0, gaussian(2000, 0.10, 500.), t1) + + +@pulse.kernel +def _b_crdur(q0, q1): # CR+/CR- duration mismatch + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + sync(d0, d1) + drive(d1, _sx(), t1) + drive(d0, _cr(), t1) + drive(d0, _x(), t0) + drive(d0, gaussian(160, -0.10, 40.), t1) + drive(d1, _sx(), t1) + + +CR = [ + _R("b_crt", lambda: _b_crt), + _R("b_crne", lambda: _b_crne), + _R("b_crsw", lambda: _b_crsw), + _R("b_crns", lambda: _b_crns), + _R("b_crde", lambda: _b_crde), + _R("b_cram", lambda: _b_cram), + _R("b_crboth", lambda: _b_crboth), + _R("b_crrev", lambda: _b_crrev), + _R("b_crnsx", lambda: _b_crnsx), + _R("b_crself", lambda: _b_crself), + _R("b_cr3q", lambda: _b_cr3q), + _R("b_crex", lambda: _b_crex), + _R("b_crsig", lambda: _b_crsig), + _R("b_crlong", lambda: _b_crlong), + _R("b_crdur", lambda: _b_crdur) +] + +# ═══ Full corpus ══════════════════════════════════════════════════════════ +ALL_CASES: list[BugCase] = CORRECT + OVERLAP + BTT + PHASE + CR +CORPUS_STATS = { + c: sum(1 for x in ALL_CASES if x.expected == c) for c in [ + "correct", "unintentional_overlap", "backward_time_travel", + "phase_bookkeeping", "cross_resonance_miscalibration" + ] +} +CORPUS_STATS["total"] = len(ALL_CASES) + +if __name__ == "__main__": + for c in ALL_CASES: + try: + c.build_fn() + s = "OK" + except Exception as e: + s = f"FAIL: {e}" + print(f"{c.name:18s} [{c.expected:40s}] {s}") + print(f"\n{CORPUS_STATS}") diff --git a/pulse/tests/workloads/w1_bell.py b/pulse/tests/workloads/w1_bell.py new file mode 100644 index 00000000000..1e9211922a2 --- /dev/null +++ b/pulse/tests/workloads/w1_bell.py @@ -0,0 +1,51 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""W1: Bell-state preparation. + +Two-qubit hello-world: H on q0 (via SX + virtual-Z decomposition), +CNOT via echo cross-resonance, readout of both. Provides the +smallest non-trivial schedule in the suite. +""" + +import math +import cudaq_pulse as pulse + +NAME = "bell" +NUM_QUBITS = 2 + + +@pulse.kernel +def build(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + r0, rt0 = get_readout_line(q0) + r1, rt1 = get_readout_line(q1) + + # Hadamard on q0 as Rz(pi/2) · SX · Rz(pi/2) + shift_phase(t0, math.pi / 2) + sx = drag(40, 0.25, 10.0, 0.5) + drive(d0, sx, t0) + shift_phase(t0, math.pi / 2) + + sync(d0, d1) + + # CNOT via echo cross-resonance + drive(d1, sx, t1) + cr = gaussian(200, 0.10, 50.0) + drive(d0, cr, t1) + x_ctrl = square(40, [0.047, 0.0]) + drive(d0, x_ctrl, t0) + cr_neg = gaussian(200, -0.10, 50.0) + drive(d0, cr_neg, t1) + drive(d1, sx, t1) + + sync(d0, d1, r0, r1) + + ro = square(1000, [0.05, 0.0]) + readout(r0, ro, rt0) + readout(r1, ro, rt1) diff --git a/pulse/tests/workloads/w2_cnot_cr.py b/pulse/tests/workloads/w2_cnot_cr.py new file mode 100644 index 00000000000..dbf7a63a19e --- /dev/null +++ b/pulse/tests/workloads/w2_cnot_cr.py @@ -0,0 +1,42 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""W2: CNOT via echo cross-resonance, standalone schedule. + +The hero program for Figure 1. A single schedule that can be executed +on any CR-compatible qubit pair by supplying the pair-specific tones +at call time -- no IR-level recompilation required. +""" + +import cudaq_pulse as pulse + +NAME = "cnot_cr" +NUM_QUBITS = 2 + + +@pulse.kernel +def build(q0, q1): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + + sync(d0, d1) + + sx = drag(40, 0.025, 10.0, 0.5) + cr = gaussian(200, 0.10, 50.0) + cr_neg = gaussian(200, -0.10, 50.0) + x_ctrl = square(40, [0.047, 0.0]) + + # Step 1: SX on target at target's tone + drive(d1, sx, t1) + # Step 2: CR drive -- control's line at target's tone + drive(d0, cr, t1) + # Step 3: X echo on control at control's tone + drive(d0, x_ctrl, t0) + # Step 4: negative-amplitude CR drive + drive(d0, cr_neg, t1) + # Step 5: SX on target closes the echo + drive(d1, sx, t1) diff --git a/pulse/tests/workloads/w3_qaoa4.py b/pulse/tests/workloads/w3_qaoa4.py new file mode 100644 index 00000000000..9ab419120b7 --- /dev/null +++ b/pulse/tests/workloads/w3_qaoa4.py @@ -0,0 +1,70 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""W3: 4-qubit QAOA single layer on a ring topology. + +Tests schedule density: 4 parallel Rz-diagonal rotations and 4 CR +gates on a ring, requiring explicit synchronization points. +""" + +import math +import cudaq_pulse as pulse + +NAME = "qaoa4" +NUM_QUBITS = 4 + + +def _cr_cnot(d_ctrl, d_tgt, t_ctrl, t_tgt): + """Inline a CR CNOT; same op sequence as w2_cnot_cr.""" + sx = drag(40, 0.025, 10.0, 0.5) + cr = gaussian(200, 0.10, 50.0) + cr_neg = gaussian(200, -0.10, 50.0) + x_c = square(40, [0.047, 0.0]) + + drive(d_tgt, sx, t_tgt) + drive(d_ctrl, cr, t_tgt) + drive(d_ctrl, x_c, t_ctrl) + drive(d_ctrl, cr_neg, t_tgt) + drive(d_tgt, sx, t_tgt) + + +def _cost_edge(d_ctrl, d_tgt, t_ctrl, t_tgt, gamma): + sync(d_ctrl, d_tgt) + _cr_cnot(d_ctrl, d_tgt, t_ctrl, t_tgt) + shift_phase(t_tgt, 2 * gamma) + _cr_cnot(d_ctrl, d_tgt, t_ctrl, t_tgt) + + +@pulse.kernel +def build(q0, q1, q2, q3): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + d2, t2 = get_drive_line(q2) + d3, t3 = get_drive_line(q3) + + lines = [d0, d1, d2, d3] + tones = [t0, t1, t2, t3] + + gamma = 0.3 + beta = 0.7 + + # Cost layer: ZZ rotation via CNOT-Rz(2*gamma)-CNOT on ring edges. + # Pulse kernels currently support static range loops; spell out this + # irregular edge list so the workload stays within that contract. + _cost_edge(d0, d1, t0, t1, gamma) + _cost_edge(d1, d2, t1, t2, gamma) + _cost_edge(d2, d3, t2, t3, gamma) + _cost_edge(d3, d0, t3, t0, gamma) + + # Mixer layer: Rx(2*beta) on every qubit via SX-Rz-SX + sx = drag(40, 0.25, 10.0, 0.5) + for q in range(4): + shift_phase(tones[q], math.pi / 2) + drive(lines[q], sx, tones[q]) + shift_phase(tones[q], 2 * beta) + drive(lines[q], sx, tones[q]) + shift_phase(tones[q], math.pi / 2) diff --git a/pulse/tests/workloads/w4_syndrome.py b/pulse/tests/workloads/w4_syndrome.py new file mode 100644 index 00000000000..bb51109359f --- /dev/null +++ b/pulse/tests/workloads/w4_syndrome.py @@ -0,0 +1,69 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""W4: Surface-code X-syndrome measurement cycle. + +One ancilla + four data qubits, sequential CNOT ladder, mid-circuit +measurement of ancilla. Tests pulse.sync across 5 lines, explicit +readout, and the stability of schedule metrics under sync resolution. +""" + +import math +import cudaq_pulse as pulse + +NAME = "syndrome" +NUM_QUBITS = 5 + + +def _cr_cnot(d_ctrl, d_tgt, t_ctrl, t_tgt): + sx = drag(40, 0.025, 10.0, 0.5) + cr = gaussian(200, 0.10, 50.0) + cr_neg = gaussian(200, -0.10, 50.0) + x_c = square(40, [0.047, 0.0]) + + drive(d_tgt, sx, t_tgt) + drive(d_ctrl, cr, t_tgt) + drive(d_ctrl, x_c, t_ctrl) + drive(d_ctrl, cr_neg, t_tgt) + drive(d_tgt, sx, t_tgt) + + +@pulse.kernel +def build(q_anc, q_d0, q_d1, q_d2, q_d3): + # Ancilla = q_anc, data = q_d0..q_d3 + d_anc, t_anc = get_drive_line(q_anc) + d0, t0 = get_drive_line(q_d0) + d1, t1 = get_drive_line(q_d1) + d2, t2 = get_drive_line(q_d2) + d3, t3 = get_drive_line(q_d3) + r_anc, rt_anc = get_readout_line(q_anc) + + data_lines = [d0, d1, d2, d3] + data_tones = [t0, t1, t2, t3] + + # Initialise ancilla in |+>: Hadamard via Rz(pi/2)-SX-Rz(pi/2) + sx = drag(40, 0.25, 10.0, 0.5) + shift_phase(t_anc, math.pi / 2) + drive(d_anc, sx, t_anc) + shift_phase(t_anc, math.pi / 2) + + # CNOT ladder: ancilla -> each data qubit + for i in range(4): + sync(d_anc, data_lines[i]) + _cr_cnot(d_anc, data_lines[i], t_anc, data_tones[i]) + + # Close with H on ancilla + shift_phase(t_anc, math.pi / 2) + drive(d_anc, sx, t_anc) + shift_phase(t_anc, math.pi / 2) + + # Sync ancilla drive line with readout line before measuring + sync(d_anc, r_anc) + + # Mid-circuit measurement of ancilla + ro = square(1000, [0.05, 0.0]) + readout(r_anc, ro, rt_anc) diff --git a/pulse/tests/workloads/w5_dd_cpmg8.py b/pulse/tests/workloads/w5_dd_cpmg8.py new file mode 100644 index 00000000000..c8e69aa907f --- /dev/null +++ b/pulse/tests/workloads/w5_dd_cpmg8.py @@ -0,0 +1,29 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""W5: Dynamical-decoupling CPMG-8 chain. + +Single qubit, 8 X gates with equal waits between them. Tests the +scheduler on a long idle-wait-idle schedule, and tests waveform reuse +(the same X waveform plays 8 times). The for-loop is captured as +scf.for in the IR. +""" + +import cudaq_pulse as pulse + +NAME = "dd_cpmg8" +NUM_QUBITS = 1 + + +@pulse.kernel +def build(q0): + d0, t0 = get_drive_line(q0) + x = square(40, [0.047, 0.0]) + wait(d0, 100) + for i in range(8): + drive(d0, x, t0) + wait(d0, 200) diff --git a/pulse/tests/workloads/w6_vqe_hea.py b/pulse/tests/workloads/w6_vqe_hea.py new file mode 100644 index 00000000000..1f1774cce94 --- /dev/null +++ b/pulse/tests/workloads/w6_vqe_hea.py @@ -0,0 +1,62 @@ +# ============================================================================ # +# Copyright (c) 2026 NVIDIA Corporation & Affiliates. # +# All rights reserved. # +# # +# This source code and the accompanying materials are made available under # +# the terms of the Apache License 2.0 which accompanies this distribution. # +# ============================================================================ # +"""W6: VQE hardware-efficient ansatz, single layer, 4 qubits. + +Alternating Ry rotations (per-qubit) and linear-chain CNOTs. The +Ry rotations use different parameters per qubit to produce a realistic +schedule (not degenerate across qubits). +""" + +import math +import cudaq_pulse as pulse + +NAME = "vqe_hea" +NUM_QUBITS = 4 + + +def _cr_cnot(d_ctrl, d_tgt, t_ctrl, t_tgt): + sx = drag(40, 0.025, 10.0, 0.5) + cr = gaussian(200, 0.10, 50.0) + cr_neg = gaussian(200, -0.10, 50.0) + x_c = square(40, [0.047, 0.0]) + + drive(d_tgt, sx, t_tgt) + drive(d_ctrl, cr, t_tgt) + drive(d_ctrl, x_c, t_ctrl) + drive(d_ctrl, cr_neg, t_tgt) + drive(d_tgt, sx, t_tgt) + + +@pulse.kernel +def build(q0, q1, q2, q3): + d0, t0 = get_drive_line(q0) + d1, t1 = get_drive_line(q1) + d2, t2 = get_drive_line(q2) + d3, t3 = get_drive_line(q3) + + lines = [d0, d1, d2, d3] + tones = [t0, t1, t2, t3] + thetas = [0.3, 0.5, 0.7, 0.9] + + # Ry(theta_q) = Rz(-pi/2) · SX · Rz(theta) · SX · Rz(pi/2) + sx = drag(40, 0.25, 10.0, 0.5) + for q in range(4): + shift_phase(tones[q], -math.pi / 2) + drive(lines[q], sx, tones[q]) + shift_phase(tones[q], thetas[q]) + drive(lines[q], sx, tones[q]) + shift_phase(tones[q], math.pi / 2) + + # Linear-chain CNOTs: 0->1, 1->2, 2->3. Pulse kernels currently support + # static range loops, so keep this irregular edge list explicit. + sync(d0, d1) + _cr_cnot(d0, d1, t0, t1) + sync(d1, d2) + _cr_cnot(d1, d2, t1, t2) + sync(d2, d3) + _cr_cnot(d2, d3, t2, t3)