--- name: ort-transformers-gpu-pytest description: Run the ONNX Runtime transformers Python tests (onnxruntime/test/python/transformers) against a GPU wheel, and confirm real cuDNN/flash SDPA dispatch. Use when a transformers pytest fails with ModuleNotFoundError onnxruntime.capi, when torch's bundled CUDA/cuDNN libs shadow the ORT-built ones (wrong-version dispatch or load errors), or when you must PROVE a test exercised the cuDNN SDPA decode tier instead of silently skipping/falling back to MATH. --- # Running ONNX Runtime transformers GPU pytest (dispatch-verified) Reusable, hard-won knowledge for running the Python transformers tests under `onnxruntime/test/python/transformers/` against a **GPU-built wheel**, on a box where `torch` is also installed. Three gotchas below will each cost you an hour if rediscovered. See the `ort-test` skill for the general test taxonomy and the false-green modes; this skill is the GPU + transformers + SDPA-dispatch specialization. ## 1. NEUTRAL-CWD gotcha — never run pytest from the repo root **Symptom:** ``` ModuleNotFoundError: No module named 'onnxruntime.capi' # or an AttributeError deep inside onnxruntime import ``` **Cause:** the repo root contains a source directory `./onnxruntime/` (the C++/Python source tree). When pytest runs with the repo root on `sys.path[0]`, `import onnxruntime` resolves to that **source** package — which has no compiled `capi` extension — instead of the **installed wheel** in your venv's `site-packages`. The source dir *shadows* the wheel. **Fix:** run pytest from a **neutral, private working directory** — a fresh `mktemp -d`, not the repo root and not a bare shared `/tmp` — so the shadowing source dir is not on the path, and point at the test file by absolute path. Put the transformers test-helper dir on `PYTHONPATH` so shared helpers still import. ```bash WORKDIR=$(mktemp -d); cd "$WORKDIR" # NEUTRAL + PRIVATE cwd — NOT repo root, NOT /tmp export PYTHONPATH=/abs/repo/onnxruntime/test/python/transformers python -m pytest /abs/repo/onnxruntime/test/python/transformers/.py -v ``` **Why `mktemp -d` and not a bare `cd /tmp`:** pytest prepends the cwd to `sys.path`, and Python auto-imports `sitecustomize.py`/`usercustomize.py` from it at startup — so on a shared box a co-tenant's planted `/tmp/sitecustomize.py` would execute as arbitrary code in your test process. A fresh per-run `mktemp -d` (private, `0700`) keeps the neutral-cwd source-shadow protection while removing that injection vector. Do **not** `cd` into the repo and run `pytest onnxruntime/test/...` — that reintroduces the shadowing. (This is the Python analogue of the C++ "run from the build output dir" rule in `ort-test`.) ## 2. LD_PRELOAD lib-pinning gotcha — torch's CUDA/cuDNN shadow ORT's **Symptom:** any of — - the SDPA decode tier silently routes to `MATH` instead of `CUDNN_FLASH_ATTENTION` (wrong-version cuDNN loaded), or - `libcudnn.so.9: cannot open shared object file` / `undefined symbol` / cuDNN version mismatch errors at first CUDA op, or - ORT loads a different CUDA runtime than it was built against. **Cause:** a pip-installed `torch` ships its **own** bundled CUDA runtime + cuDNN (e.g. cu124 → CUDA 12.4 / cuDNN 9.1) under `site-packages/nvidia/*/lib`. If ORT was built against a **different** CUDA/cuDNN (e.g. CUDA 12.9 / cuDNN 9.8), whichever set the dynamic loader resolves **first** wins. With torch imported (or its libs on the path), torch's older libs can shadow the ones ORT `dlopen`s → wrong-version dispatch or load failure. **Fix:** `LD_PRELOAD` the **system** CUDA runtime + cuDNN that ORT was built against so they are loaded first, and add their dirs to `LD_LIBRARY_PATH`. Activate the venv that has the ORT wheel. Concrete form used successfully (CUDA 12.9 + cuDNN 9.8; substitute your absolute lib paths): ```bash WORKDIR=$(mktemp -d); cd "$WORKDIR" # neutral + private (see §1) source /abs/repo/.venv/bin/activate export LD_PRELOAD=/abs/cuda12.9/lib64/libcudart.so.12:/abs/cudnn9.8/lib/libcudnn.so.9 export LD_LIBRARY_PATH=/abs/cuda12.9/lib64:/abs/cudnn9.8/lib export PYTHONPATH=/abs/repo/onnxruntime/test/python/transformers python -m pytest /abs/repo/onnxruntime/test/python/transformers/.py -v ``` Notes: - `libcudart.so.12` is correct for **both** CUDA 12.4 and 12.9 (SONAME is major-only) — pinning the 12.9 file forces the right minor. - `torch.cuda` still works fine under this preload — the bf16 IO-binding path that uses `torch` tensors + `.data_ptr()` runs correctly. - Keep the two exports and the preload together; dropping `LD_LIBRARY_PATH` can still let a transitive dependency resolve against torch's copy. ## 3. Confirm REAL cuDNN SDPA dispatch (don't trust value-equality) A numerically-correct result does **not** prove the cuDNN SDPA path ran — the kernel has a `MATH` fallback that produces the same answer (false-green mode 4 in `ort-test`). To prove the tier dispatched, **observe** ORT's routing rather than probing a version. ### Observe-dispatch (the correct probe) ORT's ONNX-domain Attention kernel emits a debug line when `ORT_ENABLE_ATTENTION_KERNEL_DEBUG_INFO=1` is set **before** the `InferenceSession` is created (the option is read once at session creation). `AttentionKernelDebugInfo::Print` emits a token of the form: ``` SdpaKernel=CUDNN_FLASH_ATTENTION # or =MATH, =FLASH_ATTENTION, =EFFICIENT_ATTENTION (non-exhaustive) ``` Capture stdout across a single `run()` and parse it. **Capture at the file-descriptor level, not `contextlib.redirect_stdout`:** the `SdpaKernel=` line is written to native fd-1 from C++, which Python-level stdout redirection never intercepts — you would get `dispatched=None`, a silent false-negative. Mirror ORT's own `_CaptureStdout` (`os.dup2` fd-1 to a temp file, run, restore, read it back): ```python os.environ["ORT_ENABLE_ATTENTION_KERNEL_DEBUG_INFO"] = "1" # BEFORE InferenceSession() # FD-level capture (see onnxruntime's _CaptureStdout for the exact idiom): saved_fd = os.dup(1) tmp = tempfile.TemporaryFile() os.dup2(tmp.fileno(), 1) # redirect native fd-1 try: # ... create session, run once ... finally: os.dup2(saved_fd, 1) # restore fd-1 os.close(saved_fd) tmp.seek(0) captured_text = tmp.read().decode() m = re.search(r"SdpaKernel=(?P[A-Z_]+)", captured_text) dispatched = m.group("kernel") if m else None assert dispatched == "CUDNN_FLASH_ATTENTION" ``` Caveat: `re.search` returns only the **first** `SdpaKernel=` token — correct for the single-node decode probe here. For a graph with **multiple** attention nodes use `re.findall` and check every token, or a later node's `MATH` fallback is masked by an earlier cuDNN hit. Prefer this over reading `torch.backends.cudnn.version()` or any library-version check: a version probe reads **torch's** cuDNN, not the cuDNN ORT actually loaded/dispatched — that mismatch is a real trustworthiness bug. Observe-dispatch reads ORT's own routing decision, so it is correct across cuDNN versions with no hard-coded version table. ### Non-skippable canary — `ORT_TEST_REQUIRE_CUDNN_SDPA` Gating decode tests by observed dispatch has a failure mode: if the tier silently regresses (stops selecting cuDNN), the observation returns "not dispatched" and every decode test **skips green**, hiding the regression as all-green skips. Close the hole with an env-gated canary: `ORT_TEST_REQUIRE_CUDNN_SDPA=1`. When set, the dispatch assertion becomes **non-skippable** — a `MATH` fallback / non-dispatch on the minimal known-good config **FAILS LOUD** instead of skipping. When unset (dev boxes, unsupported cuDNN) it falls back to the normal skip guard so it never false-alarms. The variable is *intended* for an operator to export on a known-good GPU CI leg once one exists. Note that today no ONNX Runtime pipeline definition exports it (there is no Hopper+ GPU CI leg), so it has no effect in this project's CI and only matters for manual/local runs where a developer sets it explicitly — don't describe it in test docstrings as an enforcement that CI already applies. ```python def require_cudnn_sdpa(): return os.environ.get("ORT_TEST_REQUIRE_CUDNN_SDPA") == "1" # in the test: enforce = require_cudnn_sdpa() if not enforce and not cudnn_decode_supported(head_size): # illustrative: your suite's own support predicate self.skipTest("cuDNN SDPA decode tier not dispatched; set ORT_TEST_REQUIRE_CUDNN_SDPA=1 to enforce") # then assert dispatch == CUDNN_FLASH_ATTENTION unconditionally ``` Run both ways to prove it works AND bites: ```bash python -m pytest .py -v # normal: skips where unsupported ORT_TEST_REQUIRE_CUDNN_SDPA=1 python -m pytest .py -v # enforced: fails if not cuDNN ``` **Prove the teeth.** A canary you never watched fail is not verified. Force `MATH`-only by setting the CUDA provider's `sdpa_kernel` **provider option** to the MATH bitmask (`16`) — a monkeypatch of the C++ selector is not reachable from Python — under `ORT_TEST_REQUIRE_CUDNN_SDPA=1`, and confirm it fails with, verbatim: ``` AssertionError: 'CUDNN_FLASH_ATTENTION' != 'MATH' ``` A run that never demonstrates this failure has not proven the canary has teeth (grounding rule: negative/teeth evidence must actually be observed, not asserted). ## 4. Putting it together — one clean run block ```bash WORKDIR=$(mktemp -d); cd "$WORKDIR" # neutral + private (see §1) source /abs/repo/.venv/bin/activate export LD_PRELOAD=/abs/cuda12.9/lib64/libcudart.so.12:/abs/cudnn9.8/lib/libcudnn.so.9 export LD_LIBRARY_PATH=/abs/cuda12.9/lib64:/abs/cudnn9.8/lib export PYTHONPATH=/abs/repo/onnxruntime/test/python/transformers F=/abs/repo/onnxruntime/test/python/transformers/.py python -m pytest "$F" -v # A: normal ORT_TEST_REQUIRE_CUDNN_SDPA=1 python -m pytest "$F" -v # B: canary active, non-skippable # C: teeth — force MATH under the env var, expect the AssertionError above ``` **Check the passed count, not just the exit code.** `pytest -v` exits **0 even if every test skipped** (no CUDA, or an unmet `@skipUnless(ml_dtypes)` guard) — the saved log then looks like passing evidence but proves nothing. Require a **non-zero passed count and zero unexpected skips**, and note pytest **exit code 5 = "no tests collected"** (usually a wrong path or `-k` filter, not success). RUN B's canary only converts *dispatch-related* skips into failures — it does **not** rescue collection or environment skips, so still read the summary line. Redirect to a log (`... 2>&1 | tee "$WORKDIR/gpu_run.log"`) — the debug-info stdout and pytest output are large, and a saved log is the evidence that the run happened and dispatched to cuDNN. Write it **inside** `$WORKDIR` (the `mktemp -d` above), not a predictable `/tmp/gpu_run.log` a co-tenant could pre-create as a symlink to clobber. ## Gotcha quick-reference | Symptom | Root cause | Fix | |---|---|---| | `ModuleNotFoundError: onnxruntime.capi` | repo-root `./onnxruntime/` source shadows the wheel | run pytest from a private `mktemp -d` (not repo root, not bare `/tmp`); abs path + `PYTHONPATH` | | routes to `MATH` / cuDNN load error | torch's bundled CUDA/cuDNN shadow ORT's | `LD_PRELOAD` system `libcudart.so.12` + `libcudnn.so.9`, set `LD_LIBRARY_PATH` | | test passes but path unproven | `MATH` fallback gives same numbers | observe `SdpaKernel=` via `ORT_ENABLE_ATTENTION_KERNEL_DEBUG_INFO=1` | | all tests skip green, regression hidden | dispatch-gated skip | `ORT_TEST_REQUIRE_CUDNN_SDPA=1` makes assertions non-skippable |