You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mahout.apache.org by hc...@apache.org on 2026/04/25 18:33:42 UTC

(mahout) branch main updated: [QDP] Add AMD backend selection to QDP encoding benchmarks (#1289)

This is an automated email from the ASF dual-hosted git repository.

ryankert01 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/mahout.git


The following commit(s) were added to refs/heads/main by this push:
     new 5e5d20ff2 [QDP] Add AMD backend selection to QDP encoding benchmarks (#1289)
5e5d20ff2 is described below

commit 5e5d20ff2d0a13b9ec349e2631a03f7f03f52b4b
Author: Jie-Kai Chang <ji...@apache.org>
AuthorDate: Sun Apr 26 02:33:37 2026 +0800

    [QDP] Add AMD backend selection to QDP encoding benchmarks (#1289)
    
    * Add AMD backend selection to QDP encoding benchmarks
    
    Signed-off-by: 400Ping <ji...@apache.org>
    
    * Fix AMD parity gaps surfaced by --qdp-backend amd
    
    * TritonAmdEngine.encode_amplitude now zero-pads when sample_size <
      2**num_qubits, matching the CUDA Rust path's contract; oversize input
      remains a hard error. Without this, the SVHN-kernel benchmark
      (3072-D vectors -> 12-qubit / 4096-D state) crashed on the AMD route.
    
    * qdp_pipeline/mnist_amplitude.py falls back from lightning.gpu to
      default.qubit when lightning.gpu is unavailable (CUDA-only), mirroring
      the iris pattern. CUDA hosts are unaffected; AMD hosts now run
      end-to-end with PennyLane training on CPU.
    
    * Adds two pad-parity tests for the AMD amplitude encoder.
    
    * README note that MNIST AMD training currently runs on CPU because
      lightning.gpu is CUDA-only.
    
    Verified on AMD Instinct MI300X (ROCm 7.2 / torch 2.9.0+rocm6.4):
      iris  --qdp-backend amd                 best test acc 0.40 (smoke)
      mnist --qdp-backend amd  (n=200, 3v6)   best test acc 0.98
      svhn-kernel --qdp-backend amd (n=500)   CV acc 0.7981 +/- 0.0294
    
    Tests: 13 passed, 2 skipped (NVIDIA-CUDA-only refs).
    
    ---------
    
    Signed-off-by: 400Ping <ji...@apache.org>
    Co-authored-by: Ryan Huang <hc...@apache.org>
---
 .../benchmark/encoding_benchmarks/README.md        | 57 ++++++++++++++++++++--
 .../qdp_pipeline/iris_amplitude.py                 | 16 ++++--
 .../qdp_pipeline/mnist_amplitude.py                | 32 +++++++++---
 .../qdp_pipeline/svhn_kernel_amplitude.py          | 25 +++++++---
 qdp/qdp-python/qumat_qdp/triton_amd.py             | 11 +++--
 qdp/qdp-python/tests/test_triton_amd_backend.py    | 29 +++++++++++
 6 files changed, 145 insertions(+), 25 deletions(-)

diff --git a/qdp/qdp-python/benchmark/encoding_benchmarks/README.md b/qdp/qdp-python/benchmark/encoding_benchmarks/README.md
index 2726ce78d..6ce913096 100644
--- a/qdp/qdp-python/benchmark/encoding_benchmarks/README.md
+++ b/qdp/qdp-python/benchmark/encoding_benchmarks/README.md
@@ -4,7 +4,7 @@ This directory is used to **compare a pure PennyLane baseline with a QDP pipelin
 Both scripts use the same dataset and the same variational model; **only the encoding step is different**.
 
 - **`pennylane_baseline/`**: pure PennyLane (sklearn / official Iris file → encoding → variational classifier).
-- **`qdp_pipeline/`**: same data and model, but encoding is done via the QDP `QuantumDataLoader` (amplitude).
+- **`qdp_pipeline/`**: same data and model, but encoding is done via QDP direct state preparation.
 
 Run all commands from the `qdp-python` directory:
 
@@ -15,11 +15,14 @@ cd qdp/qdp-python
 ## Environment (one-time setup)
 
 ```bash
-uv sync --group benchmark        # install PennyLane, torch(+CUDA), scikit-learn, etc.
+uv sync --group benchmark        # install PennyLane, torch, scikit-learn, etc.
 uv run maturin develop           # build QDP Python extension (qumat_qdp)
 ```
 
-If your CUDA driver is not 12.6, adjust the PyTorch index URL in `pyproject.toml` according to the comments there.
+Install the GPU runtime for your platform first. CUDA users need a CUDA-compatible
+PyTorch stack; AMD users need a ROCm-compatible PyTorch/Triton stack. If your
+CUDA driver is not 12.6, adjust the PyTorch index URL in `pyproject.toml`
+according to the comments there.
 
 ## Iris amplitude baseline (pure PennyLane)
 
@@ -49,7 +52,7 @@ uv run python benchmark/encoding_benchmarks/pennylane_baseline/iris_amplitude.py
 ## Iris amplitude (QDP pipeline)
 
 Pipeline is identical to the baseline except for encoding:
-4-D vectors → QDP `QuantumDataLoader` (amplitude) → `StatePrep(state_vector)` → same variational classifier.
+4-D vectors → QDP `QdpEngine.encode` (amplitude) → `StatePrep(state_vector)` → same variational classifier.
 
 ```bash
 uv run python benchmark/encoding_benchmarks/qdp_pipeline/iris_amplitude.py
@@ -59,6 +62,7 @@ The CLI mirrors the baseline, plus:
 
 - **QDP-specific flags**
   - `--device-id`: QDP device id (default: 0)
+  - `--qdp-backend`: QDP backend, `cuda` or `amd` (default: `cuda`)
   - `--data-dir`: directory for temporary `.npy` files (default: system temp directory)
 
 Example (same settings as the baseline example, but with QDP encoding):
@@ -69,6 +73,15 @@ uv run python benchmark/encoding_benchmarks/qdp_pipeline/iris_amplitude.py \
   --optimizer nesterov --lr 0.01 --layers 6 --trials 3 --iters 80 --early-stop 0
 ```
 
+Example using the AMD backend:
+
+```bash
+uv run python benchmark/encoding_benchmarks/qdp_pipeline/iris_amplitude.py \
+  --data-file benchmark/encoding_benchmarks/pennylane_baseline/data/iris_classes1and2_scaled.txt \
+  --optimizer nesterov --lr 0.01 --layers 6 --trials 3 --iters 80 --early-stop 0 \
+  --qdp-backend amd
+```
+
 ## MNIST amplitude baseline (pure PennyLane)
 
 Pipeline: 2-class MNIST (default: digits 3 vs 6) → PCA (784 → 2^qubits) → L2 normalize → `AmplitudeEmbedding` → variational classifier.
@@ -108,6 +121,7 @@ The CLI mirrors the baseline, plus:
 
 - **QDP-specific flags**
   - `--device-id`: QDP device id (default: 0)
+  - `--qdp-backend`: QDP backend, `cuda` or `amd` (default: `cuda`)
   - `--data-dir`: directory for temporary `.npy` files (default: system temp directory)
 
 Example (same settings as the baseline example, but with QDP encoding):
@@ -117,6 +131,39 @@ uv run python benchmark/encoding_benchmarks/qdp_pipeline/mnist_amplitude.py \
   --digits "3,6" --n-samples 100 --trials 3 --iters 500 --early-stop 0
 ```
 
+Example using the AMD backend:
+
+```bash
+uv run python benchmark/encoding_benchmarks/qdp_pipeline/mnist_amplitude.py \
+  --digits "3,6" --n-samples 100 --trials 3 --iters 500 --early-stop 0 \
+  --qdp-backend amd
+```
+
+> **Note:** With `--qdp-backend amd`, encoding runs on the AMD GPU but the
+> variational training falls back to PennyLane's `default.qubit` simulator on
+> CPU because `lightning.gpu` is CUDA-only. Expect training to be much slower
+> than the CUDA path.
+
+## SVHN kernel amplitude (QDP pipeline)
+
+Pipeline: SVHN digit 1 vs 7 → standardize → QDP `QdpEngine.encode` (amplitude, 12 qubits) → precomputed squared inner-product quantum kernel → sklearn SVM.
+
+```bash
+uv run python benchmark/encoding_benchmarks/qdp_pipeline/svhn_kernel_amplitude.py
+```
+
+QDP-specific flags:
+
+- `--device-id`: QDP device id (default: 0)
+- `--qdp-backend`: QDP backend, `cuda` or `amd` (default: `cuda`)
+
+Example using the AMD backend:
+
+```bash
+uv run python benchmark/encoding_benchmarks/qdp_pipeline/svhn_kernel_amplitude.py \
+  --n-samples 500 --folds 3 --qdp-backend amd
+```
+
 ## SVHN IQP baseline (pure PennyLane)
 
 Pipeline: 2-class SVHN (digit 1 vs 7) → PCA (3072 → n_qubits) → custom IQP circuit (H^n · Diag · H^n) → variational classifier.
@@ -157,6 +204,7 @@ uv run python benchmark/encoding_benchmarks/qdp_pipeline/svhn_iqp.py
 The CLI mirrors the baseline, plus:
 
 > **Note:** The QDP pipeline always performs the encoding step on a CUDA GPU via QDP. A CUDA-capable device is required even when you select `--backend cpu` for the training backend.
+> AMD is not supported for this benchmark yet because the AMD backend currently supports `amplitude`, `angle`, and `basis`, but not `iqp`.
 
 - **QDP-specific flags**
   - `--device-id`: CUDA device id (default: 0)
@@ -182,5 +230,6 @@ uv run python benchmark/encoding_benchmarks/pennylane_baseline/mnist_amplitude.p
 uv run python benchmark/encoding_benchmarks/pennylane_baseline/svhn_iqp.py --help
 uv run python benchmark/encoding_benchmarks/qdp_pipeline/iris_amplitude.py --help
 uv run python benchmark/encoding_benchmarks/qdp_pipeline/mnist_amplitude.py --help
+uv run python benchmark/encoding_benchmarks/qdp_pipeline/svhn_kernel_amplitude.py --help
 uv run python benchmark/encoding_benchmarks/qdp_pipeline/svhn_iqp.py --help
 ```
diff --git a/qdp/qdp-python/benchmark/encoding_benchmarks/qdp_pipeline/iris_amplitude.py b/qdp/qdp-python/benchmark/encoding_benchmarks/qdp_pipeline/iris_amplitude.py
index 049bf6c52..7b0132343 100644
--- a/qdp/qdp-python/benchmark/encoding_benchmarks/qdp_pipeline/iris_amplitude.py
+++ b/qdp/qdp-python/benchmark/encoding_benchmarks/qdp_pipeline/iris_amplitude.py
@@ -114,20 +114,21 @@ def encode_via_qdp(
     X_norm: np.ndarray,
     batch_size: int,  # kept for CLI symmetry; not used here
     device_id: int = 0,
+    qdp_backend: str = "cuda",
     data_dir: str | None = None,
     filename: str = "iris_4d.npy",
 ) -> torch.Tensor:
     """QDP: use QdpEngine.encode on 4-D vectors (amplitude), return encoded (n, 4) on GPU.
 
     Uses in-memory encoding via QdpEngine instead of writing/reading .npy files. The returned
-    tensor stays on the selected CUDA device and can be fed directly to qml.StatePrep.
+    tensor stays on the selected GPU device and can be fed directly to qml.StatePrep.
     """
     n, dim = X_norm.shape
     if dim != STATE_DIM:
         raise ValueError(
             f"X_norm must have {STATE_DIM} features for 2 qubits, got {dim}"
         )
-    engine = QdpEngine(device_id=device_id, precision="float32")
+    engine = QdpEngine(device_id=device_id, precision="float32", backend=qdp_backend)
     qt = engine.encode(
         X_norm.astype(np.float64),
         num_qubits=NUM_QUBITS,
@@ -447,6 +448,12 @@ def main() -> None:
     parser.add_argument(
         "--device-id", type=int, default=0, help="QDP device (default: 0)"
     )
+    parser.add_argument(
+        "--qdp-backend",
+        choices=("cuda", "amd"),
+        default="cuda",
+        help="QDP backend for direct state preparation (default: cuda)",
+    )
     parser.add_argument(
         "--data-dir", type=str, default=None, help="Dir for .npy files (default: temp)"
     )
@@ -476,6 +483,7 @@ def main() -> None:
         X_train_4d,
         batch_size=args.batch_size,
         device_id=args.device_id,
+        qdp_backend=args.qdp_backend,
         data_dir=args.data_dir,
         filename="iris_4d_train.npy",
     )
@@ -483,6 +491,7 @@ def main() -> None:
         X_test_4d,
         batch_size=args.batch_size,
         device_id=args.device_id,
+        qdp_backend=args.qdp_backend,
         data_dir=args.data_dir,
         filename="iris_4d_test.npy",
     )
@@ -490,7 +499,8 @@ def main() -> None:
     print("Iris amplitude (QDP encoding) — 2-class variational classifier")
     print(f"  Data: {data_src} → QDP amplitude  (n={n}; 2-class Iris = 100 samples)")
     print(
-        f"  Iters: {args.iters}, batch_size: {args.batch_size}, layers: {args.layers}, lr: {args.lr}, optimizer: {args.optimizer}"
+        f"  Iters: {args.iters}, batch_size: {args.batch_size}, layers: {args.layers}, "
+        f"lr: {args.lr}, optimizer: {args.optimizer}, qdp_backend: {args.qdp_backend}"
     )
 
     results: list[dict[str, Any]] = []
diff --git a/qdp/qdp-python/benchmark/encoding_benchmarks/qdp_pipeline/mnist_amplitude.py b/qdp/qdp-python/benchmark/encoding_benchmarks/qdp_pipeline/mnist_amplitude.py
index 21266e702..6d206f2fd 100644
--- a/qdp/qdp-python/benchmark/encoding_benchmarks/qdp_pipeline/mnist_amplitude.py
+++ b/qdp/qdp-python/benchmark/encoding_benchmarks/qdp_pipeline/mnist_amplitude.py
@@ -131,13 +131,14 @@ def encode_via_qdp(
     num_qubits: int,
     batch_size: int = 10,  # kept for CLI symmetry; not used here
     device_id: int = 0,
+    qdp_backend: str = "cuda",
     data_dir: str | None = None,
     filename: str = "mnist_nd.npy",
 ) -> torch.Tensor:
     """QDP: use QdpEngine.encode on PCA-reduced vectors (amplitude), return encoded tensor on GPU.
 
     Uses in-memory encoding via QdpEngine instead of writing/reading .npy files. The returned
-    tensor stays on the selected CUDA device and can be fed directly to qml.StatePrep.
+    tensor stays on the selected GPU device and can be fed directly to qml.StatePrep.
     """
     n, dim = X_norm.shape
     state_dim = 2**num_qubits
@@ -145,7 +146,7 @@ def encode_via_qdp(
         raise ValueError(
             f"X_norm must have {state_dim} features for {num_qubits} qubits, got {dim}"
         )
-    engine = QdpEngine(device_id=device_id, precision="float32")
+    engine = QdpEngine(device_id=device_id, precision="float32", backend=qdp_backend)
     qt = engine.encode(
         X_norm.astype(np.float64),
         num_qubits=num_qubits,
@@ -171,12 +172,21 @@ def run_training(
     early_stop_target: float | None = None,
 ) -> dict[str, Any]:
     """Train variational classifier: StatePrep(encoded) + Rot layers + bias, square loss, batched.
-    Uses lightning.gpu + torch interface + adjoint diff. Data stays on GPU.
-    Optional early stop when test acc >= target."""
+    Prefers lightning.gpu (CUDA-only); falls back to default.qubit on CPU when unavailable
+    (e.g. AMD/ROCm host). Optional early stop when test acc >= target."""
     n_train = len(Y_train)
     rng = np.random.default_rng(seed)
 
     wires = tuple(range(num_qubits))
+    qml_device_name = "cuda"
+    try:
+        dev_qml = qml.device("lightning.gpu", wires=num_qubits)
+    except Exception:
+        dev_qml = qml.device("default.qubit", wires=num_qubits)
+        qml_device_name = "cpu"
+        encoded_train = encoded_train.cpu()
+        encoded_test = encoded_test.cpu()
+
     device = encoded_train.device
     # Encoded data may be complex (from QDP); use real dtype for weights and labels.
     real_dtype = (
@@ -185,8 +195,6 @@ def run_training(
     Y_train_t = torch.tensor(Y_train, dtype=real_dtype, device=device)
     Y_test_t = torch.tensor(Y_test, dtype=real_dtype, device=device)
 
-    dev_qml = qml.device("lightning.gpu", wires=num_qubits)
-
     @qml.qnode(dev_qml, interface="torch", diff_method="adjoint")
     def circuit(weights, state_vector):
         qml.StatePrep(state_vector, wires=wires)
@@ -262,7 +270,7 @@ def run_training(
         "samples_per_sec": (steps_done * batch_size) / train_sec
         if train_sec > 0
         else 0.0,
-        "qml_device": "cuda",
+        "qml_device": qml_device_name,
     }
 
 
@@ -325,6 +333,12 @@ def main() -> None:
     parser.add_argument(
         "--device-id", type=int, default=0, help="QDP device (default: 0)"
     )
+    parser.add_argument(
+        "--qdp-backend",
+        choices=("cuda", "amd"),
+        default="cuda",
+        help="QDP backend for direct state preparation (default: cuda)",
+    )
     parser.add_argument(
         "--data-dir", type=str, default=None, help="Dir for .npy files (default: temp)"
     )
@@ -356,6 +370,7 @@ def main() -> None:
         num_qubits=args.qubits,
         batch_size=args.batch_size,
         device_id=args.device_id,
+        qdp_backend=args.qdp_backend,
         data_dir=args.data_dir,
         filename="mnist_nd_train.npy",
     )
@@ -364,6 +379,7 @@ def main() -> None:
         num_qubits=args.qubits,
         batch_size=args.batch_size,
         device_id=args.device_id,
+        qdp_backend=args.qdp_backend,
         data_dir=args.data_dir,
         filename="mnist_nd_test.npy",
     )
@@ -376,7 +392,7 @@ def main() -> None:
     )
     print(
         f"  Qubits: {args.qubits}, iters: {args.iters}, batch_size: {args.batch_size}, "
-        f"layers: {args.layers}, lr: {args.lr}"
+        f"layers: {args.layers}, lr: {args.lr}, qdp_backend: {args.qdp_backend}"
     )
     print(
         f"  QDP encode:  {encode_sec:.4f} s  (train + test, {n_train} + {n - n_train} samples)"
diff --git a/qdp/qdp-python/benchmark/encoding_benchmarks/qdp_pipeline/svhn_kernel_amplitude.py b/qdp/qdp-python/benchmark/encoding_benchmarks/qdp_pipeline/svhn_kernel_amplitude.py
index dfd80feae..9deb1bb65 100644
--- a/qdp/qdp-python/benchmark/encoding_benchmarks/qdp_pipeline/svhn_kernel_amplitude.py
+++ b/qdp/qdp-python/benchmark/encoding_benchmarks/qdp_pipeline/svhn_kernel_amplitude.py
@@ -22,7 +22,7 @@ Pipeline:
   SVHN (32×32×3) → Flatten (3072) → QdpEngine.encode(amplitude) on GPU (4096, 12 qubits)
     → Quantum Kernel K[i,j] = (encoded[i] · encoded[j])² → sklearn SVM
 
-Encoding: QdpEngine (GPU) — data stays on CUDA for kernel matmul, then moves to CPU for SVM.
+Encoding: QdpEngine (GPU) — data stays on GPU for kernel matmul, then moves to CPU for SVM.
 Kernel:   Precomputed squared inner product (GPU torch.mm).
 Classifier: sklearn.svm.SVC(kernel='precomputed').
 
@@ -124,9 +124,11 @@ def _filter_binary(X, Y):
     return X[mask], np.where(Y[mask] == CLASS_POS, 1, -1)
 
 
-def encode_qdp(X: np.ndarray, device_id: int = 0) -> torch.Tensor:
-    """QdpEngine amplitude encode → CUDA float64 tensor (n, 4096)."""
-    engine = QdpEngine(device_id=device_id, precision="float64")
+def encode_qdp(
+    X: np.ndarray, device_id: int = 0, qdp_backend: str = "cuda"
+) -> torch.Tensor:
+    """QdpEngine amplitude encode → GPU float64 tensor (n, 4096)."""
+    engine = QdpEngine(device_id=device_id, precision="float64", backend=qdp_backend)
     qt = engine.encode(
         X.astype(np.float64),
         num_qubits=NUM_QUBITS,
@@ -171,7 +173,13 @@ def main() -> None:
         help="SVM regularisation C (default: 100.0)",
     )
     parser.add_argument(
-        "--device-id", type=int, default=0, help="CUDA device (default: 0)"
+        "--device-id", type=int, default=0, help="QDP device (default: 0)"
+    )
+    parser.add_argument(
+        "--qdp-backend",
+        choices=("cuda", "amd"),
+        default="cuda",
+        help="QDP backend for direct state preparation (default: cuda)",
     )
     parser.add_argument("--data-home", type=str, default=None, help="Data cache dir")
     args = parser.parse_args()
@@ -181,7 +189,10 @@ def main() -> None:
         f"  {NUM_QUBITS} qubits, {STATE_DIM}-dim state, binary: digit {CLASS_POS} vs {CLASS_NEG}"
     )
     print(f"  n_samples={args.n_samples}, {args.folds}-fold CV, C={args.svm_c}")
-    print(f"  CUDA: {torch.cuda.is_available()}, device_id: {args.device_id}")
+    print(
+        f"  GPU available: {torch.cuda.is_available()}, device_id: {args.device_id}, "
+        f"qdp_backend: {args.qdp_backend}"
+    )
     print()
 
     # Load & filter
@@ -205,7 +216,7 @@ def main() -> None:
     t0 = time.perf_counter()
     scaler = StandardScaler().fit(X_bin)
     X_scaled = scaler.transform(X_bin)
-    X_encoded = encode_qdp(X_scaled, args.device_id)
+    X_encoded = encode_qdp(X_scaled, args.device_id, args.qdp_backend)
     torch.cuda.synchronize()
     encode_sec = time.perf_counter() - t0
     print(
diff --git a/qdp/qdp-python/qumat_qdp/triton_amd.py b/qdp/qdp-python/qumat_qdp/triton_amd.py
index a4453b427..1a531c4e7 100644
--- a/qdp/qdp-python/qumat_qdp/triton_amd.py
+++ b/qdp/qdp-python/qumat_qdp/triton_amd.py
@@ -115,15 +115,20 @@ class TritonAmdEngine:
     def encode_amplitude(self, data: Any, num_qubits: int) -> Any:
         torch_mod = self._require_torch()
         x = self._to_2d(data, dtype=self._real_dtype())
-        _, sample_size = x.shape
+        batch, sample_size = x.shape
         state_len = 1 << num_qubits
-        if sample_size != state_len:
+        if sample_size > state_len:
             raise ValueError(
-                f"Amplitude encoding expects sample size {state_len} (=2^num_qubits), got {sample_size}."
+                f"Amplitude encoding expects sample size <= {state_len} (=2^num_qubits), got {sample_size}."
             )
 
         norms = torch_mod.linalg.vector_norm(x, dim=1, keepdim=True).clamp_min(1e-12)
         amp = x / norms
+        if sample_size < state_len:
+            pad = torch_mod.zeros(
+                (batch, state_len - sample_size), device=amp.device, dtype=amp.dtype
+            )
+            amp = torch_mod.cat([amp, pad], dim=1)
         return torch_mod.complex(amp, torch_mod.zeros_like(amp)).to(
             self._complex_dtype()
         )
diff --git a/qdp/qdp-python/tests/test_triton_amd_backend.py b/qdp/qdp-python/tests/test_triton_amd_backend.py
index 74730999b..1263f65e9 100644
--- a/qdp/qdp-python/tests/test_triton_amd_backend.py
+++ b/qdp/qdp-python/tests/test_triton_amd_backend.py
@@ -77,6 +77,35 @@ def test_triton_amd_amplitude_parity() -> None:
     assert torch.allclose(got, ref, atol=1e-5, rtol=1e-5)
 
 
+@pytest.mark.skipif(
+    not is_triton_amd_available(), reason="Triton AMD backend unavailable"
+)
+@pytest.mark.rocm
+def test_triton_amd_amplitude_pad_parity() -> None:
+    """Sample sizes < 2**num_qubits zero-pad to the state length, matching the CUDA path."""
+    engine = TritonAmdEngine(device_id=0, precision="float32")
+    x = torch.randn(4, 6, device="cuda", dtype=torch.float32)
+    got = _as_torch(engine.encode(x, 3, "amplitude"))
+    assert got.shape == (4, 8)
+    assert got.dtype == torch.complex64
+    norms = torch.linalg.vector_norm(x, dim=1, keepdim=True).clamp_min(1e-12)
+    expected_real = torch.cat([x / norms, torch.zeros((4, 2), device=x.device)], dim=1)
+    assert torch.allclose(got.real, expected_real, atol=1e-5, rtol=1e-5)
+    assert torch.allclose(got.imag, torch.zeros_like(got.imag), atol=0.0)
+
+
+@pytest.mark.skipif(
+    not is_triton_amd_available(), reason="Triton AMD backend unavailable"
+)
+@pytest.mark.rocm
+def test_triton_amd_amplitude_oversize_rejected() -> None:
+    """Sample sizes > 2**num_qubits remain a hard error (matches CUDA contract)."""
+    engine = TritonAmdEngine(device_id=0, precision="float32")
+    x = torch.randn(2, 9, device="cuda", dtype=torch.float32)
+    with pytest.raises(ValueError, match="<= 8"):
+        engine.encode(x, 3, "amplitude")
+
+
 @pytest.mark.skipif(
     not is_triton_amd_available(), reason="Triton AMD backend unavailable"
 )