diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6423320..9da2212 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ env: # ONNX Runtime dylib for the reference backend. rustnn pins ort 2.0.0-rc.13 # with the `api-27` feature, so this must be >= 1.27 (any newer 1.x works). ORT_VERSION: 1.29.0 + RUSTNN_REPO: rustnn/rustnn + RUSTNN_REF: main jobs: test: @@ -69,7 +71,8 @@ jobs: id: rustnn uses: actions/checkout@v4 with: - repository: rustnn/rustnn + repository: ${{ env.RUSTNN_REPO }} + ref: ${{ env.RUSTNN_REF }} path: rustnn - name: Install Rust @@ -150,3 +153,16 @@ jobs: O2W_MODEL_TEST_JOBS: ${{ matrix.model_test_jobs || 2 }} O2W_MODEL_TEST_SKIP_HEAVY: 1 run: cargo test --release --locked ${{ matrix.cargo_flags }} + + - name: Validate curated full model + if: runner.os != 'Windows' + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + O2W_ONNX_CACHE: ${{ runner.temp }}/onnx2webnn-ci-models + O2W_WEBNN_CACHE: ${{ runner.temp }}/onnx2webnn-ci-webnn + run: | + target/release/onnx2webnn validate-models \ + --manifest tests/models/ci-validation.json \ + --selection all \ + --weights real \ + --jobs 1 diff --git a/.github/workflows/model-validation.yml b/.github/workflows/model-validation.yml new file mode 100644 index 0000000..dcbd604 --- /dev/null +++ b/.github/workflows/model-validation.yml @@ -0,0 +1,119 @@ +name: Full model validation + +on: + workflow_dispatch: + +env: + ORT_VERSION: 1.29.0 + RUSTNN_REPO: rustnn/rustnn + RUSTNN_REF: main + +jobs: + validate: + name: Full real-weight sweep + runs-on: [self-hosted, linux, x64, onnx2webnn-validation] + timeout-minutes: 360 + defaults: + run: + shell: bash + working-directory: onnx2webnn + steps: + - name: Checkout onnx2webnn + uses: actions/checkout@v4 + with: + path: onnx2webnn + + - name: Checkout rustnn + uses: actions/checkout@v4 + with: + repository: ${{ env.RUSTNN_REPO }} + ref: ${{ env.RUSTNN_REF }} + path: rustnn + + - name: Check runner contract + working-directory: ${{ github.workspace }} + run: | + cache=/var/cache/onnx2webnn + test -d "$cache" + test -w "$cache" + filesystem_bytes=$(df -PB1 "$cache" | awk 'NR == 2 { print $2 }') + memory_bytes=$(awk '/MemTotal/ { print $2 * 1024 }' /proc/meminfo) + minimum_filesystem=$((100 * 1024 * 1024 * 1024)) + minimum_memory=$((30 * 1024 * 1024 * 1024)) + if [ "$filesystem_bytes" -lt "$minimum_filesystem" ]; then + echo "Runner filesystem must be at least 100 GiB" >&2 + exit 1 + fi + if [ "$memory_bytes" -lt "$minimum_memory" ]; then + echo "Runner memory must be approximately 32 GiB (30 GiB minimum)" >&2 + exit 1 + fi + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Install protobuf compiler + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: full-validation-cargo-${{ hashFiles('onnx2webnn/Cargo.lock') }} + restore-keys: full-validation-cargo- + + - name: Download ONNX Runtime ${{ env.ORT_VERSION }} + run: | + archive=onnxruntime-linux-x64-${ORT_VERSION} + url=https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/${archive}.tgz + mkdir -p "$RUNNER_TEMP/ort" + curl -fsSL "$url" -o "$RUNNER_TEMP/ort/archive.tgz" + tar -xzf "$RUNNER_TEMP/ort/archive.tgz" -C "$RUNNER_TEMP/ort" + dylib="$RUNNER_TEMP/ort/$archive/lib/libonnxruntime.so" + if [ ! -f "$dylib" ]; then + dylib=$(find "$RUNNER_TEMP/ort/$archive/lib" -maxdepth 1 -name 'libonnxruntime.so.*' -print -quit) + fi + test -f "$dylib" + echo "ORT_DYLIB_PATH=$dylib" >> "$GITHUB_ENV" + + - name: Build release binary + run: cargo build --release --locked + + - name: Run diagnostic full sweep + id: validation + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + O2W_ONNX_CACHE: /var/cache/onnx2webnn + O2W_WEBNN_CACHE: ${{ runner.temp }}/onnx2webnn-webnn + run: | + set +e + target/release/onnx2webnn validate-models \ + --manifest tests/models/manifest.json \ + --selection all \ + --weights real \ + --jobs 1 2>&1 | tee "$RUNNER_TEMP/model-validation.log" + status=${PIPESTATUS[0]} + set -e + echo "exit_code=$status" >> "$GITHUB_OUTPUT" + if [ "$status" -eq 0 ]; then + echo "## Full model validation: passed" >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning::Full model validation reported failures (exit code $status); see the summary and uploaded log." + echo "## Full model validation: diagnostic failures" >> "$GITHUB_STEP_SUMMARY" + echo "The validator exited with code $status. This manually dispatched sweep is diagnostic and does not fail the workflow." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload validation log + if: always() + uses: actions/upload-artifact@v4 + with: + name: full-model-validation-real + path: ${{ runner.temp }}/model-validation.log + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index 955e513..c6030c8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,7 @@ __pycache__/ .vscode/ .idea/ .DS_Store + +# Model caches +.webnn-cache/ +.onnx-cache/ diff --git a/AGENTS.md b/AGENTS.md index f12c8f1..dd450cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,20 +25,23 @@ independent layers: |-------|----------------|-------|-----------------| | **1. WebNN spec** | Ops the W3C API defines | [webnn spec](https://www.w3.org/TR/webnn/) | ~105 | | **2. Name mapping** | ONNX op name ↔ WebNN `MLGraphBuilder` method | `webnn-onnx-utils/src/operation_names.rs` | ~90 | -| **3. Exporter implementation** | ONNX ops with a Rust lowering handler | `src/onnx/ops/*.rs` + `scripts/webnn_onnx_ops.py` | ~59 | +| **3. Exporter implementation** | Advertised ONNX names with a lowering or preprocessing path | `src/onnx/ops/*.rs` + `scripts/webnn_onnx_ops.py` | 135 | An ONNX op can be: -- **No WebNN target** — e.g. `If`, `Loop`, `Scan` (control flow), `StringConcat` (no string - tensors), `Compress`, `Einsum`, `Attention`. WebNN graphs are static DAGs; these are permanently - rejected. See `docs/operator-conversion-plan.md` Stage 3. -- **Mapped but not implemented** — e.g. `BatchNormalization`, `InstanceNormalization`, `ArgMax`, - `GatherND`, `Resize`. Listed in `operation_names.rs` but no handler in `src/onnx/ops/` yet. - Tests expect `UnsupportedOp` until a handler lands. -- **Implemented** — listed in `scripts/webnn_onnx_ops.py` and handled by `OpRegistry`. Tests - expect `Success` (convert + ORT vs rustnn output match). +- **No current lowering** — for example `Loop`, `Scan`, string operators, and general + sequence operators. Some have no portable WebNN representation. +- **Pattern-only or partial** — for example constant-condition `If`, the narrow + `SplitToSequence` + `SequenceAt` export pattern, static `OneHot`, and restricted + `Einsum`. The handler rejects unsupported forms. +- **Direct or decomposed** — advertised in `scripts/webnn_onnx_ops.py`. A decomposed op may be + functionally supported while remaining slower or narrower than a native backend kernel. -The test manifest (`webnn_onnx_ops.py`) mirrors layer 3 only. Regenerate tests after changing it. +The manifest contains 135 accepted names. At opset 26, 127 are standard `ai.onnx` names; +adding the registry-only `SplitToSequence`/`SequenceAt` pattern gives 129/198 (65.2%) standard +names with at least one executable lowering. Of those, 96/198 (48.5%) have a direct standardized +WebNN primitive path. Name presence is not a promise of every ONNX schema variant. See +`docs/operator-conversion-status.md` for the complete inventory and WebNN-imposed limitations. Regenerate tests after changing the manifest. ## Build and test @@ -104,9 +107,10 @@ Operators with multiple pre-9 schema revisions skip sub-opset-9 structure bands until legacy attribute normalization lands; newer bands use the true latest schema revision in range (e.g. `MaxRoiPool` at model opset 22, `Pad` at 9/17/26). -Operator dispatch and the unsupported-op pre-scan key on **`op_type` only** (standard -`ai.onnx` domain). Custom-domain ops are not supported yet; adding them requires -domain-aware handler registration and matching pre-scan logic in `convert.rs`. +Operator dispatch and the unsupported-op pre-scan key on **`op_type` only**, not on the ONNX +domain. This currently permits selected `com.microsoft` fused operators such as MoE/QMoE, but +it is not general custom-domain support and cannot distinguish same-named operators across +domains. Proper domain-aware support requires matching registry and pre-scan changes. ## Layout (committed) @@ -147,7 +151,8 @@ history and tests the highest buildable opset in each band — e.g. `Pad` at ops (attribute `pads` vs input `pads` vs optional `axes` input). Ops with unchanged structure get a single test at the newest opset. Unbuildable ops get an `#[ignore]` stub. -See [docs/operator-conversion-plan.md](docs/operator-conversion-plan.md) for the full operator rollout workflow. +See [docs/operator-conversion-status.md](docs/operator-conversion-status.md) for the complete +per-operator capability matrix. ## Dependencies (`Cargo.toml`) diff --git a/Cargo.lock b/Cargo.lock index 59f099f..1d16dab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "aquamarine" version = "0.1.12" @@ -95,6 +104,29 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "atty" version = "0.2.14" @@ -133,7 +165,7 @@ dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.10.5", "log", "proc-macro2", "quote", @@ -216,12 +248,41 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -237,7 +298,7 @@ dependencies = [ "bitflags", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -254,6 +315,19 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + [[package]] name = "block" version = "0.1.6" @@ -269,6 +343,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bs58" version = "0.5.1" @@ -278,6 +361,17 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -329,6 +423,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + [[package]] name = "chrono" version = "0.4.44" @@ -336,8 +447,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -392,6 +505,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -409,6 +531,62 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_panic" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9603f79528ece8163c496f8932121cb36cfe46259e9c907bb3d8205139d7caa3" +dependencies = [ + "typewit", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -425,6 +603,21 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "countio" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9702aee5d1d744c01d82f6915644f950f898e014903385464c773b96fefdecb" +dependencies = [ + "futures-io", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -434,6 +627,39 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crunchy" version = "0.2.4" @@ -450,6 +676,25 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + [[package]] name = "cudarc" version = "0.11.9" @@ -603,8 +848,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -639,6 +895,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -651,6 +913,21 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "env_logger" version = "0.9.3" @@ -690,7 +967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -711,6 +988,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -747,12 +1030,88 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs4" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +dependencies = [ + "rustix", + "tokio", + "windows-sys 0.59.0", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.32" @@ -765,12 +1124,23 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] +[[package]] +name = "gearhash" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616e8f476a586f1b078d9eece21f7a071f27997e709e2b471f5697deea53bda9" + [[package]] name = "generic-array" version = "0.14.7" @@ -788,8 +1158,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -799,21 +1171,76 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] -name = "glob" -version = "0.3.4" +name = "git-version" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" +dependencies = [ + "git-version-macro", +] [[package]] -name = "half" -version = "2.7.1" +name = "git-version-macro" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax 0.8.10", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ @@ -856,6 +1283,12 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heapify" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" + [[package]] name = "heck" version = "0.5.0" @@ -883,6 +1316,31 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hf-xet" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c237ef4fb0ce1962a5117f8bd8c74454b41629826a9df17d14a1840ca18f0754" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "http", + "more-asserts", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tokio_with_wasm", + "tracing", + "uuid", + "xet-client", + "xet-core-structures", + "xet-data", + "xet-runtime", +] + [[package]] name = "hmac-sha256" version = "1.1.14" @@ -899,18 +1357,138 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "huggingface-hub" +version = "0.0.1" +source = "git+https://github.com/huggingface/huggingface_hub_rust.git?rev=20acfd61a767dcf7d6a0cb7f4a02ac8406fd8a14#20acfd61a767dcf7d6a0cb7f4a02ac8406fd8a14" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "fs4", + "futures", + "globset", + "hf-xet", + "pathdiff", + "reqwest", + "reqwest-middleware", + "reqwest-retry", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "typed-builder", + "url", +] + [[package]] name = "humantime" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1079,6 +1657,12 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-terminal" version = "0.4.17" @@ -1087,7 +1671,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi 0.5.2", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1116,9 +1700,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.12.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -1129,6 +1713,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -1151,6 +1784,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "konst" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d" +dependencies = [ + "const_panic", + "konst_proc_macros", + "typewit", +] + +[[package]] +name = "konst_proc_macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1201,6 +1857,18 @@ dependencies = [ "cc", ] +[[package]] +name = "link-section" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1213,12 +1881,36 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + [[package]] name = "lzma-rust2" version = "0.15.8" @@ -1234,6 +1926,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matrixmultiply" version = "0.3.10" @@ -1273,12 +1974,45 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "more-asserts" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" + [[package]] name = "moveit" version = "0.6.0" @@ -1294,6 +2028,33 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.4", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "native-tls" version = "0.2.18" @@ -1336,6 +2097,24 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -1380,17 +2159,45 @@ dependencies = [ ] [[package]] -name = "objc_exception" -version = "0.1.2" +name = "objc2-core-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "cc", + "bitflags", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" @@ -1400,6 +2207,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oneshot" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" + [[package]] name = "onnx2webnn" version = "0.1.0" @@ -1407,12 +2220,15 @@ dependencies = [ "anyhow", "bytemuck", "clap", + "dirs", "half", + "huggingface-hub", "pretty_env_logger", "prost", "rustnn", "serde", "serde_json", + "sha2 0.10.9", "tempfile", "thiserror 1.0.69", "ureq 2.12.1", @@ -1494,6 +2310,44 @@ dependencies = [ "ureq 3.3.0", ] +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -1549,7 +2403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1562,12 +2416,38 @@ dependencies = [ "indexmap 2.14.0", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.33" @@ -1675,7 +2555,7 @@ checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", "heck", - "itertools 0.12.1", + "itertools 0.10.5", "log", "multimap", "once_cell", @@ -1683,6 +2563,8 @@ dependencies = [ "prettyplease", "prost", "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", "regex", "syn 2.0.117", "tempfile", @@ -1695,7 +2577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.10.5", "proc-macro2", "quote", "syn 2.0.117", @@ -1710,6 +2592,83 @@ dependencies = [ "prost", ] +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "10.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0194e6e1966c23cc5fd988714f85b18d548d773e81965413555d96569931833d" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1725,12 +2684,56 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "rawpointer" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" +[[package]] +name = "redb" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" +dependencies = [ + "libc", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -1831,6 +2834,96 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "reqwest-middleware" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" +dependencies = [ + "anyhow", + "async-trait", + "http", + "reqwest", + "serde", + "thiserror 2.0.18", + "tower-service", +] + +[[package]] +name = "reqwest-retry" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2412db2af7d2268e7a5406be0431f37d9eb67ff390f35b395716f5f06c2eaa" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "getrandom 0.2.17", + "http", + "hyper", + "reqwest", + "reqwest-middleware", + "retry-policies", + "thiserror 2.0.18", + "tokio", + "tracing", + "wasmtimer", +] + +[[package]] +name = "retry-policies" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc05fbf560421a0357a750cbe78c7ca19d4923918490daabba313d5dbc871e47" +dependencies = [ + "rand", +] + [[package]] name = "ring" version = "0.17.14" @@ -1851,6 +2944,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1861,7 +2963,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1870,6 +2972,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -1879,21 +2982,62 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1903,7 +3047,7 @@ dependencies = [ name = "rustnn" version = "0.5.11" dependencies = [ - "base64", + "base64 0.22.1", "block", "bytemuck", "bytes", @@ -1921,7 +3065,6 @@ dependencies = [ "prost-build", "prost-types", "regex", - "safetensors", "seahash", "serde", "serde_json", @@ -1940,6 +3083,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe-transmute" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d" + [[package]] name = "safetensors" version = "0.7.0" @@ -1951,6 +3106,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1984,6 +3148,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scratch" version = "1.0.9" @@ -2003,7 +3173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2068,13 +3238,36 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", @@ -2107,8 +3300,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "bstr", + "dirs", + "os_str_bytes", ] [[package]] @@ -2123,6 +3347,22 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -2135,6 +3375,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "socks" version = "0.3.4" @@ -2152,6 +3402,22 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "num-traits", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2164,6 +3430,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -2197,6 +3469,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -2208,6 +3489,41 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2218,7 +3534,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2270,6 +3586,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -2315,34 +3640,232 @@ dependencies = [ name = "tinyvec" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-retry" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" +dependencies = [ + "pin-project-lite", + "rand", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio_with_wasm" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6" +dependencies = [ + "js-sys", + "tokio", + "tokio_with_wasm_proc", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tokio_with_wasm_proc" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ - "tinyvec_macros", + "once_cell", + "valuable", ] [[package]] -name = "tinyvec_macros" -version = "0.1.1" +name = "tracing-log" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] [[package]] -name = "tracing" -version = "0.1.44" +name = "tracing-serde" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" dependencies = [ - "pin-project-lite", + "serde", "tracing-core", ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "tracing-subscriber" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", + "nu-ansi-term", "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", ] [[package]] @@ -2376,18 +3899,62 @@ dependencies = [ "regex", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typed-builder" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9d30e3a08026c78f246b173243cf07b3696d274debd26680773b6773c2afc7" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c36781cc0e46a83726d9879608e4cf6c2505237e263a8eb8c24502989cfdb28" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" + [[package]] name = "ucd-trie" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2424,7 +3991,7 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ - "base64", + "base64 0.22.1", "log", "once_cell", "rustls", @@ -2439,7 +4006,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64", + "base64 0.22.1", "der", "log", "native-tls", @@ -2457,7 +4024,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64", + "base64 0.22.1", "http", "httparse", "log", @@ -2475,6 +4042,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -2493,6 +4066,23 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -2505,12 +4095,40 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -2529,6 +4147,15 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.122" @@ -2542,6 +4169,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.122" @@ -2596,6 +4233,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -2608,13 +4258,47 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webnn-graph" version = "0.3.0" -source = "git+https://github.com/rustnn/webnn-graph?branch=main#075e3c2f8b78d67f5b0ffbc51d6bc81162397d43" +source = "git+https://github.com/rustnn/webnn-graph?branch=main#0bcd517d30cf740f55bf19386be254e37010ab2b" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "clap", "half", "pest", @@ -2669,6 +4353,19 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2691,7 +4388,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2700,6 +4397,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2713,6 +4431,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -2741,6 +4470,27 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -2768,6 +4518,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2793,6 +4552,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2941,6 +4709,147 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xet-client" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b8da8cc70aa2e3c500c0400e012df82c656ab9fca47f9f939fffc5afd89aca" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bytes", + "crc32fast", + "futures", + "http", + "hyper", + "more-asserts", + "rand", + "redb", + "reqwest", + "reqwest-middleware", + "serde", + "serde_json", + "serde_repr", + "statrs", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio_with_wasm", + "tracing", + "url", + "urlencoding", + "web-time", + "xet-core-structures", + "xet-runtime", +] + +[[package]] +name = "xet-core-structures" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73503c223783dccc864abde22115e09d12f190448a0baf58ab2c54bc709e2f99" +dependencies = [ + "async-trait", + "base64 0.22.1", + "blake3", + "bytemuck", + "bytes", + "countio", + "futures", + "futures-util", + "getrandom 0.4.2", + "heapify", + "itertools 0.14.0", + "lz4_flex", + "more-asserts", + "rand", + "regex", + "safe-transmute", + "serde", + "static_assertions", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "web-time", + "xet-runtime", +] + +[[package]] +name = "xet-data" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89052ec5dec2187cad30b86af92cc24fd61c4a57a795f1ff7ff5f38d49184eb" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "gearhash", + "http", + "itertools 0.14.0", + "more-asserts", + "rand", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tokio_with_wasm", + "tracing", + "url", + "uuid", + "web-time", + "xet-client", + "xet-core-structures", + "xet-runtime", +] + +[[package]] +name = "xet-runtime" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5c60d5eed38ab4c576f4421bae835e7bd07631fb381705605529d2015c106b" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "colored", + "const-str", + "ctor", + "dirs", + "futures", + "git-version", + "humantime", + "konst", + "libc", + "more-asserts", + "oneshot", + "pin-project", + "rand", + "reqwest", + "serde", + "serde_json", + "shellexpand", + "sysinfo", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tokio_with_wasm", + "tracing", + "tracing-appender", + "tracing-subscriber", + "web-time", + "whoami", + "winapi", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 1237077..ead083a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,17 +24,20 @@ trtx = ["rustnn/trtx-runtime"] [dependencies] anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } +dirs = "6.0" half = "2.7.1" prost = "0.12" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -thiserror = "1.0" +thiserror = "1.0" +ureq = { version = "2.10", default-features = false, features = ["tls"] } +sha2 = "0.10" rustnn = { path = "../rustnn", default-features = false, features = ["onnx-runtime"] } bytemuck = "1.15" webnn-onnx-utils = { git = "https://github.com/rustnn/webnn-onnx-utils", branch = "main" } +huggingface-hub = { git = "https://github.com/huggingface/huggingface_hub_rust.git", rev = "20acfd61a767dcf7d6a0cb7f4a02ac8406fd8a14", version = "0.0.1", features = ["blocking", "xet"] } [dev-dependencies] -pretty_env_logger = "0.5" +pretty_env_logger = "0.5" tempfile = "3.8" -ureq = { version = "2.10", default-features = false, features = ["tls"] } diff --git a/README.md b/README.md index 01688ec..8ca4ce6 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,51 @@ # onnx2webnn +> **Last edited:** `2026-09-22T21:07:28Z`
+> **Checkout:** `fkrall/cache-backed-validation` at `a582b89` +> +> **Freshness:** Use this document only when this provenance is recent relative to the relevant +> code and commits; otherwise verify the implementation, tests, and Git history before relying +> on it. + ONNX → WebNN lowering crate extracted from [webnn-graph](../webnn-graph). ONNX operators lower directly to [rustnn](../rustnn) `MLGraphBuilder`; full-graph validation runs via ORT CPU -`build()` (`onnx-runtime` feature). There is no JSON IR and no on-disk graph export — success -means `builder.build()` returns `Ok(MLGraph)`. +`build()` (`onnx-runtime` feature). There is no intermediate JSON IR. Without `--output`, +success means `builder.build()` returns `Ok(MLGraph)`; `--output` additionally writes a +reloadable `.webnn` graph and sibling Safetensors archive. + +Decomposed attention is not promised to be bit-identical to a source backend fused-attention +kernel. This matters for quantized models that immediately feed attention into +`DynamicQuantizeLinear`: small legal floating-point differences can cross Uint8 bucket boundaries +and compound through later layers. FastVLM prefill has this limitation; its fixed sequence-1 decode +specialization passes numerical validation. Supported ONNX opset range: **1–26** (see `MIN_SUPPORTED_OPSET` / `MAX_SUPPORTED_OPSET` in `src/onnx/convert.rs`). ## Build +### Prerequisites + +- A stable Rust toolchain. +- The Protocol Buffers compiler (`protoc`). On Debian/Ubuntu install + `protobuf-compiler`; on macOS install `protobuf` with Homebrew. Cargo uses it + while building the ONNX and CoreML protobuf bindings. +- A sibling `rustnn` checkout at `../rustnn`. This is the path dependency used + by `Cargo.toml` while the stacked RustNN changes are under review. +- ONNX Runtime 1.27 or newer when running conversions or tests with the ORT + backend. RustNN can download the pinned version into its own ignored + `target/onnxruntime` directory: + +```powershell +make -C ../rustnn onnxruntime-download +``` + +Set `ORT_DYLIB_PATH` to the downloaded `libonnxruntime` shared library before +running the CLI or tests. The exact filename is platform-specific; examples +are `libonnxruntime.so.1.29.0` on Linux, `libonnxruntime.1.29.0.dylib` on macOS, +and `onnxruntime.dll` on Windows. The CI workflow in `.github/workflows/ci.yml` +contains the complete cross-platform provisioning sequence. + ```powershell cargo build # or @@ -36,6 +72,24 @@ cargo run -- convert --input model.onnx ` If `model.dims.json` sits beside the ONNX file and no overrides were passed on the CLI, dimension bindings are loaded from that sidecar (`freeDimensionOverrides` or a flat JSON object). +With `--output`, artifacts are overwritten below the operating system's cache directory in +`onnx2webnn/onnx` and `onnx2webnn/webnn`; add `--validate` to reload the saved pair immediately and +compare deterministic execution against native ORT. Set `O2W_CACHE_DIR` to relocate both caches, +or `O2W_ONNX_CACHE` and `O2W_WEBNN_CACHE` to override either cache independently. RustNN stores +logical Int4/Uint4 constants as their original packed nibble bytes in a +versioned U8 Safetensors extension while the `.webnn` declaration retains the logical dtype and +shape. This is a RustNN archive convention, not a native Safetensors 4-bit dtype. + +`MatMulNBits` is lowered to `dequantizeLinear` followed by ordinary floating-point `matmul` because +WebNN has no fused low-bit matmul operation. The dequantized weights and activations therefore use +the scale dtype (`float16` or `float32`; the validated q4 models use `float32`). Native ORT may use +a fused packed-weight kernel instead. In particular, `MatMulNBits accuracy_level=4` permits ORT to +quantize activations to Int8 internally, which the WebNN lowering cannot represent; such models are +recorded as validation-blocked rather than numerically supported. + +`--validate-cached` reuses existing cache artifacts and is exclusive with `--output` and +`--validate`. + Merged decoders (optimum's `decoder_model_merged*.onnx`) branch at runtime on `use_cache_branch`. WebNN has no runtime `If`, so pin the input and convert each branch separately: @@ -49,6 +103,12 @@ cargo run -- convert --input decoder_model_merged.onnx --optimize ` Pinned inputs become constants, the chosen `If` branch is inlined, and inputs the branch never reads (e.g. the KV cache in the prefill branch) and zero-size dummy outputs are dropped. +Supporting both cache modes therefore produces two independent artifact pairs: one `.webnn` file +and one Safetensors file for prefill, and another pair for cached decoding. Shared decoder weights +are not deduplicated between the pairs. Loading both compiled graphs concurrently may duplicate +those weights in RAM and VRAM; applications can instead keep only the active graph resident and +manage graph selection and KV-cache handoff themselves. + | Flag | Purpose | |------|---------| | `--input` | Input `.onnx` path (required) | @@ -59,6 +119,9 @@ reads (e.g. the KV cache in the prefill branch) and zero-size dummy outputs are | `--allow-missing-external-data` | Zero-fill external tensors whose data file is absent (weight-stripped skeleton models) | | `--experimental-dynamic-inputs` | Preserve unresolved symbolic dims as dynamic metadata | | `--debug` | Verbose conversion logging (global) | +| `--output` | Overwrite cache-backed `.onnx`, `.webnn`, and Safetensors artifacts | +| `--validate` | After `--output`, reload and numerically compare against native ORT | +| `--validate-cached` | Validate existing cache artifacts without reconverting | On success the CLI prints `✓ ORT graph build succeeded for …`. @@ -66,12 +129,54 @@ On success the CLI prints `✓ ORT graph build succeeded for …`. `tests/models/manifest.json` lists the transformers.js exports the converter is expected to handle, with their dimension overrides and pinned inputs; `tests/model_skeletons.rs` converts each entry and -builds it in ORT. No weights are downloaded: the test reads each export from the Hugging Face Hub with +builds it in ORT. This broad manifest is generated by `scripts/generate_manifest.py` and intentionally +tracks the publisher repositories through their default `main` revisions. It is diagnostic coverage, +not an immutable CI gate. + +No weights are downloaded by the skeleton test: it reads each export from the Hugging Face Hub with HTTP range requests, keeps the graph and small constants, and points every large initializer at a file that does not exist, which the converter zero-fills. A 1.4 GB export becomes a ~0.2 MB skeleton for ~10 MB of traffic. Skeletons are kept in `target/model-skeletons` (or `O2W_SKELETON_CACHE`), about 40 MB for the whole manifest, and CI caches that directory keyed on the manifest and scanner source. +### Numerical-validation CI + +Every Linux pull-request job numerically validates the real-weight models in the hand-maintained +`tests/models/ci-validation.json`; the experimental, non-blocking macOS/CoreML job runs the same +set. Windows does not run numerical model validation. Each curated entry must be small enough for a +hosted runner, pass on both ORT and CoreML, and declare an immutable 40-character Hugging Face +commit `revision` plus the lowercase SHA-256 of its primary ONNX file. Downloads, external-data +sidecars, skeletons, and cache identities all use that revision. The primary +model digest is checked after download and whenever a cached model is reused. + +Full-model validation downloads through the official Hugging Face Rust client and uses the standard +`HF_HUB_CACHE`/`HF_HOME` cache by default, allowing Python and Rust tools to reuse the same blobs. +`O2W_ONNX_CACHE`, followed by `O2W_CACHE_DIR/onnx`, takes precedence when set and is interpreted as +the Hub cache root. onnx2webnn stores only its completion records in the private +`.onnx2webnn-validation` directory there; model files retain the standard `blobs` and `snapshots` +layout. Xet-backed downloads use chunked transfer, while ordinary HTTP downloads are retried but +are not guaranteed to resume from a partial byte offset. + +To add required CI coverage, resolve the model repository to a commit, download the exact ONNX file +from that revision, compute its SHA-256 (for example with `sha256sum`), and add the file, revision, +digest, and fixed dimension/input settings to `tests/models/ci-validation.json`. Do not add these +pins to `tests/models/manifest.json`: regenerating the broad manifest does not read or write the +curated file. Verify the candidate on Linux and macOS with: + +```bash +target/release/onnx2webnn validate-models \ + --manifest tests/models/ci-validation.json \ + --selection all --weights real --jobs 1 +``` + +The manually dispatched `Full model validation` workflow runs every entry from the generated main +manifest with real publisher weights. It requires a dedicated runner labeled +`self-hosted`, `linux`, `x64`, and `onnx2webnn-validation`, a writable +`/var/cache/onnx2webnn`, network access, a filesystem of at least 100 GiB, and approximately 32 GiB +of RAM. Downloaded source models persist in that directory; exported WebNN artifacts remain +temporary. Setup, build, and runner-contract failures fail the workflow, while model failures are +reported as diagnostic warnings and retained in a full log artifact for 14 days. + `O2W_MODELS` selects the source: `hub` (the default when `CI` is set), `dir=` for full local downloads laid out as `--/onnx/.onnx`, or `strip=` to run local files through the skeleton scanner. Unset outside CI, the sweep is skipped. `O2W_MODEL_FETCH_JOBS` (default 8) sets diff --git a/docs/model-validation-status.md b/docs/model-validation-status.md new file mode 100644 index 0000000..7f8be0e --- /dev/null +++ b/docs/model-validation-status.md @@ -0,0 +1,277 @@ +# Full-model numerical validation status + +> **Last edited:** `2026-09-23T11:35:58Z`
+> **Checkout:** `fkrall/cache-backed-validation` at `ac6d5ca` +> +> **Freshness:** Use this document only when this provenance is recent relative to the relevant +> code and commits; otherwise verify the implementation, tests, and Git history before relying +> on it. + +This document tracks the **latest recorded** full-model numerical-validation sweep and a concise +history of changes that materially altered coverage. Only the latest sweep keeps a complete +per-case ledger; older ledgers and duplicated historical failure tables are intentionally omitted. + +The skeleton sweep establishes broad graph-construction coverage. The manifest contains only model +selection and execution configuration; numerical status and blocker triage live in this document and +the linked failure analysis. Numerical validation additionally +exports the converted graph, reloads its `.webnn` and Safetensors artifacts, executes both the +original ONNX model and the reloaded graph on CPU ONNX Runtime, and compares matching outputs. + +Proper dynamic cache-shape support is not currently available. Validation cases use fixed +dimension overrides and execute as independent snapshots, so they do not validate a complete +prefill-to-decode loop or growing KV-cache reuse. A decode artifact that +accepts a fixed past length `N` normally produces a cache of length `N + 1`, which cannot be fed +back into that same fixed-shape artifact. Repeated decoding therefore still requires proper dynamic +cache shapes, separately specialized artifacts, or a fixed-capacity cache with an explicit position. + +## Broad manifest selection + +[`tests/models/manifest.json`](../tests/models/manifest.json) is not a global top-50 list. The +[`scripts/generate_manifest.py` generator](../scripts/generate_manifest.py) queries Hugging Face for +repositories tagged `transformers.js`, groups them by pipeline task, and by default selects the +single most-liked repository in each task (`--top 1`). Tokenizer-only repositories, repositories +without usable ONNX exports, and repositories or components listed in the +[Transformers.js excluded-model list](transformersjs_excluded_models.md) are omitted. + +For each selected repository, the generator includes the ONNX components that Transformers.js +would load for that model class, such as both encoder and merged decoder graphs for a sequence-to- +sequence model. It follows the model's `transformers.js_config` dtype when present; otherwise the +default generator mode mirrors WASM and prefers q8 exports. Missing preferred variants fall back to +an available export. Models with a `use_cache_branch` input receive separate prefill and decode +cases, while other multipart models contribute one case per required graph. Consequently the +current manifest contains 28 repositories, 44 unique ONNX files, and 51 configured cases. + +The generator assigns bounded dimensions using onnx2webnn's naming policy and carries forward +existing per-file dimension overrides, pinned inputs, and other manual fields. It inspects remote +ONNX structure using HTTP range requests rather than downloading full weights. Regeneration tracks +publisher `main` revisions and is therefore diagnostic coverage, not an immutable model set. The +current population was last generated around early September 2026 and entered Git history on +13 September 2026. + +## Validation automation + +Pull requests use `tests/models/ci-validation.json` as the required numerical-validation contract. +Linux/ORT is blocking; macOS/CoreML runs the identical set as an experimental, non-blocking job; +Windows does not run numerical validation. Membership is defined by the curated file itself and the +runner invokes it with `--selection all`. Every entry must be suitable for hosted runners and include +a full immutable Hugging Face commit revision and a lowercase SHA-256 for its primary ONNX file. +The downloader verifies that digest both after download and on cache reuse. Full-model downloads +use the standard Hugging Face cache by default so Python and Rust clients can share immutable blobs; +`O2W_ONNX_CACHE` and `O2W_CACHE_DIR/onnx` remain explicit overrides. Completion records stay +onnx2webnn-specific and do not replace the manifest digest check. + +To add a required model, first confirm it passes on ORT and CoreML, then add its exact file, commit +revision, downloaded-file digest, fixed dimension overrides, and any pinned inputs to +`tests/models/ci-validation.json`. This file is hand-maintained. The +[`scripts/generate_manifest.py`](../scripts/generate_manifest.py) generator only regenerates +`tests/models/manifest.json` and must not be used to update or carry CI pins. + +The `Full model validation` workflow is manually dispatched for real publisher weights. +It runs the entire generated manifest sequentially on a runner labeled `self-hosted`, `linux`, +`x64`, and `onnx2webnn-validation`. The runner must provide network access, a writable persistent +`/var/cache/onnx2webnn`, at least a 100 GiB filesystem, and approximately 32 GiB RAM. +Downloaded model sources persist; WebNN exports are temporary. Provisioning, storage checks, and +build failures are fatal. Per-model validation failures remain diagnostic: the workflow emits a +warning and uploads its complete log for 14 days. There is no scheduled or extended-tier sweep. + +### Runtime dtypes and comparison tolerances + +WebNN graphs are not globally limited to floating point: RustNN records integer tensors and integer +operations where their operator contracts allow them. Matrix multiplication is different. The +portable WebNN `matmul` contract accepts matching `float16` or `float32` operands, and WebNN has no +fused equivalent of ORT's `com.microsoft.MatMulNBits`. + +For q4 models, the original ONNX path gives native ORT packed Uint4 weights and a fused +`MatMulNBits` node. The reloaded path restores the same packed bytes, widens them for RustNN's +temporary ORT graph, applies `dequantizeLinear`, and runs ordinary matmul in the scale dtype. The +models in this manifest use Float32 scales and activations, so their reconstructed matmuls are +Float32. Different fused/decomposed accumulation orders are mathematically equivalent but not +bit-identical. + +The validator therefore uses these output tolerances: + +| Output/path | Comparison | +|-------------|------------| +| Float32 without `MatMulNBits` | `1e-5 + 1e-4 * abs(reference)` | +| Float32 from a model containing q4 `MatMulNBits` | `1e-3 + 1e-3 * abs(reference)` | +| Float32 from a model containing q8 `MatMulNBits` | `2e-3 + 2e-3 * abs(reference)` | +| Float16 | `1e-3 + 1e-2 * abs(reference)` | +| Integer and boolean | Exact | + +The q4 Float32 envelope is based on a complete SmolLM2 output scan whose worst normalized +difference was `9.488e-4`. The q8 envelope is selected from each source graph's +`MatMulNBits.bits` attribute; it covers the measured Qwen output delta while remaining distinct +from q4. It does not make ORT's `accuracy_level=4` equivalent to WebNN: +level 4 permits internal Int8 activation quantization, while the WebNN lowering keeps Float32 +activations. Voxtral uses level 4 on all 211 `MatMulNBits` nodes and remains blocked for this reason. + +## Latest recorded sweep + +- Real sweep: 2026-09-22 on `fkrall/cache-backed-validation` at `a582b89` plus the uncommitted Hugging Face cache migration worktree +- RustNN: `38022044` +- ORT: repository-local Linux x64 1.29.0 build +- Manifest: `tests/models/manifest.json` (51 cases, 44 unique ONNX files) +- Execution: one validation worker; ORT may use multiple CPU threads inside a case +- Skeleton verification: 51/51 passed; the test took 81.1s (133.2s including the release build) +- Real verification: 47/51 passed in one complete cold-cache process; wall time was 10m 43.6s and peak RSS was 30.3 GiB. No case was skipped or inferred without execution. + +All 51 current manifest cases were executed. A newer checkout is not considered the tested baseline +until the real sweep is rerun and this section is replaced. + +```bash +ORT_DYLIB_PATH=../rustnn/target/onnxruntime/onnxruntime-linux-x64-1.29.0/lib/libonnxruntime.so.1.29.0 \ + target/release/onnx2webnn validate-models \ + --selection all --weights real --jobs 1 +``` + +### Summary + +| Weight mode | Pass | Fail | Download skipped | Result | +|-------------|-----:|-----:|-----------------:|--------| +| Real | 47 | 4 | 0 | Complete (51/51) | + +Two cases exercise unsupported cross-backend execution modes: FastVLM precision-sensitive GQA/DQL +prefill and Voxtral level-4 quantized matmul. The two Chronos publisher artifacts are rejected by +native ORT before conversion comparison. + +The real-weight failures, exact affected cases, current diagnosis, and suggested ownership are +tracked in [Real-weight validation failures](real-weight-validation-failures.md). + +### Current failure families + +| Code | Cases | Stage | Current cause / next action | +|------|------:|-------|-----------------------------| +| Q1 | 1 | Comparison | Unsupported execution semantics: Voxtral sets `MatMulNBits accuracy_level=4`, allowing native ORT to quantize activations to Int8; WebNN lowers to Float32 dequantize-plus-matmul. | +| U1 | 1 | Comparison | Unsupported strict equivalence: FastVLM prefill repeatedly quantizes attention results whose legal floating-point rounding differs between fused ORT kernels and the portable WebNN decomposition; decode passes. | +| O1 | 2 | Native ORT load | Upstream-blocked: both Chronos paths resolve to the same publisher artifact, which feeds a float ConstantOfShape result to Gather indices. | + +Real totals: no export, reload, cached-interface, or native-ORT input failures; 2 native-ORT +model-load failures, 2 unsupported execution-mode mismatches, and 47 passes. + +### Current case ledger + +`PASS` means export, reload, both executions, and numerical comparison completed. Any other value +is the current first-blocker code from the table above. + +`WebNN If split` identifies merged decoders whose runtime `use_cache_branch` condition must be +pinned because WebNN has no `If` operation. Each prefill/decode entry produces its own independent +`.webnn`/Safetensors pair. Loading both graphs concurrently may duplicate their shared weights in +RAM and VRAM. This is distinct from ordinary repeated cases that differ only in fixed dimensions. + +| # | Manifest case | Real | WebNN If split | +|---:|---------------|------|----------------| +| 0 | `briaai--RMBG-1.4 :: model_quantized.onnx` | PASS | — | +| 1 | `openai--privacy-filter :: model_quantized.onnx` | PASS | — | +| 2 | `nomic-ai--nomic-embed-text-v1.5 :: model_quantized.onnx` | PASS | — | +| 3 | `mixedbread-ai--mxbai-embed-large-v1 :: model_quantized.onnx` | PASS | — | +| 4 | `HuggingFaceTB--SmolLM2-1.7B-Instruct :: model_q4.onnx` (`sequence=64`, `past=0`) | PASS | — | +| 5 | `HuggingFaceTB--SmolLM2-1.7B-Instruct :: model_q4.onnx` (`sequence=1`, `past=64`) | PASS | — | +| 6 | `distil-whisper--distil-large-v2 :: encoder_model_quantized.onnx` | PASS | — | +| 7 | `distil-whisper--distil-large-v2 :: decoder_model_merged_quantized.onnx` (`cache=0`) | PASS | Prefill pair (1/2) | +| 8 | `distil-whisper--distil-large-v2 :: decoder_model_merged_quantized.onnx` (`cache=1`) | PASS | Decode pair (2/2) | +| 9 | `jinaai--jina-reranker-v2-base-multilingual :: model_quantized.onnx` | PASS | — | +| 10 | `onnx-community--FastVLM-0.5B-ONNX :: embed_tokens_quantized.onnx` | PASS | — | +| 11 | `onnx-community--FastVLM-0.5B-ONNX :: decoder_model_merged_quantized.onnx` (`sequence=64`, `past=0`) | U1 | — | +| 12 | `onnx-community--FastVLM-0.5B-ONNX :: decoder_model_merged_quantized.onnx` (`sequence=1`, `past=64`) | PASS | — | +| 13 | `onnx-community--FastVLM-0.5B-ONNX :: vision_encoder_quantized.onnx` | PASS | — | +| 14 | `Marqo--marqo-fashionSigLIP :: text_model_quantized.onnx` | PASS | — | +| 15 | `Marqo--marqo-fashionSigLIP :: vision_model_quantized.onnx` | PASS | — | +| 16 | `AdamCodd--vit-base-nsfw-detector :: model_quantized.onnx` | PASS | — | +| 17 | `Xenova--nllb-200-distilled-600M :: encoder_model_quantized.onnx` | PASS | — | +| 18 | `onnx-community--Janus-Pro-1B-ONNX :: language_model_q4.onnx` | PASS | — | +| 19 | `onnx-community--Janus-Pro-1B-ONNX :: lm_head.onnx` | PASS | — | +| 20 | `onnx-community--Janus-Pro-1B-ONNX :: gen_head.onnx` | PASS | — | +| 21 | `onnx-community--Janus-Pro-1B-ONNX :: gen_img_embeds.onnx` | PASS | — | +| 22 | `onnx-community--Janus-Pro-1B-ONNX :: image_decode.onnx` | PASS | — | +| 23 | `Xenova--musicgen-small :: text_encoder_quantized.onnx` | PASS | — | +| 24 | `Xenova--musicgen-small :: decoder_model_merged_quantized.onnx` (`cache=0`) | PASS | Prefill pair (1/2) | +| 25 | `Xenova--musicgen-small :: decoder_model_merged_quantized.onnx` (`cache=1`) | PASS | Decode pair (2/2) | +| 26 | `Mozilla--distilvit :: encoder_model_quantized.onnx` | PASS | — | +| 27 | `onnx-community--Voxtral-Mini-3B-2507-ONNX :: embed_tokens_fp16.onnx` | PASS | — | +| 28 | `onnx-community--Voxtral-Mini-3B-2507-ONNX :: decoder_model_merged_q4.onnx` | Q1 | — | +| 29 | `onnx-community--Voxtral-Mini-3B-2507-ONNX :: audio_encoder_quantized.onnx` | PASS | — | +| 30 | `Xenova--LaMini-Flan-T5-783M :: encoder_model_quantized.onnx` | PASS | — | +| 31 | `Xenova--LaMini-Flan-T5-783M :: decoder_model_merged_quantized.onnx` (`cache=0`) | PASS | Prefill pair (1/2) | +| 32 | `Xenova--LaMini-Flan-T5-783M :: decoder_model_merged_quantized.onnx` (`cache=1`) | PASS | Decode pair (2/2) | +| 33 | `Xenova--detr-resnet-50 :: model_quantized.onnx` | PASS | — | +| 34 | `Xenova--donut-base-finetuned-docvqa :: encoder_model_quantized.onnx` | PASS | — | +| 35 | `Xenova--donut-base-finetuned-docvqa :: decoder_model_merged_quantized.onnx` (`cache=0`) | PASS | Prefill pair (1/2) | +| 36 | `Xenova--donut-base-finetuned-docvqa :: decoder_model_merged_quantized.onnx` (`cache=1`) | PASS | Decode pair (2/2) | +| 37 | `onnx-community--dinov3-vits16-pretrain-lvd1689m-ONNX :: model.onnx` | PASS | — | +| 38 | `Xenova--distilbart-cnn-6-6 :: encoder_model_quantized.onnx` | PASS | — | +| 39 | `Xenova--distilbart-cnn-6-6 :: decoder_model_merged_quantized.onnx` (`cache=0`) | PASS | Prefill pair (1/2) | +| 40 | `Xenova--distilbart-cnn-6-6 :: decoder_model_merged_quantized.onnx` (`cache=1`) | PASS | Decode pair (2/2) | +| 41 | `prithivMLmods--Common-Voice-Gender-Detection-ONNX :: model_quantized.onnx` | PASS | — | +| 42 | `Xenova--bert-base-multilingual-cased :: model_quantized.onnx` | PASS | — | +| 43 | `Xenova--distilbert-base-cased-distilled-squad :: model_quantized.onnx` | PASS | — | +| 44 | `onnx-community--vitpose-base-simple :: model_quantized.onnx` | PASS | — | +| 45 | `kashif--chronos-2-onnx :: encoder_model.onnx` | O1 | — | +| 46 | `kashif--chronos-2-onnx :: decoder_model_merged.onnx` | O1 | — | +| 47 | `huggingworld--Qwen2.5-VL-3B-Instruct-ONNX :: embed_tokens_quantized.onnx` | PASS | — | +| 48 | `huggingworld--Qwen2.5-VL-3B-Instruct-ONNX :: decoder_model_merged_quantized.onnx` | PASS | — | +| 49 | `onnx-community--timesformer-base-finetuned-k400 :: model_quantized.onnx` | PASS | — | +| 50 | `Xenova--tiny-random-RoFormerForMultipleChoice :: model_quantized.onnx` | PASS | — | + +### Current timing and storage + +| Run | Cases | Wall time | +|-----|------:|---------------:| +| Real, cold standard HF cache | 51 | 10m 43.6s | + +The official Rust client populated 44 completion records, one per unique ONNX file. A subsequent +offline `match=privacy-filter` run verified warm reuse of both the primary ONNX and its external-data +sidecar. + +After the sweep, the standard Hugging Face cache occupied 18 GB and temporary WebNN artifacts +occupied 32 GB; 122 GiB remained free. No selected case was skipped for download or storage. + +### Current repair order + +1. Track WebNN/backend capabilities that could make U1 or Q1 executable with comparable fused precision semantics. +2. Monitor the upstream Chronos repository for corrected reference exports. + +## Coverage change history + +This history records only changes that explain coverage movement or establish that an apparent +movement was not a functional improvement. Detailed obsolete ledgers are available through Git. + +| Date / tested revisions | Change | Comparable coverage effect | +|-------------------------|--------|----------------------------| +| 2026-09-17 — validation-tier cleanup after `25ea94c` | Removed manifest validation tiers and the hand-added FP32 RoFormer smoke case, restoring the generator-owned upstream population. Status and blocker ownership now live only in these validation documents; the CLI selects `all` or `match=`. | Population changed **52 → 51** by removing one passing duplicate-model variant. Comparable coverage is unchanged: skeleton **51/51** and real validation **47/51** with the same four blockers. | +| 2026-09-17 — onnx2webnn `25ea94c`, RustNN `28fb3bbe`, post-rebase complete rerun | Rebased both feature stacks onto `rustnn/onnx2webnn:main` and `rustnn/rustnn:main`, then ran all 52 skeleton cases and all 52 real-weight cases from warm caches, including heavy entries. | No regression: skeleton remained **52/52**; real validation remained **48/52** with the identical FastVLM U1, Voxtral Q1, and two Chronos O1 blockers. | +| 2026-09-17 — onnx2webnn `d4d350b`, RustNN `724d076b`, targeted FastVLM probes | Exposed layer boundaries and compared ORT CPU flash/non-flash GQA against the WebNN decomposition. Layer 0 and external rotary matched; the first failing boundary followed layer-1 GQA dynamic quantization and output projection. Native ORT first-logit output changed from `0.669124` to `1.066261` when only its GQA kernel changed, while WebNN remained `1.211192`. | Reclassified FastVLM prefill from generic N1 to unsupported U1. Coverage remains **48/52**; no tolerance was weakened. | +| 2026-09-17 — onnx2webnn `6f6ad8a` plus current Cast/Slice/comparison worktree, RustNN `65e76e67` plus Slice-backend worktree | Normalized ONNX numeric-to-Bool Cast through comparison, preserved positive Slice strides with WebNN extent semantics, and selected q4/q8 comparison envelopes from `MatMulNBits.bits`. | DETR, Donut encoder, and Qwen passed; real coverage rose **45 → 48** on the same 52 cases. FastVLM prefill, Voxtral level 4, and the two invalid Chronos artifacts remain blocked. | +| 2026-09-16 — onnx2webnn `24fdd50` plus current validator/manifest worktree, RustNN `7f07a5e1` plus packed-4-bit archive worktree | Measured complete q4 output error distributions and applied a `1e-3` absolute/relative Float32 envelope only to source graphs containing `MatMulNBits`. Classified Voxtral's `accuracy_level=4` Int8-activation execution mode as unsupported. | SmolLM2 prefill/decode and Janus passed; Voxtral remained Q1. Real passes rose **42 → 45** on the same 52 cases. | +| 2026-09-16 — onnx2webnn `24fdd50` plus current manifest/tests worktree, RustNN `7f07a5e1` plus packed-4-bit archive worktree | Stored packed Int4/Uint4 constants as versioned U8 Safetensors payloads while preserving logical dtype and shape in `.webnn`, then restored and executed them on reload. Marked the identical invalid Chronos publisher artifacts as upstream-blocked. | E1 was eliminated from the real sweep: all four q4 cases reached comparison and exposed N1. The aggregate remained **42/52** because those cases do not yet match numerically. | +| 2026-09-15 — onnx2webnn `ec5ba275` plus current validator worktree, RustNN `7f07a5e1` | Reconciled native ONNX interfaces with branch-specialized cached WebNN interfaces, dispatching only retained inputs and accepting omitted outputs only when native ORT proves they are empty. | Real passes rose **32 → 42** on the same 52 cases. All ten V1 cases passed numerically and no new blocker family appeared. | +| 2026-09-14 — onnx2webnn `aaa33e2` plus current validator worktree, RustNN `7f07a5e1` | Made standard `token_type_ids` zero, `attention_mask` one, and preserved zero-element input buffers without treating scalars as empty. | Real passes rose **28 → 32** on the same 52 cases. I1-I3 were eliminated: four cases passed, five exposed V1, and two exposed N1. | +| 2026-09-12 — onnx2webnn `4926c3e`, RustNN `7f07a5e1` | RustNN `2e22b3db` unified MLGraphBuilder recording and GraphJSON loader inference, replacing the loader-only string-based inference loop. Typed inference added the previously missing/rejected Resample2d, RoundEven/Clamp, Conv2d, LogicalNot, and LogicalAnd reload paths. | On the same 52-case manifest, real passes rose **6 → 28**. Former reload families R1–R5 were eliminated; newly reachable cases exposed N1 and V1 instead. | +| 2026-09-12 — RustNN `7f07a5e1` | Made `[]` unambiguously scalar and required completed GraphInfo descriptors to have known shapes. | Hardened the shared inference path but did not itself add the dispatch that cleared R1–R5; no separate coverage gain is attributed without a bisect. | +| 2026-09-09 — onnx2webnn `e5a5f88c`, RustNN `2783a191` | Rebased onto the newer upstream model manifest and converter work. The manifest changed from 63 to 52 cases: 21 retained, 31 added, 42 removed. | Retained cases preserved their outcomes. Real coverage changed to 6 passes because the population changed, not because comparable coverage improved. One real Voxtral download was skipped. | +| 2026-09-08/09 — onnx2webnn `f1ca548`, RustNN `b64cf495` | First complete real-weight full-model baseline with cache-backed export, reload, deterministic execution, and comparison. | 7/63 real cases passed. Every case that reached comparison matched; failures established the original reload, serialization, and input blocker families. | + +### RustNN inference-refactor attribution + +RustNN `2e22b3db` removed the separate ten-pass GraphJSON shape-inference implementation. Both +normal graph construction and GraphJSON loading now pass typed operations through +`GraphRecorder::record_operation` and `infer_operation_descriptors`. This directly removed: + +- R1: missing Resample2d output-shape inference. +- R2: inconsistent RoundEven spelling and missing Clamp inference. +- R3: missing Conv2d output-shape inference. +- R4: inconsistent LogicalNot spelling. +- R5: inconsistent LogicalAnd spelling. + +The full manifest was tested at `7f07a5e1`, not bisected at `2e22b3db`; attribution is based on +the code changes between the recorded revisions. A focused reload benchmark found the changed +graph reconstruction/inference stage about 6% faster on seven cached graphs, while end-to-end +weight-heavy loading was effectively unchanged. + +## Maintenance rule + +When a new complete sweep is recorded: + +1. Replace the latest metadata, summary, failure counts, timing, and the **single** case ledger. +2. Add one concise history row only when a code/revision change explains coverage movement. +3. Do not append another historical case ledger or duplicate obsolete failure-family tables. +4. Distinguish manifest/population changes from improvements on comparable cases. diff --git a/docs/operator-conversion-plan.md b/docs/operator-conversion-plan.md deleted file mode 100644 index 704b23b..0000000 --- a/docs/operator-conversion-plan.md +++ /dev/null @@ -1,483 +0,0 @@ -# ONNX → WebNN Operator Conversion Plan - -Phased plan for **onnx2webnn** to convert every **ai.onnx** operator at its **latest schema -version** within the current ONNX standard. Baseline: **opset 26** (ONNX package 1.21.0, -`onnx.defs.onnx_opset_version()`). - -Each stage below is a **conversion category**, not a calendar milestone. Work within a stage can -proceed in parallel once opset-26 schema audits and the per-op workflow (see [Implementation -workflow](#implementation-workflow)) are in place. - ---- - -## Goals - -1. Accept models declaring **ai.onnx opset 26** (raise `MIN/MAX_SUPPORTED_OPSET` from 11–18). -2. Convert every ONNX operator that has a WebNN target — directly, via decomposition, or via - convert-time constant folding. -3. Reject or pre-fold operators with no WebNN equivalent, with explicit error messages. -4. Track progress against a regenerated opset inventory (`webnn_exporter_supported` column). - -## Non-goals - -- Supporting multiple ONNX opsets simultaneously (target opset 26 only after completion). -- Executing converted graphs at runtime (conversion validates via rustnn ORT `build()` only). -- Implementing ONNX control flow (`If`, `Loop`, `Scan`) as WebNN graphs. - ---- - -## Coverage baseline (opset 26) - -| Category | Count | Share | -|----------|------:|------:| -| Total `ai.onnx` operators at opset 26 | 198 | 100% | -| **Stage 1 — Direct mappable** | ~95 | ~48% | -| **Stage 2 — Complex mappable** | ~19 | ~10% | -| **Stage 3 — Impossible to map** | ~84 | ~42% | - -| Implementation status (today) | Count | -|-------------------------------|------:| -| Handlers in `src/onnx/ops/*.rs` | 59 | -| Stage 1 backlog (direct, not yet exported) | ~36 | -| Stage 2 backlog (complex) | ~19 | - -Regenerate counts after each handler lands: - -```powershell -.\.venv\Scripts\python.exe scripts\generate_onnx_opsets.py --min 26 --max 26 -o docs\onnx-opsets -.\.venv\Scripts\python.exe -c "import csv; r=list(csv.DictReader(open('docs/onnx-opsets/opset-26.csv'))); print(sum(1 for x in r if x['webnn_exporter_supported']=='yes'))" -``` - -`generate_onnx_opsets.py` defaults to `../webnn-graph/docs/onnx-opsets/`; pass `-o docs\onnx-opsets` to keep -inventory CSVs in this repo. - -**Related artifacts** - -| Artifact | Path | -|----------|------| -| Operator inventory (regenerate for opset 26) | `docs/onnx-opsets/opset-26.csv` (or `../webnn-graph/docs/onnx-opsets/`) | -| Exporter manifest | `scripts/webnn_onnx_ops.py` | -| Op handlers (Rust) | `src/onnx/ops/*.rs`, registry in `src/onnx/ops/mod.rs` | -| Opset gate | `src/onnx/convert.rs` (`MIN/MAX_SUPPORTED_OPSET`) | -| ONNX ↔ WebNN name map | `webnn-onnx-utils/src/operation_names.rs` | -| Per-op conversion tests | `tests/onnx_ops/{category}/{op}.rs` (auto-generated) | -| Test generator | `scripts/generate_rust_op_conversion_tests.py` | -| Model audit CLI | `scripts/onnx_ops_to_csv.py --check-webnn` | -| Opset upgrade (ad hoc) | `scripts/upgrade_onnx_opset.py` | - -**Python scripts (`scripts/`)** - -| Script | Role | -|--------|------| -| `webnn_onnx_ops.py` | Supported-op manifest; keep in sync with `src/onnx/ops/*.rs` | -| `generate_rust_op_conversion_tests.py` | Regenerate `tests/onnx_ops/` and `tests/onnx_op_tests.rs` | -| `onnx_fixture_builders.py`, `onnx_test_builders.py`, `rust_model_emitter.py` | Libraries used by the test generator | -| `onnx_ops_to_csv.py` | Audit a model's operators and WebNN support | -| `upgrade_onnx_opset.py` | Upgrade a model to a target `ai.onnx` opset | -| `generate_onnx_opsets.py` | Per-opset operator inventory CSVs (`webnn_exporter_supported` column) | - -Install deps from repo root: `pip install -r requirements.txt` (needs `onnx`, `numpy`). - ---- - -## Three conversion stages - -``` -ONNX model (opset 26, latest schema per op) - │ - ▼ -┌───────────────────────┐ -│ Opset gate │ convert.rs → opset 26 only -└───────────┬───────────┘ - ▼ -┌───────────────────────┐ -│ Stage 1: Direct │ 1 ONNX node → 1 WebNN op (± trivial attrs) -└───────────┬───────────┘ - ▼ -┌───────────────────────┐ -│ Stage 2: Complex │ fold, decompose, or multi-node subgraph -└───────────┬───────────┘ - ▼ -┌───────────────────────┐ -│ Stage 3: Impossible │ reject (or fold-only for metadata ops) -└───────────────────────┘ -``` - -### Stage 1 — Direct mappable operations - -ONNX operators that map to a **single** [WebNN `MLGraphBuilder` -method](https://www.w3.org/TR/webnn/#operators) with straightforward attribute and I/O translation. -No multi-node decomposition required; optional schema-version audit for ops whose active schema -changed at opset 22/25/26. - -#### 1a. Implemented today (59) - -| Group | Operators | Rust module | -|-------|-----------|-------------| -| Linear | MatMul, Gemm | `matmul.rs` | -| Convolution | Conv, ConvTranspose | `conv.rs` | -| Pooling | MaxPool, AveragePool, GlobalMaxPool, GlobalAveragePool | `pool.rs` | -| Elementwise binary | Add, Sub, Mul, Div, Pow, Min, Max | `elementwise.rs` | -| Comparison | Greater, Less, Equal, GreaterOrEqual, LessOrEqual | `comparison.rs` | -| Conditional | Where | `conditional.rs` | -| Normalization | LayerNormalization, Softmax | `normalization.rs` | -| Shape | Reshape, Transpose, Concat, Split, Unsqueeze, Squeeze, Tile, Expand, Flatten | `reshape.rs` | -| Conversion | Cast, Constant | `conversion.rs` | -| Utility | Shape, Gather, Slice, ConstantOfShape, Range, Trilu, OneHot | `utility.rs` | -| Reduction | ReduceMean, ReduceSum, ReduceMax, ReduceMin | `reduction.rs` | -| Activation / unary | Relu, Gelu, Tanh, Sigmoid, Sqrt, Exp, Log, Abs, Neg, Erf, Cos, Sin, Identity | `activation.rs` | -| Scatter | ScatterND | `scatter.rs` | -| Pad | Pad | `pad.rs` | - -#### 1b. Direct backlog — not yet exported (~36) - -Implement as single-op handlers. Suggested Rust home in parentheses. - -| Group | ONNX → WebNN | Module | -|-------|--------------|--------| -| Normalization | BatchNormalization → `batchNormalization()` | `normalization.rs` | -| Normalization | InstanceNormalization → `instanceNormalization()` | `normalization.rs` | -| Gather | GatherND → `gatherND()` | `utility.rs` | -| Gather | GatherElements → `gatherElements()` | `utility.rs` | -| Scatter | ScatterElements → `scatterElements()` | `scatter.rs` | -| Resample | Resize → `resample2d()` | new `resize.rs` | -| Activation | Elu → `elu()`, LeakyRelu → `leakyRelu()`, PRelu → `prelu()` | `activation.rs` | -| Activation | HardSigmoid → `hardSigmoid()`, HardSwish → `hardSwish()` | `activation.rs` | -| Activation | Softplus → `softplus()`, Softsign → `softsign()` | `activation.rs` | -| Unary math | Ceil → `ceil()`, Floor → `floor()`, Reciprocal → `reciprocal()` | `activation.rs` | -| Unary math | Sign → `sign()`, Tan → `tan()`, Round → `roundEven()` | `activation.rs` | -| Clamp | Clip → `clamp()` | `activation.rs` | -| Logical | Not → `logicalNot()`, And → `logicalAnd()`, Or → `logicalOr()`, Xor → `logicalXor()` | `comparison.rs` | -| Predicate | IsNaN → `isNaN()`, IsInf → `isInfinite()` | `comparison.rs` | -| Reduction | ArgMin → `argMin()`, ArgMax → `argMax()` | `reduction.rs` | -| Reduction | ReduceL1 → `reduceL1()`, ReduceL2 → `reduceL2()` | `reduction.rs` | -| Reduction | ReduceLogSum → `reduceLogSum()`, ReduceLogSumExp → `reduceLogSumExp()` | `reduction.rs` | -| Reduction | ReduceProd → `reduceProduct()`, ReduceSumSquare → `reduceSumSquare()` | `reduction.rs` | -| Reduction | CumSum → `cumulativeSum()` | `reduction.rs` | -| Pool | LpPool → `l2Pool2d()`, GlobalLpPool → `globalAveragePool` + L2 (verify spec) | `pool.rs` | -| Sequence | ReverseSequence → `reverse()` | `utility.rs` | - -**Stage 1 exit criteria:** ~95 operators with `webnn_exporter_supported=yes`; all direct-backlog -fixtures convert; Birds EfficientNet and a small transformer ONNX convert without unsupported-op -errors for Stage 1 ops. - -**Suggested implementation order (by model impact):** - -1. BatchNormalization, InstanceNormalization, GatherND, GatherElements, ScatterElements, Resize -2. Remaining activations and unary math (Elu, LeakyRelu, PRelu, Clip, Ceil, Floor, …) -3. Reduction family completion (ArgMin/Max, ReduceL1/L2/…, CumSum) -4. Logical ops (Not, And, Or, Xor, IsNaN, IsInf) -5. LpPool, ReverseSequence - ---- - -### Stage 2 — Complex mappable operations - -Operators **can** be lowered to WebNN, but conversion requires one or more of: - -- **Convert-time constant folding** (no runtime graph node) -- **Multi-node decomposition** into Stage 1 ops -- **Non-trivial attribute / mode mapping** (document limitations and reject unsupported modes early) -- **Schema-version-specific inputs** (e.g. opset 25 `pads` tensor on Pad) - -#### 2a. Convert-time fold only (no runtime WebNN op) - -These appear in graphs but become constants or metadata during `--optimize` / conversion — not -`MLGraphBuilder` nodes at inference time. - -| Operator | Strategy | Notes | -|----------|----------|-------| -| Constant | Inline tensor data into graph const pool | Already handled; audit opset 25 schema | -| ConstantOfShape | Fold when shape is static | Audit opset 25 `value` attribute | -| Range | Fold when start/limit/delta are constant | | -| Shape | Fold when input shape is known | | -| Size | Fold to scalar product of static dimensions | Reject if shape is dynamic | - -#### 2b. Decompose into Stage 1 subgraphs - -| Operator | Decomposition sketch | Priority | -|----------|---------------------|----------| -| CastLike | Type-infer `to` from second input → `Cast` | P1 | -| Mean | `ReduceMean` over all axes (or specified) | P2 | -| Sum | `ReduceSum` over all axes (or specified) | P2 | -| LogSoftmax | `Softmax` + `Log` | P2 | -| Hardmax | `Softmax` + one-hot style mask (or reject axis edge cases) | P3 | -| Swish | `Sigmoid` × input (`Mul`) | P2 | -| Celu | `Elu` variant with alpha (after Elu lands) | P3 | -| Selu | scale × ELU-like piecewise | P3 | -| Mish | `Softplus` + `Tanh` + `Mul` (after Softplus lands) | P3 | -| ThresholdedRelu | `Relu` + comparison + `Where` | P3 | -| GroupNormalization | Mean/var over group dims + `Sub`/`Div`/`Mul`/`Add` | P1 — common in vision | -| RMSNormalization | L2 norm over axes + scale (new in opset 23) | P1 — common in LLMs | -| SimplifiedLayerNormalization | Alias of RMSNormalization | Implemented — standard/exporter fusion | -| SkipSimplifiedLayerNormalization | Residual/bias add + RMSNormalization | Implemented — com.microsoft fusion | -| RotaryEmbedding | Gather caches + rotate pairs + concatenate | Implemented — standard and com.microsoft | -| MatMulNBits | uint4/uint8 reinterpret + dequantizeLinear + transpose + matmul | Implemented — bits=4/8; reject g_idx | -| MatMulInteger | centered float `matmul` + `int32` cast (mirrors ConvInteger) | Implemented — scalar or 1-D zero points | -| Einsum | reduceSum + transpose/reshape + batched `matmul` | Implemented — ≤2 inputs; reject ellipsis/diagonals | -| MatMulBnb4 | packed uint8 constant, nibbles unpacked with int32 div/sub, codebook `gather`, per-block absmax `mul`, `matmul` | Implemented; weight stays 4-bit in the graph, dense fallback when N*K is not a whole number of blocks | -| GroupQueryAttention | reshape/transpose + KV-cache concat + scaled matmul + static causal mask + softmax | Implemented — unpadded batch assumption; reject packed QKV/do_rotary/softcap/local window | -| MoE | dense expert evaluation (batched matmul) + iterative top-k mask + masked softmax blend | Implemented — fused interleaved swiglu/relu/gelu/sigmoid; reject fc3/sparse mixer; num_experts/k× FLOP overhead | -| QMoE | uint4/uint8 expert constants with blockwise `dequantizeLinear`, then the MoE lowering | Implemented; same restrictions as MoE, dense fallback when `in` is not a whole number of blocks | -| GatherBlockQuantized | `gather` packed rows, scales and zero points, unpack nibbles, `dequantizeLinear` on the gathered slice | Implemented; 2-D tables along axis 0 (uint8, INT4, UINT4), other layouts dequantize at conversion time | -| SplitToSequence + SequenceAt | per-element slices registered as pseudo-operands, SequenceAt aliases by constant index | Implemented — torch split() export pattern; sequence graph outputs rejected | -| If (constant condition) | branch inlined into the outer graph before conversion; a boolean graph input gate (optimum's `use_cache_branch`) becomes constant via `--pin-input` | Implemented — data-dependent conditions remain unsupported | -| LSTM/GRU (direction=bidirectional/reverse) | WebNN native direction support + [dirs,...] bias split and Y layout | Implemented — LSTM initial_h/initial_c wired; sequence_lens/peephole rejected | -| CumProd | `cumulativeSum` on log + `Exp`, or iterative multiply subgraph | P3 — new in opset 26 | - -#### 2c. Advanced single-entry WebNN ops (high attribute complexity) - -Treat as Stage 2 because spec coverage and testing cost exceed typical Stage 1 ops. - -| Operator | WebNN target | Key risks | -|----------|--------------|-----------| -| GRU | `gru()` / `gruCell()` | Layout, direction, hidden size, optional inputs | -| LSTM | `lstm()` / `lstmCell()` | Same as GRU; bidirectional | -| RNN | Partial via GRU/LSTM or reject | No dedicated WebNN `rnn()` | -| QuantizeLinear | `quantizeLinear()` | Scale/zero-point types, axis | -| DequantizeLinear | `dequantizeLinear()` | Pair with quant models | -| Pad | `pad()` | Opset 25: `pads` as input tensor; verify `pad.rs` | -| Resize | `resample2d()` | Coordinate transformation modes, cubic exclusion | -| Gemm | `gemm()` | Already exported; audit transA/transB, alpha/beta at opset 13+ | -| Conv / Pool | `conv2d()` / pools | Audit opset 22 dilations, auto_pad, ceil_mode | - -**Stage 2 exit criteria:** All fold/decompose paths documented in lowering notes; GroupNormalization -and RMSNormalization decompositions tested on at least one real model; GRU/LSTM/quant handlers -pass unit tests with documented limitations. - ---- - -### Stage 3 — Impossible to map operations - -No WebNN `MLGraphBuilder` equivalent exists. **Do not** add graph handlers. Strategy: **reject** -at conversion with a clear message, unless the op can be eliminated earlier (optimizer, training -vs inference export). - -#### 3a. Control flow and graph structure - -| Operators | Reason | -|-----------|--------| -| If, Loop, Scan | WebNN has no conditional or recurrent graph execution | - -#### 3b. Sequence and optional types - -| Operators | Reason | -|-----------|--------| -| SequenceAt, SequenceConstruct, SequenceEmpty, SequenceErase, SequenceInsert, SequenceLength, SequenceMap, SplitToSequence, ConcatFromSequence | WebNN tensors only; no sequence type | -| Optional, OptionalGetElement, OptionalHasElement | No optional type in WebNN | - -#### 3c. Strings and NLP utilities - -| Operators | Reason | -|-----------|--------| -| StringConcat, StringSplit, StringNormalizer, RegexFullMatch, TfIdfVectorizer | No string tensors in WebNN | - -#### 3d. Random and stochastic - -| Operators | Reason | -|-----------|--------| -| Bernoulli, Multinomial, RandomNormal, RandomNormalLike, RandomUniform, RandomUniformLike | Non-deterministic; no WebNN RNG ops | - -#### 3e. Training, loss, and dropout - -| Operators | Reason | -|-----------|--------| -| Dropout, NegativeLogLikelihoodLoss, SoftmaxCrossEntropyLoss | Training-only; inference graphs should fold or omit | - -#### 3f. Detection, vision, and spatial extras - -| Operators | Reason | -|-----------|--------| -| NonMaxSuppression, RoiAlign, DeformConv, GridSample, ImageDecoder, MaxRoiPool, MaxUnpool, AffineGrid, CenterCropPad, Col2Im, DepthToSpace, SpaceToDepth | No WebNN ops; post-processing or custom CUDA territory | - -#### 3g. Signal processing and windows - -| Operators | Reason | -|-----------|--------| -| DFT, STFT, MelWeightMatrix, BlackmanWindow, HammingWindow, HannWindow | No WebNN signal ops | - -#### 3h. Quantized conv / matmul (integer) - -| Operators | Reason | -|-----------|--------| -| QLinearConv, QLinearMatMul | Fused quantized ops not in WebNN; use explicit float + quantization decomposition. `MatMulInteger` is now implemented via centered float matmul | - -`ConvInteger` is supported through centered float `conv2d` followed by an `int32` cast, -`DynamicQuantizeLinear` is decomposed into reductions, scale/zero-point arithmetic, and WebNN -`quantizeLinear`, and `MatMulNBits` (bits=4) follows ORT's WebNN EP path: -`dequantizeLinear` → reshape → transpose → `matmul` (+ optional bias). - -#### 3i. Bitwise and type reinterpretation - -| Operators | Reason | -|-----------|--------| -| BitwiseAnd, BitwiseOr, BitwiseXor, BitwiseNot, BitShift, **BitCast** (new opset 26) | No bitwise ops in WebNN | - -#### 3j. Linear algebra and einsum - -| Operators | Reason | -|-----------|--------| -| Det | No determinant in WebNN. `Einsum` is now implemented for one/two-input equations without ellipsis or repeated per-term labels (lowered to reduceSum/transpose/reshape/matmul) | - -#### 3k. Indexing and sorting extras - -| Operators | Reason | -|-----------|--------| -| TopK, Unique, Compress, NonZero, OneHot, EyeLike | No WebNN equivalents | -| **TensorScatter** (opset 24) | No WebNN op; use ScatterND/ScatterElements subset only | - -#### 3l. Attention and transformer extras (recent ONNX) - -| Operators | Reason | -|-----------|--------| -| **Attention** (opset 24) | Composite op; no single WebNN op — could be Stage 2 decomposition in future, but out of scope until spec stabilizes | -| **MoE** (com.microsoft) | Requires TopK expert routing and efficient sparse dispatch, neither available in WebNN | - -#### 3m. Miscellaneous - -| Operators | Reason | -|-----------|--------| -| Acos, Acosh, Asin, Asinh, Atan, Atanh, Cosh, Sinh | No inverse/hyperbolic unary in WebNN | -| LRN, LpNormalization, MeanVarianceNormalization | Legacy normalization; decompose or reject | -| Mod, Shrink | No direct WebNN op | -| Mod, Mish (as dedicated op) | Prefer Stage 2 decomposition via Swish/Mish subgraph if needed | - -**Stage 3 handling** - -| Strategy | When | Example | -|----------|------|---------| -| Reject | No WebNN op and not foldable | If, Loop, Attention | -| Pre-fold / strip | Training artifact in inference export | Dropout → Identity | -| Document workaround | Rare; manual graph rewrite | Replace TopK with external post-process | - ---- - -## Opset 26 schema revisions - -Operators whose **active schema version** changed at or after opset 21 need a fixture pass even when -already exported. Priority: schema v25 and v26. - -| Operator | Active schema | Exporter today | Action | -|----------|--------------:|----------------|--------| -| Cast | 25 | yes | Audit `to` / saturate rules | -| Constant | 25 | yes | Verify sparse / value attributes | -| ConstantOfShape | 25 | yes | Verify optional `value` | -| Flatten | 25 | yes | Verify `axis` default | -| Identity | 25 | yes | Smoke test | -| Reshape | 25 | yes | Verify `allowzero` | -| Shape | 25 | yes | Constant-fold path | -| Squeeze | 25 | yes | Verify axes input | -| Transpose | 25 | yes | Verify optional `perm` | -| Unsqueeze | 25 | yes | Verify axes input | -| Pad | 25 | yes | Confirm `pads` tensor input | -| If, Loop, Scan | 25 | no | Reject | -| CastLike | 25 | no | Stage 2 decompose | -| QuantizeLinear, DequantizeLinear | 25 | no | Stage 2 | -| Conv, ConvTranspose, MaxPool, AveragePool, … | 22 | partial | Audit dilations, auto_pad | -| **BitCast** | 26 | no | Stage 3 reject | -| **CumProd** | 26 | no | Stage 2 decompose | - -Regenerate per-op Rust conversion tests (fixture opset 26, converter gate opset 18 today): - -```powershell -.\.venv\Scripts\python.exe scripts\generate_rust_op_conversion_tests.py --fixture-opset 26 --test-opset 18 -cargo test --test onnx_op_tests -``` - ---- - -## Implementation workflow - -Use this checklist for **every** operator added in Stages 1–2: - -- [ ] **Audit real models** — `scripts/onnx_ops_to_csv.py model.onnx --check-webnn` -- [ ] **Read ONNX schema** — `onnx.defs.get_schema(op, 26)` or opset-26 sidecar fixture -- [ ] **Read WebNN spec** — method signature, options, `opSupportLimits` -- [ ] **Implement handler** — `supports()` + `convert()` in `src/onnx/ops/*.rs` -- [ ] **Shape inference** — update `src/onnx/shape_inference.rs` if needed -- [ ] **Register** — add to `OpRegistry` in `src/onnx/ops/mod.rs` -- [ ] **Sync manifest** — add to `scripts/webnn_onnx_ops.py` -- [ ] **Update name map** — `webnn-onnx-utils/src/operation_names.rs` if new mapping -- [ ] **Regenerate inventory** — `scripts/generate_onnx_opsets.py --min 26 --max 26 -o docs/onnx-opsets` -- [ ] **Regenerate tests** — `scripts/generate_rust_op_conversion_tests.py --fixture-opset 26 --test-opset 18` -- [ ] **Unit tests** — handler tests; `cargo test --test onnx_op_tests {op_snake}::opset26` -- [ ] **Convert smoke test** — `cargo run -- convert --input model.onnx --optimize` on a real model using the op - ---- - -## Rollout phases - -Cross-cutting milestones that span the three stages: - -### Phase 0 — Foundation - -| Task | Files | -|------|-------| -| Regenerate opset-26 inventory | `scripts/generate_onnx_opsets.py` → `docs/onnx-opsets/` | -| Set opset gate to 26 only | `src/onnx/convert.rs`, `scripts/webnn_onnx_ops.py` | -| Opset 25/26 schema audit for 59 implemented ops | handlers + fixtures | -| Sync `webnn_onnx_ops.py` with Rust registry | `scripts/webnn_onnx_ops.py` | - -**Exit:** Converter accepts opset 26, rejects 18; 59 ops pass v26 fixture conversion. - -### Phase 1 — Stage 1 high-impact gaps - -BatchNormalization, InstanceNormalization, GatherND, GatherElements, ScatterElements, Resize, Pad -v25 verification. - -**Exit:** EfficientNet-class vision models convert; inventory ≥ 66 supported ops. - -### Phase 2 — Stage 1 activations, unary, logical, reductions - -Complete Stage 1 backlog table in [§1b](#1b-direct-backlog--not-yet-exported-36). - -**Exit:** ~95 supported ops; Stage 1 complete. - -### Phase 3 — Stage 2 decomposition and advanced ops - -GroupNormalization, RMSNormalization, CastLike, Mean/Sum/LogSoftmax, Swish; then GRU/LSTM/quant. - -**Exit:** Documented decomposition paths; optional advanced coverage for seq2seq and int8. - -### Phase 4 — Documentation and CI - -- Maintain supported/reject lists in lowering docs -- CI: `scripts/onnx_ops_to_csv.py model.onnx --check-webnn` on checked-in ONNX models -- Optional: generate `webnn_onnx_ops.py` from Rust `supports()` lists - ---- - -## Progress tracking - -| Milestone | Ops exported | Opset gate | Notes | -|-----------|-------------:|------------|-------| -| **Today** | 59 | 11–18 | ~30% of opset 26 | -| Phase 0 done | 59 | 26 only | v25/v26 schema audit | -| Phase 1 done | ~66 | 26 only | Vision models | -| Phase 2 done | ~95 | 26 only | Stage 1 complete | -| Phase 3 done | ~95+ | 26 only | Stage 2 decompositions | -| Stage 3 | — | 26 only | 84 ops permanently rejected | - ---- - -## Risk register - -| Risk | Mitigation | -|------|------------| -| Schema v26 differs from v18 for same op name | Mandatory opset-26 fixture per op; read `schema_since_version` | -| WebNN `resample2d` ≠ ONNX `Resize` | Document mode/coordinate mapping; reject unsupported modes | -| `webnn_onnx_ops.py` drifts from Rust | Regenerate CSV after every op; optional codegen from `supports()` | -| GroupNormalization / RMSNormalization in new models | Stage 2 decomposition before reject | -| Attention / RotaryEmbedding in LLM exports | Stage 3 reject until decomposition designed | -| Dynamic shapes | `--optimize` constant folding; static shapes at runtime | -| MLGraphBuilder migration | Align handlers with [mlgraphbuilder-migration-plan.md](./mlgraphbuilder-migration-plan.md) | - ---- - -## References - -- [W3C WebNN specification](https://www.w3.org/TR/webnn/) -- [ONNX Operator Schemas](https://github.com/onnx/onnx/blob/main/docs/Operators.md) -- [webnn-onnx-utils operation_names.rs](../../webnn-onnx-utils/src/operation_names.rs) -- [webnn-graph opset 21 plan (historical)](../../webnn-graph/docs/onnx-opsets/opset21-implementation-plan.md) -- [onnx2webnn AGENTS.md](../AGENTS.md) diff --git a/docs/operator-conversion-status.md b/docs/operator-conversion-status.md new file mode 100644 index 0000000..a6ade0f --- /dev/null +++ b/docs/operator-conversion-status.md @@ -0,0 +1,302 @@ +# ONNX → WebNN operator conversion status + +> **Last edited:** `2026-09-18T13:48:47Z`
+> **Checkout:** `fkrall/cache-backed-validation` at `558f641` +> +> **Freshness:** Use this document only when this provenance is recent relative to the relevant +> code and commits; otherwise verify the implementation, tests, and Git history before relying +> on it. + +This document is a capability inventory for the current **onnx2webnn** branch. It contains no +implementation phases or rollout plan. The baseline is the 198 non-deprecated standard +`ai.onnx` operator names active at opset 26. + +An operator marked implemented has at least one executable form. It does not imply that every +schema revision, attribute combination, data type, rank, optional input/output, or dynamic-shape +form is supported. The scope column records known material restrictions. + +## Status categories + +| Category | Meaning | +|----------|---------| +| **Direct WebNN** | At least one supported form primarily maps to a standardized WebNN builder primitive. | +| **Decomposed** | Executes through multiple WebNN primitives because no matching native primitive exists. | +| **Folded/static** | Resolved or materialized while converting. | +| **Pattern-only** | Executes only when a specific static export pattern can be eliminated or inlined. | +| **Unsupported** | No executable lowering exists on this branch. | + +Direct WebNN is the spec-level subset of executable support. Decomposed, folded, and pattern-only +operators execute, but are not native WebNN operator mappings. + +### Estimated performance impact + +The performance-impact column is populated only for **Decomposed** operators. These estimates are +derived from the current emitted WebNN graph, not from cross-backend benchmarks; actual cost depends +on tensor shapes, backend fusion, memory movement, and hardware. + +| Tier | Meaning | +|------|---------| +| **minimal** | A small number of cheap elementwise, cast, or shape operations; backend fusion can often remove most overhead. | +| **moderate** | Multiple full-tensor passes, reductions, or materialized intermediates are expected to add noticeable overhead. | +| **high** | The lowering loses a fused, quantized, integer-accumulation, attention, or sparse-dispatch path and can materially increase compute or memory traffic. | +| **not inferred** | Impact varies too strongly with the accepted equation, input count, shape, or backend to estimate from the lowering alone. | + +## Coverage + +| Opset-26 standard ONNX coverage | Count | Share | +|---------------------------------|------:|------:| +| Total non-deprecated `ai.onnx` names | 198 | 100% | +| **Executable for at least one supported form** | **129** | **65.2%** | +| └ Direct WebNN/spec-level subset | **96** | **48.5%** | +| └ Decomposed, folded, or pattern-only | **33** | **16.7%** | +| Unsupported | 69 | 34.8% | + +The 129 executable names comprise 127 standard names advertised by +`scripts/webnn_onnx_ops.py`, plus the narrow registry-only `SplitToSequence` and `SequenceAt` +pattern. The 96 direct names comprise 86 mappings in the pinned `webnn-onnx-utils` name map plus +ten direct handlers not yet mirrored there: `CumSum`, `GatherElements`, `GatherND`, `IsInf`, +`IsNaN`, `LpPool`, `ReverseSequence`, `Round`, `ScatterElements`, and `ScatterND`. + +## Complete opset-26 status matrix + +| ONNX operator | Status | Execution scope and WebNN limitation | Estimated perf impact | +|---------------|--------|--------------------------------------|-----------------------| +| `Abs` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Acos` | Unsupported | No current executable lowering. | +| `Acosh` | Decomposed | Elementwise decomposition; WebNN has no native acosh. | moderate | +| `Add` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `AffineGrid` | Unsupported | No current lowering; the required vision/spatial primitive is absent or not implemented. | +| `And` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `ArgMax` | Direct WebNN | Direct WebNN argMax; select_last_index is not implemented. | +| `ArgMin` | Direct WebNN | Direct WebNN argMin; select_last_index is not implemented. | +| `Asin` | Unsupported | No current executable lowering. | +| `Asinh` | Decomposed | Elementwise decomposition; WebNN has no native asinh. | moderate | +| `Atan` | Unsupported | No current executable lowering. | +| `Atanh` | Decomposed | Elementwise decomposition; WebNN has no native atanh. | moderate | +| `Attention` | Unsupported | No native WebNN attention; no standard Attention decomposition is implemented. | +| `AveragePool` | Direct WebNN | Direct WebNN pool; 1-D is emulated, higher spatial ranks rejected, and some ONNX padding modes are restricted. | +| `BatchNormalization` | Direct WebNN | Direct WebNN primitive; inference mode only. | +| `Bernoulli` | Unsupported | WebNN has no graph random-number operators. | +| `BitCast` | Unsupported | WebNN has no corresponding bitwise/reinterpretation primitive. | +| `BitShift` | Unsupported | WebNN has no corresponding bitwise/reinterpretation primitive. | +| `BitwiseAnd` | Unsupported | WebNN has no corresponding bitwise/reinterpretation primitive. | +| `BitwiseNot` | Unsupported | WebNN has no corresponding bitwise/reinterpretation primitive. | +| `BitwiseOr` | Unsupported | WebNN has no corresponding bitwise/reinterpretation primitive. | +| `BitwiseXor` | Unsupported | WebNN has no corresponding bitwise/reinterpretation primitive. | +| `BlackmanWindow` | Unsupported | WebNN has no corresponding signal-processing primitive. | +| `Cast` | Direct WebNN | Direct WebNN cast where the target type supports it; numeric-to-Boolean casts normalize nonzero values through a WebNN comparison. Unsupported ONNX/string element types are rejected. | +| `CastLike` | Decomposed | Infers the second input's type at conversion time, then emits WebNN cast. | minimal | +| `Ceil` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Celu` | Decomposed | Elementwise decomposition; no native WebNN celu. | moderate | +| `CenterCropPad` | Unsupported | No current lowering; the required vision/spatial primitive is absent or not implemented. | +| `Clip` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Col2Im` | Unsupported | No current lowering; the required vision/spatial primitive is absent or not implemented. | +| `Compress` | Unsupported | No current lowering; missing primitive and/or data-dependent output shape. | +| `Concat` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `ConcatFromSequence` | Unsupported | WebNN has no sequence operand type. | +| `Constant` | Folded/static | Creates an inline WebNN constant; no runtime ONNX operation remains. | +| `ConstantOfShape` | Folded/static | Materialized or expanded from statically resolvable shape/value data. | +| `Conv` | Direct WebNN | Direct WebNN conv2d; 1-D is emulated and 3-D is unsupported. | +| `ConvInteger` | Decomposed | Centers values in float, executes conv2d, then casts to int32; WebNN lacks integer accumulation, so large sums may lose exactness. | high | +| `ConvTranspose` | Direct WebNN | Direct WebNN convTranspose2d; 1-D is emulated and 3-D is unsupported. | +| `Cos` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Cosh` | Decomposed | Elementwise decomposition; WebNN has no native cosh. | moderate | +| `CumProd` | Decomposed | exp(cumulativeSum(log(x))); valid only for positive floating-point inputs. Zero and negative inputs are not generally correct. | moderate | +| `CumSum` | Direct WebNN | Direct WebNN cumulativeSum; axis must be constant. | +| `DFT` | Unsupported | WebNN has no corresponding signal-processing primitive. | +| `DeformConv` | Unsupported | No current lowering; the required vision/spatial primitive is absent or not implemented. | +| `DepthToSpace` | Unsupported | No current lowering; the required vision/spatial primitive is absent or not implemented. | +| `DequantizeLinear` | Direct WebNN | Direct WebNN primitive for supported scalar/effectively-scalar or full-rank block parameters; axis must be 1. | +| `Det` | Unsupported | No current executable lowering. | +| `Div` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Dropout` | Unsupported | No current inference lowering; exporters may remove applicable training artifacts. | +| `DynamicQuantizeLinear` | Decomposed | Reduction/arithmetic decomposition followed by WebNN quantizeLinear. | moderate | +| `Einsum` | Decomposed | Static reduce/transpose/reshape/matmul decomposition; at most two inputs, no ellipsis or repeated labels within a term. | not inferred | +| `Elu` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Equal` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Erf` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Exp` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Expand` | Direct WebNN | Direct WebNN expand; target shape must be build-time resolvable. | +| `EyeLike` | Unsupported | No current lowering; missing primitive and/or data-dependent output shape. | +| `Flatten` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Floor` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `GRU` | Direct WebNN | Direct WebNN recurrent primitive with layout adaptation; sequence_lens and unsupported optional inputs are rejected. | +| `Gather` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `GatherElements` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `GatherND` | Direct WebNN | Direct WebNN gatherND; ONNX batch_dims is currently ignored. | +| `Gelu` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Gemm` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `GlobalAveragePool` | Direct WebNN | Direct WebNN pool; supported for the implemented 1-D/2-D spatial forms. | +| `GlobalLpPool` | Unsupported | No current executable lowering. | +| `GlobalMaxPool` | Direct WebNN | Direct WebNN pool; supported for the implemented 1-D/2-D spatial forms. | +| `Greater` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `GreaterOrEqual` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `GridSample` | Unsupported | No current lowering; the required vision/spatial primitive is absent or not implemented. | +| `GroupNormalization` | Decomposed | Reduction and elementwise decomposition; relevant ranks and dimensions must be known. | moderate | +| `HammingWindow` | Unsupported | WebNN has no corresponding signal-processing primitive. | +| `HannWindow` | Unsupported | WebNN has no corresponding signal-processing primitive. | +| `HardSigmoid` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `HardSwish` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Hardmax` | Decomposed | Comparison/mask decomposition; requires known input shape. | moderate | +| `Identity` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `If` | Folded/static | Only constant or pinned-constant conditions; the selected branch is resolved and inlined during conversion because WebNN has no control flow. Runtime-selectable models therefore require a separate `.webnn`/Safetensors pair per branch. Shared weights are not deduplicated, so keeping both compiled graphs resident may duplicate them in RAM and VRAM. | +| `ImageDecoder` | Unsupported | No current lowering; the required vision/spatial primitive is absent or not implemented. | +| `InstanceNormalization` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `IsInf` | Direct WebNN | Direct WebNN isInfinite; non-default ONNX detect_negative/detect_positive filtering is not implemented. | +| `IsNaN` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `LRN` | Unsupported | No current executable lowering. | +| `LSTM` | Direct WebNN | Direct WebNN recurrent primitive with layout adaptation; sequence_lens and peepholes are rejected. | +| `LayerNormalization` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `LeakyRelu` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Less` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `LessOrEqual` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Log` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `LogSoftmax` | Decomposed | Softmax/log decomposition; no native WebNN logSoftmax. | moderate | +| `Loop` | Unsupported | No WebNN runtime loop/control-flow representation. | +| `LpNormalization` | Unsupported | No current executable lowering. | +| `LpPool` | Direct WebNN | Direct WebNN l2Pool2d only when p=2; 1-D is emulated and higher spatial ranks rejected. | +| `MatMul` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `MatMulInteger` | Decomposed | Centers values in float, executes matmul, then casts to int32; WebNN lacks integer accumulation, so large sums may lose exactness. | high | +| `Max` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `MaxPool` | Direct WebNN | Direct WebNN pool; indices output is unsupported, 1-D is emulated, and higher spatial ranks are rejected. | +| `MaxRoiPool` | Unsupported | No current lowering; the required vision/spatial primitive is absent or not implemented. | +| `MaxUnpool` | Unsupported | No current lowering; the required vision/spatial primitive is absent or not implemented. | +| `Mean` | Decomposed | Variadic binary fold; operation order and floating-point rounding can differ. | not inferred | +| `MeanVarianceNormalization` | Unsupported | No current executable lowering. | +| `MelWeightMatrix` | Unsupported | WebNN has no corresponding signal-processing primitive. | +| `Min` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Mish` | Decomposed | Softplus/tanh/multiply decomposition; no native WebNN mish. | moderate | +| `Mod` | Decomposed | A - B * q(A/B) decomposition for both fmod modes; no WebNN remainder primitive. | moderate | +| `Mul` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Multinomial` | Unsupported | WebNN has no graph random-number operators. | +| `Neg` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `NegativeLogLikelihoodLoss` | Unsupported | No current inference lowering; exporters may remove applicable training artifacts. | +| `NonMaxSuppression` | Unsupported | No current lowering; the required vision/spatial primitive is absent or not implemented. | +| `NonZero` | Unsupported | No current lowering; missing primitive and/or data-dependent output shape. | +| `Not` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `OneHot` | Decomposed | Static comparison/mask decomposition; depth and values must be constant and indices shape known. | moderate | +| `Optional` | Unsupported | WebNN has no optional operand type. | +| `OptionalGetElement` | Unsupported | WebNN has no optional operand type. | +| `OptionalHasElement` | Unsupported | WebNN has no optional operand type. | +| `Or` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `PRelu` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Pad` | Direct WebNN | Direct WebNN pad; rank and non-negative pads must be known at build time. | +| `Pow` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `QLinearConv` | Unsupported | No fused WebNN quantized linear-algebra primitive; float decomposition is not implemented. | +| `QLinearMatMul` | Unsupported | No fused WebNN quantized linear-algebra primitive; float decomposition is not implemented. | +| `QuantizeLinear` | Direct WebNN | Direct WebNN primitive for supported scalar/effectively-scalar or full-rank block parameters; axis must be 1. | +| `RMSNormalization` | Decomposed | Reduction and elementwise decomposition; relevant ranks and dimensions must be known. | moderate | +| `RNN` | Unsupported | No current executable lowering. | +| `RandomNormal` | Unsupported | WebNN has no graph random-number operators. | +| `RandomNormalLike` | Unsupported | WebNN has no graph random-number operators. | +| `RandomUniform` | Unsupported | WebNN has no graph random-number operators. | +| `RandomUniformLike` | Unsupported | WebNN has no graph random-number operators. | +| `Range` | Folded/static | Implemented for constant scalar or supported bounded-symbolic forms. | +| `Reciprocal` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `ReduceL1` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `ReduceL2` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `ReduceLogSum` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `ReduceLogSumExp` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `ReduceMax` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `ReduceMean` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `ReduceMin` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `ReduceProd` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `ReduceSum` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `ReduceSumSquare` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `RegexFullMatch` | Unsupported | WebNN has no string tensor/operation support. | +| `Relu` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Reshape` | Direct WebNN | Direct WebNN reshape; target shape must be build-time resolvable. | +| `Resize` | Direct WebNN | Maps to WebNN resample2d; nearest/linear supported forms only, with 1-D emulation. Cubic and unsupported coordinate/axis modes are rejected. | +| `ReverseSequence` | Direct WebNN | Partial mapping to WebNN reverse: sequence_lens and batch_axis semantics are ignored, so only full-axis reversal is equivalent. | +| `RoiAlign` | Unsupported | No current lowering; the required vision/spatial primitive is absent or not implemented. | +| `RotaryEmbedding` | Decomposed | Gather/rotate/concat decomposition; concrete dimensions and compatible cache shapes required. | moderate | +| `Round` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `STFT` | Unsupported | WebNN has no corresponding signal-processing primitive. | +| `Scan` | Unsupported | No WebNN runtime loop/control-flow representation. | +| `ScatterElements` | Direct WebNN | Direct WebNN scatterElements; only reduction=none. | +| `ScatterND` | Direct WebNN | Direct WebNN scatterND; only reduction=none. | +| `Selu` | Decomposed | Elementwise decomposition; no native WebNN selu. | moderate | +| `SequenceAt` | Pattern-only | Only a constant index into the converter's SplitToSequence pseudo-sequence. | +| `SequenceConstruct` | Unsupported | WebNN has no sequence operand type. | +| `SequenceEmpty` | Unsupported | WebNN has no sequence operand type. | +| `SequenceErase` | Unsupported | WebNN has no sequence operand type. | +| `SequenceInsert` | Unsupported | WebNN has no sequence operand type. | +| `SequenceLength` | Unsupported | WebNN has no sequence operand type. | +| `SequenceMap` | Unsupported | WebNN has no sequence operand type. | +| `Shape` | Folded/static | Resolved from known graph shape metadata. | +| `Shrink` | Decomposed | Elementwise comparison/select decomposition; no native WebNN shrink. | moderate | +| `Sigmoid` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Sign` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Sin` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Sinh` | Decomposed | Elementwise decomposition; WebNN has no native sinh. | moderate | +| `Size` | Unsupported | No current executable lowering. | +| `Slice` | Direct WebNN | Direct WebNN slice/reverse; starts, sizes, axes, and steps must resolve at build time. Positive steps are preserved as strides; negative steps support only the full-axis reverse pattern. | +| `Softmax` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `SoftmaxCrossEntropyLoss` | Unsupported | No current inference lowering; exporters may remove applicable training artifacts. | +| `Softplus` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Softsign` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `SpaceToDepth` | Unsupported | No current lowering; the required vision/spatial primitive is absent or not implemented. | +| `Split` | Direct WebNN | Direct WebNN split; split sizes or the split dimension must be resolvable. | +| `SplitToSequence` | Pattern-only | Only the static split pattern consumed by constant-index SequenceAt; no sequence graph output. | +| `Sqrt` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Squeeze` | Direct WebNN | Direct WebNN squeeze; axes must be resolvable. | +| `StringConcat` | Unsupported | WebNN has no string tensor/operation support. | +| `StringNormalizer` | Unsupported | WebNN has no string tensor/operation support. | +| `StringSplit` | Unsupported | WebNN has no string tensor/operation support. | +| `Sub` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Sum` | Decomposed | Variadic binary fold; operation order and floating-point rounding can differ. | not inferred | +| `Swish` | Decomposed | Sigmoid/multiply decomposition; no native WebNN swish. | minimal | +| `Tan` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Tanh` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `TensorScatter` | Unsupported | No current lowering; missing primitive and/or data-dependent output shape. | +| `TfIdfVectorizer` | Unsupported | WebNN has no string tensor/operation support. | +| `ThresholdedRelu` | Decomposed | Comparison/select decomposition; no native WebNN thresholdedRelu. | minimal | +| `Tile` | Direct WebNN | Direct WebNN tile; repeats must be constant. | +| `TopK` | Unsupported | No current lowering; missing primitive and/or data-dependent output shape. | +| `Transpose` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Trilu` | Direct WebNN | Direct WebNN triangular; k must be resolvable. | +| `Unique` | Unsupported | No current lowering; missing primitive and/or data-dependent output shape. | +| `Unsqueeze` | Direct WebNN | Direct WebNN unsqueeze; axes must be resolvable. | +| `Where` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | +| `Xor` | Direct WebNN | Direct standardized WebNN primitive; handler schema checks and WebNN operand-type limits apply. | + +## Additional contrib and fused operators + +These eight advertised names are executable but are not standard `ai.onnx` opset-26 names, so +they are excluded from the 198-op percentages. + +| Operator | Status | Execution scope and WebNN limitation | Estimated perf impact | +|----------|--------|--------------------------------------|-----------------------| +| `GatherBlockQuantized` | Decomposed | Gathers packed rows/scales/zero points and dequantizes only the selected slice for supported 2-D axis-0 tables; other layouts fall back at conversion time. | moderate | +| `GroupQueryAttention` | Decomposed | Static attention/cache subgraph. WebNN has no fused attention; runtime sequence metadata is ignored and softcap/local-window modes are rejected. Fused backend kernels and the portable decomposition need not be bit-identical, so an immediately following dynamic quantizer can amplify legal rounding differences. | high | +| `MatMulBnb4` | Decomposed | Packed NF4/FP4 block path where representable; incompatible tails become dense constants, losing low-bit memory/performance benefits. | high | +| `MatMulNBits` | Decomposed | Supported 4/8-bit constant layouts dequantize into a matmul path; g_idx is rejected. WebNN has no low-bit matmul kernel. | high | +| `MoE` | Decomposed | All experts execute densely because WebNN has no TopK or sparse dispatch. Approximate compute overhead is num_experts/k; supported activation/fusion forms only. | high | +| `QMoE` | Decomposed | Supported 4/8-bit expert weights feed the same dense all-expert lowering; block-layout fallbacks may dequantize at conversion time. | high | +| `SimplifiedLayerNormalization` | Decomposed | RMS-style normalization decomposition; no matching WebNN fused primitive. | moderate | +| `SkipSimplifiedLayerNormalization` | Decomposed | Residual/bias plus simplified normalization decomposition; no matching WebNN fused primitive. | moderate | + +### MoE interpretation + +The previous documentation called MoE impossible because **native sparse MoE execution** cannot be +represented in standard WebNN. That limitation remains. This branch nevertheless executes the +supported `MoE` and `QMoE` forms by evaluating every expert and blending the selected outputs. +This is functional fallback support, not sparse-dispatch performance parity. + +## Reproducing the inventory + +```bash +python scripts/generate_onnx_opsets.py --min 26 --max 26 -o docs/onnx-opsets +python scripts/onnx_ops_to_csv.py model.onnx --check-webnn +``` + +Sources of truth: `scripts/webnn_onnx_ops.py`, the handler registry in +`src/onnx/ops/mod.rs`, handler restrictions in `src/onnx/ops/*.rs`, and the opset gate in +`src/onnx/convert.rs`. Numerical model results are tracked separately in +[model-validation-status.md](./model-validation-status.md). + +## References + +- [W3C WebNN specification](https://www.w3.org/TR/webnn/) +- [ONNX operator schemas](https://github.com/onnx/onnx/blob/main/docs/Operators.md) +- [ONNX Runtime MoE/QMoE notes](https://github.com/microsoft/onnxruntime/blob/main/docs/contrib_ops/cuda/moe_qmoe.md) diff --git a/docs/real-weight-validation-failures.md b/docs/real-weight-validation-failures.md new file mode 100644 index 0000000..87501e5 --- /dev/null +++ b/docs/real-weight-validation-failures.md @@ -0,0 +1,118 @@ +# Real-weight validation failures + +> **Last edited:** `2026-09-17T16:52:20Z`
+> **Checkout:** `fkrall/cache-backed-validation` at `25ea94c` +> +> **Freshness:** Use this document only when this provenance is recent relative to the relevant +> code and commits; otherwise verify the implementation, tests, and Git history before relying +> on it. + +This document is the current triage record for failures from full-model validation with publisher +weights. Generated-weight failures are intentionally out of scope. The summary and complete case +ledger remain in [Full-model numerical validation status](model-validation-status.md). + +## Recorded run + +- Date: 2026-09-17 +- Tested executable: onnx2webnn `25ea94c` +- RustNN: `28fb3bbe` +- Manifest: `tests/models/manifest.json` (51 current cases; verified within a completed 52-case superset) +- Runtime: CPU ONNX Runtime 1.29.0 +- Cache state: complete; no model downloads or skips +- Disk guard: minimum observed free space 78.7 GiB; stop threshold 15 GiB was not approached +- Result for the current manifest: 47 passed and 4 failed +- Recorded 52-case superset wall time: 7m 2.3s, one validation worker; peak RSS 26.4 GiB + +```bash +ORT_DYLIB_PATH=../rustnn/target/onnxruntime/onnxruntime-linux-x64-1.29.0/lib/libonnxruntime.so.1.29.0 \ + target/release/onnx2webnn validate-models \ + --selection all --weights real --jobs 1 +``` + +The current manifest is the tested 52-case run minus the passing FP32 Tiny RoFormer case; every +retained case was executed in that run. + +The recorded result for each case is its first blocker. A case that fails before comparison may +contain further converter, serialization, reload, execution, or numerical issues that are not yet +observable. + +## Failure groups + +| Code | Cases | Furthest stage | Classification | Current ownership | +|------|------:|----------------|----------------|-------------------| +| U1 | 1 | Output comparison | Unsupported strict fused/decomposed attention equivalence across dynamic quantization boundaries | FastVLM prefill remains blocked; its decode specialization passes. | +| Q1 | 1 | Output comparison | Unsupported `MatMulNBits accuracy_level=4` execution semantics | Requires a WebNN/backend mechanism for Int8-quantized activations with packed q4 weights; do not relax tolerance to hide it. | +| O1 | 2 | Native ORT model load | The publisher ONNX is rejected before conversion can be compared | Blocked upstream pending a corrected publisher artifact. | + +The real sweep has no deterministic-input or cached-interface failures. U1 and Q1 both reach +comparison, but the source and reconstructed graphs do not promise the same numerical execution +mode. O1 fails in the reference model itself. + +## U1: precision-sensitive fused attention followed by dynamic quantization + +| # | Case | Comparison diagnostics | +|--:|------|------------------------| +| 11 | `onnx-community--FastVLM-0.5B-ONNX :: decoder_model_merged_quantized.onnx` (`sequence=64`, `past=0`) | `9,702,133 / 9,705,344` logits exceed tolerance; maximum absolute error `10.0572`, mean absolute error `0.760946`, RMSE `1.05325`. | + +Boundary probes rule out a shape, rotary, cache-layout, or serialization defect. Layer 0 query +projection, external `RotaryEmbedding`, decomposed `GroupQueryAttention`, normalization, and MLP +all pass the normal Float32 envelope. Layer 1 GQA output also remains within that envelope, but +the immediately following `DynamicQuantizeLinear` makes the small difference discrete: the output +projection is the first failing observed boundary (`825 / 57,344` values, maximum absolute error +`0.001003`). Repeating this pattern through 24 layers produces the final logit disagreement above. + +ORT 1.29 CPU GQA has multiple valid kernels. With its default tiled flash-attention kernel the +first native logit is `0.669124`; setting `ORT_GQA_DISABLE_FLASH_ATTENTION=1` selects ORT non-flash +fallback and changes that same native logit to `1.066261`. The decomposed WebNN result is stable at +`1.211192`. The source backend therefore materially disagrees with itself after repeated +quantization when only its legal attention kernel changes. + +WebNN has no fused GQA primitive, and a decomposition into `matmul`, mask, `softmax`, and `matmul` +cannot guarantee bit-identical rounding to either backend-specific fused kernel. Weakening the +final tolerance would hide different Uint8 quantization decisions rather than accommodate ordinary +output accumulation error. This fixed prefill specialization is therefore recorded as unsupported +for strict cross-backend numerical validation; the sequence-1 decode specialization passes. + +## Q1: unsupported `MatMulNBits accuracy_level=4` + +Voxtral's decoder sets `accuracy_level=4` on all 211 `MatMulNBits` nodes. Native ORT may quantize +Float32 activations to Int8 internally in that mode. WebNN has no fused low-bit matmul operator, so +onnx2webnn emits packed Uint4 weights followed by `dequantizeLinear` and ordinary Float32 matmul. +The two paths therefore do not promise the same numerical algorithm. + +This is not a packed-weight serialization problem. All 211 source weight payloads matched their +saved q4 tensors byte-for-byte by SHA-256; archive marker, U8 storage shape, nibble ordering, and +generated `0x88` zero points were also correct. Direct output analysis found mean absolute error +`0.0401`, maximum error `0.367`, and correlation `0.999502`. Changing only the source attributes +to `accuracy_level=0` reduced those figures to `9.66e-6`, `9.32e-5`, and `0.99999999997`. + +This document records case 28 as Q1. Both `all` and `match` attempt it normally, so a future +implementation becomes visible. It is not counted as numerically supported. + +## O1: source ONNX rejected by native ORT + +Both Chronos files fail during native ORT model loading. A `Gather` indices input is the float +output of `ConstantOfShape`; Gather indices must be integer. + +| # | Case | +|--:|------| +| 45 | `kashif--chronos-2-onnx :: encoder_model.onnx` | +| 46 | `kashif--chronos-2-onnx :: decoder_model_merged.onnx` | + +The two manifest paths resolve to the same invalid publisher artifact. This document records them +as O1; `all` and `match` still attempt them normally, so a future publisher correction will be +visible. Until the source model runs in native ORT, these cases cannot +provide a numerical oracle and are not evidence for or against onnx2webnn correctness. + +## Repair order + +1. Track U1 until WebNN or a backend-specific fusion can provide comparable attention precision + across the dynamic-quantization boundaries. +2. Track Q1 until WebNN or a backend-specific fusion can express Int8 activations with packed q4 weights. +3. Monitor the upstream Chronos repository for corrected reference exports. + +## Maintenance rule + +This file describes only the latest complete real-weight run. Replace counts, cases, diagnoses, +and exact errors when a new run changes first blockers; do not append obsolete full failure tables. +Record revision-attributed coverage changes in the main status file's history section. diff --git a/tests/models/UNSUPPORTED_OPS.md b/docs/transformersjs_excluded_models.md similarity index 74% rename from tests/models/UNSUPPORTED_OPS.md rename to docs/transformersjs_excluded_models.md index f25f0fa..f2aff0f 100644 --- a/tests/models/UNSUPPORTED_OPS.md +++ b/docs/transformersjs_excluded_models.md @@ -1,8 +1,27 @@ -# Unsupported ONNX Operators by Hugging Face Repository - -This document records operators currently unsupported by `onnx2webnn`, grouped by Hugging Face repository. - -`scripts/generate_manifest.py` also reads it when regenerating `manifest.json`: a `## [org/repo](https://huggingface.co/org/repo)` section keeps that repo out of the sweep, and backticked `onnx/*.onnx` references inside the section narrow the exclusion to those components (the rest of the repo stays in). +# Transformers.js Excluded Models + +> **Last edited:** `2026-09-23T11:35:58Z`
+> **Checkout:** `fkrall/cache-backed-validation` at `ac6d5ca` +> +> **Freshness:** Use this document only when this provenance is recent relative to the relevant +> code and commits; otherwise verify the implementation, tests, and Git history before relying +> on it. + +This document records model exports currently excluded from onnx2webnn's generated sweep because +of unsupported operators, unsupported operator forms, unresolved dimensions, or conversion defects. +For the complete operator-level capability matrix, see +[Operator conversion status](operator-conversion-status.md). + +The [`scripts/generate_manifest.py` generator](../scripts/generate_manifest.py) reads this document +when regenerating [`tests/models/manifest.json`](../tests/models/manifest.json). A +`## [org/repo](https://huggingface.co/org/repo)` section identifies a repository. Backticked +`onnx/*.onnx` references exclude only those component stems; a section without any such +reference excludes the whole repository. The explanatory operator and error text is not parsed. + +An exclusions-disabled skeleton audit on 23 September 2026 found no listed component that became +fully supported on the current branch. Generated cases retained a blocker, except that NLLB's +decode/cache branch passed while its prefill branch still failed. Entries requiring unresolved or +model-specific realistic dimensions were not fully re-audited and retain their previous diagnosis. ## [onnx-community/dpt-dinov2-small-kitti](https://huggingface.co/onnx-community/dpt-dinov2-small-kitti) @@ -87,4 +106,6 @@ This document records operators currently unsupported by `onnx2webnn`, grouped b ## [Xenova/nllb-200-distilled-600M](https://huggingface.co/Xenova/nllb-200-distilled-600M) -- `onnx/decoder_model_merged*.onnx` — the shared output-embedding bias reshape mis-derives its target shape from `decoder_sequence_length` instead of the vocab size, producing an incompatible broadcast (`decoder_sequence_length` vs. 256206); open (encoder_model converts) +- `onnx/decoder_model_merged*.onnx` — the shared output-embedding bias reshape mis-derives its target shape from `decoder_sequence_length` instead of the vocab size, producing an incompatible broadcast (`decoder_sequence_length` vs. 256206); open. The decode/cache + branch builds, but the prefill branch still fails, so the component remains excluded + (encoder_model converts). diff --git a/scripts/generate_manifest.py b/scripts/generate_manifest.py index db03698..cf73d47 100644 --- a/scripts/generate_manifest.py +++ b/scripts/generate_manifest.py @@ -9,7 +9,7 @@ `transformers.js` (--top N for more per task; the ranking is fetched from the Hub and cached under target/, --refresh-ranking re-fetches), minus repos and components documented as unconvertible in - tests/models/UNSUPPORTED_OPS.md. Tasks with nothing to convert + docs/transformersjs_excluded_models.md. Tasks with nothing to convert (`(tokenizer-only)`) are skipped; models without onnx/*.onnx files are reported. 2. Which files: what transformers.js itself loads for that repo -- the @@ -69,7 +69,7 @@ SCRIPT_DIR = Path(__file__).resolve().parent REPO_ROOT = SCRIPT_DIR.parent DEFAULT_MANIFEST = REPO_ROOT / "tests" / "models" / "manifest.json" -DEFAULT_UNSUPPORTED_FILE = REPO_ROOT / "tests" / "models" / "UNSUPPORTED_OPS.md" +DEFAULT_UNSUPPORTED_FILE = REPO_ROOT / "docs" / "transformersjs_excluded_models.md" DEFAULT_OVERRIDES = REPO_ROOT / "tests" / "models" / "manifest-overrides.json" # target/ is gitignored: the ranking snapshot and the review copy live there. DEFAULT_MODELS_JSON = REPO_ROOT / "target" / "transformers-js-models.json" @@ -277,7 +277,7 @@ def select_repos(models: list[dict], top: int, only: list[str]) -> list[dict]: return picked -# ---- UNSUPPORTED_OPS.md: repos and components known not to convert ---- +# ---- transformersjs_excluded_models.md: repos and components known not to convert ---- SECTION_RE = re.compile(r"^## \[[^\]]+\]\(https://huggingface\.co/([^)]+)\)\s*$", re.MULTILINE) ONNX_REF_RE = re.compile(r"`onnx/([A-Za-z0-9_]+?)\*?(?:\.onnx)?`") @@ -799,7 +799,7 @@ def __init__(self, existing: list[dict]): for name, value in (e.get("override_dims") or {}).items(): if self._branch_invariant(name): self.repo_dims.setdefault(repo, {}).setdefault(name, value) - extra = {k: v for k, v in e.items() if k not in ("file", "heavy", "pin_inputs", "override_dims")} + extra = {k: v for k, v in e.items() if k not in ("file", "heavy", "pin_inputs", "override_dims", "validation")} if extra: self.manual_fields[key] = extra diff --git a/scripts/test_generate_manifest.py b/scripts/test_generate_manifest.py new file mode 100755 index 0000000..9123f26 --- /dev/null +++ b/scripts/test_generate_manifest.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import sys +import types +import unittest + +# This focused test does not use ONNX. +sys.modules.setdefault("onnx", types.ModuleType("onnx")) + +import generate_manifest as manifest + + +class LegacyValidationMetadataTests(unittest.TestCase): + def test_validation_metadata_is_not_preserved(self): + file = "org--repo/onnx/model.onnx" + baselines = manifest.Baselines( + [ + { + "file": file, + "validation": {"tier": "blocked", "reason": "legacy"}, + "coreml_slow": "preserve unrelated metadata", + } + ] + ) + + self.assertEqual( + baselines.manual_fields[(file, ())], + {"coreml_slow": "preserve unrelated metadata"}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..72aeb80 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Platform-appropriate cache locations shared by the CLI and model validator. + +use std::path::PathBuf; + +const APPLICATION_DIR: &str = "onnx2webnn"; + +/// Return the directory for cached source ONNX models. +/// +/// `O2W_ONNX_CACHE` overrides only this cache. `O2W_CACHE_DIR` relocates all +/// onnx2webnn caches while retaining their `onnx`/`webnn` subdirectories. +pub fn onnx_cache_dir() -> Result { + cache_dir("O2W_ONNX_CACHE", "onnx") +} + +/// Return the directory for exported WebNN graphs and Safetensors archives. +/// +/// `O2W_WEBNN_CACHE` overrides only this cache. `O2W_CACHE_DIR` relocates all +/// onnx2webnn caches while retaining their `onnx`/`webnn` subdirectories. +pub fn webnn_cache_dir() -> Result { + cache_dir("O2W_WEBNN_CACHE", "webnn") +} + +fn cache_dir(specific_env: &str, kind: &str) -> Result { + resolve_cache_dir( + nonempty_env(specific_env), + nonempty_env("O2W_CACHE_DIR"), + dirs::cache_dir(), + kind, + ) + .ok_or_else(|| { + format!( + "cannot determine the operating-system cache directory; set {specific_env} or O2W_CACHE_DIR" + ) + }) +} + +fn nonempty_env(name: &str) -> Option { + std::env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn resolve_cache_dir( + specific: Option, + shared: Option, + os_cache: Option, + kind: &str, +) -> Option { + specific.or_else(|| { + shared + .or_else(|| os_cache.map(|root| root.join(APPLICATION_DIR))) + .map(|root| root.join(kind)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn cache_path_precedence_is_specific_shared_then_os() { + let specific = PathBuf::from("/specific"); + let shared = PathBuf::from("/shared"); + let os = PathBuf::from("/os-cache"); + + assert_eq!( + resolve_cache_dir( + Some(specific.clone()), + Some(shared.clone()), + Some(os.clone()), + "onnx" + ), + Some(specific) + ); + assert_eq!( + resolve_cache_dir(None, Some(shared), Some(os.clone()), "onnx"), + Some(Path::new("/shared/onnx").to_path_buf()) + ); + assert_eq!( + resolve_cache_dir(None, None, Some(os), "webnn"), + Some(Path::new("/os-cache/onnx2webnn/webnn").to_path_buf()) + ); + assert_eq!(resolve_cache_dir(None, None, None, "onnx"), None); + } +} diff --git a/src/lib.rs b/src/lib.rs index 001302d..1571d5e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,13 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ +pub mod cache; pub mod debug; +pub mod model_validation; pub mod protos; +pub mod validation; pub mod onnx; pub use onnx::convert::{ - convert_model_proto, convert_onnx, ConvertOptions, OnnxError, UnsupportedOpEntry, - ValidatedGraph, + cache_onnx_model, convert_model_proto, convert_onnx, ConvertOptions, OnnxError, + UnsupportedOpEntry, ValidatedGraph, }; pub use onnx::test_models; +pub use validation::{ + validate_cached_model, validate_cached_model_with_options, + validate_cached_model_with_overrides, ValidationSummary, +}; diff --git a/src/main.rs b/src/main.rs index bdcf2fe..40af7f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,10 +18,12 @@ use clap::{Parser, Subcommand}; use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; -use onnx2webnn::convert_onnx; +use onnx2webnn::model_validation::manifest::Selection; +use onnx2webnn::model_validation::{run_manifest_validation, RunOptions, WeightMode}; use onnx2webnn::ConvertOptions; +use onnx2webnn::{cache_onnx_model, convert_onnx, validate_cached_model_with_overrides}; #[derive(Parser)] #[command(name = "onnx2webnn")] @@ -71,9 +73,66 @@ enum Command { /// Preserve unresolved symbolic input dims as dynamic metadata (experimental) #[arg(long)] experimental_dynamic_inputs: bool, + + /// Save the converted graph and self-contained ONNX model to the OS cache directory + #[arg(long)] + output: bool, + + /// Reload cached WebNN artifacts, dispatch deterministic inputs, and compare with native ORT + #[arg(long, requires = "output")] + validate: bool, + + /// Validate existing cache artifacts only; does not read or convert the source ONNX. + #[arg(long, conflicts_with_all = ["validate", "output"])] + validate_cached: bool, + }, + + /// Numerically validate manifest models against reloaded WebNN artifacts + ValidateModels { + /// Manifest selection: all or match= + #[arg(long)] + selection: String, + + /// Weight source. Only publisher weights are currently supported. + #[arg(long, default_value_t = WeightMode::Real)] + weights: WeightMode, + + /// Override tests/models/manifest.json + #[arg(long)] + manifest: Option, + + /// Number of parallel light-model workers; heavy models remain sequential + #[arg(long, default_value_t = 1)] + jobs: usize, }, } +fn cache_paths(input: &Path) -> anyhow::Result<(PathBuf, PathBuf)> { + let canonical = input.canonicalize().unwrap_or_else(|_| input.to_path_buf()); + let mut hash = 0xcbf29ce484222325u64; + for byte in canonical.to_string_lossy().bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x100000001b3); + } + let stem = input + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("model"); + let safe_stem: String = stem + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + let key = format!("{safe_stem}-{hash:016x}"); + Ok(( + onnx2webnn::cache::onnx_cache_dir() + .map_err(anyhow::Error::msg)? + .join(format!("{key}.onnx")), + onnx2webnn::cache::webnn_cache_dir() + .map_err(anyhow::Error::msg)? + .join(format!("{key}.webnn")), + )) +} + fn main() -> anyhow::Result<()> { let cli = Cli::parse(); if cli.debug { @@ -89,6 +148,9 @@ fn main() -> anyhow::Result<()> { allow_missing_external_data, optimize, experimental_dynamic_inputs, + output, + validate, + validate_cached, } => { let mut free_dim_overrides = if let Some(path) = override_dims_file { let content = std::fs::read_to_string(&path)?; @@ -144,18 +206,86 @@ fn main() -> anyhow::Result<()> { pinned_inputs.insert(name, value); } + let validation_overrides = free_dim_overrides.clone(); + if validate_cached { + let (onnx_cache, webnn_cache) = cache_paths(input_path)?; + if !onnx_cache.exists() || !webnn_cache.exists() { + return Err(anyhow::anyhow!( + "cached validation requires {} and {}; run with --output first", + onnx_cache.display(), + webnn_cache.display() + )); + } + let summary = validate_cached_model_with_overrides( + &onnx_cache, + &webnn_cache, + &validation_overrides, + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; + println!( + "✓ cached deterministic validation passed ({} inputs, {} outputs)", + summary.input_count, summary.output_count + ); + return Ok(()); + } + + let cache_paths = output.then(|| cache_paths(input_path)).transpose()?; + if let Some((onnx_cache, _)) = &cache_paths { + cache_onnx_model(input_path, onnx_cache, allow_missing_external_data) + .map_err(|e| anyhow::anyhow!("{e}"))?; + } + let options = ConvertOptions { free_dim_overrides, optimize, experimental_dynamic_inputs, pinned_inputs, zero_fill_missing_external_data: allow_missing_external_data, + output_path: cache_paths.as_ref().map(|(_, webnn)| webnn.clone()), }; let _validated = convert_onnx(input_path.to_str().unwrap(), options) .map_err(|e| anyhow::anyhow!("{e}"))?; // stdout (not stderr): PowerShell treats native stderr as NativeCommandError println!("✓ ORT graph build succeeded for {}", input); + if let Some((onnx_cache, webnn_cache)) = &cache_paths { + println!("✓ cached ONNX at {}", onnx_cache.display()); + println!("✓ cached WebNN at {}", webnn_cache.display()); + if validate { + let summary = validate_cached_model_with_overrides( + onnx_cache, + webnn_cache, + &validation_overrides, + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; + println!( + "✓ deterministic validation passed ({} inputs, {} outputs)", + summary.input_count, summary.output_count + ); + } + } + } + Command::ValidateModels { + selection, + weights, + manifest, + jobs, + } => { + let selection = Selection::parse(&selection).map_err(anyhow::Error::msg)?; + let mut options = RunOptions::new(selection, weights).map_err(anyhow::Error::msg)?; + options.jobs = jobs; + if let Some(manifest) = manifest { + options.manifest = manifest; + } + let summary = run_manifest_validation(options).map_err(anyhow::Error::msg)?; + println!("{summary}"); + if summary.has_failures() { + return Err(anyhow::anyhow!( + "{} of {} model validations failed", + summary.failed.len(), + summary.selected + )); + } } } diff --git a/src/model_validation/full_model.rs b/src/model_validation/full_model.rs new file mode 100644 index 0000000..e8767c1 --- /dev/null +++ b/src/model_validation/full_model.rs @@ -0,0 +1,538 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Full Hugging Face model downloads for the manual numerical sweep. + +use super::manifest::Entry; +use crate::protos::onnx::{GraphProto, ModelProto}; +use huggingface_hub::{HFClient, HFClientSync, HFRepositorySync, RepoDownloadFileParams}; +use prost::Message; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fs; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +const CACHE_FORMAT: u32 = 3; +const METADATA_DIR: &str = ".onnx2webnn-validation"; + +#[derive(Debug, Deserialize, Serialize)] +struct CacheMetadata { + format: u32, + repository: String, + revision: String, + sha256: Option, + primary: String, + files: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct CachedFile { + path: String, + length: u64, +} + +pub fn cache_root() -> Result { + resolve_cache_root( + nonempty_env("O2W_ONNX_CACHE"), + nonempty_env("O2W_CACHE_DIR"), + nonempty_env("HF_HUB_CACHE"), + nonempty_env("HUGGINGFACE_HUB_CACHE"), + nonempty_env("HF_HOME"), + dirs::cache_dir(), + ) + .ok_or_else(|| { + "cannot determine the Hugging Face cache directory; set O2W_ONNX_CACHE or HF_HUB_CACHE" + .to_string() + }) +} + +fn resolve_cache_root( + o2w_onnx: Option, + o2w_shared: Option, + hf_hub: Option, + legacy_hf_hub: Option, + hf_home: Option, + os_cache: Option, +) -> Option { + o2w_onnx + .or_else(|| o2w_shared.map(|path| path.join("onnx"))) + .or(hf_hub) + .or(legacy_hf_hub) + .or_else(|| hf_home.map(|path| path.join("hub"))) + .or_else(|| os_cache.map(|path| path.join("huggingface").join("hub"))) +} + +fn nonempty_env(name: &str) -> Option { + std::env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +pub fn cache_full_model(entry: &Entry) -> Result { + let cache_root = cache_root()?; + fs::create_dir_all(&cache_root).map_err(|e| format!("create {}: {e}", cache_root.display()))?; + let revision = entry.revision(); + let (repository, repository_path) = parse_manifest_file(&entry.file)?; + let (owner, name) = repository + .split_once('/') + .ok_or_else(|| format!("invalid Hugging Face repository {repository:?}"))?; + let client = HFClient::builder() + .cache_dir(&cache_root) + .build() + .map_err(|e| format!("create Hugging Face client: {e}"))?; + let client = HFClientSync::from_api(client) + .map_err(|e| format!("create blocking Hugging Face client: {e}"))?; + let repository_client = client.model(owner, name); + let metadata_path = metadata_path(&cache_root, entry); + let refresh = std::env::var_os("O2W_MODEL_CACHE_REFRESH").is_some(); + + if !refresh { + if let Some(model_path) = complete_cache( + &repository_client, + &metadata_path, + &repository, + revision, + entry.sha256.as_deref(), + )? { + return Ok(model_path); + } + } + + if metadata_path.exists() { + fs::remove_file(&metadata_path) + .map_err(|e| format!("remove stale {}: {e}", metadata_path.display()))?; + } + + let mut model_path = download_hub_file( + &repository_client, + &repository_path, + revision, + refresh, + false, + )?; + if let Some(expected) = entry.sha256.as_deref() { + if let Err(error) = verify_sha256(&model_path, expected) { + if refresh { + return Err(error); + } + model_path = + download_hub_file(&repository_client, &repository_path, revision, true, false)?; + verify_sha256(&model_path, expected)?; + } + } + + let model_bytes = + fs::read(&model_path).map_err(|e| format!("read {}: {e}", model_path.display()))?; + let model = ModelProto::decode(model_bytes.as_slice()) + .map_err(|e| format!("decode {}: {e}", model_path.display()))?; + let locations = external_locations(&model)?; + let repository_parent = Path::new(&repository_path) + .parent() + .unwrap_or_else(|| Path::new("")); + let mut files = vec![cached_file(&repository_path, &model_path)?]; + for location in locations { + let repository_sidecar = repository_parent.join(&location); + let repository_sidecar = path_for_url(&repository_sidecar)?; + let sidecar_path = download_hub_file( + &repository_client, + &repository_sidecar, + revision, + refresh, + false, + )?; + files.push(cached_file(&repository_sidecar, &sidecar_path)?); + } + + let metadata = CacheMetadata { + format: CACHE_FORMAT, + repository, + revision: revision.to_string(), + sha256: entry.sha256.clone(), + primary: repository_path, + files, + }; + write_metadata(&metadata_path, &metadata)?; + Ok(model_path) +} + +fn download_hub_file( + repository: &HFRepositorySync, + path: &str, + revision: &str, + force_download: bool, + local_files_only: bool, +) -> Result { + repository + .download_file( + &RepoDownloadFileParams::builder() + .filename(path) + .revision(revision) + .force_download(force_download) + .local_files_only(local_files_only) + .build(), + ) + .map_err(|e| format!("download {path} at revision {revision}: {e}")) +} + +fn cached_file(repository_path: &str, local_path: &Path) -> Result { + let length = fs::metadata(local_path) + .map_err(|e| format!("inspect {}: {e}", local_path.display()))? + .len(); + Ok(CachedFile { + path: repository_path.to_string(), + length, + }) +} + +fn parse_manifest_file(file: &str) -> Result<(String, String), String> { + let (org_repo, relative) = file + .split_once('/') + .ok_or_else(|| format!("{file}: expected --/"))?; + let (org, repo) = org_repo + .split_once("--") + .ok_or_else(|| format!("{file}: expected --/"))?; + if org.is_empty() || repo.is_empty() { + return Err(format!( + "{file}: empty Hugging Face organization or repository" + )); + } + let relative = path_for_url(&safe_relative_path(relative)?)?; + Ok((format!("{org}/{repo}"), relative)) +} + +fn safe_relative_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + if path.as_os_str().is_empty() || path.is_absolute() { + return Err(format!("unsafe external-data path '{}'", path.display())); + } + let mut clean = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(part) => clean.push(part), + _ => return Err(format!("unsafe external-data path '{}'", path.display())), + } + } + Ok(clean) +} + +fn path_for_url(path: &Path) -> Result { + let safe = safe_relative_path(path)?; + Ok(safe + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/")) +} + +fn metadata_path(cache_root: &Path, entry: &Entry) -> PathBuf { + let digest = Sha256::digest(entry.source_key().as_bytes()); + let key = digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + cache_root + .join(METADATA_DIR) + .join(format!("{key}.complete.json")) +} + +fn complete_cache( + repository_client: &HFRepositorySync, + metadata_path: &Path, + repository: &str, + revision: &str, + expected_sha256: Option<&str>, +) -> Result, String> { + let Ok(bytes) = fs::read(metadata_path) else { + return Ok(None); + }; + let Ok(metadata) = serde_json::from_slice::(&bytes) else { + return Ok(None); + }; + if !metadata_matches(&metadata, repository, revision, expected_sha256) { + return Ok(None); + } + + let mut primary = None; + for file in &metadata.files { + let path = match path_for_url(Path::new(&file.path)) { + Ok(path) => path, + Err(_) => return Ok(None), + }; + let local_path = match download_hub_file(repository_client, &path, revision, false, true) { + Ok(path) => path, + Err(_) => return Ok(None), + }; + let valid_length = fs::metadata(&local_path) + .map(|value| value.is_file() && value.len() == file.length) + .unwrap_or(false); + if !valid_length { + return Ok(None); + } + if path == metadata.primary { + primary = Some(local_path); + } + } + + let Some(primary) = primary else { + return Ok(None); + }; + if expected_sha256.is_some_and(|expected| verify_sha256(&primary, expected).is_err()) { + return Ok(None); + } + Ok(Some(primary)) +} + +fn metadata_matches( + metadata: &CacheMetadata, + repository: &str, + revision: &str, + expected_sha256: Option<&str>, +) -> bool { + metadata.format == CACHE_FORMAT + && metadata.repository == repository + && metadata.revision == revision + && metadata.sha256.as_deref() == expected_sha256 + && !metadata.primary.is_empty() +} + +fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?; + let mut digest = Sha256::new(); + let mut buffer = [0u8; 1 << 20]; + loop { + let read = file + .read(&mut buffer) + .map_err(|e| format!("hash {}: {e}", path.display()))?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +fn verify_sha256(path: &Path, expected: &str) -> Result<(), String> { + let actual = sha256_file(path)?; + if actual == expected { + Ok(()) + } else { + Err(format!( + "SHA-256 mismatch for {}: expected {expected}, got {actual}", + path.display() + )) + } +} + +fn write_metadata(path: &Path, metadata: &CacheMetadata) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("complete.json"); + let part = path.with_file_name(format!("{file_name}.part")); + let bytes = serde_json::to_vec_pretty(metadata).map_err(|e| e.to_string())?; + fs::write(&part, bytes).map_err(|e| format!("write {}: {e}", part.display()))?; + if path.exists() { + fs::remove_file(path).map_err(|e| format!("replace {}: {e}", path.display()))?; + } + fs::rename(&part, path) + .map_err(|e| format!("move {} to {}: {e}", part.display(), path.display())) +} + +fn external_locations(model: &ModelProto) -> Result, String> { + fn walk(graph: &GraphProto, locations: &mut BTreeSet) -> Result<(), String> { + for tensor in &graph.initializer { + if tensor.data_location != 1 { + continue; + } + let location = tensor + .external_data + .iter() + .find(|entry| entry.key == "location") + .map(|entry| entry.value.as_str()) + .ok_or_else(|| format!("external tensor '{}' has no location", tensor.name))?; + locations.insert(safe_relative_path(location)?); + } + for node in &graph.node { + for attribute in &node.attribute { + if let Some(graph) = &attribute.g { + walk(graph, locations)?; + } + for graph in &attribute.graphs { + walk(graph, locations)?; + } + } + } + Ok(()) + } + + let mut locations = BTreeSet::new(); + if let Some(graph) = &model.graph { + walk(graph, &mut locations)?; + } + Ok(locations.into_iter().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protos::onnx::{StringStringEntryProto, TensorProto}; + + #[test] + fn model_cache_precedence_prefers_o2w_then_hugging_face() { + let path = resolve_cache_root( + Some("/o2w-models".into()), + Some("/o2w".into()), + Some("/hf-hub".into()), + Some("/legacy-hf-hub".into()), + Some("/hf-home".into()), + Some("/os-cache".into()), + ); + assert_eq!(path, Some(PathBuf::from("/o2w-models"))); + + let path = resolve_cache_root( + None, + Some("/o2w".into()), + Some("/hf-hub".into()), + None, + None, + None, + ); + assert_eq!(path, Some(PathBuf::from("/o2w/onnx"))); + + let path = resolve_cache_root( + None, + None, + Some("/hf-hub".into()), + None, + Some("/hf-home".into()), + Some("/os-cache".into()), + ); + assert_eq!(path, Some(PathBuf::from("/hf-hub"))); + + let path = resolve_cache_root(None, None, None, None, None, Some("/os-cache".into())); + assert_eq!(path, Some(PathBuf::from("/os-cache/huggingface/hub"))); + } + + #[test] + fn cache_metadata_requires_current_format_revision_and_digest() { + let metadata = CacheMetadata { + format: CACHE_FORMAT, + repository: "org/repo".into(), + revision: "0123456789abcdef0123456789abcdef01234567".into(), + sha256: Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into()), + primary: "onnx/model.onnx".into(), + files: Vec::new(), + }; + let digest = metadata.sha256.as_deref(); + assert!(metadata_matches( + &metadata, + &metadata.repository, + &metadata.revision, + digest + )); + assert!(!metadata_matches( + &metadata, + &metadata.repository, + "other-revision", + digest + )); + assert!(!metadata_matches( + &metadata, + &metadata.repository, + &metadata.revision, + None + )); + + let mut old = metadata; + old.format -= 1; + assert!(!metadata_matches( + &old, + &old.repository, + &old.revision, + old.sha256.as_deref() + )); + } + + #[test] + fn computes_sha256_for_download_verification() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("model.onnx"); + fs::write(&path, b"abc").unwrap(); + let digest = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + assert_eq!(sha256_file(&path).unwrap(), digest); + verify_sha256(&path, digest).unwrap(); + let error = verify_sha256( + &path, + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .unwrap_err(); + assert!(error.contains("SHA-256 mismatch")); + } + + #[test] + fn metadata_path_is_stable_for_the_source_identity() { + let entry = Entry { + file: "org--repo/onnx/model.onnx".into(), + revision: Some("deadbeef".into()), + sha256: None, + heavy: false, + coreml_unsupported: None, + coreml_slow: None, + override_dims: Default::default(), + pin_inputs: Default::default(), + }; + let root = Path::new("/cache"); + let first = metadata_path(root, &entry); + assert_eq!(first, metadata_path(root, &entry)); + assert!(first.starts_with(root.join(METADATA_DIR))); + assert_eq!( + first.extension().and_then(|value| value.to_str()), + Some("json") + ); + } + + #[test] + fn rejects_external_paths_that_escape_the_model_directory() { + assert!(safe_relative_path("../weights.bin").is_err()); + assert!(safe_relative_path("/weights.bin").is_err()); + assert_eq!( + safe_relative_path("weights/model.data").unwrap(), + Path::new("weights/model.data") + ); + } + + #[test] + fn finds_unique_external_data_locations() { + let tensor = TensorProto { + name: "weight".into(), + data_location: 1, + external_data: vec![StringStringEntryProto { + key: "location".into(), + value: "model.onnx_data".into(), + }], + ..Default::default() + }; + let model = ModelProto { + graph: Some(GraphProto { + initializer: vec![tensor.clone(), tensor], + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + external_locations(&model).unwrap(), + vec![PathBuf::from("model.onnx_data")] + ); + } +} diff --git a/src/model_validation/manifest.rs b/src/model_validation/manifest.rs new file mode 100644 index 0000000..6250743 --- /dev/null +++ b/src/model_validation/manifest.rs @@ -0,0 +1,285 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Shared parsing and selection for the transformers.js model manifest. + +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Entry { + pub file: String, + /// Immutable Hugging Face revision for reproducible downloads. + #[serde(default)] + pub revision: Option, + /// Expected SHA-256 of the primary ONNX file. + #[serde(default)] + pub sha256: Option, + #[serde(default)] + pub heavy: bool, + /// Reason this model cannot build on the CoreML backend. + #[serde(default)] + pub coreml_unsupported: Option, + /// Reason this model is prohibitively slow to build on the CoreML backend. + #[serde(default)] + pub coreml_slow: Option, + #[serde(default)] + pub override_dims: HashMap, + #[serde(default)] + pub pin_inputs: HashMap, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Selection { + All, + Match(String), +} + +impl Selection { + pub fn parse(value: &str) -> Result { + match value { + "all" => Ok(Self::All), + value if value.starts_with("match=") && value.len() > "match=".len() => { + Ok(Self::Match(value["match=".len()..].to_string())) + } + _ => Err(format!( + "invalid selection {value:?}: expected all or match=" + )), + } + } + + pub fn includes(&self, index: usize, entry: &Entry) -> bool { + match self { + Self::All => true, + Self::Match(needle) => entry.label(index).contains(needle), + } + } +} + +impl Entry { + pub fn revision(&self) -> &str { + self.revision.as_deref().unwrap_or("main") + } + + pub fn source_key(&self) -> String { + format!( + "{}@{}#{}", + self.file, + self.revision(), + self.sha256.as_deref().unwrap_or("") + ) + } + + pub fn label(&self, index: usize) -> String { + format!( + "#{index} {} dims={:?} pins={:?}", + self.file, self.override_dims, self.pin_inputs + ) + } + + /// Stable cache identity. Map entries are sorted before hashing, so two + /// cases using the same file but different overrides or pins cannot clash. + pub fn cache_key(&self) -> String { + let mut canonical = format!("webnn-cache-v1\nfile={}\n", self.file); + if let Some(revision) = &self.revision { + canonical.push_str(&format!("revision={revision}\n")); + } + if let Some(sha256) = &self.sha256 { + canonical.push_str(&format!("sha256={sha256}\n")); + } + let mut dims: Vec<_> = self.override_dims.iter().collect(); + dims.sort_unstable_by_key(|(name, _)| *name); + for (name, value) in dims { + canonical.push_str(&format!("dim={name}:{value}\n")); + } + let mut pins: Vec<_> = self.pin_inputs.iter().collect(); + pins.sort_unstable_by_key(|(name, _)| *name); + for (name, value) in pins { + canonical.push_str(&format!("pin={name}:{value}\n")); + } + let hash = canonical.bytes().fold(0xcbf29ce484222325u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3) + }); + let stem = Path::new(&self.file) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("model") + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::(); + format!("{stem}-{hash:016x}") + } +} + +pub fn manifest_path() -> PathBuf { + std::env::var_os("O2W_MANIFEST") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/models/manifest.json")) +} + +pub fn load_manifest() -> Result, String> { + load_manifest_from(&manifest_path()) +} + +pub fn load_manifest_from(path: &Path) -> Result, String> { + let text = + std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?; + parse_manifest(&text).map_err(|e| format!("parse {}: {e}", path.display())) +} +pub fn parse_manifest(text: &str) -> Result, String> { + let entries: Vec = serde_json::from_str(text).map_err(|e| e.to_string())?; + for (index, entry) in entries.iter().enumerate() { + if entry.file.is_empty() { + return Err(format!("manifest entry #{index} has an empty file")); + } + if let Some(revision) = &entry.revision { + if revision.is_empty() + || !revision + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(format!( + "manifest entry #{index} has an invalid revision {revision:?}" + )); + } + } + if let Some(sha256) = &entry.sha256 { + if entry.revision.is_none() { + return Err(format!( + "manifest entry #{index} has sha256 without an immutable revision" + )); + } + if !valid_sha256(sha256) { + return Err(format!( + "manifest entry #{index} has an invalid lowercase sha256" + )); + } + } + } + Ok(entries) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn entry() -> Entry { + serde_json::from_str( + r#"{"file":"org--repo/onnx/model.onnx","override_dims":{"b":1},"pin_inputs":{"branch":0}}"#, + ) + .unwrap() + } + + #[test] + fn parses_and_selects_manifest_cases() { + let entry = entry(); + assert!(Selection::All.includes(0, &entry)); + assert!(Selection::Match("model.onnx".into()).includes(0, &entry)); + assert!(!Selection::Match("missing".into()).includes(0, &entry)); + } + + #[test] + fn cache_key_is_stable_and_configuration_specific() { + let first = entry(); + let reordered: Entry = serde_json::from_str( + r#"{"pin_inputs":{"branch":0},"override_dims":{"b":1},"file":"org--repo/onnx/model.onnx"}"#, + ) + .unwrap(); + assert_eq!(first.cache_key(), reordered.cache_key()); + + let mut changed = first.clone(); + changed.override_dims.insert("b".into(), 2); + assert_ne!(first.cache_key(), changed.cache_key()); + changed = first.clone(); + changed.pin_inputs.insert("branch".into(), 1); + assert_ne!(first.cache_key(), changed.cache_key()); + + changed = first.clone(); + changed.revision = Some("0123456789abcdef0123456789abcdef01234567".into()); + changed.sha256 = + Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into()); + assert_ne!(first.cache_key(), changed.cache_key()); + } + + #[test] + fn validates_revision_and_digest() { + assert!( + parse_manifest( + r#"[{"file":"org--repo/model.onnx","sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}]"# + ) + .unwrap_err() + .contains("without an immutable revision") + ); + assert!( + parse_manifest(r#"[{"file":"org--repo/model.onnx","revision":"../main"}]"#) + .unwrap_err() + .contains("invalid revision") + ); + assert!( + parse_manifest( + r#"[{"file":"org--repo/model.onnx","revision":"0123456789abcdef0123456789abcdef01234567","sha256":"ABCDEF"}]"# + ) + .unwrap_err() + .contains("invalid lowercase sha256") + ); + } + + #[test] + fn rejects_removed_validation_metadata() { + let error = + parse_manifest(r#"[{"file":"org--repo/model.onnx","validation":{"tier":"blocked"}}]"#) + .unwrap_err(); + assert!(error.contains("unknown field")); + } + + #[test] + fn curated_ci_manifest_is_small_pinned_and_unique() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/models/ci-validation.json"); + let entries = load_manifest_from(&path).expect("load curated CI manifest"); + assert!(!entries.is_empty(), "CI manifest must not be empty"); + let mut cases = HashSet::new(); + for entry in entries { + assert!(!entry.heavy, "CI model {} must not be heavy", entry.file); + let revision = entry.revision.as_deref().expect("CI model revision"); + assert!( + revision.len() == 40 + && revision + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')), + "CI model {} must use a full immutable commit SHA", + entry.file + ); + assert!( + entry.sha256.as_deref().is_some_and(valid_sha256), + "CI model {} must declare its SHA-256", + entry.file + ); + assert!( + cases.insert(entry.cache_key()), + "duplicate CI model case for {}", + entry.file + ); + } + } + + #[test] + fn selector_rejects_tiers_and_malformed_values() { + assert!(Selection::parse("smoke").is_err()); + assert!(Selection::parse("extended").is_err()); + assert!(Selection::parse("quick").is_err()); + assert!(Selection::parse("match=").is_err()); + } +} diff --git a/src/model_validation/mod.rs b/src/model_validation/mod.rs new file mode 100644 index 0000000..2f461d3 --- /dev/null +++ b/src/model_validation/mod.rs @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +pub mod full_model; +pub mod manifest; +pub mod runner; +pub mod skeleton; + +pub use runner::{run_manifest_validation, ModelFailure, RunOptions, RunSummary, WeightMode}; diff --git a/src/model_validation/runner.rs b/src/model_validation/runner.rs new file mode 100644 index 0000000..f6a7893 --- /dev/null +++ b/src/model_validation/runner.rs @@ -0,0 +1,314 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +use super::full_model::cache_full_model; +use super::manifest::{load_manifest_from, Entry, Selection}; +use crate::{convert_onnx, validate_cached_model_with_options, ConvertOptions}; +use std::collections::HashMap; +use std::fmt; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::{Arc, Mutex, OnceLock}; + +type ModelCell = Arc>>; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum WeightMode { + #[default] + Real, +} + +impl FromStr for WeightMode { + type Err = String; + fn from_str(value: &str) -> Result { + match value { + "real" => Ok(Self::Real), + _ => Err(format!("invalid weight mode {value:?}; expected real")), + } + } +} + +impl fmt::Display for WeightMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Real => "real", + }) + } +} + +#[derive(Clone, Debug)] +pub struct RunOptions { + pub selection: Selection, + pub weights: WeightMode, + pub manifest: PathBuf, + pub jobs: usize, + pub webnn_cache: PathBuf, +} + +impl RunOptions { + pub fn new(selection: Selection, weights: WeightMode) -> Result { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + Ok(Self { + selection, + weights, + manifest: std::env::var_os("O2W_MANIFEST") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| root.join("tests/models/manifest.json")), + jobs: 1, + webnn_cache: crate::cache::webnn_cache_dir()?, + }) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct RunSummary { + pub selected: usize, + pub passed: usize, + pub succeeded: Vec, + pub failed: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ModelFailure { + pub label: String, + pub error: String, +} + +impl RunSummary { + pub fn pass_percentage(&self) -> f64 { + if self.selected == 0 { + 0.0 + } else { + self.passed as f64 * 100.0 / self.selected as f64 + } + } + + pub fn has_failures(&self) -> bool { + !self.failed.is_empty() + } +} + +impl fmt::Display for RunSummary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Model validation summary")?; + writeln!(f, "Succeeded ({}):", self.succeeded.len())?; + if self.succeeded.is_empty() { + writeln!(f, " (none)")?; + } else { + for label in &self.succeeded { + writeln!(f, " PASS {label}")?; + } + } + writeln!(f, "Failed ({}):", self.failed.len())?; + if self.failed.is_empty() { + writeln!(f, " (none)")?; + } else { + for failure in &self.failed { + writeln!(f, " FAIL {}: {}", failure.label, failure.error)?; + } + } + write!( + f, + "Overall: {}/{} passed ({:.1}%)", + self.passed, + self.selected, + self.pass_percentage() + ) + } +} + +pub fn run_manifest_validation(options: RunOptions) -> Result { + if options.jobs == 0 { + return Err("validation jobs must be at least 1".to_string()); + } + let entries = load_manifest_from(&options.manifest)?; + let selected: Vec<_> = entries + .into_iter() + .enumerate() + .filter(|(index, entry)| options.selection.includes(*index, entry)) + .collect(); + if selected.is_empty() { + return Err("validation selection matched no models".to_string()); + } + let selected_count = selected.len(); + let (heavy, light): (Vec<_>, Vec<_>) = selected.into_iter().partition(|(_, entry)| entry.heavy); + let sweep = Sweep { + options, + models: Mutex::new(HashMap::new()), + results: Mutex::new(Vec::new()), + }; + sweep.run(light, sweep.options.jobs); + sweep.run(heavy, 1); + let mut results = sweep.results.into_inner().unwrap(); + results.sort_unstable_by_key(|result| result.index); + let mut succeeded = Vec::new(); + let mut failed = Vec::new(); + for result in results { + if let Some(error) = result.error { + failed.push(ModelFailure { + label: result.label, + error, + }); + } else { + succeeded.push(result.label); + } + } + let passed = succeeded.len(); + Ok(RunSummary { + selected: selected_count, + passed, + succeeded, + failed, + }) +} + +struct ModelResult { + index: usize, + label: String, + error: Option, +} + +struct Sweep { + options: RunOptions, + models: Mutex>, + results: Mutex>, +} + +impl Sweep { + fn model(&self, entry: &Entry) -> Result { + let key = format!("{}:{}", self.options.weights, entry.source_key()); + let cell = self.models.lock().unwrap().entry(key).or_default().clone(); + cell.get_or_init(|| cache_full_model(entry)).clone() + } + + fn validate(&self, index: usize, entry: &Entry) { + let label = entry.label(index); + match self.validate_inner(entry) { + Ok((inputs, pins, outputs)) => { + eprintln!( + "ok {label}\n {inputs} inputs + {pins} pinned, {outputs} outputs ({})", + self.options.weights + ); + self.results.lock().unwrap().push(ModelResult { + index, + label, + error: None, + }); + } + Err(error) => { + eprintln!("FAIL {label}\n {error}"); + self.results.lock().unwrap().push(ModelResult { + index, + label, + error: Some(error), + }); + } + } + } + + fn validate_inner(&self, entry: &Entry) -> Result<(usize, usize, usize), String> { + let onnx_path = self + .model(entry) + .map_err(|e| format!("model preparation: {e}"))?; + let webnn_path = self.options.webnn_cache.join(format!( + "{}-{}.webnn", + entry.cache_key(), + self.options.weights + )); + std::fs::create_dir_all(webnn_path.parent().expect("cache parent")) + .map_err(|e| format!("export: {e}"))?; + convert_onnx( + &onnx_path, + ConvertOptions { + free_dim_overrides: entry.override_dims.clone(), + optimize: true, + experimental_dynamic_inputs: false, + pinned_inputs: entry.pin_inputs.clone(), + zero_fill_missing_external_data: false, + output_path: Some(webnn_path.clone()), + }, + ) + .map_err(|e| format!("conversion/export: {e}"))?; + if !webnn_path.exists() || !webnn_path.with_extension("safetensors").exists() { + return Err("export did not produce .webnn and .safetensors".to_string()); + } + let summary = validate_cached_model_with_options( + &onnx_path, + &webnn_path, + &entry.override_dims, + &entry.pin_inputs, + ) + .map_err(|e| classify(&e.to_string()))?; + Ok(( + summary.input_count, + summary.pinned_input_count, + summary.output_count, + )) + } + + fn run(&self, entries: Vec<(usize, Entry)>, workers: usize) { + let queue = Mutex::new(entries); + std::thread::scope(|scope| { + for _ in 0..workers { + scope.spawn(|| loop { + let Some((index, entry)) = queue.lock().unwrap().pop() else { + break; + }; + self.validate(index, &entry); + }); + } + }); + } +} + +fn classify(error: &str) -> String { + let stage = if error.contains("native ORT") { + "native ORT" + } else if error.contains("reload WebNN") || error.contains("graph build") { + "reload" + } else if error.contains("dispatch") || error.contains("write") || error.contains("read output") + { + "dispatch" + } else if error.contains("mismatch") || error.contains("did not produce") { + "comparison" + } else { + "validation" + }; + format!("{stage}: {error}") +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn parses_weight_modes() { + assert_eq!("real".parse(), Ok(WeightMode::Real)); + assert!("generated".parse::().is_err()); + assert!("random".parse::().is_err()); + } + #[test] + fn real_is_default() { + assert_eq!(WeightMode::default(), WeightMode::Real); + } + + #[test] + fn summary_lists_all_results_and_percentage() { + let summary = RunSummary { + selected: 3, + passed: 2, + succeeded: vec!["#0 first".into(), "#2 third".into()], + failed: vec![ModelFailure { + label: "#1 second".into(), + error: "comparison: mismatch".into(), + }], + }; + let report = summary.to_string(); + assert!(report.contains("Succeeded (2):\n PASS #0 first\n PASS #2 third")); + assert!(report.contains("Failed (1):\n FAIL #1 second: comparison: mismatch")); + assert!(report.contains("Overall: 2/3 passed (66.7%)")); + assert!(summary.has_failures()); + } +} diff --git a/tests/common/skeleton.rs b/src/model_validation/skeleton.rs similarity index 91% rename from tests/common/skeleton.rs rename to src/model_validation/skeleton.rs index b8e9742..3618d88 100644 --- a/tests/common/skeleton.rs +++ b/src/model_validation/skeleton.rs @@ -51,6 +51,9 @@ const TENSOR_DATA_LOCATION: u32 = 14; /// Random-access bytes: a local file or a remote file read by ranges. pub trait ByteSource { fn len(&self) -> u64; + fn is_empty(&self) -> bool { + self.len() == 0 + } fn read_at(&mut self, start: u64, len: usize) -> Result, String>; } @@ -88,16 +91,21 @@ pub struct HubSource { agent: ureq::Agent, url: String, len: u64, + revision: Option, } impl HubSource { /// `--/onnx/.onnx` -> `https://huggingface.co///resolve/main/onnx/.onnx`. pub fn open(file: &str) -> Result { + Self::open_revision(file, "main") + } + + pub fn open_revision(file: &str, requested_revision: &str) -> Result { let (org_repo, rel) = file .split_once('/') .ok_or_else(|| format!("{file}: expected --/"))?; let repo = org_repo.replacen("--", "/", 1); - let url = format!("https://huggingface.co/{repo}/resolve/main/{rel}"); + let url = format!("https://huggingface.co/{repo}/resolve/{requested_revision}/{rel}"); let agent = ureq::AgentBuilder::new() .timeout(Duration::from_secs(120)) .build(); @@ -110,6 +118,10 @@ impl HubSource { } let response = with_retries(|| request.clone().call().map_err(|e| e.to_string())) .map_err(|e| format!("resolve {url}: {e}"))?; + let revision = response + .header("x-repo-commit") + .map(str::to_string) + .or_else(|| (requested_revision != "main").then(|| requested_revision.to_string())); let len = response .header("Content-Length") .and_then(|v| v.parse().ok()) @@ -118,8 +130,17 @@ impl HubSource { agent, url: response.get_url().to_string(), len, + revision, }) } + + pub fn revision(&self) -> Option<&str> { + self.revision.as_deref() + } + + pub fn resolved_url(&self) -> &str { + &self.url + } } impl ByteSource for HubSource { @@ -365,6 +386,8 @@ fn strip_tensor( let mut saw_data = false; let mut dims = Vec::new(); let mut data_type = 0u64; + let mut source_offset = None; + let mut source_encoding = None; while r.pos < end { let (field, wt) = r.read_tag()?; if TENSOR_DATA_FIELDS.contains(&field) { @@ -372,6 +395,14 @@ fn strip_tensor( if wt == LENGTH_DELIMITED { let n = r.read_varint()?; data_bytes += n; + if n as usize > keep_bytes { + source_offset = Some(r.pos); + source_encoding = Some(if field == 9 { + "raw_data" + } else { + "protobuf_data" + }); + } if n as usize <= keep_bytes { kept.extend(encode_len_field(field, &r.read(n as usize)?)); } else { @@ -426,6 +457,16 @@ fn strip_tensor( let mut entry = encode_len_field(1, b"length"); entry.extend(encode_len_field(2, length.to_string().as_bytes())); kept.extend(encode_len_field(TENSOR_EXTERNAL_DATA, &entry)); + if let Some(offset) = source_offset { + let mut source = encode_len_field(1, b"o2w_source_offset"); + source.extend(encode_len_field(2, offset.to_string().as_bytes())); + kept.extend(encode_len_field(TENSOR_EXTERNAL_DATA, &source)); + } + if let Some(encoding) = source_encoding { + let mut source = encode_len_field(1, b"o2w_source_encoding"); + source.extend(encode_len_field(2, encoding.as_bytes())); + kept.extend(encode_len_field(TENSOR_EXTERNAL_DATA, &source)); + } kept.extend(encode_tag(TENSOR_DATA_LOCATION, VARINT)); kept.extend(encode_varint(1)); Ok(kept) diff --git a/src/onnx/builder_helpers.rs b/src/onnx/builder_helpers.rs index 523b5b3..eb5f943 100644 --- a/src/onnx/builder_helpers.rs +++ b/src/onnx/builder_helpers.rs @@ -206,6 +206,21 @@ pub fn slice_with_params( map_op_result(b.builder.slice_with_options(input, starts, sizes, opts)) } +pub fn slice_with_params_and_strides( + b: &mut OnnxBuilder<'_, '_, '_>, + input: MLOperand, + label: &str, + starts: &[u32], + sizes: &[MLDimension], + strides: &[u32], +) -> Result { + let opts = MLSliceOptions { + label: label.to_string(), + strides: strides.to_vec(), + }; + map_op_result(b.builder.slice_with_options(input, starts, sizes, opts)) +} + /// First scalar element of an ONNX tensor as WebNN `MLNumber` (`serde_json::Value`). pub fn ml_number_from_tensor(t: &TensorProto) -> Result { match t.data_type { diff --git a/src/onnx/convert.rs b/src/onnx/convert.rs index 8f8d6d2..f0f968c 100644 --- a/src/onnx/convert.rs +++ b/src/onnx/convert.rs @@ -30,9 +30,9 @@ use rustnn::mlcontext::{ }; use rustnn::DataType; use serde_json::Value as JsonValue; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use thiserror::Error; use webnn_onnx_utils::{data_types as utils_data_types, identifiers}; @@ -101,6 +101,9 @@ pub enum OnnxError { #[error("shape inference failed for node: {0}")] ShapeInference(String), + + #[error("validation failed: {0}")] + Validation(String), } impl OnnxError { @@ -184,6 +187,9 @@ pub struct ConvertOptions { /// weight-stripped "skeleton" model exercise the full conversion and ORT /// graph build (values never matter for graph structure). pub zero_fill_missing_external_data: bool, + /// Write the lowered graph as a rustnn `.webnn` file plus sibling + /// `.safetensors` constants before building it for structural validation. + pub output_path: Option, } /// Parse a `--pin-input NAME=VALUE` argument (`true`/`false` or an integer). @@ -706,10 +712,6 @@ Provide --override-dim {}= or enable --experimental-dynamic-inputs.", ))); }; - if shape.is_empty() { - continue; - } - b.register_input(&raw_name, data_type, &shape)?; value_name_map.insert(raw_name.clone(), name.clone()); @@ -1664,6 +1666,28 @@ pub fn convert_onnx>( convert_model(model, &options) } +/// Cache an ONNX model with all external tensor data embedded in the protobuf. +/// +/// Keeping the cache self-contained means native ORT validation can reopen the +/// cached `.onnx` without relying on a source-side `.onnx.data` file. +pub fn cache_onnx_model, Q: AsRef>( + source_path: P, + cache_path: Q, + zero_fill_missing_external_data: bool, +) -> Result<(), OnnxError> { + let source_path = source_path.as_ref(); + let bytes = fs::read(source_path)?; + let mut model = ModelProto::decode(bytes.as_slice()) + .map_err(|e| OnnxError::ProtobufError(e.to_string()))?; + load_external_tensor_data(&mut model, source_path, zero_fill_missing_external_data)?; + let cache_path = cache_path.as_ref(); + if let Some(parent) = cache_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(cache_path, model.encode_to_vec())?; + Ok(()) +} + /// Inline `If` nodes whose condition folds to a constant (e.g. pyannote's /// shape-derived `Equal(Gather(Shape(x)), k)` gate). The chosen branch's /// nodes and initializers are spliced into the outer graph with internal @@ -1944,7 +1968,7 @@ pub(crate) fn convert_model( .as_ref() .ok_or_else(|| OnnxError::ProtobufError("Missing graph in model".to_string()))?; - let mut outputs: HashMap = HashMap::new(); + let mut outputs: BTreeMap = BTreeMap::new(); for output in onnx_graph.output.as_slice() { // Sequences only exist as lowered elements; one escaping to a graph // output has no WebNN representation. @@ -1961,6 +1985,17 @@ pub(crate) fn convert_model( let output_key = onnx_builder.build_output_key(output.name.as_str()); outputs.insert(output_key, op); } + if let Some(path) = &options.output_path { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let output_refs: BTreeMap<&str, MLOperand> = + outputs.iter().map(|(k, v)| (k.as_str(), *v)).collect(); + onnx_builder + .builder + .rustnn_save_webnn(&output_refs, path) + .map_err(map_rustnn_error)?; + } let output_refs: HashMap<&str, MLOperand> = outputs.iter().map(|(k, v)| (k.as_str(), *v)).collect(); diff --git a/src/onnx/ops/conversion.rs b/src/onnx/ops/conversion.rs index 83dfaab..cba4cee 100644 --- a/src/onnx/ops/conversion.rs +++ b/src/onnx/ops/conversion.rs @@ -101,8 +101,36 @@ impl ConversionHandler { let output_name = output_label(node, node_name); let input = b.resolve_operand(&inputs[0])?; + let requested_type = to_type.unwrap() as i32; + if requested_type == crate::protos::onnx::TensorProto_DataType::Bool as i32 { + // ONNX numeric-to-bool casts normalize zero to false and every nonzero + // value (including NaN) to true. WebNN represents logical tensors as + // uint8, so a numeric cast alone would incorrectly preserve values. + let input_type = resolve_value_type(context, &inputs[0]).ok_or_else(|| { + OnnxError::InvalidShape(format!( + "Cast could not infer input type from '{}'", + inputs[0] + )) + })?; + let zero_name = format!("{output_name}__zero"); + let zero_len = input_type.storage_byte_length(1).ok_or_else(|| { + OnnxError::InvalidShape("Cast boolean zero size overflow".to_string()) + })?; + b.register_constant_from_bytes(&zero_name, input_type, &[], vec![0; zero_len])?; + let zero = b.resolve_operand(&zero_name)?; + let out = b + .builder + .not_equal_with_options(input, zero, OnnxBuilder::labeled_options(&output_name)) + .map_err(map_op_error)?; + if let Some(onnx_out) = node.output.first() { + record_node_output(b, onnx_out, &output_name, out); + } else { + b.record_operand(&[&output_name], out); + } + return Ok(ConversionResult::default()); + } // float64 is lowered to float32 (WebNN has no float64). - let mut to_type = to_type.unwrap() as i32; + let mut to_type = requested_type; if to_type == crate::protos::onnx::TensorProto_DataType::Double as i32 { to_type = crate::protos::onnx::TensorProto_DataType::Float as i32; } diff --git a/src/onnx/ops/utility.rs b/src/onnx/ops/utility.rs index 13365a3..b95bdb6 100644 --- a/src/onnx/ops/utility.rs +++ b/src/onnx/ops/utility.rs @@ -21,7 +21,7 @@ use crate::onnx::builder::{map_op_error, OnnxBuilder}; use crate::onnx::builder_helpers::{ ast_dims_to_mldim, expand_with_shape, i64_starts_as_u32, output_label, record_node_output, - slice_sizes_from_i64, slice_with_params, + slice_sizes_from_i64, slice_with_params, slice_with_params_and_strides, }; use crate::onnx::convert::{sanitize_identifier, OnnxError}; use crate::onnx::ops::conv::lookup_shape; @@ -1681,7 +1681,7 @@ impl UtilityHandler { } let output_name = output_label(node, node_name); - let mut slice_params: Option<(Vec, Vec)> = None; + let mut slice_params: Option<(Vec, Vec, Vec)> = None; // Axes handled as full reverses (negative-step slices spanning the dim). let mut reverse_axes: Vec = Vec::new(); @@ -1904,11 +1904,9 @@ impl UtilityHandler { start = start.clamp(0, dim); end = end.clamp(0, dim); - let size = if end <= start { - 0 - } else { - (end - start + step - 1) / step - }; + // WebNN `sizes` are input extents. The output dimension is + // ceil(size / stride), which RustNN infers from the options. + let size = (end - start).max(0); // If this end value came from a dynamic dimension, mark the size as dynamic if let Some(dims) = ends_dims { @@ -1928,6 +1926,16 @@ impl UtilityHandler { slice_params = Some(( i64_starts_as_u32(&dense_starts)?, slice_sizes_from_i64(&dense_sizes, &dynamic_size_info)?, + dense_strides + .into_iter() + .map(|stride| { + u32::try_from(stride).map_err(|_| { + OnnxError::InvalidShape(format!( + "Slice '{node_name}' stride {stride} is out of range" + )) + }) + }) + .collect::, _>>()?, )); } else { return Err(OnnxError::InvalidShape( @@ -2025,11 +2033,8 @@ impl UtilityHandler { start = start.clamp(0, dim); end = end.clamp(0, dim); - let size = if end <= start { - 0 - } else { - (end - start + step - 1) / step - }; + // WebNN `sizes` are input extents, not output element counts. + let size = (end - start).max(0); dense_starts[axis] = start; dense_sizes[axis] = size; @@ -2039,11 +2044,21 @@ impl UtilityHandler { slice_params = Some(( i64_starts_as_u32(&dense_starts)?, slice_sizes_from_i64(&dense_sizes, &vec![None; rank])?, + dense_strides + .into_iter() + .map(|stride| { + u32::try_from(stride).map_err(|_| { + OnnxError::InvalidShape(format!( + "Slice '{node_name}' stride {stride} is out of range" + )) + }) + }) + .collect::, _>>()?, )); } } - let (starts, sizes) = slice_params.ok_or_else(|| { + let (starts, sizes, strides) = slice_params.ok_or_else(|| { OnnxError::InvalidShape( "Slice requires static starts/sizes for MLGraphBuilder".to_string(), ) @@ -2064,7 +2079,8 @@ impl UtilityHandler { } else { format!("{output_name}__pre_reverse") }; - let mut out = slice_with_params(b, input, &slice_label, &starts, &sizes)?; + let mut out = + slice_with_params_and_strides(b, input, &slice_label, &starts, &sizes, &strides)?; if !reverse_axes.is_empty() { out = b .builder diff --git a/src/validation.rs b/src/validation.rs new file mode 100644 index 0000000..3e6a355 --- /dev/null +++ b/src/validation.rs @@ -0,0 +1,890 @@ +//! Cache reload and deterministic native-ORT validation for exported WebNN graphs. +use crate::onnx::builder::OnnxBuilder; +use crate::onnx::convert::{OnnxError, ValidatedGraph}; +use crate::protos::onnx::{ + tensor_shape_proto::dimension::Value as DimensionValue, type_proto::Value as TypeProtoValue, + ModelProto, TensorProto_DataType, ValueInfoProto, +}; +use half::f16; +use prost::Message; +use rustnn::graph::OperandDescriptor; +use rustnn::mlcontext::{ + MLContext, MLContextOptions, MLPowerPreference, MLTensor, MLTensorDescriptor, +}; +use rustnn::operator_enums::MLOperandDataType; +use rustnn::{load_graph_from_path, run_onnx_path_with_inputs, OnnxInput, TensorData}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ValidationSummary { + pub input_count: usize, + pub pinned_input_count: usize, + pub output_count: usize, +} + +/// Reload saved WebNN artifacts, dispatch deterministic fixed-shape inputs, +/// and compare results with native ONNX Runtime on the cached ONNX model. +pub fn validate_cached_model( + onnx_path: impl AsRef, + webnn_path: impl AsRef, +) -> Result { + validate_cached_model_with_options(onnx_path, webnn_path, &HashMap::new(), &HashMap::new()) +} + +/// Validate cached artifacts using explicit bindings for symbolic ONNX input dimensions. +pub fn validate_cached_model_with_overrides( + onnx_path: impl AsRef, + webnn_path: impl AsRef, + free_dim_overrides: &HashMap, +) -> Result { + validate_cached_model_with_options(onnx_path, webnn_path, free_dim_overrides, &HashMap::new()) +} + +/// Validate cached artifacts with symbolic dimension bindings and converter-pinned inputs. +pub fn validate_cached_model_with_options( + onnx_path: impl AsRef, + webnn_path: impl AsRef, + free_dim_overrides: &HashMap, + pinned_inputs: &HashMap, +) -> Result { + let onnx_bytes = fs::read(onnx_path.as_ref())?; + let model = ModelProto::decode(onnx_bytes.as_slice()) + .map_err(|e| OnnxError::ProtobufError(e.to_string()))?; + let inputs = deterministic_inputs(&model, free_dim_overrides, pinned_inputs)?; + let reference = run_onnx_path_with_inputs(onnx_path.as_ref(), clone_inputs(&inputs)) + .map_err(|e| OnnxError::Validation(format!("native ORT run failed: {e}")))?; + let graph_info = load_graph_from_path(webnn_path.as_ref()) + .map_err(|e| OnnxError::Validation(format!("failed to reload WebNN cache: {e}")))?; + let mut context = MLContext::create(&MLContextOptions::new(MLPowerPreference::Default, false)) + .map_err(|e| OnnxError::Validation(format!("MLContext::create failed: {e}")))?; + let graph = context + .rustnn_build_graph(graph_info) + .map_err(|e| OnnxError::Validation(format!("cached graph build failed: {e}")))?; + let mut validated = ValidatedGraph { context, graph }; + let input_count = validated.graph.input_descriptors.len(); + let actual = dispatch_and_collect(&mut validated, &model, &inputs, pinned_inputs)?; + compare_outputs(&model, &reference, &actual)?; + Ok(ValidationSummary { + input_count, + pinned_input_count: pinned_inputs.len(), + output_count: reference.len(), + }) +} + +fn graph(model: &ModelProto) -> Result<&crate::protos::onnx::GraphProto, OnnxError> { + model + .graph + .as_ref() + .ok_or_else(|| OnnxError::ProtobufError("Missing graph in model".to_string())) +} + +fn tensor_dims( + vi: &ValueInfoProto, + free_dim_overrides: &HashMap, +) -> Result<(i32, Vec), OnnxError> { + let ty = vi + .r#type + .as_ref() + .and_then(|ty| ty.value.as_ref()) + .ok_or_else(|| OnnxError::Validation(format!("missing tensor type for {}", vi.name)))?; + let tensor = match ty { + TypeProtoValue::TensorType(tensor) => tensor, + _ => { + return Err(OnnxError::Validation(format!( + "non-tensor input {}", + vi.name + ))) + } + }; + let shape = tensor + .shape + .as_ref() + .ok_or_else(|| OnnxError::Validation(format!("missing shape for {}", vi.name)))?; + let dims = shape + .dim + .iter() + .map(|dim| match dim.value.as_ref() { + Some(DimensionValue::DimValue(value)) if *value >= 0 => Ok(*value as usize), + Some(DimensionValue::DimParam(name)) => free_dim_overrides + .get(name) + .map(|value| *value as usize) + .ok_or_else(|| { + OnnxError::Validation(format!( + "dynamic input dimension {name} in {}; provide an override for validation", + vi.name + )) + }), + _ => Err(OnnxError::Validation(format!( + "dynamic input dimension in {}; use fixed shapes for validation", + vi.name + ))), + }) + .collect::, _>>()?; + Ok((tensor.elem_type, dims)) +} + +fn feedable_inputs(model: &ModelProto) -> Result, OnnxError> { + let graph = graph(model)?; + let initializer_names: HashSet<&str> = + graph.initializer.iter().map(|t| t.name.as_str()).collect(); + Ok(graph + .input + .iter() + .filter(|input| !initializer_names.contains(input.name.as_str())) + .collect()) +} + +fn checked_element_count(name: &str, shape: &[usize]) -> Result { + if shape.contains(&0) { + return Ok(0); + } + shape.iter().try_fold(1usize, |count, &dimension| { + count.checked_mul(dimension).ok_or_else(|| { + OnnxError::Validation(format!( + "input {name} shape {shape:?} overflows element count" + )) + }) + }) +} + +fn semantic_input_value(name: &str) -> Option { + match name { + "token_type_ids" => Some(0), + "attention_mask" => Some(1), + _ => None, + } +} + +fn repeated_data( + elem_type: i32, + count: usize, + value: i64, + name: &str, +) -> Result { + macro_rules! checked { + ($variant:ident, $type:ty) => { + TensorData::$variant(vec![ + <$type>::try_from(value).map_err(|_| { + OnnxError::Validation(format!("value {value} is out of range for {name}")) + })?; + count + ]) + }; + } + Ok(match elem_type { + x if x == TensorProto_DataType::Float as i32 => { + TensorData::Float32(vec![value as f32; count]) + } + x if x == TensorProto_DataType::Float16 as i32 => { + TensorData::Float16(vec![f16::from_f32(value as f32).to_bits(); count]) + } + x if x == TensorProto_DataType::Int8 as i32 => checked!(Int8, i8), + x if x == TensorProto_DataType::Uint8 as i32 => checked!(Uint8, u8), + x if x == TensorProto_DataType::Int32 as i32 => checked!(Int32, i32), + x if x == TensorProto_DataType::Uint32 as i32 => checked!(Uint32, u32), + x if x == TensorProto_DataType::Int64 as i32 => TensorData::Int64(vec![value; count]), + x if x == TensorProto_DataType::Uint64 as i32 => checked!(Uint64, u64), + x if x == TensorProto_DataType::Bool as i32 && matches!(value, 0 | 1) => { + TensorData::Uint8(vec![value as u8; count]) + } + x if x == TensorProto_DataType::Bool as i32 => { + return Err(OnnxError::Validation(format!( + "bool input {name} must be 0 or 1, got {value}" + ))) + } + other => { + return Err(OnnxError::Validation(format!( + "unsupported deterministic input dtype {other} for {name}" + ))) + } + }) +} + +fn deterministic_data(elem_type: i32, count: usize, name: &str) -> Result { + Ok(match elem_type { + x if x == TensorProto_DataType::Float as i32 => { + TensorData::Float32((0..count).map(|i| ((i % 17) as f32 - 8.0) / 16.0).collect()) + } + x if x == TensorProto_DataType::Float16 as i32 => TensorData::Float16( + (0..count) + .map(|i| f16::from_f32(((i % 17) as f32 - 8.0) / 16.0).to_bits()) + .collect(), + ), + x if x == TensorProto_DataType::Int8 as i32 => { + TensorData::Int8((0..count).map(|i| (i % 7) as i8).collect()) + } + x if x == TensorProto_DataType::Uint8 as i32 => { + TensorData::Uint8((0..count).map(|i| (i % 7) as u8).collect()) + } + x if x == TensorProto_DataType::Int32 as i32 => { + TensorData::Int32((0..count).map(|i| (i % 7) as i32).collect()) + } + x if x == TensorProto_DataType::Uint32 as i32 => { + TensorData::Uint32((0..count).map(|i| (i % 7) as u32).collect()) + } + x if x == TensorProto_DataType::Int64 as i32 => { + TensorData::Int64((0..count).map(|i| (i % 7) as i64).collect()) + } + x if x == TensorProto_DataType::Uint64 as i32 => { + TensorData::Uint64((0..count).map(|i| (i % 7) as u64).collect()) + } + x if x == TensorProto_DataType::Bool as i32 => { + TensorData::Uint8((0..count).map(|i| u8::from(i % 2 == 0)).collect()) + } + other => { + return Err(OnnxError::Validation(format!( + "unsupported deterministic input dtype {other} for {name}" + ))) + } + }) +} + +fn input_data( + elem_type: i32, + count: usize, + name: &str, + pinned_value: Option, +) -> Result { + if let Some(value) = pinned_value.or_else(|| semantic_input_value(name)) { + repeated_data(elem_type, count, value, name) + } else { + deterministic_data(elem_type, count, name) + } +} + +fn deterministic_inputs( + model: &ModelProto, + free_dim_overrides: &HashMap, + pinned_inputs: &HashMap, +) -> Result, OnnxError> { + let feedable = feedable_inputs(model)?; + let feedable_names: HashSet<&str> = feedable.iter().map(|input| input.name.as_str()).collect(); + for name in pinned_inputs.keys() { + if !feedable_names.contains(name.as_str()) { + return Err(OnnxError::Validation(format!( + "pinned input {name} is not a feedable graph input" + ))); + } + } + feedable + .into_iter() + .map(|input| { + let (elem_type, shape) = tensor_dims(input, free_dim_overrides)?; + let count = checked_element_count(&input.name, &shape)?; + let data = input_data( + elem_type, + count, + &input.name, + pinned_inputs.get(&input.name).copied(), + )?; + Ok(OnnxInput { + name: input.name.clone(), + shape, + data, + }) + }) + .collect() +} + +fn clone_inputs(inputs: &[OnnxInput]) -> Vec { + inputs + .iter() + .map(|input| OnnxInput { + name: input.name.clone(), + shape: input.shape.clone(), + data: match &input.data { + TensorData::Float32(v) => TensorData::Float32(v.clone()), + TensorData::Float16(v) => TensorData::Float16(v.clone()), + TensorData::Int8(v) => TensorData::Int8(v.clone()), + TensorData::Uint8(v) => TensorData::Uint8(v.clone()), + TensorData::Int32(v) => TensorData::Int32(v.clone()), + TensorData::Uint32(v) => TensorData::Uint32(v.clone()), + TensorData::Int64(v) => TensorData::Int64(v.clone()), + TensorData::Uint64(v) => TensorData::Uint64(v.clone()), + }, + }) + .collect() +} + +fn tensor_descriptor(desc: &OperandDescriptor) -> MLTensorDescriptor { + let data_type = MLOperandDataType::try_from(desc.data_type).expect("WebNN operand type"); + let mut tensor = MLTensorDescriptor::new( + data_type, + desc.static_or_max_shape() + .into_iter() + .map(u64::from) + .collect(), + ); + tensor.set_readable(true); + tensor.set_writable(true); + tensor +} + +fn write_input( + context: &mut MLContext, + tensor: &MLTensor, + input: &OnnxInput, +) -> Result<(), OnnxError> { + if checked_element_count(&input.name, &input.shape)? == 0 { + return Ok(()); + } + let result = match &input.data { + TensorData::Float32(data) => context.write_tensor(tensor, data), + TensorData::Float16(data) => context.write_tensor(tensor, data), + TensorData::Int8(data) => context.write_tensor(tensor, data), + TensorData::Uint32(data) => context.write_tensor(tensor, data), + TensorData::Uint64(data) => context.write_tensor(tensor, data), + TensorData::Int32(data) => context.write_tensor(tensor, data), + TensorData::Int64(data) => context.write_tensor(tensor, data), + TensorData::Uint8(data) => context.write_tensor(tensor, data), + }; + result.map_err(|e| OnnxError::Validation(format!("failed to write {}: {e}", input.name))) +} + +enum CollectedOutput { + Numeric(Vec), + Int64(Vec), + Uint64(Vec), +} + +impl CollectedOutput { + fn len(&self) -> usize { + match self { + Self::Numeric(data) => data.len(), + Self::Int64(data) => data.len(), + Self::Uint64(data) => data.len(), + } + } +} + +fn read_output( + context: &mut MLContext, + tensor: &MLTensor, + desc: &OperandDescriptor, +) -> Result { + let count = desc + .element_count() + .ok_or_else(|| OnnxError::Validation("output shape overflows element count".to_string()))?; + macro_rules! read_numeric { + ($type:ty) => {{ + let mut data = vec![<$type>::default(); count]; + if count != 0 { + context + .read_tensor(tensor, &mut data) + .map_err(|e| OnnxError::Validation(format!("failed to read output: {e}")))?; + } + CollectedOutput::Numeric(data.into_iter().map(|v| v as f64).collect()) + }}; + } + Ok(match desc.data_type { + rustnn::DataType::Float32 => read_numeric!(f32), + rustnn::DataType::Float16 => { + let mut data = vec![0u16; count]; + if count != 0 { + context + .read_tensor(tensor, &mut data) + .map_err(|e| OnnxError::Validation(format!("failed to read output: {e}")))?; + } + CollectedOutput::Numeric( + data.into_iter() + .map(|v| f64::from(f16::from_bits(v).to_f32())) + .collect(), + ) + } + rustnn::DataType::Int8 => read_numeric!(i8), + rustnn::DataType::Int32 => read_numeric!(i32), + rustnn::DataType::Int64 => { + let mut data = vec![0i64; count]; + if count != 0 { + context + .read_tensor(tensor, &mut data) + .map_err(|e| OnnxError::Validation(format!("failed to read output: {e}")))?; + } + CollectedOutput::Int64(data) + } + rustnn::DataType::Uint8 => read_numeric!(u8), + rustnn::DataType::Uint32 => read_numeric!(u32), + rustnn::DataType::Uint64 => { + let mut data = vec![0u64; count]; + if count != 0 { + context + .read_tensor(tensor, &mut data) + .map_err(|e| OnnxError::Validation(format!("failed to read output: {e}")))?; + } + CollectedOutput::Uint64(data) + } + other => { + return Err(OnnxError::Validation(format!( + "unsupported output dtype {other:?}" + ))) + } + }) +} + +fn dispatch_and_collect( + validated: &mut ValidatedGraph, + model: &ModelProto, + inputs: &[OnnxInput], + pinned_inputs: &HashMap, +) -> Result, OnnxError> { + let graph_proto = graph(model)?; + let mut source_inputs = HashMap::new(); + for input in inputs + .iter() + .filter(|input| !pinned_inputs.contains_key(&input.name)) + { + let key = OnnxBuilder::webnn_id(&input.name); + if let Some(previous) = source_inputs.insert(key.clone(), input) { + return Err(OnnxError::Validation(format!( + "ONNX inputs {} and {} both map to cached input key {key}", + previous.name, input.name + ))); + } + } + + // Pinning a merged model's branch selector specializes the converted graph. Inputs used only + // by the other branch are deliberately absent from the cached interface, while native ORT + // still needs the complete source-model feed above. Drive WebNN from its actual interface and + // require every retained cached input to map back to the source model. + let mut input_storage = Vec::new(); + let mut input_keys = Vec::new(); + for (key, desc) in &validated.graph.input_descriptors { + let input = source_inputs.get(key).ok_or_else(|| { + OnnxError::Validation(format!( + "cached graph input {key} has no matching unpinned ONNX input" + )) + })?; + let tensor = validated + .context + .create_tensor(&tensor_descriptor(desc)) + .map_err(|e| OnnxError::Validation(format!("failed to create {key}: {e}")))?; + write_input(&mut validated.context, &tensor, input)?; + input_keys.push(key.clone()); + input_storage.push(tensor); + } + let input_bindings: BTreeMap<&str, &MLTensor> = input_keys + .iter() + .zip(input_storage.iter()) + .map(|(name, tensor)| (name.as_str(), tensor)) + .collect(); + let mut output_storage = Vec::new(); + let mut output_keys = Vec::new(); + let mut output_map = HashMap::new(); + let cached_input_names: HashSet = + validated.graph.input_descriptors.keys().cloned().collect(); + let mut source_outputs = HashMap::new(); + for output in &graph_proto.output { + let key = OnnxBuilder::output_key_for(&output.name, &cached_input_names); + if let Some(previous) = source_outputs.insert(key.clone(), output.name.as_str()) { + return Err(OnnxError::Validation(format!( + "ONNX outputs {previous} and {} both map to cached output key {key}", + output.name + ))); + } + } + for (key, desc) in &validated.graph.output_descriptors { + let onnx_name = source_outputs.get(key).ok_or_else(|| { + OnnxError::Validation(format!( + "cached graph output {key} has no matching ONNX output" + )) + })?; + let tensor = validated + .context + .create_tensor(&tensor_descriptor(desc)) + .map_err(|e| OnnxError::Validation(format!("failed to create {key}: {e}")))?; + output_keys.push(key.clone()); + output_storage.push(tensor); + output_map.insert((*onnx_name).to_string(), key.clone()); + } + let output_bindings: BTreeMap<&str, &MLTensor> = output_keys + .iter() + .zip(output_storage.iter()) + .map(|(name, tensor)| (name.as_str(), tensor)) + .collect(); + validated + .context + .dispatch(&mut validated.graph, &input_bindings, &output_bindings) + .map_err(|e| OnnxError::Validation(format!("cached graph dispatch failed: {e}")))?; + output_map + .into_iter() + .map(|(onnx_name, key)| { + let desc = validated + .graph + .output_descriptors + .get(&key) + .expect("validated above"); + let tensor = output_bindings.get(key.as_str()).expect("bound above"); + read_output(&mut validated.context, tensor, desc).map(|values| (onnx_name, values)) + }) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FloatComparisonProfile { + Standard, + MatMulNBits4, + MatMulNBits8, + UnsupportedMatMulNBitsAccuracy(i64), +} + +fn float_comparison_profile(model: &ModelProto) -> Result { + let mut profile = FloatComparisonProfile::Standard; + for node in graph(model)? + .node + .iter() + .filter(|node| node.op_type == "MatMulNBits") + { + let bits = node + .attribute + .iter() + .find(|attr| attr.name == "bits") + .map(|attr| attr.i) + .unwrap_or(4); + let accuracy_level = node + .attribute + .iter() + .find(|attr| attr.name == "accuracy_level") + .map(|attr| attr.i) + .unwrap_or(0); + if accuracy_level != 0 { + return Ok(FloatComparisonProfile::UnsupportedMatMulNBitsAccuracy( + accuracy_level, + )); + } + profile = match (profile, bits) { + (_, 8) => FloatComparisonProfile::MatMulNBits8, + (FloatComparisonProfile::Standard, 4) => FloatComparisonProfile::MatMulNBits4, + (current, 4) => current, + (current, _) => current, + }; + } + Ok(profile) +} + +fn float_tolerance(elem_type: Option, expected: f64, profile: FloatComparisonProfile) -> f64 { + match elem_type { + Some(x) if x == TensorProto_DataType::Float16 as i32 => 1e-3 + expected.abs() * 1e-2, + Some(x) + if x == TensorProto_DataType::Float as i32 + && profile == FloatComparisonProfile::MatMulNBits8 => + { + 2e-3 + expected.abs() * 2e-3 + } + Some(x) + if x == TensorProto_DataType::Float as i32 + && profile == FloatComparisonProfile::MatMulNBits4 => + { + 1e-3 + expected.abs() * 1e-3 + } + Some(x) if x == TensorProto_DataType::Float as i32 => 1e-5 + expected.abs() * 1e-4, + _ => 0.0, + } +} + +fn compare_outputs( + model: &ModelProto, + reference: &[rustnn::OnnxOutputWithData], + actual: &HashMap, +) -> Result<(), OnnxError> { + let outputs = &graph(model)?.output; + let comparison_profile = float_comparison_profile(model)?; + if let FloatComparisonProfile::UnsupportedMatMulNBitsAccuracy(level) = comparison_profile { + return Err(OnnxError::Validation(format!( + "MatMulNBits accuracy_level={level} is unsupported for numerical comparison" + ))); + } + if outputs.len() != reference.len() { + return Err(OnnxError::Validation( + "native ORT output count mismatch".to_string(), + )); + } + let mut reference_by_name = HashMap::new(); + for expected in reference { + if reference_by_name + .insert(expected.name.as_str(), expected) + .is_some() + { + return Err(OnnxError::Validation(format!( + "native ORT returned duplicate output {}", + expected.name + ))); + } + } + for output in outputs { + let expected = reference_by_name.get(output.name.as_str()).ok_or_else(|| { + OnnxError::Validation(format!( + "native ORT did not return declared output {}", + output.name + )) + })?; + let Some(got) = actual.get(&output.name) else { + if expected.data.is_empty() { + continue; + } + return Err(OnnxError::Validation(format!( + "cached graph omitted non-empty ONNX output {} ({} elements)", + output.name, + expected.data.len() + ))); + }; + let elem_type = output + .r#type + .as_ref() + .and_then(|ty| ty.value.as_ref()) + .and_then(|value| match value { + TypeProtoValue::TensorType(tensor) => Some(tensor.elem_type), + _ => None, + }); + if expected.data.len() != got.len() { + return Err(OnnxError::Validation(format!( + "{} length mismatch: ORT={}, WebNN={}", + output.name, + expected.data.len(), + got.len() + ))); + } + match got { + CollectedOutput::Int64(actual) => { + let expected = expected.int64_data.as_ref().ok_or_else(|| { + OnnxError::Validation(format!( + "native ORT did not return typed int64 data for {}", + output.name + )) + })?; + if let Some(index) = expected.iter().zip(actual).position(|(a, b)| a != b) { + return Err(OnnxError::Validation(format!( + "{}[{index}] mismatch: ORT={}, WebNN={}", + output.name, expected[index], actual[index] + ))); + } + } + CollectedOutput::Uint64(actual) => { + let expected = expected.uint64_data.as_ref().ok_or_else(|| { + OnnxError::Validation(format!( + "native ORT did not return typed uint64 data for {}", + output.name + )) + })?; + if let Some(index) = expected.iter().zip(actual).position(|(a, b)| a != b) { + return Err(OnnxError::Validation(format!( + "{}[{index}] mismatch: ORT={}, WebNN={}", + output.name, expected[index], actual[index] + ))); + } + } + CollectedOutput::Numeric(actual) => { + let mut first_mismatch = None; + let mut failure_count = 0usize; + let mut max_abs_error = 0.0f64; + let mut sum_abs_error = 0.0f64; + let mut sum_squared_error = 0.0f64; + let mut max_normalized_error = 0.0f64; + for (index, (expected, actual)) in expected.data.iter().zip(actual).enumerate() { + if expected.is_nan() && actual.is_nan() { + continue; + } + let tolerance = float_tolerance(elem_type, *expected, comparison_profile); + let abs_error = if expected == actual { + 0.0 + } else if !expected.is_finite() || !actual.is_finite() { + f64::INFINITY + } else { + (expected - actual).abs() + }; + max_abs_error = max_abs_error.max(abs_error); + sum_abs_error += abs_error; + sum_squared_error += abs_error * abs_error; + let normalized_error = if tolerance == 0.0 { + if abs_error == 0.0 { + 0.0 + } else { + f64::INFINITY + } + } else { + abs_error / tolerance + }; + max_normalized_error = max_normalized_error.max(normalized_error); + if abs_error > tolerance { + failure_count += 1; + first_mismatch.get_or_insert((index, *expected, *actual, tolerance)); + } + } + if let Some((index, expected, observed, tolerance)) = first_mismatch { + let count = actual.len().max(1) as f64; + return Err(OnnxError::Validation(format!( + "{}[{index}] mismatch: ORT={expected}, WebNN={observed}, tolerance={tolerance}; failures={failure_count}/{}, max_abs_error={max_abs_error}, mean_abs_error={}, rmse={}, max_normalized_error={max_normalized_error}", + output.name, + actual.len(), + sum_abs_error / count, + (sum_squared_error / count).sqrt(), + ))); + } + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn comparison_model(op_type: &str) -> ModelProto { + ModelProto { + graph: Some(crate::protos::onnx::GraphProto { + node: vec![crate::protos::onnx::NodeProto { + op_type: op_type.to_string(), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + } + } + + fn matmul_nbits_model(bits: i64, accuracy_level: i64) -> ModelProto { + let mut model = comparison_model("MatMulNBits"); + let node = &mut model.graph.as_mut().unwrap().node[0]; + node.attribute.push(crate::protos::onnx::AttributeProto { + name: "bits".to_string(), + i: bits, + ..Default::default() + }); + if accuracy_level != 0 { + node.attribute.push(crate::protos::onnx::AttributeProto { + name: "accuracy_level".to_string(), + i: accuracy_level, + ..Default::default() + }); + } + model + } + + #[test] + fn matmul_nbits_models_use_attribute_derived_tolerances() { + let q4 = matmul_nbits_model(4, 0); + let q8 = matmul_nbits_model(8, 0); + let unsupported = matmul_nbits_model(4, 4); + assert_eq!( + float_comparison_profile(&q4).unwrap(), + FloatComparisonProfile::MatMulNBits4 + ); + assert_eq!( + float_comparison_profile(&q8).unwrap(), + FloatComparisonProfile::MatMulNBits8 + ); + assert_eq!( + float_comparison_profile(&unsupported).unwrap(), + FloatComparisonProfile::UnsupportedMatMulNBitsAccuracy(4) + ); + + let expected = 0.009632587432861328f64; + let ordinary = float_tolerance( + Some(TensorProto_DataType::Float as i32), + expected, + FloatComparisonProfile::Standard, + ); + let q4_tolerance = float_tolerance( + Some(TensorProto_DataType::Float as i32), + expected, + FloatComparisonProfile::MatMulNBits4, + ); + let q8_tolerance = float_tolerance( + Some(TensorProto_DataType::Float as i32), + expected, + FloatComparisonProfile::MatMulNBits8, + ); + assert!(ordinary < q4_tolerance); + assert!(q4_tolerance < q8_tolerance); + + let qwen_expected = -0.12115895748138428f64; + let qwen_observed = -0.11994504928588867f64; + let qwen_difference = (qwen_expected - qwen_observed).abs(); + assert!( + qwen_difference + > float_tolerance( + Some(TensorProto_DataType::Float as i32), + qwen_expected, + FloatComparisonProfile::MatMulNBits4, + ) + ); + assert!( + qwen_difference + <= float_tolerance( + Some(TensorProto_DataType::Float as i32), + qwen_expected, + FloatComparisonProfile::MatMulNBits8, + ) + ); + assert_eq!( + float_comparison_profile(&comparison_model("MatMul")).unwrap(), + FloatComparisonProfile::Standard + ); + } + + #[test] + fn element_count_distinguishes_scalars_zero_dimensions_and_overflow() { + assert_eq!(checked_element_count("scalar", &[]).unwrap(), 1); + assert_eq!(checked_element_count("empty", &[1, 2, 0, 64]).unwrap(), 0); + assert_eq!( + checked_element_count("empty", &[usize::MAX, 2, 0]).unwrap(), + 0 + ); + let TensorData::Float32(empty) = + input_data(TensorProto_DataType::Float as i32, 0, "past_key", None).unwrap() + else { + panic!("expected float32 empty input"); + }; + assert!(empty.is_empty()); + assert!(checked_element_count("huge", &[usize::MAX, 2]).is_err()); + } + + #[test] + fn semantic_inputs_use_valid_deterministic_values() { + let TensorData::Int64(token_types) = input_data( + TensorProto_DataType::Int64 as i32, + 4, + "token_type_ids", + None, + ) + .unwrap() else { + panic!("expected int64 token types"); + }; + assert_eq!(token_types, vec![0; 4]); + + let TensorData::Int64(mask) = input_data( + TensorProto_DataType::Int64 as i32, + 65, + "attention_mask", + None, + ) + .unwrap() else { + panic!("expected int64 attention mask"); + }; + assert_eq!(mask, vec![1; 65]); + assert_eq!(mask.iter().sum::() - 1, 64); + } + + #[test] + fn generic_pattern_and_pinned_precedence_are_preserved() { + let TensorData::Int64(generic) = + input_data(TensorProto_DataType::Int64 as i32, 9, "input_ids", None).unwrap() + else { + panic!("expected int64 generic input"); + }; + assert_eq!(generic, vec![0, 1, 2, 3, 4, 5, 6, 0, 1]); + + let TensorData::Int64(pinned) = input_data( + TensorProto_DataType::Int64 as i32, + 3, + "attention_mask", + Some(0), + ) + .unwrap() else { + panic!("expected int64 pinned input"); + }; + assert_eq!(pinned, vec![0; 3]); + } +} diff --git a/tests/cache_validation.rs b/tests/cache_validation.rs new file mode 100644 index 0000000..09b5cdd --- /dev/null +++ b/tests/cache_validation.rs @@ -0,0 +1,795 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +//! End-to-end cache export, reload, dispatch, and native ORT comparison. + +use std::fs; + +use onnx2webnn::onnx::test_models::prelude::*; +use onnx2webnn::{ + cache_onnx_model, convert_onnx, validate_cached_model, validate_cached_model_with_options, + ConvertOptions, +}; +use prost::Message; + +#[test] +fn saved_add_graph_reloads_and_matches_native_ort() { + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("source.onnx"); + let cached_onnx = dir.path().join("cached.onnx"); + let cached_webnn = dir.path().join("cached.webnn"); + let model = model( + 17, + graph( + "add", + vec![f32_input("x", &[1, 2])], + vec![f32_output("y", &[1, 2])], + vec![node("Add", "add", &["x", "w"], &["y"], &[])], + vec![f32_init("w", &[1, 2], &[0.5, 1.0])], + ), + ); + fs::write(&source, model.encode_to_vec()).expect("write source model"); + cache_onnx_model(&source, &cached_onnx, false).expect("cache ONNX"); + convert_onnx( + &cached_onnx, + ConvertOptions { + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert and export"); + assert!(cached_webnn.with_extension("safetensors").exists()); + assert_eq!( + validate_cached_model(&cached_onnx, &cached_webnn) + .expect("validate") + .output_count, + 1 + ); +} + +#[test] +fn packed_uint4_matmul_round_trips_and_matches_native_ort() { + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("matmul-nbits.onnx"); + let cached_webnn = dir.path().join("matmul-nbits.webnn"); + let (k, n, block_size) = (32i64, 2i64, 32i64); + let packed_weights = (0..n * block_size / 2) + .map(|index| ((index * 13 + 7) % 256) as u8) + .collect::>(); + let mut matmul = node( + "MatMulNBits", + "matmul_nbits", + &["A", "B", "scales"], + &["Y"], + &[ + attr_int("K", k), + attr_int("N", n), + attr_int("bits", 4), + attr_int("block_size", block_size), + ], + ); + matmul.domain = "com.microsoft".to_string(); + let mut fixture = model( + 17, + graph( + "packed_uint4_matmul", + vec![f32_input("A", &[1, k])], + vec![f32_output("Y", &[1, n])], + vec![matmul], + vec![ + u8_init("B", &[n, 1, block_size / 2], &packed_weights), + f32_init("scales", &[n], &[0.05, 0.08]), + ], + ), + ); + fixture + .opset_import + .push(onnx2webnn::protos::onnx::OperatorSetIdProto { + domain: "com.microsoft".to_string(), + version: 1, + }); + fs::write(&source, fixture.encode_to_vec()).expect("write packed fixture"); + + convert_onnx( + &source, + ConvertOptions { + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert and export packed fixture"); + + assert!(cached_webnn.with_extension("safetensors").exists()); + + let summary = validate_cached_model(&source, &cached_webnn).expect("validate packed fixture"); + assert_eq!(summary.input_count, 1); + assert_eq!(summary.output_count, 1); +} + +fn integer_dtype_round_trip(label: &str, elem_type: i32) -> Result<(), String> { + let dir = tempfile::tempdir().map_err(|error| format!("temporary cache: {error}"))?; + let source = dir.path().join(format!("{label}.onnx")); + let cached_webnn = dir.path().join(format!("{label}.webnn")); + let model = model( + 17, + graph( + label, + vec![tensor_input("x", elem_type, &[4])], + vec![tensor_output("y", elem_type, &[4])], + vec![node("Identity", "identity", &["x"], &["y"], &[])], + vec![], + ), + ); + fs::write(&source, model.encode_to_vec()) + .map_err(|error| format!("write {label} dtype model: {error}"))?; + convert_onnx( + &source, + ConvertOptions { + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .map_err(|error| format!("convert {label}: {error}"))?; + let summary = validate_cached_model(&source, &cached_webnn) + .map_err(|error| format!("validate {label}: {error}"))?; + if summary.input_count != 1 || summary.output_count != 1 { + return Err(format!( + "validate {label}: expected one input and output, got {} and {}", + summary.input_count, summary.output_count + )); + } + Ok(()) +} + +fn expect_integer_dtype_round_trip(label: &str, elem_type: i32) { + integer_dtype_round_trip(label, elem_type) + .unwrap_or_else(|error| panic!("{label} should round-trip exactly: {error}")); +} + +// WebNN permits all operand data types for Identity, but only requires float32, +// float16, and int32. CoreML therefore legitimately omits optional int8 graph +// boundary support. Keep executing it as a strict XFAIL instead of hiding it: +// a different error is a regression, and unexpected success is an XPASS. +// https://github.com/webmachinelearning/webnn/blob/main/index.bs#tensor-limits-elementwise-unary-identity +#[cfg(all(target_os = "macos", feature = "coreml"))] +fn expect_coreml_identity_boundary_xfail(label: &str, elem_type: i32) { + let error = match integer_dtype_round_trip(label, elem_type) { + Ok(()) => panic!("XPASS: CoreML now supports {label} Identity graph I/O"), + Err(error) => error, + }; + let normalized = error.to_ascii_lowercase(); + assert!( + normalized.contains("coreml") && normalized.contains("identity"), + "unexpected failure for CoreML {label} Identity graph I/O: {error}" + ); + eprintln!("XFAIL: CoreML {label} Identity graph I/O: {error}"); +} + +#[cfg(not(all(target_os = "macos", feature = "coreml")))] +#[test] +fn accepted_int8_dtype_round_trips_exactly() { + expect_integer_dtype_round_trip( + "int8", + onnx2webnn::protos::onnx::TensorProto_DataType::Int8 as i32, + ); +} + +#[test] +fn accepted_uint32_dtype_round_trips_exactly() { + expect_integer_dtype_round_trip( + "uint32", + onnx2webnn::protos::onnx::TensorProto_DataType::Uint32 as i32, + ); +} + +#[test] +fn accepted_uint64_dtype_round_trips_exactly() { + expect_integer_dtype_round_trip( + "uint64", + onnx2webnn::protos::onnx::TensorProto_DataType::Uint64 as i32, + ); +} + +#[cfg(all(target_os = "macos", feature = "coreml"))] +#[test] +fn coreml_xfail_int8_identity_boundary() { + expect_coreml_identity_boundary_xfail( + "int8", + onnx2webnn::protos::onnx::TensorProto_DataType::Int8 as i32, + ); +} + +#[test] +fn pinned_input_is_used_by_native_ort_but_not_dispatched() { + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("pinned.onnx"); + let cached_webnn = dir.path().join("pinned.webnn"); + let model = model( + 17, + graph( + "pinned", + vec![f32_input("x", &[2]), f32_input("scale", &[])], + vec![f32_output("y", &[2])], + vec![node("Mul", "mul", &["x", "scale"], &["y"], &[])], + vec![], + ), + ); + fs::write(&source, model.encode_to_vec()).expect("write pinned model"); + let pins = std::collections::HashMap::from([("scale".to_string(), 2)]); + convert_onnx( + &source, + ConvertOptions { + pinned_inputs: pins.clone(), + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert pinned model"); + let summary = validate_cached_model_with_options( + &source, + &cached_webnn, + &std::collections::HashMap::new(), + &pins, + ) + .expect("validate pinned model"); + assert_eq!(summary.input_count, 1); + assert_eq!(summary.pinned_input_count, 1); + assert_eq!(summary.output_count, 1); +} + +#[test] +fn pinned_input_name_reused_as_output_uses_the_converted_output_key() { + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("pinned-output-collision.onnx"); + let cached_webnn = dir.path().join("pinned-output-collision.webnn"); + let model = model( + 17, + graph( + "pinned-output-collision", + vec![f32_input("x", &[2]), f32_input("scale", &[])], + vec![f32_output("y", &[2]), f32_output("scale", &[])], + vec![node("Identity", "identity", &["x"], &["y"], &[])], + vec![], + ), + ); + fs::write(&source, model.encode_to_vec()).expect("write pinned-output collision model"); + let pins = std::collections::HashMap::from([("scale".to_string(), 2)]); + convert_onnx( + &source, + ConvertOptions { + pinned_inputs: pins.clone(), + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert pinned-output collision model"); + + let summary = validate_cached_model_with_options( + &source, + &cached_webnn, + &std::collections::HashMap::new(), + &pins, + ) + .expect("validate output whose name matches a pinned input"); + assert_eq!(summary.input_count, 1); + assert_eq!(summary.pinned_input_count, 1); + assert_eq!(summary.output_count, 2); +} + +#[test] +fn pinned_if_branches_validate_with_their_specialized_input_interfaces() { + use onnx2webnn::protos::onnx::{AttributeProto, GraphProto}; + + let branch = |name: &str, input: &str| GraphProto { + name: name.to_string(), + node: vec![node( + "Identity", + &format!("{name}_identity"), + &[input], + &["branch_output"], + &[], + )], + output: vec![f32_output("branch_output", &[2])], + ..Default::default() + }; + let mut if_node = node("If", "gate", &["use_cache_branch"], &["y"], &[]); + if_node.attribute = vec![ + AttributeProto { + name: "then_branch".to_string(), + r#type: 5, + g: Some(branch("then", "then_input")), + ..Default::default() + }, + AttributeProto { + name: "else_branch".to_string(), + r#type: 5, + g: Some(branch("else", "else_input")), + ..Default::default() + }, + ]; + let model = model( + 17, + graph( + "merged", + vec![ + f32_input("then_input", &[2]), + f32_input("else_input", &[2]), + bool_input("use_cache_branch", &[1]), + ], + vec![f32_output("y", &[2])], + vec![if_node], + vec![], + ), + ); + + for branch_value in [0, 1] { + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("merged.onnx"); + let cached_webnn = dir.path().join("merged.webnn"); + fs::write(&source, model.encode_to_vec()).expect("write merged model"); + let pins = + std::collections::HashMap::from([("use_cache_branch".to_string(), branch_value)]); + convert_onnx( + &source, + ConvertOptions { + pinned_inputs: pins.clone(), + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert selected branch"); + + let summary = validate_cached_model_with_options( + &source, + &cached_webnn, + &std::collections::HashMap::new(), + &pins, + ) + .expect("validate selected branch"); + assert_eq!(summary.input_count, 1); + assert_eq!(summary.output_count, 1); + } +} + +#[test] +fn specialized_cache_accepts_pruned_inputs_and_empty_outputs() { + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("specialized.onnx"); + let cached_webnn = dir.path().join("specialized.webnn"); + let model = model( + 17, + graph( + "specialized", + vec![ + f32_input("x", &[2]), + f32_input("dead_branch_input", &[2]), + bool_input("use_cache_branch", &[]), + ], + vec![f32_output("y", &[2]), f32_output("empty_cache", &[0])], + vec![node("Identity", "identity", &["x"], &["y"], &[])], + vec![f32_init("empty_cache", &[0], &[])], + ), + ); + fs::write(&source, model.encode_to_vec()).expect("write specialized model"); + let pins = std::collections::HashMap::from([("use_cache_branch".to_string(), 0)]); + convert_onnx( + &source, + ConvertOptions { + pinned_inputs: pins.clone(), + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert specialized model"); + + let summary = validate_cached_model_with_options( + &source, + &cached_webnn, + &std::collections::HashMap::new(), + &pins, + ) + .expect("validate specialized interface"); + assert_eq!(summary.input_count, 1); + assert_eq!(summary.output_count, 2); +} + +#[test] +fn specialized_cache_rejects_an_omitted_nonempty_output() { + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("source.onnx"); + let cache_source = dir.path().join("cache-source.onnx"); + let cached_webnn = dir.path().join("cached.webnn"); + let source_model = model( + 17, + graph( + "source", + vec![f32_input("x", &[2])], + vec![f32_output("y", &[2]), f32_output("z", &[2])], + vec![ + node("Identity", "y", &["x"], &["y"], &[]), + node("Identity", "z", &["x"], &["z"], &[]), + ], + vec![], + ), + ); + let cache_model = model( + 17, + graph( + "cache", + vec![f32_input("x", &[2])], + vec![f32_output("y", &[2])], + vec![node("Identity", "y", &["x"], &["y"], &[])], + vec![], + ), + ); + fs::write(&source, source_model.encode_to_vec()).expect("write source model"); + fs::write(&cache_source, cache_model.encode_to_vec()).expect("write cache source"); + convert_onnx( + &cache_source, + ConvertOptions { + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert cache source"); + + let error = validate_cached_model(&source, &cached_webnn) + .expect_err("non-empty source output must not be silently omitted"); + assert!(error + .to_string() + .contains("cached graph omitted non-empty ONNX output z")); +} + +#[test] +fn specialized_cache_rejects_extra_inputs_and_outputs() { + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("source.onnx"); + let extra_input_source = dir.path().join("extra-input.onnx"); + let extra_output_source = dir.path().join("extra-output.onnx"); + let extra_input_webnn = dir.path().join("extra-input.webnn"); + let extra_output_webnn = dir.path().join("extra-output.webnn"); + let source_model = model( + 17, + graph( + "source", + vec![f32_input("x", &[2])], + vec![f32_output("y", &[2])], + vec![node("Identity", "y", &["x"], &["y"], &[])], + vec![], + ), + ); + let extra_input_model = model( + 17, + graph( + "extra-input", + vec![f32_input("x", &[2]), f32_input("extra", &[2])], + vec![f32_output("y", &[2])], + vec![node("Add", "y", &["x", "extra"], &["y"], &[])], + vec![], + ), + ); + let extra_output_model = model( + 17, + graph( + "extra-output", + vec![f32_input("x", &[2])], + vec![f32_output("y", &[2]), f32_output("extra", &[2])], + vec![ + node("Identity", "y", &["x"], &["y"], &[]), + node("Identity", "extra", &["x"], &["extra"], &[]), + ], + vec![], + ), + ); + fs::write(&source, source_model.encode_to_vec()).expect("write source model"); + fs::write(&extra_input_source, extra_input_model.encode_to_vec()) + .expect("write extra-input model"); + fs::write(&extra_output_source, extra_output_model.encode_to_vec()) + .expect("write extra-output model"); + + convert_onnx( + &extra_input_source, + ConvertOptions { + output_path: Some(extra_input_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert extra-input model"); + let error = validate_cached_model(&source, &extra_input_webnn) + .expect_err("cached-only input must be rejected"); + assert!(error + .to_string() + .contains("cached graph input extra has no matching unpinned ONNX input")); + + convert_onnx( + &extra_output_source, + ConvertOptions { + output_path: Some(extra_output_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert extra-output model"); + let error = validate_cached_model(&source, &extra_output_webnn) + .expect_err("cached-only output must be rejected"); + assert!(error + .to_string() + .contains("cached graph output extra has no matching ONNX output")); +} + +#[test] +fn sanitized_source_input_collisions_are_rejected() { + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("source.onnx"); + let cache_source = dir.path().join("cache-source.onnx"); + let cached_webnn = dir.path().join("cached.webnn"); + let source_model = model( + 17, + graph( + "source", + vec![f32_input("a/b", &[2]), f32_input("a_b", &[2])], + vec![f32_output("y", &[2])], + vec![node("Identity", "y", &["a/b"], &["y"], &[])], + vec![], + ), + ); + let cache_model = model( + 17, + graph( + "cache", + vec![f32_input("a_b", &[2])], + vec![f32_output("y", &[2])], + vec![node("Identity", "y", &["a_b"], &["y"], &[])], + vec![], + ), + ); + fs::write(&source, source_model.encode_to_vec()).expect("write source model"); + fs::write(&cache_source, cache_model.encode_to_vec()).expect("write cache source"); + convert_onnx( + &cache_source, + ConvertOptions { + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert cache source"); + + let error = validate_cached_model(&source, &cached_webnn) + .expect_err("ambiguous source input mapping must be rejected"); + assert!(error + .to_string() + .contains("both map to cached input key a_b")); +} + +#[test] +fn external_data_model_round_trips_without_embedding_weights() { + use onnx2webnn::protos::onnx::StringStringEntryProto; + + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("external.onnx"); + let weights = dir.path().join("weights.bin"); + let cached_webnn = dir.path().join("external.webnn"); + let mut model = model( + 17, + graph( + "external", + vec![f32_input("x", &[2])], + vec![f32_output("y", &[2])], + vec![node("Add", "add", &["x", "w"], &["y"], &[])], + vec![f32_init("w", &[2], &[0.5, 1.0])], + ), + ); + let weight = &mut model.graph.as_mut().unwrap().initializer[0]; + weight.float_data.clear(); + weight.data_location = 1; + weight.external_data = vec![ + StringStringEntryProto { + key: "location".into(), + value: "weights.bin".into(), + }, + StringStringEntryProto { + key: "offset".into(), + value: "0".into(), + }, + StringStringEntryProto { + key: "length".into(), + value: "8".into(), + }, + ]; + fs::write(&source, model.encode_to_vec()).expect("write external-data model"); + fs::write( + &weights, + [0.5f32, 1.0f32] + .into_iter() + .flat_map(f32::to_le_bytes) + .collect::>(), + ) + .expect("write external weights"); + + convert_onnx( + &source, + ConvertOptions { + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert external-data model"); + validate_cached_model(&source, &cached_webnn) + .expect("path-based native ORT resolves external weights"); +} + +fn validate_semantic_index_input(input_name: &str, length: i64) { + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join(format!("{input_name}.onnx")); + let cached_webnn = dir.path().join(format!("{input_name}.webnn")); + let model = model( + 17, + graph( + input_name, + vec![i64_input(input_name, &[length])], + vec![f32_output("y", &[length, 1])], + vec![node( + "Gather", + "gather", + &["table", input_name], + &["y"], + &[], + )], + vec![f32_init("table", &[2, 1], &[0.25, 0.75])], + ), + ); + fs::write(&source, model.encode_to_vec()).expect("write semantic-input model"); + convert_onnx( + &source, + ConvertOptions { + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert semantic-input model"); + validate_cached_model(&source, &cached_webnn).expect("semantic input stays in range"); +} + +#[test] +fn token_type_ids_stay_within_a_two_row_embedding() { + validate_semantic_index_input("token_type_ids", 4); +} + +#[test] +fn attention_mask_is_binary_and_valid_for_indexing() { + validate_semantic_index_input("attention_mask", 65); +} + +#[test] +fn zero_element_input_round_trips_without_becoming_a_scalar() { + use onnx2webnn::protos::onnx::{ + tensor_shape_proto::dimension::Value as DimensionValue, type_proto::Value as TypeValue, + }; + + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("zero-element-input.onnx"); + let cached_webnn = dir.path().join("zero-element-input.webnn"); + let mut empty_cache = f32_input("past_key", &[1, 2, 1, 4]); + let Some(TypeValue::TensorType(tensor)) = + empty_cache.r#type.as_mut().and_then(|ty| ty.value.as_mut()) + else { + panic!("tensor input"); + }; + tensor.shape.as_mut().unwrap().dim[2].value = + Some(DimensionValue::DimParam("past_sequence_length".into())); + let model = model( + 17, + graph( + "zero-element-input", + vec![empty_cache, f32_input("x", &[1])], + vec![f32_output("y", &[1])], + vec![node("Identity", "identity", &["x"], &["y"], &[])], + vec![], + ), + ); + fs::write(&source, model.encode_to_vec()).expect("write zero-element-input model"); + let overrides = std::collections::HashMap::from([("past_sequence_length".to_string(), 0)]); + convert_onnx( + &source, + ConvertOptions { + free_dim_overrides: overrides.clone(), + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert zero-element-input model"); + let summary = validate_cached_model_with_options( + &source, + &cached_webnn, + &overrides, + &std::collections::HashMap::new(), + ) + .expect("zero-element input survives native and cached execution"); + assert_eq!(summary.input_count, 2); + assert_eq!(summary.output_count, 1); +} + +#[test] +fn numeric_cast_to_bool_normalizes_nonzero_values() { + use onnx2webnn::protos::onnx::TensorProto_DataType; + + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("cast-bool.onnx"); + let cached_webnn = dir.path().join("cast-bool.webnn"); + let fixture = model( + 17, + graph( + "cast-bool", + vec![], + vec![f32_output("y", &[5])], + vec![ + node( + "Cast", + "to_bool", + &["x"], + &["as_bool"], + &[attr_int("to", TensorProto_DataType::Bool as i64)], + ), + node( + "Cast", + "to_float", + &["as_bool"], + &["y"], + &[attr_int("to", TensorProto_DataType::Float as i64)], + ), + ], + vec![f32_init("x", &[5], &[0.0, 1.0, 2.0, -1.0, f32::NAN])], + ), + ); + fs::write(&source, fixture.encode_to_vec()).expect("write cast-bool fixture"); + convert_onnx( + &source, + ConvertOptions { + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert cast-bool fixture"); + validate_cached_model(&source, &cached_webnn).expect("validate cast-bool fixture"); +} + +#[test] +fn positive_step_slice_round_trips_with_extent_semantics() { + let dir = tempfile::tempdir().expect("temporary cache"); + let source = dir.path().join("strided-slice.onnx"); + let cached_webnn = dir.path().join("strided-slice.webnn"); + let fixture = model( + 17, + graph( + "strided-slice", + vec![f32_input("x", &[10])], + vec![f32_output("y", &[4])], + vec![node( + "Slice", + "slice", + &["x", "starts", "ends", "axes", "steps"], + &["y"], + &[], + )], + vec![ + i64_init("starts", &[1], &[1]), + i64_init("ends", &[1], &[9]), + i64_init("axes", &[1], &[0]), + i64_init("steps", &[1], &[2]), + ], + ), + ); + fs::write(&source, fixture.encode_to_vec()).expect("write strided-slice fixture"); + convert_onnx( + &source, + ConvertOptions { + output_path: Some(cached_webnn.clone()), + ..ConvertOptions::default() + }, + ) + .expect("convert strided-slice fixture"); + let serialized = fs::read_to_string(&cached_webnn).expect("read serialized graph"); + assert!(serialized.contains("sizes=[8]")); + assert!(serialized.contains("strides=[2]")); + validate_cached_model(&source, &cached_webnn).expect("validate strided-slice fixture"); +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 93d9588..4f8ef1e 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -5,8 +5,10 @@ //! Shared helpers for ONNX op conversion integration tests. -mod runner; -pub mod skeleton; +mod runner; + +#[allow(unused_imports)] +pub use onnx2webnn::model_validation::{full_model, manifest, skeleton}; // Not every test crate uses every helper. #[allow(unused_imports)] diff --git a/tests/model_skeletons.rs b/tests/model_skeletons.rs index 8852d09..484bba6 100644 --- a/tests/model_skeletons.rs +++ b/tests/model_skeletons.rs @@ -33,36 +33,11 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; +use common::manifest::{load_manifest, Entry}; use common::skeleton::{strip_model, FileSource, HubSource, KEEP_BYTES}; use onnx2webnn::protos::onnx::ModelProto; use onnx2webnn::{convert_model_proto, convert_onnx, ConvertOptions}; use prost::Message; -use serde::Deserialize; - -/// Large models recurse deeply in shape inference; the default 2 MB thread -/// stack is not enough. -const WORKER_STACK_BYTES: usize = 256 << 20; - -#[derive(Deserialize)] -struct Entry { - file: String, - #[serde(default)] - heavy: bool, - /// Reason this model cannot build on the CoreML backend (e.g. a hard - /// backend limit like max tensor rank 5). Skipped when built with - /// `--features coreml`; still exercised on the ORT backend. - #[serde(default)] - coreml_unsupported: Option, - /// Reason this model, while it does build on CoreML, takes so long there - /// that it would dominate the sweep (e.g. pathological Espresso compile - /// times). Skipped like `coreml_unsupported`. - #[serde(default)] - coreml_slow: Option, - #[serde(default)] - override_dims: HashMap, - #[serde(default)] - pin_inputs: HashMap, -} enum Source { Hub, @@ -107,23 +82,32 @@ struct Sweep { } impl Sweep { - fn skeleton(&self, file: &str) -> Skeleton { + fn skeleton(&self, entry: &Entry) -> Skeleton { + let file = entry.file.as_str(); let cell = self .skeletons .lock() .unwrap() - .entry(file.to_string()) + .entry(entry.source_key()) .or_default() .clone(); cell.get_or_init(|| { let started = std::time::Instant::now(); - let cached = matches!(self.source, Source::Hub).then(|| cache_dir().join(file)); + let cached = matches!(self.source, Source::Hub).then(|| { + entry + .revision + .as_deref() + .map(|revision| cache_dir().join("revisions").join(revision).join(file)) + .unwrap_or_else(|| cache_dir().join(file)) + }); if let Some(bytes) = cached.as_ref().and_then(|path| std::fs::read(path).ok()) { let note = format!("skeleton {:.2} MB (cached)", bytes.len() as f64 / 1e6); return Ok((Arc::new(bytes), note)); } let (bytes, stats) = match &self.source { - Source::Hub => strip_model(HubSource::open(file)?, KEEP_BYTES)?, + Source::Hub => { + strip_model(HubSource::open_revision(file, entry.revision())?, KEEP_BYTES)? + } Source::StripDir(dir) => strip_model(FileSource::open(&dir.join(file))?, KEEP_BYTES)?, Source::Dir(_) => unreachable!("full models are converted from disk"), }; @@ -152,31 +136,27 @@ impl Sweep { } fn convert(&self, idx: usize, entry: &Entry) { - let label = format!( - "#{idx} {} dims={:?} pins={:?}", - entry.file, entry.override_dims, entry.pin_inputs - ); + let label = entry.label(idx); let options = ConvertOptions { free_dim_overrides: entry.override_dims.clone(), optimize: true, experimental_dynamic_inputs: false, pinned_inputs: entry.pin_inputs.clone(), zero_fill_missing_external_data: true, + output_path: None, }; let started = std::time::Instant::now(); let result = match &self.source { Source::Dir(dir) => convert_onnx(dir.join(&entry.file), options) .map(|_| String::new()) .map_err(|e| e.to_string()), - Source::Hub | Source::StripDir(_) => { - self.skeleton(&entry.file).and_then(|(bytes, note)| { - let model = ModelProto::decode(&bytes[..]) - .map_err(|e| format!("decode skeleton: {e}"))?; - convert_model_proto(model, &options) - .map(|_| note) - .map_err(|e| e.to_string()) - }) - } + Source::Hub | Source::StripDir(_) => self.skeleton(entry).and_then(|(bytes, note)| { + let model = + ModelProto::decode(&bytes[..]).map_err(|e| format!("decode skeleton: {e}"))?; + convert_model_proto(model, &options) + .map(|_| note) + .map_err(|e| e.to_string()) + }), }; match result { Ok(note) => { @@ -205,17 +185,17 @@ impl Sweep { if matches!(self.source, Source::Dir(_)) { return; } - let mut files: Vec<&str> = entries.iter().map(|(_, e)| e.file.as_str()).collect(); - files.sort_unstable(); - files.dedup(); - let queue = Mutex::new(files); + let mut sources: Vec<&Entry> = entries.iter().map(|(_, entry)| *entry).collect(); + sources.sort_unstable_by_key(|entry| entry.source_key()); + sources.dedup_by(|left, right| left.source_key() == right.source_key()); + let queue = Mutex::new(sources); std::thread::scope(|scope| { for _ in 0..workers { scope.spawn(|| loop { - let Some(file) = queue.lock().unwrap().pop() else { + let Some(entry) = queue.lock().unwrap().pop() else { break; }; - let _ = self.skeleton(file); + let _ = self.skeleton(entry); }); } }); @@ -226,15 +206,12 @@ impl Sweep { let queue = Mutex::new(entries); std::thread::scope(|scope| { for _ in 0..workers { - std::thread::Builder::new() - .stack_size(WORKER_STACK_BYTES) - .spawn_scoped(scope, || loop { - let Some((idx, entry)) = queue.lock().unwrap().pop() else { - break; - }; - self.convert(idx, entry); - }) - .expect("spawn sweep worker"); + scope.spawn(|| loop { + let Some((idx, entry)) = queue.lock().unwrap().pop() else { + break; + }; + self.convert(idx, entry); + }); } }); } @@ -244,16 +221,7 @@ impl Sweep { fn manifest_models_convert_and_build() { // Backend build errors are only visible through `log` (RUST_LOG=error). let _ = pretty_env_logger::try_init(); - // O2W_MANIFEST points at another manifest (e.g. candidates under evaluation). - let manifest_path = std::env::var_os("O2W_MANIFEST") - .filter(|v| !v.is_empty()) - .map(PathBuf::from) - .unwrap_or_else(|| { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/models/manifest.json") - }); - let manifest = std::fs::read_to_string(&manifest_path) - .unwrap_or_else(|e| panic!("read {}: {e}", manifest_path.display())); - let entries: Vec = serde_json::from_str(&manifest).expect("parse manifest"); + let entries = load_manifest().unwrap_or_else(|error| panic!("{error}")); let Some(source) = source() else { eprintln!("skipping model sweep: set O2W_MODELS=hub, dir= or strip="); return; diff --git a/tests/models/ci-validation.json b/tests/models/ci-validation.json new file mode 100644 index 0000000..3642528 --- /dev/null +++ b/tests/models/ci-validation.json @@ -0,0 +1,12 @@ +[ + { + "file": "Xenova--tiny-random-RoFormerForMultipleChoice/onnx/model.onnx", + "revision": "3db6c9837f894f35ff0c26afe547bcbf24c97de0", + "sha256": "62128b34993971fcb7ded053c9af6932f2490724247315a112317fe398c97572", + "override_dims": { + "batch_size": 1, + "num_choices": 2, + "sequence_length": 16 + } + } +] diff --git a/tests/models/manifest.json b/tests/models/manifest.json index e73ae2a..64a31dd 100644 --- a/tests/models/manifest.json +++ b/tests/models/manifest.json @@ -27,7 +27,7 @@ {"file": "Xenova--musicgen-small/onnx/decoder_model_merged_quantized.onnx", "pin_inputs": {"use_cache_branch": 1}, "override_dims": {"batch_size": 1, "encoder_sequence_length": 16, "total_batch_size_x_num_codebooks": 8, "decoder_sequence_length": 1, "total_batch_size": 2, "past_decoder_sequence_length": 8, "encoder_sequence_length_out": 16}}, {"file": "Mozilla--distilvit/onnx/encoder_model_quantized.onnx", "override_dims": {"batch_size": 1, "num_channels": 3, "height": 224, "width": 224}}, {"file": "onnx-community--Voxtral-Mini-3B-2507-ONNX/onnx/embed_tokens_fp16.onnx", "override_dims": {"batch_size": 1, "sequence_length": 16}}, - {"file": "onnx-community--Voxtral-Mini-3B-2507-ONNX/onnx/decoder_model_merged_q4.onnx", "heavy": true, "override_dims": {"batch_size": 1, "sequence_length": 16, "total_sequence_length": 16, "past_sequence_length": 0}}, + {"file": "onnx-community--Voxtral-Mini-3B-2507-ONNX/onnx/decoder_model_merged_q4.onnx", "heavy": true, "override_dims": {"batch_size": 1, "sequence_length": 16, "total_sequence_length": 16, "past_sequence_length": 0}, "coreml_unsupported": "CoreML MLProgram compilation overflows a native worker thread stack (SIGABRT)"}, {"file": "onnx-community--Voxtral-Mini-3B-2507-ONNX/onnx/audio_encoder_quantized.onnx", "override_dims": {"batch_size": 1}}, {"file": "Xenova--LaMini-Flan-T5-783M/onnx/encoder_model_quantized.onnx", "override_dims": {"batch_size": 1, "encoder_sequence_length": 128}}, {"file": "Xenova--LaMini-Flan-T5-783M/onnx/decoder_model_merged_quantized.onnx", "pin_inputs": {"use_cache_branch": 0}, "override_dims": {"batch_size": 1, "encoder_sequence_length": 128, "decoder_sequence_length": 16, "past_decoder_sequence_length": 0, "encoder_sequence_length_out": 128}}, diff --git a/tests/scalar_io.rs b/tests/scalar_io.rs new file mode 100644 index 0000000..f322746 --- /dev/null +++ b/tests/scalar_io.rs @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +//! Regression coverage for rank-0 ONNX graph inputs and outputs. + +#[allow(dead_code)] +mod common; + +use common::{assert_op_matches_ort, ExpectConvertOp}; +use onnx2webnn::test_models::prelude::*; + +#[test] +fn scalar_input_and_output_match_ort() { + let model = model( + 17, + graph( + "scalar_identity", + vec![f32_input("input", &[])], + vec![f32_output("output", &[])], + vec![node( + "Identity", + "scalar_identity", + &["input"], + &["output"], + &[], + )], + vec![], + ), + ); + + assert_op_matches_ort(model, ExpectConvertOp::Success, 17); +}